diff --git a/AGENT_HANDOFF.md b/AGENT_HANDOFF.md index b59644926..7807c6b6c 100644 --- a/AGENT_HANDOFF.md +++ b/AGENT_HANDOFF.md @@ -3,6 +3,76 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Exact Morris serialized-clamp contract (#4142 R13.4) + +- The producer maps `sigma` and `mu*` standard error at or below + `64*epsilon*max(1,mu_star)` to exact zero. The consumer now rejects positive + values inside that interval; the prior `1e-14` perturbation allowance is + removed. Clamp uncertainty contributes to the squared identity only when the + corresponding serialized metric is exactly zero. +- Identity arithmetic is normalized before squaring. Nonzero metrics receive + only ordinary scale-aware rounding allowance; huge finite values that cannot + be squared safely fail closed rather than producing `Infinity`/`NaN` that can + evade comparisons. +- Cohesive numerical checks moved to `morrisMetricValidation.ts`, keeping the + primary strict parser below 400 lines. Tests retain normal `n=4`/`n=12` and + `10^6`-scale cases and add clamp-boundary and near-`1e308` adversarial cases. +- Producer calculations, UI/export/execution, and UpstreamDrift remain open and + unchanged. + +## 2026-08-12 Morris clamp-scale tolerance correction (#4142 R13.4) + +- The earlier squared-space unit floor was too permissive near + `mu_star = abs(mu)` and zero standard error. The consumer now mirrors the + producer clamp exactly as `delta = 64*epsilon*max(1, mu_star)` and propagates + `2*abs(metric)*delta + delta^2` through `sigma^2`, `n*SE^2`, both mean-square + terms, and the `n/(n-1)` correction. +- A metric-level degeneracy invariant rejects `sigma` above `delta` when the + mean-magnitude difference and standard error are within `delta`. Mutation + tests reject `sigma=1e-8`, accept a serializer-scale `1e-14` perturbation, + and pin realizable identities for `n=4`, `n=12`, and scale `10^6`. +- No producer, UI, export, execution, or UpstreamDrift behavior changed. + +## 2026-08-12 Morris TypeScript review hardening (#4142 R13.4) + +- Review mutation tests now prove the four reported statistics are jointly + realizable for `valid_pairs`: for `n > 1`, the parser checks + `sigma^2 - n*SE^2 = n/(n-1)*(mu_star^2-mu^2)` with a documented tolerance + of 256 IEEE-754 epsilons scaled to the squared metrics. +- Exact zero `mu_star` requires zero `mu`, `sigma`, and standard error plus + `constant-output`; exact zero `sigma` requires zero standard error and + `mu_star` equal to `abs(mu)` within that tolerance. An all-zero tuple labeled + `available` fails closed. Explicit insufficient estimates remain all-null and + bypass finite-metric algebra without weakening denominator/adequacy checks. +- The parser accepts only ordinary or null-prototype records, rejects all C0 + and C1 control characters, and uses nested source/target sets instead of a + delimiter-composite identity. Tests pin former NUL collisions, class/custom + prototypes, nested missing/excess fields, stable source/target provenance, + complete matrices, duplicate pairs, and deep immutability. +- No Python producer behavior, UI/export/execution adapter, or UpstreamDrift + integration changes in this review fix. Later R13-R15 scope remains open. + +## 2026-08-12 Strict Morris TypeScript parity contract (#4142 R13.4) + +- Branch `codex/4142-morris-typescript-parity` fast-forwarded normally from + published parent exact head `f08494f3a2698ddd69f7452dfdb1e70765388ef8`; + no history, configured base, or parent branch was rewritten. +- The Python report now emits stable schema identity + `swing-sim/morris-global-sensitivity-report` at `schema_version: 1` while + `morris-elementary-effects` remains the independent scientific method value. +- A UI-neutral TypeScript parser consumes the golden fixture into immutable + typed source/target/effect/denominator objects. It rejects unknown fields, + coercive or non-finite values, unsupported vocabulary, broken source loci, + units/frames/bounds, invalid Morris seed/grid/sample provenance, duplicate or + inconsistent factors and estimate pairs, and invalid denominator cohorts. +- Unavailable effects must be four JSON `null` values paired with + `insufficient-data`/`insufficient`; available and constant effects must be + finite. Typed no-impact totals retain their intentional overlap with valid + state outputs while unavailable misses, failures, and non-finite pairs remain + mutually exclusive denominator cohorts. +- This slice deliberately does not add UI, export, simulation execution, or + UpstreamDrift wiring owned by later R13-R15 work and PR #4280. + ## 2026-08-12 Bounded Morris global-sensitivity core (#4142 R13.2-R13.4) - Exact-head CI follow-up: the hosted changed-file MyPy gate exposed five diff --git a/SPEC.md b/SPEC.md index ce62e3f71..a41f6c39b 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.21 | -| **Spec Version** | 1.16.21 | +| **Current Version** | 1.16.25 | +| **Spec Version** | 1.16.25 | | **Last Spec Update** | 2026-08-12 | ## 2. Purpose & Mission @@ -2922,6 +2922,10 @@ Active development with stable core, continuous tool expansion, and web API in p | Date | Version | Changes | | ---- | ------- | ------- | +| 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. | +| 2026-08-12 | 1.16.24 | fix(rate-of-closure, #4142 R13.4): replace the Morris squared-identity unit-floor tolerance with the Python producer's exact metric clamp `64*epsilon*max(1, mu*)`, propagate that delta through every squared statistic and `n/(n-1)` term, and add a clamp-scale degeneracy check so an impossible `sigma=1e-8` is rejected when `mu*=abs(mu)` and standard error is zero while serializer-scale perturbations near `1e-14` remain accepted; pin valid identities at `n=4`, `n=12`, and metric scale `10^6`. | +| 2026-08-12 | 1.16.23 | fix(rate-of-closure, #4142 R13.4): harden the strict Morris report consumer to accept only plain/null-prototype records, reject C0/C1 controls and composite-identity ambiguity, require complete unique source-target matrices with stable provenance, and verify `mu`, `mu*`, `mu*` standard error, and `sigma` are jointly possible for the declared valid-pair count using the sample-moment identity and a bounded scale-aware tolerance of 256 IEEE-754 epsilons; require zero `mu*` to mean an exact all-zero `constant-output` estimate and enforce zero-sigma implications without weakening explicit null unavailable states. | +| 2026-08-12 | 1.16.22 | feat(rate-of-closure, #4142 R13.4): add the strict UI-neutral TypeScript consumer for the Morris global-sensitivity report, give the cross-runtime wire contract the stable `swing-sim/morris-global-sensitivity-report` identity independent of its method vocabulary, and fail closed on unknown/malformed/non-finite payloads, grid/sample provenance errors, invalid units/frames/loci/bounds, unavailable-estimate encoding, typed availability/adequacy states, and denominator inconsistencies; retain UI/export/execution and UpstreamDrift integration as follow-up scope. | | 2026-08-12 | 1.16.21 | feat(swing-sim, #4142 R13.2-R13.4): add deterministic validated Morris elementary-effects design/analysis contracts with registered units and finite bounds, source-locus and downstream target attribution, canonical typed hit/no-impact/failure handling, per-output availability, total typed-miss plus unavailable-miss denominators, sample-adequacy and unavailable states, uncertainty/interaction caveats, exact design provenance, JSON-safe report serialization, and a versioned cross-runtime golden fixture; retain finite no-impact state metrics without fabricating impact/shot outputs, while deferring execution adapters, UI/export, and UpstreamDrift consumption. | | 2026-08-11 | 1.16.20 | merge(rate-of-closure, #4279 #4280): normally merge exact variation-export child `9b45bd5beca38370c1d541f8c488ef0edad08517` first with exact workspace/toolstrip parent `983805d799b76e5e1ad1dbdc7a5ab28957d805c8` second; preserve the configured base, variation export/continuation behavior, workspace/plot/toolstrip contracts, both append-only histories, and the explicit pre-manifest boundary. | | 2026-08-11 | 1.16.19 | merge(rate-of-closure, #4279 #4280): normally merge exact published variation-export child `e6c7460a01082631565fb9ed48aa32538bd7772c` first with exact reconciled workspace/toolstrip parent `89af587c8f4141680bb923fc4295e261829f5c75` second while preserving PR #4280's `feat/4218-toolstrip-workspace` base and both histories; retain variation export, selected-scatter parity, linked selection, accessible evidence, and workspace behavior while inheriting the parent's exact D-plane format-repair ancestry; preserve both append-only handoffs, use one new monotonic unique SPEC version, and keep publication, protected CI, review, unresolved-thread, dependency, and release gates open. | diff --git a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md index ff0eb2b0a..161822afd 100644 --- a/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md +++ b/docs/development/RATE_OF_CLOSURE_CAMPAIGN_HANDOFF.md @@ -1,5 +1,62 @@ # Rate of Closure Campaign Handoff +## 2026-08-12 exact Morris serialized-clamp contract (#4142 R13.4) + +- Serialized `sigma` and `mu*` standard error are now accepted only as exact + zero or strictly above the producer's 64-epsilon clamp; positive values at or + below it fail closed. +- Clamp uncertainty is propagated only for exact zero metrics. Nonzero metrics + use ordinary scaled rounding, the identity is normalized before squaring, + and huge finite values that cannot be squared safely are rejected. +- Numerical validation moved into a bounded helper module; regression tests pin + normal counts/scales, clamp boundaries, and near-`1e308` input. Python + calculations and later UI/export/execution/UpstreamDrift scope are unchanged. + +## 2026-08-12 Morris clamp-scale tolerance correction (#4142 R13.4) + +- The strict consumer now derives tolerance from the producer's exact + `64*epsilon*max(1, mu_star)` metric clamp and propagates its squared error + through every term and sample-count multiplier in the Morris identity. +- A complementary clamp-scale degeneracy invariant rejects impossible + `sigma=1e-8` near `mu*=abs(mu)`/zero standard error, while accepting only + serializer-scale perturbations near `1e-14`. +- Tests pin the relationship at `n=4`, `n=12`, unit scale, and `10^6` scale. + Python output, UI/export/execution, and UpstreamDrift scope are unchanged. + +## 2026-08-12 Morris TypeScript review hardening (#4142 R13.4) + +- Finite estimates now satisfy the exact Morris sample-moment relationship + among `mu`, `mu*`, `mu*` standard error, `sigma`, and `valid_pairs`, allowing + only a documented 256-epsilon scale-aware IEEE-754 tolerance. +- Zero `mu*` is exclusively an all-zero `constant-output` state; zero `sigma` + also requires zero standard error and `mu* = abs(mu)`. Unavailable estimates + remain explicitly all-null and retain their complete typed denominator. +- Only plain/null-prototype objects cross the parser boundary. C0/C1 controls + are rejected and nested pair maps eliminate delimiter-composite collisions. + Tests also pin nested exact fields, deep freeze, stable repeated provenance, + duplicate rejection, and a complete source-by-target result matrix. +- This is a strict-consumer review fix only. Python scientific calculations, + UI/export/execution adapters, UpstreamDrift consumption, and later R13-R15 + acceptance/release work remain unchanged and open. + +## 2026-08-12 strict TypeScript Morris report parity (#4142 R13.4) + +- The report wire format now has stable identity + `swing-sim/morris-global-sensitivity-report`, integer version `1`, and a + separately validated `morris-elementary-effects` method value. +- The TypeScript model layer consumes the shared Python golden report into + immutable typed data and rejects unknown fields, unsupported vocabularies, + non-finite/coercive values, broken availability/nullability relationships, + incomplete denominators, invalid grid/sample/seed provenance, and invalid + units, frames, point/time loci, or factor bounds. +- Denominator validation preserves the scientific distinction that typed + no-impact pairs may still supply finite state metrics; unavailable misses, + failures, and ordinary non-finite pairs remain exclusive cohorts. +- This branch normally retains published parent exact head + `f08494f3a2698ddd69f7452dfdb1e70765388ef8`. UI/export/execution adapters and + UpstreamDrift consumption remain explicit later R13-R15 work; #4142 is not + complete and PR #4280-owned presentation/export scope is untouched. + ## 2026-08-12 bounded global-sensitivity analysis slice (#4142) - Exact-head CI repair adds explicit NumPy array/status annotations required by diff --git a/src/rate_of_closure/AGENT_HANDOFF.md b/src/rate_of_closure/AGENT_HANDOFF.md index 9020de20a..0cdaf4770 100644 --- a/src/rate_of_closure/AGENT_HANDOFF.md +++ b/src/rate_of_closure/AGENT_HANDOFF.md @@ -3,6 +3,64 @@ > **Update this file with every PR and every push to main.** > Last updated: 2026-08-12 +## 2026-08-12 Exact Morris serialized-clamp contract (#4142 R13.4) + +The consumer now matches the Python wire behavior exactly: `sigma` and `mu*` +standard error must be zero or strictly above the producer's 64-epsilon clamp. +Only exact serialized zeros receive propagated clamp uncertainty; nonzero +metrics use ordinary scale-aware floating tolerance. The moment identity is +evaluated after magnitude normalization, and un-squareable huge finite metrics +fail closed. Numerical logic now lives in the focused +`morrisMetricValidation.ts` module so the parser remains below 400 lines. +Tests cover clamp boundaries, a valid zero-SE/nonzero-sigma case, normal sample +counts/scales, and near-`1e308` adversarial input. Other scopes are unchanged. + +## 2026-08-12 Morris clamp-scale tolerance correction (#4142 R13.4) + +The squared moment check no longer uses a unit-floor tolerance. It derives the +same metric delta as the Python producer (`64*epsilon*max(1, mu_star)`), +propagates it through every squared statistic and sample-count coefficient, and +adds a metric-level near-degenerate check. Therefore `sigma=1e-8` cannot hide +behind cancellation when `mu*=abs(mu)` and standard error is zero, while a +`1e-14` serializer-scale perturbation remains admissible. Tests cover valid +`n=4`/`n=12` identities and a `10^6` metric scale. Other scopes are unchanged. + +## 2026-08-12 Morris TypeScript review hardening (#4142 R13.4) + +The strict consumer now rejects statistically impossible combinations of +Morris `mu`, `mu*`, `mu*` standard error, and sample `sigma`. For finite +estimates with `n = valid_pairs > 1`, it enforces the shared-second-moment +identity with a bounded 256-epsilon scaled floating tolerance, plus explicit +zero-`mu*`, zero-`sigma`, and constant-output implications. All-null +`insufficient-data` estimates remain valid for zero/one usable pairs and do not +enter finite-metric algebra. + +Parser records must have `Object.prototype` or a null prototype. C0/C1 control +characters are prohibited, and source/target uniqueness uses nested maps rather +than delimiter-concatenated IDs. Mutation tests cover prototype attacks, the +former NUL collision, nested shape drift, incomplete/duplicate matrices, +provenance disagreement, deep freeze, and metric-identity violations. UI, +export, execution, Python calculations, and UpstreamDrift wiring are unchanged. + +## 2026-08-12 Strict Morris TypeScript parity contract (#4142 R13.4) + +The shared Morris report and its React-side consumer now use stable identity +`swing-sim/morris-global-sensitivity-report` with independent integer version +`1`; `morris-elementary-effects` remains the method rather than being +overloaded as a schema identifier. The exact parent typing repair at +`f08494f3a2698ddd69f7452dfdb1e70765388ef8` is retained normally. + +`web/src/model/morrisGlobalSensitivityContract.ts` parses the Python golden +payload into frozen typed data. It fails closed for unknown fields, malformed +or non-finite values, invalid availability/adequacy states, non-null unavailable +effects, inconsistent complete denominators, bad Morris grid/sample/seed +provenance, and invalid units, coordinate frames, points, time windows, or +bounds. Finite no-impact state metrics remain valid while unavailable miss +pairs retain a separate exclusive cohort. + +This remains a model-contract parity slice. It does not modify the React/PyQt6 +views, #4280 exports, simulation adapters, or UpstreamDrift consumption. + ## 2026-08-12 Shared Morris screening foundation (#4142 R13.2-R13.4) Hosted exact-head MyPy identified five missing NumPy inference annotations. diff --git a/src/rate_of_closure/web/src/model/__fixtures__/morris_global_sensitivity_golden_v1.json b/src/rate_of_closure/web/src/model/__fixtures__/morris_global_sensitivity_golden_v1.json index 5c86d0433..d43468132 100644 --- a/src/rate_of_closure/web/src/model/__fixtures__/morris_global_sensitivity_golden_v1.json +++ b/src/rate_of_closure/web/src/model/__fixtures__/morris_global_sensitivity_golden_v1.json @@ -1,4 +1,5 @@ { + "schema_id": "swing-sim/morris-global-sensitivity-report", "schema_version": 1, "method": "morris-elementary-effects", "design": { diff --git a/src/rate_of_closure/web/src/model/morrisGlobalSensitivityContract.test.ts b/src/rate_of_closure/web/src/model/morrisGlobalSensitivityContract.test.ts new file mode 100644 index 000000000..e3cfd41bd --- /dev/null +++ b/src/rate_of_closure/web/src/model/morrisGlobalSensitivityContract.test.ts @@ -0,0 +1,327 @@ +/** Strict cross-runtime Morris sensitivity report contract tests (#4142). */ + +import { describe, expect, it } from "vitest"; + +import fixture from "./__fixtures__/morris_global_sensitivity_golden_v1.json"; +import { + MORRIS_REPORT_SCHEMA_ID, + parseMorrisReport, + parseMorrisReportJson, +} from "./morrisGlobalSensitivityContract"; + +const cloneFixture = (): unknown => structuredClone(fixture); +const record = (value: unknown): Record => value as Record; +const firstEstimate = (value: unknown): Record => { + const estimates = record(value).estimates as unknown[]; + return record(estimates[0]); +}; +const effectsOf = (value: unknown): Record => record(firstEstimate(value).effects); +const estimatesOf = (value: unknown): unknown[] => record(value).estimates as unknown[]; + +const addSecondTargetMatrix = (value: unknown): void => { + const estimates = estimatesOf(value); + for (const sourceEstimate of [...estimates]) { + const copy = structuredClone(sourceEstimate); + record(record(copy).target).name = "clubhead_y_m"; + estimates.push(copy); + } +}; + +describe("Morris global-sensitivity report parity", () => { + it("parses the Python golden fixture with complete typed provenance", () => { + const report = parseMorrisReport(cloneFixture()); + + expect(report.schemaId).toBe(MORRIS_REPORT_SCHEMA_ID); + expect(report.schemaVersion).toBe(1); + expect(report.method).toBe("morris-elementary-effects"); + expect(report.design).toEqual({ + trajectories: 12, + levels: 4, + seed: 73, + totalSamples: 36, + normalizedStep: 2 / 3, + }); + expect(report.estimates[0]).toMatchObject({ + availability: "available", + sampleAdequacy: "adequate", + source: { + unit: "deg", + bounds: [0, 1], + timeWindowS: [0.01, 0.02], + pointIds: ["clubhead"], + }, + target: { + unit: "m", + kind: "state-point", + timeS: 0.03, + pointId: "clubhead", + coordinateFrame: "app_frame:x_target,y_up,z_right", + }, + }); + expect(Object.isFrozen(report)).toBe(true); + expect(Object.isFrozen(report.design)).toBe(true); + expect(Object.isFrozen(report.assumptions)).toBe(true); + expect(Object.isFrozen(report.estimates)).toBe(true); + expect(Object.isFrozen(report.estimates[0])).toBe(true); + expect(Object.isFrozen(report.estimates[0].source)).toBe(true); + expect(Object.isFrozen(report.estimates[0].source.bounds)).toBe(true); + expect(Object.isFrozen(report.estimates[0].source.timeWindowS)).toBe(true); + expect(Object.isFrozen(report.estimates[0].source.pointIds)).toBe(true); + expect(Object.isFrozen(report.estimates[0].target)).toBe(true); + expect(Object.isFrozen(report.estimates[0].effects)).toBe(true); + expect(Object.isFrozen(report.estimates[0].denominator)).toBe(true); + }); + + it("accepts explicit unavailable null estimates with retained denominators", () => { + const payload = record(cloneFixture()); + const estimate = firstEstimate(payload); + estimate.availability = "insufficient-data"; + estimate.sample_adequacy = "insufficient"; + estimate.effects = { + mu: null, mu_star: null, mu_star_standard_error: null, sigma: null, + }; + estimate.denominator = { + total_pairs: 12, + valid_pairs: 1, + typed_no_impact_pairs: 5, + no_impact_unavailable_pairs: 4, + failed_pairs: 3, + nonfinite_pairs: 4, + }; + + expect(parseMorrisReport(payload).estimates[0].effects.muStar).toBeNull(); + }); + + it("parses JSON text without coercing contract values", () => { + const report = parseMorrisReportJson(JSON.stringify(fixture)); + expect(report.estimates).toHaveLength(2); + expect(() => parseMorrisReportJson("not-json")).toThrow("valid JSON"); + }); + + it("accepts a null-prototype record but rejects class and custom prototypes", () => { + const plainPayload = record(cloneFixture()); + const nullPrototype = Object.assign(Object.create(null) as Record, plainPayload); + expect(parseMorrisReport(nullPrototype).schemaVersion).toBe(1); + + class ReportPayload {} + const classPayload = Object.assign(new ReportPayload(), plainPayload); + expect(() => parseMorrisReport(classPayload)).toThrow("plain object"); + + const customDesign = Object.assign(Object.create({ inherited: true }) as Record, record(plainPayload.design)); + plainPayload.design = customDesign; + expect(() => parseMorrisReport(plainPayload)).toThrow("plain object"); + }); + + it.each([ + ["schema ID", (root: Record) => { root.schema_id = "other"; }], + ["schema version", (root: Record) => { root.schema_version = 2; }], + ["method", (root: Record) => { root.method = "sobol"; }], + ["unknown root field", (root: Record) => { root.extra = true; }], + ["non-array estimates", (root: Record) => { root.estimates = {}; }], + ])("rejects malformed %s", (_name, mutate) => { + const payload = record(cloneFixture()); + mutate(payload); + expect(() => parseMorrisReport(payload)).toThrow(); + }); + + it.each([ + ["availability", (item: Record) => { item.availability = "partial"; }], + ["adequacy", (item: Record) => { item.sample_adequacy = "maybe"; }], + ["unknown estimate field", (item: Record) => { item.status = "ok"; }], + ["non-finite effect", (item: Record) => { + record(item.effects).mu = Number.POSITIVE_INFINITY; + }], + ["negative uncertainty", (item: Record) => { + record(item.effects).sigma = -1; + }], + ["mu-star below absolute mean", (item: Record) => { + record(item.effects).mu_star = 1; + }], + ["unavailable finite estimates", (item: Record) => { + item.availability = "insufficient-data"; + item.sample_adequacy = "insufficient"; + }], + ])("rejects invalid estimate %s", (_name, mutate) => { + const payload = cloneFixture(); + mutate(firstEstimate(payload)); + expect(() => parseMorrisReport(payload)).toThrow(); + }); + + it("rejects inconsistent source provenance for a repeated factor ID", () => { + const payload = record(cloneFixture()); + const estimates = payload.estimates as unknown[]; + record(record(estimates[1]).source).spec_id = "face-window"; + expect(() => parseMorrisReport(payload)).toThrow("source provenance"); + }); + + it("rejects inconsistent target provenance and duplicate source/target pairs", () => { + const inconsistent = record(cloneFixture()); + record(record(estimatesOf(inconsistent)[1]).target).unit = "ft"; + expect(() => parseMorrisReport(inconsistent)).toThrow("target provenance"); + + const duplicate = record(cloneFixture()); + estimatesOf(duplicate).push(structuredClone(estimatesOf(duplicate)[0])); + expect(() => parseMorrisReport(duplicate)).toThrow("pairs must be unique"); + }); + + it("requires the complete source-by-target estimate matrix", () => { + const payload = record(cloneFixture()); + addSecondTargetMatrix(payload); + expect(parseMorrisReport(payload).estimates).toHaveLength(4); + estimatesOf(payload).pop(); + expect(() => parseMorrisReport(payload)).toThrow("every source/target"); + }); + + it.each(["\u0000", "\u001f", "\u007f", "\u009f"])( + "rejects control character U+%s in stable identifiers", + (control) => { + const payload = cloneFixture(); + record(firstEstimate(payload).source).spec_id = `face${control}window`; + expect(() => parseMorrisReport(payload)).toThrow("control characters"); + }, + ); + + it("rejects the former NUL-delimited composite-ID collision", () => { + const payload = record(cloneFixture()); + const estimates = estimatesOf(payload); + record(record(estimates[0]).source).spec_id = "a\u0000b"; + record(record(estimates[0]).target).name = "c"; + record(record(estimates[1]).source).spec_id = "a"; + record(record(estimates[1]).target).name = "b\u0000c"; + expect(() => parseMorrisReport(payload)).toThrow("control characters"); + }); + + it.each([ + ["missing source field", (item: Record) => { delete record(item.source).unit; }], + ["excess target field", (item: Record) => { record(item.target).extra = true; }], + ["missing effect field", (item: Record) => { delete record(item.effects).sigma; }], + ["excess denominator field", (item: Record) => { record(item.denominator).extra = 0; }], + ])("rejects nested shape with %s", (_name, mutate) => { + const payload = cloneFixture(); + mutate(firstEstimate(payload)); + expect(() => parseMorrisReport(payload)).toThrow("fields do not match"); + }); + + it.each([[4, 1], [12, 1], [12, 1e6]])( + "accepts a possible metric tuple for n=%i at scale %d", + (validPairs, scale) => { + const payload = cloneFixture(); + const effects = effectsOf(payload); + effects.mu = 0.5 * scale; + effects.mu_star = 1.5 * scale; + effects.sigma = scale * Math.sqrt(validPairs * 2.25 / (validPairs - 1)); + effects.mu_star_standard_error = scale * 0.5 / Math.sqrt(validPairs - 1); + firstEstimate(payload).sample_adequacy = validPairs >= 10 ? "adequate" : "limited"; + firstEstimate(payload).denominator = { + total_pairs: 12, + valid_pairs: validPairs, + typed_no_impact_pairs: 0, + no_impact_unavailable_pairs: 0, + failed_pairs: 0, + nonfinite_pairs: 12 - validPairs, + }; + expect(parseMorrisReport(payload).estimates[0].effects.muStar).toBe(1.5 * scale); + }, + ); + + it.each([ + ["variance identity", (effects: Record) => { effects.sigma = 0.25; }], + ["zero sigma mean magnitude", (effects: Record) => { effects.mu_star = 3; }], + ["zero sigma standard error", (effects: Record) => { effects.mu_star_standard_error = 0.01; }], + ])("rejects impossible Morris metric relationship: %s", (_name, mutate) => { + const payload = cloneFixture(); + mutate(effectsOf(payload)); + expect(() => parseMorrisReport(payload)).toThrow("metric"); + }); + + it("rejects an available all-zero tuple and accepts the constant-output state", () => { + const payload = cloneFixture(); + const effects = effectsOf(payload); + effects.mu = 0; + effects.mu_star = 0; + expect(() => parseMorrisReport(payload)).toThrow("constant-output"); + firstEstimate(payload).availability = "constant-output"; + expect(parseMorrisReport(payload).estimates[0].availability).toBe("constant-output"); + }); + + it("rejects positive metrics inside the producer clamp interval", () => { + const payload = cloneFixture(); + effectsOf(payload).sigma = 1e-14; + expect(() => parseMorrisReport(payload)).toThrow("serialized sigma"); + effectsOf(payload).sigma = 0; + effectsOf(payload).mu_star_standard_error = 1e-14; + expect(() => parseMorrisReport(payload)).toThrow("serialized standard error"); + }); + + it("allows a nonzero sigma when only its corresponding SE was clamped", () => { + const payload = cloneFixture(); + effectsOf(payload).sigma = 4e-14; + expect(parseMorrisReport(payload).estimates[0].effects.sigma).toBe(4e-14); + }); + + it("rejects a variance far above the clamp in a degenerate tuple", () => { + const payload = cloneFixture(); + effectsOf(payload).sigma = 1e-8; + expect(() => parseMorrisReport(payload)).toThrow("metric"); + }); + + it("rejects huge finite metrics instead of admitting NaN identity arithmetic", () => { + const payload = cloneFixture(); + const effects = effectsOf(payload); + effects.mu = 1e308; + effects.mu_star = 1e308; + effects.mu_star_standard_error = 1e308; + effects.sigma = 1e308; + expect(() => parseMorrisReport(payload)).toThrow("safely squared"); + }); + + it.each([ + ["denominator sum", (item: Record) => { + record(item.denominator).failed_pairs = 1; + }], + ["trajectory denominator", (item: Record) => { + record(item.denominator).total_pairs = 11; + }], + ["typed miss subset", (item: Record) => { + record(item.denominator).typed_no_impact_pairs = 0; + record(item.denominator).no_impact_unavailable_pairs = 1; + }], + ])("rejects broken %s invariant", (_name, mutate) => { + const payload = cloneFixture(); + mutate(firstEstimate(payload)); + expect(() => parseMorrisReport(payload)).toThrow("denominator"); + }); + + it.each([ + ["bounds", (item: Record) => { + record(item.source).bounds = [1, 1]; + }], + ["source time locus", (item: Record) => { + record(item.source).time_window_s = [0.02, 0.01]; + }], + ["duplicate source points", (item: Record) => { + record(item.source).point_ids = ["clubhead", "clubhead"]; + }], + ["state-point frame", (item: Record) => { + record(item.target).coordinate_frame = null; + }], + ["trimmed unit", (item: Record) => { + record(item.target).unit = " m"; + }], + ])("rejects invalid provenance %s", (_name, mutate) => { + const payload = cloneFixture(); + mutate(firstEstimate(payload)); + expect(() => parseMorrisReport(payload)).toThrow(); + }); + + it.each([ + ["odd levels", (design: Record) => { design.levels = 5; }], + ["negative seed", (design: Record) => { design.seed = -1; }], + ["sample count", (design: Record) => { design.total_samples = 35; }], + ["grid step", (design: Record) => { design.normalized_step = 0.5; }], + ])("rejects invalid design provenance %s", (_name, mutate) => { + const payload = record(cloneFixture()); + mutate(record(payload.design)); + expect(() => parseMorrisReport(payload)).toThrow(); + }); +}); diff --git a/src/rate_of_closure/web/src/model/morrisGlobalSensitivityContract.ts b/src/rate_of_closure/web/src/model/morrisGlobalSensitivityContract.ts new file mode 100644 index 000000000..108be3ab4 --- /dev/null +++ b/src/rate_of_closure/web/src/model/morrisGlobalSensitivityContract.ts @@ -0,0 +1,344 @@ +/** Strict, UI-neutral consumer for the versioned Morris screening report. */ + +import { validateMorrisMetrics } from "./morrisMetricValidation"; + +export const MORRIS_REPORT_SCHEMA_ID = "swing-sim/morris-global-sensitivity-report" as const; +export const MORRIS_REPORT_SCHEMA_VERSION = 1 as const; +export const MORRIS_METHOD = "morris-elementary-effects" as const; + +export type MorrisAvailability = "available" | "constant-output" | "insufficient-data"; +export type MorrisSampleAdequacy = "adequate" | "limited" | "insufficient"; +export type MorrisTargetKind = "scalar" | "state-point" | "impact" | "shot-outcome"; + +export interface MorrisDesignProvenance { + readonly trajectories: number; + readonly levels: number; + readonly seed: number; + readonly totalSamples: number; + readonly normalizedStep: number; +} +export interface MorrisSourceProvenance { + readonly specId: string; + readonly variableKey: string; + readonly unit: string; + readonly bounds: readonly [number, number]; + readonly timeWindowS: readonly [number, number] | null; + readonly pointIds: readonly string[]; +} +export interface MorrisTargetProvenance { + readonly name: string; + readonly unit: string; + readonly kind: MorrisTargetKind; + readonly timeS: number | null; + readonly pointId: string | null; + readonly coordinateFrame: string | null; +} +export interface MorrisEffects { + readonly mu: number | null; + readonly muStar: number | null; + readonly muStarStandardError: number | null; + readonly sigma: number | null; +} +export interface MorrisDenominator { + readonly totalPairs: number; + readonly validPairs: number; + readonly typedNoImpactPairs: number; + readonly noImpactUnavailablePairs: number; + readonly failedPairs: number; + readonly nonfinitePairs: number; +} +export interface MorrisEstimate { + readonly source: MorrisSourceProvenance; + readonly target: MorrisTargetProvenance; + readonly effects: MorrisEffects; + readonly availability: MorrisAvailability; + readonly sampleAdequacy: MorrisSampleAdequacy; + readonly denominator: MorrisDenominator; +} +export interface MorrisReport { + readonly schemaId: typeof MORRIS_REPORT_SCHEMA_ID; + readonly schemaVersion: typeof MORRIS_REPORT_SCHEMA_VERSION; + readonly method: typeof MORRIS_METHOD; + readonly design: MorrisDesignProvenance; + readonly assumptions: readonly string[]; + readonly interactionCaveat: string; + readonly estimates: readonly MorrisEstimate[]; +} + +const ROOT_FIELDS = ["schema_id", "schema_version", "method", "design", "assumptions", "interaction_caveat", "estimates"] as const; +const DESIGN_FIELDS = ["trajectories", "levels", "seed", "total_samples", "normalized_step"] as const; +const ESTIMATE_FIELDS = ["source", "target", "effects", "availability", "sample_adequacy", "denominator"] as const; +const SOURCE_FIELDS = ["spec_id", "variable_key", "unit", "bounds", "time_window_s", "point_ids"] as const; +const TARGET_FIELDS = ["name", "unit", "kind", "time_s", "point_id", "coordinate_frame"] as const; +const EFFECT_FIELDS = ["mu", "mu_star", "mu_star_standard_error", "sigma"] as const; +const DENOMINATOR_FIELDS = ["total_pairs", "valid_pairs", "typed_no_impact_pairs", "no_impact_unavailable_pairs", "failed_pairs", "nonfinite_pairs"] as const; +const C0_CONTROL_MAX = 0x1f; +const C1_CONTROL_MIN = 0x7f; +const C1_CONTROL_MAX = 0x9f; + +const asRecord = (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 exactFields = (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 containsControlCharacter = (value: string): boolean => Array.from(value).some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= C0_CONTROL_MAX || (codePoint >= C1_CONTROL_MIN && codePoint <= C1_CONTROL_MAX); +}); + +const stableText = (value: unknown, name: string): string => { + if (typeof value !== "string" || value === "" || value !== value.trim()) { + throw new RangeError(`${name} must be a nonempty trimmed string`); + } + if (containsControlCharacter(value)) throw new RangeError(`${name} must not contain control characters`); + return value; +}; + +const finite = (value: unknown, name: string): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new RangeError(`${name} must be finite`); + } + return value; +}; + +const integerAtLeast = (value: unknown, minimum: number, name: string): number => { + const parsed = finite(value, name); + if (!Number.isInteger(parsed) || parsed < minimum) { + throw new RangeError(`${name} must be an integer >= ${minimum}`); + } + return parsed; +}; + +const nullableFinite = (value: unknown, name: string): number | null => ( + value === null ? null : finite(value, name) +); + +const nullableText = (value: unknown, name: string): string | null => ( + value === null ? null : stableText(value, name) +); + +const pair = (value: unknown, name: string): readonly [number, number] => { + if (!Array.isArray(value) || value.length !== 2) throw new RangeError(`${name} must contain two values`); + const result = [finite(value[0], `${name}[0]`), finite(value[1], `${name}[1]`)] as const; + if (!(result[0] < result[1])) throw new RangeError(`${name} must satisfy lower < upper`); + return Object.freeze(result); +}; + +const optionalPair = (value: unknown, name: string): readonly [number, number] | null => ( + value === null ? null : pair(value, name) +); + +const textArray = (value: unknown, name: string): readonly string[] => { + if (!Array.isArray(value)) throw new RangeError(`${name} must be an array`); + const parsed = value.map((entry, index) => stableText(entry, `${name}[${index}]`)); + if (new Set(parsed).size !== parsed.length) throw new RangeError(`${name} values must be unique`); + return Object.freeze(parsed); +}; + +const vocabulary = ( + value: unknown, allowed: readonly Value[], name: string, +): Value => { + const parsed = stableText(value, name); + if (!allowed.includes(parsed as Value)) throw new RangeError(`${name} is unsupported`); + return parsed as Value; +}; + +const parseDesign = (value: unknown): MorrisDesignProvenance => { + const item = asRecord(value, "Morris design"); + exactFields(item, DESIGN_FIELDS, "Morris design"); + const levels = integerAtLeast(item.levels, 4, "design levels"); + if (levels % 2 !== 0) throw new RangeError("design levels must be even"); + const normalizedStep = finite(item.normalized_step, "design normalized_step"); + const expectedStep = levels / (2 * (levels - 1)); + if (Math.abs(normalizedStep - expectedStep) > Number.EPSILON * 8) { + throw new RangeError("design normalized_step does not match the Morris grid"); + } + return Object.freeze({ + trajectories: integerAtLeast(item.trajectories, 1, "design trajectories"), + levels, + seed: integerAtLeast(item.seed, 0, "design seed"), + totalSamples: integerAtLeast(item.total_samples, 1, "design total_samples"), + normalizedStep, + }); +}; + +const parseSource = (value: unknown): MorrisSourceProvenance => { + const item = asRecord(value, "Morris source"); + exactFields(item, SOURCE_FIELDS, "Morris source"); + return Object.freeze({ + specId: stableText(item.spec_id, "source spec_id"), + variableKey: stableText(item.variable_key, "source variable_key"), + unit: stableText(item.unit, "source unit"), + bounds: pair(item.bounds, "source bounds"), + timeWindowS: optionalPair(item.time_window_s, "source time_window_s"), + pointIds: textArray(item.point_ids, "source point_ids"), + }); +}; + +const parseTarget = (value: unknown): MorrisTargetProvenance => { + const item = asRecord(value, "Morris target"); + exactFields(item, TARGET_FIELDS, "Morris target"); + const kind = vocabulary(item.kind, ["scalar", "state-point", "impact", "shot-outcome"] as const, "target kind"); + const pointId = nullableText(item.point_id, "target point_id"); + const coordinateFrame = nullableText(item.coordinate_frame, "target coordinate_frame"); + if (kind === "state-point" && (pointId === null || coordinateFrame === null)) { + throw new RangeError("state-point target requires point_id and coordinate_frame"); + } + return Object.freeze({ + name: stableText(item.name, "target name"), + unit: stableText(item.unit, "target unit"), + kind, + timeS: nullableFinite(item.time_s, "target time_s"), + pointId, + coordinateFrame, + }); +}; + +const parseEffects = (value: unknown): MorrisEffects => { + const item = asRecord(value, "Morris effects"); + exactFields(item, EFFECT_FIELDS, "Morris effects"); + const effects = { + mu: nullableFinite(item.mu, "effect mu"), + muStar: nullableFinite(item.mu_star, "effect mu_star"), + muStarStandardError: nullableFinite(item.mu_star_standard_error, "effect mu_star_standard_error"), + sigma: nullableFinite(item.sigma, "effect sigma"), + }; + for (const [name, metric] of [["mu_star", effects.muStar], ["mu_star_standard_error", effects.muStarStandardError], ["sigma", effects.sigma]] as const) { + if (metric !== null && metric < 0) throw new RangeError(`effect ${name} must be nonnegative`); + } + const nullCount = Object.values(effects).filter((metric) => metric === null).length; + if (nullCount !== 0 && nullCount !== 4) throw new RangeError("effect estimates must be all finite or all null"); + if (effects.mu !== null && effects.muStar !== null && effects.muStar < Math.abs(effects.mu)) { + throw new RangeError("effect mu_star must be at least the absolute mean effect"); + } + return Object.freeze(effects); +}; + +const parseDenominator = (value: unknown): MorrisDenominator => { + const item = asRecord(value, "Morris denominator"); + exactFields(item, DENOMINATOR_FIELDS, "Morris denominator"); + return Object.freeze({ + totalPairs: integerAtLeast(item.total_pairs, 0, "denominator total_pairs"), + validPairs: integerAtLeast(item.valid_pairs, 0, "denominator valid_pairs"), + typedNoImpactPairs: integerAtLeast(item.typed_no_impact_pairs, 0, "denominator typed_no_impact_pairs"), + noImpactUnavailablePairs: integerAtLeast(item.no_impact_unavailable_pairs, 0, "denominator no_impact_unavailable_pairs"), + failedPairs: integerAtLeast(item.failed_pairs, 0, "denominator failed_pairs"), + nonfinitePairs: integerAtLeast(item.nonfinite_pairs, 0, "denominator nonfinite_pairs"), + }); +}; + +const validateEstimate = (estimate: MorrisEstimate, trajectories: number): void => { + const denominator = estimate.denominator; + const exclusiveTotal = denominator.validPairs + denominator.noImpactUnavailablePairs + + denominator.failedPairs + denominator.nonfinitePairs; + if (denominator.totalPairs !== trajectories || exclusiveTotal !== denominator.totalPairs) { + throw new RangeError("Morris denominator invariant failed"); + } + if (denominator.noImpactUnavailablePairs > denominator.typedNoImpactPairs + || denominator.typedNoImpactPairs > denominator.totalPairs - denominator.failedPairs) { + throw new RangeError("Morris typed no-impact denominator invariant failed"); + } + const unavailable = estimate.availability === "insufficient-data"; + const allNull = estimate.effects.mu === null; + if (unavailable !== allNull || unavailable !== (estimate.sampleAdequacy === "insufficient")) { + throw new RangeError("Morris availability, adequacy, and null estimates disagree"); + } + if (estimate.sampleAdequacy === "adequate" && denominator.validPairs < 10) { + throw new RangeError("adequate Morris estimate requires at least ten valid pairs"); + } + if (estimate.sampleAdequacy === "limited" && (denominator.validPairs < 2 || denominator.validPairs >= 10)) { + throw new RangeError("limited Morris estimate requires two through nine valid pairs"); + } + validateMorrisMetrics(estimate.effects, estimate.availability, denominator.validPairs); +}; + +const parseEstimate = (value: unknown, trajectories: number): MorrisEstimate => { + const item = asRecord(value, "Morris estimate"); + exactFields(item, ESTIMATE_FIELDS, "Morris estimate"); + const estimate = Object.freeze({ + source: parseSource(item.source), + target: parseTarget(item.target), + effects: parseEffects(item.effects), + availability: vocabulary(item.availability, ["available", "constant-output", "insufficient-data"] as const, "availability"), + sampleAdequacy: vocabulary(item.sample_adequacy, ["adequate", "limited", "insufficient"] as const, "sample_adequacy"), + denominator: parseDenominator(item.denominator), + }); + validateEstimate(estimate, trajectories); + return estimate; +}; + +const validateReport = (report: MorrisReport): void => { + const sources = new Map(); + const targets = new Map(); + const pairsBySource = new Map>(); + for (const estimate of report.estimates) { + const sourceIdentity = JSON.stringify(estimate.source); + const previous = sources.get(estimate.source.specId); + if (previous !== undefined && previous !== sourceIdentity) throw new RangeError("source provenance changes within report"); + sources.set(estimate.source.specId, sourceIdentity); + const targetIdentity = JSON.stringify(estimate.target); + const previousTarget = targets.get(estimate.target.name); + if (previousTarget !== undefined && previousTarget !== targetIdentity) throw new RangeError("target provenance changes within report"); + targets.set(estimate.target.name, targetIdentity); + const sourceTargets = pairsBySource.get(estimate.source.specId) ?? new Set(); + if (sourceTargets.has(estimate.target.name)) throw new RangeError("source/target estimate pairs must be unique"); + sourceTargets.add(estimate.target.name); + pairsBySource.set(estimate.source.specId, sourceTargets); + } + const expectedSamples = report.design.trajectories * (sources.size + 1); + if (sources.size === 0 || report.design.totalSamples !== expectedSamples) { + throw new RangeError("design total_samples does not match trajectories and factor count"); + } + if (report.estimates.length !== sources.size * targets.size) { + throw new RangeError("Morris report must contain every source/target estimate pair"); + } +}; + +export function parseMorrisReport(value: unknown): MorrisReport { + const item = asRecord(value, "Morris report"); + exactFields(item, ROOT_FIELDS, "Morris report"); + if (item.schema_id !== MORRIS_REPORT_SCHEMA_ID) throw new RangeError("unsupported Morris schema ID"); + if (item.schema_version !== MORRIS_REPORT_SCHEMA_VERSION) throw new RangeError("unsupported Morris schema version"); + if (item.method !== MORRIS_METHOD) throw new RangeError("unsupported Morris method"); + if (!Array.isArray(item.assumptions) || !Array.isArray(item.estimates)) { + throw new RangeError("Morris assumptions and estimates must be arrays"); + } + const design = parseDesign(item.design); + const report = Object.freeze({ + schemaId: MORRIS_REPORT_SCHEMA_ID, + schemaVersion: MORRIS_REPORT_SCHEMA_VERSION, + method: MORRIS_METHOD, + design, + assumptions: textArray(item.assumptions, "Morris assumptions"), + interactionCaveat: stableText(item.interaction_caveat, "interaction_caveat"), + estimates: Object.freeze(item.estimates.map((estimate) => parseEstimate(estimate, design.trajectories))), + }); + if (report.assumptions.length === 0) throw new RangeError("Morris assumptions must not be empty"); + validateReport(report); + return report; +} + +export function parseMorrisReportJson(source: string): MorrisReport { + if (typeof source !== "string") throw new TypeError("Morris report JSON source must be a string"); + let value: unknown; + try { + value = JSON.parse(source) as unknown; + } catch (error: unknown) { + throw new SyntaxError(`Morris report must be valid JSON: ${error instanceof Error ? error.message : "parse failed"}`); + } + return parseMorrisReport(value); +} diff --git a/src/rate_of_closure/web/src/model/morrisMetricValidation.ts b/src/rate_of_closure/web/src/model/morrisMetricValidation.ts new file mode 100644 index 000000000..aac9c9b6f --- /dev/null +++ b/src/rate_of_closure/web/src/model/morrisMetricValidation.ts @@ -0,0 +1,93 @@ +/** Numerically safe scientific invariants for finite Morris report metrics. */ + +import type { MorrisAvailability, MorrisEffects } from "./morrisGlobalSensitivityContract"; + +const PRODUCER_CLAMP_EPSILON_MULTIPLIER = 64; +const IDENTITY_ROUNDING_EPSILON_MULTIPLIER = 32; +const MAX_SAFELY_SQUARED_METRIC = Math.sqrt(Number.MAX_VALUE); + +interface FiniteMetrics { + readonly mu: number; + readonly muStar: number; + readonly standardError: number; + readonly sigma: number; +} + +const finiteMetrics = (effects: MorrisEffects): FiniteMetrics | null => { + const { mu, muStar, muStarStandardError, sigma } = effects; + if (mu === null || muStar === null || muStarStandardError === null || sigma === null) return null; + return { mu, muStar, standardError: muStarStandardError, sigma }; +}; + +const requireSafeMagnitude = (metrics: FiniteMetrics): void => { + if (Object.values(metrics).some((metric) => Math.abs(metric) > MAX_SAFELY_SQUARED_METRIC)) { + throw new RangeError("Morris metrics must be safely squared finite values"); + } +}; + +const requireWireClamp = (metric: number, delta: number, name: string): void => { + if (metric > 0 && metric <= delta) { + throw new RangeError(`serialized ${name} must be zero or strictly above the producer clamp`); + } +}; + +const ordinaryDifferenceTolerance = (first: number, second: number): number => ( + IDENTITY_ROUNDING_EPSILON_MULTIPLIER * Number.EPSILON + * Math.max(Math.abs(first), Math.abs(second)) +); + +const normalizedIdentity = (metrics: FiniteMetrics, sampleCount: number, delta: number): void => { + const scale = Math.max( + Math.abs(metrics.mu), metrics.muStar, metrics.standardError, metrics.sigma, delta, + ); + const mu = metrics.mu / scale; + const muStar = metrics.muStar / scale; + const standardError = metrics.standardError / scale; + const sigma = metrics.sigma / scale; + const normalizedDelta = delta / scale; + const correction = sampleCount / (sampleCount - 1); + const sigmaTerm = sigma ** 2; + const standardErrorTerm = sampleCount * standardError ** 2; + const muStarTerm = correction * muStar ** 2; + const muTerm = correction * mu ** 2; + const residual = sigmaTerm - standardErrorTerm - muStarTerm + muTerm; + const termScale = Math.abs(sigmaTerm) + Math.abs(standardErrorTerm) + + Math.abs(muStarTerm) + Math.abs(muTerm); + const rounding = IDENTITY_ROUNDING_EPSILON_MULTIPLIER * Number.EPSILON * termScale; + const sigmaClamp = metrics.sigma === 0 ? normalizedDelta ** 2 : 0; + const standardErrorClamp = metrics.standardError === 0 + ? sampleCount * normalizedDelta ** 2 : 0; + if (Math.abs(residual) > rounding + sigmaClamp + standardErrorClamp) { + throw new RangeError("Morris metric identity is inconsistent with valid_pairs"); + } +}; + +/** Validate realizability and the producer's exact zero-clamp wire semantics. */ +export function validateMorrisMetrics( + effects: MorrisEffects, availability: MorrisAvailability, sampleCount: number, +): void { + const metrics = finiteMetrics(effects); + if (metrics === null) return; + requireSafeMagnitude(metrics); + if (metrics.muStar === 0) { + if (metrics.mu !== 0 || metrics.sigma !== 0 || metrics.standardError !== 0 + || availability !== "constant-output") { + throw new RangeError("zero mu_star requires zero metrics and constant-output availability"); + } + return; + } + if (availability === "constant-output") throw new RangeError("constant-output Morris effects must be zero"); + const delta = PRODUCER_CLAMP_EPSILON_MULTIPLIER * Number.EPSILON * Math.max(1, metrics.muStar); + requireWireClamp(metrics.sigma, delta, "sigma"); + requireWireClamp(metrics.standardError, delta, "standard error"); + const meanDifference = Math.abs(metrics.muStar - Math.abs(metrics.mu)); + const meanTolerance = ordinaryDifferenceTolerance(metrics.muStar, Math.abs(metrics.mu)); + if (meanDifference <= meanTolerance && metrics.standardError === 0 + && metrics.sigma > Math.sqrt(sampleCount) * delta + meanTolerance) { + throw new RangeError("Morris metric clamp-scale degeneracy is inconsistent"); + } + if (metrics.sigma === 0 && (metrics.standardError !== 0 || meanDifference > meanTolerance)) { + throw new RangeError("zero-sigma Morris metric relationship failed"); + } + normalizedIdentity(metrics, sampleCount, delta); +} diff --git a/src/shared/python/swing_sim/variation/__init__.py b/src/shared/python/swing_sim/variation/__init__.py index 6ae5f51d5..c1b66083a 100644 --- a/src/shared/python/swing_sim/variation/__init__.py +++ b/src/shared/python/swing_sim/variation/__init__.py @@ -41,7 +41,13 @@ compute_position_dispersion_view, find_low_variability_intervals, ) -from .global_sensitivity import MorrisEstimate, MorrisReport, analyze_morris +from .global_sensitivity import ( + MORRIS_REPORT_SCHEMA_ID, + MORRIS_REPORT_SCHEMA_VERSION, + MorrisEstimate, + MorrisReport, + analyze_morris, +) from .group_spec import PerturbationGroup from .morris_design import ( MorrisDesign, @@ -102,6 +108,8 @@ "MorrisObservations", "MorrisOutput", "MorrisReport", + "MORRIS_REPORT_SCHEMA_ID", + "MORRIS_REPORT_SCHEMA_VERSION", "NoiseSpec", "OutputStats", "PairedIntervention", diff --git a/src/shared/python/swing_sim/variation/global_sensitivity.py b/src/shared/python/swing_sim/variation/global_sensitivity.py index 4759ba09c..ce3ba83b3 100644 --- a/src/shared/python/swing_sim/variation/global_sensitivity.py +++ b/src/shared/python/swing_sim/variation/global_sensitivity.py @@ -25,6 +25,8 @@ ) _ADEQUATE_TRAJECTORIES = 10 +MORRIS_REPORT_SCHEMA_ID = "swing-sim/morris-global-sensitivity-report" +MORRIS_REPORT_SCHEMA_VERSION = 1 @dataclass(frozen=True) @@ -89,7 +91,8 @@ def estimate(self, spec_id: str, output_name: str) -> MorrisEstimate: def to_json_dict(self) -> dict[str, Any]: """Return a deterministic, non-finite-safe cross-runtime document.""" return { - "schema_version": 1, + "schema_id": MORRIS_REPORT_SCHEMA_ID, + "schema_version": MORRIS_REPORT_SCHEMA_VERSION, "method": self.method, "design": { "trajectories": self.trajectories, @@ -348,6 +351,8 @@ def _require_effect_count(value: object) -> int: __all__ = [ + "MORRIS_REPORT_SCHEMA_ID", + "MORRIS_REPORT_SCHEMA_VERSION", "MorrisEstimate", "MorrisReport", "analyze_morris", diff --git a/src/shared/python/swing_sim/variation/tests/test_global_sensitivity.py b/src/shared/python/swing_sim/variation/tests/test_global_sensitivity.py index af9497a2c..c78efb5f2 100644 --- a/src/shared/python/swing_sim/variation/tests/test_global_sensitivity.py +++ b/src/shared/python/swing_sim/variation/tests/test_global_sensitivity.py @@ -11,6 +11,8 @@ from rate_of_closure.variation.simulation_types import TrialEvaluationStatus from shared.python.swing_sim.variation import ( + MORRIS_REPORT_SCHEMA_ID, + MORRIS_REPORT_SCHEMA_VERSION, MorrisDesign, MorrisFactor, MorrisObservations, @@ -269,4 +271,6 @@ def test_cross_runtime_golden_fixture_matches_serialized_report() -> None: expected = json.loads(fixture_path.read_text(encoding="utf-8")) + assert expected["schema_id"] == MORRIS_REPORT_SCHEMA_ID + assert expected["schema_version"] == MORRIS_REPORT_SCHEMA_VERSION assert report.to_json_dict() == expected