physmap 0.2.0__py3-none-any.whl

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 (88) hide show
  1. physmap/__init__.py +61 -0
  2. physmap/_paths.py +69 -0
  3. physmap/applicability/__init__.py +0 -0
  4. physmap/applicability/fixtures.py +83 -0
  5. physmap/applicability/screen.py +99 -0
  6. physmap/baselines/__init__.py +0 -0
  7. physmap/benchmarks/__init__.py +0 -0
  8. physmap/benchmarks/benchmark_report.py +405 -0
  9. physmap/benchmarks/benchmark_v0_4.py +424 -0
  10. physmap/benchmarks/compare.py +149 -0
  11. physmap/benchmarks/registry.py +217 -0
  12. physmap/benchmarks/report.py +224 -0
  13. physmap/cli.py +301 -0
  14. physmap/closures/__init__.py +48 -0
  15. physmap/closures/data/__init__.py +7 -0
  16. physmap/closures/data/closure_index.json +2997 -0
  17. physmap/closures/formulas.py +213 -0
  18. physmap/closures/geometry_classes.py +109 -0
  19. physmap/closures/index.py +393 -0
  20. physmap/closures/registry.py +313 -0
  21. physmap/compat/__init__.py +0 -0
  22. physmap/core/__init__.py +0 -0
  23. physmap/core/mechanism.py +69 -0
  24. physmap/core/signals.py +50 -0
  25. physmap/corpus/__init__.py +12 -0
  26. physmap/corpus/calibration.py +543 -0
  27. physmap/corpus/data/__init__.py +12 -0
  28. physmap/corpus/data/corpus_seed.jsonl +15 -0
  29. physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
  30. physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
  31. physmap/corpus/data/premium_coverage.json +60 -0
  32. physmap/corpus/evidence.py +871 -0
  33. physmap/explain/__init__.py +0 -0
  34. physmap/explain/benchmark.py +101 -0
  35. physmap/explain/causal.py +82 -0
  36. physmap/guardrail/__init__.py +38 -0
  37. physmap/guardrail/aggregator_observability.py +187 -0
  38. physmap/guardrail/classify.py +147 -0
  39. physmap/guardrail/configs.py +120 -0
  40. physmap/guardrail/corpus_regimes.py +208 -0
  41. physmap/guardrail/detector_conformal.py +129 -0
  42. physmap/guardrail/detector_density.py +74 -0
  43. physmap/guardrail/enums.py +69 -0
  44. physmap/guardrail/graph.py +73 -0
  45. physmap/guardrail/guardrail.py +606 -0
  46. physmap/guardrail/io.py +201 -0
  47. physmap/guardrail/regime_observability.py +519 -0
  48. physmap/guardrail/render.py +159 -0
  49. physmap/guardrail/weighting_heuristic.py +216 -0
  50. physmap/infra/__init__.py +23 -0
  51. physmap/infra/blindspot_oracle.py +356 -0
  52. physmap/infra/corpus_runtime.py +275 -0
  53. physmap/integrations/__init__.py +0 -0
  54. physmap/materiality/__init__.py +0 -0
  55. physmap/materiality/estimator.py +239 -0
  56. physmap/materiality/independence.py +92 -0
  57. physmap/materiality/surrogate_fit.py +293 -0
  58. physmap/observability/__init__.py +0 -0
  59. physmap/pipeline/__init__.py +58 -0
  60. physmap/pipeline/aggregators.py +199 -0
  61. physmap/pipeline/assessment_v06.py +509 -0
  62. physmap/pipeline/core.py +442 -0
  63. physmap/pipeline/defeasible_aggregator.py +324 -0
  64. physmap/pipeline/detectors.py +309 -0
  65. physmap/pipeline/observability.py +430 -0
  66. physmap/pipeline/surrogate.py +251 -0
  67. physmap/pipeline/validity_signal.py +273 -0
  68. physmap/pipeline/vehicle_spec.py +287 -0
  69. physmap/release.py +81 -0
  70. physmap/stress_tests/__init__.py +9 -0
  71. physmap/stress_tests/lewis_reuse.py +517 -0
  72. physmap/substrate/__init__.py +28 -0
  73. physmap/substrate/corpus_real.py +206 -0
  74. physmap/substrate/engine.py +209 -0
  75. physmap/substrate/forrest.py +249 -0
  76. physmap/substrate/loaders.py +2176 -0
  77. physmap/substrate/naca_tn1451.py +379 -0
  78. physmap/substrate/naca_wpd_loader.py +187 -0
  79. physmap/substrate/stage1_ingest.py +187 -0
  80. physmap/substrate/vehicle_config.py +407 -0
  81. physmap-0.2.0.dist-info/METADATA +270 -0
  82. physmap-0.2.0.dist-info/RECORD +88 -0
  83. physmap-0.2.0.dist-info/WHEEL +5 -0
  84. physmap-0.2.0.dist-info/entry_points.txt +2 -0
  85. physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
  87. physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
  88. physmap-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,206 @@
1
+ """PhysMAP Corpus Rung 2 — real-truth corpus-vs-causal test on Row-shaped data.
2
+
3
+ Data-source-agnostic: three adapters feed the same machinery — `pipe` (the vehicle-3
4
+ NB Ri sweep at conv_step3_final.json), `pump` (FDA pump fHb measurement; idles at
5
+ EMPIRICAL-INCOMPLETE while harvest grows + magnitude bridge lands), `step0_fixture`
6
+ (the algebraic stand-in for the plumbing smoke-test). Leave-one-out at the row level;
7
+ rung-1's verdict functions are imported.
8
+
9
+ The HEADLINE is G-corpus-beats-causal, but its interpretation is gated by
10
+ G-divergence-exists, which itself has BOTH a numeric criterion AND a substrate
11
+ criterion. If the substrate cannot carry a closure-reality divergence claim (Eq13 is a
12
+ correlation, not measurement; algebraic truth is closure-derivable by construction),
13
+ the headline is NOT-INFORMATIVE — neither pass nor fail. The corpus value test
14
+ requires a divergent-truth substrate, which today only the (future) bridged pump is.
15
+
16
+ Run: python -m physmap.substrate.corpus_real [--dataset pipe|pump|step0_fixture]
17
+ Torch-free.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import csv
24
+ import inspect
25
+ import json
26
+ import os
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+
30
+ import numpy as np
31
+
32
+ from physmap.infra.blindspot_oracle import EMAX, SMAX, THETA_MAT
33
+ from physmap.infra.corpus_runtime import _bootstrap_ci
34
+ from physmap.infra.corpus_runtime import Entry, corpus_verdict, pde_residual_flag
35
+ from physmap.substrate.stage1_ingest import (
36
+ TRUTH_FIXTURE,
37
+ TRUTH_INDEPENDENT,
38
+ Mechanism,
39
+ Row,
40
+ cfd_rows,
41
+ step0_fixture,
42
+ )
43
+
44
+ OUT_DIR = Path(__file__).parent.parent / "results" / "physmap_corpus_real"
45
+
46
+ # Defaults
47
+ K_DEFAULT = 5
48
+ THETA_ERR = 0.05
49
+ TOL = 0.05
50
+ GUARDRAIL_PCT = 95
51
+ SCALE_STEP0 = SMAX + EMAX
52
+
53
+ # Pipe knobs
54
+ RI_CAL_LO, RI_CAL_HI = 0.1, 10.0
55
+ DEFAULT_PIPE_PATH = os.path.expanduser(
56
+ os.environ.get("PHYSMAP_PIPE_DATA", "~/physmap-wall/convection/conv_step3_final.json"))
57
+ DEFAULT_PUMP_PATH = os.path.expanduser(
58
+ os.environ.get("PHYSMAP_PUMP_DATA", "~/physmap-wall/pump/pump_rows_step2.json"))
59
+
60
+
61
+ # ── substrate metadata ───────────────────────────────────────────────────────
62
+
63
+ @dataclass
64
+ class SubstrateMeta:
65
+ """Per-substrate declaration of the properties that gate the headline's
66
+ interpretation. `divergent_truth_substrate` is the critical flag — if False, the
67
+ substrate cannot test the corpus's value (closure-vs-correlation is not closure-
68
+ vs-reality), and G-corpus-beats-causal must report NOT-INFORMATIVE."""
69
+ name: str
70
+ divergent_truth_substrate: bool
71
+ reason: str
72
+ norm_strategy: str # how to normalize closure/truth to comparable units
73
+ bound_for_pde: float | None # None = PDE arm inert (no fixed ceiling)
74
+ magnitude_bridge_ok: bool
75
+ extra: dict = field(default_factory=dict)
76
+
77
+
78
+ # ── physics coords (kNN index) ───────────────────────────────────────────────
79
+
80
+ def physics_coords_row(row: Row, scaling: str = "log") -> np.ndarray:
81
+ """Per-row coord vector for kNN indexing. Mechanism-agnostic: uses each mechanism's
82
+ operating_value. Log scaling is the default — operating values typically span
83
+ orders of magnitude and Euclidean kNN behaves better in log space."""
84
+ vals = np.array([m.operating_value for m in row.mechanisms], float)
85
+ if scaling == "log":
86
+ return np.log(np.maximum(vals, 1e-12))
87
+ if scaling == "raw":
88
+ return vals
89
+ if scaling == "calib_range":
90
+ out = []
91
+ for m in row.mechanisms:
92
+ rng = m.calib_hi - m.calib_lo
93
+ out.append((m.operating_value - m.calib_lo) / max(rng, 1e-12))
94
+ return np.array(out, float)
95
+ raise ValueError(f"unknown scaling: {scaling}")
96
+
97
+
98
+ # ── normalization (closure vs truth on a comparable scale) ───────────────────
99
+
100
+ def _norm_for(row: Row, meta: SubstrateMeta) -> float:
101
+ """Per-row normalizer so |closure - truth|/norm is unitless and comparable to TOL."""
102
+ if meta.norm_strategy == "per_row_truth":
103
+ return float(row.cfd_truth)
104
+ if meta.norm_strategy == "global_scale":
105
+ return float(SCALE_STEP0)
106
+ if meta.norm_strategy == "per_row_ref_const":
107
+ return float(row.meta.get("RIH2_C5_normalization_reference", 1.0))
108
+ raise ValueError(f"unknown norm_strategy: {meta.norm_strategy}")
109
+
110
+
111
+ def normalize_row(row: Row, meta: SubstrateMeta) -> tuple[float, float, float, float]:
112
+ """Return (closure_norm, truth_norm, error_norm_signed, norm)."""
113
+ norm = _norm_for(row, meta)
114
+ if meta.norm_strategy == "per_row_truth":
115
+ # closure/truth_norm both reported on the per-row truth scale
116
+ closure_norm = float(row.surrogate_prediction) / norm
117
+ truth_norm = 1.0
118
+ else:
119
+ closure_norm = float(row.surrogate_prediction) / norm
120
+ truth_norm = float(row.cfd_truth) / norm
121
+ err = truth_norm - closure_norm
122
+ return closure_norm, truth_norm, err, norm
123
+
124
+
125
+ # ── per-row verdicts (the four real-arm verdicts; pde may be inert) ──────────
126
+
127
+ def naive_verdict_row(row: Row) -> bool:
128
+ """Untrustworthy iff any mechanism is out of [calib_lo, calib_hi]."""
129
+ return any(not m.in_calibration() for m in row.mechanisms)
130
+
131
+
132
+ def causal_verdict_row(row: Row, theta_mat: float = THETA_MAT) -> bool:
133
+ """Untrustworthy iff any material mechanism (contribution fraction ≥ theta_mat) is
134
+ out of calibration. Mirrors rung 1's causal verdict on the schema's mechanisms."""
135
+ mats = row.materialities()
136
+ for i, m in enumerate(row.mechanisms):
137
+ if (not m.in_calibration()) and (float(mats[i]) >= theta_mat):
138
+ return True
139
+ return False
140
+
141
+
142
+ def guardrails_verdict_row(row: Row, reference: dict, pct: float = GUARDRAIL_PCT) -> bool:
143
+ """Untrustworthy iff any guardrail signal exceeds the pct-th percentile of the
144
+ precomputed reference distribution. Reference signals all-zero (placeholder substrate)
145
+ → guardrail arm is inert (never flags)."""
146
+ for k in ("ood", "residual", "variance"):
147
+ ref = np.asarray(reference.get(k, [0.0]), float)
148
+ thr = float(np.percentile(ref, pct)) if len(ref) else 0.0
149
+ sig = float(row.guardrail_signals.get(k, 0.0))
150
+ if sig > thr:
151
+ return True
152
+ return False
153
+
154
+
155
+ def pde_residual_verdict(closure_norm: float, bound: float | None) -> bool | None:
156
+ """Bound check on the NORMALIZED closure. Returns None if the arm is inert
157
+ (no fixed ceiling for this substrate)."""
158
+ if bound is None:
159
+ return None
160
+ return bool(pde_residual_flag(np.array([closure_norm]), bound=bound)[0])
161
+
162
+
163
+ # ── corpus build + LOO ───────────────────────────────────────────────────────
164
+
165
+ def row_to_entry(row: Row, meta: SubstrateMeta, scaling: str = "log") -> Entry:
166
+ """Build a rung-1 Entry from a Row, normalized + with materiality fractions."""
167
+ closure_norm, truth_norm, err_norm, _ = normalize_row(row, meta)
168
+ mats = row.materialities()
169
+ s_frac = float(mats[0]) if len(mats) >= 1 else 0.0
170
+ e_frac = float(mats[1]) if len(mats) >= 2 else 0.0
171
+ return Entry(
172
+ coords=physics_coords_row(row, scaling=scaling),
173
+ closure=closure_norm,
174
+ truth=truth_norm,
175
+ error=err_norm, # signed; corpus_verdict takes |error|
176
+ qoi_contrib_s=s_frac,
177
+ qoi_contrib_e=e_frac,
178
+ )
179
+
180
+
181
+ def divergence_exists(rows: list[Row], meta: SubstrateMeta, tol: float,
182
+ theta_mat: float) -> dict:
183
+ """Per-row check: is |closure - truth| > tol AND does causal NOT flag it?
184
+ Records the divergence-bearing condition ops explicitly."""
185
+ divergent = []
186
+ for r in rows:
187
+ cl_n, tr_n, _, _ = normalize_row(r, meta)
188
+ if abs(cl_n - tr_n) > tol and not causal_verdict_row(r, theta_mat):
189
+ divergent.append({"op": list(r.operating_point),
190
+ "closure_norm": round(cl_n, 4),
191
+ "truth_norm": round(tr_n, 4),
192
+ "abs_err_norm": round(abs(cl_n - tr_n), 4)})
193
+ return {"n_divergent": len(divergent), "divergent_ops": divergent,
194
+ "numeric_criterion_passed": len(divergent) >= 1}
195
+
196
+
197
+ # ---------------------------------------------------------------------------
198
+ # The leave-one-out EXPERIMENT path -- loo_run, metrics_loo, gates, plotting,
199
+ # CSV writing, the dataset loaders and the __main__ runner -- is not ported.
200
+ #
201
+ # None of it is on the benchmark path: outside this module only SubstrateMeta is
202
+ # imported. It also computed per-arm recall and false-alarm rates, which a build
203
+ # that reports no performance metrics must not carry -- the public-surface audit
204
+ # flagged exactly those two lines, and deleting the dead code is the honest fix
205
+ # rather than adding an exemption to the guard.
206
+ # ---------------------------------------------------------------------------
@@ -0,0 +1,209 @@
1
+ """Generic config-driven substrate engine.
2
+
3
+ Per the v0.2 architecture refactor (Part 1): one `build_substrate(config, ...)`
4
+ function replaces the per-vehicle hand-coded substrate loaders. Vehicle-specific
5
+ data (geometry, closures, cell bands, raw-data loader) lives in YAML configs
6
+ under `physmap/vehicles/`; the engine reads the config, enforces the
7
+ geometry-match invariant, dispatches to a registered loader adapter, and
8
+ annotates the returned SubstrateMeta with engine-level provenance.
9
+
10
+ THE GEOMETRY-MATCH INVARIANT (the Sparrow-Cur / Lance & Smith lesson encoded
11
+ as code):
12
+
13
+ assert matched_closure.geometry_class == config.geometry.class_, REFUSE
14
+
15
+ The check has a single escape hatch: `expect_mismatch=True` paired with a
16
+ non-empty `mismatch_rationale` in the VehicleConfig. This is the explicit
17
+ override used by positive-control vehicles (Mudhafar) whose mismatch is the
18
+ experiment. The override surfaces in the returned meta's `extra` dict so
19
+ downstream auditors can see why the invariant was bypassed.
20
+
21
+ Phase-1 staging: loader adapters in `substrate_loaders.py` are THIN wrappers
22
+ around the existing hand-coded substrate loaders (`lance_smith_to_rows()`,
23
+ etc.). The engine's job in Step 4 is the invariant + dispatch + meta
24
+ annotation, not row-level computation. In Step 9 (per-vehicle migration),
25
+ each loader is decomposed into a raw-data reader; the engine then computes
26
+ matched + reference predictions via the closure registry, and the
27
+ structural-equality gate becomes non-trivial.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ from pathlib import Path
34
+ from typing import Callable
35
+
36
+ from physmap.closures import REGISTRY, ClosureEntry
37
+ from physmap.substrate.corpus_real import SubstrateMeta
38
+ from physmap.substrate.stage1_ingest import Row
39
+ from physmap.substrate.vehicle_config import (
40
+ VehicleConfig,
41
+ VehicleConfigError,
42
+ load_vehicle_config,
43
+ )
44
+
45
+
46
+ # ── exceptions ──────────────────────────────────────────────────────────────
47
+
48
+ class GeometryMismatchError(ValueError):
49
+ """The matched closure's geometry_class does not equal the vehicle's
50
+ geometry class, and `expect_mismatch=True` was not set. Refused at
51
+ build time — the substrate engine guards itself against the trap that
52
+ bit Lance & Smith and almost bit Forrest."""
53
+
54
+
55
+ # Loader signature: takes the validated VehicleConfig and the closure registry,
56
+ # returns (rows, reference, meta) compatible with the existing pipeline.
57
+ LoaderFn = Callable[[VehicleConfig, dict[str, ClosureEntry]],
58
+ tuple[list[Row], dict, SubstrateMeta]]
59
+
60
+
61
+ # ── LOADERS registry ────────────────────────────────────────────────────────
62
+ # Populated by `substrate_loaders` at import time. Kept as a plain dict so
63
+ # tests can monkey-patch entries for the byte-equality gate.
64
+
65
+ LOADERS: dict[str, LoaderFn] = {}
66
+
67
+
68
+ def register_loader(name: str, fn: LoaderFn) -> None:
69
+ """Register a data-source loader adapter. Raises if the name is taken
70
+ (silent override would mask refactor bugs)."""
71
+ if name in LOADERS:
72
+ raise ValueError(
73
+ f"LOADERS already has entry {name!r}; refusing silent override. "
74
+ f"If you intend to swap implementations, delete the entry first."
75
+ )
76
+ LOADERS[name] = fn
77
+
78
+
79
+ # ── the engine ──────────────────────────────────────────────────────────────
80
+
81
+ def build_substrate(
82
+ config: VehicleConfig | str | Path,
83
+ *,
84
+ registry: dict[str, ClosureEntry] | None = None,
85
+ corpus_path: str | Path | None = None,
86
+ ) -> tuple[list[Row], dict, SubstrateMeta]:
87
+ """Build a substrate from a VehicleConfig.
88
+
89
+ Args:
90
+ config: a `VehicleConfig` instance, or a path/string to a YAML file
91
+ (auto-loaded via `vehicle_config.load_vehicle_config`).
92
+ registry: closure registry; defaults to the global `REGISTRY`. Tests
93
+ inject a custom registry to exercise edge cases.
94
+ corpus_path: optional calibration-corpus path; accepted for forward
95
+ compatibility but unused in Phase 1 (the closure registry caches
96
+ ranges; the ClosureValidityDetector reads corpus.jsonl directly).
97
+
98
+ Returns:
99
+ (rows, reference, meta) — the same tuple shape every existing
100
+ substrate loader returns. `meta.extra` is augmented with engine-level
101
+ provenance keys: `substrate_engine_version`, `vehicle_id`,
102
+ `geometry_class`, `matched_closure_id`, `matched_closure_geometry_class`,
103
+ `matched_closure_status`, `expect_mismatch`, `mismatch_rationale` (only
104
+ if expect_mismatch is set), `reference_closure_ids`, `cell_bands_type`.
105
+
106
+ Raises:
107
+ GeometryMismatchError: matched-closure geometry != vehicle geometry
108
+ and expect_mismatch is not set.
109
+ ValueError: expect_mismatch is set but geometries actually agree
110
+ (dead override; either fix the YAML or drop the flag).
111
+ KeyError: unknown loader name in `data_source.loader`.
112
+ """
113
+ # Step 1 — coerce path to VehicleConfig
114
+ if isinstance(config, (str, Path)):
115
+ config = load_vehicle_config(config)
116
+ elif not isinstance(config, VehicleConfig):
117
+ raise TypeError(
118
+ f"build_substrate(config): expected VehicleConfig or path, got "
119
+ f"{type(config).__name__}."
120
+ )
121
+
122
+ reg = registry if registry is not None else REGISTRY
123
+
124
+ # Step 2 — registry lookup (KeyError already produced by VehicleConfig
125
+ # validation, but defensively re-check here so test-injected registries
126
+ # surface the error from the engine, not from dataclass construction).
127
+ if config.matched_closure_id not in reg:
128
+ raise KeyError(
129
+ f"matched_closure_id={config.matched_closure_id!r} not in the "
130
+ f"provided registry. Known: {sorted(reg.keys())}."
131
+ )
132
+ matched: ClosureEntry = reg[config.matched_closure_id]
133
+
134
+ # Step 3 — geometry-match invariant (THE Sparrow-Cur lesson)
135
+ geom_match = (matched.geometry_class == config.geometry.class_)
136
+
137
+ if not geom_match and not config.expect_mismatch:
138
+ raise GeometryMismatchError(
139
+ f"VehicleConfig {config.vehicle_id!r}: matched_closure_id "
140
+ f"{config.matched_closure_id!r} has geometry_class="
141
+ f"{matched.geometry_class!r} but vehicle geometry.class="
142
+ f"{config.geometry.class_!r}. This is the Sparrow-Cur / "
143
+ f"Lance & Smith trap; refused at build time. If this mismatch is "
144
+ f"INTENTIONAL (positive control), set `expect_mismatch: true` "
145
+ f"and supply a non-empty `mismatch_rationale` in the YAML."
146
+ )
147
+ if geom_match and config.expect_mismatch:
148
+ raise ValueError(
149
+ f"VehicleConfig {config.vehicle_id!r}: expect_mismatch=true but "
150
+ f"geometries actually match (both are "
151
+ f"{matched.geometry_class!r}). Either remove expect_mismatch / "
152
+ f"mismatch_rationale (override is dead), or correct the geometry "
153
+ f"class. Refusing to silently treat a matching configuration as "
154
+ f"a positive control."
155
+ )
156
+
157
+ # Step 4 — verify all reference closures resolve in the registry
158
+ # (VehicleConfig already checks against the GLOBAL REGISTRY; if a custom
159
+ # registry is injected, re-check.)
160
+ if registry is not None:
161
+ unknown_refs = [c for c in config.reference_closure_ids if c not in reg]
162
+ if unknown_refs:
163
+ raise KeyError(
164
+ f"reference_closure_ids unknown to provided registry: "
165
+ f"{unknown_refs}. Known: {sorted(reg.keys())}."
166
+ )
167
+
168
+ # Step 5 — loader dispatch
169
+ loader_name = config.data_source.loader
170
+ if loader_name not in LOADERS:
171
+ raise KeyError(
172
+ f"Unknown data-source loader {loader_name!r}. Registered: "
173
+ f"{sorted(LOADERS.keys())}. To add a new loader, call "
174
+ f"substrate_engine.register_loader(...) at import time."
175
+ )
176
+ loader = LOADERS[loader_name]
177
+
178
+ rows, reference, meta = loader(config, reg)
179
+
180
+ # Step 6 — annotate meta with engine-level provenance
181
+ extra = dict(meta.extra)
182
+ extra.update({
183
+ "substrate_engine_version": "v0.2-phase1",
184
+ "vehicle_id": config.vehicle_id,
185
+ "domain": config.domain,
186
+ "geometry_class": config.geometry.class_,
187
+ "geometry_dims": dict(config.geometry.dims),
188
+ "matched_closure_id": config.matched_closure_id,
189
+ "matched_closure_geometry_class": matched.geometry_class,
190
+ "matched_closure_status": matched.status,
191
+ "reference_closure_ids": list(config.reference_closure_ids),
192
+ "cell_bands_type": config.cell_bands.type,
193
+ "expect_mismatch": bool(config.expect_mismatch),
194
+ })
195
+ if config.expect_mismatch:
196
+ extra["mismatch_rationale"] = config.mismatch_rationale
197
+
198
+ annotated = dataclasses.replace(meta, extra=extra)
199
+ return rows, reference, annotated
200
+
201
+
202
+ # Bootstrap: register the loader adapters at engine-import time. Importing
203
+ # substrate_loaders also imports the existing legacy substrate modules (which
204
+ # is fine — we want them available during the Phase-1 wrapper period).
205
+ def _register_default_loaders() -> None:
206
+ from physmap.substrate import loaders as substrate_loaders # noqa: F401 (side-effect import)
207
+
208
+
209
+ _register_default_loaders()
@@ -0,0 +1,249 @@
1
+ """PhysMAP Forrest mini-channel vehicle — D3 differentiator substrate.
2
+
3
+ Build the substrate for the D3 differentiator experiment. Source paper:
4
+ Forrest, Hu, Buongiorno, McKrell (2014), "Convective Heat Transfer in a High
5
+ Aspect Ratio Mini-Channel Heated on One Side," J. Heat Transfer 138(2):021704,
6
+ DOI: 10.1115/1.4031646 / OSTI 1295764 / SAND2014-18834J. Geometry confirmed
7
+ against the 2012 RERTR predecessor paper (Forrest, Buongiorno, McKrell, Hu).
8
+
9
+ What this substrate is for (one sentence): a steady-state internal-flow
10
+ heat-transfer vehicle with three cells defined by Re band — BENIGN
11
+ (10k≤Re≤70k, geometry-MATCHED Modified Sparrow-Cur MAE 6.1%, paper Table 4),
12
+ TRANSITION (4k≤Re<10k, textbook turbulent closures not validated; paper's
13
+ semi-analytic Eq. 24 covers it at MAE 4.6%), and SUB_CRITICAL_LAMINAR
14
+ (Re<4k, critical Re between 3500-4000 per Eq. 18; turbulent closures
15
+ overpredict by ~40% at Pr=5.4 per paper body p.7) — suitable for testing
16
+ whether PhysMAP's corpus-error signal catches divergence that
17
+ input-distribution novelty detectors miss or weakly signal.
18
+
19
+ Geometry-matched primary closure (resolves the Lance & Smith trap):
20
+ The primary closure tested on this substrate is Modified Sparrow-Cur
21
+ (Forrest 2014 Eq. 7, corpus entry
22
+ `modified-sparrow-cur-asym-narrow-rect-channel-2014`), which IS designed
23
+ for one-sided heated narrow rectangular channels. Circular-pipe closures
24
+ (Gnielinski, Dittus-Boelter, Petukhov, Sieder-Tate) are computed for
25
+ cross-comparison against Forrest's Table 4 MAEs but are NOT the primary
26
+ test target. Testing the differentiator on the geometry-mismatched
27
+ circular-pipe closures would contaminate the result with geometry
28
+ mismatch — exactly the trap that produced the Lance & Smith
29
+ confined-geometry finding. By using the matched closure, the
30
+ divergence at Re<4k and the transition is cleanly attributable to
31
+ closure validity-edge failure (Re below the closure's validated lower
32
+ bound 10000), not to geometry incompatibility.
33
+
34
+ Build discipline (mirrors D1 / Lance & Smith):
35
+ - Truth column = measured Nu ONLY. Refuse fitted-correlation truth
36
+ (Eq. 19 and Eq. 24 are the AUTHORS' own fits; cannot serve as truth —
37
+ independence guard).
38
+ - Steady-state → no temporal-autocorrelation discount on effective n.
39
+ Each (Re, Pr) operating point is a genuinely independent sample.
40
+ - Inspect before blessing (D1 done-gate discipline).
41
+
42
+ Two corrections wired in from the build directive:
43
+
44
+ Correction 1 — baseline-invisibility is the MEASURED OUTCOME of D3,
45
+ NOT passed by reading the paper. D3 measures the SIGNAL-GAP between
46
+ both baseline detectors (distance-to-training AND prediction-variance/
47
+ ensemble) and the corpus signal at the transition cell. Both detectors
48
+ must be quiet simultaneously at the transition for the truly-invisible
49
+ bar; gap-against-threshold is the weaker bar. Which bar counts as a win
50
+ is pre-registered BEFORE the experiment runs.
51
+
52
+ Correction 2 — Mudhafar (canonical smooth pipe, separate substrate) is
53
+ the baseline positive control. Built deliberately, not as an
54
+ afterthought. Mudhafar's divergent cells (small d, rough surface) are
55
+ baseline-VISIBLE by design. Same detectors that fire on Mudhafar's
56
+ divergence should stay quiet/weak on Forrest's transition — the
57
+ contrast that makes the differentiator result interpretable.
58
+
59
+ Data-acquisition status:
60
+ - OSTI 1295764 / 2014 paper read in full; confirms NO supplementary data
61
+ tables. Table 2 (geometry) + Table 4 (cell-level MAE summary) are the
62
+ only tables. Per-point Nu values are in Figures 5, 6, 7, 9.
63
+ - Hybrid path: cell-level analysis from Table 4 + Eq. 24 model for the
64
+ benign cell (no digitization); figure digitization of Fig. 5 for the
65
+ transition+sub-critical cells where the differentiator signal lives.
66
+ Author-data request as long-tail clean path.
67
+
68
+ The raw-data ingest function (forrest_to_rows) is parameterized by a
69
+ caller-supplied data table; the three ingest stubs below
70
+ (from_thesis_table / from_digitization / from_author_csv) raise
71
+ NotImplementedError until the data-acquisition decision lands.
72
+
73
+ Entry (post-data-acquisition): python -m physmap.forrest_inspect
74
+ """
75
+
76
+ from __future__ import annotations
77
+
78
+ from typing import Literal
79
+
80
+
81
+ # Cleanup 5 part 2: the Forrest legacy closure functions, calibration-range
82
+ # constants, ForrestRow dataclass, forrest_to_rows loader, and the three
83
+ # data-acquisition stubs have all been retired. Their canonical homes:
84
+ # - closures/formulas.py (Pohlhausen, McAdams, Churchill,
85
+ # Sparrow-Cur, Gnielinski, Dittus-Boelter,
86
+ # Petukhov, Sieder-Tate formulas)
87
+ # - closures/registry.py (re_range, pr_range, geometry_class,
88
+ # status)
89
+ # - vehicles/forrest.yaml (geometry + data_source)
90
+ # - substrate_loaders.py (engine-driven row build)
91
+ #
92
+ # This module retains ONLY:
93
+ # - geometry constants (CHANNEL_*) documenting the rig dimensions
94
+ # - the Cell Literal type + cell_assignment helper (still imported by
95
+ # forrest_resolvability)
96
+ # - the Re-band thresholds + Forrest Table-4 reference MAE dicts
97
+ # (descriptive constants used by reports + analysis scripts)
98
+
99
+
100
+ # ── geometry + fluid properties ──────────────────────────────────────────────
101
+ # From the Forrest et al. predecessor paper (2012 RERTR conference paper),
102
+ # Table 1, confirmed in SAND2014-18834J / OSTI 1295764:
103
+ # - High-aspect-ratio rectangular channel, α* = gap/width = 0.035 (≈29:1
104
+ # width:gap convention).
105
+ # - Hydraulic diameter Dh = 3.78 mm (Table 1, explicit). The 2*gap = 3.92 mm
106
+ # value is the parallel-plates asymptote; the actual finite-width
107
+ # rectangular-channel Dh = 4·A_flow / P_w gives 3.78. The paper uses Dh
108
+ # as the characteristic length in its Re and Nu definitions.
109
+ # - One-sided heating (asymmetric); the heated wall is one of the long
110
+ # faces (51 mm × 305 mm heated). The Nu is the channel-averaged for the
111
+ # heated wall.
112
+ # - Working fluid: water. Pr range 2.2 – 5.4 (corresponds to bulk
113
+ # temperatures of roughly 60-95°C; lower Pr = higher temperature).
114
+
115
+ # Geometry per Forrest, Buongiorno, McKrell, Hu (2012 RERTR conference paper),
116
+ # Table 1 — the predecessor publication to the 2014 J. Heat Transfer paper.
117
+ # Same experimental rig; the 2012 paper's Table 1 gives the dimensions
118
+ # explicitly. (The 2014 paper extends the dataset to more Pr values and
119
+ # higher Re, but uses the same rig.)
120
+ CHANNEL_GAP_MM = 1.96
121
+ CHANNEL_WIDTH_MM = 56.0
122
+ CHANNEL_LENGTH_MM = 483.0
123
+ CHANNEL_HEATED_LENGTH_MM = 305.0
124
+ CHANNEL_HEATED_WIDTH_MM = 51.0
125
+ CHANNEL_ASPECT_RATIO_ALPHA_STAR = CHANNEL_GAP_MM / CHANNEL_WIDTH_MM # = 0.035, per Table 1
126
+ CHANNEL_ASPECT_RATIO_INVERSE = CHANNEL_WIDTH_MM / CHANNEL_GAP_MM # = 28.6, the
127
+ # "high aspect ratio"
128
+ # convention (~29:1)
129
+ CHANNEL_HYDRAULIC_DIAMETER_MM = 3.79 # Forrest 2014 Table 2 explicit value
130
+ # (2012 RERTR paper Table 1 had 3.78;
131
+ # 2014 paper supersedes with 3.79).
132
+ # NOT 2*gap=3.92: the 2*gap limit is the
133
+ # parallel-plates asymptote; the actual
134
+ # rectangular-channel Dh = 4·A_flow/P_w
135
+ # accounts for finite width.
136
+
137
+ # Closure-bound calib ranges per the Track C corpus entries.
138
+ #
139
+ # PRIMARY (geometry-matched) closure for this substrate:
140
+ # Modified Sparrow-Cur (Forrest 2014 Eq. 7) — Nu = 0.036·Re^0.76·Pr^(1/3).
141
+ # Corpus entry: modified-sparrow-cur-asym-narrow-rect-channel-2014.
142
+ # This is the ONLY corpus closure with explicit one-sided-heating geometry
143
+ # dependence and is the right closure for Forrest's narrow rectangular
144
+ # mini-channel. Its validity range is what should drive the
145
+ # benign/transition/laminar cell-validity gates.
146
+ #
147
+ # COMPARISON (geometry-mismatched, circular-pipe) closures:
148
+ # Gnielinski, Dittus-Boelter, Petukhov, Sieder-Tate. These are computed
149
+ # alongside Sparrow-Cur for cross-comparison against Forrest 2014 Table 4
150
+ # MAEs, but the closure-validity test runs on Sparrow-Cur (the matched
151
+ # closure). Testing the differentiator on Gnielinski would contaminate
152
+ # the result with geometry mismatch (the same trap that produced the
153
+ # Lance & Smith confined-geometry finding), so the matched closure is
154
+ # the primary; circular-pipe closures are reported for reference only.
155
+
156
+
157
+
158
+ # ── cell definitions ─────────────────────────────────────────────────────────
159
+ # Cell-band thresholds match Forrest 2014 paper's EXPLICIT characterizations
160
+ # (revised after reading the full paper; the prior recon-memory had four
161
+ # cells with wrong boundaries).
162
+ #
163
+ # - Re < 4,000: SUB_CRITICAL / LAMINAR. Critical Re between 3,500
164
+ # and 4,000 (Eq. 18). At Re < 4,000 the flow is
165
+ # likely still laminar. Paper body, p.7: the
166
+ # Gnielinski correlation for Pr = 5.4 (top curve in
167
+ # Fig. 6) overpredicts the Nusselt number by about
168
+ # 40%, indicating the closure is being applied
169
+ # below its validity. Modified Sparrow-Cur (turbulent
170
+ # form, Re^0.76) would similarly over-predict here.
171
+ #
172
+ # - 4,000 ≤ Re < 10,000: TRANSITION. Flow becomes fully turbulent at
173
+ # Re ≈ 7,000 per the friction-factor data (paper
174
+ # p.5). Textbook turbulent closures are not
175
+ # validated here — Forrest's Table 4 MAEs are
176
+ # reported only for Re ≥ 10,000. The paper's
177
+ # semi-analytic Eq. (24) covers 4k–70k at MAE
178
+ # 4.6%.
179
+ #
180
+ # - 10,000 ≤ Re ≤ 70,000: BENIGN (turbulent). Textbook closures applicable
181
+ # here with MAE 6.1-15.2% (Table 4). NO sub-cell
182
+ # structure within this range — Gnielinski's
183
+ # 8.4% MAE is reported uniformly across this
184
+ # interval, not split by sub-range.
185
+ #
186
+ # This is THREE cells, not four. The prior `divergent_high_re` (Re > 35k)
187
+ # was a misreading of Eq. (19)'s 10k–35k validity (the authors' OWN power
188
+ # law fit, limited to the range where a simple power law is adequate);
189
+ # it is NOT a textbook-closure divergence cell.
190
+
191
+ Cell = Literal[
192
+ "benign_turbulent", # do-no-harm cell: closures perform per Table 4
193
+ "transition_excluded_indeterminate", # excluded: closure-divergence signal within measurement floor
194
+ "sub_critical_laminar", # divergent test cell: ~50-80% closure over-prediction
195
+ ]
196
+
197
+ BENIGN_RE_LO = 10000.0 # paper Table 4 lower bound for all textbook closures
198
+ BENIGN_RE_HI = 70000.0 # paper Table 4 upper bound for all textbook closures
199
+ TRANSITION_RE_LO = 4000.0 # paper p.7 / Fig. 5 region — EXCLUDED, not benign
200
+ TRANSITION_RE_HI = 10000.0
201
+ SUB_CRITICAL_RE_HI = 4000.0 # paper Eq. 18: Re_crit between 3500-4000
202
+
203
+
204
+ def cell_assignment(Re: float) -> Cell:
205
+ """Per-row cell label by Re band. Bands READ FROM the Forrest 2014 paper
206
+ (Table 4 + Eq. 18 + p.7 cell-by-cell discussion), NOT derived from the
207
+ data (which would be a leak).
208
+
209
+ Cell roles in the D3 differentiator (per the build directive):
210
+ - `sub_critical_laminar`: the ONLY divergent test cell. Visible n=2
211
+ in Fig 5 at Re ~ 3800, Pr=5.4. Both Sparrow-Cur and Gnielinski
212
+ over-predict by ~50-80% (resolvable: |gap|/unc > 3).
213
+ - `transition_excluded_indeterminate`: excluded from the test cell
214
+ set. Resolvability triage on visual estimates from Fig 5 (n=12)
215
+ shows median |gap|/unc ~ 0.4-0.5 — within the measurement-
216
+ uncertainty floor. Paper text calls the transition "conservative
217
+ estimate" territory, which is a safety statement not a validity
218
+ statement, so the cell is indeterminate (NOT benign-relabeled).
219
+ - `benign_turbulent`: do-no-harm cell. Closures perform per Table 4
220
+ (Sparrow-Cur MAE 6.1%, Gnielinski 8.4%, etc.). The corpus signal
221
+ is expected to be quiet here.
222
+ """
223
+ if Re < SUB_CRITICAL_RE_HI:
224
+ return "sub_critical_laminar"
225
+ if Re < TRANSITION_RE_HI:
226
+ return "transition_excluded_indeterminate"
227
+ return "benign_turbulent" # 10000 ≤ Re; covers full validated turbulent range
228
+
229
+
230
+ # Paper Table 4 reference MAE numbers (2.2 ≤ Pr ≤ 5.4, Re per cell)
231
+ # Used for cell-level comparison against corpus signal predictions.
232
+ # Recorded here as ground-truth references; the live closure-vs-truth
233
+ # comparison still runs row-by-row on measured Nu when data is in hand.
234
+ TABLE_4_MAES_BENIGN = {
235
+ "modified-sparrow-cur-asym-narrow-rect-channel-2014": 0.061, # 6.1% — GEOMETRY-MATCHED, lowest
236
+ "dittus-boelter-1930": 0.064, # 6.4%
237
+ "modified-colburn": 0.073, # 7.3% (not in corpus)
238
+ "gnielinski-1976": 0.084, # 8.4%
239
+ "petukhov-1970": 0.114, # 11.4% (not currently in corpus)
240
+ "sieder-tate-1936": 0.152, # 15.2%
241
+ "barrow-one-sided": 0.204, # 20.4% (worst — not in corpus)
242
+ }
243
+ TABLE_4_FORREST_FITS = {
244
+ "Empirical fit Eq.(19) [10000-35000]": 0.038, # 3.8%
245
+ "Semi-analytic Eq.(24) [4000-70000]": 0.046, # 4.6%
246
+ }
247
+
248
+
249
+