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
|
File without changes
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Deterministic explanation of one benchmark cell.
|
|
2
|
+
|
|
3
|
+
Every rendering states, in the header, which KIND of claim the cell is. That is not
|
|
4
|
+
decoration. The seven-vehicle benchmark measures closure validity and surrogate
|
|
5
|
+
observability; it measures nothing about causal materiality.
|
|
6
|
+
|
|
7
|
+
**And the reason cannot be "no vehicle is close to the causal case", because one is.**
|
|
8
|
+
`jin_sco2_buoyancy` is a mixed-convection vertical tube whose failure driver is a
|
|
9
|
+
buoyancy parameter -- geometrically the nearest thing here to the NAFEMS mixed-convection
|
|
10
|
+
pipe. It is the cell most likely to be mistaken for causal evidence, and it is not: it
|
|
11
|
+
asks whether `Bu` is observable to the surrogate. It computes no ablation, no
|
|
12
|
+
counterfactual, no materiality and no metric. Its truth is a measured heat-transfer
|
|
13
|
+
coefficient, not a quantity of interest with a mechanism removed.
|
|
14
|
+
|
|
15
|
+
So the disclaimer names it rather than waving at geometry, and a test asserts that it
|
|
16
|
+
does. A reader who takes any cell here as support for the causal result has drawn a
|
|
17
|
+
conclusion the data cannot carry.
|
|
18
|
+
|
|
19
|
+
Templates only. Same cell, same bytes.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from physmap.benchmarks.registry import DataQuality, Redistribution, get
|
|
25
|
+
from physmap.core.signals import SignalKind
|
|
26
|
+
from physmap.release import CURRENT_RELEASE_STATE
|
|
27
|
+
|
|
28
|
+
__all__ = ["BENCHMARK_MEASURES", "render_cell", "NOT_EVIDENCE_FOR",
|
|
29
|
+
"NEAREST_TO_CAUSAL_CASE"]
|
|
30
|
+
|
|
31
|
+
#: What this benchmark measures. Both are closure/observability claims.
|
|
32
|
+
BENCHMARK_MEASURES = (SignalKind.CLOSURE_VALIDITY, SignalKind.OBSERVABILITY)
|
|
33
|
+
|
|
34
|
+
#: What it does not measure, and must never be quoted as supporting.
|
|
35
|
+
NOT_EVIDENCE_FOR = SignalKind.CAUSAL_MATERIALITY
|
|
36
|
+
|
|
37
|
+
#: The vehicle closest to the causal case, and therefore the one most likely to be
|
|
38
|
+
#: mistaken for evidence of it. Named explicitly in every rendering.
|
|
39
|
+
NEAREST_TO_CAUSAL_CASE = "jin_sco2_buoyancy"
|
|
40
|
+
|
|
41
|
+
_OUTCOME_MEANING = {
|
|
42
|
+
"PHYSMAP_WINS": "the closure guard fired where the statistical baselines were silent",
|
|
43
|
+
"PARTIAL": "the guard fired, but the baselines were not wholly silent",
|
|
44
|
+
"DO_NO_HARM": "the guard stayed quiet where the baselines already saw the failure",
|
|
45
|
+
"BASELINE_VISIBLE": "the baselines saw it; the guard correctly claims no credit",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_REDISTRIBUTION_LINE = {
|
|
49
|
+
Redistribution.CLEAR: "source data ships under an affirmative licence",
|
|
50
|
+
Redistribution.NO_LICENCE_FACTS_BASIS:
|
|
51
|
+
"source data ships WITHOUT a licence, on a facts basis (see NOTICE)",
|
|
52
|
+
Redistribution.AGAINST_PUBLISHER_TERMS:
|
|
53
|
+
"source data ships AGAINST an express publisher term (see NOTICE)",
|
|
54
|
+
Redistribution.EXCLUDED_BY_DECISION: "source data does not ship",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def render_cell(cell: dict, *, recomputed: bool = False) -> str:
|
|
59
|
+
vid = cell["vehicle_id"]
|
|
60
|
+
rec = get(vid)
|
|
61
|
+
kinds = " + ".join(k.value for k in BENCHMARK_MEASURES)
|
|
62
|
+
|
|
63
|
+
lines = [
|
|
64
|
+
f"{vid} [{cell['empirical_outcome']}]",
|
|
65
|
+
f" measures {kinds}",
|
|
66
|
+
f" NOT evidence for {NOT_EVIDENCE_FOR.value} -- no cell here computes an",
|
|
67
|
+
f" ablation, a counterfactual, a materiality or any metric.",
|
|
68
|
+
f" Not even {NEAREST_TO_CAUSAL_CASE}, which IS a mixed-convection",
|
|
69
|
+
f" vertical tube and is the closest vehicle to the causal case:",
|
|
70
|
+
f" it asks whether its buoyancy parameter is observable, nothing more.",
|
|
71
|
+
"",
|
|
72
|
+
f" domain {cell['domain']}",
|
|
73
|
+
f" regime {cell.get('regime', 'n/a')}",
|
|
74
|
+
f" failure driver {cell['failure_var']} ({cell['failure_observability']})",
|
|
75
|
+
f" surrogate sees {', '.join(cell.get('surrogate_inputs', []))}",
|
|
76
|
+
f" train / test {cell.get('n_train')} / {cell.get('n_test')}",
|
|
77
|
+
"",
|
|
78
|
+
f" outcome {cell['empirical_outcome']} -- "
|
|
79
|
+
f"{_OUTCOME_MEANING.get(cell['empirical_outcome'], 'see the benchmark report')}",
|
|
80
|
+
f" guard passed {cell.get('observability_guard_passed')}",
|
|
81
|
+
f" numbers from {'this run (recomputed)' if recomputed else 'the banked matrix'}",
|
|
82
|
+
]
|
|
83
|
+
if cell.get("caveat"):
|
|
84
|
+
lines.append(f" caveat {cell['caveat']}")
|
|
85
|
+
if cell.get("rationale"):
|
|
86
|
+
lines.append(f" rationale {cell['rationale']}")
|
|
87
|
+
|
|
88
|
+
lines.append("")
|
|
89
|
+
lines.append(f" provenance {rec.source}")
|
|
90
|
+
lines.append(f" values {rec.how_values_were_produced}")
|
|
91
|
+
lines.append(f" redistribution {_REDISTRIBUTION_LINE[rec.redistribution]}")
|
|
92
|
+
|
|
93
|
+
if rec.quality is not DataQuality.BENCHMARK_GRADE:
|
|
94
|
+
lines.append("")
|
|
95
|
+
lines.append(f" DATA QUALITY {rec.quality.value.upper()}")
|
|
96
|
+
lines.append(f" {rec.quality_note}")
|
|
97
|
+
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append(f" release {CURRENT_RELEASE_STATE.value} -- this build reports "
|
|
100
|
+
f"no precision, recall or F1.")
|
|
101
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Deterministic explanations for causal assessments.
|
|
2
|
+
|
|
3
|
+
Rendered templates. No LLM, no sampling, no model call anywhere in this path -- the same
|
|
4
|
+
inputs give byte-identical output, which is what makes an explanation checkable rather
|
|
5
|
+
than merely fluent.
|
|
6
|
+
|
|
7
|
+
Every rendering states its evidence state and its release state, so an explanation can
|
|
8
|
+
never be quoted as a performance claim that this build does not make.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from physmap.applicability.screen import Applicability, ScreenResult
|
|
14
|
+
from physmap.core.signals import Signal
|
|
15
|
+
from physmap.materiality.estimator import MaterialityResult, MaterialityStatus
|
|
16
|
+
from physmap.release import CURRENT_RELEASE_STATE, EvidenceState
|
|
17
|
+
|
|
18
|
+
__all__ = ["render_screen", "render_materiality", "render_signal"]
|
|
19
|
+
|
|
20
|
+
_EVIDENCE_NOTE = {
|
|
21
|
+
EvidenceState.SYNTHETIC_FIXTURE:
|
|
22
|
+
"Synthetic fixture: inputs are constructed, not measured. No claim about any "
|
|
23
|
+
"real case follows from this result.",
|
|
24
|
+
EvidenceState.DECLARATIVE:
|
|
25
|
+
"Declarative: the preconditions are asserted from the case description, not read "
|
|
26
|
+
"from data. This demonstrates refusal logic; it is not an evidence-backed case.",
|
|
27
|
+
EvidenceState.MEASURED:
|
|
28
|
+
"Measured: inputs come from recorded data with stated provenance.",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _footer(evidence_state: EvidenceState) -> str:
|
|
33
|
+
return (
|
|
34
|
+
f" evidence: {evidence_state.value} -- {_EVIDENCE_NOTE[evidence_state]}\n"
|
|
35
|
+
f" release: {CURRENT_RELEASE_STATE.value} -- this build reports no precision, "
|
|
36
|
+
f"recall or F1."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_screen(result: ScreenResult) -> str:
|
|
41
|
+
verdict = {
|
|
42
|
+
Applicability.APPLICABLE: "APPLICABLE",
|
|
43
|
+
Applicability.NOT_APPLICABLE: "NOT APPLICABLE",
|
|
44
|
+
Applicability.INSUFFICIENT_EVIDENCE: "INSUFFICIENT EVIDENCE",
|
|
45
|
+
}[result.applicability]
|
|
46
|
+
lines = [
|
|
47
|
+
f"{result.case_id} [{verdict}]",
|
|
48
|
+
f" quantity of interest: {result.qoi}",
|
|
49
|
+
f" reason code: {result.reason_code.value}",
|
|
50
|
+
f" why: {result.rationale}",
|
|
51
|
+
]
|
|
52
|
+
lines.extend(f" note: {n}" for n in result.notes)
|
|
53
|
+
lines.append(_footer(result.evidence_state))
|
|
54
|
+
return "\n".join(lines)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def render_materiality(result: MaterialityResult) -> str:
|
|
58
|
+
head = (
|
|
59
|
+
f"{result.mechanism_id} -> {result.qoi} "
|
|
60
|
+
f"[{'materiality ' + format(result.value, '.4g') if result.is_usable() else result.status.value.upper()}]"
|
|
61
|
+
)
|
|
62
|
+
lines = [head, f" why: {result.reason}"]
|
|
63
|
+
if result.status is MaterialityStatus.ESTIMATED:
|
|
64
|
+
i = result.inputs
|
|
65
|
+
lines.append(
|
|
66
|
+
f" inputs: qoi_full={i.qoi_full:g} qoi_ablated={i.qoi_ablated:g} "
|
|
67
|
+
f"provenance={i.provenance.value}"
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
lines.append(" inputs: incomplete -- no value is inferred, and none is a zero.")
|
|
71
|
+
lines.append(_footer(result.evidence_state))
|
|
72
|
+
return "\n".join(lines)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render_signal(signal: Signal) -> str:
|
|
76
|
+
state = "FIRED" if signal.fired else "quiet"
|
|
77
|
+
value = "n/a" if signal.value is None else format(signal.value, ".4g")
|
|
78
|
+
thresh = "n/a" if signal.threshold is None else format(signal.threshold, ".4g")
|
|
79
|
+
return (
|
|
80
|
+
f"{signal.kind.value} [{state}] value={value} threshold={thresh}\n"
|
|
81
|
+
f" {signal.rationale}"
|
|
82
|
+
)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Public API for the physmap CredibilityGuardrail.
|
|
2
|
+
|
|
3
|
+
Importing this package pulls numpy + sklearn (via the pipeline detectors) but
|
|
4
|
+
never torch or an LLM SDK — the no-heavy-imports guard stays green. Bare
|
|
5
|
+
`import physmap` does NOT import this package; it is loaded lazily on first
|
|
6
|
+
access to a guardrail symbol (see physmap/__init__.py).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from physmap.guardrail.configs import (
|
|
10
|
+
Assessment,
|
|
11
|
+
ClosureValidityDetectorConfig,
|
|
12
|
+
ColumnMap,
|
|
13
|
+
DetectorResult,
|
|
14
|
+
DistanceDetectorConfig,
|
|
15
|
+
GPVarianceDetectorConfig,
|
|
16
|
+
NoveltyDetectorConfig,
|
|
17
|
+
)
|
|
18
|
+
from physmap.guardrail.enums import (
|
|
19
|
+
AggregatorKind,
|
|
20
|
+
DensityMethod,
|
|
21
|
+
Device,
|
|
22
|
+
DetectorKind,
|
|
23
|
+
Disposition,
|
|
24
|
+
Observability,
|
|
25
|
+
Regime,
|
|
26
|
+
Verdict,
|
|
27
|
+
)
|
|
28
|
+
from physmap.guardrail.guardrail import CredibilityGuardrail
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"CredibilityGuardrail",
|
|
32
|
+
# enums
|
|
33
|
+
"Regime", "DetectorKind", "DensityMethod", "AggregatorKind", "Device",
|
|
34
|
+
"Observability", "Verdict", "Disposition",
|
|
35
|
+
# configs / data contracts
|
|
36
|
+
"NoveltyDetectorConfig", "DistanceDetectorConfig", "GPVarianceDetectorConfig",
|
|
37
|
+
"ClosureValidityDetectorConfig", "ColumnMap", "DetectorResult", "Assessment",
|
|
38
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""The observability-weighted aggregator — where the physics signal is trusted
|
|
2
|
+
precisely on the axes the statistical baselines are structurally blind to.
|
|
3
|
+
|
|
4
|
+
This is NOT a pipeline.core.Aggregator (that returns the internal fire/quiet
|
|
5
|
+
Verdict and sees only decision_signals); it needs the observability dict + the
|
|
6
|
+
governing fired bound, so it has its own combine(). Three branches, keyed on the
|
|
7
|
+
FIRED BOUND's observability, not baseline silence:
|
|
8
|
+
|
|
9
|
+
UNOBSERVABLE → REJECT baselines blind → their silence is expected, not
|
|
10
|
+
reassuring → trust the corpus. THE product value.
|
|
11
|
+
PARTIAL → UNCERTAIN/REVIEW honest interim; defer, do not over-fire.
|
|
12
|
+
OBSERVABLE / → baseline verdict corpus redundant → defer to baselines, never
|
|
13
|
+
corpus quiet double-count (do-no-harm).
|
|
14
|
+
|
|
15
|
+
The Disposition reuses the shipped DefeasibleAdjudicator (action_class ∈ the 5
|
|
16
|
+
SHACL classes), mapped to the public 4-way Disposition enum. Verdict is the public
|
|
17
|
+
4-way enum (distinct from the internal fire/quiet core.Verdict).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
|
|
24
|
+
from physmap.guardrail.enums import Disposition, Observability, Regime, Verdict
|
|
25
|
+
from physmap.guardrail.weighting_heuristic import (
|
|
26
|
+
LEAN_BASELINE_TRUST,
|
|
27
|
+
LEAN_CORPUS_TRUST,
|
|
28
|
+
WEIGHT_GRADED,
|
|
29
|
+
graded_lean,
|
|
30
|
+
resolve_partial,
|
|
31
|
+
)
|
|
32
|
+
from physmap.pipeline.assessment_v06 import (
|
|
33
|
+
_PATTERN_ID_BY_DETECTOR,
|
|
34
|
+
WeakenerAnnotationNode,
|
|
35
|
+
)
|
|
36
|
+
from physmap.pipeline.core import DetectorResult
|
|
37
|
+
from physmap.pipeline.defeasible_aggregator import DefeasibleAdjudicator
|
|
38
|
+
from physmap.pipeline.validity_signal import PerBoundMargin
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
CORPUS_NAME = "closure_validity"
|
|
42
|
+
|
|
43
|
+
# SHACL action_class → public Disposition. The public enum (spec-locked at 4) is
|
|
44
|
+
# narrower than the 5 SHACL classes: acquire-validation maps to REVIEW, and
|
|
45
|
+
# change-cou folds into RESTRICT_COU (both narrow/alter the context of use).
|
|
46
|
+
_DISPOSITION_FROM_ACTION = {
|
|
47
|
+
"characterize-region": Disposition.CHARACTERIZE_REGION,
|
|
48
|
+
"restrict-cou": Disposition.RESTRICT_COU,
|
|
49
|
+
"accept-residual-risk": Disposition.ACCEPT_RESIDUAL_RISK,
|
|
50
|
+
"acquire-validation": Disposition.REVIEW,
|
|
51
|
+
"change-cou": Disposition.RESTRICT_COU,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AggregateOutcome:
|
|
57
|
+
verdict: Verdict
|
|
58
|
+
disposition: Disposition
|
|
59
|
+
action_class: str # the SHACL action_class (kept for graph/audit consistency)
|
|
60
|
+
rule: str # which branch fired (debug / rationale seed)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _build_weakeners(decision_signals: dict[str, DetectorResult]) -> list[WeakenerAnnotationNode]:
|
|
64
|
+
"""One WeakenerAnnotation per FIRED detector — the adjudicator's input. Mirrors
|
|
65
|
+
assessment_v06's construction but needs no truth/Discrepancy (placeholder ids)."""
|
|
66
|
+
return [
|
|
67
|
+
WeakenerAnnotationNode(
|
|
68
|
+
id_=f"weakener:guardrail/{name}",
|
|
69
|
+
pattern_id=_PATTERN_ID_BY_DETECTOR.get(name, name),
|
|
70
|
+
affected_node="discrepancy:guardrail",
|
|
71
|
+
justification=r.rationale,
|
|
72
|
+
)
|
|
73
|
+
for name, r in decision_signals.items()
|
|
74
|
+
if r.fired
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ObservabilityWeightedAggregator:
|
|
80
|
+
"""The default aggregator. status_weighting is informational here (the
|
|
81
|
+
governing bound is chosen upstream in the detector); kept for parity."""
|
|
82
|
+
|
|
83
|
+
status_weighting: bool = True
|
|
84
|
+
_adjudicator: DefeasibleAdjudicator = field(default_factory=DefeasibleAdjudicator, repr=False)
|
|
85
|
+
|
|
86
|
+
def combine(
|
|
87
|
+
self,
|
|
88
|
+
*,
|
|
89
|
+
decision_signals: dict[str, DetectorResult],
|
|
90
|
+
observability: dict[str, Observability],
|
|
91
|
+
fired_bound: PerBoundMargin | None,
|
|
92
|
+
severities: dict[str, str | None],
|
|
93
|
+
fired_closure_id: str | None = None,
|
|
94
|
+
regime: Regime | None = None,
|
|
95
|
+
) -> AggregateOutcome:
|
|
96
|
+
coord = fired_bound.coord if fired_bound is not None else None
|
|
97
|
+
obs = observability.get(coord) if coord is not None else None
|
|
98
|
+
|
|
99
|
+
if fired_bound is not None and obs is Observability.UNOBSERVABLE:
|
|
100
|
+
# Corpus fired on a bound the baselines are STRUCTURALLY BLIND to.
|
|
101
|
+
# Trust it. Disposition from the adjudicator over ALL fired weakeners.
|
|
102
|
+
action = self._adjudicate(decision_signals)
|
|
103
|
+
return AggregateOutcome(
|
|
104
|
+
Verdict.REJECT, _DISPOSITION_FROM_ACTION[action], action,
|
|
105
|
+
"unobservable-corpus-trusted",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if fired_bound is not None and obs is Observability.PARTIAL:
|
|
109
|
+
return self._partial_outcome(
|
|
110
|
+
decision_signals, severities, coord, fired_closure_id, regime,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# OBSERVABLE (corpus redundant) or corpus quiet → baseline verdict.
|
|
114
|
+
return self._baseline_outcome(decision_signals, severities)
|
|
115
|
+
|
|
116
|
+
def _partial_outcome(
|
|
117
|
+
self,
|
|
118
|
+
decision_signals: dict[str, DetectorResult],
|
|
119
|
+
severities: dict[str, str | None],
|
|
120
|
+
coord: str,
|
|
121
|
+
closure_id: str | None,
|
|
122
|
+
regime: Regime | None,
|
|
123
|
+
) -> AggregateOutcome:
|
|
124
|
+
"""The PARTIAL middle. Uncalibrated → DEFER (honest UNCERTAIN), exactly as
|
|
125
|
+
before. Calibrated (a real vehicle has set this regime+variable's Layer-2c
|
|
126
|
+
degree) → the continuous-blend graded weight: corpus-trust near the unobservable
|
|
127
|
+
pole, baseline verdict near the observable pole (corpus redundant), else a
|
|
128
|
+
calibrated WARN soft-flag. No rule change at graduation — we just read the
|
|
129
|
+
richer corpus value."""
|
|
130
|
+
regime_value = regime.value if regime is not None else None
|
|
131
|
+
decision = (
|
|
132
|
+
resolve_partial(closure_id, coord, regime_value=regime_value)
|
|
133
|
+
if closure_id else None
|
|
134
|
+
)
|
|
135
|
+
if decision is None or decision.weight_target != WEIGHT_GRADED:
|
|
136
|
+
# Uncalibrated, or a labeled lean — set membership can't weight it; defer.
|
|
137
|
+
return AggregateOutcome(
|
|
138
|
+
Verdict.UNCERTAIN, Disposition.REVIEW, "acquire-validation",
|
|
139
|
+
"partial-defer",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
lean = graded_lean(decision.partial_degree)
|
|
143
|
+
if lean == LEAN_CORPUS_TRUST:
|
|
144
|
+
# Calibrated near the unobservable pole → trust the corpus fire.
|
|
145
|
+
action = self._adjudicate(decision_signals)
|
|
146
|
+
return AggregateOutcome(
|
|
147
|
+
Verdict.REJECT, _DISPOSITION_FROM_ACTION[action], action,
|
|
148
|
+
"partial-graded-corpus-trust",
|
|
149
|
+
)
|
|
150
|
+
if lean == LEAN_BASELINE_TRUST:
|
|
151
|
+
# Calibrated near the observable pole → corpus redundant; defer to baselines.
|
|
152
|
+
return self._baseline_outcome(decision_signals, severities)
|
|
153
|
+
# Mid-axis → calibrated soft-flag: warn + characterize the partial-driver region.
|
|
154
|
+
return AggregateOutcome(
|
|
155
|
+
Verdict.WARN, Disposition.CHARACTERIZE_REGION, "characterize-region",
|
|
156
|
+
"partial-graded-softflag",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _baseline_outcome(
|
|
160
|
+
self,
|
|
161
|
+
decision_signals: dict[str, DetectorResult],
|
|
162
|
+
severities: dict[str, str | None],
|
|
163
|
+
) -> AggregateOutcome:
|
|
164
|
+
# Exclude the corpus detector entirely — never double-count on observable
|
|
165
|
+
# axes; this is what makes OBSERVABLE equivalent to statistical-only.
|
|
166
|
+
baselines = {n: r for n, r in decision_signals.items() if n != CORPUS_NAME}
|
|
167
|
+
fired = {n: r for n, r in baselines.items() if r.fired}
|
|
168
|
+
action = self._adjudicate(baselines) # adjudicate over fired baselines only
|
|
169
|
+
|
|
170
|
+
if not fired:
|
|
171
|
+
verdict = Verdict.TRUSTWORTHY
|
|
172
|
+
elif any(severities.get(n) == "reject" for n in fired):
|
|
173
|
+
verdict = Verdict.REJECT
|
|
174
|
+
else:
|
|
175
|
+
verdict = Verdict.WARN
|
|
176
|
+
return AggregateOutcome(
|
|
177
|
+
verdict, _DISPOSITION_FROM_ACTION[action], action, "baseline-verdict",
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def _adjudicate(self, decision_signals: dict[str, DetectorResult]) -> str:
|
|
181
|
+
weakeners = _build_weakeners(decision_signals)
|
|
182
|
+
result = self._adjudicator.adjudicate(
|
|
183
|
+
weakeners=weakeners,
|
|
184
|
+
decision_signals=decision_signals,
|
|
185
|
+
discrepancy_id="discrepancy:guardrail",
|
|
186
|
+
)
|
|
187
|
+
return result.action_class
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Structural observability — the one genuinely new mechanism.
|
|
2
|
+
|
|
3
|
+
At fit time, classify each resolved closure's validity-bound variable as
|
|
4
|
+
OBSERVABLE / PARTIAL / UNOBSERVABLE by SET MEMBERSHIP against the surrogate's
|
|
5
|
+
declared inputs. NOT CV-R²: that estimator is retrospective (needs truth + a
|
|
6
|
+
region + a population) and cannot run at deployment. This is pure — a corpus
|
|
7
|
+
lookup + a feature-name map + a corpus tag; no truth, no population.
|
|
8
|
+
|
|
9
|
+
Also houses the coordinate-name bridges the guardrail shares. Three vocabularies
|
|
10
|
+
are in play:
|
|
11
|
+
* corpus coords (long): reynolds_number, prandtl_number, x_over_D, richardson_number
|
|
12
|
+
* surrogate inputs (short): Re, Pr, x_over_D, Ri
|
|
13
|
+
* detector features: log10_Re, Pr, x_over_D
|
|
14
|
+
FEATURE_TO_CORPUS_COORD (from validity_signal) is the canonical feature↔coord
|
|
15
|
+
source; the short-name + meta-key bridges are added here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Sequence
|
|
21
|
+
|
|
22
|
+
from physmap.corpus.calibration import ClosureEntry, index_by_id, load_corpus
|
|
23
|
+
from physmap.guardrail.corpus_regimes import (
|
|
24
|
+
KNOWN_PARTIAL,
|
|
25
|
+
REGIME_TO_CLOSURES,
|
|
26
|
+
observability_class_for,
|
|
27
|
+
)
|
|
28
|
+
from physmap.guardrail.enums import Observability, Regime
|
|
29
|
+
from physmap.pipeline.validity_signal import FEATURE_TO_CORPUS_COORD
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Corpus coord → surrogate-input name(s) that would make it observable. These mirror
|
|
33
|
+
# FEATURE_TO_CORPUS_COORD; the absent-but-correlated axes (richardson_number/Ri,
|
|
34
|
+
# viscosity_ratio_wall_bulk) are listed explicitly so set-membership reads them as
|
|
35
|
+
# absent from a (Re, Pr) surrogate and routes them PARTIAL rather than OBSERVABLE.
|
|
36
|
+
_COORD_TO_INPUT_ALIASES: dict[str, tuple[str, ...]] = {
|
|
37
|
+
"reynolds_number": ("Re", "log10_Re"),
|
|
38
|
+
"prandtl_number": ("Pr",),
|
|
39
|
+
"x_over_D": ("x_over_D", "log10_x_over_D"),
|
|
40
|
+
"richardson_number": ("Ri", "log10_Ri"),
|
|
41
|
+
"viscosity_ratio_wall_bulk": ("ratio_mu_w_b",),
|
|
42
|
+
# Jin: Liu buoyancy parameter Bu is NOT a (Re, Pr) surrogate input and (MEASURED) not recoverable
|
|
43
|
+
# from them — cv_r2_knn ~ 0 over the deploy region, because Bu carries the wall-temperature/HTD
|
|
44
|
+
# signal the DIRECTION toggle drives, orthogonal to the bulk inputs (the SAME structure as Casper's
|
|
45
|
+
# freestream noise). Its only alias is its own meta key and it is NOT known-partial, so set-
|
|
46
|
+
# membership reads it absent → UNOBSERVABLE (the pole, like Casper). Distinct physical group from
|
|
47
|
+
# richardson_number (Gr/Re^2); the same-name trap is avoided by its own coordinate.
|
|
48
|
+
"liu_buoyancy_parameter": ("Bu",),
|
|
49
|
+
# Casper: tunnel freestream noise is a distinct flow-environment axis, NOT a
|
|
50
|
+
# surrogate input and NOT recoverable from (M, Re/m, x) — its only alias is
|
|
51
|
+
# its own feature, so set-membership reads it absent → UNOBSERVABLE (the
|
|
52
|
+
# PHYSMAP_WINS classification: baselines are structurally blind to it).
|
|
53
|
+
"freestream_noise_rms_pitot_pct": ("freestream_noise_pct",),
|
|
54
|
+
# Marineau: the entropy-layer/shock ratio S_T/X_SW is a deterministic
|
|
55
|
+
# function of nose radius (Rn) and unit Reynolds at fixed Mach — BOTH
|
|
56
|
+
# surrogate inputs — so it is recoverable from the inputs → OBSERVABLE
|
|
57
|
+
# (baseline-visible). The negative control: a real validity bound exists,
|
|
58
|
+
# but the steelman baseline already sees the failure via Rn.
|
|
59
|
+
"entropy_layer_shock_ratio": ("Rn_mm", "Re_per_m"),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Corpus coord → the raw meta key the feature extractor reads (extract_features
|
|
63
|
+
# computes log10_Re from meta["Re"], reads meta["Pr"], meta["x_over_D"], …).
|
|
64
|
+
_COORD_TO_META_KEY: dict[str, str] = {
|
|
65
|
+
"reynolds_number": "Re",
|
|
66
|
+
"prandtl_number": "Pr",
|
|
67
|
+
"x_over_D": "x_over_D",
|
|
68
|
+
"richardson_number": "Ri",
|
|
69
|
+
"viscosity_ratio_wall_bulk": "ratio_mu_w_b",
|
|
70
|
+
"liu_buoyancy_parameter": "Bu", # Jin (sCO2 vertical-tube buoyancy)
|
|
71
|
+
"freestream_noise_rms_pitot_pct": "freestream_noise_pct", # Casper
|
|
72
|
+
"entropy_layer_shock_ratio": "st_xsw_ratio", # Marineau
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
# Short surrogate-input name → canonical feature name extract_features understands.
|
|
76
|
+
_INPUT_TO_FEATURE: dict[str, str] = {
|
|
77
|
+
"Re": "log10_Re",
|
|
78
|
+
"Pr": "Pr",
|
|
79
|
+
"x_over_D": "x_over_D",
|
|
80
|
+
"Ri": "Ri",
|
|
81
|
+
"Dh_mm": "log10_Dh_mm",
|
|
82
|
+
"alpha_star": "alpha_star",
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def input_to_feature(name: str) -> str:
|
|
87
|
+
"""Surrogate-input name → feature name (pass-through if already a feature)."""
|
|
88
|
+
return _INPUT_TO_FEATURE.get(name, name)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def coord_input_aliases(coord: str) -> tuple[str, ...]:
|
|
92
|
+
"""The surrogate-input name(s) whose presence makes `coord` observable."""
|
|
93
|
+
if coord in _COORD_TO_INPUT_ALIASES:
|
|
94
|
+
return _COORD_TO_INPUT_ALIASES[coord]
|
|
95
|
+
return tuple(f for f, c in FEATURE_TO_CORPUS_COORD.items() if c == coord)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def coord_to_feature(coord: str) -> str | None:
|
|
99
|
+
"""Corpus coord → canonical baseline feature name, or None if the coord has
|
|
100
|
+
no baseline feature (e.g. richardson_number is not in the feature space)."""
|
|
101
|
+
feats = [f for f, c in FEATURE_TO_CORPUS_COORD.items() if c == coord]
|
|
102
|
+
if not feats:
|
|
103
|
+
return None
|
|
104
|
+
for f in feats: # prefer the passthrough form (x_over_D over log10_x_over_D)
|
|
105
|
+
if f == coord:
|
|
106
|
+
return f
|
|
107
|
+
return sorted(feats)[0]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def coord_to_meta_key(coord: str) -> str | None:
|
|
111
|
+
"""Corpus coord → the raw data column / meta key the extractor needs."""
|
|
112
|
+
return _COORD_TO_META_KEY.get(coord)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_default_corpus_index() -> dict[str, ClosureEntry]:
|
|
116
|
+
"""Load + index the installed calibration corpus (closure_id → ClosureEntry)."""
|
|
117
|
+
return index_by_id(load_corpus())
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def classify_observability(
|
|
121
|
+
surrogate_inputs: Sequence[str],
|
|
122
|
+
regime: Regime,
|
|
123
|
+
corpus_index: dict[str, ClosureEntry],
|
|
124
|
+
) -> dict[str, Observability]:
|
|
125
|
+
"""Map each resolved closure's bound variable → its structural observability.
|
|
126
|
+
|
|
127
|
+
UNLISTED (or no resolved closures) → {} → the guard runs statistical-only.
|
|
128
|
+
A bound variable is OBSERVABLE if the surrogate consumes its axis; PARTIAL if
|
|
129
|
+
it is tagged known-partial (absent-but-correlated); else UNOBSERVABLE.
|
|
130
|
+
"""
|
|
131
|
+
if regime is Regime.UNLISTED:
|
|
132
|
+
return {}
|
|
133
|
+
inputs = set(surrogate_inputs)
|
|
134
|
+
out: dict[str, Observability] = {}
|
|
135
|
+
for closure_id in REGIME_TO_CLOSURES.get(regime, ()):
|
|
136
|
+
entry = corpus_index.get(closure_id)
|
|
137
|
+
if entry is None:
|
|
138
|
+
continue
|
|
139
|
+
for bound in entry.validated_range:
|
|
140
|
+
coord = bound.coord
|
|
141
|
+
if any(a in inputs for a in coord_input_aliases(coord)):
|
|
142
|
+
out[coord] = Observability.OBSERVABLE
|
|
143
|
+
elif observability_class_for(closure_id, coord) == KNOWN_PARTIAL:
|
|
144
|
+
out[coord] = Observability.PARTIAL
|
|
145
|
+
else:
|
|
146
|
+
out[coord] = Observability.UNOBSERVABLE
|
|
147
|
+
return out
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Detector config dataclasses, the ColumnMap data contract, and the public
|
|
2
|
+
result types (DetectorResult, Assessment) for the CredibilityGuardrail.
|
|
3
|
+
|
|
4
|
+
A detector is specified by a bare DetectorKind (no params) OR a frozen config
|
|
5
|
+
object (params). Continuous params are numbers; categorical params are enums.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Union
|
|
12
|
+
|
|
13
|
+
from physmap.guardrail.enums import (
|
|
14
|
+
DensityMethod,
|
|
15
|
+
DetectorKind,
|
|
16
|
+
Device,
|
|
17
|
+
Disposition,
|
|
18
|
+
Observability,
|
|
19
|
+
Verdict,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ── Detector specs (bare enum if no params; config object if params) ──────────
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class NoveltyDetectorConfig:
|
|
26
|
+
kind: DetectorKind = DetectorKind.NOVELTY_DENSITY
|
|
27
|
+
method: DensityMethod = DensityMethod.GMM
|
|
28
|
+
components: int = 1
|
|
29
|
+
warn_pct: float = 99.0
|
|
30
|
+
reject_pct: float = 99.9
|
|
31
|
+
device: Device = Device.CPU
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class DistanceDetectorConfig:
|
|
36
|
+
kind: DetectorKind = DetectorKind.DISTANCE_TO_TRAINING
|
|
37
|
+
k: int = 3
|
|
38
|
+
device: Device = Device.CPU
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class GPVarianceDetectorConfig:
|
|
43
|
+
kind: DetectorKind = DetectorKind.GP_VARIANCE
|
|
44
|
+
kernel: str = "matern52"
|
|
45
|
+
device: Device = Device.CPU
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class ClosureValidityDetectorConfig:
|
|
50
|
+
kind: DetectorKind = DetectorKind.CLOSURE_VALIDITY
|
|
51
|
+
graded: bool = True # continuous distance-past-bound; False = binary
|
|
52
|
+
status_weighting: bool = True # weight signal by bound status (confirmed > … > claimed)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class ConformalResidualDetectorConfig:
|
|
57
|
+
"""The observable-pole conformal statistical mode (spec v0.1, action #4). Opt-in —
|
|
58
|
+
NOT in the default detector set, so it is strictly additive."""
|
|
59
|
+
kind: DetectorKind = DetectorKind.CONFORMAL_RESIDUAL
|
|
60
|
+
alpha: float = 0.1 # target miscoverage / in-distribution false-alarm rate
|
|
61
|
+
calib_frac: float = 0.3 # fraction of train held out for the conformal calibration split
|
|
62
|
+
random_state: int = 20260605 # locked seed (matches the GP emulator)
|
|
63
|
+
device: Device = Device.CPU
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
DetectorSpec = Union[
|
|
67
|
+
DetectorKind,
|
|
68
|
+
NoveltyDetectorConfig,
|
|
69
|
+
DistanceDetectorConfig,
|
|
70
|
+
GPVarianceDetectorConfig,
|
|
71
|
+
ClosureValidityDetectorConfig,
|
|
72
|
+
ConformalResidualDetectorConfig,
|
|
73
|
+
]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class ColumnMap:
|
|
78
|
+
"""Column schema for the Path / named-array branches.
|
|
79
|
+
|
|
80
|
+
`inputs` is the FULL assessment-coordinate set the data carries — a SUPERSET
|
|
81
|
+
of surrogate_inputs that MUST include the bound variables the closure-validity
|
|
82
|
+
detector reads (e.g. x_over_D even when the surrogate only sees Re, Pr).
|
|
83
|
+
"""
|
|
84
|
+
inputs: list[str]
|
|
85
|
+
truth: str = "truth"
|
|
86
|
+
prediction: str = "prediction"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Results ───────────────────────────────────────────────────────────────────
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class DetectorResult:
|
|
92
|
+
detector: DetectorKind
|
|
93
|
+
score: float
|
|
94
|
+
fired: bool
|
|
95
|
+
threshold: float | None
|
|
96
|
+
rationale: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass(frozen=True)
|
|
100
|
+
class Assessment:
|
|
101
|
+
verdict: Verdict
|
|
102
|
+
disposition: Disposition
|
|
103
|
+
rationale: str
|
|
104
|
+
signals: dict[DetectorKind, DetectorResult]
|
|
105
|
+
observability: Observability | None
|
|
106
|
+
fired_bound_variable: str | None
|
|
107
|
+
# Context needed to render the v0.6 SHACL subgraph (to_graph). Not part of the
|
|
108
|
+
# narrative surface, but the Discrepancy/Disposition nodes require it. truth is
|
|
109
|
+
# optional: a deployment without truth still yields a flat Assessment, but
|
|
110
|
+
# to_graph() then raises (the Discrepancy shape pins exactly-one solverTruth).
|
|
111
|
+
surrogate_prediction: float | None = None
|
|
112
|
+
solver_truth: float | None = None
|
|
113
|
+
operating_point: tuple = ()
|
|
114
|
+
closure_id: str | None = None
|
|
115
|
+
region: str = "" # human-readable coords, e.g. "Re=55570, x_over_D=1.42"
|
|
116
|
+
|
|
117
|
+
def to_graph(self) -> dict:
|
|
118
|
+
"""v0.6-conformant, SHACL-valid JSON-LD subgraph (reuses assessment_v06)."""
|
|
119
|
+
from physmap.guardrail.graph import assessment_to_graph
|
|
120
|
+
return assessment_to_graph(self)
|