From a28d9cf8d39d6333e3b8336911eb29e784f09c99 Mon Sep 17 00:00:00 2001 From: "Joshua C. Macdonald" Date: Thu, 22 Oct 2026 09:04:46 -0400 Subject: [PATCH] Add qualified local information feasibility screens --- .github/workflows/ci.yml | 48 +++- CHANGELOG.md | 10 + docs/api/fisher.md | 13 + docs/guide/capabilities.md | 1 + docs/guide/execution-roadmap.md | 11 +- docs/guide/fisher.md | 147 +++++++++++ examples/fisher_op_stack.py | 155 ++++++++++++ justfile | 10 +- mkdocs.yml | 2 + src/trade_study/__init__.py | 12 + src/trade_study/fisher.py | 421 ++++++++++++++++++++++++++++++++ tests/test_documentation.py | 16 ++ tests/test_fisher.py | 295 ++++++++++++++++++++++ 13 files changed, 1132 insertions(+), 9 deletions(-) create mode 100644 docs/api/fisher.md create mode 100644 docs/guide/fisher.md create mode 100644 examples/fisher_op_stack.py create mode 100644 src/trade_study/fisher.py create mode 100644 tests/test_fisher.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7faebe5..9e84c54 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,14 +27,14 @@ jobs: examples/serosurvey_study.py examples/serosurvey_study.ipynb examples/serosurvey_contexts.py examples/serosurvey_frames.py examples/replicated_study.py - examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py + examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py examples/fisher_op_stack.py - name: Lint run: >- uv run ruff check --preview src tests examples/serosurvey_study.py examples/serosurvey_study.ipynb examples/serosurvey_contexts.py examples/serosurvey_frames.py examples/replicated_study.py - examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py + examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py examples/fisher_op_stack.py - name: Type check (strict) run: uv run mypy --strict src - name: Build documentation (strict) @@ -124,10 +124,48 @@ jobs: just build-all uv run --no-sync python -c 'from pathlib import Path; import trade_study; assert Path(trade_study.__file__).resolve().is_relative_to(Path("src").resolve())' + fisher-numpy-minimum: + name: Fisher screen (NumPy 1.24 CPU) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + python-version: '3.11' + - name: Install core at its minimum NumPy version + run: | + uv venv + uv pip install -e . 'numpy==1.24.0' pytest + - name: Qualify the bounded core and complete guide + run: >- + uv run --no-sync python -m pytest tests/test_fisher.py + tests/test_documentation.py::test_complete_fisher_guide_preserves_analytic_designs_and_denominators + + fisher-op-stack: + name: Fisher information artifact (NumPy CPU) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + with: + python-version: '3.12' + - name: Install reviewed public producers and this consumer + run: | + uv venv + uv pip install -e . \ + 'op-system @ git+https://github.com/ACCIDDA/op_system@bbde00697428f16f6cf948e7aff207f7b65231c8' \ + 'op-engine @ git+https://github.com/ACCIDDA/op_engine@5f7dd592e97a849bca5ccad348ea66e935187f18' \ + 'op-infer @ git+https://github.com/ACCIDDA/op_infer@713a2b76119e174dab75290eaf5bff9ffce63af6' + - name: Qualify actual measured information and declared directions + env: + OPENBLAS_NUM_THREADS: '1' + OMP_NUM_THREADS: '1' + run: uv run --no-sync python examples/fisher_op_stack.py + ci: name: ci if: always() - needs: [lint, test, examples, wheel] + needs: [lint, test, examples, wheel, fisher-numpy-minimum, fisher-op-stack] runs-on: ubuntu-latest steps: - name: Require every validation job to succeed @@ -136,8 +174,12 @@ jobs: TEST_RESULT: ${{ needs.test.result }} EXAMPLES_RESULT: ${{ needs.examples.result }} WHEEL_RESULT: ${{ needs.wheel.result }} + FISHER_RESULT: ${{ needs.fisher-op-stack.result }} + FISHER_MINIMUM_RESULT: ${{ needs.fisher-numpy-minimum.result }} run: | test "$LINT_RESULT" = success test "$TEST_RESULT" = success test "$EXAMPLES_RESULT" = success test "$WHEEL_RESULT" = success + test "$FISHER_RESULT" = success + test "$FISHER_MINIMUM_RESULT" = success diff --git a/CHANGELOG.md b/CHANGELOG.md index d7a2e3f..8dce95a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### Added + +- Bounded local Fisher-screen adapter for caller-qualified host information + matrices, explicit coordinates/target directions, existing feasibility + constraints, distinct unavailable/failed outcomes and planned-ID counts. + Export primitive JSON context/policy/evidence without new dependencies. + ### Documentation +- Separate computational SBC, effect recovery, predictive adequacy and simulation + support. Document PPC reference-law, conditional-denominator and sampling limits. + - Record execution ownership, host-result boundaries, Fisher-screen integration requirements and separate qualification targets for penalized stacking and EVSI. diff --git a/docs/api/fisher.md b/docs/api/fisher.md new file mode 100644 index 0000000..c87ecf7 --- /dev/null +++ b/docs/api/fisher.md @@ -0,0 +1,13 @@ +# Local information screening + +See the [complete example and qualification scope](../guide/fisher.md). + +::: trade_study.FisherContext + +::: trade_study.FisherScreenPolicy + +::: trade_study.FisherScreenResult + +::: trade_study.screen_fisher + +::: trade_study.fisher_screen_counts diff --git a/docs/guide/capabilities.md b/docs/guide/capabilities.md index b961fd3..2f4bc86 100644 --- a/docs/guide/capabilities.md +++ b/docs/guide/capabilities.md @@ -14,6 +14,7 @@ scoring, optimization and plotting; statistical estimators belong to the caller. | Submit frozen Python tasks | `slurm.freeze_checkout`, `prepare_slurm_plan`, `SlurmTask` | [Experimental Slurm launch layer](slurm.md); Git/POSIX/shared environment, checked source/imports and task receipts. Runner integration remains explicit. | | Compare paired designs | `paired_difference`, `paired_rank` | Core bootstrap; t intervals need SciPy. Nonfinite pairs are excluded and `n_pairs` is reported. | | Allocate replication | `run_sequential`, `ReplicationPolicy` | Fixed or adaptive policy with recorded bounds, counts and assumptions; power is not automatically certified. | +| Screen local information | `screen_fisher`, `FisherScreenPolicy`, `fisher_screen_counts` | Caller-qualified bounded host matrices, declared directions, existing constraints and retained unavailable outcomes; [scope and example](fisher.md). | | Plan fixed proportion precision | `proportion_interval`, `replicates_for_half_width` | [Proportions guide](proportion-intervals.md); Wilson precision is not a power calculation or sequential interval. | | Select nondominated alternatives | `extract_front`, `pareto_rank`, `hypervolume`, `igd_plus` | `pareto`; positive objective scaling preserves front membership. | | Report decisions across preferences | `preference_sweep`, `PreferencePolicy` | Regret, practical equivalence and optional paired uncertainty; preferences are specified by the caller. | diff --git a/docs/guide/execution-roadmap.md b/docs/guide/execution-roadmap.md index dadf289..3457877 100644 --- a/docs/guide/execution-roadmap.md +++ b/docs/guide/execution-roadmap.md @@ -22,8 +22,9 @@ failures and stable task identities regardless of a simulator's backend. ## Fisher feasibility screening -A future Fisher screen can supply a scalar result to existing constraint and -replication machinery. Consume an explicit information artifact or caller-owned +The [bounded Fisher adapter](fisher.md) supplies a scalar result to existing +constraint machinery and preserves unavailable outcomes. It consumes an explicit +information artifact or caller-owned screen instead of adding a compiler/inference dependency to the core. The artifact records parameter coordinates/scales, observation noise, design, nuisance treatment, reference parameter regime and numerical tolerances. @@ -35,6 +36,12 @@ Local numerical rank is a screen, not a structural-identifiability proof. Estima screen uncertainty needs an actual sampling/replication contract before a probabilistic feasibility claim. +The implemented fixed-replicate allocation and finite-horizon bounded-score +methods remain available. Recoverable failed-attempt artifacts and gate-specific +power/false-failure qualification retain their separate scope. The Fisher guide +also distinguishes SBC, effect recovery, predictive checks and simulation-support +evidence; each needs its own declared reference and denominator. + ## Predictive stacking and dimension penalties The existing Bayesian path delegates to ArviZ predictive stacking. Preserve that diff --git a/docs/guide/fisher.md b/docs/guide/fisher.md new file mode 100644 index 0000000..d17d216 --- /dev/null +++ b/docs/guide/fisher.md @@ -0,0 +1,147 @@ +# Local information feasibility screens + +`screen_fisher` consumes a caller-qualified information matrix and exposes a +local minimum-eigenvalue screen through the existing `Constraint` API. Core +dependencies remain NumPy-only. The inference layer or project supplies measured +output sensitivities, the observation/noise model, coordinate transformations and +any nuisance profiling. For example, OP Infer's bounded Gaussian information +adapter can supply its explicitly transferred host matrix; its qualification +scope and evidence references travel with the screen. + +The optional `examples/fisher_op_stack.py` exercises the actual compiler, +forward sensitivity solver and Gaussian information adapter against an +independent exponential-decay derivative, including an initial-state parameter +and direct measurement parameter. A separate required NumPy CPU CI job installs +immutable reviewed public producer commits and runs that example. Those packages +remain optional for users of the core screen; no device qualification is implied. + +For independent unit-variance measurements of `a + b*x`, a balanced design at +`x=-1,+1` has information `diag(n,n)`. The following complete example compares +two designs against a prespecified threshold. The Hessian of the Gaussian +negative log likelihood gives this reference independently of the screen. + +```python +import numpy as np +from trade_study import ( + Direction, FisherContext, FisherScreenPolicy, Observable, ResultsTable, + fisher_screen_counts, screen_fisher, +) + +def context(n): + return FisherContext( + parameter_names=("a", "b"), + parameter_coordinates="physical a,b; dimensionless", + parameter_scales=(1.0, 1.0), + observation_model="independent Gaussian, known variance=1", + design=f"{n} balanced measurements at x=-1,+1", + reference_regime="linear mean; all a,b", + nuisance_treatment="none; both coefficients targeted", + sensitivity_reference="analytic d(a+b*x)/d(a,b)=(1,x)", + numerical_reference="exact design; host float64 algebra", + qualification_reference="analytic Gaussian likelihood Hessian", + information_reference=f"retained synthetic design n={n}", + ) + +policy = FisherScreenPolicy(threshold=4.0) +records = { + str(n): screen_fisher(np.eye(2) * n, context=context(n), policy=policy) + for n in (2, 8) +} +table = ResultsTable( + configs=[{"n": n} for n in (2, 8)], + scores=np.array([[records[str(n)].as_scores()["fisher_min_eigenvalue"]] + for n in (2, 8)]), + observable_names=["fisher_min_eigenvalue"], + metadata=[{"fisher_screen": records[str(n)].to_metadata()} for n in (2, 8)], +) +observable = Observable("fisher_min_eigenvalue", Direction.MAXIMIZE) +assert table.feasible([policy.constraint()]).tolist() == [False, True] + +# Each unavailable attempt keeps its identity and reason. +records["missing-artifact"] = screen_fisher(None, context=context(8), policy=policy) +records["failed-solve"] = screen_fisher( + None, context=context(8), policy=policy, failure="upstream solve failed" +) +counts = fisher_screen_counts( + records, planned_ids=["2", "8", "missing-artifact", "failed-solve", "pending"] +) +assert counts == dict(planned=5, feasible=1, infeasible=1, + unreadable=1, failed=1, missing=1) +``` + +`to_metadata()` returns detached primitive JSON for existing result bundles. +Retain the per-attempt records and complete planned IDs alongside any scored +table. The two numeric rows above are not the denominator for all five planned +attempts. `as_scores()` raises for unreadable/failed outcomes, so the generic +runner follows its existing failure/checkpoint policy rather than silently +averaging them away. These outcome counts alone supply no probability or gate. + +## Coordinates, targets and numerical limits + +The input matrix is already expressed in its declared coordinates and scales. +This adapter never rescales it automatically. Thresholds have meaning in those +coordinates; a change of units requires a correspondingly qualified policy. +Context references declare the observation law/noise, design, parameter regime, +nuisance convention and sensitivity/numerical/qualification evidence. The caller +retains and verifies those artifacts. The recorded matrix digest identifies the +actual float64 host snapshot, independently of the caller's reference labels. + +An optional p-by-k `directions` basis has orthonormal columns, checked with +absolute and relative tolerances of `1e-10`. The tested matrix is `B.T @ F @ B`, +with the complementary directions fixed. This is useful for explicitly declared +identifiable perturbations of a singular nominal matrix. For observations of +`a+b`, the normalized `(1,1)` direction has information while `(1,-1)` does not. +The caller must justify which targets and gauge directions this represents. + +Directional restriction does not perform nuisance elimination. For example, +`F=[[1,1],[1,2]]` has conditional information 1 for its first coordinate, while +profiling the second coordinate gives `1 - 1*(1/2)*1 = 0.5`. Supply the caller's +profiled matrix and its distinct context when that is the intended calculation. + +The default dense dimension limit is 128 parameters. Inputs are real NumPy host +arrays; a device-to-host transfer is an explicit caller boundary. Matrix and +eigensolver storage remain quadratic in dimension; the limit does not bound +whole-process or AD/compiler memory. No sensitivity array is retained here. + +Invalid shape/dtype, nonfinite, asymmetric, indefinite or unsupported matrices +produce unreadable records. Upstream/eigensolver failures produce failed records. +Symmetry and positive-semidefiniteness use +`atol + rtol * max(abs(F))`. Accepted near-symmetric matrices are averaged with +their transpose. Actual tested eigenvalues are retained; tiny negative values +are not clipped and comparison with the threshold is inclusive and exact on the +computed value. Numerical tolerances do not relax the decision threshold. + +## Qualification beyond a screen + +A local information screen helps shortlist designs. A design decision also needs +declared simulation/refitting targets, numerical checks and relevant recovery +and predictive evidence. In particular: + +- SBC tests the selected posterior computations under a declared generator and + design. Finite non-rejection supports “no miscalibration detected”. Ordinary + parameter ranks can miss data-ignoring estimators; appropriately chosen + data-dependent quantities improve sensitivity ([Modrák et al.](https://arxiv.org/abs/2211.02383)). +- Effect recovery uses the actual reporting rule and design. Define signed + effects in a null-centred coordinate. Type S and Type M condition on reporting; + zero decisions leave them unavailable. At zero effect, report false-support + rather than an undefined magnitude ratio. Preserve failures and conditional + denominators ([Gelman and Carlin](https://sites.stat.columbia.edu/gelman/research/published/retropower_final.pdf)). +- Predictive checks examine the relevant observed features and held-out units. + Ordinary in-sample posterior predictive probabilities need not be uniform. + Randomized ties address atoms; a rejection gate still needs a qualified + reference distribution and dependence/multiplicity treatment + ([Gelman](https://sites.stat.columbia.edu/gelman/research/published/ppc_understand3.pdf)). +- Amortized estimators also need checks of the observed data against their + training-simulation distribution. A simulator-generated SBC bank does not + directly test that observed-data discrepancy. + +Repeated information matrices estimate a probability only under a declared +sampling law. A confidence argument on a deterministic matrix creates no such +law. Use [fixed-sample proportions](proportion-intervals.md) for their supported +Bernoulli assumptions, or the [sequential runner](../api/runner.md) for its +bounded-score allocation contract. Keep planned attempts, failed and missing +results, independent/cluster units and the stopping rule explicit. Interval +precision, gate power and false-failure rates each require their own evidence. + +General Fisher families, nuisance profiling, global structural-identifiability +analysis, posterior adequacy and EVSI retain their caller/inference-layer scope. diff --git a/examples/fisher_op_stack.py b/examples/fisher_op_stack.py new file mode 100644 index 0000000..0280d71 --- /dev/null +++ b/examples/fisher_op_stack.py @@ -0,0 +1,155 @@ +"""Qualify the host screen with actual compiler, solver and information artifacts. + +This optional example requires reviewed op_system/op_engine/op_infer sources. +It uses only synthetic NumPy CPU inputs and performs no statistical fit. +""" + +from __future__ import annotations + +import json +from importlib.metadata import version + +import numpy as np +from op_engine.core_solver import RunConfig +from op_engine.sensitivities import ( + SensitivityInitial, + SensitivityOptions, + SensitivitySystem, + solve_sensitivities, +) +from op_infer.adapters.op import structural_model_reference +from op_infer.adapters.op_identify import ( + information_from_sensitivities, + sensitivity_execution_reference, +) +from op_infer.identify import InformationContext +from op_infer.observe import IIDObservation +from op_infer.posterior import ArtifactReference +from op_system import compile_spec, symbolic_derivatives + +from trade_study import FisherContext, FisherScreenPolicy, screen_fisher + + +def reference(label: str) -> ArtifactReference: + """Identify a retained synthetic fixture declaration. + + Returns: + Content identity of the supplied fixture label. + """ + return ArtifactReference.from_bytes(label.encode(), kind="synthetic-fisher-v1") + + +def main() -> None: # ruff: ignore[too-many-locals] -- complete qualification fixture + """Check measured sensitivities and both nominal/targeted screen outcomes. + + Raises: + ValueError: If actual artifact screens disagree with the reference. + """ + compiled = compile_spec({ + "kind": "expr", + "state": ["x", "z"], + "equations": {"x": "-rate*x", "z": "-rate*z"}, + "observables": {"measurement": {"expr": "gain*(x+2*z)"}}, + }) + system = SensitivitySystem( + compiled.eval_fn, + symbolic_derivatives(compiled), + initial_parameters=("amplitude",), + ) + initial = SensitivityInitial( + np.array([2.0, 4.0]), np.array([[0.0, 0.0, 1.0], [0.0, 0.0, 2.0]]) + ) + parameters = { + name: np.asarray(value) + for name, value in zip(system.parameter_names, (0.7, 1.5, 2.0), strict=True) + } + times = np.array([0.0, 0.5, 1.0]) + solution = solve_sensitivities( + system, + parameters, + initial, + times, + options=SensitivityOptions( + store_history=True, + solver=RunConfig(method="rk4", fixed_max_step=0.0625), + ), + ) + observation = IIDObservation(family="normal", scale=0.4, slope=2.0, offset=1.0) + native_context = InformationContext( + source_model=structural_model_reference(compiled), + design=reference("times=0,.5,1; all observed"), + parameter_point=reference("rate=.7,gain=1.5,amplitude=2"), + initialization=reference("y0=amplitude*(1,2)"), + observation=observation.reference, + numerical=sensitivity_execution_reference(solution), + qualification=reference("independent exponential measured derivative"), + parameter_names=solution.parameter_names, + parameter_units=("1/time", "measurement/state", "state"), + observable_names=solution.observable_names, + ) + information = information_from_sensitivities( + solution, observation, context=native_context + ) + # y_measured = 2*gain*5*amplitude*exp(-rate*t)+1; divide by sigma=.4. + decay = np.exp(-0.7 * times) + jacobian = np.stack((-15 * times * decay, 10 * decay, 7.5 * decay), axis=1) + expected = 25 * jacobian.T @ jacobian + np.testing.assert_allclose(information.matrix, expected, rtol=3e-7, atol=1e-6) + ctx = FisherContext( + parameter_names=solution.parameter_names, + parameter_coordinates="physical rate,gain,amplitude; units in native context", + parameter_scales=(1.0, 1.0, 1.0), + observation_model=observation.reference.model_dump_json(), + design=native_context.design.model_dump_json(), + reference_regime=native_context.parameter_point.model_dump_json(), + nuisance_treatment="complement fixed; gain/amplitude product direction", + sensitivity_reference=native_context.source_model.model_dump_json(), + numerical_reference=native_context.numerical.model_dump_json(), + qualification_reference=native_context.qualification.model_dump_json(), + information_reference=reference( + "retained actual information from measured sensitivities" + ).model_dump_json(), + ) + policy = FisherScreenPolicy(1.0) + nominal = screen_fisher(information.matrix, context=ctx, policy=policy) + basis = np.array([[1.0, 0.0], [0.0, 0.8], [0.0, 0.6]]) + targeted = screen_fisher( + information.matrix, + context=ctx, + policy=FisherScreenPolicy( + 1.0, directions=tuple(tuple(float(x) for x in row) for row in basis) + ), + ) + np.testing.assert_allclose( + targeted.eigenvalues, np.linalg.eigvalsh(basis.T @ expected @ basis), rtol=3e-7 + ) + if nominal.status != "infeasible" or targeted.status != "feasible": + msg = "Actual information artifacts failed the declared screen reference" + raise ValueError(msg) + print( + json.dumps( + { + "packages": { + name: version(name) + for name in ( + "numpy", + "op-system", + "op-engine", + "op-infer", + "trade-study", + ) + }, + "nominal": nominal.to_metadata(), + "targeted": targeted.to_metadata(), + "maximum_information_error": float( + np.max(np.abs(information.matrix - expected)) + ), + }, + allow_nan=False, + sort_keys=True, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/justfile b/justfile index f31da68..3885849 100644 --- a/justfile +++ b/justfile @@ -6,16 +6,16 @@ default: lint test # Auto-format all Python sources. format: - uv run ruff format --preview src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py + uv run ruff format --preview src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py examples/fisher_op_stack.py # Lint-fix all Python sources. check: - uv run ruff check --preview --fix src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py + uv run ruff check --preview --fix src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py examples/fisher_op_stack.py # Check formatting + lint without modifying files (matches CI). lint: - uv run ruff format --preview --check src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py - uv run ruff check --preview src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py + uv run ruff format --preview --check src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py examples/fisher_op_stack.py + uv run ruff check --preview src tests examples/replicated_study.py examples/illustration_artifacts.py examples/illustrate_replicated_study.py examples/frozen_slurm.py examples/fisher_op_stack.py # ── Tests ──────────────────────────────────────────────────────────── @@ -57,6 +57,8 @@ build-test: uv pip install --python "$CLEANROOM/venv/bin/python" --no-cache "$CLEANROOM/dist/$(ls "$CLEANROOM/dist/" | grep '\.whl$')[test]" cp -R tests examples "$CLEANROOM/" cp README.md "$CLEANROOM/README.md" + mkdir -p "$CLEANROOM/docs/guide" + cp docs/guide/fisher.md "$CLEANROOM/docs/guide/" cd "$CLEANROOM" unset PYTHONPATH "$CLEANROOM/venv/bin/python" -c 'import trade_study; assert "site-packages" in trade_study.__file__' diff --git a/mkdocs.yml b/mkdocs.yml index 12f3354..7937af3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -73,6 +73,7 @@ nav: - Choosing Ensemble Weights: guide/stacking.md - Result Bundles: guide/result-bundles.md - Fixed-sample Proportions: guide/proportion-intervals.md + - Local Information Screens: guide/fisher.md - Frozen Slurm Launch Plans: guide/slurm.md - API Reference: - Protocols: api/protocols.md @@ -82,6 +83,7 @@ nav: - Adaptive Sessions: api/session.md - Paired Comparisons: api/paired.md - Proportion Intervals: api/proportions.md + - Local Information Screens: api/fisher.md - Decision Summaries: api/decision.md - Post-hoc Sensitivity: api/sensitivity.md - Study: api/study.md diff --git a/src/trade_study/__init__.py b/src/trade_study/__init__.py index 6278af5..3292e04 100644 --- a/src/trade_study/__init__.py +++ b/src/trade_study/__init__.py @@ -25,6 +25,13 @@ unit_to_value, value_to_unit, ) +from .fisher import ( + FisherContext, + FisherScreenPolicy, + FisherScreenResult, + fisher_screen_counts, + screen_fisher, +) from .io import load_results, save_results from .paired import PairedDifference, paired_difference, paired_rank from .proportions import ( @@ -82,6 +89,9 @@ "Factor", "FactorConstraint", "FactorType", + "FisherContext", + "FisherScreenPolicy", + "FisherScreenResult", "FrozenCheckout", "Observable", "PairedDifference", @@ -113,6 +123,7 @@ "ensemble_predict", "extract_front", "feasibility_filter", + "fisher_screen_counts", "fit_regime_surrogate", "fit_surrogate", "freeze_checkout", @@ -144,6 +155,7 @@ "save_results", "score", "screen", + "screen_fisher", "sensitivity_from_table", "sobol_indices", "stack_bayesian", diff --git a/src/trade_study/fisher.py b/src/trade_study/fisher.py new file mode 100644 index 0000000..efdd2be --- /dev/null +++ b/src/trade_study/fisher.py @@ -0,0 +1,421 @@ +"""Bounded host-side feasibility screening of caller-qualified information.""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +from math import isfinite +from numbers import Integral, Real +from typing import TYPE_CHECKING, Literal + +import numpy as np + +from .protocols import Constraint + +if TYPE_CHECKING: + from collections.abc import Mapping, Sequence + + from numpy.typing import NDArray + +ScreenStatus = Literal["feasible", "infeasible", "unreadable", "failed"] + + +def _label(value: str) -> bool: + return isinstance(value, str) and bool(value.strip()) + + +def _nonnegative(value: float) -> bool: + return ( + not isinstance(value, bool) + and isinstance(value, Real) + and isfinite(value) + and value >= 0 + ) + + +@dataclass(frozen=True) +class FisherContext: + """Caller-owned meaning and qualification of an information matrix. + + All references are nonempty caller identifiers whose underlying evidence + must be retained. They are declarations, not automatically verified hashes. + The supplied matrix is already in ``parameter_coordinates`` with the + recorded ``parameter_scales``; this adapter applies no further scaling. + + Attributes: + parameter_names: Ordered matrix coordinates, distinct and nonempty. + parameter_coordinates: Coordinate/transform definition and units. + parameter_scales: Positive finite scales already used by the caller. + observation_model: Measurement law, noise and observation definitions. + design: Experimental design identity. + reference_regime: Parameter point or declared sampled regime. + nuisance_treatment: Explicit conditioning/profiling convention. + sensitivity_reference: Measured-output sensitivity source. + numerical_reference: Solver, derivative and numerical settings. + qualification_reference: Independent qualification and its scope. + information_reference: Identity of the caller's information artifact. + """ + + parameter_names: tuple[str, ...] + parameter_coordinates: str + parameter_scales: tuple[float, ...] + observation_model: str + design: str + reference_regime: str + nuisance_treatment: str + sensitivity_reference: str + numerical_reference: str + qualification_reference: str + information_reference: str + + def __post_init__(self) -> None: + """Detach coordinate sequences and require explicit context. + + Raises: + ValueError: If names, scales or context references are invalid. + """ + names, scales = tuple(self.parameter_names), tuple(self.parameter_scales) + references = ( + self.parameter_coordinates, + self.observation_model, + self.design, + self.reference_regime, + self.nuisance_treatment, + self.sensitivity_reference, + self.numerical_reference, + self.qualification_reference, + self.information_reference, + ) + valid_names = ( + not isinstance(self.parameter_names, str) + and bool(names) + and all(_label(name) for name in names) + and len(set(names)) == len(names) + ) + valid_scales = len(scales) == len(names) and all( + _nonnegative(scale) and scale > 0 for scale in scales + ) + if ( + not valid_names + or not valid_scales + or not all(_label(r) for r in references) + ): + msg = ( + "Fisher context requires distinct names, positive scales and references" + ) + raise ValueError(msg) + object.__setattr__(self, "parameter_names", names) + object.__setattr__(self, "parameter_scales", tuple(float(s) for s in scales)) + + def to_metadata(self) -> dict[str, object]: + """Return detached primitive JSON context. + + Returns: + Explicit coordinates and caller-owned evidence references. + """ + return { + "parameter_names": list(self.parameter_names), + "parameter_coordinates": self.parameter_coordinates, + "parameter_scales": list(self.parameter_scales), + "observation_model": self.observation_model, + "design": self.design, + "reference_regime": self.reference_regime, + "nuisance_treatment": self.nuisance_treatment, + "sensitivity_reference": self.sensitivity_reference, + "numerical_reference": self.numerical_reference, + "qualification_reference": self.qualification_reference, + "information_reference": self.information_reference, + } + + +@dataclass(frozen=True) +class FisherScreenPolicy: + """Prespecified local minimum-eigenvalue screen. + + Attributes: + threshold: Required minimum eigenvalue, finite and nonnegative. + directions: Optional row-major p-by-k orthonormal direction basis in + the matrix coordinates. The tested matrix is B.T @ F @ B. + Its complement is fixed; this operation does not profile nuisance + parameters. None tests all directions. + atol: Absolute symmetry and positive-semidefiniteness tolerance. + rtol: Relative symmetry and positive-semidefiniteness tolerance. + max_parameters: Dense matrix dimension limit, default 128. This is + not a whole-process memory bound. + """ + + threshold: float + directions: tuple[tuple[float, ...], ...] | None = None + atol: float = 1e-10 + rtol: float = 1e-8 + max_parameters: int = 128 + + def __post_init__(self) -> None: + """Validate the policy before evaluating any design. + + Raises: + ValueError: If bounds or the orthonormal basis are invalid. + """ + if ( + not all(_nonnegative(v) for v in (self.threshold, self.atol, self.rtol)) + or isinstance(self.max_parameters, bool) + or not isinstance(self.max_parameters, Integral) + or self.max_parameters < 1 + ): + msg = ( + "Fisher policy requires nonnegative finite bounds and a positive limit" + ) + raise ValueError(msg) + if self.directions is not None: + basis = np.asarray(self.directions) + valid_shape = ( + basis.ndim == 2 + and 0 < basis.shape[1] <= basis.shape[0] <= self.max_parameters + and basis.dtype.kind in "iuf" + ) + if not valid_shape: + msg = "directions must be a finite real p-by-k orthonormal basis" + raise ValueError(msg) + # Validate norms in float64, avoiding integer Gram-matrix overflow. + with np.errstate(over="ignore", invalid="ignore"): + basis = np.asarray(basis, dtype=np.float64) + gram = basis.T @ basis + if ( + not np.isfinite(basis).all() + or not np.isfinite(gram).all() + or not np.allclose(gram, np.eye(basis.shape[1]), rtol=1e-10, atol=1e-10) + ): + msg = "directions must be a finite real p-by-k orthonormal basis" + raise ValueError(msg) + object.__setattr__( + self, "directions", tuple(tuple(float(v) for v in row) for row in basis) + ) + + def constraint(self, observable: str = "fisher_min_eigenvalue") -> Constraint: + """Build the existing deterministic feasibility constraint. + + Args: + observable: Scored column name. + + Returns: + A >= constraint without an invented confidence level. + + Raises: + ValueError: If the observable name is empty. + """ + if not _label(observable): + msg = "observable must be nonempty" + raise ValueError(msg) + return Constraint("local_information", observable, ">=", float(self.threshold)) + + def to_metadata(self) -> dict[str, object]: + """Return detached primitive JSON policy values. + + Returns: + Threshold, basis, numerical tolerances and dense dimension limit. + """ + return { + "threshold": float(self.threshold), + "directions": None + if self.directions is None + else [list(row) for row in self.directions], + "atol": float(self.atol), + "rtol": float(self.rtol), + "max_parameters": int(self.max_parameters), + } + + +@dataclass(frozen=True) +class FisherScreenResult: + """A local screen outcome retaining its context and numerical evidence. + + Attributes: + status: Feasible, infeasible, unreadable or failed. + reason: Machine-readable outcome explanation or upstream failure text. + eigenvalues: Actual tested eigenvalues; empty for unavailable results. + matrix_digest: SHA256 of the supplied host float64 little-endian matrix + bytes in C order, before symmetrization; None for missing inputs. + context: Caller-owned scientific and numerical declarations. + policy: The prespecified screen. + """ + + status: ScreenStatus + reason: str + eigenvalues: tuple[float, ...] + matrix_digest: str | None + context: FisherContext + policy: FisherScreenPolicy + + def as_scores(self, observable: str = "fisher_min_eigenvalue") -> dict[str, float]: + """Expose a readable result to an existing scorer/constraint. + + Args: + observable: Scored column name. + + Returns: + The actual minimum tested eigenvalue in a single scored column. + + Raises: + ValueError: If the result is unavailable or the name is empty. + """ + if self.status not in {"feasible", "infeasible"} or not _label(observable): + msg = ( + "Only readable Fisher results with a nonempty observable can be scored" + ) + raise ValueError(msg) + return {observable: min(self.eigenvalues)} + + def to_metadata(self) -> dict[str, object]: + """Return a result-bundle-compatible record without nonfinite sentinels. + + Returns: + Versioned primitive JSON with actual values and preserved context. + """ + return { + "schema_version": 1, + "scope": "local_directional_information_screen", + "status": self.status, + "reason": self.reason, + "eigenvalues": list(self.eigenvalues), + "matrix_digest": self.matrix_digest, + "context": self.context.to_metadata(), + "policy": self.policy.to_metadata(), + } + + +def _prepare_information( + information: NDArray[np.float64], context: FisherContext, policy: FisherScreenPolicy +) -> tuple[NDArray[np.float64] | None, str, str | None, float]: + """Validate and snapshot a bounded host matrix before spectral arithmetic. + + Returns: + Symmetrized matrix or None, reason, input digest and absolute tolerance. + """ + digest = None + p = len(context.parameter_names) + if ( + not isinstance(information, np.ndarray) + or information.ndim != 2 + or information.shape != (p, p) + or information.dtype.kind not in "iuf" + or p > policy.max_parameters + ): + return None, "unsupported_host_matrix_shape_dtype_or_limit", digest, 0.0 + with np.errstate(over="ignore", invalid="ignore"): + matrix = np.array(information, dtype=np.float64, order="C", copy=True) + if not np.isfinite(matrix).all(): + return None, "nonfinite_information", digest, 0.0 + digest = sha256(matrix.astype(" FisherScreenResult: + """Screen a caller-qualified host information matrix in declared directions. + + No derivatives, Fisher model, scaling, nuisance profile, statistical + uncertainty or global identifiability conclusion is constructed here. + The caller explicitly transfers native backend artifacts to host NumPy. + + Args: + information: Real host NumPy p-by-p matrix, or None when unavailable. + context: Ordered coordinates and retained qualification references. + policy: Prespecified threshold, directions and numerical limits. + failure: Upstream failure reason, supplied only with information=None. + + Returns: + A readable local result, an unreadable invalid/unsupported input, or + a failed upstream/eigensolver record. No input matrix is retained. + + Raises: + ValueError: If failure is empty or supplied alongside a matrix. + """ + + def result( + status: ScreenStatus, reason: str, values: tuple[float, ...] = () + ) -> FisherScreenResult: + return FisherScreenResult(status, reason, values, digest, context, policy) + + digest = None + if failure is not None: + if information is not None or not _label(failure): + msg = "An upstream failure needs a reason and no information matrix" + raise ValueError(msg) + return result("failed", failure) + if information is None: + return result("unreadable", "missing_information") + matrix, reason, digest, tolerance = _prepare_information( + information, context, policy + ) + if matrix is None: + return result("unreadable", reason) + p = len(context.parameter_names) + basis = None if policy.directions is None else np.asarray(policy.directions) + if basis is not None and basis.shape[0] != p: + return result("unreadable", "direction_coordinate_mismatch") + try: + full_values = np.linalg.eigvalsh(matrix) + with np.errstate(over="ignore", invalid="ignore"): + values = ( + full_values + if basis is None + else np.linalg.eigvalsh(basis.T @ matrix @ basis) + ) + except np.linalg.LinAlgError: + return result("failed", "eigensolver_failed") + if not np.isfinite(full_values).all() or not np.isfinite(values).all(): + return result("unreadable", "nonfinite_spectrum") + if float(full_values[0]) < -tolerance: + return result("unreadable", "indefinite_information") + status: ScreenStatus = ( + "feasible" if float(values[0]) >= policy.threshold else "infeasible" + ) + return result(status, "threshold_comparison", tuple(float(v) for v in values)) + + +def fisher_screen_counts( + records: Mapping[str, FisherScreenResult], *, planned_ids: Sequence[str] +) -> dict[str, int]: + """Count outcomes against a fixed declared cohort without dropping failures. + + Args: + records: At most one outcome per planned identifier. + planned_ids: Complete, distinct, nonempty planned replicate/task IDs. + + Returns: + Planned, feasible, infeasible, unreadable, failed and missing counts. + These counts alone establish no probability, interval or gate verdict. + + Raises: + ValueError: If identities or result statuses are invalid. + """ + ids = tuple(planned_ids) + if ( + isinstance(planned_ids, str) + or not ids + or not all(_label(identity) for identity in ids) + or len(set(ids)) != len(ids) + or set(records) - set(ids) + ): + msg = "Screen counts require a complete distinct planned-ID cohort" + raise ValueError(msg) + counts = dict.fromkeys(("feasible", "infeasible", "unreadable", "failed"), 0) + for record in records.values(): + if not isinstance(record, FisherScreenResult) or record.status not in counts: + msg = "Screen counts require valid FisherScreenResult statuses" + raise ValueError(msg) + counts[record.status] += 1 + return {"planned": len(ids), **counts, "missing": len(ids) - len(records)} diff --git a/tests/test_documentation.py b/tests/test_documentation.py index a81aa66..4f75ca9 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -14,6 +14,22 @@ ROOT = Path(__file__).resolve().parents[1] +def test_complete_fisher_guide_preserves_analytic_designs_and_denominators(): + """The published screen uses a known Hessian and keeps unavailable tasks.""" + blocks = re.findall( + r"```python\n(.*?)\n```", (ROOT / "docs/guide/fisher.md").read_text(), re.DOTALL + ) + namespace: dict[str, Any] = {} + for block in blocks: + exec(block, namespace) # ruff: ignore[exec-builtin] -- repository guide + np.testing.assert_array_equal(namespace["table"].scores, [[2.0], [8.0]]) + assert namespace["counts"]["planned"] == 5 + assert ( + sum(namespace["counts"][key] for key in ("failed", "unreadable", "missing")) + == 3 + ) + + def test_readme_and_standalone_replicates_agree(): """Both published entry points preserve pairing and the analytic cost.""" blocks = re.findall( diff --git a/tests/test_fisher.py b/tests/test_fisher.py new file mode 100644 index 0000000..1ae00f0 --- /dev/null +++ b/tests/test_fisher.py @@ -0,0 +1,295 @@ +"""Independent Gaussian design references and unavailable-outcome accounting.""" + +from __future__ import annotations + +import json +from dataclasses import replace +from typing import TYPE_CHECKING, Any, NoReturn + +import numpy as np +import pytest + +from trade_study import ( + Direction, + FisherContext, + FisherScreenPolicy, + Observable, + ResultsTable, + fisher_screen_counts, + load_result_bundle, + run_grid, + save_result_bundle, + screen_fisher, +) + +if TYPE_CHECKING: + from pathlib import Path + + +def context(names: tuple[str, ...] = ("intercept", "slope")) -> FisherContext: + return FisherContext( + parameter_names=names, + parameter_coordinates="physical coefficients; dimensionless", + parameter_scales=(1.0,) * len(names), + observation_model="independent Gaussian, fixed known variance", + design="two independent observations at x=-1,+1", + reference_regime="linear mean; all coefficient values", + nuisance_treatment="none; coefficients both targeted", + sensitivity_reference="analytic derivative of intercept + slope*x", + numerical_reference="analytic mean, exact design, host float64", + qualification_reference="independent Gaussian log-likelihood Hessian", + information_reference="synthetic caller artifact", + ) + + +@pytest.mark.parametrize( + ("variance", "expected_status"), [(0.25, "feasible"), (1.0, "infeasible")] +) +def test_gaussian_design_matches_independent_likelihood_hessian( + variance: float, expected_status: str +): + # -log L = ((y_minus-a+b)^2 + (y_plus-a-b)^2)/(2*variance) + # gives Hessian diag(2/variance, 2/variance), independently of y. + jacobian = np.array([[1.0, -1.0], [1.0, 1.0]]) + information = jacobian.T @ jacobian / variance + policy = FisherScreenPolicy(4.0) + result = screen_fisher(information, context=context(), policy=policy) + assert result.status == expected_status + np.testing.assert_allclose(result.eigenvalues, [2 / variance] * 2) + assert policy.constraint().check(result.as_scores()["fisher_min_eigenvalue"]) == ( + expected_status == "feasible" + ) + assert policy.constraint().confidence is None + + +def test_declared_identifiable_direction_survives_singular_nominal_matrix(): + # Three unit-variance observations of a+b identify that sum; a-b is a gauge. + information = np.full((2, 2), 3.0) + policy = FisherScreenPolicy(4.0) + assert ( + screen_fisher(information, context=context(), policy=policy).status + == "infeasible" + ) + direction = ((1 / np.sqrt(2),), (1 / np.sqrt(2),)) + targeted = screen_fisher( + information, context=context(), policy=replace(policy, directions=direction) + ) + assert targeted.status == "feasible" + np.testing.assert_allclose(targeted.eigenvalues, [6.0]) + gauge = replace(policy, directions=((1 / np.sqrt(2),), (-1 / np.sqrt(2),))) + assert ( + screen_fisher(information, context=context(), policy=gauge).status + == "infeasible" + ) + + +def test_projection_and_caller_profile_have_explicitly_different_meanings(): + information = np.array([[1.0, 1.0], [1.0, 2.0]]) + conditional = screen_fisher( + information, + context=replace(context(), nuisance_treatment="slope held fixed"), + policy=FisherScreenPolicy(0.75, directions=((1.0,), (0.0,))), + ) + # Analytically minimize the quadratic over slope: 1 - 1*(1/2)*1 = 1/2. + profiled = screen_fisher( + np.array([[0.5]]), + context=replace( + context(("intercept",)), + nuisance_treatment="slope profiled by caller Schur complement", + ), + policy=FisherScreenPolicy(0.75), + ) + assert conditional.status == "feasible" + assert profiled.status == "infeasible" + assert conditional.eigenvalues == (1.0,) + assert profiled.eigenvalues == (0.5,) + + +def test_readonly_input_snapshot_and_declared_scales_are_preserved(): + information = np.diag([2.0, 8.0]) + information.flags.writeable = False + ctx = replace(context(), parameter_scales=(100.0, 0.01)) + policy = FisherScreenPolicy(3.0) + result = screen_fisher(information, context=ctx, policy=policy) + assert result.eigenvalues == (2.0, 8.0) # already in the declared coordinates + digest = result.matrix_digest + assert ( + screen_fisher( + np.asfortranarray(information), context=ctx, policy=policy + ).matrix_digest + == digest + ) + np.testing.assert_array_equal(information, np.diag([2.0, 8.0])) + assert not information.flags.writeable + metadata = result.to_metadata() + assert json.loads(json.dumps(metadata, allow_nan=False)) == metadata + metadata["eigenvalues"] = [] + assert result.eigenvalues == (2.0, 8.0) + + +@pytest.mark.parametrize( + ("matrix", "reason"), + [ + (None, "missing_information"), + (np.ones((3, 3)), "unsupported_host_matrix_shape_dtype_or_limit"), + (np.array([[1.0, np.nan], [np.nan, 1.0]]), "nonfinite_information"), + (np.array([[1.0, np.inf], [np.inf, 1.0]]), "nonfinite_information"), + (np.array([[1.0, 1.0], [0.0, 1.0]]), "asymmetric_information"), + (np.array([[1.0, 2.0], [2.0, 1.0]]), "indefinite_information"), + (np.eye(2, dtype=complex), "unsupported_host_matrix_shape_dtype_or_limit"), + (np.eye(2, dtype=bool), "unsupported_host_matrix_shape_dtype_or_limit"), + ], +) +def test_unreadable_inputs_cannot_enter_generic_score_aggregation( + matrix: Any, reason: str +): + result = screen_fisher(matrix, context=context(), policy=FisherScreenPolicy(0.1)) + assert result.status == "unreadable" + assert result.reason == reason + with pytest.raises(ValueError, match="Only readable"): + result.as_scores() + json.dumps(result.to_metadata(), allow_nan=False) + + +def test_numerical_tolerances_are_recorded_without_softening_the_threshold(): + policy = FisherScreenPolicy(0.0, atol=1e-10, rtol=0.0) + tiny_negative = screen_fisher( + np.diag([-1e-12, 1.0]), context=context(), policy=policy + ) + assert tiny_negative.status == "infeasible" + assert tiny_negative.eigenvalues[0] == pytest.approx( + -1e-12, abs=1e-20 + ) # retained, never clipped to pass + invalid = screen_fisher(np.diag([-1e-8, 1.0]), context=context(), policy=policy) + assert invalid.status == "unreadable" + noisy = screen_fisher( + np.array([[2.0, 1e-12], [0.0, 1.0]]), context=context(), policy=policy + ) + assert noisy.status == "feasible" + + +def test_dimension_and_direction_limits_are_unreadable(): + too_large = screen_fisher( + np.eye(2), context=context(), policy=FisherScreenPolicy(0.0, max_parameters=1) + ) + assert too_large.status == "unreadable" + wrong_basis = screen_fisher( + np.eye(2), + context=context(), + policy=FisherScreenPolicy(0.0, directions=((1.0,),)), + ) + assert wrong_basis.reason == "direction_coordinate_mismatch" + + +def test_failures_and_planned_denominators_remain_explicit( + monkeypatch: pytest.MonkeyPatch, +): + policy, ctx = FisherScreenPolicy(1.0), context() + records = { + "a": screen_fisher(np.eye(2), context=ctx, policy=policy), + "b": screen_fisher(np.eye(2) / 2, context=ctx, policy=policy), + "c": screen_fisher(None, context=ctx, policy=policy), + "d": screen_fisher( + None, context=ctx, policy=policy, failure="forward solve failed" + ), + } + assert fisher_screen_counts(records, planned_ids=["a", "b", "c", "d", "e"]) == { + "planned": 5, + "feasible": 1, + "infeasible": 1, + "unreadable": 1, + "failed": 1, + "missing": 1, + } + + def fail(_matrix: Any) -> NoReturn: + raise np.linalg.LinAlgError + + monkeypatch.setattr(np.linalg, "eigvalsh", fail) + result = screen_fisher(np.eye(2), context=ctx, policy=policy) + assert result.status == "failed" + assert result.reason == "eigensolver_failed" + + +@pytest.mark.parametrize("ids", [[], ["a", "a"], [""], "a", ["b"]]) +def test_counts_reject_ambiguous_or_unplanned_identities(ids: Any): + record = screen_fisher(None, context=context(), policy=FisherScreenPolicy(0.0)) + with pytest.raises(ValueError, match="planned-ID"): + fisher_screen_counts({"a": record}, planned_ids=ids) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"threshold": -1.0}, + {"threshold": float("nan")}, + {"threshold": True}, + {"atol": -1.0}, + {"rtol": float("inf")}, + {"max_parameters": True}, + {"max_parameters": 1.5}, + {"max_parameters": 0}, + {"directions": ((0.0,), (0.0,))}, + {"directions": ((1.0, 0.0),)}, + {"directions": ((1j,),)}, + {"directions": ((2**63 - 1,),)}, + ], +) +def test_invalid_policy_fails_before_any_design_evaluation(kwargs: dict[str, Any]): + values = {"threshold": 0.5, **kwargs} + with pytest.raises(ValueError, match=r"Fisher policy|directions"): + FisherScreenPolicy(**values) + + +@pytest.mark.parametrize( + "kwargs", + [ + {"parameter_names": ("a", "a")}, + {"parameter_names": "ab"}, + {"parameter_scales": (0.0, 1.0)}, + {"parameter_scales": (1.0,)}, + {"nuisance_treatment": ""}, + {"qualification_reference": " "}, + ], +) +def test_missing_coordinate_or_qualification_context_is_rejected( + kwargs: dict[str, Any], +): + with pytest.raises(ValueError, match="context"): + replace(context(), **kwargs) + + +class _GaussianWorld: + def generate(self, config: dict[str, Any]) -> tuple[None, np.ndarray]: + return None, np.diag([2.0, 2.0]) / config["variance"] + + +class _GaussianScorer: + def score( + self, truth: Any, observations: Any, config: dict[str, Any] + ) -> dict[str, float]: + return screen_fisher( + observations, context=context(), policy=FisherScreenPolicy(4.0) + ).as_scores() + + +def test_actual_grid_constraints_and_result_artifacts(tmp_path: Path): + policy = FisherScreenPolicy(4.0) + obs = [Observable("fisher_min_eigenvalue", Direction.MAXIMIZE)] + table = run_grid( + _GaussianWorld(), + _GaussianScorer(), + [{"variance": 0.25}, {"variance": 1.0}], + obs, + ) + np.testing.assert_array_equal(table.feasible([policy.constraint()]), [True, False]) + record = screen_fisher(np.eye(2) * 8, context=context(), policy=policy) + table.metadata[0]["fisher_screen"] = record.to_metadata() + table.metadata[1]["fisher_unavailable"] = screen_fisher( + None, context=context(), policy=policy + ).to_metadata() + save_result_bundle(table, tmp_path / "fisher", observables=obs) + restored = load_result_bundle(tmp_path / "fisher").results + assert restored.metadata == table.metadata + np.testing.assert_array_equal(restored.scores, table.scores) + assert isinstance(restored, ResultsTable)