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,208 @@
1
+ """Corpus prerequisites for the structural-observability layer.
2
+
3
+ Four additive pieces the CredibilityGuardrail needs that the shipped corpus
4
+ does not yet provide. They live here — NOT by mutating corpus/calibration.py or
5
+ corpus.jsonl — so the locked calibration validator + SHACL + phase tests stay
6
+ untouched. The classifier reads the existing corpus via the existing accessors
7
+ (load_corpus / index_by_id / get_validated_range) and joins to these maps.
8
+
9
+ 1. REGIME_TO_CLOSURES — a structured Regime → closure_ids map. The corpus only
10
+ carries `regime_context` as prose; this is authored from closures/registry.py
11
+ geometry groupings ∩ the corpus closure_ids. ENTRANCE_REGION_PIPE →
12
+ gnielinski-1976 is what carries the x/D bound the locked NACA case fires on.
13
+ 2. observability_class — a (closure_id, coord) → tag, defaulting to
14
+ "structural-binary"; only buoyancy/property-ratio/roughness coords are
15
+ "known-partial". Drives the PARTIAL routing.
16
+ 3. partial_degree — the graded-partial DEGREE (Layer 2c) for known-partial
17
+ coords: empirical, regime-specific, and EMPTY until a middle vehicle calibrates
18
+ it. Leaving it empty is the honest state — an uncalibrated degree that drove a
19
+ weight would be a fabricated empirical claim.
20
+ 4. corpus_fingerprint — version + content hash for the save/load manifest.
21
+
22
+ These maps are the authoritative source the regime→observability mapping artifact
23
+ (physmap/guardrail/regime_observability.py) is GENERATED from; the artifact is a
24
+ validated, version-tagged projection, never a second runtime source of truth.
25
+
26
+ TODO: the canonical home for observability_class is a field on CoordinateBound,
27
+ once a buoyancy vehicle lands and the corpus owner re-blesses the validator.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import hashlib
33
+ from pathlib import Path
34
+
35
+ from physmap.corpus.calibration import (
36
+ DEFAULT_PATH as CALIBRATION_CORPUS_PATH,
37
+ active_tier,
38
+ resolve_corpus_path,
39
+ )
40
+ from physmap.guardrail.enums import Regime
41
+
42
+
43
+ # Regime → the corpus closure_ids whose validity bounds define that regime.
44
+ # Closure_ids verified present in results/calibration_corpus/corpus.jsonl.
45
+ REGIME_TO_CLOSURES: dict[Regime, tuple[str, ...]] = {
46
+ Regime.ENTRANCE_REGION_PIPE: ("gnielinski-1976",),
47
+ Regime.INTERNAL_FORCED_CONVECTION_PIPE: (
48
+ "gnielinski-1976",
49
+ "dittus-boelter-1930",
50
+ "sieder-tate-1936",
51
+ "petukhov-1970",
52
+ ),
53
+ Regime.INTERNAL_FORCED_CONVECTION_RECT_CHANNEL: (
54
+ "modified-sparrow-cur-asym-narrow-rect-channel-2014",
55
+ ),
56
+ Regime.EXTERNAL_FLAT_PLATE_FORCED: (
57
+ "blasius-pohlhausen-flat-plate-forced-1921",
58
+ ),
59
+ Regime.MIXED_CONVECTION_HORIZONTAL_TUBE: (
60
+ "aung-worku-mixed-convection-1986", # carries the richardson_number bound
61
+ ),
62
+ Regime.MIXED_CONVECTION_VERTICAL_TUBE: (
63
+ "dittus-boelter-buoyancy-sco2", # carries the liu_buoyancy_parameter (Bu) bound (Jin sCO2)
64
+ ),
65
+ Regime.INTERNAL_FORCED_CONVECTION_PROPERTY_VARIATION: (
66
+ "gnielinski-constprop-sco2", # carries the viscosity_ratio_wall_bulk bound
67
+ ),
68
+ Regime.HYPERSONIC_TRANSITION_DISTURBANCE: (
69
+ "pate-stainback-freestream-noise-hypersonic-1980", # freestream-noise validity bound (Casper)
70
+ ),
71
+ Regime.HYPERSONIC_TRANSITION_ENTROPY: (
72
+ "marineau-entropy-layer-shock-interaction-2014", # S_T/X_SW validity bound (Marineau)
73
+ ),
74
+ Regime.UNLISTED: (), # statistical-only; physics layer inactive
75
+ }
76
+
77
+ # TODO (handbook-harvest rounds 1+2, corpus v0.2.3) — closures present in the corpus but NOT yet
78
+ # wired to a Regime above. They are INERT: discoverable via load_default_corpus_index(), but they
79
+ # do NOT fire in the guardrail until mapped here. Each needs a human physics decision on which
80
+ # Regime it defines (+ a known-partial override in OBSERVABILITY_CLASS_OVERRIDES below if its coord
81
+ # is absent-but-correlated rather than a clean structural-binary miss).
82
+ #
83
+ # ⚠ CONSISTENCY OBLIGATION — viscosity_ratio_wall_bulk is KNOWN_PARTIAL on the mapped
84
+ # gnielinski-constprop-sco2, and a variable's class is one value everywhere it appears, so BOTH
85
+ # inert closures that reuse it (whitaker-sphere-external-forced-1972, sieder-tate-laminar-
86
+ # combined-entry-1936) MUST get a known-partial override here AT THE MOMENT THEY ARE WIRED, or
87
+ # the mapping class-consistency gate fails. Harmless while inert: build_mapping() omits unmapped
88
+ # closures, so the conflict is latent until one is wired.
89
+ #
90
+ # Round 1 (still inert):
91
+ # * swanson-catton-opposing-mixed-convection-1987 (grashof_number) — opposing mixed convection
92
+ # * mack-second-mode-transition-1969 (mach_number) — compressible 2nd-mode transition
93
+ # * perfect-gas-constant-cp-compressible-bl-1989 (mach_number) — compressible boundary layer
94
+ # * blasius-pohlhausen-horizontal-plate-forced-1921 (gr_over_re2p5, gr_xl1p5_*) — horizontal-plate mixed convection
95
+ # * campbell-perkins-triangular-duct-air-1968 (temperature_ratio_wall_bulk) — triangular-duct gas property variation
96
+ # * sieder-tate-laminar-combined-entry-1936 (viscosity_ratio_wall_bulk) — laminar combined-entry μ/μ_s [known-partial WHEN WIRED]
97
+ # Round 2 (promoted v0.2.3, all inert):
98
+ # * whitaker-sphere-external-forced-1972 (viscosity_ratio_wall_bulk) — sphere μ/μ_s moat [known-partial WHEN WIRED]
99
+ # * cheng-curved-square-duct-nusselt-1975 (dean_number) — curved square duct; De is hydraulic-diameter-normalized
100
+ # * depew-august-horizontal-tube-mixed-convection-1971 (rayleigh_number) — horizontal-tube mixed convection (isothermal wall)
101
+ # * marcos-bergles-horizontal-tube-mixed-convection-1975 (rayleigh_number) — horizontal-tube mixed convection (uniform heat flux)
102
+ # * eckert-reference-temperature-compressible-1972 (mach_number) — compressible reference-temperature property method
103
+ # * herwig-perturbation-variable-property-bl-1985 (mach_number) — compressible variable-property BL skin friction
104
+ # * goertler-concave-wall-transition-1940 (goertler_number) — concave-wall centrifugal transition (momentum-thickness G)
105
+ # * millikan-log-law-of-the-wall-1938 (wall_y_plus) — log-law overlap onset; may JOIN the wall-function closures
106
+ # * rotta-constant-turbulent-prandtl-number-1964 (prandtl_number) — bound on MOLECULAR Pr (not Pr_t) → OBSERVABLE / low-moat
107
+ # * laminar-fully-developed-tube-isothermal-nu366 (reynolds_number) — Nu=3.66 analytical backbone → OBSERVABLE / low-moat
108
+ # * laminar-fully-developed-tube-uniform-flux-nu436 (reynolds_number) — Nu=4.36 analytical backbone → OBSERVABLE / low-moat
109
+
110
+
111
+ # Observability class for a bound coordinate. Cleanly-in/out axes (Re, Pr, x/D)
112
+ # are "structural-binary": the surrogate either consumes the axis or it does not.
113
+ # Absent-but-correlated axes (buoyancy, property-ratio, roughness) are
114
+ # "known-partial" — a surrogate may carry correlated information even without the
115
+ # axis itself, so set-membership can't cleanly weight them yet.
116
+ DEFAULT_OBSERVABILITY_CLASS = "structural-binary"
117
+ KNOWN_PARTIAL = "known-partial"
118
+
119
+ OBSERVABILITY_CLASS_OVERRIDES: dict[tuple[str, str], str] = {
120
+ ("aung-worku-mixed-convection-1986", "richardson_number"): KNOWN_PARTIAL,
121
+ # Wall/bulk property ratio: absent from (Re, Pr) inputs but correlated with them
122
+ # through the property field near the pseudo-critical point — set-membership sees
123
+ # it absent, so it routes PARTIAL (not a clean structural-binary miss).
124
+ ("gnielinski-constprop-sco2", "viscosity_ratio_wall_bulk"): KNOWN_PARTIAL,
125
+ # NOTE (Jin sCO2 vertical-tube, liu_buoyancy_parameter Bu): NOT overridden -> defaults to
126
+ # structural-binary -> routes UNOBSERVABLE. This is the MEASURED result, not a guess: the Liu Bu
127
+ # (and even the bulk-only Jackson Bo*) recoverability from the (Re, Pr) surrogate inputs over the
128
+ # deploy region is cv_r2_knn ~ 0 (corr ~ -0.2), because Bu carries the wall-temperature/HTD signal
129
+ # the DIRECTION toggle drives — orthogonal to the bulk inputs, the SAME structure as Casper's
130
+ # freestream noise (a flow-environment binary), NOT the Velazquez property-ratio middle. So Jin
131
+ # plots at the unobservable pole (a thermal-fluids PHYSMAP_WINS + the buoyancy/property-variation
132
+ # confound-isolation case), not the partial band. (Distinct group from richardson_number Gr/Re^2.)
133
+ # Sparrow-Gregg forced-convection buoyancy-validity bound (Gr/Re^2 <= 0.225, Rohsenow §4.9).
134
+ # richardson_number is the SAME physical quantity already classed known-partial above, and the
135
+ # per-variable class-consistency invariant requires one class everywhere it appears — so it is
136
+ # known-partial here too (Ri absent from a forced (Re,Pr) surrogate, correlated through Re).
137
+ ("blasius-pohlhausen-flat-plate-forced-1921", "richardson_number"): KNOWN_PARTIAL,
138
+ }
139
+
140
+
141
+ def observability_class_for(closure_id: str, coord: str) -> str:
142
+ """Return "structural-binary" (default) or "known-partial" (override table)."""
143
+ return OBSERVABILITY_CLASS_OVERRIDES.get((closure_id, coord), DEFAULT_OBSERVABILITY_CLASS)
144
+
145
+
146
+ # Graded-partial DEGREE (Layer 2c). For a known-partial coord, HOW partial it is —
147
+ # the correlation between the absent axis and the surrogate's inputs in a given
148
+ # operating regime. Empirical and regime-specific, so keyed (closure_id, coord) →
149
+ # {"partial_degree": {regime_value: degree}, "calibrated_by": [middle_vehicle_ids]}.
150
+ # A cell is populated ONLY by a middle vehicle that passes the digitization protocol
151
+ # + the stability gate; an uncalibrated cell stays absent here (the classifier then
152
+ # defers: PARTIAL → UNCERTAIN/REVIEW). Honesty discipline: an uncalibrated degree that
153
+ # drives a weight is a fabricated empirical claim — never invent a cell, and never key
154
+ # one to a regime the calibrating vehicle did not actually operate in.
155
+ PARTIAL_DEGREE_CALIBRATIONS: dict[tuple[str, str], dict] = {
156
+ # Velázquez sCO2 (property-variation middle): cv_r2_knn = 0.575, stability-confirmed
157
+ # (provisional=false, subsampling std 0.139 < 0.15 gate). Measured value banked in
158
+ # results/velazquez_sco2_digitization/observability_result.json; this cell consumes
159
+ # that score, it does not recompute it. Keyed to the regime the vehicle operates in.
160
+ ("gnielinski-constprop-sco2", "viscosity_ratio_wall_bulk"): {
161
+ "partial_degree": {"internal_forced_convection_property_variation": 0.575},
162
+ "calibrated_by": ["velazquez_sco2"],
163
+ },
164
+ }
165
+
166
+
167
+ def partial_degree_for(
168
+ closure_id: str, coord: str,
169
+ ) -> tuple[dict[str, float] | None, str, list[str]]:
170
+ """Return (partial_degree, degree_status, calibrated_by) for a bound coordinate.
171
+
172
+ Three states, mirroring the spec's `degree_status` vocabulary:
173
+ * structural-binary → (None, "n/a", [])
174
+ * known-partial, uncalibrated → (None, "uncalibrated", [])
175
+ * known-partial, calibrated → ({regime_value: degree}, "calibrated", [ids])
176
+ """
177
+ if observability_class_for(closure_id, coord) != KNOWN_PARTIAL:
178
+ return None, "n/a", []
179
+ cal = PARTIAL_DEGREE_CALIBRATIONS.get((closure_id, coord))
180
+ if not cal:
181
+ return None, "uncalibrated", []
182
+ return cal["partial_degree"], "calibrated", list(cal.get("calibrated_by", []))
183
+
184
+
185
+ # Bumped by hand when the calibration corpus changes in a way that affects
186
+ # guardrail bounds. The content hash catches silent drift a semver alone misses.
187
+ CALIBRATION_CORPUS_VERSION = "0.2.4" # 0.2.4: +dittus-boelter-buoyancy-sco2 (corpus 52->53) — NEW coordinate liu_buoyancy_parameter (Liu et al. 2017 Bu, wall-aware; DISTINCT from richardson_number Gr/Re^2, same-name trap avoided), validated_range Bu<=1.3e-5 (a-priori, banked jin-2023-correct Claim). WIRED into REGIME_TO_CLOSURES[MIXED_CONVECTION_VERTICAL_TUBE] + KNOWN_PARTIAL (Bu partial-recoverable) — FIRES for the Jin sCO2-buoyancy benchmark cell, INERT for the other six (they carry no Bu coordinate, so their cells stay byte-identical). MAPPING regenerated (Jin regime/closure now in the projection). 0.2.3: handbook-harvest round 2 — 11 INERT new closures promoted (corpus 41->52), all claimed, none wired. MAPPING_VERSION intentionally stays 0.2.2: every promoted closure is inert (not in REGIME_TO_CLOSURES), so build_mapping()'s projection is byte-identical and the drift-guard is green WITHOUT regen. New: Incropera whitaker-sphere-external-forced-1972 (viscosity_ratio_wall_bulk [1.0,3.2] sphere μ/μ_s moat; known-partial WHEN WIRED) + laminar-fully-developed-tube nu366/nu436 (reynolds_number<=2300 analytical backbone, OBSERVABLE); Rohsenow cheng-curved-square-duct-nusselt-1975 (dean_number [20,705], D_h-normalized) + depew-august/marcos-bergles horizontal-tube mixed-conv (rayleigh_number); VDI eckert-reference-temperature-compressible-1972 (mach_number<=20); Schlichting millikan-log-law (wall_y_plus>=70) + goertler-concave-wall-transition (goertler_number>=7, momentum-thickness) + herwig-variable-property-bl (mach_number<=3) + rotta-constant-turbulent-prandtl (prandtl_number>=0.5 MOLECULAR-Pr, OBSERVABLE). dean_number & goertler_number are new vocabulary coords (conventions pinned in SCHEMA.md). 0.2.2: handbook-harvest batch — VDI gnielinski-1976 property-variation (prandtl_ratio_bulk_wall [0.1,10] + temperature_ratio_bulk_wall [0.5,1.0], FIRE) + swanson-catton-opposing-mixed-convection-1987 grashof_number (inert); Schlichting mach_number closures mack-second-mode-transition-1969 (>=2.2) + perfect-gas-constant-cp-compressible-bl-1989 (<=5.0) (inert); Rohsenow blasius-pohlhausen-horizontal-plate-forced-1921 (3 buoyancy-grouping coords) + campbell-perkins-triangular-duct-air-1968 (temperature_ratio_wall_bulk [1.10,2.11] gas T-ratio) (both inert); all new coords structural-binary/UNOBSERVABLE, additive. 0.2.1: +Sparrow-Gregg Ri<=0.225 on blasius-pohlhausen (fires) + sieder-tate-laminar (inert)
188
+
189
+
190
+ def corpus_fingerprint(path: str | Path | None = None) -> dict:
191
+ """{'version', 'sha256', 'n_entries', 'tier'} for the save/load manifest.
192
+
193
+ The hash is over the SORTED non-empty JSONL lines, so reordering closures
194
+ does not change it (the corpus is a set of closures, not an ordered list).
195
+ path=None fingerprints the ACTIVE corpus (env → premium → bundled seed); the
196
+ 'tier' field lets load() distinguish a seed-fitted guard from a premium one
197
+ (different content → different hash already, but tier makes the drift message
198
+ explicit). Seed and premium therefore carry distinct fingerprints by design.
199
+ """
200
+ p = Path(path) if path is not None else resolve_corpus_path()
201
+ lines = [ln.strip() for ln in p.read_text(encoding="utf-8").splitlines() if ln.strip()]
202
+ digest = hashlib.sha256("\n".join(sorted(lines)).encode("utf-8")).hexdigest()
203
+ return {
204
+ "version": CALIBRATION_CORPUS_VERSION,
205
+ "sha256": digest,
206
+ "n_entries": len(lines),
207
+ "tier": active_tier(p),
208
+ }
@@ -0,0 +1,129 @@
1
+ """Observable-pole conformal statistical mode — the split-conformal residual detector.
2
+
3
+ Spec: docs/specs/PhysMAP_AxisAdaptive_Guardrail_Unified_Spec_v0_1.md (action #4 — the
4
+ observable-pole "competent statistical detector" mode).
5
+
6
+ At the OBSERVABLE pole the failure driver IS a surrogate input, so a GP emulator fit on
7
+ truth sees it and is a sound reference. This detector flags test points where the
8
+ SURROGATE's prediction falls OUTSIDE the GP's split-conformal prediction interval — a
9
+ DEPLOYABLE credibility signal (it needs the surrogate prediction, NOT the test truth) with
10
+ a distribution-free coverage guarantee.
11
+
12
+ Split conformal (Vovk; Lei et al. 2018):
13
+ * fit a GP emulator (the locked Matérn-5/2 GPVarianceDetector) on a PROPER-train split;
14
+ * on a held-out CALIBRATION split compute the studentized GP residual
15
+ s_i = |y_i - mu(x_i)| / sigma(x_i)
16
+ and take the conformal quantile q = the ceil((n_cal+1)(1-alpha))-th smallest s_i, so
17
+ P(|y - mu(x)| / sigma(x) <= q) >= 1 - alpha for an exchangeable future point;
18
+ * at DEPLOY, FIRE when the surrogate's prediction is outside that interval:
19
+ |pred(x) - mu(x)| / sigma(x) > q.
20
+
21
+ WHY THIS IS NOT THE FLOORED gp_variance BASELINE (the honest comparison bar — the floor
22
+ already beats naive percentile-of-self, so beating naive would be hollow):
23
+ * The floored gp_variance fires on the predictive-variance MAGNITUDE (sigma/|mu| above a
24
+ floored percentile-of-self). It is structurally BLIND to a surrogate that is
25
+ confidently WRONG at a point where the GP variance is LOW (a dense-training region):
26
+ sigma is small there, so it stays quiet.
27
+ * This detector fires on surrogate–GP DISAGREEMENT studentized by sigma, so a confident
28
+ wrong surrogate at low variance lands far outside the (tight) conformal interval and
29
+ FIRES — the failure class the floored gp_variance misses — while the conformal quantile
30
+ is calibrated OUT-OF-SAMPLE (on a held-out split, not the in-sample self-scores that
31
+ degenerate under dense training), so the in-distribution false-alarm rate is ~alpha: a
32
+ coverage guarantee the percentile-of-self + floor heuristic does not provide.
33
+
34
+ DISCIPLINE: a competent, better-than-NAIVE statistical detector for OBSERVABLE regimes
35
+ (table stakes), NOT a moat and NOT aiming to beat a steelman OOD detector. It is OPT-IN
36
+ (absent from the default detector set and from the banked benchmark detector tuple), so it
37
+ is strictly ADDITIVE — the banked matrix is untouched.
38
+ """
39
+ from __future__ import annotations
40
+
41
+ import numpy as np
42
+
43
+ from physmap.pipeline.core import DetectorResult
44
+ from physmap.pipeline.detectors import GPVarianceDetector
45
+
46
+ CONFORMAL_NAME = "conformal_residual"
47
+ _MIN_PROPER_TRAIN = 2 # the GP emulator needs at least this many proper-train rows
48
+ _STD_FLOOR_REL = 1e-6 # relative floor on sigma, applied identically to calib + test
49
+
50
+
51
+ class ConformalResidualDetector:
52
+ """Split-conformal studentized-residual detector over the locked GP emulator.
53
+
54
+ Calibration uses train TRUTH (the GP residual on a held-out split); the deploy-time
55
+ FIRE condition uses the surrogate PREDICTION (no test truth) — so it is deployable.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ train_X: np.ndarray,
61
+ train_y: np.ndarray,
62
+ *,
63
+ alpha: float = 0.1,
64
+ calib_frac: float = 0.3,
65
+ random_state: int = 20260605,
66
+ ) -> None:
67
+ train_X = np.asarray(train_X, dtype=float)
68
+ train_y = np.asarray(train_y, dtype=float).ravel()
69
+ n = train_X.shape[0]
70
+ if n < _MIN_PROPER_TRAIN + 1:
71
+ raise ValueError(
72
+ f"conformal_residual needs >= {_MIN_PROPER_TRAIN + 1} train rows to split "
73
+ f"into proper-train + calibration; got {n}.")
74
+ self.alpha = float(alpha)
75
+ self.calib_frac = float(calib_frac)
76
+
77
+ rng = np.random.default_rng(random_state)
78
+ perm = rng.permutation(n)
79
+ n_cal = int(round(self.calib_frac * n))
80
+ n_cal = min(max(n_cal, 1), n - _MIN_PROPER_TRAIN) # keep >= _MIN_PROPER_TRAIN proper
81
+ cal_idx, prop_idx = perm[:n_cal], perm[n_cal:]
82
+
83
+ # Emulator on the PROPER-train split only (split-conformal exchangeability).
84
+ self._gp = GPVarianceDetector(
85
+ train_X[prop_idx], train_y[prop_idx], random_state=random_state)
86
+ self._std_floor = max(float(np.abs(train_y).mean()) * _STD_FLOOR_REL, _STD_FLOOR_REL)
87
+
88
+ mu, std = self._gp.gp.predict(train_X[cal_idx], return_std=True)
89
+ std = np.maximum(std, self._std_floor)
90
+ nonconf = np.abs(train_y[cal_idx] - mu) / std
91
+
92
+ # Split-conformal quantile: the ceil((n_cal+1)(1-alpha))-th smallest calibration score.
93
+ k = int(np.ceil((n_cal + 1) * (1.0 - self.alpha)))
94
+ k = min(max(k, 1), n_cal)
95
+ self.q = float(np.sort(nonconf)[k - 1])
96
+ self.n_cal = int(n_cal)
97
+ self.n_proper = int(len(prop_idx))
98
+
99
+ def score(self, test_X: np.ndarray, test_pred: np.ndarray):
100
+ """The studentized surrogate–GP residual |pred - mu| / sigma at each test point,
101
+ plus (mu, sigma) for audit. Uses the surrogate prediction; NO test truth."""
102
+ test_X = np.asarray(test_X, dtype=float)
103
+ mu, std = self._gp.gp.predict(test_X, return_std=True)
104
+ std = np.maximum(std, self._std_floor)
105
+ pred = np.asarray(test_pred, dtype=float).ravel()
106
+ return np.abs(pred - mu) / std, mu, std
107
+
108
+ def evaluate(self, test_X: np.ndarray, test_pred) -> list[DetectorResult]:
109
+ """One DetectorResult per test row. With no surrogate prediction the detector is
110
+ honestly quiet (its signal is undefined without a prediction to test)."""
111
+ n = np.asarray(test_X, dtype=float).shape[0]
112
+ if test_pred is None:
113
+ return [
114
+ DetectorResult(
115
+ CONFORMAL_NAME, 0.0, False, self.q,
116
+ f"{CONFORMAL_NAME}: quiet (no surrogate prediction supplied)", "decision")
117
+ for _ in range(n)
118
+ ]
119
+ scores, _mu, _std = self.score(test_X, test_pred)
120
+ out: list[DetectorResult] = []
121
+ for si in scores:
122
+ si_f = float(si)
123
+ fired = bool(si_f > self.q) # NaN (missing pred) → False
124
+ rationale = (
125
+ f"{CONFORMAL_NAME}: {'FIRED' if fired else 'quiet'} "
126
+ f"(studentized surrogate–GP residual={si_f:.3g} "
127
+ f"{'>' if fired else '<='} conformal q(α={self.alpha:g})={self.q:.3g})")
128
+ out.append(DetectorResult(CONFORMAL_NAME, si_f, fired, self.q, rationale, "decision"))
129
+ return out
@@ -0,0 +1,74 @@
1
+ """GMM novelty-density detector — the steelman NVIDIA-style density baseline.
2
+
3
+ New code: the shipped pipeline had distance / GP-variance / ensemble / corpus /
4
+ closure-validity, but NO density detector, and the spec's default novelty slot is
5
+ GMM density. This fits a sklearn GaussianMixture on the training feature matrix;
6
+ the per-point signal is the negative log-likelihood (higher = lower density =
7
+ more novel). Two thresholds are calibrated from the TRAIN NLL distribution —
8
+ warn_pct and reject_pct — so the guardrail can tier WARN vs REJECT.
9
+
10
+ sklearn is imported lazily (inside fit) so importing this module stays import-safe
11
+ even without the experiment extra (the no-heavy-imports test imports every
12
+ submodule). DensityMethod.PCE and Device.CUDA are wired but raise
13
+ NotImplementedError — GPU is deferred per the locked scope (workloads are small).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from dataclasses import dataclass, field
19
+
20
+ import numpy as np
21
+
22
+ from physmap.guardrail.enums import DensityMethod, Device
23
+
24
+ RANDOM_STATE = 20260605
25
+
26
+
27
+ @dataclass
28
+ class DensityNoveltyDetector:
29
+ """A `.signal(test_X) -> ndarray` detector (the uniform inner interface),
30
+ fitted at construction. Carries two thresholds for WARN/REJECT tiering."""
31
+
32
+ train_X: np.ndarray
33
+ components: int = 1
34
+ warn_pct: float = 99.0
35
+ reject_pct: float = 99.9
36
+ method: DensityMethod = DensityMethod.GMM
37
+ device: Device = Device.CPU
38
+ random_state: int = RANDOM_STATE
39
+
40
+ _gmm: object = field(init=False, default=None, repr=False)
41
+ warn_threshold: float = field(init=False, default=0.0)
42
+ reject_threshold: float = field(init=False, default=0.0)
43
+
44
+ def __post_init__(self) -> None:
45
+ if self.method is not DensityMethod.GMM:
46
+ raise NotImplementedError(
47
+ f"density method {self.method.value!r} is not implemented; "
48
+ f"only GMM ships now (PCE is deferred)."
49
+ )
50
+ if self.device is Device.CUDA:
51
+ raise NotImplementedError(
52
+ "CUDA density path is not implemented; device is wired but only "
53
+ "CPU is built (workloads are small — GPU is deferred)."
54
+ )
55
+ from sklearn.mixture import GaussianMixture
56
+
57
+ X = np.asarray(self.train_X, dtype=float)
58
+ self._gmm = GaussianMixture(
59
+ n_components=self.components,
60
+ covariance_type="full",
61
+ random_state=self.random_state,
62
+ ).fit(X)
63
+ train_nll = -self._gmm.score_samples(X)
64
+ self.warn_threshold = float(np.percentile(train_nll, self.warn_pct))
65
+ self.reject_threshold = float(np.percentile(train_nll, self.reject_pct))
66
+
67
+ def signal(self, test_X: np.ndarray) -> np.ndarray:
68
+ """Per-point negative log-likelihood under the fitted GMM.
69
+
70
+ Higher = lower density = more novel. Compared against warn/reject
71
+ thresholds (percentiles of the train NLL) by the guardrail.
72
+ """
73
+ X = np.asarray(test_X, dtype=float)
74
+ return -np.asarray(self._gmm.score_samples(X), dtype=float)
@@ -0,0 +1,69 @@
1
+ """Public typed surface for the physmap CredibilityGuardrail.
2
+
3
+ Everything categorical is an enum — no magic strings for any fixed choice.
4
+ Continuous params (thresholds, counts) are plain numbers; only the developer's
5
+ own data-column names stay strings (validated, not enumerated).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+
12
+
13
+ # ── Developer's fixed choices ─────────────────────────────────────────────────
14
+ class Regime(Enum):
15
+ INTERNAL_FORCED_CONVECTION_PIPE = "internal_forced_convection_pipe"
16
+ INTERNAL_FORCED_CONVECTION_RECT_CHANNEL = "internal_forced_convection_rect_channel"
17
+ ENTRANCE_REGION_PIPE = "entrance_region_pipe"
18
+ EXTERNAL_FLAT_PLATE_FORCED = "external_flat_plate_forced"
19
+ MIXED_CONVECTION_HORIZONTAL_TUBE = "mixed_convection_horizontal_tube"
20
+ MIXED_CONVECTION_VERTICAL_TUBE = "mixed_convection_vertical_tube" # Jin (sCO2 buoyancy, Liu Bu)
21
+ INTERNAL_FORCED_CONVECTION_PROPERTY_VARIATION = "internal_forced_convection_property_variation"
22
+ HYPERSONIC_TRANSITION_DISTURBANCE = "hypersonic_transition_disturbance" # Casper (freestream noise)
23
+ HYPERSONIC_TRANSITION_ENTROPY = "hypersonic_transition_entropy" # Marineau (bluntness/entropy)
24
+ UNLISTED = "unlisted" # → statistical-only mode
25
+
26
+
27
+ class DetectorKind(Enum):
28
+ DISTANCE_TO_TRAINING = "distance_to_training"
29
+ GP_VARIANCE = "gp_variance"
30
+ NOVELTY_DENSITY = "novelty_density"
31
+ CLOSURE_VALIDITY = "closure_validity"
32
+ CONFORMAL_RESIDUAL = "conformal_residual" # observable-pole conformal statistical mode
33
+
34
+
35
+ class DensityMethod(Enum):
36
+ GMM = "gmm"
37
+ PCE = "pce" # deferred (raises NotImplementedError)
38
+
39
+
40
+ class AggregatorKind(Enum):
41
+ OBSERVABILITY_WEIGHTED = "observability_weighted" # default
42
+ ANY_FIRED = "any_fired"
43
+ CORPUS_GATED = "corpus_gated"
44
+
45
+
46
+ class Device(Enum):
47
+ CPU = "cpu"
48
+ CUDA = "cuda" # wired; CPU implemented (CUDA → NotImplementedError)
49
+
50
+
51
+ # ── Typed outputs ─────────────────────────────────────────────────────────────
52
+ class Observability(Enum):
53
+ OBSERVABLE = "observable"
54
+ PARTIAL = "partial"
55
+ UNOBSERVABLE = "unobservable"
56
+
57
+
58
+ class Verdict(Enum):
59
+ TRUSTWORTHY = "trustworthy"
60
+ WARN = "warn"
61
+ REJECT = "reject"
62
+ UNCERTAIN = "uncertain"
63
+
64
+
65
+ class Disposition(Enum):
66
+ CHARACTERIZE_REGION = "characterize_region"
67
+ RESTRICT_COU = "restrict_cou"
68
+ ACCEPT_RESIDUAL_RISK = "accept_residual_risk"
69
+ REVIEW = "review"
@@ -0,0 +1,73 @@
1
+ """Public Assessment → v0.6 SHACL-valid JSON-LD subgraph.
2
+
3
+ Thin reuse of pipeline.assessment_v06: rebuild a core Assessment from the public
4
+ one and run assessment_to_v06_subgraph with the DefeasibleAdjudicator (which emits
5
+ an action_class in the 5 SHACL classes). The graph is conformant by construction,
6
+ and the public 4-way Disposition (incl. REVIEW) never reaches the graph as a
7
+ literal — the adjudicator's SHACL action_class is used directly.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from physmap.guardrail.configs import Assessment as PublicAssessment
13
+ from physmap.guardrail.enums import DetectorKind
14
+ from physmap.pipeline.assessment_v06 import assessment_to_v06_subgraph
15
+ from physmap.pipeline.core import Assessment as CoreAssessment
16
+ from physmap.pipeline.core import DetectorResult as CoreDetectorResult
17
+ from physmap.pipeline.defeasible_aggregator import DefeasibleAdjudicator
18
+
19
+
20
+ # Public DetectorKind ↔ the core detector_name strings the v0.6 mapper keys on.
21
+ NAME_BY_KIND = {
22
+ DetectorKind.DISTANCE_TO_TRAINING: "distance",
23
+ DetectorKind.GP_VARIANCE: "gp_variance",
24
+ DetectorKind.NOVELTY_DENSITY: "novelty_density",
25
+ DetectorKind.CLOSURE_VALIDITY: "closure_validity",
26
+ DetectorKind.CONFORMAL_RESIDUAL: "conformal_residual",
27
+ }
28
+ KIND_BY_NAME = {v: k for k, v in NAME_BY_KIND.items()}
29
+
30
+
31
+ def _to_core_signals(signals: dict) -> dict[str, CoreDetectorResult]:
32
+ out: dict[str, CoreDetectorResult] = {}
33
+ for kind, r in signals.items():
34
+ name = NAME_BY_KIND.get(kind, getattr(kind, "value", str(kind)))
35
+ out[name] = CoreDetectorResult(
36
+ detector_name=name, score=r.score, fired=r.fired,
37
+ threshold=r.threshold, rationale=r.rationale, role="decision",
38
+ )
39
+ return out
40
+
41
+
42
+ def assessment_to_graph(a: PublicAssessment) -> dict:
43
+ """Render one public Assessment as a v0.6 JSON-LD doc (@context + @graph)."""
44
+ if a.solver_truth is None:
45
+ raise ValueError(
46
+ "to_graph() requires solver_truth — the v0.6 Discrepancy shape pins "
47
+ "exactly one solverTruth. A deployment without truth still yields a "
48
+ "flat Assessment, but cannot produce a SHACL graph. Supply truth "
49
+ "(a Path with a truth column, or graph=True over data that carries it)."
50
+ )
51
+ if a.surrogate_prediction is None:
52
+ raise ValueError(
53
+ "to_graph() requires surrogate_prediction (the Discrepancy needs the "
54
+ "surrogate-vs-truth pair). Supply predictions via the surrogate "
55
+ "callable, a prediction column, or test_pred."
56
+ )
57
+ core_signals = _to_core_signals(a.signals)
58
+ label = "fire" if any(r.fired for r in core_signals.values()) else "quiet"
59
+ core = CoreAssessment(
60
+ operating_point=a.operating_point,
61
+ verdict=label,
62
+ decision_signals=core_signals,
63
+ justification_signals={},
64
+ rationale=a.rationale,
65
+ )
66
+ sub = assessment_to_v06_subgraph(
67
+ core,
68
+ surrogate_prediction=float(a.surrogate_prediction),
69
+ solver_truth=float(a.solver_truth),
70
+ discrepancy_region=a.region,
71
+ adjudicator=DefeasibleAdjudicator(),
72
+ )
73
+ return sub.to_jsonld()