moju 2.3.0__tar.gz → 2.3.2__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.3.0 → moju-2.3.2}/PKG-INFO +4 -4
  2. {moju-2.3.0 → moju-2.3.2}/README.md +1 -1
  3. {moju-2.3.0 → moju-2.3.2}/moju/__init__.py +1 -1
  4. {moju-2.3.0 → moju-2.3.2}/moju/monitor/auditor.py +11 -0
  5. {moju-2.3.0 → moju-2.3.2}/moju/monitor/evaluate_batch.py +38 -4
  6. {moju-2.3.0 → moju-2.3.2}/moju/monitor/regions.py +4 -4
  7. {moju-2.3.0 → moju-2.3.2}/moju/monitor/report.py +54 -17
  8. {moju-2.3.0 → moju-2.3.2}/moju.egg-info/PKG-INFO +4 -4
  9. {moju-2.3.0 → moju-2.3.2}/moju.egg-info/SOURCES.txt +1 -0
  10. {moju-2.3.0 → moju-2.3.2}/pyproject.toml +13 -3
  11. {moju-2.3.0 → moju-2.3.2}/tests/test_evaluate_regions_batched.py +6 -3
  12. moju-2.3.2/tests/test_not_identifiable_tier.py +247 -0
  13. {moju-2.3.0 → moju-2.3.2}/tests/test_region_aware_scoring.py +3 -1
  14. {moju-2.3.0 → moju-2.3.2}/LICENSE +0 -0
  15. {moju-2.3.0 → moju-2.3.2}/moju/monitor/__init__.py +0 -0
  16. {moju-2.3.0 → moju-2.3.2}/moju/monitor/analytic_flows.py +0 -0
  17. {moju-2.3.0 → moju-2.3.2}/moju/monitor/audit_meta.py +0 -0
  18. {moju-2.3.0 → moju-2.3.2}/moju/monitor/boundary_closures.py +0 -0
  19. {moju-2.3.0 → moju-2.3.2}/moju/monitor/closure_registry.py +0 -0
  20. {moju-2.3.0 → moju-2.3.2}/moju/monitor/config.py +0 -0
  21. {moju-2.3.0 → moju-2.3.2}/moju/monitor/constitutive_closure_summary.py +0 -0
  22. {moju-2.3.0 → moju-2.3.2}/moju/monitor/constitutive_closures.py +0 -0
  23. {moju-2.3.0 → moju-2.3.2}/moju/monitor/curvilinear.py +0 -0
  24. {moju-2.3.0 → moju-2.3.2}/moju/monitor/derivative_error.py +0 -0
  25. {moju-2.3.0 → moju-2.3.2}/moju/monitor/derivative_keys.py +0 -0
  26. {moju-2.3.0 → moju-2.3.2}/moju/monitor/derivative_metadata.py +0 -0
  27. {moju-2.3.0 → moju-2.3.2}/moju/monitor/derivative_provenance.py +0 -0
  28. {moju-2.3.0 → moju-2.3.2}/moju/monitor/derived_state_chain.py +0 -0
  29. {moju-2.3.0 → moju-2.3.2}/moju/monitor/eval_mask.py +0 -0
  30. {moju-2.3.0 → moju-2.3.2}/moju/monitor/fd_stencils.py +0 -0
  31. {moju-2.3.0 → moju-2.3.2}/moju/monitor/implied_scoring.py +0 -0
  32. {moju-2.3.0 → moju-2.3.2}/moju/monitor/key_declarations.py +0 -0
  33. {moju-2.3.0 → moju-2.3.2}/moju/monitor/law_fd_recipes.py +0 -0
  34. {moju-2.3.0 → moju-2.3.2}/moju/monitor/law_group_inference.py +0 -0
  35. {moju-2.3.0 → moju-2.3.2}/moju/monitor/law_implied_diagnostics.py +0 -0
  36. {moju-2.3.0 → moju-2.3.2}/moju/monitor/law_scale_recipes.py +0 -0
  37. {moju-2.3.0 → moju-2.3.2}/moju/monitor/model_derived_registry.py +0 -0
  38. {moju-2.3.0 → moju-2.3.2}/moju/monitor/monitor_log_export.py +0 -0
  39. {moju-2.3.0 → moju-2.3.2}/moju/monitor/nondim_inference.py +0 -0
  40. {moju-2.3.0 → moju-2.3.2}/moju/monitor/nondim_scales_parse.py +0 -0
  41. {moju-2.3.0 → moju-2.3.2}/moju/monitor/path_b_derivatives.py +0 -0
  42. {moju-2.3.0 → moju-2.3.2}/moju/monitor/path_b_spectral.py +0 -0
  43. {moju-2.3.0 → moju-2.3.2}/moju/monitor/resolution_sensor.py +0 -0
  44. {moju-2.3.0 → moju-2.3.2}/moju/monitor/scaling_closures.py +0 -0
  45. {moju-2.3.0 → moju-2.3.2}/moju/monitor/spatial_rnorm_panels.py +0 -0
  46. {moju-2.3.0 → moju-2.3.2}/moju/monitor/state_builder.py +0 -0
  47. {moju-2.3.0 → moju-2.3.2}/moju/monitor/state_ref.py +0 -0
  48. {moju-2.3.0 → moju-2.3.2}/moju/monitor/term_breakdown.py +0 -0
  49. {moju-2.3.0 → moju-2.3.2}/moju/monitor/tiers.py +0 -0
  50. {moju-2.3.0 → moju-2.3.2}/moju/monitor/types.py +0 -0
  51. {moju-2.3.0 → moju-2.3.2}/moju/monitor/visualize_components.py +0 -0
  52. {moju-2.3.0 → moju-2.3.2}/moju/monitor/visualize_constitutive.py +0 -0
  53. {moju-2.3.0 → moju-2.3.2}/moju/monitor/visualize_export.py +0 -0
  54. {moju-2.3.0 → moju-2.3.2}/moju/monitor/visualize_labels.py +0 -0
  55. {moju-2.3.0 → moju-2.3.2}/moju/monitor/visualize_plotly.py +0 -0
  56. {moju-2.3.0 → moju-2.3.2}/moju/monitor/visualize_theme.py +0 -0
  57. {moju-2.3.0 → moju-2.3.2}/moju/monitor/weighted_norms.py +0 -0
  58. {moju-2.3.0 → moju-2.3.2}/moju/monitor/weighted_safeguard.py +0 -0
  59. {moju-2.3.0 → moju-2.3.2}/moju/piratio/__init__.py +0 -0
  60. {moju-2.3.0 → moju-2.3.2}/moju/piratio/groups.py +0 -0
  61. {moju-2.3.0 → moju-2.3.2}/moju/piratio/laws.py +0 -0
  62. {moju-2.3.0 → moju-2.3.2}/moju/piratio/models.py +0 -0
  63. {moju-2.3.0 → moju-2.3.2}/moju/piratio/nondim.py +0 -0
  64. {moju-2.3.0 → moju-2.3.2}/moju/piratio/operators.py +0 -0
  65. {moju-2.3.0 → moju-2.3.2}/moju/registry.py +0 -0
  66. {moju-2.3.0 → moju-2.3.2}/moju/torch/__init__.py +0 -0
  67. {moju-2.3.0 → moju-2.3.2}/moju/torch/_closure.py +0 -0
  68. {moju-2.3.0 → moju-2.3.2}/moju/torch/_derived.py +0 -0
  69. {moju-2.3.0 → moju-2.3.2}/moju/torch/_engine.py +0 -0
  70. {moju-2.3.0 → moju-2.3.2}/moju/torch/_fd_stencils.py +0 -0
  71. {moju-2.3.0 → moju-2.3.2}/moju/torch/_implied_diagnostics.py +0 -0
  72. {moju-2.3.0 → moju-2.3.2}/moju/torch/_nondim.py +0 -0
  73. {moju-2.3.0 → moju-2.3.2}/moju/torch/_path_b.py +0 -0
  74. {moju-2.3.0 → moju-2.3.2}/moju/torch/_r_eff.py +0 -0
  75. {moju-2.3.0 → moju-2.3.2}/moju/torch_interop.py +0 -0
  76. {moju-2.3.0 → moju-2.3.2}/moju.egg-info/dependency_links.txt +0 -0
  77. {moju-2.3.0 → moju-2.3.2}/moju.egg-info/requires.txt +0 -0
  78. {moju-2.3.0 → moju-2.3.2}/moju.egg-info/top_level.txt +0 -0
  79. {moju-2.3.0 → moju-2.3.2}/setup.cfg +0 -0
  80. {moju-2.3.0 → moju-2.3.2}/tests/test_analytic_ns_diagnostics.py +0 -0
  81. {moju-2.3.0 → moju-2.3.2}/tests/test_audit_meta.py +0 -0
  82. {moju-2.3.0 → moju-2.3.2}/tests/test_auditor.py +0 -0
  83. {moju-2.3.0 → moju-2.3.2}/tests/test_builtin_law_evaluate_batch.py +0 -0
  84. {moju-2.3.0 → moju-2.3.2}/tests/test_closure_debug_sidecar.py +0 -0
  85. {moju-2.3.0 → moju-2.3.2}/tests/test_compat_2_0_defaults.py +0 -0
  86. {moju-2.3.0 → moju-2.3.2}/tests/test_config_forms.py +0 -0
  87. {moju-2.3.0 → moju-2.3.2}/tests/test_constitutive_closure_summary.py +0 -0
  88. {moju-2.3.0 → moju-2.3.2}/tests/test_custom_law_hooks.py +0 -0
  89. {moju-2.3.0 → moju-2.3.2}/tests/test_derivative_provenance.py +0 -0
  90. {moju-2.3.0 → moju-2.3.2}/tests/test_derived_state_chain.py +0 -0
  91. {moju-2.3.0 → moju-2.3.2}/tests/test_evaluate.py +0 -0
  92. {moju-2.3.0 → moju-2.3.2}/tests/test_evaluate_optin_batched.py +0 -0
  93. {moju-2.3.0 → moju-2.3.2}/tests/test_examples_implied_cookbooks.py +0 -0
  94. {moju-2.3.0 → moju-2.3.2}/tests/test_examples_path_b_fd_cookbooks.py +0 -0
  95. {moju-2.3.0 → moju-2.3.2}/tests/test_examples_turbulence_cookbooks.py +0 -0
  96. {moju-2.3.0 → moju-2.3.2}/tests/test_fd_stencils.py +0 -0
  97. {moju-2.3.0 → moju-2.3.2}/tests/test_fornberg_and_dual_spacing.py +0 -0
  98. {moju-2.3.0 → moju-2.3.2}/tests/test_groups.py +0 -0
  99. {moju-2.3.0 → moju-2.3.2}/tests/test_implied_robust_scoring.py +0 -0
  100. {moju-2.3.0 → moju-2.3.2}/tests/test_implied_viscosity_ls.py +0 -0
  101. {moju-2.3.0 → moju-2.3.2}/tests/test_imports.py +0 -0
  102. {moju-2.3.0 → moju-2.3.2}/tests/test_jax_torch_fd_parity.py +0 -0
  103. {moju-2.3.0 → moju-2.3.2}/tests/test_law_fd_recipes.py +0 -0
  104. {moju-2.3.0 → moju-2.3.2}/tests/test_law_group_inference.py +0 -0
  105. {moju-2.3.0 → moju-2.3.2}/tests/test_law_implied_diagnostics.py +0 -0
  106. {moju-2.3.0 → moju-2.3.2}/tests/test_law_registry.py +0 -0
  107. {moju-2.3.0 → moju-2.3.2}/tests/test_law_scale_recipes.py +0 -0
  108. {moju-2.3.0 → moju-2.3.2}/tests/test_laws.py +0 -0
  109. {moju-2.3.0 → moju-2.3.2}/tests/test_minimal_input_flow.py +0 -0
  110. {moju-2.3.0 → moju-2.3.2}/tests/test_model_derived_registry.py +0 -0
  111. {moju-2.3.0 → moju-2.3.2}/tests/test_models.py +0 -0
  112. {moju-2.3.0 → moju-2.3.2}/tests/test_monitor_log_export.py +0 -0
  113. {moju-2.3.0 → moju-2.3.2}/tests/test_nondim.py +0 -0
  114. {moju-2.3.0 → moju-2.3.2}/tests/test_nondim_inference.py +0 -0
  115. {moju-2.3.0 → moju-2.3.2}/tests/test_notebooks_media.py +0 -0
  116. {moju-2.3.0 → moju-2.3.2}/tests/test_operators.py +0 -0
  117. {moju-2.3.0 → moju-2.3.2}/tests/test_paper_goldens.py +0 -0
  118. {moju-2.3.0 → moju-2.3.2}/tests/test_path_b_derivatives.py +0 -0
  119. {moju-2.3.0 → moju-2.3.2}/tests/test_path_b_spectral.py +0 -0
  120. {moju-2.3.0 → moju-2.3.2}/tests/test_phase_a2_derivative_layer.py +0 -0
  121. {moju-2.3.0 → moju-2.3.2}/tests/test_phase_a5_periodic.py +0 -0
  122. {moju-2.3.0 → moju-2.3.2}/tests/test_readme_examples.py +0 -0
  123. {moju-2.3.0 → moju-2.3.2}/tests/test_readme_pypi_links.py +0 -0
  124. {moju-2.3.0 → moju-2.3.2}/tests/test_registry.py +0 -0
  125. {moju-2.3.0 → moju-2.3.2}/tests/test_report_schema.py +0 -0
  126. {moju-2.3.0 → moju-2.3.2}/tests/test_safeguard_diagnostics.py +0 -0
  127. {moju-2.3.0 → moju-2.3.2}/tests/test_scoring_and_bounds.py +0 -0
  128. {moju-2.3.0 → moju-2.3.2}/tests/test_spatial_rnorm_panels.py +0 -0
  129. {moju-2.3.0 → moju-2.3.2}/tests/test_split_admissibility.py +0 -0
  130. {moju-2.3.0 → moju-2.3.2}/tests/test_state_ref.py +0 -0
  131. {moju-2.3.0 → moju-2.3.2}/tests/test_state_ref_hdf5.py +0 -0
  132. {moju-2.3.0 → moju-2.3.2}/tests/test_state_ref_meshio.py +0 -0
  133. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_auto_config.py +0 -0
  134. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_dependency_planner.py +0 -0
  135. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_implied_groups.py +0 -0
  136. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_io.py +0 -0
  137. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_law_fd_hints.py +0 -0
  138. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_model_derived_registry.py +0 -0
  139. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_pi_gating.py +0 -0
  140. {moju-2.3.0 → moju-2.3.2}/tests/test_studio_plots.py +0 -0
  141. {moju-2.3.0 → moju-2.3.2}/tests/test_torch_engine.py +0 -0
  142. {moju-2.3.0 → moju-2.3.2}/tests/test_torch_interop.py +0 -0
  143. {moju-2.3.0 → moju-2.3.2}/tests/test_visualize_components.py +0 -0
  144. {moju-2.3.0 → moju-2.3.2}/tests/test_visualize_constitutive.py +0 -0
  145. {moju-2.3.0 → moju-2.3.2}/tests/test_visualize_labels.py +0 -0
  146. {moju-2.3.0 → moju-2.3.2}/tests/test_visualize_parity.py +0 -0
  147. {moju-2.3.0 → moju-2.3.2}/tests/test_visualize_theme.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moju
3
- Version: 2.3.0
4
- Summary: Physics supervision and audit tools for SciML models, with JAX-native residuals and PyTorch support.
3
+ Version: 2.3.2
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>
7
7
  License: MIT
@@ -12,7 +12,7 @@ Project-URL: Paper, https://zenodo.org/records/20519331
12
12
  Project-URL: Changelog, https://github.com/IfimoAI/moju/blob/main/CHANGELOG.md
13
13
  Project-URL: Citation, https://github.com/IfimoAI/moju/blob/main/CITATION.cff
14
14
  Project-URL: Issues, https://github.com/IfimoAI/moju/issues
15
- Keywords: physics,jax,torch,pytorch,sciml,pinn,dimensionless,cfd,differentiable,residual
15
+ Keywords: physics-informed machine learning,scientific machine learning,surrogate validation,verification and validation,admissibility,JAX,PyTorch,SciML,PINN
16
16
  Classifier: Development Status :: 5 - Production/Stable
17
17
  Classifier: Intended Audience :: Science/Research
18
18
  Classifier: License :: OSI Approved :: MIT License
@@ -49,7 +49,7 @@ Dynamic: license-file
49
49
 
50
50
  # moju
51
51
 
52
- Physics supervision and audit tools for SciML and Physics AI.
52
+ Moju audits predicted fields against governing-law residuals, constitutive and implied-parameter checks, and optional per-region diagnostics. It reports admissibility scores, qualitative tiers, and scoring provenance so you can see how well a state satisfies the physics you stated. It is for people validating ML surrogates or simulation fields. Secondary capabilities include composing laws, models, and dimensionless groups, and using governing-law residuals as a training-time loss.
53
53
 
54
54
  ```bash
55
55
  pip install moju
@@ -1,6 +1,6 @@
1
1
  # moju
2
2
 
3
- Physics supervision and audit tools for SciML and Physics AI.
3
+ Moju audits predicted fields against governing-law residuals, constitutive and implied-parameter checks, and optional per-region diagnostics. It reports admissibility scores, qualitative tiers, and scoring provenance so you can see how well a state satisfies the physics you stated. It is for people validating ML surrogates or simulation fields. Secondary capabilities include composing laws, models, and dimensionless groups, and using governing-law residuals as a training-time loss.
4
4
 
5
5
  ```bash
6
6
  pip install moju
@@ -1,3 +1,3 @@
1
1
  """Moju: Physics-AI supervision for engineering-grade simulations."""
2
2
 
3
- __version__ = "2.3.0"
3
+ __version__ = "2.3.2"
@@ -805,7 +805,9 @@ def _compute_log_step_metrics(
805
805
  key_report["r_max"] = imp_score.get("r_max_raw")
806
806
  if id_meta is not None:
807
807
  # Explicit per-key sidecar: never treat as passing; rollup skips this key.
808
+ # Score is null (not 0.0 / NaN) so JSON exports stay strict-compliant.
808
809
  key_report["identifiability_status"] = "not_identifiable"
810
+ key_report["admissibility_score"] = None
809
811
  key_report["excluded_fraction"] = id_meta.get("excluded_fraction")
810
812
  key_report["n_identifiable"] = id_meta.get("n_identifiable")
811
813
  key_report["n_total"] = id_meta.get("n_total")
@@ -831,6 +833,12 @@ def _compute_log_step_metrics(
831
833
  category_failed = False
832
834
  for kk in keys:
833
835
  # Skip not-identifiable implied rows — they must not zero the category.
836
+ kr = per_key_report.get(kk) or {}
837
+ if (
838
+ kr.get("identifiability_status") == "not_identifiable"
839
+ or kr.get("admissibility_level") == "Not identifiable"
840
+ ):
841
+ continue
834
842
  if (
835
843
  isinstance(id_map_entry, dict)
836
844
  and kk.startswith("constitutive/")
@@ -1461,6 +1469,9 @@ def _worst_keys_table_rows(
1461
1469
  row["r_max_raw"] = rep.get("r_max_raw")
1462
1470
  if is_ni:
1463
1471
  row["admissibility_level"] = "Not identifiable"
1472
+ # Missing score → null in JSON (never NaN / 0.0).
1473
+ row["admissibility"] = None
1474
+ row["admissibility_score"] = None
1464
1475
  if rep.get("excluded_fraction") is not None:
1465
1476
  row["excluded_fraction"] = rep.get("excluded_fraction")
1466
1477
  if rep.get("identifiability_reason"):
@@ -105,7 +105,10 @@ def evaluate_batched(
105
105
  resolve_implied_scoring_options,
106
106
  score_from_r_max,
107
107
  )
108
- from moju.monitor.law_implied_diagnostics import _gate_params
108
+ from moju.monitor.law_implied_diagnostics import (
109
+ _gate_params,
110
+ materialize_identifiability_meta,
111
+ )
109
112
  from moju.monitor.regions import (
110
113
  DEFAULT_REGION_MIN_POINTS,
111
114
  normalize_regions,
@@ -281,6 +284,16 @@ def evaluate_batched(
281
284
  score_from_r_max=score_from_r_max,
282
285
  )
283
286
  ms["regions"] = entry["region_results"].get("provenance")
287
+ # Host-side: materialise not-identifiable flags so audit rollup matches
288
+ # the loop / ResidualEngine path (NaN implied scores must not zero the tier).
289
+ if "implied_id" in out:
290
+ id_map: Dict[str, Any] = {}
291
+ for bn, nums in out["implied_id"].items():
292
+ compact: Dict[str, Any] = {}
293
+ for sk, arr in nums.items():
294
+ compact[sk] = jnp.asarray(arr)[i]
295
+ id_map[str(bn)] = materialize_identifiability_meta(compact)
296
+ entry["implied_identifiability"] = id_map
284
297
  entry["monitor_settings"] = ms
285
298
  report = audit([entry], r_ref=r_ref)
286
299
  if return_residuals:
@@ -437,6 +450,8 @@ def _score_one(
437
450
  visc_fq = None
438
451
  visc_scale = None
439
452
  implied_debug: Dict[str, Dict[str, Any]] = {}
453
+ # Tracer-safe identifiability scalars (status strings materialised on the host).
454
+ implied_id: Dict[str, Dict[str, Any]] = {}
440
455
  for audit_spec in plan.audits:
441
456
  spec = audit_spec["spec"]
442
457
  reg = audit_spec["reg"]
@@ -481,6 +496,23 @@ def _score_one(
481
496
  side[sk] = jnp.asarray(dbg[sk])
482
497
  if side:
483
498
  implied_debug[key] = side
499
+ if "identifiable" in dbg or dbg.get("status") == "not_identifiable":
500
+ pack: Dict[str, Any] = {}
501
+ if "identifiable" in dbg:
502
+ pack["identifiable"] = jnp.asarray(dbg["identifiable"])
503
+ elif dbg.get("status") == "not_identifiable":
504
+ pack["identifiable"] = jnp.asarray(False)
505
+ for sk in (
506
+ "excluded_fraction",
507
+ "n_identifiable",
508
+ "n_total",
509
+ "conditioning_threshold",
510
+ "fit_quality_threshold",
511
+ ):
512
+ if dbg.get(sk) is not None:
513
+ pack[sk] = jnp.asarray(dbg[sk])
514
+ if pack:
515
+ implied_id[str(base)] = pack
484
516
  if (
485
517
  dbg.get("noise_rhs") is not None
486
518
  and dbg.get("noise_basis") is not None
@@ -550,6 +582,8 @@ def _score_one(
550
582
  "scale": dynamic_scale,
551
583
  "recipe_ok": recipe_ok,
552
584
  }
585
+ if implied_id:
586
+ out["implied_id"] = implied_id
553
587
 
554
588
  if apply_optin:
555
589
  # Global opt-in: same arithmetic as score_implied_delta_array, jit-safe.
@@ -712,9 +746,9 @@ def _assemble_region_results(
712
746
  key_row.update(
713
747
  {
714
748
  "status": "insufficient_points",
715
- "admissibility_score": float("nan"),
749
+ "admissibility_score": None,
716
750
  "admissibility_level": "Insufficient points",
717
- "score_for_admissibility": float("nan"),
751
+ "score_for_admissibility": None,
718
752
  "worst_point_index": None,
719
753
  "reason": flags.get("reason"),
720
754
  }
@@ -728,7 +762,7 @@ def _assemble_region_results(
728
762
  key_row.update(
729
763
  {
730
764
  "status": vis_status,
731
- "admissibility_score": float("nan"),
765
+ "admissibility_score": None,
732
766
  "admissibility_level": (
733
767
  "Insufficient points"
734
768
  if vis_status == "insufficient_points"
@@ -671,9 +671,9 @@ def compute_region_results(
671
671
  key_row.update(
672
672
  {
673
673
  "status": "insufficient_points",
674
- "admissibility_score": float("nan"),
674
+ "admissibility_score": None,
675
675
  "admissibility_level": "Insufficient points",
676
- "score_for_admissibility": float("nan"),
676
+ "score_for_admissibility": None,
677
677
  "worst_point_index": None,
678
678
  "reason": region_reason,
679
679
  }
@@ -692,7 +692,7 @@ def compute_region_results(
692
692
  key_row.update(
693
693
  {
694
694
  "status": "insufficient_points",
695
- "admissibility_score": float("nan"),
695
+ "admissibility_score": None,
696
696
  "admissibility_level": "Insufficient points",
697
697
  "nu_tilde": viscosity_meta.get("nu_tilde"),
698
698
  "mu_tilde": viscosity_meta.get("mu_tilde"),
@@ -708,7 +708,7 @@ def compute_region_results(
708
708
  key_row.update(
709
709
  {
710
710
  "status": "not_identifiable",
711
- "admissibility_score": float("nan"),
711
+ "admissibility_score": None,
712
712
  "admissibility_level": "Not identifiable",
713
713
  "nu_tilde": viscosity_meta.get("nu_tilde"),
714
714
  "mu_tilde": viscosity_meta.get("mu_tilde"),
@@ -99,29 +99,60 @@ def _group_keys_by_category(per_key: Dict[str, Any]) -> List[Tuple[str, List[Tup
99
99
  return result
100
100
 
101
101
 
102
- def _residual_dict_to_json_serializable(residual_dict: Dict[str, Any]) -> Dict[str, Any]:
103
- """Recursively convert residual dict (JAX/NumPy arrays) to JSON-serializable lists."""
102
+ def _json_safe_value(val: Any) -> Any:
103
+ """Convert arrays / nested structures to JSON-safe Python; non-finite floats → ``None``."""
104
104
  try:
105
105
  import jax
106
106
  except ImportError:
107
107
  jax = None
108
108
  import numpy as np
109
109
 
110
- out: Dict[str, Any] = {}
111
- for key, val in residual_dict.items():
112
- if isinstance(val, dict):
113
- out[key] = _residual_dict_to_json_serializable(val)
114
- elif hasattr(val, "tolist"):
115
- try:
116
- arr = val
117
- if jax is not None and hasattr(arr, "__jax_array__"):
110
+ if val is None or isinstance(val, (str, bool)):
111
+ return val
112
+ if isinstance(val, dict):
113
+ return {str(k): _json_safe_value(v) for k, v in val.items()}
114
+ if isinstance(val, (list, tuple)):
115
+ return [_json_safe_value(v) for v in val]
116
+ if isinstance(val, (int,)) and not isinstance(val, bool):
117
+ return int(val)
118
+ if isinstance(val, float):
119
+ return val if math.isfinite(val) else None
120
+ if hasattr(val, "tolist"):
121
+ try:
122
+ arr = val
123
+ if jax is not None:
124
+ try:
118
125
  arr = jax.device_get(arr)
119
- out[key] = np.asarray(arr).tolist()
120
- except Exception:
121
- out[key] = str(val)
122
- else:
123
- out[key] = val
124
- return out
126
+ except Exception:
127
+ pass
128
+ # 0-d numpy / jax scalars
129
+ as_np = np.asarray(arr)
130
+ if as_np.shape == ():
131
+ try:
132
+ f = float(as_np.item())
133
+ except (TypeError, ValueError):
134
+ return as_np.item()
135
+ return f if math.isfinite(f) else None
136
+ return _json_safe_value(as_np.tolist())
137
+ except Exception:
138
+ return str(val)
139
+ try:
140
+ f = float(val)
141
+ except (TypeError, ValueError):
142
+ return val
143
+ return f if math.isfinite(f) else None
144
+
145
+
146
+ def _residual_dict_to_json_serializable(residual_dict: Dict[str, Any]) -> Dict[str, Any]:
147
+ """Recursively convert residual dict (JAX/NumPy arrays) to JSON-serializable values.
148
+
149
+ Non-finite floats (``NaN`` / ``±Inf``) become ``None`` (JSON ``null``) so
150
+ ``json.dumps(..., allow_nan=False)`` succeeds. Used by residual JSON writers
151
+ and :func:`moju.monitor.monitor_log_export.monitor_log_export_to_jsonable`.
152
+ """
153
+ if not isinstance(residual_dict, dict):
154
+ return _json_safe_value(residual_dict) # type: ignore[return-value]
155
+ return {str(k): _json_safe_value(v) for k, v in residual_dict.items()}
125
156
 
126
157
 
127
158
  def write_residuals_json(residual_dict: Dict[str, Any], path: str) -> None:
@@ -515,7 +546,13 @@ def write_audit_pdf(
515
546
  for label, data in items:
516
547
  score = data.get("admissibility_score", 0.0)
517
548
  level = data.get("admissibility_level", "Non-Admissible")
518
- score_s = format_admissibility_pct(float(score)) if level != "Not identifiable" else "N/A"
549
+ if (
550
+ level in ("Not identifiable", "Insufficient points")
551
+ or score is None
552
+ ):
553
+ score_s = "N/A"
554
+ else:
555
+ score_s = format_admissibility_pct(float(score))
519
556
  if level == "Not identifiable" and data.get("excluded_fraction") is not None:
520
557
  try:
521
558
  excl_pct = 100.0 * float(data["excluded_fraction"])
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: moju
3
- Version: 2.3.0
4
- Summary: Physics supervision and audit tools for SciML models, with JAX-native residuals and PyTorch support.
3
+ Version: 2.3.2
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>
7
7
  License: MIT
@@ -12,7 +12,7 @@ Project-URL: Paper, https://zenodo.org/records/20519331
12
12
  Project-URL: Changelog, https://github.com/IfimoAI/moju/blob/main/CHANGELOG.md
13
13
  Project-URL: Citation, https://github.com/IfimoAI/moju/blob/main/CITATION.cff
14
14
  Project-URL: Issues, https://github.com/IfimoAI/moju/issues
15
- Keywords: physics,jax,torch,pytorch,sciml,pinn,dimensionless,cfd,differentiable,residual
15
+ Keywords: physics-informed machine learning,scientific machine learning,surrogate validation,verification and validation,admissibility,JAX,PyTorch,SciML,PINN
16
16
  Classifier: Development Status :: 5 - Production/Stable
17
17
  Classifier: Intended Audience :: Science/Research
18
18
  Classifier: License :: OSI Approved :: MIT License
@@ -49,7 +49,7 @@ Dynamic: license-file
49
49
 
50
50
  # moju
51
51
 
52
- Physics supervision and audit tools for SciML and Physics AI.
52
+ Moju audits predicted fields against governing-law residuals, constitutive and implied-parameter checks, and optional per-region diagnostics. It reports admissibility scores, qualitative tiers, and scoring provenance so you can see how well a state satisfies the physics you stated. It is for people validating ML surrogates or simulation fields. Secondary capabilities include composing laws, models, and dimensionless groups, and using governing-law residuals as a training-time loss.
53
53
 
54
54
  ```bash
55
55
  pip install moju
@@ -108,6 +108,7 @@ tests/test_models.py
108
108
  tests/test_monitor_log_export.py
109
109
  tests/test_nondim.py
110
110
  tests/test_nondim_inference.py
111
+ tests/test_not_identifiable_tier.py
111
112
  tests/test_notebooks_media.py
112
113
  tests/test_operators.py
113
114
  tests/test_paper_goldens.py
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "moju"
7
- version = "2.3.0"
8
- description = "Physics supervision and audit tools for SciML models, with JAX-native residuals and PyTorch support."
7
+ version = "2.3.2"
8
+ description = "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."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "Ifimo Lab (Ifimo Global Solutions LLC)", email = "lab@ifimoanalytics.com" }]
11
11
  maintainers = [
@@ -13,7 +13,17 @@ maintainers = [
13
13
  ]
14
14
  license = { text = "MIT" }
15
15
  requires-python = ">=3.10"
16
- keywords = ["physics", "jax", "torch", "pytorch", "sciml", "pinn", "dimensionless", "cfd", "differentiable", "residual"]
16
+ keywords = [
17
+ "physics-informed machine learning",
18
+ "scientific machine learning",
19
+ "surrogate validation",
20
+ "verification and validation",
21
+ "admissibility",
22
+ "JAX",
23
+ "PyTorch",
24
+ "SciML",
25
+ "PINN",
26
+ ]
17
27
  classifiers = [
18
28
  "Development Status :: 5 - Production/Stable",
19
29
  "Intended Audience :: Science/Research",
@@ -52,10 +52,13 @@ def _region_snapshot(rep: dict) -> dict:
52
52
  for name, block in rr.items():
53
53
  pk = block["per_key"][_MU_KEY]
54
54
  vis = block.get("viscosity") or {}
55
+ raw_score = pk.get("admissibility_score")
56
+ try:
57
+ score_f = float(raw_score) if raw_score is not None else float("nan")
58
+ except (TypeError, ValueError):
59
+ score_f = float("nan")
55
60
  out[name] = {
56
- "score": float(pk["admissibility_score"])
57
- if math.isfinite(float(pk.get("admissibility_score", float("nan"))))
58
- else float("nan"),
61
+ "score": score_f if math.isfinite(score_f) else float("nan"),
59
62
  "v_eff": float(pk.get("score_for_admissibility", float("nan")))
60
63
  if pk.get("score_for_admissibility") is not None
61
64
  else float("nan"),
@@ -0,0 +1,247 @@
1
+ """Not-identifiable implied laws must not drive the overall tier (2.1 semantics / 2.3.1 fix)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ from pathlib import Path
8
+
9
+ import jax
10
+
11
+ jax.config.update("jax_enable_x64", True)
12
+
13
+ import jax.numpy as jnp
14
+ import numpy as np
15
+ import pytest
16
+
17
+ from moju.monitor import ResidualEngine, audit, evaluate
18
+ from moju.monitor.analytic_flows import ns_state_from_analytic, taylor_green_2d
19
+
20
+ ROOT = Path(__file__).resolve().parents[1]
21
+ GOLDEN = ROOT / "tests" / "goldens" / "not_identifiable" / "tg_re100.json"
22
+
23
+ _NI_KEY = "constitutive/dynamic_viscosity_from_re/law_momentum_navier_stokes/implied_delta"
24
+ _NI_BN = "dynamic_viscosity_from_re/law_momentum_navier_stokes"
25
+ _LAW_KEY = "laws/momentum_navier_stokes"
26
+
27
+
28
+ def _ns_sm():
29
+ return {
30
+ "u_t": "u_t",
31
+ "u": "u",
32
+ "u_grad": "u_grad",
33
+ "p_grad": "p_grad",
34
+ "u_laplacian": "u_laplacian",
35
+ "re": "re",
36
+ }
37
+
38
+
39
+ def _ni_state(*, re: float = 100.0, mu: float = 0.01, n: int = 32, t: float = 0.1):
40
+ xs = jnp.linspace(0.0, 2.0 * jnp.pi, n, endpoint=False)
41
+ x, y = jnp.meshgrid(xs, xs, indexing="ij")
42
+ fields = taylor_green_2d(x, y, t, re=re)
43
+ state = ns_state_from_analytic(fields, x, y, t=t)
44
+ state.update({"rho": 1.0, "U": 1.0, "L": 1.0, "mu": mu, "re": re})
45
+ return state, x
46
+
47
+
48
+ def _identifiable_state(*, re: float = 10.0, mu: float = 0.1, n: int = 32, t: float = 0.1):
49
+ return _ni_state(re=re, mu=mu, n=n, t=t)
50
+
51
+
52
+ def _engine(**kwargs):
53
+ return ResidualEngine(
54
+ laws=[{"name": "momentum_navier_stokes", "state_map": _ns_sm()}],
55
+ law_implied_audits=True,
56
+ constants={"rho": 1.0, "U": 1.0, "L": 1.0},
57
+ **kwargs,
58
+ )
59
+
60
+
61
+ def _assert_ni_semantics(report: dict, *, golden: dict | None = None) -> None:
62
+ exp = (golden or json.loads(GOLDEN.read_text()))["expected"]
63
+ pk = report["per_key"][_NI_KEY]
64
+ assert pk["admissibility_level"] == exp["implied_admissibility_level"]
65
+ assert pk.get("identifiability_status") == exp["identifiability_status"]
66
+ assert pk["admissibility_score"] is None
67
+ assert pk["admissibility_score"] == exp["implied_admissibility_score"]
68
+ assert report["per_key"][_LAW_KEY]["admissibility_level"].startswith(
69
+ exp["law_admissibility_level_prefix"]
70
+ )
71
+ assert report["overall_admissibility_level"].startswith(
72
+ exp["overall_admissibility_level_prefix"]
73
+ )
74
+ id_map = report.get("implied_identifiability") or {}
75
+ assert id_map[_NI_BN]["status"] == "not_identifiable"
76
+ cats = report.get("per_category") or {}
77
+ if exp["constitutive_category_absent_or_nonzero"]:
78
+ assert "constitutive" not in cats or (
79
+ math.isfinite(float(cats["constitutive"])) and float(cats["constitutive"]) != 0.0
80
+ )
81
+
82
+
83
+ def test_golden_fixture_documents_expected_tier():
84
+ golden = json.loads(GOLDEN.read_text())
85
+ assert golden["keys"]["implied"] == _NI_KEY
86
+ assert golden["expected"]["overall_admissibility_level_prefix"] == "High"
87
+
88
+
89
+ def test_audit_not_identifiable_matches_golden():
90
+ golden = json.loads(GOLDEN.read_text())
91
+ state, _ = _ni_state()
92
+ eng = _engine()
93
+ eng.compute_residuals(state, weighted_norms=False)
94
+ _assert_ni_semantics(audit(eng.log), golden=golden)
95
+
96
+
97
+ def test_evaluate_loop_not_identifiable_matches_golden():
98
+ golden = json.loads(GOLDEN.read_text())
99
+ state, _ = _ni_state()
100
+ reports = evaluate(_engine(), [state], weighted_norms=False, evaluate_debug_loop=True)
101
+ assert reports[0].get("execution") == "loop"
102
+ _assert_ni_semantics(reports[0], golden=golden)
103
+
104
+
105
+ def test_evaluate_batched_not_identifiable_matches_golden():
106
+ golden = json.loads(GOLDEN.read_text())
107
+ state, _ = _ni_state()
108
+ reports = evaluate(_engine(), [state], weighted_norms=False)
109
+ assert reports[0].get("execution") == "batched"
110
+ _assert_ni_semantics(reports[0], golden=golden)
111
+
112
+
113
+ def test_evaluate_batched_with_regions_not_identifiable_matches_golden():
114
+ golden = json.loads(GOLDEN.read_text())
115
+ state, x = _ni_state()
116
+ near = np.asarray(x < jnp.pi)
117
+ regions = {"near": near, "far": ~near}
118
+ eng = _engine(regions=regions, region_min_points=8)
119
+ reports = evaluate(
120
+ eng, [state], weighted_norms=False, regions=regions, region_min_points=8
121
+ )
122
+ assert reports[0].get("execution") == "batched"
123
+ _assert_ni_semantics(reports[0], golden=golden)
124
+ for name, blk in reports[0]["region_results"]["regions"].items():
125
+ pk = blk["per_key"][_NI_KEY]
126
+ assert pk["admissibility_level"] == "Not identifiable", name
127
+ assert pk.get("status") == "not_identifiable", name
128
+ assert pk["admissibility_score"] is None, name
129
+ assert blk["viscosity"]["status"] == "not_identifiable", name
130
+
131
+
132
+ def test_batched_vs_loop_equality_including_not_identifiable():
133
+ state, _ = _ni_state()
134
+ eng = _engine()
135
+ loop = evaluate(eng, [state], weighted_norms=False, evaluate_debug_loop=True)[0]
136
+ bat = evaluate(eng, [state], weighted_norms=False)[0]
137
+ assert bat["execution"] == "batched"
138
+ assert loop["overall_admissibility_level"] == bat["overall_admissibility_level"]
139
+ assert loop["overall_admissibility_score"] == pytest.approx(
140
+ bat["overall_admissibility_score"], rel=0, abs=1e-12
141
+ )
142
+ assert loop["per_key"][_NI_KEY]["admissibility_level"] == bat["per_key"][_NI_KEY][
143
+ "admissibility_level"
144
+ ]
145
+ assert loop["per_category"].keys() == bat["per_category"].keys()
146
+ for cat in loop["per_category"]:
147
+ assert loop["per_category"][cat] == pytest.approx(bat["per_category"][cat])
148
+
149
+
150
+ def test_mixed_batch_identifiable_and_not_identifiable():
151
+ ni, _ = _ni_state()
152
+ ok, _ = _identifiable_state()
153
+ reports = evaluate(_engine(), [ni, ok], weighted_norms=False)
154
+ assert reports[0]["execution"] == "batched"
155
+ _assert_ni_semantics(reports[0])
156
+ pk_ok = reports[1]["per_key"][_NI_KEY]
157
+ assert pk_ok["admissibility_level"].startswith("High")
158
+ assert pk_ok.get("identifiability_status") in (None, "ok")
159
+ assert math.isfinite(float(pk_ok["admissibility_score"]))
160
+ assert reports[1]["overall_admissibility_level"].startswith("High")
161
+
162
+
163
+ def test_insufficient_points_does_not_zero_overall_tier():
164
+ """Region insufficient_points uses NaN scores locally; global tier stays from scored keys."""
165
+ state, x = _identifiable_state()
166
+ tiny = np.zeros(np.asarray(x).shape, dtype=bool)
167
+ tiny.ravel()[:2] = True
168
+ regions = {"tiny": tiny, "rest": ~tiny}
169
+ eng = _engine(regions=regions, region_min_points=16)
170
+ eng.compute_residuals(state, weighted_norms=False)
171
+ rep = audit(eng.log)
172
+ assert rep["overall_admissibility_level"].startswith("High")
173
+ tiny_blk = rep["region_results"]["regions"]["tiny"]
174
+ assert tiny_blk["status"] == "insufficient_points"
175
+ assert tiny_blk["per_key"][_NI_KEY]["admissibility_level"] == "Insufficient points"
176
+ assert tiny_blk["per_key"][_NI_KEY]["admissibility_score"] is None
177
+ bat = evaluate(
178
+ eng, [state], weighted_norms=False, regions=regions, region_min_points=16
179
+ )[0]
180
+ assert bat["overall_admissibility_level"].startswith("High")
181
+ assert bat["region_results"]["regions"]["tiny"]["status"] == "insufficient_points"
182
+ assert bat["region_results"]["regions"]["tiny"]["per_key"][_NI_KEY][
183
+ "admissibility_score"
184
+ ] is None
185
+
186
+
187
+ def test_json_export_ni_and_insufficient_scores_are_strict_null():
188
+ """JSON export must use null (allow_nan=False); never NaN or 0.0."""
189
+ from moju.monitor.monitor_log_export import (
190
+ export_monitor_log,
191
+ monitor_log_export_to_jsonable,
192
+ )
193
+
194
+ state, x = _ni_state()
195
+ near = np.asarray(x < jnp.pi)
196
+ tiny = np.zeros(np.asarray(x).shape, dtype=bool)
197
+ tiny.ravel()[:2] = True
198
+ regions = {"near": near & ~tiny, "tiny": tiny, "far": ~near}
199
+ eng = _engine(regions=regions, region_min_points=16)
200
+ eng.compute_residuals(state, weighted_norms=False)
201
+ export = export_monitor_log(eng.log, scope="audit", persist=False)
202
+ blob = monitor_log_export_to_jsonable(export)
203
+ text = json.dumps(blob, allow_nan=False)
204
+ parsed = json.loads(text)
205
+ pk = parsed["summary"]["per_key"][_NI_KEY]
206
+ assert pk["admissibility_level"] == "Not identifiable"
207
+ assert pk["admissibility_score"] is None
208
+ assert pk["admissibility_score"] != 0.0
209
+ # Batched evaluate export path
210
+ bat = evaluate(
211
+ eng, [state], weighted_norms=False, regions=regions, region_min_points=16
212
+ )[0]
213
+ # Re-export via a one-step log-shaped summary: dump report through jsonable helper
214
+ from moju.monitor.report import _residual_dict_to_json_serializable
215
+
216
+ bat_json = _residual_dict_to_json_serializable(bat)
217
+ bat_text = json.dumps(bat_json, allow_nan=False)
218
+ bat_parsed = json.loads(bat_text)
219
+ assert bat_parsed["per_key"][_NI_KEY]["admissibility_score"] is None
220
+ tiny_score = bat_parsed["region_results"]["regions"]["tiny"]["per_key"][_NI_KEY][
221
+ "admissibility_score"
222
+ ]
223
+ assert tiny_score is None
224
+
225
+
226
+ def test_equivalent_to_worst_point_does_not_zero_overall_tier():
227
+ """percentile_status=equivalent_to_worst_point is advisory; scores stay finite."""
228
+ state, x = _identifiable_state()
229
+ small = np.zeros(np.asarray(x).shape, dtype=bool)
230
+ small.ravel()[:10] = True
231
+ regions = {"small": small, "big": ~small}
232
+ eng = _engine(
233
+ regions=regions,
234
+ region_min_points=8,
235
+ implied_metric="percentile",
236
+ implied_percentile=95.0,
237
+ )
238
+ eng.compute_residuals(
239
+ state, weighted_norms=False, implied_metric="percentile", implied_percentile=95.0
240
+ )
241
+ rep = audit(eng.log)
242
+ assert rep["overall_admissibility_level"].startswith("High")
243
+ small_blk = rep["region_results"]["regions"]["small"]
244
+ assert small_blk.get("percentile_status") == "equivalent_to_worst_point"
245
+ assert math.isfinite(float(small_blk["per_key"][_NI_KEY]["admissibility_score"]))
246
+ assert "constitutive" in (rep.get("per_category") or {})
247
+ assert float(rep["per_category"]["constitutive"]) != 0.0
@@ -489,7 +489,9 @@ def test_evaluate_with_regions_uses_batched_jit_vmap():
489
489
  assert prov["region_min_points"] == 8
490
490
  assert set(prov["region_names"]) == {"far", "near"}
491
491
  assert "mask_sha256" in prov
492
- assert prov["moju_version"] == "2.3.0"
492
+ import moju
493
+
494
+ assert prov["moju_version"] == moju.__version__
493
495
 
494
496
 
495
497
  def test_mask_hashes_and_region_provenance_in_report_and_json():
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes