physmap 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. physmap/__init__.py +61 -0
  2. physmap/_paths.py +69 -0
  3. physmap/applicability/__init__.py +0 -0
  4. physmap/applicability/fixtures.py +83 -0
  5. physmap/applicability/screen.py +99 -0
  6. physmap/baselines/__init__.py +0 -0
  7. physmap/benchmarks/__init__.py +0 -0
  8. physmap/benchmarks/benchmark_report.py +405 -0
  9. physmap/benchmarks/benchmark_v0_4.py +424 -0
  10. physmap/benchmarks/compare.py +149 -0
  11. physmap/benchmarks/registry.py +217 -0
  12. physmap/benchmarks/report.py +224 -0
  13. physmap/cli.py +301 -0
  14. physmap/closures/__init__.py +48 -0
  15. physmap/closures/data/__init__.py +7 -0
  16. physmap/closures/data/closure_index.json +2997 -0
  17. physmap/closures/formulas.py +213 -0
  18. physmap/closures/geometry_classes.py +109 -0
  19. physmap/closures/index.py +393 -0
  20. physmap/closures/registry.py +313 -0
  21. physmap/compat/__init__.py +0 -0
  22. physmap/core/__init__.py +0 -0
  23. physmap/core/mechanism.py +69 -0
  24. physmap/core/signals.py +50 -0
  25. physmap/corpus/__init__.py +12 -0
  26. physmap/corpus/calibration.py +543 -0
  27. physmap/corpus/data/__init__.py +12 -0
  28. physmap/corpus/data/corpus_seed.jsonl +15 -0
  29. physmap/corpus/data/evidence_claims_seed.jsonl +21 -0
  30. physmap/corpus/data/evidence_sources_seed.jsonl +8 -0
  31. physmap/corpus/data/premium_coverage.json +60 -0
  32. physmap/corpus/evidence.py +871 -0
  33. physmap/explain/__init__.py +0 -0
  34. physmap/explain/benchmark.py +101 -0
  35. physmap/explain/causal.py +82 -0
  36. physmap/guardrail/__init__.py +38 -0
  37. physmap/guardrail/aggregator_observability.py +187 -0
  38. physmap/guardrail/classify.py +147 -0
  39. physmap/guardrail/configs.py +120 -0
  40. physmap/guardrail/corpus_regimes.py +208 -0
  41. physmap/guardrail/detector_conformal.py +129 -0
  42. physmap/guardrail/detector_density.py +74 -0
  43. physmap/guardrail/enums.py +69 -0
  44. physmap/guardrail/graph.py +73 -0
  45. physmap/guardrail/guardrail.py +606 -0
  46. physmap/guardrail/io.py +201 -0
  47. physmap/guardrail/regime_observability.py +519 -0
  48. physmap/guardrail/render.py +159 -0
  49. physmap/guardrail/weighting_heuristic.py +216 -0
  50. physmap/infra/__init__.py +23 -0
  51. physmap/infra/blindspot_oracle.py +356 -0
  52. physmap/infra/corpus_runtime.py +275 -0
  53. physmap/integrations/__init__.py +0 -0
  54. physmap/materiality/__init__.py +0 -0
  55. physmap/materiality/estimator.py +239 -0
  56. physmap/materiality/independence.py +92 -0
  57. physmap/materiality/surrogate_fit.py +293 -0
  58. physmap/observability/__init__.py +0 -0
  59. physmap/pipeline/__init__.py +58 -0
  60. physmap/pipeline/aggregators.py +199 -0
  61. physmap/pipeline/assessment_v06.py +509 -0
  62. physmap/pipeline/core.py +442 -0
  63. physmap/pipeline/defeasible_aggregator.py +324 -0
  64. physmap/pipeline/detectors.py +309 -0
  65. physmap/pipeline/observability.py +430 -0
  66. physmap/pipeline/surrogate.py +251 -0
  67. physmap/pipeline/validity_signal.py +273 -0
  68. physmap/pipeline/vehicle_spec.py +287 -0
  69. physmap/release.py +81 -0
  70. physmap/stress_tests/__init__.py +9 -0
  71. physmap/stress_tests/lewis_reuse.py +517 -0
  72. physmap/substrate/__init__.py +28 -0
  73. physmap/substrate/corpus_real.py +206 -0
  74. physmap/substrate/engine.py +209 -0
  75. physmap/substrate/forrest.py +249 -0
  76. physmap/substrate/loaders.py +2176 -0
  77. physmap/substrate/naca_tn1451.py +379 -0
  78. physmap/substrate/naca_wpd_loader.py +187 -0
  79. physmap/substrate/stage1_ingest.py +187 -0
  80. physmap/substrate/vehicle_config.py +407 -0
  81. physmap-0.2.0.dist-info/METADATA +270 -0
  82. physmap-0.2.0.dist-info/RECORD +88 -0
  83. physmap-0.2.0.dist-info/WHEEL +5 -0
  84. physmap-0.2.0.dist-info/entry_points.txt +2 -0
  85. physmap-0.2.0.dist-info/licenses/LICENSE +21 -0
  86. physmap-0.2.0.dist-info/licenses/LICENSE-CORPUS +469 -0
  87. physmap-0.2.0.dist-info/licenses/NOTICE +77 -0
  88. physmap-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,509 @@
1
+ """Phase-2 Assessment as a v0.6-conformant UofA subgraph.
2
+
3
+ Per the v0.2 architecture refactor spec, Part 4: the Phase-1 flat Assessment
4
+ becomes a focused subgraph conforming to the v0.6 vocab
5
+ (`https://uofa.net/vocab#`). The mapping is mechanical from the Phase-1
6
+ field names (chosen as Phase-2 seeds during Steps 5–6):
7
+
8
+ Assessment.operating_point -> Discrepancy.id_ + discrepancyRegion
9
+ Assessment.verdict -> Disposition.actionClass (via adjudicator)
10
+ Assessment.decision_signals[*] -> CredibilityFactor (always)
11
+ + WeakenerAnnotation (when fired)
12
+ DetectorResult.rationale -> WeakenerAnnotation.justification
13
+ Assessment.rationale -> Disposition.actionParameters
14
+
15
+ Phase-2 scope (per the spec): the ~8 IN-SCOPE node types
16
+ * Discrepancy — the surrogate point under review
17
+ * CredibilityFactor — one per detector (status: fired/quiet)
18
+ * WeakenerAnnotation — one per FIRED detector
19
+ * OffsetRationale — emitted by the defeasible adjudicator (Phase 2C)
20
+ * Disposition — actionClass from the v0.6 controlled vocab
21
+ * hasEvidence/hasJustification — link edges (carried as `affectedNode`
22
+ and the v0.6 `justification` property)
23
+
24
+ OUT-OF-SCOPE per point (these belong to the enclosing UnitOfAssurance
25
+ case, not the per-point assessment): VerificationActivity, ModelConfiguration,
26
+ SensitivityAnalysis, ProcessAttestation, DeploymentRecord, InputPedigreeLink,
27
+ ReviewActivity, full UnitOfAssurance lifecycle.
28
+
29
+ This module ships the dataclasses + the Phase-1 → v0.6 mapper. The
30
+ defeasible-adjudication aggregator (Phase 2C) plugs into
31
+ `_choose_action_class` and `_build_offset_rationales` so this layer
32
+ stays a pure structural mapping; Phase 2C reasons over it.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from dataclasses import dataclass, field
39
+ from pathlib import Path
40
+ from typing import Any, Iterable, Sequence
41
+
42
+ from physmap.pipeline.core import Assessment, DetectorResult
43
+
44
+
45
+ # Path the v0.6 @context is loaded from — relative to package root.
46
+ # Mirrors evidence_corpus.py's pattern (commit 2897392).
47
+ V06_CONTEXT_REF = "physmap/fixtures/context/v0.6.jsonld"
48
+
49
+ # Disposition controlled vocabulary (locked by uofa's disposition_shapes.ttl;
50
+ # any string outside this set fails SHACL).
51
+ DISPOSITION_ACTION_CLASSES = (
52
+ "restrict-cou", # narrow the context of use to avoid the failure
53
+ "acquire-validation", # get more validation data
54
+ "characterize-region", # describe where the failure occurs
55
+ "accept-residual-risk", # accept the failure with justification
56
+ "change-cou", # alter the context of use (model swap, etc.)
57
+ )
58
+
59
+
60
+ # ── factor / pattern dictionaries (Phase-1 -> v0.6 controlled-ish terms) ────
61
+ #
62
+ # These maps make the per-detector mapping mechanical. They are not fixed
63
+ # vocab (v0.6 doesn't pin factorType / patternId controlled strings the way
64
+ # it pins actionClass), but they ARE the names the EvidenceEnrichmentStage
65
+ # from Step 7 already uses for its provenance keys — preserving them lets
66
+ # Phase-1 + Phase-2 outputs line up without renames.
67
+
68
+ _FACTOR_TYPE_BY_DETECTOR = {
69
+ "distance": "input-distribution-novelty",
70
+ "gp_variance": "surrogate-prediction-variance",
71
+ "ensemble_variance": "ensemble-prediction-variance",
72
+ "corpus": "training-set-corpus-error",
73
+ "closure_validity": "literature-validity-distance",
74
+ "novelty_density": "input-distribution-novelty", # physmap GMM density baseline
75
+ }
76
+ # Note: EvidenceEnrichmentStage (in evidence_stage.py, Cleanup 6) attaches
77
+ # claim-centric provenance to justification_signals when the
78
+ # closure_validity gating signal fires. The justification payload's
79
+ # `claim_type` / `source` fields map to v0.6's hasEvidence and
80
+ # wasDerivedFrom edges in the Phase-2 graph.
81
+
82
+ _PATTERN_ID_BY_DETECTOR = {
83
+ "distance": "InputOutOfTrainingDistribution",
84
+ "gp_variance": "SurrogateVarianceExceedsThreshold",
85
+ "ensemble_variance": "EnsembleDisagreement",
86
+ "corpus": "CorpusErrorMagnitudeAboveThreshold",
87
+ "closure_validity": "OutOfValidatedRange",
88
+ # physmap GMM density baseline → an input-distribution novelty pattern (already
89
+ # in defeasible_aggregator.NOVELTY_PATTERNS), so a fired GMM adjudicates as novelty.
90
+ "novelty_density": "InputOutOfTrainingDistribution",
91
+ # observable-pole conformal statistical mode → a surrogate-variance-family weakener
92
+ # (a baseline-side credibility signal, never the corpus), so it adjudicates like gp_variance.
93
+ "conformal_residual": "SurrogateVarianceExceedsThreshold",
94
+ }
95
+
96
+
97
+ # ── v0.6 node dataclasses ───────────────────────────────────────────────────
98
+
99
+ # DiscrepancyShape pins measureType to this 2-entry vocab.
100
+ DISCREPANCY_MEASURE_TYPES = ("point", "aggregate")
101
+
102
+
103
+ @dataclass(frozen=True)
104
+ class DiscrepancyNode:
105
+ """uofa:Discrepancy — the surrogate point under review. Measured FACT;
106
+ carries surrogate-vs-truth + region/measure metadata. SHACL pins
107
+ surrogatePrediction, solverTruth, discrepancyMagnitude as xsd:double
108
+ (exactly one each) and `measureType ∈ {"point", "aggregate"}`."""
109
+ id_: str
110
+ surrogate_prediction: float
111
+ solver_truth: float
112
+ discrepancy_magnitude: float
113
+ discrepancy_region: str = ""
114
+ measure_type: str = "point" # one row = one operating-point measurement
115
+
116
+ def __post_init__(self) -> None:
117
+ if self.measure_type not in DISCREPANCY_MEASURE_TYPES:
118
+ raise ValueError(
119
+ f"Discrepancy.measure_type={self.measure_type!r} not in v0.6 "
120
+ f"controlled vocab {DISCREPANCY_MEASURE_TYPES}. SHACL "
121
+ f"DiscrepancyShape will reject this; refuse at construction."
122
+ )
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class CredibilityFactorNode:
127
+ """uofa:CredibilityFactor — one per detector. `factor_type` names the
128
+ detector family; `factor_status` is "fired" or "quiet"; `factor_standard`
129
+ cites the literature standard backing the detector (e.g., the closure_id
130
+ a validity-range detector queries)."""
131
+ id_: str
132
+ factor_type: str
133
+ factor_status: str # "fired" | "quiet"
134
+ factor_standard: str = ""
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class WeakenerAnnotationNode:
139
+ """uofa:WeakenerAnnotation — emitted for EVERY fired detector. patternId
140
+ names the failure pattern; affectedNode points at the Discrepancy; the
141
+ `justification` text is the DetectorResult.rationale verbatim
142
+ (preserves the audit trail Phase-1 already built)."""
143
+ id_: str
144
+ pattern_id: str
145
+ affected_node: str
146
+ justification: str
147
+ severity: str | None = None
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class OffsetRationaleNode:
152
+ """uofa:OffsetRationale — emitted by the defeasible adjudicator (Phase 2C)
153
+ to say "this CredibilityFactor's fire is OFFSET by other evidence; do
154
+ not weigh it as a defeater." Phase 2A doesn't emit these; the dataclass
155
+ is defined here so the Phase 2C aggregator can construct them without
156
+ introducing new types later."""
157
+ id_: str
158
+ refers_to_factor: str
159
+ justification: str
160
+ offsetting_evidence: str = ""
161
+
162
+
163
+ @dataclass(frozen=True)
164
+ class DispositionNode:
165
+ """uofa:Disposition — the action taken in response to the assessment.
166
+ SHACL pins actionClass to the 5-entry controlled vocab
167
+ (DISPOSITION_ACTION_CLASSES). `actionParameters` is optional free text;
168
+ `confidenceLevel` is optional High/Medium/Low or numeric;
169
+ `residual_risk_justification` is required when actionClass is
170
+ 'accept-residual-risk'."""
171
+ id_: str
172
+ action_class: str
173
+ action_parameters: str = ""
174
+ confidence_level: str | None = None
175
+ residual_risk_justification: str = ""
176
+
177
+ def __post_init__(self) -> None:
178
+ if self.action_class not in DISPOSITION_ACTION_CLASSES:
179
+ raise ValueError(
180
+ f"Disposition.action_class={self.action_class!r} not in v0.6 "
181
+ f"controlled vocab {DISPOSITION_ACTION_CLASSES}. SHACL will "
182
+ f"reject this; refuse at construction."
183
+ )
184
+
185
+
186
+ @dataclass
187
+ class V06AssessmentSubgraph:
188
+ """The full per-point Phase-2 subgraph: one Discrepancy + N
189
+ CredibilityFactors + M WeakenerAnnotations + 0-K OffsetRationales +
190
+ one Disposition."""
191
+ discrepancy: DiscrepancyNode
192
+ credibility_factors: list[CredibilityFactorNode]
193
+ weakener_annotations: list[WeakenerAnnotationNode]
194
+ offset_rationales: list[OffsetRationaleNode]
195
+ disposition: DispositionNode
196
+ operating_point: tuple
197
+
198
+ def to_jsonld(self, *, context_ref: str = V06_CONTEXT_REF) -> dict:
199
+ """Serialize as a v0.6 JSON-LD doc with @context + @graph."""
200
+ graph: list[dict] = []
201
+ graph.append(_discrepancy_to_jsonld(self.discrepancy))
202
+ for f in self.credibility_factors:
203
+ graph.append(_credibility_factor_to_jsonld(f))
204
+ for w in self.weakener_annotations:
205
+ graph.append(_weakener_annotation_to_jsonld(w))
206
+ for o in self.offset_rationales:
207
+ graph.append(_offset_rationale_to_jsonld(o))
208
+ graph.append(_disposition_to_jsonld(self.disposition))
209
+ return {"@context": context_ref, "@graph": graph}
210
+
211
+
212
+ # ── JSON-LD serializers (each emits one @graph entry) ───────────────────────
213
+
214
+ def _discrepancy_to_jsonld(d: DiscrepancyNode) -> dict:
215
+ payload: dict[str, Any] = {
216
+ "@type": "Discrepancy",
217
+ "id": d.id_,
218
+ # SHACL pins these as xsd:double; the @context coerces JSON numbers
219
+ # to xsd:double automatically.
220
+ "surrogatePrediction": float(d.surrogate_prediction),
221
+ "solverTruth": float(d.solver_truth),
222
+ "discrepancyMagnitude": float(d.discrepancy_magnitude),
223
+ }
224
+ if d.discrepancy_region:
225
+ payload["discrepancyRegion"] = d.discrepancy_region
226
+ if d.measure_type:
227
+ payload["measureType"] = d.measure_type
228
+ return payload
229
+
230
+
231
+ def _credibility_factor_to_jsonld(f: CredibilityFactorNode) -> dict:
232
+ payload: dict[str, Any] = {
233
+ "@type": "CredibilityFactor",
234
+ "id": f.id_,
235
+ "factorType": f.factor_type,
236
+ "factorStatus": f.factor_status,
237
+ }
238
+ if f.factor_standard:
239
+ payload["factorStandard"] = f.factor_standard
240
+ return payload
241
+
242
+
243
+ def _weakener_annotation_to_jsonld(w: WeakenerAnnotationNode) -> dict:
244
+ payload: dict[str, Any] = {
245
+ "@type": "WeakenerAnnotation",
246
+ "id": w.id_,
247
+ "patternId": w.pattern_id,
248
+ "affectedNode": w.affected_node,
249
+ "justification": w.justification,
250
+ }
251
+ if w.severity is not None:
252
+ payload["severity"] = w.severity
253
+ return payload
254
+
255
+
256
+ def _offset_rationale_to_jsonld(o: OffsetRationaleNode) -> dict:
257
+ payload: dict[str, Any] = {
258
+ "@type": "OffsetRationale",
259
+ "id": o.id_,
260
+ "refersToFactor": o.refers_to_factor,
261
+ "justification": o.justification,
262
+ }
263
+ if o.offsetting_evidence:
264
+ payload["offsettingEvidence"] = o.offsetting_evidence
265
+ return payload
266
+
267
+
268
+ def _disposition_to_jsonld(d: DispositionNode) -> dict:
269
+ payload: dict[str, Any] = {
270
+ "@type": "Disposition",
271
+ "id": d.id_,
272
+ "actionClass": d.action_class,
273
+ }
274
+ if d.action_parameters:
275
+ payload["actionParameters"] = d.action_parameters
276
+ if d.confidence_level is not None:
277
+ payload["confidenceLevel"] = d.confidence_level
278
+ if d.residual_risk_justification:
279
+ payload["residualRiskJustification"] = d.residual_risk_justification
280
+ return payload
281
+
282
+
283
+ # ── Phase-1 → v0.6 mapper ───────────────────────────────────────────────────
284
+
285
+ # Match a closure_id inside a rationale string. ValidityRangeDistanceDetector
286
+ # rationales surface 'gnielinski-1976' / 'modified-sparrow-cur-...' in single
287
+ # quotes; pull them out to populate factorStandard.
288
+ _CLOSURE_ID_RE = re.compile(r"'([a-z][a-z0-9-]+(?:-[a-z0-9]+)*-\d{4})'")
289
+
290
+
291
+ def _factor_standard_from_rationale(result: DetectorResult) -> str:
292
+ """Extract a literature-standard string from a DetectorResult rationale.
293
+ Today only the closure_validity rationale carries a closure_id; other
294
+ detectors leave the field blank.
295
+ """
296
+ if result.detector_name != "closure_validity":
297
+ return ""
298
+ match = _CLOSURE_ID_RE.search(result.rationale)
299
+ return match.group(1) if match else ""
300
+
301
+
302
+ def _default_action_class_for_verdict(verdict: str,
303
+ weakeners: list[WeakenerAnnotationNode],
304
+ ) -> tuple[str, str]:
305
+ """Phase-2A default action-class chooser. The Phase 2C defeasible
306
+ aggregator replaces this with proper offset/agreement/threshold-distance
307
+ reasoning. Defaults:
308
+ verdict='quiet' AND no weakeners -> accept-residual-risk
309
+ verdict='fire' AND >=1 weakener -> characterize-region
310
+
311
+ Returns (action_class, residual_risk_justification).
312
+ """
313
+ if verdict == "quiet":
314
+ return ("accept-residual-risk",
315
+ "No decision-signal weakeners fired; accepting with residual risk.")
316
+ # verdict == "fire"
317
+ weakener_patterns = sorted({w.pattern_id for w in weakeners})
318
+ return ("characterize-region",
319
+ f"Weakeners fired on patterns {weakener_patterns}; downstream "
320
+ f"reasoning must characterize the affected region before deploying. "
321
+ f"(Phase-2A default; Phase-2C adjudicator may upgrade/downgrade.)")
322
+
323
+
324
+ def _op_to_id_suffix(operating_point: tuple) -> str:
325
+ """Make a stable id-safe slug from an operating-point tuple. Avoids
326
+ collisions across vehicles by including the structure verbatim."""
327
+ parts = ["%g" % float(p) if isinstance(p, (int, float)) else str(p)
328
+ for p in operating_point]
329
+ raw = "x".join(parts)
330
+ return re.sub(r"[^A-Za-z0-9._-]", "_", raw)
331
+
332
+
333
+ def assessment_to_v06_subgraph(
334
+ phase1: Assessment,
335
+ *,
336
+ surrogate_prediction: float,
337
+ solver_truth: float,
338
+ point_id_prefix: str = "p1pt",
339
+ discrepancy_region: str = "",
340
+ measure_type: str = "point",
341
+ confidence_level: str | None = None,
342
+ action_class_chooser=None,
343
+ adjudicator=None,
344
+ ) -> V06AssessmentSubgraph:
345
+ """Map a Phase-1 Assessment + the underlying surrogate/truth pair into a
346
+ v0.6-conformant subgraph.
347
+
348
+ Args:
349
+ phase1: the Phase-1 Assessment from Pipeline.run / phase1_gate.
350
+ surrogate_prediction: scalar — the row's surrogate_prediction (often
351
+ the matched-closure prediction the engine populated).
352
+ solver_truth: scalar — the row's cfd_truth (measured Nu).
353
+ point_id_prefix: id prefix for the generated nodes (so multiple
354
+ subgraphs can coexist in one document without colliding).
355
+ discrepancy_region: free-text region descriptor surfaced into
356
+ Discrepancy.discrepancyRegion (e.g., "x_over_D=2.5, Re=55570").
357
+ measure_type: what the discrepancy is in (default "Nu").
358
+ confidence_level: optional override for Disposition.confidenceLevel.
359
+ action_class_chooser: optional callable
360
+ (verdict, weakeners) -> (action_class, residual_risk_justification).
361
+ Defaults to `_default_action_class_for_verdict`. The defeasible
362
+ adjudicator (Phase 2C) plugs in here.
363
+
364
+ Returns:
365
+ A V06AssessmentSubgraph carrying the typed nodes. Serialize to
366
+ JSON-LD via `.to_jsonld()`.
367
+ """
368
+ suffix = _op_to_id_suffix(phase1.operating_point)
369
+ base = f"{point_id_prefix}/{suffix}"
370
+
371
+ discrepancy = DiscrepancyNode(
372
+ id_=f"discrepancy:{base}",
373
+ surrogate_prediction=float(surrogate_prediction),
374
+ solver_truth=float(solver_truth),
375
+ discrepancy_magnitude=abs(float(surrogate_prediction) - float(solver_truth)),
376
+ discrepancy_region=discrepancy_region,
377
+ measure_type=measure_type,
378
+ )
379
+
380
+ factors: list[CredibilityFactorNode] = []
381
+ weakeners: list[WeakenerAnnotationNode] = []
382
+ for name, det_result in phase1.decision_signals.items():
383
+ f = CredibilityFactorNode(
384
+ id_=f"factor:{base}/{name}",
385
+ factor_type=_FACTOR_TYPE_BY_DETECTOR.get(name, name),
386
+ factor_status="fired" if det_result.fired else "quiet",
387
+ factor_standard=_factor_standard_from_rationale(det_result),
388
+ )
389
+ factors.append(f)
390
+ if det_result.fired:
391
+ weakeners.append(WeakenerAnnotationNode(
392
+ id_=f"weakener:{base}/{name}",
393
+ pattern_id=_PATTERN_ID_BY_DETECTOR.get(name, name),
394
+ affected_node=discrepancy.id_,
395
+ justification=det_result.rationale,
396
+ # The DetectorResult doesn't carry a separate "severity"
397
+ # term; leave None so SHACL/audit can see absence.
398
+ severity=None,
399
+ ))
400
+
401
+ # Adjudicator overrides chooser when set (Phase-2C path). Otherwise
402
+ # fall through to the Phase-2A default chooser.
403
+ offset_rationales: list[OffsetRationaleNode] = []
404
+ if adjudicator is not None:
405
+ result = adjudicator.adjudicate(
406
+ weakeners=weakeners,
407
+ decision_signals=phase1.decision_signals,
408
+ discrepancy_id=discrepancy.id_,
409
+ )
410
+ action_class = result.action_class
411
+ residual_just = result.residual_risk_justification
412
+ offset_rationales = list(result.offset_rationales)
413
+ else:
414
+ chooser = action_class_chooser or _default_action_class_for_verdict
415
+ action_class, residual_just = chooser(phase1.verdict, weakeners)
416
+
417
+ disposition = DispositionNode(
418
+ id_=f"disposition:{base}",
419
+ action_class=action_class,
420
+ action_parameters=phase1.rationale,
421
+ confidence_level=confidence_level,
422
+ residual_risk_justification=(
423
+ residual_just if action_class == "accept-residual-risk" else ""
424
+ ),
425
+ )
426
+
427
+ return V06AssessmentSubgraph(
428
+ discrepancy=discrepancy,
429
+ credibility_factors=factors,
430
+ weakener_annotations=weakeners,
431
+ offset_rationales=offset_rationales,
432
+ disposition=disposition,
433
+ operating_point=phase1.operating_point,
434
+ )
435
+
436
+
437
+ def assessments_to_v06_document(
438
+ phase1_assessments: Sequence[Assessment],
439
+ *,
440
+ surrogates: Sequence[float],
441
+ truths: Sequence[float],
442
+ regions: Sequence[str] | None = None,
443
+ point_id_prefix: str = "naca-p1pt",
444
+ measure_type: str = "point",
445
+ action_class_chooser=None,
446
+ adjudicator=None,
447
+ ) -> dict:
448
+ """Map a sequence of Phase-1 Assessments into a single v0.6 JSON-LD
449
+ document with one shared @context and a flat @graph of all nodes
450
+ across all points. The id_prefix keeps points cleanly addressable.
451
+
452
+ Args:
453
+ phase1_assessments: list of Phase-1 Assessments (e.g. from phase1_gate).
454
+ surrogates, truths: parallel arrays of per-point surrogate/truth pairs.
455
+ regions: optional parallel array of free-text region descriptors.
456
+ point_id_prefix: id prefix used per subgraph.
457
+ action_class_chooser: defeasible adjudicator (Phase 2C). If None,
458
+ defaults to the Phase-2A heuristic.
459
+ """
460
+ n = len(phase1_assessments)
461
+ if len(surrogates) != n or len(truths) != n:
462
+ raise ValueError(
463
+ f"surrogates ({len(surrogates)}) and truths ({len(truths)}) must "
464
+ f"match the assessment count ({n})."
465
+ )
466
+ if regions is None:
467
+ regions = [""] * n
468
+ elif len(regions) != n:
469
+ raise ValueError(
470
+ f"regions ({len(regions)}) must match assessment count ({n})."
471
+ )
472
+
473
+ graph: list[dict] = []
474
+ for i, assess in enumerate(phase1_assessments):
475
+ sub = assessment_to_v06_subgraph(
476
+ assess,
477
+ surrogate_prediction=float(surrogates[i]),
478
+ solver_truth=float(truths[i]),
479
+ point_id_prefix=point_id_prefix,
480
+ discrepancy_region=regions[i],
481
+ measure_type=measure_type,
482
+ action_class_chooser=action_class_chooser,
483
+ adjudicator=adjudicator,
484
+ )
485
+ graph.append(_discrepancy_to_jsonld(sub.discrepancy))
486
+ for f in sub.credibility_factors:
487
+ graph.append(_credibility_factor_to_jsonld(f))
488
+ for w in sub.weakener_annotations:
489
+ graph.append(_weakener_annotation_to_jsonld(w))
490
+ for o in sub.offset_rationales:
491
+ graph.append(_offset_rationale_to_jsonld(o))
492
+ graph.append(_disposition_to_jsonld(sub.disposition))
493
+
494
+ return {"@context": V06_CONTEXT_REF, "@graph": graph}
495
+
496
+
497
+ __all__ = [
498
+ "DISPOSITION_ACTION_CLASSES",
499
+ "DISCREPANCY_MEASURE_TYPES",
500
+ "V06_CONTEXT_REF",
501
+ "DiscrepancyNode",
502
+ "CredibilityFactorNode",
503
+ "WeakenerAnnotationNode",
504
+ "OffsetRationaleNode",
505
+ "DispositionNode",
506
+ "V06AssessmentSubgraph",
507
+ "assessment_to_v06_subgraph",
508
+ "assessments_to_v06_document",
509
+ ]