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
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)