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.
- physmap/__init__.py +61 -0
- physmap/_paths.py +69 -0
- physmap/applicability/__init__.py +0 -0
- physmap/applicability/fixtures.py +83 -0
- physmap/applicability/screen.py +99 -0
- physmap/baselines/__init__.py +0 -0
- physmap/benchmarks/__init__.py +0 -0
- physmap/benchmarks/benchmark_report.py +405 -0
- physmap/benchmarks/benchmark_v0_4.py +424 -0
- physmap/benchmarks/compare.py +149 -0
- physmap/benchmarks/registry.py +217 -0
- physmap/benchmarks/report.py +224 -0
- physmap/cli.py +301 -0
- physmap/closures/__init__.py +48 -0
- physmap/closures/data/__init__.py +7 -0
- physmap/closures/data/closure_index.json +2997 -0
- physmap/closures/formulas.py +213 -0
- physmap/closures/geometry_classes.py +109 -0
- physmap/closures/index.py +393 -0
- physmap/closures/registry.py +313 -0
- physmap/compat/__init__.py +0 -0
- physmap/core/__init__.py +0 -0
- physmap/core/mechanism.py +69 -0
- physmap/core/signals.py +50 -0
- physmap/corpus/__init__.py +12 -0
- physmap/corpus/calibration.py +543 -0
- physmap/corpus/data/__init__.py +12 -0
- physmap/corpus/data/corpus_seed.jsonl +15 -0
- physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
- physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
- physmap/corpus/data/premium_coverage.json +60 -0
- physmap/corpus/evidence.py +871 -0
- physmap/explain/__init__.py +0 -0
- physmap/explain/benchmark.py +101 -0
- physmap/explain/causal.py +82 -0
- physmap/guardrail/__init__.py +38 -0
- physmap/guardrail/aggregator_observability.py +187 -0
- physmap/guardrail/classify.py +147 -0
- physmap/guardrail/configs.py +120 -0
- physmap/guardrail/corpus_regimes.py +208 -0
- physmap/guardrail/detector_conformal.py +129 -0
- physmap/guardrail/detector_density.py +74 -0
- physmap/guardrail/enums.py +69 -0
- physmap/guardrail/graph.py +73 -0
- physmap/guardrail/guardrail.py +606 -0
- physmap/guardrail/io.py +201 -0
- physmap/guardrail/regime_observability.py +519 -0
- physmap/guardrail/render.py +159 -0
- physmap/guardrail/weighting_heuristic.py +216 -0
- physmap/infra/__init__.py +23 -0
- physmap/infra/blindspot_oracle.py +356 -0
- physmap/infra/corpus_runtime.py +275 -0
- physmap/integrations/__init__.py +0 -0
- physmap/materiality/__init__.py +0 -0
- physmap/materiality/estimator.py +239 -0
- physmap/materiality/independence.py +92 -0
- physmap/materiality/surrogate_fit.py +293 -0
- physmap/observability/__init__.py +0 -0
- physmap/pipeline/__init__.py +58 -0
- physmap/pipeline/aggregators.py +199 -0
- physmap/pipeline/assessment_v06.py +509 -0
- physmap/pipeline/core.py +442 -0
- physmap/pipeline/defeasible_aggregator.py +324 -0
- physmap/pipeline/detectors.py +309 -0
- physmap/pipeline/observability.py +430 -0
- physmap/pipeline/surrogate.py +251 -0
- physmap/pipeline/validity_signal.py +273 -0
- physmap/pipeline/vehicle_spec.py +287 -0
- physmap/release.py +81 -0
- physmap/stress_tests/__init__.py +9 -0
- physmap/stress_tests/lewis_reuse.py +517 -0
- physmap/substrate/__init__.py +28 -0
- physmap/substrate/corpus_real.py +206 -0
- physmap/substrate/engine.py +209 -0
- physmap/substrate/forrest.py +249 -0
- physmap/substrate/loaders.py +2176 -0
- physmap/substrate/naca_tn1451.py +379 -0
- physmap/substrate/naca_wpd_loader.py +187 -0
- physmap/substrate/stage1_ingest.py +187 -0
- physmap/substrate/vehicle_config.py +407 -0
- physmap-0.2.0.dist-info/METADATA +270 -0
- physmap-0.2.0.dist-info/RECORD +88 -0
- physmap-0.2.0.dist-info/WHEEL +5 -0
- physmap-0.2.0.dist-info/entry_points.txt +2 -0
- physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
- physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
- physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
- physmap-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""PhysMAP D3 — literature-derived validity-range-distance signal.
|
|
2
|
+
|
|
3
|
+
Per user directive after the sweep finding:
|
|
4
|
+
"Track B's signal was never the physics-validity signal PhysMAP claims —
|
|
5
|
+
that signal (literature validity-range distance) still needs to be
|
|
6
|
+
built and tested against genuinely-quiet baselines on real data."
|
|
7
|
+
|
|
8
|
+
This module implements the ACTUAL PhysMAP physics-validity claim: a signal
|
|
9
|
+
that depends ONLY on the literature-derived closure validated_range from
|
|
10
|
+
`corpus.jsonl`, NOT on training data. That's the structural property that
|
|
11
|
+
differentiates PhysMAP from input-distribution novelty detectors.
|
|
12
|
+
|
|
13
|
+
Signal definition:
|
|
14
|
+
Given a test point (Re, Pr, ...) and a matched closure_id, look up the
|
|
15
|
+
closure's `validated_range` from corpus.jsonl. For each coordinate in
|
|
16
|
+
the range, compute the relative distance from the test point's value to
|
|
17
|
+
the [min, max] interval:
|
|
18
|
+
- 0 if min <= value <= max
|
|
19
|
+
- (min - value) / min if value < min
|
|
20
|
+
- (value - max) / max if value > max
|
|
21
|
+
Combine across coordinates as L2 norm. Signal > 0 iff test point is
|
|
22
|
+
outside the validated rectangle on at least one coordinate.
|
|
23
|
+
|
|
24
|
+
Key property: this signal has NO training-data dependence. It catches
|
|
25
|
+
out-of-validity points regardless of how training is distributed.
|
|
26
|
+
That's the structural mechanism that lets PhysMAP differentiate from
|
|
27
|
+
novelty detectors, IF such a differentiator cell exists in the data.
|
|
28
|
+
|
|
29
|
+
Important: just because this signal fires doesn't mean it differentiates
|
|
30
|
+
from baselines. Baselines fire when test points are far from training.
|
|
31
|
+
A clean differentiator requires test points that are:
|
|
32
|
+
- INSIDE the training distribution (baselines genuinely quiet)
|
|
33
|
+
- OUTSIDE the closure's validated range (validity-distance fires)
|
|
34
|
+
These conditions can only co-occur if training data includes points
|
|
35
|
+
outside the closure's validated range — i.e., a practitioner trained on
|
|
36
|
+
data spanning the closure's validity boundary.
|
|
37
|
+
|
|
38
|
+
For Forrest:
|
|
39
|
+
Modified Sparrow-Cur validated_range: Re [10000, 70000], Pr [2.2, 5.4].
|
|
40
|
+
Forrest data includes:
|
|
41
|
+
- Sub-critical Re<4000 (OUTSIDE validated Re range)
|
|
42
|
+
- Transition Re 4000-10000 (OUTSIDE validated Re range)
|
|
43
|
+
- Benign Re>=10000 (INSIDE validated range)
|
|
44
|
+
If training includes any sub-critical or transition points, the closure's
|
|
45
|
+
validity boundary cuts THROUGH the training distribution. Test points on
|
|
46
|
+
the wrong side of that internal boundary would be baseline-quiet but
|
|
47
|
+
validity-loud — the genuine differentiator cell.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import json
|
|
53
|
+
from dataclasses import dataclass, field
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from typing import Sequence
|
|
56
|
+
|
|
57
|
+
import numpy as np
|
|
58
|
+
|
|
59
|
+
from physmap.corpus.calibration import resolve_corpus_path
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# Map D3 feature names to corpus.jsonl coordinate names.
|
|
63
|
+
# Geometry features (Dh, alpha_star, etc.) are NOT in corpus validated_range
|
|
64
|
+
# (yet); they're in regime_context as prose. So those features don't
|
|
65
|
+
# contribute to validity-distance — they're for baselines only.
|
|
66
|
+
#
|
|
67
|
+
# x_over_D added after the Track-C-extension for Gnielinski. The validity
|
|
68
|
+
# signal now fires on entrance-region (x/D < 10) test points where the
|
|
69
|
+
# bare correlation is invalid per textbook (Bergman/Incropera 7th ed §8.5).
|
|
70
|
+
FEATURE_TO_CORPUS_COORD = {
|
|
71
|
+
"log10_Re": "reynolds_number", # validated_range stores raw Re
|
|
72
|
+
"Pr": "prandtl_number",
|
|
73
|
+
"x_over_D": "x_over_D", # straight passthrough
|
|
74
|
+
"log10_x_over_D": "x_over_D", # for log-scale axial-position features
|
|
75
|
+
"Ri": "richardson_number", # buoyancy validity ceiling (Stage-3 middle).
|
|
76
|
+
# raw Ri (NOT log) — signal() compares the raw
|
|
77
|
+
# value to the corpus min/max directly.
|
|
78
|
+
"ratio_mu_w_b": "viscosity_ratio_wall_bulk", # property-variation bound (Velazquez sCO2)
|
|
79
|
+
"Bu": "liu_buoyancy_parameter", # buoyancy validity bound (Jin sCO2 vertical tube;
|
|
80
|
+
# raw Bu vs corpus max 1.3e-5 directly)
|
|
81
|
+
# aerospace / hypersonic-transition validity coords (raw passthrough — the
|
|
82
|
+
# signal() comparison uses the raw value vs corpus min/max directly):
|
|
83
|
+
"freestream_noise_pct": "freestream_noise_rms_pitot_pct", # Casper (Pate-Stainback bound)
|
|
84
|
+
"st_xsw_ratio": "entropy_layer_shock_ratio", # Marineau (entropy-layer/shock bound)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# Status weighting for picking the GOVERNING (dispositive) fired bound when a
|
|
89
|
+
# point is past multiple bounds at once: a confirmed bound outranks a claimed
|
|
90
|
+
# one at equal margin. Mirrors the corpus bound_status vocabulary.
|
|
91
|
+
_STATUS_WEIGHT = {
|
|
92
|
+
"confirmed": 1.0,
|
|
93
|
+
"confirmed-contested": 0.9,
|
|
94
|
+
"extrapolated": 0.6,
|
|
95
|
+
"claimed": 0.4,
|
|
96
|
+
}
|
|
97
|
+
_DEFAULT_STATUS_WEIGHT = 0.4
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class PerBoundMargin:
|
|
102
|
+
"""One bound's graded distance-past-bound for one test point.
|
|
103
|
+
|
|
104
|
+
`margin` is the same per-coordinate relative distance `signal()` squares into
|
|
105
|
+
its L2 norm: 0 inside [min, max], > 0 outside. `side` ∈ {below, above, inside}.
|
|
106
|
+
This is the graded, un-collapsed signal (the Tier-1 finding: graded margin
|
|
107
|
+
ensembles better than binary fire-at-bound). The detector reports which bound
|
|
108
|
+
was violated and by how much; it does NOT decide observability — that routing
|
|
109
|
+
lives in the aggregator.
|
|
110
|
+
"""
|
|
111
|
+
coord: str # corpus bound variable, e.g. "x_over_D"
|
|
112
|
+
feature_name: str # the feature column read, e.g. "x_over_D" or "log10_Re"
|
|
113
|
+
margin: float # graded distance-past-bound (0 inside; >0 outside)
|
|
114
|
+
bound_status: str # confirmed | confirmed-contested | extrapolated | claimed
|
|
115
|
+
side: str # below | above | inside
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class ValidityRangeDistanceDetector:
|
|
120
|
+
"""PhysMAP's literature-derived physics-validity signal.
|
|
121
|
+
|
|
122
|
+
Reads the matched closure's validated_range from corpus.jsonl. At each
|
|
123
|
+
test point, computes distance from the test point's coordinates to the
|
|
124
|
+
validated rectangle. Signal > 0 iff test point is outside the
|
|
125
|
+
rectangle on at least one coordinate.
|
|
126
|
+
|
|
127
|
+
NO training-data dependence. Pure literature lookup.
|
|
128
|
+
"""
|
|
129
|
+
closure_id: str
|
|
130
|
+
feature_names: Sequence[str]
|
|
131
|
+
# Resolves the ACTIVE corpus (env → premium → bundled seed) at construction
|
|
132
|
+
# time; a dev/editable tree gets the full premium corpus unchanged, a core-only
|
|
133
|
+
# install gets the bundled seed. Pass an explicit path to override.
|
|
134
|
+
corpus_path: Path = field(default_factory=resolve_corpus_path)
|
|
135
|
+
|
|
136
|
+
valid_ranges: dict[str, tuple[float, float]] = field(default_factory=dict, init=False)
|
|
137
|
+
# bound_status[coord] mirrors the corpus bound_status; used by governing_bound's
|
|
138
|
+
# status weighting. Populated alongside valid_ranges; signal() never reads it.
|
|
139
|
+
bound_status: dict[str, str] = field(default_factory=dict, init=False)
|
|
140
|
+
|
|
141
|
+
def __post_init__(self):
|
|
142
|
+
self.valid_ranges = self._load_validated_ranges()
|
|
143
|
+
if not self.valid_ranges:
|
|
144
|
+
raise ValueError(
|
|
145
|
+
f"closure_id '{self.closure_id}' has no usable validated_range "
|
|
146
|
+
f"in corpus.jsonl (or closure_id not found)"
|
|
147
|
+
)
|
|
148
|
+
self.bound_status = self._load_bound_status()
|
|
149
|
+
|
|
150
|
+
def _load_validated_ranges(self) -> dict[str, tuple[float, float]]:
|
|
151
|
+
ranges = {}
|
|
152
|
+
with open(self.corpus_path) as f:
|
|
153
|
+
for line in f:
|
|
154
|
+
line = line.strip()
|
|
155
|
+
if not line:
|
|
156
|
+
continue
|
|
157
|
+
entry = json.loads(line)
|
|
158
|
+
if entry.get("closure_id") != self.closure_id:
|
|
159
|
+
continue
|
|
160
|
+
for vr in entry.get("validated_range", []):
|
|
161
|
+
coord = vr["coord"]
|
|
162
|
+
vmin = float(vr["min"])
|
|
163
|
+
vmax = float(vr["max"])
|
|
164
|
+
ranges[coord] = (vmin, vmax)
|
|
165
|
+
break
|
|
166
|
+
return ranges
|
|
167
|
+
|
|
168
|
+
def signal(self, test_X: np.ndarray) -> np.ndarray:
|
|
169
|
+
"""Per-test-point validity-distance to the closure's validated range.
|
|
170
|
+
|
|
171
|
+
For each test point and each feature that maps to a corpus coord,
|
|
172
|
+
compute the relative distance to the [min, max] interval and
|
|
173
|
+
combine as L2 norm. Geometry features (not in corpus) contribute 0.
|
|
174
|
+
"""
|
|
175
|
+
n_test = len(test_X)
|
|
176
|
+
scores = np.zeros(n_test, dtype=float)
|
|
177
|
+
for i, x in enumerate(test_X):
|
|
178
|
+
sq_dist = 0.0
|
|
179
|
+
for j, fname in enumerate(self.feature_names):
|
|
180
|
+
coord = FEATURE_TO_CORPUS_COORD.get(fname)
|
|
181
|
+
if coord is None or coord not in self.valid_ranges:
|
|
182
|
+
continue
|
|
183
|
+
# Convert feature value back to physical units for coord lookup
|
|
184
|
+
if fname == "log10_Re":
|
|
185
|
+
value = 10.0 ** float(x[j])
|
|
186
|
+
elif fname == "log10_x_over_D":
|
|
187
|
+
value = 10.0 ** float(x[j])
|
|
188
|
+
else:
|
|
189
|
+
value = float(x[j])
|
|
190
|
+
vmin, vmax = self.valid_ranges[coord]
|
|
191
|
+
if value < vmin:
|
|
192
|
+
rel = (vmin - value) / max(vmin, 1e-9)
|
|
193
|
+
sq_dist += rel ** 2
|
|
194
|
+
elif value > vmax:
|
|
195
|
+
rel = (value - vmax) / max(vmax, 1e-9)
|
|
196
|
+
sq_dist += rel ** 2
|
|
197
|
+
# else: in [min, max], contributes 0
|
|
198
|
+
scores[i] = float(np.sqrt(sq_dist))
|
|
199
|
+
return scores
|
|
200
|
+
|
|
201
|
+
# ── graded per-bound margins + governing fired bound (additive) ───────────
|
|
202
|
+
# signal() above is left byte-identical (the locked phase1_gate depends on
|
|
203
|
+
# it). These methods expose the SAME per-coordinate relative distances
|
|
204
|
+
# un-collapsed, plus which bound governs — what the observability-weighted
|
|
205
|
+
# aggregator needs to route on the fired bound VARIABLE.
|
|
206
|
+
|
|
207
|
+
def _load_bound_status(self) -> dict[str, str]:
|
|
208
|
+
"""{coord: bound_status} for the matched closure. A separate read keeps
|
|
209
|
+
_load_validated_ranges() and signal() byte-identical."""
|
|
210
|
+
statuses: dict[str, str] = {}
|
|
211
|
+
with open(self.corpus_path) as f:
|
|
212
|
+
for line in f:
|
|
213
|
+
line = line.strip()
|
|
214
|
+
if not line:
|
|
215
|
+
continue
|
|
216
|
+
entry = json.loads(line)
|
|
217
|
+
if entry.get("closure_id") != self.closure_id:
|
|
218
|
+
continue
|
|
219
|
+
for vr in entry.get("validated_range", []):
|
|
220
|
+
statuses[vr["coord"]] = vr.get("bound_status", "")
|
|
221
|
+
break
|
|
222
|
+
return statuses
|
|
223
|
+
|
|
224
|
+
def evaluate_bounds(self, test_X: np.ndarray) -> list[list[PerBoundMargin]]:
|
|
225
|
+
"""Per test point, the graded per-bound margins — the un-collapsed terms
|
|
226
|
+
signal() L2-combines. One PerBoundMargin per (feature → corpus coord) the
|
|
227
|
+
detector can evaluate; margin > 0 means the point is past that bound. The
|
|
228
|
+
per-coordinate math mirrors signal() exactly."""
|
|
229
|
+
out: list[list[PerBoundMargin]] = []
|
|
230
|
+
for x in test_X:
|
|
231
|
+
row: list[PerBoundMargin] = []
|
|
232
|
+
for j, fname in enumerate(self.feature_names):
|
|
233
|
+
coord = FEATURE_TO_CORPUS_COORD.get(fname)
|
|
234
|
+
if coord is None or coord not in self.valid_ranges:
|
|
235
|
+
continue
|
|
236
|
+
if fname in ("log10_Re", "log10_x_over_D"):
|
|
237
|
+
value = 10.0 ** float(x[j])
|
|
238
|
+
else:
|
|
239
|
+
value = float(x[j])
|
|
240
|
+
vmin, vmax = self.valid_ranges[coord]
|
|
241
|
+
if value < vmin:
|
|
242
|
+
margin = (vmin - value) / max(vmin, 1e-9)
|
|
243
|
+
side = "below"
|
|
244
|
+
elif value > vmax:
|
|
245
|
+
margin = (value - vmax) / max(vmax, 1e-9)
|
|
246
|
+
side = "above"
|
|
247
|
+
else:
|
|
248
|
+
margin = 0.0
|
|
249
|
+
side = "inside"
|
|
250
|
+
row.append(PerBoundMargin(
|
|
251
|
+
coord=coord, feature_name=fname, margin=float(margin),
|
|
252
|
+
bound_status=self.bound_status.get(coord, ""), side=side,
|
|
253
|
+
))
|
|
254
|
+
out.append(row)
|
|
255
|
+
return out
|
|
256
|
+
|
|
257
|
+
def governing_bound(
|
|
258
|
+
self, per_bound: Sequence[PerBoundMargin], *, status_weighting: bool = True,
|
|
259
|
+
) -> PerBoundMargin | None:
|
|
260
|
+
"""The governing (dispositive) fired bound for one point: the most-
|
|
261
|
+
violated, status-weighted when `status_weighting` (a confirmed bound
|
|
262
|
+
outranks a claimed one at equal margin). None if the point is inside
|
|
263
|
+
every bound."""
|
|
264
|
+
fired = [b for b in per_bound if b.margin > 0.0]
|
|
265
|
+
if not fired:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
def _key(b: PerBoundMargin) -> float:
|
|
269
|
+
w = (_STATUS_WEIGHT.get(b.bound_status, _DEFAULT_STATUS_WEIGHT)
|
|
270
|
+
if status_weighting else 1.0)
|
|
271
|
+
return w * b.margin
|
|
272
|
+
|
|
273
|
+
return max(fired, key=_key)
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""Per-vehicle parameterization shared by the observability score (D1) and the
|
|
2
|
+
Stage-1 ensemble sweep (D2).
|
|
3
|
+
|
|
4
|
+
The failure-region split, the surrogate-input + validity feature spaces, the
|
|
5
|
+
closure_id, and the failure-driving variable are ALL derived here from the
|
|
6
|
+
VehicleConfig (chiefly `cell_bands.type`). D1 computes the observability score
|
|
7
|
+
over the failure region; D2 evaluates the sweep over the same region. Defining
|
|
8
|
+
the region ONCE here is what keeps them from drifting apart — if they diverged,
|
|
9
|
+
the Stage-3 axis plot would pair a score over one region with a verdict over
|
|
10
|
+
another (a silent correctness bug).
|
|
11
|
+
|
|
12
|
+
Step-scope: the NACA (`x_over_d_bands`) row is wired now. Forrest (`re_bands`)
|
|
13
|
+
is added with the observability work (Step 1); Mudhafar (`dh_roughness_bands`)
|
|
14
|
+
and Testi-Grassi (`richardson_bands`) with the scaffolding (Step 4). Unwired
|
|
15
|
+
band types raise a clear NotImplementedError naming the step that wires them.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Callable
|
|
22
|
+
|
|
23
|
+
from physmap.substrate.vehicle_config import VehicleConfig
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Failure-driving variable per cell-bands type — the meta key whose
|
|
27
|
+
# recoverability from the surrogate inputs IS the observability score.
|
|
28
|
+
# None = the vehicle has no single failure axis (caller must pass failure_var).
|
|
29
|
+
FAILURE_VAR_BY_CELL_BANDS_TYPE: dict[str, str | None] = {
|
|
30
|
+
"x_over_d_bands": "x_over_D", # NACA — entrance region
|
|
31
|
+
"re_bands": "Re", # Forrest — sub-critical Re
|
|
32
|
+
"dh_roughness_bands": "Dh_um", # Mudhafar — hydraulic diameter
|
|
33
|
+
"richardson_bands": "Ri", # buoyancy middle vehicles (Testi-Grassi)
|
|
34
|
+
"buoyancy_parameter_bands": "Bu", # Jin sCO2 vertical tube — Liu buoyancy parameter
|
|
35
|
+
"property_variation_bands": "ratio_mu_w_b", # Velazquez sCO2 — wall/bulk viscosity ratio
|
|
36
|
+
"freestream_disturbance_bands": "freestream_noise_pct", # Casper — tunnel freestream noise
|
|
37
|
+
"entropy_layer_shock_interaction_bands": "st_xsw_ratio", # Marineau — S_T/X_SW (bluntness)
|
|
38
|
+
"continuous": None,
|
|
39
|
+
"custom": None,
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# NACA entrance-region locked boundary: x/D >= 10 trains (fully developed);
|
|
43
|
+
# x/D < 10 is the failure region. Mirrors phase1_gate.X_OVER_D_CUTOFF.
|
|
44
|
+
NACA_X_OVER_D_CUTOFF = 10.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class SplitSpec:
|
|
49
|
+
"""How the failure region is carved from a vehicle's operating points.
|
|
50
|
+
|
|
51
|
+
`train_predicate` / `test_predicate` operate on a row's `.meta` dict. The
|
|
52
|
+
failure region (test) is where the surrogate is deployed beyond the regime
|
|
53
|
+
its closure was validated on; train is the in-distribution complement.
|
|
54
|
+
"""
|
|
55
|
+
kind: str
|
|
56
|
+
train_predicate: Callable[[dict], bool]
|
|
57
|
+
test_predicate: Callable[[dict], bool]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class VehicleSpec:
|
|
62
|
+
"""Everything the observability score + Stage-1 sweep need that is not in
|
|
63
|
+
the bare VehicleConfig: the two feature spaces, the closure for the
|
|
64
|
+
validity detector, the failure-driving variable, and the failure-region
|
|
65
|
+
split."""
|
|
66
|
+
vehicle_id: str
|
|
67
|
+
baseline_feature_names: tuple[str, ...]
|
|
68
|
+
validity_feature_names: tuple[str, ...]
|
|
69
|
+
closure_id: str
|
|
70
|
+
failure_var: str
|
|
71
|
+
split: SplitSpec
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def vehicle_spec(cfg: VehicleConfig) -> VehicleSpec:
|
|
75
|
+
"""Derive the VehicleSpec from a VehicleConfig, keyed on `cell_bands.type`."""
|
|
76
|
+
bands_type = cfg.cell_bands.type
|
|
77
|
+
closure_id = cfg.matched_closure_id
|
|
78
|
+
|
|
79
|
+
if bands_type == "x_over_d_bands":
|
|
80
|
+
# NACA: baselines see (log10_Re, Pr); the validity detector also sees
|
|
81
|
+
# x/D (the omitted failure axis). Locked cutoff x/D = 10. These feature
|
|
82
|
+
# spaces + closure reproduce phase1_gate's constants exactly, so the
|
|
83
|
+
# sweep's NACA arm is byte-identical to the locked gate.
|
|
84
|
+
cutoff = NACA_X_OVER_D_CUTOFF
|
|
85
|
+
return VehicleSpec(
|
|
86
|
+
vehicle_id=cfg.vehicle_id,
|
|
87
|
+
baseline_feature_names=("log10_Re", "Pr"),
|
|
88
|
+
validity_feature_names=("log10_Re", "Pr", "x_over_D"),
|
|
89
|
+
closure_id=closure_id,
|
|
90
|
+
failure_var="x_over_D",
|
|
91
|
+
split=SplitSpec(
|
|
92
|
+
kind="x_over_d_threshold",
|
|
93
|
+
train_predicate=lambda m, c=cutoff: m["x_over_D"] >= c,
|
|
94
|
+
test_predicate=lambda m, c=cutoff: m["x_over_D"] < c,
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
if bands_type == "re_bands":
|
|
99
|
+
# Forrest: failure axis is Reynolds number, which IS a surrogate input
|
|
100
|
+
# (log10_Re) — the high-observability pole. The validity space carries
|
|
101
|
+
# no extra coord (Sparrow-Cur's validated range is (Re, Pr) only).
|
|
102
|
+
# Failure region = lowest Re band (sub-critical); train = top catch-all
|
|
103
|
+
# band (benign turbulent); the transition band is excluded from both
|
|
104
|
+
# (indeterminate, per forrest_resolvability).
|
|
105
|
+
bands = cfg.cell_bands.bands
|
|
106
|
+
sub_critical_cut = float(bands[0]["re_lt"]) # e.g. 4000 (sub-critical < this)
|
|
107
|
+
benign_lo = float(bands[-2]["re_lt"]) # e.g. 10000 (benign >= this)
|
|
108
|
+
return VehicleSpec(
|
|
109
|
+
vehicle_id=cfg.vehicle_id,
|
|
110
|
+
baseline_feature_names=("log10_Re", "Pr"),
|
|
111
|
+
validity_feature_names=("log10_Re", "Pr"),
|
|
112
|
+
closure_id=closure_id,
|
|
113
|
+
failure_var="Re",
|
|
114
|
+
split=SplitSpec(
|
|
115
|
+
kind="re_bands",
|
|
116
|
+
train_predicate=lambda m, lo=benign_lo: m["Re"] >= lo,
|
|
117
|
+
test_predicate=lambda m, c=sub_critical_cut: m["Re"] < c,
|
|
118
|
+
),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if bands_type == "dh_roughness_bands":
|
|
122
|
+
# Mudhafar (out-of-envelope control): the steelman baseline is the
|
|
123
|
+
# cross-substrate feature space, which INCLUDES geometry (log10_Dh_mm).
|
|
124
|
+
# So the failure axis Dh IS a baseline input → high-observability /
|
|
125
|
+
# baseline-sufficient pole (the spec's prediction). Failure region =
|
|
126
|
+
# small-diameter OR rough cells; train = standard smooth.
|
|
127
|
+
bands = cfg.cell_bands.bands
|
|
128
|
+
dh_min = next((float(b["dh_um_min"]) for b in bands if "dh_um_min" in b), 200.0)
|
|
129
|
+
cross = ("log10_Re", "Pr", "log10_Dh_mm", "alpha_star",
|
|
130
|
+
"heating_pattern_indicator", "roughness_relative")
|
|
131
|
+
return VehicleSpec(
|
|
132
|
+
vehicle_id=cfg.vehicle_id,
|
|
133
|
+
baseline_feature_names=cross,
|
|
134
|
+
validity_feature_names=cross,
|
|
135
|
+
closure_id=closure_id,
|
|
136
|
+
failure_var="Dh_um",
|
|
137
|
+
split=SplitSpec(
|
|
138
|
+
kind="dh_roughness",
|
|
139
|
+
train_predicate=lambda m, lo=dh_min: (
|
|
140
|
+
float(m.get("Dh_um", 0.0)) >= lo and not m.get("rough", False)),
|
|
141
|
+
test_predicate=lambda m, lo=dh_min: (
|
|
142
|
+
float(m.get("Dh_um", 1e18)) < lo or bool(m.get("rough", False))),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
if bands_type == "richardson_bands":
|
|
147
|
+
# Buoyancy middle vehicle (Testi-Grassi): the surrogate is a
|
|
148
|
+
# forced-convection (Re, Pr) closure with the buoyancy variable
|
|
149
|
+
# (Richardson number) OMITTED. Ri is NOT a baseline input but is
|
|
150
|
+
# partially correlated with Re/heat flux → MIDDLE observability by
|
|
151
|
+
# construction. Failure region = where buoyancy matters (Ri >= the
|
|
152
|
+
# forced-dominated cutoff); train = forced-dominated (low Ri).
|
|
153
|
+
#
|
|
154
|
+
# The corpus Ri-bound IS wired (Dirker/Meyer/Reid water vehicle): the
|
|
155
|
+
# MATCHED closure aung-worku-mixed-convection-1986 carries the
|
|
156
|
+
# richardson_number ceiling (corpus.jsonl); detectors.extract_features
|
|
157
|
+
# supports "Ri"/"log10_Ri", and validity_feature_names is ("Ri",) below.
|
|
158
|
+
# NOTE: aung-worku bounds ONLY richardson_number, so the public
|
|
159
|
+
# CredibilityGuardrail's derived (log10_Re, Pr, Ri) validity space is
|
|
160
|
+
# behaviorally IDENTICAL to this ("Ri",) — Re/Pr have no aung-worku bound
|
|
161
|
+
# and contribute zero distance (verified). The dedicated mixed-convection
|
|
162
|
+
# closure (NOT the shared gnielinski) keeps the Ri bound off NACA/Forrest.
|
|
163
|
+
# baseline_feature_names keeps (log10_Re, Pr) — buoyancy omission from the
|
|
164
|
+
# surrogate inputs IS the failure mechanism.
|
|
165
|
+
bands = cfg.cell_bands.bands
|
|
166
|
+
ri_forced_cut = float(bands[0].get("ri_lt", 0.1))
|
|
167
|
+
return VehicleSpec(
|
|
168
|
+
vehicle_id=cfg.vehicle_id,
|
|
169
|
+
baseline_feature_names=("log10_Re", "Pr"),
|
|
170
|
+
validity_feature_names=("Ri",),
|
|
171
|
+
closure_id=closure_id,
|
|
172
|
+
failure_var="Ri",
|
|
173
|
+
split=SplitSpec(
|
|
174
|
+
kind="richardson_bands",
|
|
175
|
+
train_predicate=lambda m, c=ri_forced_cut: float(m["Ri"]) < c,
|
|
176
|
+
test_predicate=lambda m, c=ri_forced_cut: float(m["Ri"]) >= c,
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if bands_type == "buoyancy_parameter_bands":
|
|
181
|
+
# Jin sCO2 vertical-tube buoyancy vehicle: the surrogate is a CONSTANT-PROPERTY,
|
|
182
|
+
# direction-OMITTING Dittus-Boelter (Re, Pr) closure; the omitted failure driver is the Liu
|
|
183
|
+
# buoyancy parameter Bu (wall-aware), absent from the (Re, Pr) inputs and only PARTIALLY
|
|
184
|
+
# recoverable from them (it carries the wall-temperature/HTD signal) -> MIDDLE observability,
|
|
185
|
+
# MEASURED via cv_r2_knn. The matched closure dittus-boelter-buoyancy-sco2 carries the corpus
|
|
186
|
+
# Bu <= 1.3e-5 bound (REGIME_TO_CLOSURES[MIXED_CONVECTION_VERTICAL_TUBE]); validity_feature_names
|
|
187
|
+
# adds "Bu", so the corpus detector fires in the upward-HTD deploy while the (Re, Pr) baseline
|
|
188
|
+
# stays quiet (the matched up/down inputs are identical — the A6(ii) toggle). The benign/deploy
|
|
189
|
+
# split is the physical regime label `split_role` (benign = downward / high-G / low-q NHT;
|
|
190
|
+
# deploy = upward HTD), precomputed offline — NOT a Bu threshold (downward q=43.9 has Bu>1.3e-5
|
|
191
|
+
# from buoyancy ENHANCEMENT yet is benign NHT). baseline keeps (log10_Re, Pr): buoyancy
|
|
192
|
+
# omission from the surrogate inputs IS the failure mechanism.
|
|
193
|
+
return VehicleSpec(
|
|
194
|
+
vehicle_id=cfg.vehicle_id,
|
|
195
|
+
baseline_feature_names=("log10_Re", "Pr"),
|
|
196
|
+
validity_feature_names=("log10_Re", "Pr", "Bu"),
|
|
197
|
+
closure_id=closure_id,
|
|
198
|
+
failure_var="Bu",
|
|
199
|
+
split=SplitSpec(
|
|
200
|
+
kind="buoyancy_parameter_bands",
|
|
201
|
+
train_predicate=lambda m: str(m["split_role"]) == "benign",
|
|
202
|
+
test_predicate=lambda m: str(m["split_role"]) == "deploy",
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
if bands_type == "property_variation_bands":
|
|
207
|
+
# Velazquez sCO2 property-variation middle vehicle: the surrogate is a
|
|
208
|
+
# CONSTANT-PROPERTY (Re, Pr) closure (Gnielinski); the wall/bulk viscosity
|
|
209
|
+
# ratio mu_w/mu_b is OMITTED and drives the failure near the pseudo-critical
|
|
210
|
+
# point. mu_w/mu_b is NOT a baseline input but is partially correlated with
|
|
211
|
+
# Pr (which rises toward pseudo-critical) -> MIDDLE observability, to be
|
|
212
|
+
# MEASURED (could land nearer the observable pole; that is the open question).
|
|
213
|
+
# Buoyancy confound isolated by pressure: only p >= p_min_mpa, where the paper
|
|
214
|
+
# (Fig. 11) shows buoyancy negligible (>= 15 MPa). Failure region = near
|
|
215
|
+
# pseudo-critical (|T_b - T_pc| < abs_dt_pc_lt); train = far from it (benign
|
|
216
|
+
# property variation), same pressure subset.
|
|
217
|
+
bands = cfg.cell_bands.bands
|
|
218
|
+
b0 = bands[0]
|
|
219
|
+
p_min = float(b0.get("p_min_mpa", 15.0))
|
|
220
|
+
dt_cut = float(b0.get("abs_dt_pc_lt", 10.0))
|
|
221
|
+
return VehicleSpec(
|
|
222
|
+
vehicle_id=cfg.vehicle_id,
|
|
223
|
+
baseline_feature_names=("log10_Re", "Pr"),
|
|
224
|
+
validity_feature_names=("log10_Re", "Pr", "ratio_mu_w_b"),
|
|
225
|
+
closure_id=closure_id,
|
|
226
|
+
failure_var="ratio_mu_w_b",
|
|
227
|
+
split=SplitSpec(
|
|
228
|
+
kind="property_variation_bands",
|
|
229
|
+
train_predicate=lambda m, p=p_min, c=dt_cut: (
|
|
230
|
+
float(m["p_MPa"]) >= p and float(m["abs_dT_pc"]) >= c),
|
|
231
|
+
test_predicate=lambda m, p=p_min, c=dt_cut: (
|
|
232
|
+
float(m["p_MPa"]) >= p and float(m["abs_dT_pc"]) < c),
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
if bands_type == "freestream_disturbance_bands":
|
|
237
|
+
# Casper hypersonic transition (aerospace PHYSMAP_WINS). The surrogate
|
|
238
|
+
# sees (Mach, unit Reynolds, axial x); the omitted driver is the tunnel
|
|
239
|
+
# freestream disturbance (freestream_noise_pct), a distinct flow-
|
|
240
|
+
# environment axis NOT recoverable from the inputs -> UNOBSERVABLE. The
|
|
241
|
+
# validity space adds freestream_noise_pct, and the corpus Pate-Stainback
|
|
242
|
+
# bound fires on the QUIET deploy while the baseline stays silent (quiet
|
|
243
|
+
# deploy is interior in the Mach-spanned training). Train = noisy
|
|
244
|
+
# (fs >= cut), deploy = quiet (fs < cut); band[0].fs_lt is the 0.5% edge.
|
|
245
|
+
fs_cut = float(cfg.cell_bands.bands[0].get("fs_lt", 0.5))
|
|
246
|
+
return VehicleSpec(
|
|
247
|
+
vehicle_id=cfg.vehicle_id,
|
|
248
|
+
baseline_feature_names=("M", "Re_per_m_e6", "x_m"),
|
|
249
|
+
validity_feature_names=("M", "Re_per_m_e6", "x_m", "freestream_noise_pct"),
|
|
250
|
+
closure_id=closure_id,
|
|
251
|
+
failure_var="freestream_noise_pct",
|
|
252
|
+
split=SplitSpec(
|
|
253
|
+
kind="freestream_disturbance_bands",
|
|
254
|
+
train_predicate=lambda m, c=fs_cut: float(m["freestream_noise_pct"]) >= c,
|
|
255
|
+
test_predicate=lambda m, c=fs_cut: float(m["freestream_noise_pct"]) < c,
|
|
256
|
+
),
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
if bands_type == "entropy_layer_shock_interaction_bands":
|
|
260
|
+
# Marineau bluntness transition (aerospace NEGATIVE CONTROL). The
|
|
261
|
+
# surrogate sees (unit Reynolds, nose radius). The failure driver
|
|
262
|
+
# S_T/X_SW (st_xsw_ratio) is a deterministic function of nose radius (a
|
|
263
|
+
# surrogate input) at fixed Mach -> OBSERVABLE; the steelman baseline
|
|
264
|
+
# fires on the large-bluntness deploy (exterior in Rn). The validity
|
|
265
|
+
# space adds st_xsw_ratio and the corpus bound fires there too, but the
|
|
266
|
+
# baseline is NOT quiet -> no clean lift (the discrimination control).
|
|
267
|
+
# Train = benign (S_T/X_SW >= cut), deploy = failure (< cut).
|
|
268
|
+
st_cut = float(cfg.cell_bands.bands[0].get("st_xsw_lt", 0.1))
|
|
269
|
+
return VehicleSpec(
|
|
270
|
+
vehicle_id=cfg.vehicle_id,
|
|
271
|
+
baseline_feature_names=("Re_per_m", "Rn_mm"),
|
|
272
|
+
validity_feature_names=("Re_per_m", "Rn_mm", "st_xsw_ratio"),
|
|
273
|
+
closure_id=closure_id,
|
|
274
|
+
failure_var="st_xsw_ratio",
|
|
275
|
+
split=SplitSpec(
|
|
276
|
+
kind="entropy_layer_shock_interaction_bands",
|
|
277
|
+
train_predicate=lambda m, c=st_cut: float(m["st_xsw_ratio"]) >= c,
|
|
278
|
+
test_predicate=lambda m, c=st_cut: float(m["st_xsw_ratio"]) < c,
|
|
279
|
+
),
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
raise NotImplementedError(
|
|
283
|
+
f"vehicle_spec: cell_bands.type={bands_type!r} not wired. "
|
|
284
|
+
f"Supported: x_over_d_bands, re_bands, dh_roughness_bands, richardson_bands, "
|
|
285
|
+
f"buoyancy_parameter_bands, property_variation_bands, freestream_disturbance_bands, "
|
|
286
|
+
f"entropy_layer_shock_interaction_bands."
|
|
287
|
+
)
|
physmap/release.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Release state — what this build of PhysMAP is allowed to claim.
|
|
2
|
+
|
|
3
|
+
Three states. The state is a CONSTANT fixed at release time. It is deliberately
|
|
4
|
+
**not** derived from which files happen to be present on disk: a command must not
|
|
5
|
+
appear or disappear because a data directory was copied in. That would make the
|
|
6
|
+
package's claims a function of the user's filesystem instead of a function of what
|
|
7
|
+
was actually reviewed and released.
|
|
8
|
+
|
|
9
|
+
GUARDRAIL closure/observability method, the NACA x/D regression, and the
|
|
10
|
+
v0.4 seven-vehicle benchmark. No causal-materiality claim.
|
|
11
|
+
|
|
12
|
+
CAUSAL_PREVIEW the above, plus the causal-materiality API exercised on
|
|
13
|
+
synthetic and unit fixtures only. No precision/recall/F1,
|
|
14
|
+
no expected_metrics.json, and NO `reproduce` subcommand.
|
|
15
|
+
|
|
16
|
+
NAFEMS_REPRODUCTION the above, plus the complete redistributable benchmark:
|
|
17
|
+
a locked reconstruction protocol, independent experimental
|
|
18
|
+
truth, a geometry-appropriate forced-convection
|
|
19
|
+
counterfactual, and expected metrics.
|
|
20
|
+
|
|
21
|
+
Promotion to NAFEMS_REPRODUCTION is gated by `physmap.benchmarks.readiness`, which
|
|
22
|
+
is a RELEASE-ENGINEERING check run in CI before this constant may be changed. It is
|
|
23
|
+
never consulted at runtime to decide what the CLI offers.
|
|
24
|
+
|
|
25
|
+
Why the causal numbers in the NAFEMS abstract are not reproduced here: the original
|
|
26
|
+
study's inputs are not available, and the experimental-truth basis is unresolved.
|
|
27
|
+
A reconstruction under a locked protocol will produce its OWN numbers. Those will
|
|
28
|
+
not be the abstract's numbers, and this package never presents them as such.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
from enum import Enum
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"ReleaseState",
|
|
37
|
+
"CURRENT_RELEASE_STATE",
|
|
38
|
+
"EvidenceState",
|
|
39
|
+
"emits_performance_metrics",
|
|
40
|
+
"has_reproduce_command",
|
|
41
|
+
"causal_api_is_preview",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ReleaseState(str, Enum):
|
|
46
|
+
GUARDRAIL = "guardrail"
|
|
47
|
+
CAUSAL_PREVIEW = "causal_preview"
|
|
48
|
+
NAFEMS_REPRODUCTION = "nafems_reproduction"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
#: Set at release time. Changing this requires the readiness gate to pass in CI.
|
|
52
|
+
CURRENT_RELEASE_STATE: ReleaseState = ReleaseState.CAUSAL_PREVIEW
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class EvidenceState(str, Enum):
|
|
56
|
+
"""What kind of thing produced a result. Carried on every causal assessment and
|
|
57
|
+
screen so no emitted artifact can be mistaken for a validated performance claim."""
|
|
58
|
+
|
|
59
|
+
#: Generated by a synthetic or unit fixture. Demonstrates mechanics, proves nothing
|
|
60
|
+
#: about the physical world.
|
|
61
|
+
SYNTHETIC_FIXTURE = "synthetic_fixture"
|
|
62
|
+
#: A stated case: the refusal logic and its reason code, with no evidence inputs
|
|
63
|
+
#: behind it. The two screened-out vehicles are this.
|
|
64
|
+
DECLARATIVE = "declarative"
|
|
65
|
+
#: Backed by checked-in evidence inputs with cleared provenance.
|
|
66
|
+
MEASURED = "measured"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def emits_performance_metrics(state: ReleaseState | None = None) -> bool:
|
|
70
|
+
"""True only where precision/recall/F1 may legitimately be produced."""
|
|
71
|
+
return (state or CURRENT_RELEASE_STATE) is ReleaseState.NAFEMS_REPRODUCTION
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def has_reproduce_command(state: ReleaseState | None = None) -> bool:
|
|
75
|
+
"""Whether the CLI exposes `reproduce`. Static per release, by design."""
|
|
76
|
+
return (state or CURRENT_RELEASE_STATE) is ReleaseState.NAFEMS_REPRODUCTION
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def causal_api_is_preview(state: ReleaseState | None = None) -> bool:
|
|
80
|
+
"""True where the causal-materiality API ships but carries no performance claim."""
|
|
81
|
+
return (state or CURRENT_RELEASE_STATE) is ReleaseState.CAUSAL_PREVIEW
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Controlled stress tests of the causal path.
|
|
2
|
+
|
|
3
|
+
A stress test here is a deliberately constructed situation with a known answer, used to show
|
|
4
|
+
what a check can and cannot see. It is a development demonstration, not an evaluation: no
|
|
5
|
+
performance metric is computed or implied. Each one reproduces from the checkout with one
|
|
6
|
+
command and is drift-checked against a committed bank, the same way `physmap benchmark run`
|
|
7
|
+
is -- and each is kept apart from that benchmark, which measures closure validity and
|
|
8
|
+
observability, not materiality.
|
|
9
|
+
"""
|