moju 2.3.2__tar.gz → 2.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {moju-2.3.2 → moju-2.4.1}/PKG-INFO +27 -1
  2. {moju-2.3.2 → moju-2.4.1}/README.md +26 -0
  3. {moju-2.3.2 → moju-2.4.1}/moju/__init__.py +1 -1
  4. {moju-2.3.2 → moju-2.4.1}/moju/monitor/__init__.py +14 -0
  5. {moju-2.3.2 → moju-2.4.1}/moju/monitor/auditor.py +188 -12
  6. {moju-2.3.2 → moju-2.4.1}/moju/monitor/evaluate_batch.py +57 -1
  7. moju-2.4.1/moju/monitor/evaluation_coverage.py +547 -0
  8. {moju-2.3.2 → moju-2.4.1}/moju/monitor/law_implied_diagnostics.py +15 -3
  9. {moju-2.3.2 → moju-2.4.1}/moju/monitor/monitor_log_export.py +61 -8
  10. {moju-2.3.2 → moju-2.4.1}/moju/monitor/report.py +12 -2
  11. {moju-2.3.2 → moju-2.4.1}/moju/monitor/tiers.py +30 -4
  12. {moju-2.3.2 → moju-2.4.1}/moju/monitor/visualize_components.py +16 -2
  13. {moju-2.3.2 → moju-2.4.1}/moju/monitor/visualize_plotly.py +22 -10
  14. {moju-2.3.2 → moju-2.4.1}/moju.egg-info/PKG-INFO +27 -1
  15. {moju-2.3.2 → moju-2.4.1}/moju.egg-info/SOURCES.txt +2 -0
  16. {moju-2.3.2 → moju-2.4.1}/pyproject.toml +1 -1
  17. moju-2.4.1/tests/test_evaluation_coverage.py +552 -0
  18. {moju-2.3.2 → moju-2.4.1}/tests/test_imports.py +6 -0
  19. {moju-2.3.2 → moju-2.4.1}/tests/test_monitor_log_export.py +45 -0
  20. {moju-2.3.2 → moju-2.4.1}/tests/test_paper_goldens.py +19 -0
  21. {moju-2.3.2 → moju-2.4.1}/tests/test_report_schema.py +3 -1
  22. {moju-2.3.2 → moju-2.4.1}/LICENSE +0 -0
  23. {moju-2.3.2 → moju-2.4.1}/moju/monitor/analytic_flows.py +0 -0
  24. {moju-2.3.2 → moju-2.4.1}/moju/monitor/audit_meta.py +0 -0
  25. {moju-2.3.2 → moju-2.4.1}/moju/monitor/boundary_closures.py +0 -0
  26. {moju-2.3.2 → moju-2.4.1}/moju/monitor/closure_registry.py +0 -0
  27. {moju-2.3.2 → moju-2.4.1}/moju/monitor/config.py +0 -0
  28. {moju-2.3.2 → moju-2.4.1}/moju/monitor/constitutive_closure_summary.py +0 -0
  29. {moju-2.3.2 → moju-2.4.1}/moju/monitor/constitutive_closures.py +0 -0
  30. {moju-2.3.2 → moju-2.4.1}/moju/monitor/curvilinear.py +0 -0
  31. {moju-2.3.2 → moju-2.4.1}/moju/monitor/derivative_error.py +0 -0
  32. {moju-2.3.2 → moju-2.4.1}/moju/monitor/derivative_keys.py +0 -0
  33. {moju-2.3.2 → moju-2.4.1}/moju/monitor/derivative_metadata.py +0 -0
  34. {moju-2.3.2 → moju-2.4.1}/moju/monitor/derivative_provenance.py +0 -0
  35. {moju-2.3.2 → moju-2.4.1}/moju/monitor/derived_state_chain.py +0 -0
  36. {moju-2.3.2 → moju-2.4.1}/moju/monitor/eval_mask.py +0 -0
  37. {moju-2.3.2 → moju-2.4.1}/moju/monitor/fd_stencils.py +0 -0
  38. {moju-2.3.2 → moju-2.4.1}/moju/monitor/implied_scoring.py +0 -0
  39. {moju-2.3.2 → moju-2.4.1}/moju/monitor/key_declarations.py +0 -0
  40. {moju-2.3.2 → moju-2.4.1}/moju/monitor/law_fd_recipes.py +0 -0
  41. {moju-2.3.2 → moju-2.4.1}/moju/monitor/law_group_inference.py +0 -0
  42. {moju-2.3.2 → moju-2.4.1}/moju/monitor/law_scale_recipes.py +0 -0
  43. {moju-2.3.2 → moju-2.4.1}/moju/monitor/model_derived_registry.py +0 -0
  44. {moju-2.3.2 → moju-2.4.1}/moju/monitor/nondim_inference.py +0 -0
  45. {moju-2.3.2 → moju-2.4.1}/moju/monitor/nondim_scales_parse.py +0 -0
  46. {moju-2.3.2 → moju-2.4.1}/moju/monitor/path_b_derivatives.py +0 -0
  47. {moju-2.3.2 → moju-2.4.1}/moju/monitor/path_b_spectral.py +0 -0
  48. {moju-2.3.2 → moju-2.4.1}/moju/monitor/regions.py +0 -0
  49. {moju-2.3.2 → moju-2.4.1}/moju/monitor/resolution_sensor.py +0 -0
  50. {moju-2.3.2 → moju-2.4.1}/moju/monitor/scaling_closures.py +0 -0
  51. {moju-2.3.2 → moju-2.4.1}/moju/monitor/spatial_rnorm_panels.py +0 -0
  52. {moju-2.3.2 → moju-2.4.1}/moju/monitor/state_builder.py +0 -0
  53. {moju-2.3.2 → moju-2.4.1}/moju/monitor/state_ref.py +0 -0
  54. {moju-2.3.2 → moju-2.4.1}/moju/monitor/term_breakdown.py +0 -0
  55. {moju-2.3.2 → moju-2.4.1}/moju/monitor/types.py +0 -0
  56. {moju-2.3.2 → moju-2.4.1}/moju/monitor/visualize_constitutive.py +0 -0
  57. {moju-2.3.2 → moju-2.4.1}/moju/monitor/visualize_export.py +0 -0
  58. {moju-2.3.2 → moju-2.4.1}/moju/monitor/visualize_labels.py +0 -0
  59. {moju-2.3.2 → moju-2.4.1}/moju/monitor/visualize_theme.py +0 -0
  60. {moju-2.3.2 → moju-2.4.1}/moju/monitor/weighted_norms.py +0 -0
  61. {moju-2.3.2 → moju-2.4.1}/moju/monitor/weighted_safeguard.py +0 -0
  62. {moju-2.3.2 → moju-2.4.1}/moju/piratio/__init__.py +0 -0
  63. {moju-2.3.2 → moju-2.4.1}/moju/piratio/groups.py +0 -0
  64. {moju-2.3.2 → moju-2.4.1}/moju/piratio/laws.py +0 -0
  65. {moju-2.3.2 → moju-2.4.1}/moju/piratio/models.py +0 -0
  66. {moju-2.3.2 → moju-2.4.1}/moju/piratio/nondim.py +0 -0
  67. {moju-2.3.2 → moju-2.4.1}/moju/piratio/operators.py +0 -0
  68. {moju-2.3.2 → moju-2.4.1}/moju/registry.py +0 -0
  69. {moju-2.3.2 → moju-2.4.1}/moju/torch/__init__.py +0 -0
  70. {moju-2.3.2 → moju-2.4.1}/moju/torch/_closure.py +0 -0
  71. {moju-2.3.2 → moju-2.4.1}/moju/torch/_derived.py +0 -0
  72. {moju-2.3.2 → moju-2.4.1}/moju/torch/_engine.py +0 -0
  73. {moju-2.3.2 → moju-2.4.1}/moju/torch/_fd_stencils.py +0 -0
  74. {moju-2.3.2 → moju-2.4.1}/moju/torch/_implied_diagnostics.py +0 -0
  75. {moju-2.3.2 → moju-2.4.1}/moju/torch/_nondim.py +0 -0
  76. {moju-2.3.2 → moju-2.4.1}/moju/torch/_path_b.py +0 -0
  77. {moju-2.3.2 → moju-2.4.1}/moju/torch/_r_eff.py +0 -0
  78. {moju-2.3.2 → moju-2.4.1}/moju/torch_interop.py +0 -0
  79. {moju-2.3.2 → moju-2.4.1}/moju.egg-info/dependency_links.txt +0 -0
  80. {moju-2.3.2 → moju-2.4.1}/moju.egg-info/requires.txt +0 -0
  81. {moju-2.3.2 → moju-2.4.1}/moju.egg-info/top_level.txt +0 -0
  82. {moju-2.3.2 → moju-2.4.1}/setup.cfg +0 -0
  83. {moju-2.3.2 → moju-2.4.1}/tests/test_analytic_ns_diagnostics.py +0 -0
  84. {moju-2.3.2 → moju-2.4.1}/tests/test_audit_meta.py +0 -0
  85. {moju-2.3.2 → moju-2.4.1}/tests/test_auditor.py +0 -0
  86. {moju-2.3.2 → moju-2.4.1}/tests/test_builtin_law_evaluate_batch.py +0 -0
  87. {moju-2.3.2 → moju-2.4.1}/tests/test_closure_debug_sidecar.py +0 -0
  88. {moju-2.3.2 → moju-2.4.1}/tests/test_compat_2_0_defaults.py +0 -0
  89. {moju-2.3.2 → moju-2.4.1}/tests/test_config_forms.py +0 -0
  90. {moju-2.3.2 → moju-2.4.1}/tests/test_constitutive_closure_summary.py +0 -0
  91. {moju-2.3.2 → moju-2.4.1}/tests/test_custom_law_hooks.py +0 -0
  92. {moju-2.3.2 → moju-2.4.1}/tests/test_derivative_provenance.py +0 -0
  93. {moju-2.3.2 → moju-2.4.1}/tests/test_derived_state_chain.py +0 -0
  94. {moju-2.3.2 → moju-2.4.1}/tests/test_evaluate.py +0 -0
  95. {moju-2.3.2 → moju-2.4.1}/tests/test_evaluate_optin_batched.py +0 -0
  96. {moju-2.3.2 → moju-2.4.1}/tests/test_evaluate_regions_batched.py +0 -0
  97. {moju-2.3.2 → moju-2.4.1}/tests/test_examples_implied_cookbooks.py +0 -0
  98. {moju-2.3.2 → moju-2.4.1}/tests/test_examples_path_b_fd_cookbooks.py +0 -0
  99. {moju-2.3.2 → moju-2.4.1}/tests/test_examples_turbulence_cookbooks.py +0 -0
  100. {moju-2.3.2 → moju-2.4.1}/tests/test_fd_stencils.py +0 -0
  101. {moju-2.3.2 → moju-2.4.1}/tests/test_fornberg_and_dual_spacing.py +0 -0
  102. {moju-2.3.2 → moju-2.4.1}/tests/test_groups.py +0 -0
  103. {moju-2.3.2 → moju-2.4.1}/tests/test_implied_robust_scoring.py +0 -0
  104. {moju-2.3.2 → moju-2.4.1}/tests/test_implied_viscosity_ls.py +0 -0
  105. {moju-2.3.2 → moju-2.4.1}/tests/test_jax_torch_fd_parity.py +0 -0
  106. {moju-2.3.2 → moju-2.4.1}/tests/test_law_fd_recipes.py +0 -0
  107. {moju-2.3.2 → moju-2.4.1}/tests/test_law_group_inference.py +0 -0
  108. {moju-2.3.2 → moju-2.4.1}/tests/test_law_implied_diagnostics.py +0 -0
  109. {moju-2.3.2 → moju-2.4.1}/tests/test_law_registry.py +0 -0
  110. {moju-2.3.2 → moju-2.4.1}/tests/test_law_scale_recipes.py +0 -0
  111. {moju-2.3.2 → moju-2.4.1}/tests/test_laws.py +0 -0
  112. {moju-2.3.2 → moju-2.4.1}/tests/test_minimal_input_flow.py +0 -0
  113. {moju-2.3.2 → moju-2.4.1}/tests/test_model_derived_registry.py +0 -0
  114. {moju-2.3.2 → moju-2.4.1}/tests/test_models.py +0 -0
  115. {moju-2.3.2 → moju-2.4.1}/tests/test_nondim.py +0 -0
  116. {moju-2.3.2 → moju-2.4.1}/tests/test_nondim_inference.py +0 -0
  117. {moju-2.3.2 → moju-2.4.1}/tests/test_not_identifiable_tier.py +0 -0
  118. {moju-2.3.2 → moju-2.4.1}/tests/test_notebooks_media.py +0 -0
  119. {moju-2.3.2 → moju-2.4.1}/tests/test_operators.py +0 -0
  120. {moju-2.3.2 → moju-2.4.1}/tests/test_path_b_derivatives.py +0 -0
  121. {moju-2.3.2 → moju-2.4.1}/tests/test_path_b_spectral.py +0 -0
  122. {moju-2.3.2 → moju-2.4.1}/tests/test_phase_a2_derivative_layer.py +0 -0
  123. {moju-2.3.2 → moju-2.4.1}/tests/test_phase_a5_periodic.py +0 -0
  124. {moju-2.3.2 → moju-2.4.1}/tests/test_readme_examples.py +0 -0
  125. {moju-2.3.2 → moju-2.4.1}/tests/test_readme_pypi_links.py +0 -0
  126. {moju-2.3.2 → moju-2.4.1}/tests/test_region_aware_scoring.py +0 -0
  127. {moju-2.3.2 → moju-2.4.1}/tests/test_registry.py +0 -0
  128. {moju-2.3.2 → moju-2.4.1}/tests/test_safeguard_diagnostics.py +0 -0
  129. {moju-2.3.2 → moju-2.4.1}/tests/test_scoring_and_bounds.py +0 -0
  130. {moju-2.3.2 → moju-2.4.1}/tests/test_spatial_rnorm_panels.py +0 -0
  131. {moju-2.3.2 → moju-2.4.1}/tests/test_split_admissibility.py +0 -0
  132. {moju-2.3.2 → moju-2.4.1}/tests/test_state_ref.py +0 -0
  133. {moju-2.3.2 → moju-2.4.1}/tests/test_state_ref_hdf5.py +0 -0
  134. {moju-2.3.2 → moju-2.4.1}/tests/test_state_ref_meshio.py +0 -0
  135. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_auto_config.py +0 -0
  136. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_dependency_planner.py +0 -0
  137. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_implied_groups.py +0 -0
  138. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_io.py +0 -0
  139. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_law_fd_hints.py +0 -0
  140. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_model_derived_registry.py +0 -0
  141. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_pi_gating.py +0 -0
  142. {moju-2.3.2 → moju-2.4.1}/tests/test_studio_plots.py +0 -0
  143. {moju-2.3.2 → moju-2.4.1}/tests/test_torch_engine.py +0 -0
  144. {moju-2.3.2 → moju-2.4.1}/tests/test_torch_interop.py +0 -0
  145. {moju-2.3.2 → moju-2.4.1}/tests/test_visualize_components.py +0 -0
  146. {moju-2.3.2 → moju-2.4.1}/tests/test_visualize_constitutive.py +0 -0
  147. {moju-2.3.2 → moju-2.4.1}/tests/test_visualize_labels.py +0 -0
  148. {moju-2.3.2 → moju-2.4.1}/tests/test_visualize_parity.py +0 -0
  149. {moju-2.3.2 → moju-2.4.1}/tests/test_visualize_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moju
3
- Version: 2.3.2
3
+ Version: 2.4.1
4
4
  Summary: Physics admissibility auditing for ML surrogates and simulation data: scores how well predicted fields satisfy governing laws and constitutive relations, with implied-parameter checks and admissibility tiers, for JAX and PyTorch.
5
5
  Author-email: "Ifimo Lab (Ifimo Global Solutions LLC)" <lab@ifimoanalytics.com>
6
6
  Maintainer-email: "Ifimo Global Solutions LLC (dba Ifimo Lab)" <info@ifimoanalytics.com>
@@ -80,6 +80,32 @@ Moju is an open-source physics admissibility auditing library for scientific ML
80
80
 
81
81
  A tier is computed from an admissibility score in `[0, 1]`: High at `≥ 0.909`, Moderate at `≥ 0.667`, Low at `≥ 0.5`, and Non-Admissible below that (see `moju.monitor.auditor.admissibility_level`). A passing tier indicates consistency with the stated physics within the audited conditions: the laws, constitutive models, sample points, and scales you supplied. It is not a guarantee of correctness.
82
82
 
83
+ ### Evaluation coverage
84
+
85
+ Coverage counts residual elements, including trailing vector/tensor components.
86
+ An evaluation mask excludes all components at each excluded spatial point.
87
+ Audit-scope monitor-log exports preserve the non-finite policy recorded by
88
+ `audit`, including incomplete per-key verdicts and null scores in JSON series.
89
+
90
+ NaN-aware reductions may score on the finite subset of a residual. Every scored key and the aggregate now carry an evaluation-coverage record (`n_total`, `n_masked`, `n_not_identifiable`, `n_selected`, `n_finite`, `n_nonfinite`, `coverage_status`). Default `nonfinite_policy="flag"` keeps scores and tiers unchanged; set `"strict"` to treat incomplete or empty coverage as tier **Incomplete** (not assessable — distinct from **Not identifiable**). Under `"flag"`, a NaN residual is dropped from the score while an infinite residual enters the RMS and drives the score to 0; both are reported as `coverage_status = "incomplete"`. `"strict"` treats NaN and inf identically (null score, Incomplete). Points excluded by implied-audit identifiability (finite conditioning that fails the scoring-path thresholds) are counted as `n_not_identifiable` (not `n_nonfinite`), so `n_selected = n_total − n_masked − n_not_identifiable`. Non-finite inputs or non-finite conditioning at a point count as `n_nonfinite`, never as not identifiable.
91
+
92
+ **Known limitation.** Coverage counts non-finite values in the residual array. A required input that is non-finite while the law's residual stays finite (for example, a law that applies `jnp.where` or `zeros_like` to that input) is currently reported as `coverage_status = "ok"`. Counting non-finite inputs per sample needs a mapping from input samples to residual samples that holds for scalars, parameters and broadcast shapes; this is planned for a follow-up release.
93
+
94
+ ```python
95
+ import jax.numpy as jnp
96
+ from moju.monitor import ResidualEngine, LawSpec, Scoring, audit
97
+
98
+ eng = ResidualEngine(
99
+ laws=[LawSpec(name="unit_law", fn=lambda r: r, state_map={"r": "r"},
100
+ scoring=Scoring("rms", dimensionless=True))],
101
+ law_implied_audits=False,
102
+ )
103
+ eng.compute_residuals({"r": jnp.array([0.0, float("nan")])}, run_mode="eval")
104
+ report = audit(eng.log) # default flag: High score, coverage_status="incomplete"
105
+ print(report["coverage_status"])
106
+ print(report["per_key"]["laws/unit_law"]["evaluation_coverage"])
107
+ ```
108
+
83
109
  ## What Moju Does
84
110
 
85
111
  - Builds residuals from composable `Laws`, `Groups`, and `Models`.
@@ -31,6 +31,32 @@ Moju is an open-source physics admissibility auditing library for scientific ML
31
31
 
32
32
  A tier is computed from an admissibility score in `[0, 1]`: High at `≥ 0.909`, Moderate at `≥ 0.667`, Low at `≥ 0.5`, and Non-Admissible below that (see `moju.monitor.auditor.admissibility_level`). A passing tier indicates consistency with the stated physics within the audited conditions: the laws, constitutive models, sample points, and scales you supplied. It is not a guarantee of correctness.
33
33
 
34
+ ### Evaluation coverage
35
+
36
+ Coverage counts residual elements, including trailing vector/tensor components.
37
+ An evaluation mask excludes all components at each excluded spatial point.
38
+ Audit-scope monitor-log exports preserve the non-finite policy recorded by
39
+ `audit`, including incomplete per-key verdicts and null scores in JSON series.
40
+
41
+ NaN-aware reductions may score on the finite subset of a residual. Every scored key and the aggregate now carry an evaluation-coverage record (`n_total`, `n_masked`, `n_not_identifiable`, `n_selected`, `n_finite`, `n_nonfinite`, `coverage_status`). Default `nonfinite_policy="flag"` keeps scores and tiers unchanged; set `"strict"` to treat incomplete or empty coverage as tier **Incomplete** (not assessable — distinct from **Not identifiable**). Under `"flag"`, a NaN residual is dropped from the score while an infinite residual enters the RMS and drives the score to 0; both are reported as `coverage_status = "incomplete"`. `"strict"` treats NaN and inf identically (null score, Incomplete). Points excluded by implied-audit identifiability (finite conditioning that fails the scoring-path thresholds) are counted as `n_not_identifiable` (not `n_nonfinite`), so `n_selected = n_total − n_masked − n_not_identifiable`. Non-finite inputs or non-finite conditioning at a point count as `n_nonfinite`, never as not identifiable.
42
+
43
+ **Known limitation.** Coverage counts non-finite values in the residual array. A required input that is non-finite while the law's residual stays finite (for example, a law that applies `jnp.where` or `zeros_like` to that input) is currently reported as `coverage_status = "ok"`. Counting non-finite inputs per sample needs a mapping from input samples to residual samples that holds for scalars, parameters and broadcast shapes; this is planned for a follow-up release.
44
+
45
+ ```python
46
+ import jax.numpy as jnp
47
+ from moju.monitor import ResidualEngine, LawSpec, Scoring, audit
48
+
49
+ eng = ResidualEngine(
50
+ laws=[LawSpec(name="unit_law", fn=lambda r: r, state_map={"r": "r"},
51
+ scoring=Scoring("rms", dimensionless=True))],
52
+ law_implied_audits=False,
53
+ )
54
+ eng.compute_residuals({"r": jnp.array([0.0, float("nan")])}, run_mode="eval")
55
+ report = audit(eng.log) # default flag: High score, coverage_status="incomplete"
56
+ print(report["coverage_status"])
57
+ print(report["per_key"]["laws/unit_law"]["evaluation_coverage"])
58
+ ```
59
+
34
60
  ## What Moju Does
35
61
 
36
62
  - Builds residuals from composable `Laws`, `Groups`, and `Models`.
@@ -1,3 +1,3 @@
1
1
  """Moju: Physics-AI supervision for engineering-grade simulations."""
2
2
 
3
- __version__ = "2.3.2"
3
+ __version__ = "2.4.1"
@@ -45,6 +45,12 @@ from moju.monitor.implied_scoring import (
45
45
  resolve_implied_scoring_options,
46
46
  score_from_r_max,
47
47
  )
48
+ from moju.monitor.evaluation_coverage import (
49
+ DEFAULT_NONFINITE_POLICY,
50
+ TIER_INCOMPLETE,
51
+ coverage_from_residual,
52
+ validate_nonfinite_policy,
53
+ )
48
54
  from moju.monitor.regions import (
49
55
  DEFAULT_REGION_MIN_POINTS,
50
56
  normalize_regions,
@@ -101,9 +107,11 @@ from moju.monitor.visualize_labels import pretty_category_name, pretty_residual_
101
107
  from moju.monitor.tiers import (
102
108
  TIER_CUTOFFS,
103
109
  TIER_DEFINITION,
110
+ TIER_NON_SCORED,
104
111
  TIER_ORDER,
105
112
  tier_definition,
106
113
  tier_for_score,
114
+ tier_rank,
107
115
  )
108
116
  from moju.monitor.types import (
109
117
  REPORT_SCHEMA_VERSION,
@@ -154,6 +162,10 @@ __all__ = [
154
162
  "parse_implied_scoring_options",
155
163
  "resolve_implied_scoring_options",
156
164
  "score_from_r_max",
165
+ "DEFAULT_NONFINITE_POLICY",
166
+ "TIER_INCOMPLETE",
167
+ "coverage_from_residual",
168
+ "validate_nonfinite_policy",
157
169
  "DEFAULT_REGION_MIN_POINTS",
158
170
  "normalize_regions",
159
171
  "percentile_tail_adequacy",
@@ -176,9 +188,11 @@ __all__ = [
176
188
  "merge_implied_groups_first",
177
189
  "TIER_CUTOFFS",
178
190
  "TIER_DEFINITION",
191
+ "TIER_NON_SCORED",
179
192
  "TIER_ORDER",
180
193
  "tier_definition",
181
194
  "tier_for_score",
195
+ "tier_rank",
182
196
  "REPORT_SCHEMA_VERSION",
183
197
  "AuditReport",
184
198
  "BoundCheck",
@@ -381,7 +381,7 @@ def _r_eff_scalar(x: jnp.ndarray) -> jnp.ndarray:
381
381
  return rms_r * jnp.power(q, p)
382
382
 
383
383
 
384
- def admissibility_level(score: float) -> str:
384
+ def admissibility_level(score: Optional[float]) -> str:
385
385
  """
386
386
  Map admissibility score in ``[0, 1]`` to a qualitative label (``Unknown`` if non-finite).
387
387
 
@@ -389,14 +389,25 @@ def admissibility_level(score: float) -> str:
389
389
  ``scale_k`` (:data:`ADM_HIGH_THRESHOLD`, :data:`ADM_MODERATE_THRESHOLD`,
390
390
  :data:`ADM_LOW_THRESHOLD`). High requires ``score >= ADM_HIGH_THRESHOLD`` (~90.9% at ±0.1%
391
391
  fractional RMS); Moderate and Low use inclusive lower bounds at ~66.7% and 50%.
392
+
393
+ ``None`` maps to ``Incomplete`` (not assessable under ``nonfinite_policy=\"strict\"``;
394
+ distinct from ``Not identifiable``).
392
395
  """
393
- if not math.isfinite(score):
396
+ if score is None:
397
+ from moju.monitor.evaluation_coverage import TIER_INCOMPLETE
398
+
399
+ return TIER_INCOMPLETE
400
+ try:
401
+ s = float(score)
402
+ except (TypeError, ValueError):
394
403
  return "Unknown"
395
- if score >= ADM_HIGH_THRESHOLD:
404
+ if not math.isfinite(s):
405
+ return "Unknown"
406
+ if s >= ADM_HIGH_THRESHOLD:
396
407
  return "High Admissibility"
397
- if score >= ADM_MODERATE_THRESHOLD:
408
+ if s >= ADM_MODERATE_THRESHOLD:
398
409
  return "Moderate Admissibility"
399
- if score >= ADM_LOW_THRESHOLD:
410
+ if s >= ADM_LOW_THRESHOLD:
400
411
  return "Low Admissibility"
401
412
  return "Non-Admissible"
402
413
 
@@ -982,6 +993,106 @@ def _apply_implied_scoring_to_entry(
982
993
  entry["implied_scoring"] = implied_scoring
983
994
 
984
995
 
996
+ def _apply_evaluation_coverage_policy(
997
+ report: Dict[str, Any],
998
+ entry: Mapping[str, Any],
999
+ *,
1000
+ policy: str,
1001
+ last_residual_dict: Optional[Dict[str, Any]] = None,
1002
+ eval_mask_label: Optional[str] = None,
1003
+ ) -> None:
1004
+ """Attach coverage records and apply ``nonfinite_policy`` to per-key / overall."""
1005
+ from moju.monitor.evaluation_coverage import (
1006
+ TIER_INCOMPLETE,
1007
+ aggregate_coverage,
1008
+ apply_nonfinite_policy_to_key,
1009
+ coverage_map_from_flat,
1010
+ is_incomplete_key,
1011
+ validate_nonfinite_policy,
1012
+ )
1013
+
1014
+ policy = validate_nonfinite_policy(policy)
1015
+ cov_map: Dict[str, Any] = {}
1016
+ raw = entry.get("evaluation_coverage")
1017
+ if isinstance(raw, dict) and raw:
1018
+ cov_map = {str(k): dict(v) for k, v in raw.items() if isinstance(v, dict)}
1019
+ elif last_residual_dict is not None:
1020
+ flat: Dict[str, Any] = {}
1021
+ for cat, content in last_residual_dict.items():
1022
+ if cat == "closure_debug" or not isinstance(content, dict):
1023
+ continue
1024
+ for name, arr in content.items():
1025
+ flat[f"{cat}/{name}"] = arr
1026
+ cov_map = coverage_map_from_flat(
1027
+ flat,
1028
+ eval_mask_label=eval_mask_label
1029
+ or (entry.get("eval_mask") or {}).get("label"),
1030
+ mask_meta=entry.get("eval_mask") if isinstance(entry.get("eval_mask"), dict) else None,
1031
+ closure_debug=last_residual_dict.get("closure_debug")
1032
+ if isinstance(last_residual_dict.get("closure_debug"), dict)
1033
+ else None,
1034
+ )
1035
+ per_key = report.get("per_key") or {}
1036
+ incomplete_keys: List[str] = []
1037
+ for k, row in per_key.items():
1038
+ if not isinstance(row, dict):
1039
+ continue
1040
+ cov = cov_map.get(k)
1041
+ apply_nonfinite_policy_to_key(row, cov, policy=policy)
1042
+ if is_incomplete_key(row):
1043
+ incomplete_keys.append(str(k))
1044
+ if cov_map:
1045
+ report["evaluation_coverage"] = {
1046
+ "per_key": cov_map,
1047
+ "aggregate": aggregate_coverage(cov_map),
1048
+ }
1049
+ report["coverage_status"] = report["evaluation_coverage"]["aggregate"]["coverage_status"]
1050
+ report["nonfinite_policy"] = policy
1051
+ if policy == "strict" and incomplete_keys:
1052
+ report["overall_admissibility_score"] = None
1053
+ report["overall_admissibility_level"] = TIER_INCOMPLETE
1054
+ report["incomplete_keys"] = incomplete_keys
1055
+ # Drop incomplete keys from category numeric rollup already reflected via null scores;
1056
+ # recompute categories excluding Incomplete / Not identifiable.
1057
+ cats: Dict[str, float] = {}
1058
+ buckets: Dict[str, List[float]] = {
1059
+ "laws": [],
1060
+ "constitutive": [],
1061
+ "scaling": [],
1062
+ "data": [],
1063
+ }
1064
+ for k, row in per_key.items():
1065
+ if not isinstance(row, dict):
1066
+ continue
1067
+ if is_incomplete_key(row):
1068
+ continue
1069
+ if (
1070
+ row.get("admissibility_level") == "Not identifiable"
1071
+ or row.get("identifiability_status") == "not_identifiable"
1072
+ ):
1073
+ continue
1074
+ if "/" not in k:
1075
+ continue
1076
+ prefix = k.split("/", 1)[0]
1077
+ if prefix not in buckets:
1078
+ continue
1079
+ try:
1080
+ vf = float(row.get("admissibility_score"))
1081
+ except (TypeError, ValueError):
1082
+ continue
1083
+ if not math.isfinite(vf):
1084
+ continue
1085
+ buckets[prefix].append(vf)
1086
+ for cat, vals in buckets.items():
1087
+ if not vals:
1088
+ continue
1089
+ if cat == "constitutive":
1090
+ cats[cat] = _min_admissibility(vals)
1091
+ else:
1092
+ cats[cat] = _geom_mean_admissibility(vals)
1093
+ report["per_category"] = cats
1094
+
1095
+
985
1096
  def audit(
986
1097
  log: List[Dict[str, Any]],
987
1098
  r_ref: Optional[Dict[str, float]] = None,
@@ -999,6 +1110,8 @@ def audit(
999
1110
  regions: Any = None,
1000
1111
  region_names: Optional[Mapping[Any, str]] = None,
1001
1112
  region_min_points: Optional[int] = None,
1113
+ nonfinite_policy: Optional[str] = None,
1114
+ eval_mask_label: Optional[str] = None,
1002
1115
  ) -> Dict[str, Any]:
1003
1116
  """
1004
1117
  Physics admissibility from logged **R_eff** (field ``rms``) and scales.
@@ -1226,6 +1339,27 @@ def audit(
1226
1339
 
1227
1340
  report["audit_meta"] = audit_meta(log, r_ref=r_ref)
1228
1341
 
1342
+ _apply_evaluation_coverage_policy(
1343
+ report,
1344
+ log[-1],
1345
+ policy=nonfinite_policy,
1346
+ last_residual_dict=last_residual_dict,
1347
+ eval_mask_label=eval_mask_label,
1348
+ )
1349
+ # Keep log entry aligned with the returned report (strict may null scores).
1350
+ if log:
1351
+ log[-1]["overall_admissibility_score"] = report.get("overall_admissibility_score")
1352
+ log[-1]["overall_admissibility_level"] = report.get("overall_admissibility_level")
1353
+ if report.get("coverage_status") is not None:
1354
+ log[-1]["coverage_status"] = report.get("coverage_status")
1355
+ if report.get("nonfinite_policy") is not None:
1356
+ log[-1]["nonfinite_policy"] = report.get("nonfinite_policy")
1357
+ adm_map = log[-1].get("admissibility_score")
1358
+ if isinstance(adm_map, dict):
1359
+ for k, row in (report.get("per_key") or {}).items():
1360
+ if isinstance(row, dict) and row.get("admissibility_level") == "Incomplete":
1361
+ adm_map[k] = None
1362
+
1229
1363
  if export_dir:
1230
1364
  import zipfile
1231
1365
  from pathlib import Path
@@ -1424,11 +1558,12 @@ def _worst_keys_table_rows(
1424
1558
  continue
1425
1559
  level = str(rep.get("admissibility_level") or "")
1426
1560
  is_ni = level == "Not identifiable" or rep.get("identifiability_status") == "not_identifiable"
1561
+ is_incomplete = level == "Incomplete" or rep.get("coverage_incomplete") is True
1427
1562
  try:
1428
1563
  rn_f = float(rep.get("r_norm"))
1429
1564
  except (TypeError, ValueError):
1430
1565
  rn_f = float("nan")
1431
- if not is_ni and not math.isfinite(rn_f):
1566
+ if not is_ni and not is_incomplete and not math.isfinite(rn_f):
1432
1567
  continue
1433
1568
  try:
1434
1569
  r_eff = float(rep.get("rms"))
@@ -1445,7 +1580,8 @@ def _worst_keys_table_rows(
1445
1580
  else:
1446
1581
  sk_f = float("nan")
1447
1582
  try:
1448
- adm_f = float(rep.get("admissibility_score"))
1583
+ adm_raw = rep.get("admissibility_score")
1584
+ adm_f = float(adm_raw) if adm_raw is not None else float("nan")
1449
1585
  except (TypeError, ValueError):
1450
1586
  adm_f = float("nan")
1451
1587
  prefix = k.split("/", 1)[0] if "/" in str(k) else "other"
@@ -1455,9 +1591,9 @@ def _worst_keys_table_rows(
1455
1591
  "r_eff": r_eff,
1456
1592
  "scale_k": sk_f,
1457
1593
  "r_norm": rn_f,
1458
- "admissibility": adm_f,
1459
- "admissibility_score": adm_f,
1460
- "admissibility_level": level or admissibility_level(adm_f),
1594
+ "admissibility": adm_f if math.isfinite(adm_f) else None,
1595
+ "admissibility_score": adm_f if math.isfinite(adm_f) else None,
1596
+ "admissibility_level": level or admissibility_level(adm_f if math.isfinite(adm_f) else None),
1461
1597
  "category": prefix,
1462
1598
  }
1463
1599
  if rep.get("implied_metric") is not None:
@@ -1477,6 +1613,11 @@ def _worst_keys_table_rows(
1477
1613
  if rep.get("identifiability_reason"):
1478
1614
  row["identifiability_reason"] = rep.get("identifiability_reason")
1479
1615
  not_ident_rows.append(row)
1616
+ elif is_incomplete:
1617
+ row["admissibility_level"] = "Incomplete"
1618
+ row["admissibility"] = None
1619
+ row["admissibility_score"] = None
1620
+ rows.append(row)
1480
1621
  else:
1481
1622
  rows.append(row)
1482
1623
  rows.sort(key=lambda r: r["r_norm"], reverse=True)
@@ -1609,7 +1750,14 @@ def _build_visualize_bundle(
1609
1750
  "bar_display": bar_display,
1610
1751
  "bar_values": bar_values_arr,
1611
1752
  "bar_values_eff": bar_values_eff_arr,
1612
- "overall_adm": [float(metrics[i]["overall_admissibility_score"]) for i in range(n)],
1753
+ "overall_adm": [
1754
+ (
1755
+ float(metrics[i]["overall_admissibility_score"])
1756
+ if metrics[i].get("overall_admissibility_score") is not None
1757
+ else float("nan")
1758
+ )
1759
+ for i in range(n)
1760
+ ],
1613
1761
  "cats_fin": cats_fin,
1614
1762
  "cat_colors": cat_colors,
1615
1763
  "spatial": spatial_parsed,
@@ -2560,6 +2708,7 @@ class ResidualEngine:
2560
2708
  nondim_scales: Any = None,
2561
2709
  derivative_metadata: Optional[Dict[str, Any]] = None,
2562
2710
  eval_mask: Any = None,
2711
+ eval_mask_label: Optional[str] = None,
2563
2712
  state_pred_alt: Optional[Dict[str, Any]] = None,
2564
2713
  estimate_derivative_error: Optional[bool] = None,
2565
2714
  derivative_error_threshold: Optional[float] = None,
@@ -2610,6 +2759,8 @@ class ResidualEngine:
2610
2759
  - ``derivative_metadata``: optional per-key metadata for consistency warnings.
2611
2760
  - ``eval_mask``: boolean array (True = keep). When set, RMS/max for this call use
2612
2761
  masked points only.
2762
+ - ``eval_mask_label``: optional string recorded on evaluation-coverage records for
2763
+ this mask (provenance only; does not change scores).
2613
2764
  - ``state_pred_alt`` / ``estimate_derivative_error``: opt-in derivative-error estimates.
2614
2765
  - ``point_weights`` / ``weighted_norms`` (Moju 2.0 default ``True``): area-weighted
2615
2766
  R_eff for scoring, with a percentile safeguard (see ``weighted_safeguard``). Use
@@ -2641,6 +2792,7 @@ class ResidualEngine:
2641
2792
  nondim_scales=nondim_scales,
2642
2793
  derivative_metadata=derivative_metadata,
2643
2794
  eval_mask=eval_mask,
2795
+ eval_mask_label=eval_mask_label,
2644
2796
  state_pred_alt=state_pred_alt,
2645
2797
  estimate_derivative_error=estimate_derivative_error,
2646
2798
  derivative_error_threshold=derivative_error_threshold,
@@ -2681,6 +2833,7 @@ class ResidualEngine:
2681
2833
  nondim_scales: Any = None,
2682
2834
  derivative_metadata: Optional[Dict[str, Any]] = None,
2683
2835
  eval_mask: Any = None,
2836
+ eval_mask_label: Optional[str] = None,
2684
2837
  state_pred_alt: Optional[Dict[str, Any]] = None,
2685
2838
  estimate_derivative_error: Optional[bool] = None,
2686
2839
  derivative_error_threshold: Optional[float] = None,
@@ -3224,12 +3377,23 @@ class ResidualEngine:
3224
3377
  }
3225
3378
 
3226
3379
  flat = _flatten_residual_dict(residuals)
3380
+ unmasked_flat = flat
3227
3381
  mask_meta: Dict[str, Any] = {}
3228
3382
  if eval_mask is not None:
3229
3383
  from moju.monitor.eval_mask import mask_flat_residuals, mask_provenance
3230
3384
 
3231
3385
  flat = mask_flat_residuals(flat, eval_mask)
3232
3386
  mask_meta = mask_provenance(eval_mask)
3387
+ from moju.monitor.evaluation_coverage import coverage_map_from_flat
3388
+
3389
+ evaluation_coverage = coverage_map_from_flat(
3390
+ flat,
3391
+ eval_mask=eval_mask,
3392
+ eval_mask_label=eval_mask_label,
3393
+ mask_meta=mask_meta or None,
3394
+ unmasked_flat=unmasked_flat,
3395
+ closure_debug=closure_debug or None,
3396
+ )
3233
3397
  rms_per_key = _rms_per_key(flat, to_python=log_to_python)
3234
3398
  r_max_per_key = _max_per_key(flat, to_python=log_to_python, scoring=self._scoring_by_key)
3235
3399
 
@@ -3649,6 +3813,8 @@ class ResidualEngine:
3649
3813
  entry["derivative_error"] = derivative_error_report
3650
3814
  if mask_meta:
3651
3815
  entry["eval_mask"] = mask_meta
3816
+ if evaluation_coverage:
3817
+ entry["evaluation_coverage"] = evaluation_coverage
3652
3818
  if rms_weighted is not None:
3653
3819
  entry["rms_weighted"] = rms_weighted
3654
3820
  entry["weighted_norms"] = {
@@ -3857,6 +4023,8 @@ def evaluate(
3857
4023
  region_names: Optional[Mapping[Any, str]] = None,
3858
4024
  region_min_points: Optional[int] = None,
3859
4025
  evaluate_debug_loop: bool = False,
4026
+ nonfinite_policy: Optional[str] = None,
4027
+ eval_mask_label: Optional[str] = None,
3860
4028
  **compute_kwargs: Any,
3861
4029
  ) -> List[Dict[str, Any]]:
3862
4030
  """
@@ -3953,6 +4121,8 @@ def evaluate(
3953
4121
  implied_percentile=implied_percentile,
3954
4122
  implied_noise_normalization=implied_noise_normalization,
3955
4123
  require_batch=require_batch,
4124
+ nonfinite_policy=nonfinite_policy,
4125
+ eval_mask_label=eval_mask_label,
3956
4126
  )
3957
4127
  if batched is not None:
3958
4128
  for report in batched:
@@ -3973,7 +4143,13 @@ def evaluate(
3973
4143
  **compute_kwargs,
3974
4144
  )
3975
4145
  entry["index"] = i
3976
- report = audit([entry], r_ref=r_ref)
4146
+ report = audit(
4147
+ [entry],
4148
+ r_ref=r_ref,
4149
+ last_residual_dict=residuals if return_residuals else residuals,
4150
+ nonfinite_policy=nonfinite_policy,
4151
+ eval_mask_label=eval_mask_label,
4152
+ )
3977
4153
  if return_residuals:
3978
4154
  report["residuals"] = residuals
3979
4155
  report["execution"] = "loop"
@@ -43,6 +43,8 @@ def evaluate_batched(
43
43
  implied_percentile: Optional[float] = None,
44
44
  implied_noise_normalization: Optional[bool] = None,
45
45
  require_batch: bool = False,
46
+ nonfinite_policy: Optional[str] = None,
47
+ eval_mask_label: Optional[str] = None,
46
48
  ) -> Optional[List[Dict[str, Any]]]:
47
49
  """
48
50
  Score every candidate in one compiled call.
@@ -294,8 +296,24 @@ def evaluate_batched(
294
296
  compact[sk] = jnp.asarray(arr)[i]
295
297
  id_map[str(bn)] = materialize_identifiability_meta(compact)
296
298
  entry["implied_identifiability"] = id_map
299
+ if "coverage_nums" in out:
300
+ from moju.monitor.evaluation_coverage import materialize_coverage_counts
301
+
302
+ cov_map: Dict[str, Any] = {}
303
+ for k, nums in out["coverage_nums"].items():
304
+ host_nums = {sk: jnp.asarray(arr)[i] for sk, arr in nums.items()}
305
+ cov_map[str(k)] = materialize_coverage_counts(
306
+ host_nums, eval_mask_label=eval_mask_label
307
+ )
308
+ entry["evaluation_coverage"] = cov_map
297
309
  entry["monitor_settings"] = ms
298
- report = audit([entry], r_ref=r_ref)
310
+ report = audit(
311
+ [entry],
312
+ r_ref=r_ref,
313
+ last_residual_dict=residuals,
314
+ nonfinite_policy=nonfinite_policy,
315
+ eval_mask_label=eval_mask_label,
316
+ )
299
317
  if return_residuals:
300
318
  report["residuals"] = residuals
301
319
  reports.append(report)
@@ -452,6 +470,10 @@ def _score_one(
452
470
  implied_debug: Dict[str, Dict[str, Any]] = {}
453
471
  # Tracer-safe identifiability scalars (status strings materialised on the host).
454
472
  implied_id: Dict[str, Dict[str, Any]] = {}
473
+ # Pointwise NI masks / row-NI flags for coverage (keyed by residual basename).
474
+ batch_id_masks: Dict[str, Any] = {}
475
+ batch_ni_masks: Dict[str, Any] = {}
476
+ batch_id_row_ni: Dict[str, Any] = {}
455
477
  for audit_spec in plan.audits:
456
478
  spec = audit_spec["spec"]
457
479
  reg = audit_spec["reg"]
@@ -513,6 +535,19 @@ def _score_one(
513
535
  pack[sk] = jnp.asarray(dbg[sk])
514
536
  if pack:
515
537
  implied_id[str(base)] = pack
538
+ if dbg.get("identifiable_mask") is not None:
539
+ batch_id_masks[str(base)] = jnp.asarray(dbg["identifiable_mask"])
540
+ if dbg.get("not_identifiable_mask") is not None:
541
+ batch_ni_masks[str(base)] = jnp.asarray(
542
+ dbg["not_identifiable_mask"]
543
+ )
544
+ # Row-level wipe (tracer-safe 0-d bool): entire δ is NaN for scoring.
545
+ if "identifiable" in dbg:
546
+ batch_id_row_ni[str(base)] = jnp.logical_not(
547
+ jnp.asarray(dbg["identifiable"])
548
+ )
549
+ elif dbg.get("status") == "not_identifiable":
550
+ batch_id_row_ni[str(base)] = jnp.asarray(True)
516
551
  if (
517
552
  dbg.get("noise_rhs") is not None
518
553
  and dbg.get("noise_basis") is not None
@@ -558,6 +593,25 @@ def _score_one(
558
593
  flat = _flat(residuals)
559
594
  rms = {k: r_eff(v) for k, v in flat.items()}
560
595
  rmax = {k: r_max(v) for k, v in flat.items()}
596
+ from moju.monitor.evaluation_coverage import coverage_counts_arrays
597
+
598
+ coverage_nums: Dict[str, Dict[str, Any]] = {}
599
+ for k, v in flat.items():
600
+ id_mask = None
601
+ ni_mask = None
602
+ row_ni: Any = False
603
+ if str(k).startswith("constitutive/") and str(k).endswith("/implied_delta"):
604
+ bn = str(k)[len("constitutive/") : -len("/implied_delta")]
605
+ id_mask = batch_id_masks.get(bn)
606
+ ni_mask = batch_ni_masks.get(bn)
607
+ if bn in batch_id_row_ni:
608
+ row_ni = batch_id_row_ni[bn]
609
+ coverage_nums[k] = coverage_counts_arrays(
610
+ v,
611
+ identifiable_mask=id_mask,
612
+ not_identifiable_mask=ni_mask,
613
+ row_not_identifiable=row_ni,
614
+ )
561
615
  dynamic_scale: Dict[str, Any] = {}
562
616
  recipe_ok: Dict[str, Any] = {}
563
617
  for key, recipe, spec, _is_user in plan.auto_scale:
@@ -582,6 +636,8 @@ def _score_one(
582
636
  "scale": dynamic_scale,
583
637
  "recipe_ok": recipe_ok,
584
638
  }
639
+ if coverage_nums:
640
+ out["coverage_nums"] = coverage_nums
585
641
  if implied_id:
586
642
  out["implied_id"] = implied_id
587
643