From 3c95dcaf88c4a0eacc747b48678e1f5c225f12ec Mon Sep 17 00:00:00 2001 From: codex-scheduled Date: Wed, 12 Aug 2026 11:07:32 -0700 Subject: [PATCH] feat(rate): add Morris authority bridge --- AGENT_HANDOFF.md | 24 ++ SPEC.md | 5 +- .../RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md | 17 + src/rate_of_closure/AGENT_HANDOFF.md | 18 + .../application/morris/__init__.py | 22 ++ .../application/morris/contracts.py | 369 ++++++++++++++++++ .../application/morris/router.py | 320 +++++++++++++++ .../application/morris/service.py | 82 ++++ .../web/src/model/morrisAuthorityClient.ts | 69 ++++ .../src/model/morrisAuthorityContract.test.ts | 66 ++++ .../web/src/model/morrisAuthorityContract.ts | 109 ++++++ .../test_morris_authority_contracts.py | 207 ++++++++++ .../test_morris_authority_router.py | 158 ++++++++ .../test_morris_authority_service.py | 48 +++ 14 files changed, 1512 insertions(+), 2 deletions(-) create mode 100644 src/rate_of_closure/application/morris/__init__.py create mode 100644 src/rate_of_closure/application/morris/contracts.py create mode 100644 src/rate_of_closure/application/morris/router.py create mode 100644 src/rate_of_closure/application/morris/service.py create mode 100644 src/rate_of_closure/web/src/model/morrisAuthorityClient.ts create mode 100644 src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts create mode 100644 src/rate_of_closure/web/src/model/morrisAuthorityContract.ts create mode 100644 tests/rate_of_closure/test_morris_authority_contracts.py create mode 100644 tests/rate_of_closure/test_morris_authority_router.py create mode 100644 tests/rate_of_closure/test_morris_authority_service.py diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index 25fc282fa..a4e95e95a 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -3,6 +3,30 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Rate Morris authority bridge (#4142 R13.5) + +Exact request/job v1 contracts and `RateMorrisService` now bridge the current +ten-factor Rate adapter to the unchanged Morris report v1. Reconstruction pins +passive, unlocked, profile-free double-pendulum fixed-ball execution with no +prescribed impact time and zero time offset. The internal 113 mph scenario +speed is compatibility-only, not a new measured input or physics claim. +Wire validation is unconditional: factor ordering, pendulum/club/ball physical +domains, contextual tee factors, and both factor endpoints are checked before +shared DbC-backed constructors. WARN/OFF contract modes remain fail-closed. + +The optional router strictly decodes bounded raw JSON, and its injected-clock +registry owns active/global worker budgets, TTL/retention, cancellation, and a +lock-linearized lifecycle. Cancellation registered before terminal completion +discards the report; running work stays running until acknowledgment. Expected +sample numerical failures remain completed report denominator data, while +programming failures yield only a sanitized stable job error. The TypeScript +model uses the existing report parser and an injected create/status/cancel +client with no browser physics fallback. + +Open: UI/polling presentation, export, persistence, host mount, UpstreamDrift, +and a genuine fixed-ball double-pendulum hit. Cancellation latency depends on +executor observation; no partial report or per-sample diagnostic is exposed. + ## 2026-08-12 Rate fixed-ball Morris evaluator (#4142 R13.3) - Branch `codex/4142-morris-rate-adapter` starts at exact shared-executor parent diff --git a/SPEC.md b/SPEC.md index 47e0bcff4..c1d090cbd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -26,8 +26,8 @@ | **Owner** | D-sorganization | | **Primary Language(s)** | Python 3.11+, Rust, JavaScript, TypeScript | | **License** | MIT | -| **Current Version** | 1.16.27 | -| **Spec Version** | 1.16.27 | +| **Current Version** | 1.16.28 | +| **Spec Version** | 1.16.28 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission @@ -2922,6 +2922,7 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 2026-08-12 | 1.16.28 | feat(rate-of-closure, #4142 R13.5): add exact primitive-only Morris request/job v1 contracts, deterministic execution into unchanged report v1, a dependency-injected mountable FastAPI router with strict bounded raw JSON and lock-linearized ephemeral jobs, and a strict TypeScript parser plus injected transport. Retain presentation, export, persistence, host registration, UpstreamDrift consumption, and a genuine fixed-ball double-pendulum hit as open gates. | | 2026-08-12 | 1.16.27 | feat(rate-of-closure, #4142 R13.3): add the bounded Rate fixed-ball Morris evaluator for ten exact global simulation variables and the current 17-scalar output contract; extract shared trial capture/projection so ensemble and Morris execution retain identical hit/miss/numerical-failure availability, apply samples through one public immutable config seam, reject fixed-contact timing no-ops/localized or invalid factors, and validate a genuine double-pendulum miss while retaining double-pendulum fixed-hit validation, UI/export, per-sample failure diagnostics, and UpstreamDrift consumption as open gates. | | 2026-08-12 | 1.16.26 | feat(rate-of-closure, #4142 R13.3): add a bounded UI-neutral Morris execution adapter with immutable physical sample identity, injected typed evaluators that explicitly normalize their own domain failures, exact per-output availability, deterministic serial/parallel tensors and completed-prefix progress every eight samples plus final, cooperative no-partial-result cancellation, and named worker/sample/observation-cell resource limits; retain Rate, UI, export, and `evaluate_run` integration as later scope. | | 2026-08-12 | 1.16.25 | fix(rate-of-closure, #4142 R13.4): mirror the Morris producer's serialized clamp exactly by requiring `sigma` and `mu*` standard error to be either zero or strictly above `64*epsilon*max(1,mu*)`; apply clamp uncertainty only to zero-valued squared terms, use scale-normalized identity arithmetic with ordinary floating tolerance for nonzero metrics, reject finite magnitudes that cannot be squared safely, and move cohesive metric validation to a dedicated bounded module. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index f7244effd..67f176d2b 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,22 @@ # Rate of Closure Campaign Handoff +## 2026-08-12 #4142 R13.5 bounded authority bridge + +- Added exact request/job v1 contracts, deterministic Rate execution, bounded + lock-linearized ephemeral jobs, and a mountable optional FastAPI router. +- Strict raw JSON and domain parsing reject media/size/UTF-8/duplicate-key/ + non-finite/schema/resource/unit/factor/tee violations. +- Physical/config/factor invariants are mirrored before shared DbC-backed + constructors; WARN and OFF contract modes remain fail-closed. +- Added a strict TypeScript envelope parser and injected client using the + existing report parser, with no local physics fallback. +- The authority remains passive unlocked fixed-ball double pendulum. The + contact model remains a fixed point/sphere, not swept collision, compression, + or mesh contact. Cancellation waits for executor observation and no partial + report or per-sample diagnostic crosses the job envelope. +- Open: presentation, export, persistence, host mount, UpstreamDrift, and a + genuine fixed-ball double-pendulum hit. + ## 2026-08-12 Rate fixed-ball Morris evaluator (#4142 R13.3) - Exact shared-executor parent `b2fa365087f184d9ada16a6d35b08cbce64879c6` diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 0346c6431..3b331cd0f 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -3,6 +3,24 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Morris authority bridge (#4142 R13.5) + +`application/morris/` now provides strict primitive request/job v1 contracts, +deterministic `RateMorrisService`, and an optional mountable FastAPI router; +the core package does not eagerly import FastAPI. Only completed jobs carry +the unchanged shared report v1. Raw JSON rejects media, size, UTF-8, duplicate +key, non-finite, schema, resource, unit, duplicate-factor, and tee violations. +Primitive/base/factor physical rules and both endpoints are checked before +require-backed constructors, so WARN/OFF shared DbC modes cannot admit invalid +authority requests. + +Authority reconstruction is passive/unlocked/profile-free double pendulum, +fixed-ball, no impact time, zero offset; 113 mph is an internal compatibility +seed. The TypeScript parser/client has injected transport and no local fallback. +Still open: presentation, export, persistence, host registration, UpstreamDrift, +and a genuine fixed-ball double-pendulum hit. Cancellation is cooperative and +per-sample failure detail remains reduced to established report denominators. + ## 2026-08-12 Rate fixed-ball Morris execution (#4142 R13.3) The shared Morris executor now has a Rate-owned injected adapter over exact diff --git a/src/rate_of_closure/application/morris/__init__.py b/src/rate_of_closure/application/morris/__init__.py new file mode 100644 index 000000000..09cb75c1e --- /dev/null +++ b/src/rate_of_closure/application/morris/__init__.py @@ -0,0 +1,22 @@ +"""UI-neutral Morris authority contracts and execution service.""" + +from .contracts import ( + MORRIS_AUTHORITY_SCHEMA_VERSION, + MORRIS_JOB_SCHEMA_ID, + MORRIS_REQUEST_SCHEMA_ID, + MorrisAuthorityRequest, + MorrisJobEnvelope, + parse_morris_request, +) +from .service import MorrisExecutionService, RateMorrisService + +__all__ = [ + "MORRIS_AUTHORITY_SCHEMA_VERSION", + "MORRIS_JOB_SCHEMA_ID", + "MORRIS_REQUEST_SCHEMA_ID", + "MorrisAuthorityRequest", + "MorrisExecutionService", + "MorrisJobEnvelope", + "RateMorrisService", + "parse_morris_request", +] diff --git a/src/rate_of_closure/application/morris/contracts.py b/src/rate_of_closure/application/morris/contracts.py new file mode 100644 index 000000000..65d6ff27a --- /dev/null +++ b/src/rate_of_closure/application/morris/contracts.py @@ -0,0 +1,369 @@ +"""Strict versioned wire contracts for the Rate Morris authority.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Literal + +from rate_of_closure.application._workspace_validation import exact_mapping, stable_id +from rate_of_closure.club import CLUB_LIBRARY +from rate_of_closure.club.types import SPEC_BOUNDS +from rate_of_closure.model import ImpactScenario +from rate_of_closure.simulation import ( + BallSetup, + BallSupportMode, + ContactMode, + SimulationConfig, +) +from rate_of_closure.variation.morris_rate_adapter import RATE_MORRIS_VARIABLE_KEYS +from rate_of_closure.variation.request_builder import apply_global_simulation_values +from shared.python.swing_sim.flight.registry import FlightModelType +from shared.python.swing_sim.types import PendulumParameters, PlaneOrientation +from shared.python.swing_sim.variation import ( + CATEGORY_DELIVERY, + MAX_MORRIS_OBSERVATION_CELLS, + MAX_MORRIS_SAMPLES, + MAX_MORRIS_WORKERS, + MorrisDesign, + MorrisFactor, + generate_morris_design, + variable_registry, +) + +MORRIS_REQUEST_SCHEMA_ID = "rate-of-closure/morris-request" +MORRIS_JOB_SCHEMA_ID = "rate-of-closure/morris-job" +MORRIS_AUTHORITY_SCHEMA_VERSION = 1 +JobStatus = Literal["queued", "running", "completed", "cancelled", "failed"] + +_REQUEST_FIELDS = frozenset( + { + "schema_id", + "schema_version", + "request_id", + "base", + "factors", + "trajectories", + "levels", + "seed", + "minimum_effects", + "worker_count", + } +) +_BASE_FIELDS = frozenset( + { + "club_name", + "support_mode", + "tee_height_m", + "plane_yaw_deg", + "plane_side_tilt_deg", + "plane_forward_tilt_deg", + "pendulum_m1_kg", + "pendulum_l1_m", + "pendulum_lc1_m", + "pendulum_i1_kg_m2", + "pendulum_m2_kg", + "pendulum_l2_m", + "pendulum_lc2_m", + "pendulum_i2_kg_m2", + "damping_shoulder", + "damping_wrist", + "swing_duration_s", + "flight_model", + "impact_offset_toe_mm", + "impact_offset_high_mm", + } +) +_FACTOR_FIELDS = frozenset({"spec_id", "variable_key", "lower", "upper", "unit"}) +_PENDULUM_POSITIVE = ( + "pendulum_m1_kg", + "pendulum_l1_m", + "pendulum_lc1_m", + "pendulum_i1_kg_m2", + "pendulum_m2_kg", + "pendulum_l2_m", + "pendulum_lc2_m", + "pendulum_i2_kg_m2", +) +_DAMPING_KEYS = frozenset( + {"swing_sim.swing.damping_shoulder", "swing_sim.swing.damping_wrist"} +) +_TOE_KEY = f"{CATEGORY_DELIVERY}.impact_offset_toe_mm" +_HIGH_KEY = f"{CATEGORY_DELIVERY}.impact_offset_high_mm" +_HEAD_MASS_KEY = "swing_sim.club.head_mass_kg" +_HEAD_MOI_KEY = "swing_sim.club.head_moi_kg_m2" +_TEE_KEY = "swing_sim.ball_setup.tee_height_m" + + +def _finite(value: object, name: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise TypeError(f"{name} must be a finite number") + result = float(value) + if not math.isfinite(result): + raise ValueError(f"{name} must be a finite number") + return result + + +def _integer(value: object, name: str, minimum: int, maximum: int) -> int: + if type(value) is not int or not minimum <= value <= maximum: + raise ValueError(f"{name} must be an integer within [{minimum}, {maximum}]") + return value + + +@dataclass(frozen=True) +class MorrisBaseRequest: + """Primitive-only base simulation fields accepted on the wire.""" + + values: dict[str, Any] + + def simulation_config(self) -> SimulationConfig: + """Reconstruct the one pinned passive fixed-ball authority config.""" + value = self.values + support = BallSupportMode(str(value["support_mode"])) + scenario = ImpactScenario( + clubhead_speed_mph=113.0, + impact_offset_toe_mm=float(value["impact_offset_toe_mm"]), + impact_offset_high_mm=float(value["impact_offset_high_mm"]), + ) + plane = PlaneOrientation( + float(value["plane_yaw_deg"]), + float(value["plane_side_tilt_deg"]), + float(value["plane_forward_tilt_deg"]), + ) + parameters = PendulumParameters( + *( + float(value[name]) + for name in ( + "pendulum_m1_kg", + "pendulum_l1_m", + "pendulum_lc1_m", + "pendulum_i1_kg_m2", + "pendulum_m2_kg", + "pendulum_l2_m", + "pendulum_lc2_m", + "pendulum_i2_kg_m2", + "damping_shoulder", + "damping_wrist", + ) + ) + ) + return SimulationConfig( + scenario=scenario, + club=CLUB_LIBRARY[str(value["club_name"])], + ball_setup=BallSetup(support, float(value["tee_height_m"])), + source_kind="double_pendulum", + plane=plane, + flight_model=str(value["flight_model"]), + swing_duration_s=float(value["swing_duration_s"]), + contact_mode=ContactMode.FIXED_BALL_CONTACT, + pendulum_parameters=parameters, + ) + + +@dataclass(frozen=True) +class MorrisAuthorityRequest: + """Validated v1 request and deterministic construction methods.""" + + request_id: str + base: MorrisBaseRequest + factors: tuple[MorrisFactor, ...] + trajectories: int + levels: int + seed: int + minimum_effects: int + worker_count: int + + def base_config(self) -> SimulationConfig: + """Return the reconstructed immutable simulation config.""" + return self.base.simulation_config() + + def design(self) -> MorrisDesign: + """Generate this request's deterministic Morris design.""" + return generate_morris_design( + self.factors, self.trajectories, self.levels, self.seed + ) + + @property + def total_samples(self) -> int: + """Return the exact bounded design sample count.""" + return self.trajectories * (len(self.factors) + 1) + + +@dataclass(frozen=True) +class MorrisJobEnvelope: + """Stable job status envelope; only completed jobs carry reports.""" + + job_id: str + request_id: str + status: JobStatus + completed_samples: int + total_samples: int + cancel_requested: bool = False + report: dict[str, Any] | None = None + error: dict[str, str] | None = None + + @classmethod + def running( + cls, + job_id: str, + request_id: str, + progress: tuple[int, int], + cancel: bool, + ) -> MorrisJobEnvelope: + """Build a running envelope.""" + done, total = progress + return cls(job_id, request_id, "running", done, total, cancel) + + def to_json_dict(self) -> dict[str, Any]: + """Serialize exact v1 snake-case fields.""" + return { + "schema_id": MORRIS_JOB_SCHEMA_ID, + "schema_version": 1, + "job_id": self.job_id, + "request_id": self.request_id, + "status": self.status, + "completed_samples": self.completed_samples, + "total_samples": self.total_samples, + "cancel_requested": self.cancel_requested, + "report": self.report, + "error": self.error, + } + + +def _parse_base(value: object) -> MorrisBaseRequest: + item = dict(exact_mapping(value, _BASE_FIELDS, "Morris base")) + for name in _BASE_FIELDS - {"club_name", "support_mode", "flight_model"}: + item[name] = _finite(item[name], f"base {name}") + if item["support_mode"] not in {"ground", "tee"}: + raise ValueError("base support_mode is unsupported") + if item["club_name"] not in CLUB_LIBRARY: + raise ValueError("base club_name is not in the club library") + if item["flight_model"] not in {model.value for model in FlightModelType}: + raise ValueError("base flight_model is unsupported") + _validate_base_physics(item) + if item["support_mode"] == "ground" and item["tee_height_m"] != 0.0: + raise ValueError("ground support requires tee_height_m == 0") + result = MorrisBaseRequest(item) + result.simulation_config() + return result + + +def _validate_base_physics(item: dict[str, Any]) -> None: + if item["tee_height_m"] < 0.0: + raise ValueError("base tee_height_m must be nonnegative") + if item["swing_duration_s"] <= 0.0: + raise ValueError("base swing_duration_s must be positive") + if any(item[name] <= 0.0 for name in _PENDULUM_POSITIVE): + raise ValueError( + "base pendulum masses, lengths, centers, and inertias must be positive" + ) + if item["pendulum_lc1_m"] > item["pendulum_l1_m"]: + raise ValueError("base pendulum_lc1_m must not exceed pendulum_l1_m") + if item["pendulum_lc2_m"] > item["pendulum_l2_m"]: + raise ValueError("base pendulum_lc2_m must not exceed pendulum_l2_m") + if item["damping_shoulder"] < 0.0 or item["damping_wrist"] < 0.0: + raise ValueError("base pendulum damping must be nonnegative") + _bounded(item["impact_offset_toe_mm"], (-80.0, 80.0), "base toe offset") + _bounded(item["impact_offset_high_mm"], (-40.0, 40.0), "base high offset") + + +def _bounded(value: float, bounds: tuple[float, float], name: str) -> None: + if not bounds[0] <= value <= bounds[1]: + raise ValueError(f"{name} must be within [{bounds[0]}, {bounds[1]}]") + + +def _parse_factors(value: object, config: SimulationConfig) -> tuple[MorrisFactor, ...]: + if not isinstance(value, list) or not value: + raise TypeError("factors must be a nonempty array") + factors = tuple(_parse_factor(item) for item in value) + if len({factor.spec_id for factor in factors}) != len(factors): + raise ValueError("factor spec_id values must be unique") + if len({factor.variable_key for factor in factors}) != len(factors): + raise ValueError("factor variable_key values must be unique") + for factor in factors: + _validate_factor_endpoint(factor.variable_key, factor.lower, config) + _validate_factor_endpoint(factor.variable_key, factor.upper, config) + apply_global_simulation_values(config, {factor.variable_key: factor.lower}) + apply_global_simulation_values(config, {factor.variable_key: factor.upper}) + return factors + + +def _parse_factor(value: object) -> MorrisFactor: + item = exact_mapping(value, _FACTOR_FIELDS, "Morris factor") + key = item["variable_key"] + if not isinstance(key, str) or key not in RATE_MORRIS_VARIABLE_KEYS: + raise ValueError("factor variable_key is unsupported") + unit = variable_registry()[key].unit + if item["unit"] != unit: + raise ValueError("factor unit must match the registry unit") + lower = _finite(item["lower"], "factor lower") + upper = _finite(item["upper"], "factor upper") + if lower >= upper: + raise ValueError("factor bounds must satisfy lower < upper") + return MorrisFactor( + stable_id(item["spec_id"], "factor spec_id"), + key, + lower, + upper, + unit, + ) + + +def _validate_factor_endpoint(key: str, value: float, config: SimulationConfig) -> None: + if key in _DAMPING_KEYS and value < 0.0: + raise ValueError("damping factor endpoints must be nonnegative") + if key == _TOE_KEY: + _bounded(value, (-80.0, 80.0), "toe factor endpoint") + if key == _HIGH_KEY: + _bounded(value, (-40.0, 40.0), "high factor endpoint") + if key == _HEAD_MASS_KEY: + _bounded(value, SPEC_BOUNDS["head_mass_kg"], "head mass factor endpoint") + if key == _HEAD_MOI_KEY: + _bounded( + value, + SPEC_BOUNDS["moi_about_shaft_kg_m2"], + "head MOI factor endpoint", + ) + if key == _TEE_KEY: + if config.ball_setup.support_mode is not BallSupportMode.TEE: + raise ValueError("tee_height_m factor requires tee support") + if value < 0.0: + raise ValueError("tee height factor endpoints must be nonnegative") + + +def parse_morris_request(value: object) -> MorrisAuthorityRequest: + """Parse an exact v1 request and validate its full allocation.""" + item = exact_mapping(value, _REQUEST_FIELDS, "Morris request") + if item["schema_id"] != MORRIS_REQUEST_SCHEMA_ID: + raise ValueError("unsupported Morris request schema ID") + if item["schema_version"] != MORRIS_AUTHORITY_SCHEMA_VERSION: + raise ValueError("unsupported Morris request schema version") + base = _parse_base(item["base"]) + factors = _parse_factors(item["factors"], base.simulation_config()) + trajectories = _integer(item["trajectories"], "trajectories", 1, 2**31 - 1) + levels = _integer(item["levels"], "levels", 4, 10_000) + if levels % 2: + raise ValueError("levels must be even") + total = trajectories * (len(factors) + 1) + if total > MAX_MORRIS_SAMPLES or total * 17 > MAX_MORRIS_OBSERVATION_CELLS: + raise ValueError("Morris sample allocation exceeds resource limits") + return MorrisAuthorityRequest( + stable_id(item["request_id"], "request_id"), + base, + factors, + trajectories, + levels, + _integer(item["seed"], "seed", 0, 2**32 - 1), + _integer(item["minimum_effects"], "minimum_effects", 2, trajectories), + _integer(item["worker_count"], "worker_count", 1, MAX_MORRIS_WORKERS), + ) + + +__all__ = [ + "MORRIS_AUTHORITY_SCHEMA_VERSION", + "MORRIS_JOB_SCHEMA_ID", + "MORRIS_REQUEST_SCHEMA_ID", + "MorrisAuthorityRequest", + "MorrisJobEnvelope", + "parse_morris_request", +] diff --git a/src/rate_of_closure/application/morris/router.py b/src/rate_of_closure/application/morris/router.py new file mode 100644 index 000000000..952c11ebc --- /dev/null +++ b/src/rate_of_closure/application/morris/router.py @@ -0,0 +1,320 @@ +"""Mountable FastAPI router and bounded in-memory Morris job registry.""" + +from __future__ import annotations + +import json +import logging +import threading +import time +import uuid +from collections.abc import Callable +from concurrent.futures import Future, ThreadPoolExecutor +from dataclasses import dataclass, field +from typing import Any + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from rate_of_closure.application._workspace_validation import unique_json_object +from shared.python.swing_sim.variation import CancelledError + +from .contracts import ( + JobStatus, + MorrisAuthorityRequest, + MorrisJobEnvelope, + parse_morris_request, +) +from .service import MorrisExecutionService + +_LOGGER = logging.getLogger(__name__) +_TERMINAL = frozenset({"completed", "cancelled", "failed"}) + + +@dataclass +class _Job: + job_id: str + request: MorrisAuthorityRequest + status: JobStatus = "queued" + completed_samples: int = 0 + cancel_requested: bool = False + report: dict[str, Any] | None = None + error: dict[str, str] | None = None + terminal_at: float | None = None + cancel: threading.Event = field(default_factory=threading.Event) + future: Future[None] | None = None + + +@dataclass(frozen=True) +class MorrisRegistryOptions: + """Explicit resource and retention limits for one registry.""" + + max_active_jobs: int = 2 + max_body_bytes: int = 64_000 + terminal_ttl_s: float = 900.0 + max_retained_jobs: int = 128 + max_total_study_workers: int = 64 + + def __post_init__(self) -> None: + if not 1 <= self.max_active_jobs <= 32: + raise ValueError("max_active_jobs must be within [1, 32]") + if self.max_body_bytes < 1_024 or self.terminal_ttl_s <= 0.0: + raise ValueError("body and retention limits must be positive") + if self.max_retained_jobs < self.max_active_jobs: + raise ValueError("retained job limit must cover active jobs") + if self.max_total_study_workers < 1: + raise ValueError("study worker budget must be positive") + + +_DEFAULT_REGISTRY_OPTIONS = MorrisRegistryOptions() + + +class MorrisJobRegistry: + """Own a bounded executor and lock-linearized ephemeral job lifecycle.""" + + def __init__( + self, + service: MorrisExecutionService, + options: MorrisRegistryOptions = _DEFAULT_REGISTRY_OPTIONS, + clock: Callable[[], float] = time.monotonic, + ) -> None: + if not hasattr(service, "execute"): + raise TypeError("service must implement execute") + if not isinstance(options, MorrisRegistryOptions) or not callable(clock): + raise TypeError("registry options and monotonic clock are required") + self._service = service + self._options = options + self.max_body_bytes = options.max_body_bytes + self._clock = clock + self._lock = threading.Lock() + self._jobs: dict[str, _Job] = {} + self._pool = ThreadPoolExecutor( + max_workers=options.max_active_jobs, thread_name_prefix="rate-morris" + ) + + def create(self, request: MorrisAuthorityRequest) -> MorrisJobEnvelope: + """Register and submit one job, rejecting saturated capacity.""" + with self._lock: + self._prune_locked() + active = sum(job.status not in _TERMINAL for job in self._jobs.values()) + workers = sum( + job.request.worker_count + for job in self._jobs.values() + if job.status not in _TERMINAL + ) + saturated = active >= self._options.max_active_jobs + worker_limit = ( + workers + request.worker_count > self._options.max_total_study_workers + ) + if saturated or worker_limit: + raise OverflowError("Morris authority is at capacity") + job_id = str(uuid.uuid4()) + job = _Job(job_id, request) + self._jobs[job_id] = job + job.future = self._pool.submit(self._run, job_id) + return self._envelope(job) + + def status(self, job_id: str) -> MorrisJobEnvelope: + """Return one detached status envelope or reject an unknown job.""" + with self._lock: + self._prune_locked() + return self._envelope(self._known_locked(job_id)) + + def cancel(self, job_id: str) -> MorrisJobEnvelope: + """Idempotently register cancellation without prematurely terminating work.""" + with self._lock: + job = self._known_locked(job_id) + if job.status in _TERMINAL: + return self._envelope(job) + job.cancel_requested = True + job.cancel.set() + if ( + job.status == "queued" + and job.future is not None + and job.future.cancel() + ): + self._terminal_locked(job, "cancelled") + return self._envelope(job) + + def close(self) -> None: + """Cancel outstanding work and release owned executor threads.""" + with self._lock: + for job in self._jobs.values(): + if job.status not in _TERMINAL: + job.cancel_requested = True + job.cancel.set() + self._pool.shutdown(wait=True, cancel_futures=True) + + def _run(self, job_id: str) -> None: + with self._lock: + job = self._jobs[job_id] + if job.cancel_requested: + self._terminal_locked(job, "cancelled") + return + job.status = "running" + try: + report = self._service.execute( + job.request, + job.cancel, + lambda done, total: self._progress(job_id, done, total), + ) + except CancelledError: + with self._lock: + self._terminal_locked(job, "cancelled") + except Exception: + _LOGGER.exception("Morris job failed: job_id=%s", job_id) + with self._lock: + self._finish_failure_locked(job) + else: + with self._lock: + self._finish_success_locked(job, report) + + def _finish_failure_locked(self, job: _Job) -> None: + if job.cancel_requested: + self._terminal_locked(job, "cancelled") + return + job.error = { + "code": "execution_failed", + "message": "Morris execution failed", + } + self._terminal_locked(job, "failed") + + def _finish_success_locked(self, job: _Job, report: dict[str, Any]) -> None: + if job.cancel_requested: + self._terminal_locked(job, "cancelled") + return + job.completed_samples = job.request.total_samples + job.report = report + self._terminal_locked(job, "completed") + + def _progress(self, job_id: str, done: int, total: int) -> None: + with self._lock: + job = self._jobs[job_id] + if job.status == "running" and total == job.request.total_samples: + job.completed_samples = max(job.completed_samples, min(done, total)) + + def _terminal_locked(self, job: _Job, status: JobStatus) -> None: + job.status = status + job.terminal_at = self._clock() + if status != "completed": + job.report = None + + def _known_locked(self, job_id: str) -> _Job: + if job_id not in self._jobs: + raise KeyError(job_id) + return self._jobs[job_id] + + def _prune_locked(self) -> None: + cutoff = self._clock() - self._options.terminal_ttl_s + expired = [ + key + for key, job in self._jobs.items() + if job.terminal_at is not None and job.terminal_at < cutoff + ] + for key in expired: + del self._jobs[key] + terminal = sorted( + ( + (job.terminal_at, key) + for key, job in self._jobs.items() + if job.terminal_at is not None + ), + key=lambda item: item[0], + ) + excess = max(0, len(self._jobs) - self._options.max_retained_jobs) + for _time, key in terminal[:excess]: + del self._jobs[key] + + @staticmethod + def _envelope(job: _Job) -> MorrisJobEnvelope: + return MorrisJobEnvelope( + job.job_id, + job.request.request_id, + job.status, + job.completed_samples, + job.request.total_samples, + job.cancel_requested, + job.report, + job.error, + ) + + +async def _strict_document(request: Request, limit: int) -> object: + content_type = ( + request.headers.get("content-type", "").split(";", 1)[0].strip().lower() + ) + if content_type != "application/json": + raise _HttpFailure(415, "application/json is required") + length = request.headers.get("content-length") + if length is not None: + try: + if int(length) > limit: + raise _HttpFailure(413, "request body is too large") + except ValueError as exc: + raise _HttpFailure(400, "invalid content length") from exc + body = bytearray() + async for chunk in request.stream(): + body.extend(chunk) + if len(body) > limit: + raise _HttpFailure(413, "request body is too large") + try: + text = bytes(body).decode("utf-8", errors="strict") + return json.loads( + text, + object_pairs_hook=unique_json_object, + parse_constant=lambda value: (_ for _ in ()).throw(ValueError(value)), + ) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc: + raise _HttpFailure(400, "invalid JSON request") from exc + + +class _HttpFailure(Exception): + def __init__(self, status: int, message: str) -> None: + super().__init__(message) + self.status = status + self.message = message + + +def _response(envelope: MorrisJobEnvelope, status: int = 200) -> JSONResponse: + return JSONResponse(envelope.to_json_dict(), status_code=status) + + +def create_morris_router(registry: MorrisJobRegistry) -> APIRouter: + """Create a mountable router without host, CORS, or global state changes.""" + if not isinstance(registry, MorrisJobRegistry): + raise TypeError("registry must be a MorrisJobRegistry") + router = APIRouter() + + @router.post("/morris/jobs") + async def create_job(request: Request) -> JSONResponse: + try: + document = await _strict_document(request, registry.max_body_bytes) + parsed = parse_morris_request(document) + return _response(registry.create(parsed), 202) + except _HttpFailure as exc: + return JSONResponse({"error": exc.message}, status_code=exc.status) + except (TypeError, ValueError) as exc: + return JSONResponse({"error": str(exc)}, status_code=422) + except OverflowError: + return JSONResponse( + {"error": "Morris authority is at capacity"}, status_code=429 + ) + + @router.get("/morris/jobs/{job_id}") + async def get_job(job_id: str) -> JSONResponse: + try: + return _response(registry.status(job_id)) + except KeyError: + return JSONResponse({"error": "unknown Morris job"}, status_code=404) + + @router.delete("/morris/jobs/{job_id}") + async def cancel_job(job_id: str) -> JSONResponse: + try: + envelope = registry.cancel(job_id) + return _response(envelope, 200 if envelope.status in _TERMINAL else 202) + except KeyError: + return JSONResponse({"error": "unknown Morris job"}, status_code=404) + + return router + + +__all__ = ["MorrisJobRegistry", "MorrisRegistryOptions", "create_morris_router"] diff --git a/src/rate_of_closure/application/morris/service.py b/src/rate_of_closure/application/morris/service.py new file mode 100644 index 000000000..671647861 --- /dev/null +++ b/src/rate_of_closure/application/morris/service.py @@ -0,0 +1,82 @@ +"""UI-neutral execution service for Rate fixed-ball Morris studies.""" + +from __future__ import annotations + +import threading +from collections.abc import Callable +from typing import Any, Protocol, cast + +from rate_of_closure.simulation import SimulationConfig +from rate_of_closure.variation.morris_rate_adapter import ( + RATE_MORRIS_OUTPUTS, + RateMorrisEvaluator, +) +from shared.python.swing_sim.solver.solve import ProgressReport +from shared.python.swing_sim.variation import ( + MorrisDesign, + MorrisEvaluator, + MorrisExecutionOptions, + analyze_morris, + evaluate_morris_design, +) + +from .contracts import MorrisAuthorityRequest + +ProgressSink = Callable[[int, int], None] +EvaluatorFactory = Callable[[MorrisDesign, SimulationConfig], MorrisEvaluator] + + +class MorrisExecutionService(Protocol): + """Minimal dependency injected into a job registry.""" + + def execute( + self, + request: MorrisAuthorityRequest, + cancel: threading.Event, + progress: ProgressSink, + ) -> dict[str, Any]: + """Execute one validated request or raise.""" + ... + + +def _rate_evaluator(design: MorrisDesign, config: SimulationConfig) -> MorrisEvaluator: + return RateMorrisEvaluator(design, config) + + +class RateMorrisService: + """Build, execute, analyze, and serialize one deterministic study.""" + + def __init__(self, evaluator_factory: EvaluatorFactory = _rate_evaluator) -> None: + if not callable(evaluator_factory): + raise TypeError("evaluator_factory must be callable") + self._evaluator_factory = evaluator_factory + + def execute( + self, + request: MorrisAuthorityRequest, + cancel: threading.Event, + progress: ProgressSink, + ) -> dict[str, Any]: + """Return the unchanged shared Morris report-v1 document.""" + if not isinstance(request, MorrisAuthorityRequest): + raise TypeError("request must be a MorrisAuthorityRequest") + if not isinstance(cancel, threading.Event) or not callable(progress): + raise TypeError("cancel and progress controls are required") + design = request.design() + total = request.total_samples + + def report(update: ProgressReport) -> None: + progress(update.iteration, total) + + options = MorrisExecutionOptions(request.worker_count, report, cancel) + evaluator = self._evaluator_factory(design, request.base_config()) + observations = evaluate_morris_design( + design, RATE_MORRIS_OUTPUTS, evaluator, options + ) + return cast( + dict[str, Any], + analyze_morris(observations, request.minimum_effects).to_json_dict(), + ) + + +__all__ = ["MorrisExecutionService", "ProgressSink", "RateMorrisService"] diff --git a/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts b/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts new file mode 100644 index 000000000..0e2671656 --- /dev/null +++ b/src/rate_of_closure/web/src/model/morrisAuthorityClient.ts @@ -0,0 +1,69 @@ +/** Injected transport client for the Rate Morris authority; no physics fallback. */ + +import { parseMorrisJobEnvelope, type MorrisJobEnvelope } from "./morrisAuthorityContract"; + +export interface MorrisAuthorityClient { + create(request: unknown, signal?: AbortSignal): Promise; + status(jobId: string, signal?: AbortSignal): Promise; + cancel(jobId: string, signal?: AbortSignal): Promise; +} + +export interface MorrisAuthorityClientOptions { + readonly baseUrl?: string; + readonly fetchImpl?: typeof fetch; +} + +const record = (value: unknown): Record => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Morris authority error must be a JSON object"); + } + const item = value as Record; + if (Object.keys(item).length !== 1 || !("error" in item) || typeof item.error !== "string") { + throw new Error("Morris authority error fields do not match the contract"); + } + return item; +}; + +const stableJobId = (value: string): string => { + if (typeof value !== "string" || value === "" || value !== value.trim()) { + throw new TypeError("jobId must be a nonempty trimmed string"); + } + return value; +}; + +const normalizedBaseUrl = (value: string): string => { + if (typeof value !== "string" || value !== value.trim()) throw new TypeError("baseUrl must be a trimmed string"); + return value.endsWith("/") ? value.slice(0, -1) : value; +}; + +const responseDocument = async (response: Response): Promise => { + const mediaType = response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase(); + if (mediaType !== "application/json") throw new Error("Morris authority returned non-JSON content"); + let document: unknown; + try { + document = await response.json() as unknown; + } catch { + throw new Error("Morris authority returned invalid JSON"); + } + if (!response.ok) throw new Error(String(record(document).error)); + return document; +}; + +export function createMorrisAuthorityClient(options: MorrisAuthorityClientOptions = {}): MorrisAuthorityClient { + const baseUrl = normalizedBaseUrl(options.baseUrl ?? ""); + const fetchImpl = options.fetchImpl ?? globalThis.fetch; + if (typeof fetchImpl !== "function") throw new TypeError("fetchImpl must be callable"); + + const call = async (path: string, init: RequestInit): Promise => { + const response = await fetchImpl(`${baseUrl}${path}`, init); + return parseMorrisJobEnvelope(await responseDocument(response)); + }; + const jobPath = (jobId: string): string => `/morris/jobs/${encodeURIComponent(stableJobId(jobId))}`; + return Object.freeze({ + create: (request: unknown, signal?: AbortSignal) => call("/morris/jobs", { + method: "POST", signal, headers: { "Content-Type": "application/json" }, body: JSON.stringify(request), + }), + status: (jobId: string, signal?: AbortSignal) => call(jobPath(jobId), { method: "GET", signal }), + cancel: (jobId: string, signal?: AbortSignal) => call(jobPath(jobId), { method: "DELETE", signal }), + }); +} diff --git a/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts b/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts new file mode 100644 index 000000000..27e839aed --- /dev/null +++ b/src/rate_of_closure/web/src/model/morrisAuthorityContract.test.ts @@ -0,0 +1,66 @@ +/** Strict Morris authority envelope and injected client tests. */ + +import { describe, expect, it, vi } from "vitest"; + +import fixture from "./__fixtures__/morris_global_sensitivity_golden_v1.json"; +import { createMorrisAuthorityClient } from "./morrisAuthorityClient"; +import { parseMorrisJobEnvelope } from "./morrisAuthorityContract"; + +const completed = (): Record => ({ + schema_id: "rate-of-closure/morris-job", + schema_version: 1, + job_id: "job-1", + request_id: "request-17", + status: "completed", + completed_samples: 36, + total_samples: 36, + cancel_requested: false, + report: structuredClone(fixture), + error: null, +}); + +describe("Morris authority contract", () => { + it("strictly parses a completed job through the existing report parser", () => { + const parsed = parseMorrisJobEnvelope(completed()); + expect(parsed.status).toBe("completed"); + expect(parsed.report?.schemaVersion).toBe(1); + expect(Object.isFrozen(parsed)).toBe(true); + }); + + it.each([ + ["unknown field", (item: Record) => { item.extra = true; }], + ["version", (item: Record) => { item.schema_version = 2; }], + ["partial report", (item: Record) => { item.status = "running"; }], + ["failed without error", (item: Record) => { item.status = "failed"; item.report = null; }], + ["progress overflow", (item: Record) => { item.completed_samples = 37; }], + ])("rejects %s", (_name, mutate) => { + const payload = completed(); + mutate(payload); + expect(() => parseMorrisJobEnvelope(payload)).toThrow(); + }); + + it("uses only the injected base URL and forwards AbortSignal for status", async () => { + const fetcher = vi.fn(async (input, init) => { + void input; + void init; + return new Response(JSON.stringify(completed()), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + }); + const client = createMorrisAuthorityClient({ + baseUrl: "http://127.0.0.1:8765/mount", + fetchImpl: fetcher, + }); + const signal = new AbortController().signal; + + await client.status("job-1", signal); + await client.cancel("job-1"); + await client.create({ schema_id: "request" }); + + expect(fetcher.mock.calls[0]?.[0]).toBe("http://127.0.0.1:8765/mount/morris/jobs/job-1"); + expect(fetcher.mock.calls[0]?.[1]).toMatchObject({ method: "GET", signal }); + expect(fetcher.mock.calls[1]?.[1]).toMatchObject({ method: "DELETE" }); + expect(fetcher.mock.calls[2]?.[1]).toMatchObject({ method: "POST" }); + }); +}); diff --git a/src/rate_of_closure/web/src/model/morrisAuthorityContract.ts b/src/rate_of_closure/web/src/model/morrisAuthorityContract.ts new file mode 100644 index 000000000..b3d2a434c --- /dev/null +++ b/src/rate_of_closure/web/src/model/morrisAuthorityContract.ts @@ -0,0 +1,109 @@ +/** Strict wire parser and injected transport for the Rate Morris authority. */ + +import { parseMorrisReport, type MorrisReport } from "./morrisGlobalSensitivityContract"; + +export const MORRIS_JOB_SCHEMA_ID = "rate-of-closure/morris-job" as const; +export const MORRIS_AUTHORITY_SCHEMA_VERSION = 1 as const; + +export type MorrisJobStatus = "queued" | "running" | "completed" | "cancelled" | "failed"; + +export interface MorrisJobError { + readonly code: string; + readonly message: string; +} + +export interface MorrisJobEnvelope { + readonly schemaId: typeof MORRIS_JOB_SCHEMA_ID; + readonly schemaVersion: typeof MORRIS_AUTHORITY_SCHEMA_VERSION; + readonly jobId: string; + readonly requestId: string; + readonly status: MorrisJobStatus; + readonly completedSamples: number; + readonly totalSamples: number; + readonly cancelRequested: boolean; + readonly report: MorrisReport | null; + readonly error: MorrisJobError | null; +} + +const JOB_FIELDS = [ + "schema_id", "schema_version", "job_id", "request_id", "status", + "completed_samples", "total_samples", "cancel_requested", "report", "error", +] as const; +const ERROR_FIELDS = ["code", "message"] as const; +const STATUSES = ["queued", "running", "completed", "cancelled", "failed"] as const; + +const record = (value: unknown, name: string): Record => { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new RangeError(`${name} must be a plain object`); + } + const prototype = Object.getPrototypeOf(value) as object | null; + if (prototype !== Object.prototype && prototype !== null) { + throw new RangeError(`${name} must be a plain object`); + } + return value as Record; +}; + +const exact = (value: Record, fields: readonly string[], name: string): void => { + const actual = Object.keys(value).sort(); + const expected = [...fields].sort(); + if (actual.length !== expected.length || actual.some((field, index) => field !== expected[index])) { + throw new RangeError(`${name} fields do not match the v1 schema`); + } +}; + +const text = (value: unknown, name: string): string => { + if (typeof value !== "string" || value === "" || value !== value.trim()) { + throw new RangeError(`${name} must be a nonempty trimmed string`); + } + return value; +}; + +const count = (value: unknown, name: string): number => { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new RangeError(`${name} must be a nonnegative safe integer`); + } + return value; +}; + +const parseError = (value: unknown): MorrisJobError | null => { + if (value === null) return null; + const item = record(value, "Morris job error"); + exact(item, ERROR_FIELDS, "Morris job error"); + return Object.freeze({ code: text(item.code, "error code"), message: text(item.message, "error message") }); +}; + +const statusValue = (value: unknown): MorrisJobStatus => { + const parsed = text(value, "job status"); + if (!STATUSES.includes(parsed as MorrisJobStatus)) throw new RangeError("job status is unsupported"); + return parsed as MorrisJobStatus; +}; + +export function parseMorrisJobEnvelope(value: unknown): MorrisJobEnvelope { + const item = record(value, "Morris job envelope"); + exact(item, JOB_FIELDS, "Morris job envelope"); + if (item.schema_id !== MORRIS_JOB_SCHEMA_ID) throw new RangeError("unsupported Morris job schema ID"); + if (item.schema_version !== MORRIS_AUTHORITY_SCHEMA_VERSION) throw new RangeError("unsupported Morris job schema version"); + const status = statusValue(item.status); + const completedSamples = count(item.completed_samples, "completed_samples"); + const totalSamples = count(item.total_samples, "total_samples"); + if (totalSamples < 1 || completedSamples > totalSamples) throw new RangeError("job progress invariant failed"); + if (typeof item.cancel_requested !== "boolean") throw new RangeError("cancel_requested must be boolean"); + const report = item.report === null ? null : parseMorrisReport(item.report); + const error = parseError(item.error); + if ((status === "completed") !== (report !== null) || (status === "completed" && completedSamples !== totalSamples)) { + throw new RangeError("only a fully completed job may carry a report"); + } + if ((status === "failed") !== (error !== null)) throw new RangeError("only a failed job must carry an error"); + return Object.freeze({ + schemaId: MORRIS_JOB_SCHEMA_ID, + schemaVersion: MORRIS_AUTHORITY_SCHEMA_VERSION, + jobId: text(item.job_id, "job_id"), + requestId: text(item.request_id, "request_id"), + status, + completedSamples, + totalSamples, + cancelRequested: item.cancel_requested, + report, + error, + }); +} diff --git a/tests/rate_of_closure/test_morris_authority_contracts.py b/tests/rate_of_closure/test_morris_authority_contracts.py new file mode 100644 index 000000000..003d5f5d1 --- /dev/null +++ b/tests/rate_of_closure/test_morris_authority_contracts.py @@ -0,0 +1,207 @@ +"""Strict versioned wire contracts for the Rate Morris authority.""" + +from __future__ import annotations + +import pytest + +from rate_of_closure.application.morris.contracts import ( + MORRIS_JOB_SCHEMA_ID, + MORRIS_REQUEST_SCHEMA_ID, + MorrisJobEnvelope, + parse_morris_request, +) +from rate_of_closure.simulation import BallSupportMode, ContactMode +from shared.python.contracts import ( + ContractLevel, + get_contract_level, + set_contract_level, +) +from shared.python.swing_sim.run_config import SwingRunMode +from shared.python.swing_sim.variation import variable_registry + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +def request_document() -> dict[str, object]: + """Return one minimal valid v1 request document.""" + key = "swing_sim.swing.yaw_deg" + return { + "schema_id": MORRIS_REQUEST_SCHEMA_ID, + "schema_version": 1, + "request_id": "request-17", + "base": { + "club_name": "Driver 10.5°", + "support_mode": "tee", + "tee_height_m": 0.0381, + "plane_yaw_deg": 0.0, + "plane_side_tilt_deg": 0.0, + "plane_forward_tilt_deg": 0.0, + "pendulum_m1_kg": 7.5, + "pendulum_l1_m": 0.75, + "pendulum_lc1_m": 0.3375, + "pendulum_i1_kg_m2": 1.210546875, + "pendulum_m2_kg": 0.35, + "pendulum_l2_m": 1.0, + "pendulum_lc2_m": 0.7557142857142858, + "pendulum_i2_kg_m2": 0.2877354761904762, + "damping_shoulder": 0.4, + "damping_wrist": 0.25, + "swing_duration_s": 0.05, + "flight_model": "waterloo_penner", + "impact_offset_toe_mm": 0.0, + "impact_offset_high_mm": 0.0, + }, + "factors": [ + { + "spec_id": "yaw", + "variable_key": key, + "lower": -2.0, + "upper": 2.0, + "unit": variable_registry()[key].unit, + } + ], + "trajectories": 2, + "levels": 4, + "seed": 17, + "minimum_effects": 2, + "worker_count": 1, + } + + +def test_request_reconstructs_pinned_simulation_authority() -> None: + request = parse_morris_request(request_document()) + config = request.base_config() + + assert config.source_kind == "double_pendulum" + assert config.contact_mode is ContactMode.FIXED_BALL_CONTACT + assert config.swing_run_config.mode is SwingRunMode.PASSIVE + assert not config.swing_run_config.joint_locks.has_locks + assert config.torque_library is None + assert config.impact_time_s is None + assert config.impact_time_offset_s == 0.0 + assert config.ball_setup.support_mode is BallSupportMode.TEE + assert request.design().factors[0].variable_key == "swing_sim.swing.yaw_deg" + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda item: item.update(extra=True), "fields"), + (lambda item: item.pop("seed"), "fields"), + (lambda item: item.update(schema_version=2), "version"), + (lambda item: item.update(trajectories=100_001), "sample"), + (lambda item: item.update(worker_count=33), "worker"), + ( + lambda item: item["factors"][0].update(unit="rad"), # type: ignore[index,union-attr] + "unit", + ), + ( + lambda item: item["factors"].append(dict(item["factors"][0])), # type: ignore[index,union-attr] + "unique", + ), + ( + lambda item: item["base"].update(support_mode="ground"), # type: ignore[union-attr] + "tee_height_m", + ), + ], +) +def test_request_rejects_schema_resource_unit_and_tee_violations( + mutation: object, message: str +) -> None: + document = request_document() + mutation(document) # type: ignore[operator] + with pytest.raises((TypeError, ValueError), match=message): + parse_morris_request(document) + + +def test_job_envelope_is_exact_and_never_exposes_partial_report() -> None: + envelope = MorrisJobEnvelope.running("job-1", "request-17", (1, 4), True) + assert envelope.to_json_dict() == { + "schema_id": MORRIS_JOB_SCHEMA_ID, + "schema_version": 1, + "job_id": "job-1", + "request_id": "request-17", + "status": "running", + "completed_samples": 1, + "total_samples": 4, + "cancel_requested": True, + "report": None, + "error": None, + } + + +def _reverse_factor(document: dict[str, object]) -> None: + factor = document["factors"][0] # type: ignore[index] + factor.update(lower=2.0, upper=-2.0) + + +def _ground_tee_factor(document: dict[str, object]) -> None: + document["base"].update(support_mode="ground", tee_height_m=0.0) # type: ignore[union-attr] + document["factors"] = [ + { + "spec_id": "tee", + "variable_key": "swing_sim.ball_setup.tee_height_m", + "lower": 0.01, + "upper": 0.05, + "unit": "m", + } + ] + + +@pytest.mark.parametrize("level", [ContractLevel.WARN, ContractLevel.OFF]) +@pytest.mark.parametrize( + "mutation", + [ + _reverse_factor, + lambda item: item["base"].update(pendulum_m1_kg=-7.5), # type: ignore[union-attr] + _ground_tee_factor, + lambda item: item["base"].update(pendulum_lc1_m=0.9), # type: ignore[union-attr] + lambda item: item["base"].update(damping_wrist=-0.1), # type: ignore[union-attr] + lambda item: item["base"].update(swing_duration_s=0.0), # type: ignore[union-attr] + lambda item: item["base"].update(impact_offset_toe_mm=81.0), # type: ignore[union-attr] + ], +) +def test_wire_validation_is_unconditional_when_shared_dbc_is_not_enforcing( + level: ContractLevel, mutation: object +) -> None: + document = request_document() + mutation(document) # type: ignore[operator] + original = get_contract_level() + try: + set_contract_level(level) + with pytest.raises((TypeError, ValueError)): + parse_morris_request(document) + finally: + set_contract_level(original) + + +@pytest.mark.parametrize("level", [ContractLevel.WARN, ContractLevel.OFF]) +@pytest.mark.parametrize( + ("variable_key", "lower", "upper"), + [ + ("swing_sim.impact.delivery.impact_offset_toe_mm", -81.0, 0.0), + ("swing_sim.impact.delivery.impact_offset_toe_mm", 0.0, 81.0), + ("swing_sim.impact.delivery.impact_offset_high_mm", -41.0, 0.0), + ("swing_sim.impact.delivery.impact_offset_high_mm", 0.0, 41.0), + ], +) +def test_delivery_factor_endpoints_fail_closed_at_both_physical_bounds( + level: ContractLevel, variable_key: str, lower: float, upper: float +) -> None: + document = request_document() + document["factors"] = [ + { + "spec_id": "offset", + "variable_key": variable_key, + "lower": lower, + "upper": upper, + "unit": "mm", + } + ] + original = get_contract_level() + try: + set_contract_level(level) + with pytest.raises(ValueError, match="factor endpoint"): + parse_morris_request(document) + finally: + set_contract_level(original) diff --git a/tests/rate_of_closure/test_morris_authority_router.py b/tests/rate_of_closure/test_morris_authority_router.py new file mode 100644 index 000000000..006213a69 --- /dev/null +++ b/tests/rate_of_closure/test_morris_authority_router.py @@ -0,0 +1,158 @@ +"""Bounded mountable FastAPI router and in-memory job registry tests.""" + +from __future__ import annotations + +import json +import threading + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from rate_of_closure.application.morris.contracts import parse_morris_request +from rate_of_closure.application.morris.router import ( + MorrisJobRegistry, + MorrisRegistryOptions, + create_morris_router, +) + +from .test_morris_authority_contracts import request_document + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +class BlockingService: + """A controllable service for lifecycle and cancellation tests.""" + + def __init__(self) -> None: + self.started = threading.Event() + self.release = threading.Event() + + def execute( + self, request: object, cancel: threading.Event, progress: object + ) -> dict[str, object]: + self.started.set() + self.release.wait(timeout=3) + if cancel.is_set(): + from shared.python.swing_sim.variation import CancelledError + + raise CancelledError("cancelled") + progress(4, 4) # type: ignore[operator] + return { + "schema_id": "swing-sim/morris-global-sensitivity-report", + "schema_version": 1, + } + + +def _client( + service: object, **registry_options: object +) -> tuple[TestClient, MorrisJobRegistry]: + registry = MorrisJobRegistry( + service=service, + options=MorrisRegistryOptions(**registry_options), # type: ignore[arg-type] + ) + app = FastAPI() + app.include_router(create_morris_router(registry), prefix="/authority") + return TestClient(app), registry + + +def test_router_create_status_and_acknowledged_cancel() -> None: + service = BlockingService() + client, _registry = _client(service) + created = client.post("/authority/morris/jobs", json=request_document()) + assert created.status_code == 202 + job_id = created.json()["job_id"] + assert service.started.wait(timeout=1) + + cancelling = client.delete(f"/authority/morris/jobs/{job_id}") + assert cancelling.status_code == 202 + assert cancelling.json()["status"] == "running" + assert cancelling.json()["cancel_requested"] is True + service.release.set() + + terminal = _await_terminal(client, job_id) + assert terminal["status"] == "cancelled" + assert terminal["report"] is None + assert client.delete(f"/authority/morris/jobs/{job_id}").json() == terminal + + +def test_router_rejects_duplicate_nonfinite_and_oversized_raw_json() -> None: + client, _registry = _client(BlockingService(), max_body_bytes=1_500) + duplicate = json.dumps(request_document()).replace( + '"seed": 17', '"seed": 17, "seed": 18' + ) + headers = {"Content-Type": "application/json"} + assert ( + client.post( + "/authority/morris/jobs", content=duplicate, headers=headers + ).status_code + == 400 + ) + nonfinite = json.dumps(request_document()).replace('"seed": 17', '"seed": NaN') + assert ( + client.post( + "/authority/morris/jobs", content=nonfinite, headers=headers + ).status_code + == 400 + ) + assert ( + client.post( + "/authority/morris/jobs", content=b"{" + b" " * 2_000, headers=headers + ).status_code + == 413 + ) + + +def test_registry_bounds_active_jobs_and_sanitizes_failures() -> None: + service = BlockingService() + client, _registry = _client(service, max_active_jobs=1) + first = client.post("/authority/morris/jobs", json=request_document()) + assert first.status_code == 202 + assert ( + client.post( + "/authority/morris/jobs", + json={**request_document(), "request_id": "second"}, + ).status_code + == 429 + ) + service.release.set() + + +def test_registry_expires_terminal_jobs_and_sanitizes_programming_failures() -> None: + failed = threading.Event() + + class BrokenService: + def execute( + self, _request: object, _cancel: object, _progress: object + ) -> object: + failed.set() + raise TypeError("C:\\private\\source.py must not cross the wire") + + now = [10.0] + options = MorrisRegistryOptions(terminal_ttl_s=1.0) + registry = MorrisJobRegistry(BrokenService(), options, lambda: now[0]) + request = parse_morris_request(request_document()) + job_id = registry.create(request).job_id + assert failed.wait(timeout=1) + for _index in range(100): + envelope = registry.status(job_id) + if envelope.status == "failed": + break + threading.Event().wait(0.001) + assert envelope.error == { + "code": "execution_failed", + "message": "Morris execution failed", + } + assert "private" not in str(envelope.to_json_dict()) + now[0] = 12.0 + with pytest.raises(KeyError): + registry.status(job_id) + registry.close() + + +def _await_terminal(client: TestClient, job_id: str) -> dict[str, object]: + for _index in range(100): + payload = client.get(f"/authority/morris/jobs/{job_id}").json() + if payload["status"] in {"completed", "cancelled", "failed"}: + return payload + raise AssertionError("job did not finish") diff --git a/tests/rate_of_closure/test_morris_authority_service.py b/tests/rate_of_closure/test_morris_authority_service.py new file mode 100644 index 000000000..ef80592af --- /dev/null +++ b/tests/rate_of_closure/test_morris_authority_service.py @@ -0,0 +1,48 @@ +"""UI-neutral deterministic Rate Morris service tests.""" + +from __future__ import annotations + +import threading + +import pytest + +from rate_of_closure.application.morris.contracts import parse_morris_request +from rate_of_closure.application.morris.service import RateMorrisService +from rate_of_closure.variation.simulation_types import ALL_OUTPUT_NAMES +from shared.python.swing_sim.variation import MorrisEvaluation + +from .test_morris_authority_contracts import request_document + +pytestmark = [pytest.mark.unit, pytest.mark.headless_safe] + + +def deterministic_evaluator(sample: object) -> MorrisEvaluation: + """Return deterministic hit-shaped scalar data for service isolation.""" + values = {name: float(sample.ordinal) for name in ALL_OUTPUT_NAMES} + return MorrisEvaluation("evaluated_hit", values) + + +def test_service_returns_unchanged_v1_report_deterministically() -> None: + request = parse_morris_request(request_document()) + service = RateMorrisService( + evaluator_factory=lambda _design, _config: deterministic_evaluator + ) + + first = service.execute(request, threading.Event(), lambda _done, _total: None) + second = service.execute(request, threading.Event(), lambda _done, _total: None) + + assert first == second + assert first["schema_id"] == "swing-sim/morris-global-sensitivity-report" + assert first["schema_version"] == 1 + assert first["design"]["total_samples"] == 4 # type: ignore[index] + + +def test_programming_failure_aborts_whole_service_call() -> None: + request = parse_morris_request(request_document()) + + def broken(_sample: object) -> MorrisEvaluation: + raise TypeError("C:\\private\\source.py leaked") + + service = RateMorrisService(evaluator_factory=lambda _design, _config: broken) + with pytest.raises(TypeError, match="private"): + service.execute(request, threading.Event(), lambda _done, _total: None)