moju 2.2.0__tar.gz → 2.3.0__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 (147) hide show
  1. {moju-2.2.0 → moju-2.3.0}/PKG-INFO +1 -1
  2. {moju-2.2.0 → moju-2.3.0}/moju/__init__.py +1 -1
  3. {moju-2.2.0 → moju-2.3.0}/moju/monitor/__init__.py +10 -0
  4. {moju-2.2.0 → moju-2.3.0}/moju/monitor/auditor.py +287 -26
  5. {moju-2.2.0 → moju-2.3.0}/moju/monitor/closure_registry.py +16 -6
  6. moju-2.3.0/moju/monitor/evaluate_batch.py +941 -0
  7. {moju-2.2.0 → moju-2.3.0}/moju/monitor/implied_scoring.py +28 -8
  8. {moju-2.2.0 → moju-2.3.0}/moju/monitor/law_implied_diagnostics.py +62 -20
  9. {moju-2.2.0 → moju-2.3.0}/moju/monitor/monitor_log_export.py +21 -0
  10. moju-2.3.0/moju/monitor/regions.py +807 -0
  11. {moju-2.2.0 → moju-2.3.0}/moju/monitor/report.py +3 -1
  12. {moju-2.2.0 → moju-2.3.0}/moju/torch/_engine.py +9 -0
  13. {moju-2.2.0 → moju-2.3.0}/moju.egg-info/PKG-INFO +1 -1
  14. {moju-2.2.0 → moju-2.3.0}/moju.egg-info/SOURCES.txt +4 -0
  15. {moju-2.2.0 → moju-2.3.0}/pyproject.toml +1 -1
  16. moju-2.3.0/tests/test_evaluate_optin_batched.py +146 -0
  17. moju-2.3.0/tests/test_evaluate_regions_batched.py +233 -0
  18. {moju-2.2.0 → moju-2.3.0}/tests/test_implied_viscosity_ls.py +5 -1
  19. {moju-2.2.0 → moju-2.3.0}/tests/test_imports.py +4 -0
  20. {moju-2.2.0 → moju-2.3.0}/tests/test_paper_goldens.py +2 -1
  21. moju-2.3.0/tests/test_region_aware_scoring.py +583 -0
  22. moju-2.2.0/moju/monitor/evaluate_batch.py +0 -464
  23. {moju-2.2.0 → moju-2.3.0}/LICENSE +0 -0
  24. {moju-2.2.0 → moju-2.3.0}/README.md +0 -0
  25. {moju-2.2.0 → moju-2.3.0}/moju/monitor/analytic_flows.py +0 -0
  26. {moju-2.2.0 → moju-2.3.0}/moju/monitor/audit_meta.py +0 -0
  27. {moju-2.2.0 → moju-2.3.0}/moju/monitor/boundary_closures.py +0 -0
  28. {moju-2.2.0 → moju-2.3.0}/moju/monitor/config.py +0 -0
  29. {moju-2.2.0 → moju-2.3.0}/moju/monitor/constitutive_closure_summary.py +0 -0
  30. {moju-2.2.0 → moju-2.3.0}/moju/monitor/constitutive_closures.py +0 -0
  31. {moju-2.2.0 → moju-2.3.0}/moju/monitor/curvilinear.py +0 -0
  32. {moju-2.2.0 → moju-2.3.0}/moju/monitor/derivative_error.py +0 -0
  33. {moju-2.2.0 → moju-2.3.0}/moju/monitor/derivative_keys.py +0 -0
  34. {moju-2.2.0 → moju-2.3.0}/moju/monitor/derivative_metadata.py +0 -0
  35. {moju-2.2.0 → moju-2.3.0}/moju/monitor/derivative_provenance.py +0 -0
  36. {moju-2.2.0 → moju-2.3.0}/moju/monitor/derived_state_chain.py +0 -0
  37. {moju-2.2.0 → moju-2.3.0}/moju/monitor/eval_mask.py +0 -0
  38. {moju-2.2.0 → moju-2.3.0}/moju/monitor/fd_stencils.py +0 -0
  39. {moju-2.2.0 → moju-2.3.0}/moju/monitor/key_declarations.py +0 -0
  40. {moju-2.2.0 → moju-2.3.0}/moju/monitor/law_fd_recipes.py +0 -0
  41. {moju-2.2.0 → moju-2.3.0}/moju/monitor/law_group_inference.py +0 -0
  42. {moju-2.2.0 → moju-2.3.0}/moju/monitor/law_scale_recipes.py +0 -0
  43. {moju-2.2.0 → moju-2.3.0}/moju/monitor/model_derived_registry.py +0 -0
  44. {moju-2.2.0 → moju-2.3.0}/moju/monitor/nondim_inference.py +0 -0
  45. {moju-2.2.0 → moju-2.3.0}/moju/monitor/nondim_scales_parse.py +0 -0
  46. {moju-2.2.0 → moju-2.3.0}/moju/monitor/path_b_derivatives.py +0 -0
  47. {moju-2.2.0 → moju-2.3.0}/moju/monitor/path_b_spectral.py +0 -0
  48. {moju-2.2.0 → moju-2.3.0}/moju/monitor/resolution_sensor.py +0 -0
  49. {moju-2.2.0 → moju-2.3.0}/moju/monitor/scaling_closures.py +0 -0
  50. {moju-2.2.0 → moju-2.3.0}/moju/monitor/spatial_rnorm_panels.py +0 -0
  51. {moju-2.2.0 → moju-2.3.0}/moju/monitor/state_builder.py +0 -0
  52. {moju-2.2.0 → moju-2.3.0}/moju/monitor/state_ref.py +0 -0
  53. {moju-2.2.0 → moju-2.3.0}/moju/monitor/term_breakdown.py +0 -0
  54. {moju-2.2.0 → moju-2.3.0}/moju/monitor/tiers.py +0 -0
  55. {moju-2.2.0 → moju-2.3.0}/moju/monitor/types.py +0 -0
  56. {moju-2.2.0 → moju-2.3.0}/moju/monitor/visualize_components.py +0 -0
  57. {moju-2.2.0 → moju-2.3.0}/moju/monitor/visualize_constitutive.py +0 -0
  58. {moju-2.2.0 → moju-2.3.0}/moju/monitor/visualize_export.py +0 -0
  59. {moju-2.2.0 → moju-2.3.0}/moju/monitor/visualize_labels.py +0 -0
  60. {moju-2.2.0 → moju-2.3.0}/moju/monitor/visualize_plotly.py +0 -0
  61. {moju-2.2.0 → moju-2.3.0}/moju/monitor/visualize_theme.py +0 -0
  62. {moju-2.2.0 → moju-2.3.0}/moju/monitor/weighted_norms.py +0 -0
  63. {moju-2.2.0 → moju-2.3.0}/moju/monitor/weighted_safeguard.py +0 -0
  64. {moju-2.2.0 → moju-2.3.0}/moju/piratio/__init__.py +0 -0
  65. {moju-2.2.0 → moju-2.3.0}/moju/piratio/groups.py +0 -0
  66. {moju-2.2.0 → moju-2.3.0}/moju/piratio/laws.py +0 -0
  67. {moju-2.2.0 → moju-2.3.0}/moju/piratio/models.py +0 -0
  68. {moju-2.2.0 → moju-2.3.0}/moju/piratio/nondim.py +0 -0
  69. {moju-2.2.0 → moju-2.3.0}/moju/piratio/operators.py +0 -0
  70. {moju-2.2.0 → moju-2.3.0}/moju/registry.py +0 -0
  71. {moju-2.2.0 → moju-2.3.0}/moju/torch/__init__.py +0 -0
  72. {moju-2.2.0 → moju-2.3.0}/moju/torch/_closure.py +0 -0
  73. {moju-2.2.0 → moju-2.3.0}/moju/torch/_derived.py +0 -0
  74. {moju-2.2.0 → moju-2.3.0}/moju/torch/_fd_stencils.py +0 -0
  75. {moju-2.2.0 → moju-2.3.0}/moju/torch/_implied_diagnostics.py +0 -0
  76. {moju-2.2.0 → moju-2.3.0}/moju/torch/_nondim.py +0 -0
  77. {moju-2.2.0 → moju-2.3.0}/moju/torch/_path_b.py +0 -0
  78. {moju-2.2.0 → moju-2.3.0}/moju/torch/_r_eff.py +0 -0
  79. {moju-2.2.0 → moju-2.3.0}/moju/torch_interop.py +0 -0
  80. {moju-2.2.0 → moju-2.3.0}/moju.egg-info/dependency_links.txt +0 -0
  81. {moju-2.2.0 → moju-2.3.0}/moju.egg-info/requires.txt +0 -0
  82. {moju-2.2.0 → moju-2.3.0}/moju.egg-info/top_level.txt +0 -0
  83. {moju-2.2.0 → moju-2.3.0}/setup.cfg +0 -0
  84. {moju-2.2.0 → moju-2.3.0}/tests/test_analytic_ns_diagnostics.py +0 -0
  85. {moju-2.2.0 → moju-2.3.0}/tests/test_audit_meta.py +0 -0
  86. {moju-2.2.0 → moju-2.3.0}/tests/test_auditor.py +0 -0
  87. {moju-2.2.0 → moju-2.3.0}/tests/test_builtin_law_evaluate_batch.py +0 -0
  88. {moju-2.2.0 → moju-2.3.0}/tests/test_closure_debug_sidecar.py +0 -0
  89. {moju-2.2.0 → moju-2.3.0}/tests/test_compat_2_0_defaults.py +0 -0
  90. {moju-2.2.0 → moju-2.3.0}/tests/test_config_forms.py +0 -0
  91. {moju-2.2.0 → moju-2.3.0}/tests/test_constitutive_closure_summary.py +0 -0
  92. {moju-2.2.0 → moju-2.3.0}/tests/test_custom_law_hooks.py +0 -0
  93. {moju-2.2.0 → moju-2.3.0}/tests/test_derivative_provenance.py +0 -0
  94. {moju-2.2.0 → moju-2.3.0}/tests/test_derived_state_chain.py +0 -0
  95. {moju-2.2.0 → moju-2.3.0}/tests/test_evaluate.py +0 -0
  96. {moju-2.2.0 → moju-2.3.0}/tests/test_examples_implied_cookbooks.py +0 -0
  97. {moju-2.2.0 → moju-2.3.0}/tests/test_examples_path_b_fd_cookbooks.py +0 -0
  98. {moju-2.2.0 → moju-2.3.0}/tests/test_examples_turbulence_cookbooks.py +0 -0
  99. {moju-2.2.0 → moju-2.3.0}/tests/test_fd_stencils.py +0 -0
  100. {moju-2.2.0 → moju-2.3.0}/tests/test_fornberg_and_dual_spacing.py +0 -0
  101. {moju-2.2.0 → moju-2.3.0}/tests/test_groups.py +0 -0
  102. {moju-2.2.0 → moju-2.3.0}/tests/test_implied_robust_scoring.py +0 -0
  103. {moju-2.2.0 → moju-2.3.0}/tests/test_jax_torch_fd_parity.py +0 -0
  104. {moju-2.2.0 → moju-2.3.0}/tests/test_law_fd_recipes.py +0 -0
  105. {moju-2.2.0 → moju-2.3.0}/tests/test_law_group_inference.py +0 -0
  106. {moju-2.2.0 → moju-2.3.0}/tests/test_law_implied_diagnostics.py +0 -0
  107. {moju-2.2.0 → moju-2.3.0}/tests/test_law_registry.py +0 -0
  108. {moju-2.2.0 → moju-2.3.0}/tests/test_law_scale_recipes.py +0 -0
  109. {moju-2.2.0 → moju-2.3.0}/tests/test_laws.py +0 -0
  110. {moju-2.2.0 → moju-2.3.0}/tests/test_minimal_input_flow.py +0 -0
  111. {moju-2.2.0 → moju-2.3.0}/tests/test_model_derived_registry.py +0 -0
  112. {moju-2.2.0 → moju-2.3.0}/tests/test_models.py +0 -0
  113. {moju-2.2.0 → moju-2.3.0}/tests/test_monitor_log_export.py +0 -0
  114. {moju-2.2.0 → moju-2.3.0}/tests/test_nondim.py +0 -0
  115. {moju-2.2.0 → moju-2.3.0}/tests/test_nondim_inference.py +0 -0
  116. {moju-2.2.0 → moju-2.3.0}/tests/test_notebooks_media.py +0 -0
  117. {moju-2.2.0 → moju-2.3.0}/tests/test_operators.py +0 -0
  118. {moju-2.2.0 → moju-2.3.0}/tests/test_path_b_derivatives.py +0 -0
  119. {moju-2.2.0 → moju-2.3.0}/tests/test_path_b_spectral.py +0 -0
  120. {moju-2.2.0 → moju-2.3.0}/tests/test_phase_a2_derivative_layer.py +0 -0
  121. {moju-2.2.0 → moju-2.3.0}/tests/test_phase_a5_periodic.py +0 -0
  122. {moju-2.2.0 → moju-2.3.0}/tests/test_readme_examples.py +0 -0
  123. {moju-2.2.0 → moju-2.3.0}/tests/test_readme_pypi_links.py +0 -0
  124. {moju-2.2.0 → moju-2.3.0}/tests/test_registry.py +0 -0
  125. {moju-2.2.0 → moju-2.3.0}/tests/test_report_schema.py +0 -0
  126. {moju-2.2.0 → moju-2.3.0}/tests/test_safeguard_diagnostics.py +0 -0
  127. {moju-2.2.0 → moju-2.3.0}/tests/test_scoring_and_bounds.py +0 -0
  128. {moju-2.2.0 → moju-2.3.0}/tests/test_spatial_rnorm_panels.py +0 -0
  129. {moju-2.2.0 → moju-2.3.0}/tests/test_split_admissibility.py +0 -0
  130. {moju-2.2.0 → moju-2.3.0}/tests/test_state_ref.py +0 -0
  131. {moju-2.2.0 → moju-2.3.0}/tests/test_state_ref_hdf5.py +0 -0
  132. {moju-2.2.0 → moju-2.3.0}/tests/test_state_ref_meshio.py +0 -0
  133. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_auto_config.py +0 -0
  134. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_dependency_planner.py +0 -0
  135. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_implied_groups.py +0 -0
  136. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_io.py +0 -0
  137. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_law_fd_hints.py +0 -0
  138. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_model_derived_registry.py +0 -0
  139. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_pi_gating.py +0 -0
  140. {moju-2.2.0 → moju-2.3.0}/tests/test_studio_plots.py +0 -0
  141. {moju-2.2.0 → moju-2.3.0}/tests/test_torch_engine.py +0 -0
  142. {moju-2.2.0 → moju-2.3.0}/tests/test_torch_interop.py +0 -0
  143. {moju-2.2.0 → moju-2.3.0}/tests/test_visualize_components.py +0 -0
  144. {moju-2.2.0 → moju-2.3.0}/tests/test_visualize_constitutive.py +0 -0
  145. {moju-2.2.0 → moju-2.3.0}/tests/test_visualize_labels.py +0 -0
  146. {moju-2.2.0 → moju-2.3.0}/tests/test_visualize_parity.py +0 -0
  147. {moju-2.2.0 → moju-2.3.0}/tests/test_visualize_theme.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moju
3
- Version: 2.2.0
3
+ Version: 2.3.0
4
4
  Summary: Physics supervision and audit tools for SciML models, with JAX-native residuals and PyTorch support.
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>
@@ -1,3 +1,3 @@
1
1
  """Moju: Physics-AI supervision for engineering-grade simulations."""
2
2
 
3
- __version__ = "2.2.0"
3
+ __version__ = "2.3.0"
@@ -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.regions import (
49
+ DEFAULT_REGION_MIN_POINTS,
50
+ normalize_regions,
51
+ percentile_tail_adequacy,
52
+ validate_region_min_points,
53
+ )
48
54
  from moju.monitor.law_implied_diagnostics import (
49
55
  DEFAULT_IMPLIED_TERM_FRACTION,
50
56
  ImpliedPropertyResult,
@@ -148,6 +154,10 @@ __all__ = [
148
154
  "parse_implied_scoring_options",
149
155
  "resolve_implied_scoring_options",
150
156
  "score_from_r_max",
157
+ "DEFAULT_REGION_MIN_POINTS",
158
+ "normalize_regions",
159
+ "percentile_tail_adequacy",
160
+ "validate_region_min_points",
151
161
  "ImpliedPropertyResult",
152
162
  "effective_audit_specs_for_fragment",
153
163
  "law_implied_unsupported_reasons",
@@ -53,6 +53,8 @@ from moju.monitor.closure_registry import (
53
53
  from moju.monitor.derived_state_chain import all_ref_keys_from_chain, keys_produced_by_chain
54
54
  from moju.monitor.model_derived_registry import enrich_derived_state_from_constitutive_audits
55
55
  from moju.monitor.law_implied_diagnostics import (
56
+ is_not_identifiable,
57
+ materialize_identifiability_meta,
56
58
  merge_fragment_law_implied_audit_specs,
57
59
  merge_law_implied_audit_specs,
58
60
  supported_auto_implied_laws_for,
@@ -776,8 +778,7 @@ def _compute_log_step_metrics(
776
778
  bn = k[len("constitutive/") : -len("/implied_delta")]
777
779
  meta = id_map.get(bn) if isinstance(id_map.get(bn), dict) else None
778
780
  if meta and (
779
- meta.get("status") == "not_identifiable"
780
- or meta.get("identifiable") is False
781
+ is_not_identifiable(meta)
781
782
  ):
782
783
  level = "Not identifiable"
783
784
  id_meta = meta
@@ -837,10 +838,7 @@ def _compute_log_step_metrics(
837
838
  ):
838
839
  bn = kk[len("constitutive/") : -len("/implied_delta")]
839
840
  meta = id_map_entry.get(bn) if isinstance(id_map_entry.get(bn), dict) else None
840
- if meta and (
841
- meta.get("status") == "not_identifiable"
842
- or meta.get("identifiable") is False
843
- ):
841
+ if meta and is_not_identifiable(meta):
844
842
  continue
845
843
  try:
846
844
  vf = float(admissibility[kk])
@@ -990,6 +988,9 @@ def audit(
990
988
  implied_percentile: Optional[float] = None,
991
989
  implied_noise_normalization: Optional[bool] = None,
992
990
  constants: Optional[Mapping[str, Any]] = None,
991
+ regions: Any = None,
992
+ region_names: Optional[Mapping[Any, str]] = None,
993
+ region_min_points: Optional[int] = None,
993
994
  ) -> Dict[str, Any]:
994
995
  """
995
996
  Physics admissibility from logged **R_eff** (field ``rms``) and scales.
@@ -1013,6 +1014,10 @@ def audit(
1013
1014
  finite present category score (missing categories excluded); for legacy entries without
1014
1015
  ``run_mode``, minimum finite score across all present categories.
1015
1016
 
1017
+ Optional ``regions`` / ``region_names`` / ``region_min_points`` (per-call > any
1018
+ ``region_results`` already on the log from the engine) recompute per-region tables
1019
+ when ``last_residual_dict`` is provided. Global scores are unchanged.
1020
+
1016
1021
  The returned dict includes ``monitor_run_mode`` from the last log entry when present, plus
1017
1022
  ``schema_version`` (:data:`moju.monitor.types.REPORT_SCHEMA_VERSION`), ``moju_version``, and
1018
1023
  ``tier_definition`` (:data:`moju.monitor.tiers.TIER_DEFINITION`).
@@ -1057,6 +1062,83 @@ def audit(
1057
1062
  log[-1]["implied_scoring_settings"] = implied_scoring_settings_payload(
1058
1063
  _opts, moju_version=_moju_version
1059
1064
  )
1065
+
1066
+ # Per-call regions on audit() override / (re)compute region_results.
1067
+ if regions is not None:
1068
+ if last_residual_dict is None:
1069
+ raise ValueError(
1070
+ "audit(..., regions=...) requires last_residual_dict to build per-region tables"
1071
+ )
1072
+ from moju import __version__ as _moju_version
1073
+ from moju.monitor.implied_scoring import resolve_implied_scoring_options
1074
+ from moju.monitor.regions import (
1075
+ DEFAULT_REGION_MIN_POINTS,
1076
+ compute_region_results,
1077
+ normalize_regions,
1078
+ validate_region_min_points,
1079
+ )
1080
+
1081
+ _opts = resolve_implied_scoring_options(
1082
+ constants=constants if constants is not None else (log[-1].get("constants") or {}),
1083
+ engine_defaults={
1084
+ "implied_metric": (log[-1].get("implied_scoring_settings") or {}).get(
1085
+ "implied_metric"
1086
+ ),
1087
+ "implied_percentile": (log[-1].get("implied_scoring_settings") or {}).get(
1088
+ "implied_percentile"
1089
+ ),
1090
+ "implied_noise_normalization": (log[-1].get("implied_scoring_settings") or {}).get(
1091
+ "implied_noise_normalization"
1092
+ ),
1093
+ },
1094
+ implied_metric=implied_metric,
1095
+ implied_percentile=implied_percentile,
1096
+ implied_noise_normalization=implied_noise_normalization,
1097
+ )
1098
+ ref_shape = None
1099
+ for _content in (
1100
+ list((last_residual_dict.get("laws") or {}).values())
1101
+ + list((last_residual_dict.get("constitutive") or {}).values())
1102
+ ):
1103
+ a = jnp.asarray(_content)
1104
+ if a.size > 0:
1105
+ if a.ndim >= 2 and a.shape[-1] in (2, 3):
1106
+ ref_shape = a.shape[:-1]
1107
+ else:
1108
+ ref_shape = a.shape
1109
+ break
1110
+ normalized = normalize_regions(
1111
+ regions,
1112
+ reference_shape=ref_shape,
1113
+ region_names=region_names,
1114
+ )
1115
+ eff_rmin = (
1116
+ validate_region_min_points(region_min_points)
1117
+ if region_min_points is not None
1118
+ else DEFAULT_REGION_MIN_POINTS
1119
+ )
1120
+ # Prefer an existing engine-stored floor when per-call omits region_min_points.
1121
+ if region_min_points is None:
1122
+ prev = (log[-1].get("region_results") or {}).get("provenance") or {}
1123
+ if "region_min_points" in prev:
1124
+ eff_rmin = int(prev["region_min_points"])
1125
+ else:
1126
+ ms_r = ((log[-1].get("monitor_settings") or {}).get("regions") or {})
1127
+ if "region_min_points" in ms_r:
1128
+ eff_rmin = int(ms_r["region_min_points"])
1129
+ region_block = compute_region_results(
1130
+ residual_dict=last_residual_dict,
1131
+ entry=log[-1],
1132
+ normalized_regions=normalized,
1133
+ region_min_points=eff_rmin,
1134
+ implied_scoring_opts=_opts,
1135
+ moju_version=_moju_version,
1136
+ )
1137
+ log[-1]["region_results"] = region_block
1138
+ ms = dict(log[-1].get("monitor_settings") or {})
1139
+ ms["regions"] = region_block.get("provenance")
1140
+ log[-1]["monitor_settings"] = ms
1141
+
1060
1142
  step_metrics = _compute_log_step_metrics(log, r_ref)
1061
1143
  last_report_per_key: Dict[str, Any] = {}
1062
1144
  for entry, m in zip(log, step_metrics):
@@ -1083,6 +1165,41 @@ def audit(
1083
1165
  report["implied_identifiability"] = dict(log[-1]["implied_identifiability"])
1084
1166
  if log[-1].get("implied_scoring"):
1085
1167
  report["implied_scoring"] = dict(log[-1]["implied_scoring"])
1168
+ if log[-1].get("region_results"):
1169
+ report["region_results"] = log[-1]["region_results"]
1170
+
1171
+ # Global percentile adequacy (same arithmetic as per-region flags).
1172
+ if log[-1].get("percentile_adequacy"):
1173
+ report["percentile_adequacy"] = dict(log[-1]["percentile_adequacy"])
1174
+ else:
1175
+ _iss = report.get("implied_scoring_settings") or {}
1176
+ if str(_iss.get("implied_metric", "")) == "percentile":
1177
+ from moju.monitor.regions import percentile_tail_adequacy
1178
+
1179
+ _pct = float(_iss.get("implied_percentile", 95.0))
1180
+ n_global = None
1181
+ if last_residual_dict is not None:
1182
+ for _cat in ("laws", "constitutive"):
1183
+ for _arr in (last_residual_dict.get(_cat) or {}).values():
1184
+ a = jnp.asarray(_arr)
1185
+ if a.size == 0:
1186
+ continue
1187
+ if a.ndim >= 2 and a.shape[-1] in (2, 3):
1188
+ n_global = int(math.prod(a.shape[:-1]))
1189
+ else:
1190
+ n_global = int(a.size)
1191
+ break
1192
+ if n_global is not None:
1193
+ break
1194
+ if n_global is None:
1195
+ rr = report.get("region_results") or {}
1196
+ prov = rr.get("provenance") or {}
1197
+ if prov.get("n_total") is not None:
1198
+ n_global = int(prov["n_total"])
1199
+ if n_global is not None:
1200
+ pa = percentile_tail_adequacy(n_global, _pct)
1201
+ report["percentile_adequacy"] = pa
1202
+ log[-1]["percentile_adequacy"] = pa
1086
1203
  for opt_key in (
1087
1204
  "term_breakdown",
1088
1205
  "derivative_metadata",
@@ -2172,6 +2289,9 @@ class ResidualEngine:
2172
2289
  implied_metric: Optional[str] = None,
2173
2290
  implied_percentile: Optional[float] = None,
2174
2291
  implied_noise_normalization: Optional[bool] = None,
2292
+ regions: Any = None,
2293
+ region_names: Optional[Mapping[Any, str]] = None,
2294
+ region_min_points: Optional[int] = None,
2175
2295
  ):
2176
2296
  law_implied_enabled = bool(law_implied_audits)
2177
2297
  cfg_law_scale_mode: Optional[str] = None
@@ -2277,6 +2397,7 @@ class ResidualEngine:
2277
2397
  self.implied_percentile = implied_percentile
2278
2398
  self.implied_noise_normalization = implied_noise_normalization
2279
2399
  from moju.monitor.implied_scoring import resolve_implied_scoring_options
2400
+ from moju.monitor.regions import DEFAULT_REGION_MIN_POINTS, validate_region_min_points
2280
2401
 
2281
2402
  # Validate constructor / constants combination at init (raises on bad values).
2282
2403
  resolve_implied_scoring_options(
@@ -2287,6 +2408,14 @@ class ResidualEngine:
2287
2408
  "implied_noise_normalization": implied_noise_normalization,
2288
2409
  },
2289
2410
  )
2411
+ # Regions (Moju 2.3): constructor defaults; not accepted via constants.
2412
+ self.regions = regions
2413
+ self.region_names = dict(region_names) if region_names is not None else None
2414
+ self.region_min_points = (
2415
+ validate_region_min_points(region_min_points)
2416
+ if region_min_points is not None
2417
+ else DEFAULT_REGION_MIN_POINTS
2418
+ )
2290
2419
 
2291
2420
  # Config-time validation (low effort)
2292
2421
  def _validate_specs(
@@ -2434,6 +2563,9 @@ class ResidualEngine:
2434
2563
  implied_metric: Optional[str] = None,
2435
2564
  implied_percentile: Optional[float] = None,
2436
2565
  implied_noise_normalization: Optional[bool] = None,
2566
+ regions: Any = None,
2567
+ region_names: Optional[Mapping[Any, str]] = None,
2568
+ region_min_points: Optional[int] = None,
2437
2569
  ) -> Dict[str, Any]:
2438
2570
  """
2439
2571
  Compute residuals.
@@ -2512,6 +2644,9 @@ class ResidualEngine:
2512
2644
  implied_metric=implied_metric,
2513
2645
  implied_percentile=implied_percentile,
2514
2646
  implied_noise_normalization=implied_noise_normalization,
2647
+ regions=regions,
2648
+ region_names=region_names,
2649
+ region_min_points=region_min_points,
2515
2650
  )
2516
2651
  self._log.append(entry)
2517
2652
  self._index += 1
@@ -2549,6 +2684,9 @@ class ResidualEngine:
2549
2684
  implied_metric: Optional[str] = None,
2550
2685
  implied_percentile: Optional[float] = None,
2551
2686
  implied_noise_normalization: Optional[bool] = None,
2687
+ regions: Any = None,
2688
+ region_names: Optional[Mapping[Any, str]] = None,
2689
+ region_min_points: Optional[int] = None,
2552
2690
  ) -> Tuple[Dict[str, Any], Dict[str, Any]]:
2553
2691
  """Compute residuals and one log entry without touching the engine log."""
2554
2692
  if run_mode not in ("training", "eval"):
@@ -3010,6 +3148,22 @@ class ResidualEngine:
3010
3148
  debug["category"] = category
3011
3149
  debug["model_name"] = str(name)
3012
3150
  debug["state_map"] = dict(state_map)
3151
+ # Host path: materialize tracer-friendly identifiability scalars.
3152
+ if log_to_python and (
3153
+ "identifiable" in debug or debug.get("status") == "not_identifiable"
3154
+ ):
3155
+ debug.update(
3156
+ materialize_identifiability_meta(
3157
+ {
3158
+ "identifiable": debug.get("identifiable", True),
3159
+ "status": debug.get("status"),
3160
+ "excluded_fraction": debug.get("excluded_fraction"),
3161
+ "n_identifiable": debug.get("n_identifiable"),
3162
+ "n_total": debug.get("n_total"),
3163
+ "reason": debug.get("reason"),
3164
+ }
3165
+ )
3166
+ )
3013
3167
  debug_collector[base] = debug
3014
3168
 
3015
3169
  return out
@@ -3385,17 +3539,18 @@ class ResidualEngine:
3385
3539
  if not isinstance(dbg, dict):
3386
3540
  continue
3387
3541
  if "identifiable" in dbg or dbg.get("status") == "not_identifiable":
3388
- compact: Dict[str, Any] = {
3389
- "identifiable": bool(dbg.get("identifiable", True)),
3390
- "status": dbg.get("status", "ok"),
3391
- "excluded_fraction": dbg.get("excluded_fraction"),
3392
- "n_identifiable": dbg.get("n_identifiable"),
3393
- "n_total": dbg.get("n_total"),
3394
- "conditioning_threshold": dbg.get("conditioning_threshold"),
3395
- "fit_quality_threshold": dbg.get("fit_quality_threshold"),
3396
- }
3397
- if dbg.get("reason"):
3398
- compact["reason"] = dbg["reason"]
3542
+ compact = materialize_identifiability_meta(
3543
+ {
3544
+ "identifiable": dbg.get("identifiable", True),
3545
+ "status": dbg.get("status"),
3546
+ "excluded_fraction": dbg.get("excluded_fraction"),
3547
+ "n_identifiable": dbg.get("n_identifiable"),
3548
+ "n_total": dbg.get("n_total"),
3549
+ "conditioning_threshold": dbg.get("conditioning_threshold"),
3550
+ "fit_quality_threshold": dbg.get("fit_quality_threshold"),
3551
+ "reason": dbg.get("reason"),
3552
+ }
3553
+ )
3399
3554
  implied_identifiability[str(bn)] = compact
3400
3555
 
3401
3556
  # Opt-in robust scoring for constitutive implied_delta (defaults = 2.1).
@@ -3448,6 +3603,7 @@ class ResidualEngine:
3448
3603
  "scale": scale_per_key,
3449
3604
  "scale_source": scale_source_per_key,
3450
3605
  "run_mode": run_mode,
3606
+ "constants": dict(self.constants),
3451
3607
  "monitor_settings": {
3452
3608
  "law_scale_mode": eff_law_scale_mode,
3453
3609
  "state_units": eff_state_units,
@@ -3574,6 +3730,71 @@ class ResidualEngine:
3574
3730
  cs = _coord_snapshot_from_merged(merged)
3575
3731
  if cs:
3576
3732
  entry["coord_snapshot"] = cs
3733
+
3734
+ # Per-region fit/score (Moju 2.3). Global scores above are unchanged.
3735
+ eff_regions = self.regions if regions is None else regions
3736
+ eff_region_names = self.region_names if region_names is None else region_names
3737
+ from moju.monitor.regions import (
3738
+ DEFAULT_REGION_MIN_POINTS,
3739
+ compute_region_results,
3740
+ normalize_regions,
3741
+ validate_region_min_points,
3742
+ )
3743
+
3744
+ eff_rmin = (
3745
+ validate_region_min_points(region_min_points)
3746
+ if region_min_points is not None
3747
+ else int(self.region_min_points or DEFAULT_REGION_MIN_POINTS)
3748
+ )
3749
+ if eff_regions is not None:
3750
+ # Infer point count from a primary residual if available.
3751
+ ref_shape = None
3752
+ for _k, _arr in flat.items():
3753
+ a = jnp.asarray(_arr)
3754
+ if a.size > 0:
3755
+ if a.ndim >= 2 and a.shape[-1] in (2, 3):
3756
+ ref_shape = a.shape[:-1]
3757
+ else:
3758
+ ref_shape = a.shape
3759
+ break
3760
+ normalized = normalize_regions(
3761
+ eff_regions,
3762
+ reference_shape=ref_shape,
3763
+ region_names=eff_region_names,
3764
+ )
3765
+ region_block = compute_region_results(
3766
+ residual_dict=residuals,
3767
+ entry=entry,
3768
+ normalized_regions=normalized,
3769
+ region_min_points=eff_rmin,
3770
+ implied_scoring_opts=implied_scoring_opts,
3771
+ moju_version=_moju_version,
3772
+ )
3773
+ entry["region_results"] = region_block
3774
+ ms = dict(entry.get("monitor_settings") or {})
3775
+ ms["regions"] = region_block.get("provenance")
3776
+ entry["monitor_settings"] = ms
3777
+
3778
+ # Global percentile adequacy (independent of whether regions were set).
3779
+ if str(implied_scoring_opts.get("implied_metric", "")) == "percentile":
3780
+ from moju.monitor.regions import percentile_tail_adequacy
3781
+
3782
+ n_global = None
3783
+ for _k, _arr in flat.items():
3784
+ a = jnp.asarray(_arr)
3785
+ if a.size == 0:
3786
+ continue
3787
+ if a.ndim >= 2 and a.shape[-1] in (2, 3):
3788
+ n_global = int(math.prod(a.shape[:-1]))
3789
+ else:
3790
+ n_global = int(a.size)
3791
+ break
3792
+ if n_global is not None:
3793
+ entry["percentile_adequacy"] = percentile_tail_adequacy(
3794
+ n_global,
3795
+ float(implied_scoring_opts.get("implied_percentile", 95.0)),
3796
+ )
3797
+
3577
3798
  return residuals, entry
3578
3799
 
3579
3800
  def required_state_keys(
@@ -3621,20 +3842,25 @@ def evaluate(
3621
3842
  implied_metric: Optional[str] = None,
3622
3843
  implied_percentile: Optional[float] = None,
3623
3844
  implied_noise_normalization: Optional[bool] = None,
3845
+ regions: Any = None,
3846
+ region_names: Optional[Mapping[Any, str]] = None,
3847
+ region_min_points: Optional[int] = None,
3848
+ evaluate_debug_loop: bool = False,
3624
3849
  **compute_kwargs: Any,
3625
3850
  ) -> List[Dict[str, Any]]:
3626
3851
  """
3627
3852
  One :func:`audit` report per candidate; ``engine.log`` is not modified.
3628
3853
 
3629
3854
  Same-shaped candidates are scored in one :func:`jax.jit` / :func:`jax.vmap` call
3630
- (``report["execution"] == "batched"``). Mismatched shapes, an untraceable callable, or
3631
- derivative fill (``auto_path_b_derivatives`` / ``fill_law_fd``) keep the per-candidate
3632
- loop (``report["execution"] == "loop"``).
3855
+ (``report["execution"] == "batched"``), including when ``regions`` is set (masks are
3856
+ static across the batch) and when opt-in implied scoring is used without regions.
3857
+ Path B / ``fill_law_fd`` is loop-only. With ``regions`` and/or opt-in scoring,
3858
+ batching is required unless ``evaluate_debug_loop=True`` (no silent fallback).
3633
3859
 
3634
3860
  ``state_refs``, when given, must have the same length as ``candidates``. Further keywords are
3635
3861
  forwarded to :meth:`ResidualEngine._compute_entry` (``law_scale_mode``, ``state_units``,
3636
- ``implied_metric`` / ``implied_percentile`` / ``implied_noise_normalization``, and so on).
3637
- Explicit implied-scoring kwargs take precedence over ``engine.constants`` (see
3862
+ ``implied_metric`` / ``implied_percentile`` / ``implied_noise_normalization``, ``regions``,
3863
+ and so on). Explicit implied-scoring kwargs take precedence over ``engine.constants`` (see
3638
3864
  :func:`moju.monitor.implied_scoring.resolve_implied_scoring_options`).
3639
3865
  Set ``return_residuals=True`` to attach each candidate's residual dict under ``"residuals"``.
3640
3866
  """
@@ -3645,10 +3871,19 @@ def evaluate(
3645
3871
  "pass implied_metric / implied_percentile / implied_noise_normalization as "
3646
3872
  "explicit keyword arguments to evaluate(...), not via **compute_kwargs"
3647
3873
  )
3874
+ for banned in ("regions", "region_names", "region_min_points", "evaluate_debug_loop"):
3875
+ if banned in compute_kwargs:
3876
+ raise TypeError(
3877
+ f"pass {banned} as an explicit keyword argument to evaluate(...), "
3878
+ "not via **compute_kwargs"
3879
+ )
3648
3880
  scoring_kwargs = {
3649
3881
  "implied_metric": implied_metric,
3650
3882
  "implied_percentile": implied_percentile,
3651
3883
  "implied_noise_normalization": implied_noise_normalization,
3884
+ "regions": regions,
3885
+ "region_names": region_names,
3886
+ "region_min_points": region_min_points,
3652
3887
  }
3653
3888
  from moju.monitor.implied_scoring import (
3654
3889
  is_default_implied_scoring,
@@ -3662,10 +3897,14 @@ def evaluate(
3662
3897
  "implied_percentile": engine.implied_percentile,
3663
3898
  "implied_noise_normalization": engine.implied_noise_normalization,
3664
3899
  },
3665
- **scoring_kwargs,
3900
+ implied_metric=implied_metric,
3901
+ implied_percentile=implied_percentile,
3902
+ implied_noise_normalization=implied_noise_normalization,
3666
3903
  )
3667
- # Batched evaluate path scores with 2.1 worst-point only; opt-in settings use the loop.
3668
- force_loop = not is_default_implied_scoring(resolved_scoring)
3904
+ eff_regions = engine.regions if regions is None else regions
3905
+ optin = not is_default_implied_scoring(resolved_scoring)
3906
+ # Path B / fill_law_fd is loop-only today (with or without regions). Regions + Path B
3907
+ # raises unless evaluate_debug_loop=True; see MIGRATION_2.2_to_2.3.md.
3669
3908
  n = len(candidates)
3670
3909
  if state_refs is None:
3671
3910
  refs: Sequence[Optional[Mapping[str, Any]]] = (None,) * n
@@ -3676,9 +3915,17 @@ def evaluate(
3676
3915
  )
3677
3916
  refs = state_refs
3678
3917
  fd_fill = bool(compute_kwargs.get("auto_path_b_derivatives") or compute_kwargs.get("fill_law_fd"))
3679
- if not fd_fill and not force_loop:
3918
+ use_debug_loop = bool(evaluate_debug_loop)
3919
+ if eff_regions is not None and fd_fill and not use_debug_loop:
3920
+ raise RuntimeError(
3921
+ "evaluate(..., regions=...) cannot combine Path B / fill_law_fd with the "
3922
+ "batched region path (Path B evaluate is loop-only regardless of regions). "
3923
+ "Omit derivative fill, or pass evaluate_debug_loop=True."
3924
+ )
3925
+ if not fd_fill and not use_debug_loop:
3680
3926
  from moju.monitor.evaluate_batch import evaluate_batched
3681
3927
 
3928
+ require_batch = (eff_regions is not None) or optin
3682
3929
  batched = evaluate_batched(
3683
3930
  engine,
3684
3931
  candidates,
@@ -3686,11 +3933,25 @@ def evaluate(
3686
3933
  run_mode=run_mode,
3687
3934
  r_ref=r_ref,
3688
3935
  return_residuals=return_residuals,
3936
+ regions=eff_regions,
3937
+ region_names=region_names if regions is not None else (
3938
+ engine.region_names if eff_regions is not None else None
3939
+ ),
3940
+ region_min_points=region_min_points,
3941
+ implied_metric=implied_metric,
3942
+ implied_percentile=implied_percentile,
3943
+ implied_noise_normalization=implied_noise_normalization,
3944
+ require_batch=require_batch,
3689
3945
  )
3690
3946
  if batched is not None:
3691
3947
  for report in batched:
3692
3948
  report["execution"] = "batched"
3693
3949
  return batched
3950
+ if require_batch:
3951
+ raise RuntimeError(
3952
+ "evaluate requires the batched jit/vmap path for regions and/or "
3953
+ "opt-in implied scoring (percentile / noise normalization)"
3954
+ )
3694
3955
  reports: List[Dict[str, Any]] = []
3695
3956
  for i, (state, ref) in enumerate(zip(candidates, refs)):
3696
3957
  residuals, entry = engine._compute_entry(
@@ -185,21 +185,31 @@ def compute_implied_delta_with_debug(
185
185
  except (TypeError, ValueError):
186
186
  return None, None
187
187
  # Drop non-identifiable rows from scoring (all-NaN delta) while keeping debug.
188
- if implied_meta.get("status") == "not_identifiable" or implied_meta.get(
189
- "identifiable"
190
- ) is False:
191
- delta = jnp.full_like(jnp.asarray(delta), jnp.nan)
188
+ # ``identifiable`` may be a 0-d JAX bool under jit/vmap — use jnp.where, not Python `if`.
189
+ delta_a = jnp.asarray(delta)
190
+ ident = implied_meta.get("identifiable", True)
191
+ if implied_meta.get("status") == "not_identifiable":
192
+ delta_a = jnp.full_like(delta_a, jnp.nan)
193
+ elif ident is False:
194
+ delta_a = jnp.full_like(delta_a, jnp.nan)
195
+ else:
196
+ try:
197
+ ident_arr = jnp.asarray(ident)
198
+ if ident_arr.dtype == jnp.bool_ or ident_arr.dtype == bool:
199
+ delta_a = jnp.where(ident_arr, delta_a, jnp.full_like(delta_a, jnp.nan))
200
+ except (TypeError, ValueError):
201
+ pass
192
202
  debug = {
193
203
  "pred": pred_debug,
194
204
  "implied": implied_debug,
195
205
  "raw": raw,
196
- "delta": jnp.asarray(delta),
206
+ "delta": delta_a,
197
207
  "mode": "subtract",
198
208
  "output_key": output_key,
199
209
  }
200
210
  if implied_meta:
201
211
  debug.update(implied_meta)
202
- return delta, debug
212
+ return delta_a, debug
203
213
 
204
214
 
205
215
  def compute_implied_delta(