ashu1069 commited on
Commit
cc060d7
·
1 Parent(s): dabb502

ui: pipeline trace UI + verifier panel + insights banner

Browse files

The Space now surfaces every MIE layer's intermediate state:
- Insights banner: punchy headline (CO2e avoided, guardrail fires, hazards)
- Layer A validator (D012): JSON shape + enum
- Layer B calibration (D015): raw vs calibrated table
- Layer C hazard auto-flagger (D019): before/added/after diff
- Layer D do_not guardrail (D018): proposed vs safe-default
- Verifier panel: independent attestation via matter.verifier
- Passport JSON

Backed by engine.infer_with_trace() which captures per-layer state
without changing infer()'s API. Demo mode shows passport + verifier
(no trace, since model didn't run); Live mode shows the full trace.

Files changed (3) hide show
  1. app.py +267 -41
  2. matter/engine.py +138 -0
  3. matter/verifier.py +341 -0
app.py CHANGED
@@ -18,8 +18,11 @@ import gradio as gr
18
 
19
  from matter.engine import MIE, CaptureInput, MIEError
20
  from matter.heads import HEADS
 
21
  from transformers_runtime import TransformersRuntime
22
 
 
 
23
  ROOT = Path(__file__).parent
24
  EXAMPLES_DIR = ROOT / "examples"
25
  SPEC_EXAMPLES = ROOT / "spec" / "examples"
@@ -55,27 +58,95 @@ def get_engine() -> MIE:
55
  return MIE(runtime=_runtime, on_device=True)
56
 
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  def render_summary(p: dict) -> str:
 
59
  ident = p.get("identity", {})
60
  state = p.get("state", {})
61
  nba = p.get("next_best_action", {})
62
  routing = p.get("routing", {})
63
  prov = p.get("provenance", {})
64
- val = (p.get("value") or {}).get("environmental") or {}
65
 
66
  hazards = state.get("hazard_flags") or []
67
  do_not = nba.get("do_not") or []
68
 
69
  badge = "🟢 clear"
70
  if nba.get("fallback_used"):
71
- badge = "🟡 guardrail fired — safe default applied"
72
  if any(h in {"biohazard", "sharps_injury_risk", "thermal_runaway_risk"} for h in hazards):
73
  badge = "🔴 hazard"
74
 
75
- lines = [
76
- f"### {ident.get('class', '?')} · _{ident.get('subclass', '')}_",
77
  "",
78
- f"**Status** · {badge}",
79
  "",
80
  "| | |",
81
  "|---|---|",
@@ -84,51 +155,152 @@ def render_summary(p: dict) -> str:
84
  f"| **Do not** | "
85
  + (", ".join(f"`{x}`" for x in do_not) if do_not else "_none_") + " |",
86
  f"| **Confidence** | `{ident.get('confidence', 0):.3f}` "
87
- + ("(calibrated)" if prov.get("confidence_calibrated") else "(raw)") + " |",
88
  f"| **Hazards** | "
89
  + (", ".join(f"`{h}`" for h in hazards) if hazards else "_none_") + " |",
90
  f"| **Condition** | `{state.get('condition', '?')}` |",
91
  f"| **Jurisdiction** | {routing.get('jurisdiction', '?')} |",
 
92
  ]
93
- if val.get("co2e_avoided_kg") is not None:
94
- lines.append(f"| **CO₂e avoided** | `{val['co2e_avoided_kg']} kg` |")
95
- lines += [
96
- f"| **Model** | `{prov.get('model', '?')}` ({prov.get('runtime', '?')}) |",
97
- f"| **On-device** | {'✅' if prov.get('on_device') else '—'} |",
98
- ]
99
- return "\n".join(lines)
 
 
 
 
 
 
 
 
 
100
 
101
 
102
- def render_pipeline(p: dict) -> str:
103
- nba = p.get("next_best_action", {})
104
- state = p.get("state", {})
105
- fallback = nba.get("fallback_used", False)
106
- hazards = state.get("hazard_flags") or []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  return "\n".join([
108
- "**MIE pipeline**",
109
  "",
110
- "| Step | Status |",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  "|---|---|",
112
- "| 01 · Validator | ✅ JSON shape + taxonomy enum |",
113
- "| 02 · Calibration | histogram-calibrated |",
114
- "| 03 · Hazard auto-flagger | "
115
- + (f"⚠️ flagged: {', '.join(hazards)}" if hazards else " no class-implied hazard") + " |",
116
- "| 04 · Guardrail | "
117
- + ("⚠️ fired unsafe action overridden" if fallback else "✅ action passed `do_not` rules") + " |",
118
  ])
119
 
120
 
121
- def run_demo(head: str) -> tuple[str, str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  fname = DEMO_PASSPORTS.get(head, DEMO_PASSPORTS["domestic"])
123
  p = json.loads((SPEC_EXAMPLES / fname).read_text())
124
- return render_summary(p), render_pipeline(p), json.dumps(p, indent=2)
 
 
 
 
 
 
 
 
 
 
125
 
126
 
127
- def run_live(image_path: str | None, head: str, jurisdiction: str) -> tuple[str, str, str]:
128
  if image_path is None:
129
  return (
130
- "⚠️ Upload an image first, or switch to **Demo** mode for the canonical example.",
131
- "", "",
132
  )
133
  try:
134
  engine = get_engine()
@@ -136,21 +308,30 @@ def run_live(image_path: str | None, head: str, jurisdiction: str) -> tuple[str,
136
  image_path=Path(image_path),
137
  jurisdiction=jurisdiction.strip() or None,
138
  )
139
- passport = engine.infer(capture, head)
140
  p = passport.to_dict()
141
- return render_summary(p), render_pipeline(p), json.dumps(p, indent=2)
 
 
 
 
 
 
 
 
 
 
142
  except MIEError as e:
143
  return (
144
- f"### ❌ MIE pipeline rejected the model output\n\n```\n{e}\n```\n\n"
145
- "_The model returned malformed or out-of-taxonomy JSON. Try a clearer image or switch to Demo mode._",
146
- "", "",
147
  )
148
  except Exception as e:
149
  return (
150
  f"### ❌ Runtime error\n\n```\n{e.__class__.__name__}: {e}\n```\n\n"
151
- "_If this is the first call after a cold start, the GPU worker is still loading Gemma 4 (≈30s). Try again in a moment, or use Demo mode._",
152
  f"<details><summary>traceback</summary>\n\n```\n{traceback.format_exc()}\n```\n</details>",
153
- "",
154
  )
155
 
156
 
@@ -519,6 +700,32 @@ html, body, gradio-app, .gradio-container {
519
  /* ===== Selection ===== */
520
  ::selection { background: rgba(0, 217, 126, 0.35); color: white; }
521
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
522
  /* ===== Catch-all readability ===== */
523
  .gradio-container { color: #f1faf4; }
524
  .gradio-container input::placeholder,
@@ -587,10 +794,28 @@ with gr.Blocks(title="Matter — Material Intelligence") as demo:
587
  )
588
 
589
  with gr.Column(scale=7):
 
 
 
 
 
590
  gr.Markdown("### Passport")
591
  summary_out = gr.Markdown(value="_Pick a mode and press_ **Generate Passport**.")
592
- pipeline_out = gr.Markdown()
593
- with gr.Accordion("Passport JSON", open=True):
 
 
 
 
 
 
 
 
 
 
 
 
 
594
  json_out = gr.Code(language="json", label=None, lines=22)
595
 
596
  gr.Markdown(
@@ -603,7 +828,8 @@ with gr.Blocks(title="Matter — Material Intelligence") as demo:
603
  run_btn.click(
604
  dispatch,
605
  inputs=[mode_in, image_in, head_in, juris_in],
606
- outputs=[summary_out, pipeline_out, json_out],
 
607
  )
608
 
609
 
 
18
 
19
  from matter.engine import MIE, CaptureInput, MIEError
20
  from matter.heads import HEADS
21
+ from matter.verifier import Verifier
22
  from transformers_runtime import TransformersRuntime
23
 
24
+ verifier = Verifier()
25
+
26
  ROOT = Path(__file__).parent
27
  EXAMPLES_DIR = ROOT / "examples"
28
  SPEC_EXAMPLES = ROOT / "spec" / "examples"
 
58
  return MIE(runtime=_runtime, on_device=True)
59
 
60
 
61
+ # =====================================================================
62
+ # Rendering helpers — every section returns a Markdown string.
63
+ # Outputs in order:
64
+ # insights, summary, raw, validator, calibration, hazards, guardrail,
65
+ # verifier, json
66
+ # =====================================================================
67
+
68
+ EMPTY_OUTPUT = ("",) * 9
69
+
70
+
71
+ def render_insights(p: dict, trace: dict | None) -> str:
72
+ """Top banner — the punchy 'value generated' headline.
73
+
74
+ Pulls 1–3 high-impact statements out of the Passport. Examples:
75
+ 🌱 0.0315 kg CO₂e avoided — routes to blue_bin_recycle
76
+ 🛡️ Guardrail caught a sharps misroute (CRITICAL)
77
+ ⚠️ Biohazard auto-flagged
78
+ """
79
+ lines: list[str] = []
80
+ nba = p.get("next_best_action", {})
81
+ state = p.get("state", {})
82
+ routing = p.get("routing", {})
83
+ val = (p.get("value") or {}).get("environmental") or {}
84
+ hazards = state.get("hazard_flags") or []
85
+
86
+ # Guardrail save (highest priority — safety win)
87
+ if nba.get("fallback_used") and trace and trace.get("guardrail", {}).get("fired"):
88
+ g = trace["guardrail"]
89
+ sev = (g.get("severity") or "").upper()
90
+ sev_emoji = "🚨" if sev == "CRITICAL" else "🛡️"
91
+ lines.append(
92
+ f"{sev_emoji} **Guardrail fired ({sev or 'high severity'})** — "
93
+ f"unsafe action `{g['proposed_action']}` overridden to `{g['safe_default']}`"
94
+ )
95
+
96
+ # Hazards detected
97
+ canonical = {"biohazard", "sharps_injury_risk", "thermal_runaway_risk",
98
+ "lead_toxicity", "acid_corrosion"}
99
+ flagged = [h for h in hazards if h in canonical]
100
+ if flagged:
101
+ lines.append(f"⚠️ **Hazards detected**: {', '.join(f'`{h}`' for h in flagged)}")
102
+
103
+ # Hazards added by auto-flagger (different from 'detected' — these are the ones the model missed)
104
+ if trace and trace.get("hazards", {}).get("added"):
105
+ lines.append(f"🔍 **Auto-flagger added** missing hazards: {', '.join(f'`{h}`' for h in trace['hazards']['added'])}")
106
+
107
+ # Environmental impact
108
+ if val.get("co2e_avoided_kg") is not None:
109
+ co2 = val["co2e_avoided_kg"]
110
+ lines.append(f"🌱 **{co2} kg CO₂e avoided** by routing to `{nba.get('primary', '?')}`")
111
+
112
+ # Routing / jurisdiction
113
+ if routing.get("jurisdiction"):
114
+ lines.append(f"📋 **Jurisdiction**: {routing['jurisdiction']}")
115
+
116
+ # Calibration shift (if Live)
117
+ if trace:
118
+ cr = trace["calibration"]["raw"]["identity"]
119
+ cc = trace["calibration"]["calibrated"]["identity"]
120
+ if abs(cr - cc) > 0.05:
121
+ arrow = "↓" if cc < cr else "↑"
122
+ lines.append(f"📊 **Confidence calibrated**: {cr:.2f} {arrow} {cc:.2f} (histogram-corrected)")
123
+
124
+ if not lines:
125
+ return ""
126
+ return "\n\n".join(lines)
127
+
128
+
129
  def render_summary(p: dict) -> str:
130
+ """The Passport headline card — always visible."""
131
  ident = p.get("identity", {})
132
  state = p.get("state", {})
133
  nba = p.get("next_best_action", {})
134
  routing = p.get("routing", {})
135
  prov = p.get("provenance", {})
 
136
 
137
  hazards = state.get("hazard_flags") or []
138
  do_not = nba.get("do_not") or []
139
 
140
  badge = "🟢 clear"
141
  if nba.get("fallback_used"):
142
+ badge = "🟡 guardrail fired"
143
  if any(h in {"biohazard", "sharps_injury_risk", "thermal_runaway_risk"} for h in hazards):
144
  badge = "🔴 hazard"
145
 
146
+ rows = [
147
+ f"### {ident.get('class', '?')} · _{ident.get('subclass') or ''}_",
148
  "",
149
+ f"**Status** {badge}",
150
  "",
151
  "| | |",
152
  "|---|---|",
 
155
  f"| **Do not** | "
156
  + (", ".join(f"`{x}`" for x in do_not) if do_not else "_none_") + " |",
157
  f"| **Confidence** | `{ident.get('confidence', 0):.3f}` "
158
+ + ("calibrated" if prov.get("confidence_calibrated") else "raw") + " |",
159
  f"| **Hazards** | "
160
  + (", ".join(f"`{h}`" for h in hazards) if hazards else "_none_") + " |",
161
  f"| **Condition** | `{state.get('condition', '?')}` |",
162
  f"| **Jurisdiction** | {routing.get('jurisdiction', '?')} |",
163
+ f"| **Model** | `{prov.get('model', '?')}` |",
164
  ]
165
+ return "\n".join(rows)
166
+
167
+
168
+ def render_raw(trace: dict | None) -> str:
169
+ """Step 1 what Gemma actually emitted."""
170
+ if not trace:
171
+ return "_Live mode only — Demo mode shows a pre-computed Passport, no model output._"
172
+ raw = trace["raw_output"]
173
+ parsed = trace["parsed"]
174
+ pretty = json.dumps(parsed, indent=2)
175
+ return (
176
+ "**Gemma 4's raw output (post `parse_response`):**\n\n"
177
+ f"```\n{raw[:600] if len(raw) > 600 else raw}\n```\n\n"
178
+ "**Parsed by the validator (D012):**\n\n"
179
+ f"```json\n{pretty}\n```"
180
+ )
181
 
182
 
183
+ def render_validator(trace: dict | None) -> str:
184
+ if not trace:
185
+ return ""
186
+ v = trace["validators"]
187
+ return "\n".join([
188
+ "**Layer A — JSON validator (D012)**",
189
+ "",
190
+ "| Check | Result |",
191
+ "|---|---|",
192
+ f"| JSON shape | {'✅ valid' if v['json_ok'] else '❌ malformed'} |",
193
+ f"| Taxonomy enum | {'✅ in domestic' if v['enum_ok'] else '❌ out of taxonomy'} |",
194
+ ])
195
+
196
+
197
+ def render_calibration(trace: dict | None) -> str:
198
+ if not trace:
199
+ return ""
200
+ c = trace["calibration"]
201
+ raw = c["raw"]
202
+ cal = c["calibrated"]
203
+ arrow = lambda r, k: "→" if abs(r - k) > 0.001 else "="
204
  return "\n".join([
205
+ f"**Layer B — Confidence calibration (D015 · `{c['method']}`)**",
206
  "",
207
+ "| Block | Raw | | Calibrated | Δ |",
208
+ "|---|---:|:---:|---:|---:|",
209
+ f"| identity | `{raw['identity']:.3f}` | {arrow(raw['identity'], cal['identity'])} | `{cal['identity']:.3f}` | `{cal['identity'] - raw['identity']:+.3f}` |",
210
+ f"| state | `{raw['state']:.3f}` | {arrow(raw['state'], cal['state'])} | `{cal['state']:.3f}` | `{cal['state'] - raw['state']:+.3f}` |",
211
+ f"| nba | `{raw['nba']:.3f}` | {arrow(raw['nba'], cal['nba'])} | `{cal['nba']:.3f}` | `{cal['nba'] - raw['nba']:+.3f}` |",
212
+ ])
213
+
214
+
215
+ def render_hazards(trace: dict | None) -> str:
216
+ if not trace:
217
+ return ""
218
+ h = trace["hazards"]
219
+ return "\n".join([
220
+ "**Layer C — Hazard auto-flagger (D019)**",
221
+ "",
222
+ "| | |",
223
  "|---|---|",
224
+ f"| Model said | "
225
+ + (", ".join(f"`{x}`" for x in h["before"]) if h["before"] else "_none_") + " |",
226
+ f"| Auto-flagger added | "
227
+ + (", ".join(f"`{x}`" for x in h["added"]) if h["added"] else "_none_") + " |",
228
+ f"| Final hazard set | "
229
+ + (", ".join(f"`{x}`" for x in h["after"]) if h["after"] else "_none_") + " |",
230
  ])
231
 
232
 
233
+ def render_guardrail(trace: dict | None) -> str:
234
+ if not trace:
235
+ return ""
236
+ g = trace["guardrail"]
237
+ if not g["fired"]:
238
+ return "\n".join([
239
+ "**Layer D — `do_not` guardrail (D018)**",
240
+ "",
241
+ f"✅ Proposed action `{g['proposed_action']}` passed all `do_not` rules.",
242
+ ])
243
+ return "\n".join([
244
+ "**Layer D — `do_not` guardrail (D018)**",
245
+ "",
246
+ f"⚠️ **Guardrail fired** — severity: `{g['severity']}`",
247
+ "",
248
+ "| | |",
249
+ "|---|---|",
250
+ f"| Triggered class | `{g['triggered_class']}` |",
251
+ f"| Model proposed | `{g['proposed_action']}` |",
252
+ f"| Safe default applied | `{g['safe_default']}` |",
253
+ ])
254
+
255
+
256
+ def render_verifier(p: dict, head: str) -> str:
257
+ """Run the Passport back through the verifier — independent attestation that
258
+ every layer's contract held."""
259
+ raw = json.dumps({
260
+ "identity": p.get("identity", {}),
261
+ "state": p.get("state", {}),
262
+ "next_best_action": p.get("next_best_action", {}),
263
+ })
264
+ score = verifier.score(raw, head, ground_truth=None)
265
+ rows = [
266
+ "**Verifier scoring** (`matter.verifier.Verifier`)",
267
+ "",
268
+ "| Component | Score | Status |",
269
+ "|---|---:|:---:|",
270
+ f"| `json_valid` | `{score.json_valid:.0f}` | {'✅' if score.json_valid else '❌'} |",
271
+ f"| `enum_valid` | `{score.enum_valid:.0f}` | {'✅' if score.enum_valid else '❌'} |",
272
+ f"| `do_not_compliance` | `{score.do_not_compliance:.0f}` | {'✅' if score.do_not_compliance else '❌'} |",
273
+ f"| `hazard_completeness` | `{score.hazard_completeness:.0f}` | {'✅' if score.hazard_completeness else '❌'} |",
274
+ f"| **Structural total** | **`{score.structural:.3f}`** | {'✅' if score.structural >= 0.99 else '⚠️'} |",
275
+ ]
276
+ return "\n".join(rows)
277
+
278
+
279
+ # =====================================================================
280
+ # Run handlers
281
+ # =====================================================================
282
+
283
+ def run_demo(head: str) -> tuple:
284
  fname = DEMO_PASSPORTS.get(head, DEMO_PASSPORTS["domestic"])
285
  p = json.loads((SPEC_EXAMPLES / fname).read_text())
286
+ return (
287
+ render_insights(p, trace=None),
288
+ render_summary(p),
289
+ render_raw(trace=None),
290
+ render_validator(trace=None),
291
+ render_calibration(trace=None),
292
+ render_hazards(trace=None),
293
+ render_guardrail(trace=None),
294
+ render_verifier(p, head),
295
+ json.dumps(p, indent=2),
296
+ )
297
 
298
 
299
+ def run_live(image_path: str | None, head: str, jurisdiction: str) -> tuple:
300
  if image_path is None:
301
  return (
302
+ "⚠️ Upload an image first, or switch to **Demo** mode.",
303
+ "_no Passport yet_", "", "", "", "", "", "", "",
304
  )
305
  try:
306
  engine = get_engine()
 
308
  image_path=Path(image_path),
309
  jurisdiction=jurisdiction.strip() or None,
310
  )
311
+ passport, trace = engine.infer_with_trace(capture, head)
312
  p = passport.to_dict()
313
+ return (
314
+ render_insights(p, trace),
315
+ render_summary(p),
316
+ render_raw(trace),
317
+ render_validator(trace),
318
+ render_calibration(trace),
319
+ render_hazards(trace),
320
+ render_guardrail(trace),
321
+ render_verifier(p, head),
322
+ json.dumps(p, indent=2),
323
+ )
324
  except MIEError as e:
325
  return (
326
+ f"### ❌ MIE rejected the model output\n\n```\n{e}\n```",
327
+ "_pipeline halted_", "", "", "", "", "", "", "",
 
328
  )
329
  except Exception as e:
330
  return (
331
  f"### ❌ Runtime error\n\n```\n{e.__class__.__name__}: {e}\n```\n\n"
332
+ "_If this is the first call after a cold start, the GPU worker is still loading Gemma 4 (≈30s). Try again or use Demo mode._",
333
  f"<details><summary>traceback</summary>\n\n```\n{traceback.format_exc()}\n```\n</details>",
334
+ "", "", "", "", "", "", "",
335
  )
336
 
337
 
 
700
  /* ===== Selection ===== */
701
  ::selection { background: rgba(0, 217, 126, 0.35); color: white; }
702
 
703
+ /* ===== Insights banner — value-generated headline ===== */
704
+ #insights-banner {
705
+ background: linear-gradient(135deg, rgba(0, 217, 126, 0.10), rgba(0, 229, 255, 0.05)) !important;
706
+ border: 1px solid rgba(0, 217, 126, 0.32) !important;
707
+ border-radius: 14px !important;
708
+ padding: 18px 22px !important;
709
+ margin-bottom: 18px !important;
710
+ box-shadow: 0 0 24px rgba(0, 217, 126, 0.10), inset 0 1px 0 rgba(255, 255, 255, 0.04);
711
+ }
712
+ #insights-banner p, #insights-banner strong {
713
+ color: #f1faf4 !important;
714
+ font-size: 0.96rem !important;
715
+ line-height: 1.55 !important;
716
+ margin: 4px 0 !important;
717
+ }
718
+ #insights-banner code {
719
+ background: rgba(0, 217, 126, 0.12) !important;
720
+ color: #00ff8c !important;
721
+ border: 1px solid rgba(0, 217, 126, 0.28) !important;
722
+ font-weight: 500;
723
+ }
724
+ #insights-banner:has(p:empty),
725
+ #insights-banner:not(:has(*)) {
726
+ display: none;
727
+ }
728
+
729
  /* ===== Catch-all readability ===== */
730
  .gradio-container { color: #f1faf4; }
731
  .gradio-container input::placeholder,
 
794
  )
795
 
796
  with gr.Column(scale=7):
797
+ # Insights banner — punchy 1-3 line summary of the value generated
798
+ insights_out = gr.Markdown(
799
+ value="",
800
+ elem_id="insights-banner",
801
+ )
802
  gr.Markdown("### Passport")
803
  summary_out = gr.Markdown(value="_Pick a mode and press_ **Generate Passport**.")
804
+
805
+ # Step-by-step trace — one accordion per pipeline layer
806
+ with gr.Accordion("🔍 Gemma 4's raw output (pre-pipeline)", open=False):
807
+ raw_out = gr.Markdown()
808
+ with gr.Accordion("✅ Layer A — JSON validator (D012)", open=False):
809
+ validator_out = gr.Markdown()
810
+ with gr.Accordion("📐 Layer B — Confidence calibration (D015)", open=False):
811
+ calibration_out = gr.Markdown()
812
+ with gr.Accordion("⚠️ Layer C — Hazard auto-flagger (D019)", open=False):
813
+ hazards_out = gr.Markdown()
814
+ with gr.Accordion("🛡️ Layer D — `do_not` guardrail (D018)", open=False):
815
+ guardrail_out = gr.Markdown()
816
+ with gr.Accordion("🧪 Verifier — independent attestation", open=False):
817
+ verifier_out = gr.Markdown()
818
+ with gr.Accordion("📋 Passport JSON", open=False):
819
  json_out = gr.Code(language="json", label=None, lines=22)
820
 
821
  gr.Markdown(
 
828
  run_btn.click(
829
  dispatch,
830
  inputs=[mode_in, image_in, head_in, juris_in],
831
+ outputs=[insights_out, summary_out, raw_out, validator_out, calibration_out,
832
+ hazards_out, guardrail_out, verifier_out, json_out],
833
  )
834
 
835
 
matter/engine.py CHANGED
@@ -201,6 +201,144 @@ class MIE:
201
  # 7. Final Pydantic validation against the v0.1 schema
202
  return Passport.model_validate(draft)
203
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
  __all__ = ["MIE", "MIEError", "Runtime", "CaptureInput", "Capture", "Identity",
206
  "State", "NextBestAction", "Provenance", "Routing", "Passport"]
 
201
  # 7. Final Pydantic validation against the v0.1 schema
202
  return Passport.model_validate(draft)
203
 
204
+ def infer_with_trace(
205
+ self, capture: CaptureInput, head_name: str
206
+ ) -> tuple[Passport, dict]:
207
+ """Run the pipeline and capture every layer's intermediate state.
208
+
209
+ Returns (passport, trace) where trace is a JSON-serializable dict with:
210
+ - raw_output: Gemma's raw response text
211
+ - parsed: JSON-parsed model output (pre-pipeline)
212
+ - validators: {json_ok, enum_ok}
213
+ - calibration: {raw, calibrated, method} (per-block confidences)
214
+ - hazards: {before, after, added}
215
+ - guardrail: {proposed_action, fired, safe_default,
216
+ triggered_class, severity}
217
+ - metadata: {head, jurisdiction, runtime, model_id}
218
+
219
+ Used by the demo UI to render a step-by-step pipeline view. The Passport
220
+ return value is identical to what `infer()` would have produced.
221
+ """
222
+ if head_name not in HEADS:
223
+ raise MIEError(f"unknown head: {head_name}. Heads: {list(HEADS)}")
224
+ head = HEADS[head_name]
225
+ jurisdiction = capture.jurisdiction or head.default_jurisdiction
226
+ prompt = build_prompt(head_name, jurisdiction)
227
+
228
+ # 1. Runtime
229
+ raw = self.runtime.infer(prompt, capture.image_path)
230
+
231
+ # 2. Validator
232
+ parsed = _parse_json_block(raw)
233
+ _validate_enum(parsed, head_name)
234
+ ident = parsed["identity"]
235
+ st = parsed.get("state", {})
236
+ nba = parsed["next_best_action"]
237
+
238
+ confidences_raw = {
239
+ "identity": float(ident.get("confidence", 0.0)),
240
+ "state": float(st.get("confidence", 0.0)),
241
+ "nba": float(nba.get("confidence", 0.0)),
242
+ }
243
+
244
+ # 3. Calibration
245
+ ident_conf_cal = _calibrate(confidences_raw["identity"], self.calib)
246
+ state_conf_cal = _calibrate(confidences_raw["state"], self.calib)
247
+ nba_conf_cal = _calibrate(confidences_raw["nba"], self.calib)
248
+ confidences_cal = {
249
+ "identity": ident_conf_cal,
250
+ "state": state_conf_cal,
251
+ "nba": nba_conf_cal,
252
+ }
253
+
254
+ # 4. Build draft
255
+ modality, content_hash = _content_hash(capture)
256
+ ts = now_utc()
257
+ passport_id = make_passport_id(content_hash, ident["class"], ts)
258
+ draft: dict = {
259
+ "schema": "matter-passport/v0.1",
260
+ "passport_id": passport_id,
261
+ "prev": None,
262
+ "timestamp": ts,
263
+ "capture": {
264
+ "modality": modality,
265
+ "content_hash": content_hash,
266
+ **({"geohash_coarse": capture.geohash_coarse} if capture.geohash_coarse else {}),
267
+ },
268
+ "identity": {
269
+ "class": ident["class"],
270
+ "subclass": ident.get("subclass"),
271
+ "taxonomy": head.taxonomy_uri,
272
+ "confidence": ident_conf_cal,
273
+ },
274
+ "state": {
275
+ "condition": st.get("condition", "unknown"),
276
+ "hazard_flags": list(st.get("hazard_flags") or []),
277
+ "confidence": state_conf_cal,
278
+ },
279
+ "next_best_action": {
280
+ "primary": nba["primary"],
281
+ "secondary": nba.get("secondary"),
282
+ "do_not": list(nba.get("do_not") or []),
283
+ "confidence": nba_conf_cal,
284
+ "fallback_used": False,
285
+ },
286
+ "routing": {"jurisdiction": jurisdiction, "regulation_refs": []},
287
+ "provenance": {
288
+ "model": self.runtime.model_id,
289
+ "runtime": self.runtime.name,
290
+ "on_device": self.on_device,
291
+ "confidence_calibrated": True,
292
+ "calibration_ref": self._calibration_ref,
293
+ },
294
+ }
295
+
296
+ # 5. Hazard auto-flagger — capture before/after
297
+ hazards_before = list(draft["state"].get("hazard_flags") or [])
298
+ apply_hazard_flagger(draft, self.hazard_rules)
299
+ hazards_after = list(draft["state"].get("hazard_flags") or [])
300
+ hazards_added = [h for h in hazards_after if h not in hazards_before]
301
+
302
+ # 6. Guardrail — capture decision via GuardrailResult
303
+ proposed_action = draft["next_best_action"]["primary"]
304
+ g_result = apply_guardrail(draft, self.safety_rules)
305
+
306
+ # 7. Final Pydantic validation
307
+ passport = Passport.model_validate(draft)
308
+
309
+ trace = {
310
+ "raw_output": raw,
311
+ "parsed": parsed,
312
+ "validators": {"json_ok": True, "enum_ok": True},
313
+ "calibration": {
314
+ "raw": confidences_raw,
315
+ "calibrated": confidences_cal,
316
+ "method": self.calib.method,
317
+ "ref": self._calibration_ref,
318
+ },
319
+ "hazards": {
320
+ "before": hazards_before,
321
+ "after": hazards_after,
322
+ "added": hazards_added,
323
+ },
324
+ "guardrail": {
325
+ "proposed_action": proposed_action,
326
+ "fired": g_result.fallback_used,
327
+ "safe_default": (g_result.triggered_rule.safe_default
328
+ if g_result.fallback_used and g_result.triggered_rule else None),
329
+ "triggered_class": ident["class"] if g_result.fallback_used else None,
330
+ "severity": (g_result.triggered_rule.severity
331
+ if g_result.triggered_rule else None),
332
+ },
333
+ "metadata": {
334
+ "head": head_name,
335
+ "jurisdiction": jurisdiction,
336
+ "runtime": self.runtime.name,
337
+ "model_id": self.runtime.model_id,
338
+ },
339
+ }
340
+ return passport, trace
341
+
342
 
343
  __all__ = ["MIE", "MIEError", "Runtime", "CaptureInput", "Capture", "Identity",
344
  "State", "NextBestAction", "Provenance", "Routing", "Passport"]
matter/verifier.py ADDED
@@ -0,0 +1,341 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Matter Verifier — programmatic reward signal for Passport generation.
2
+
3
+ The MIE pipeline already encodes "what makes a Passport good": JSON shape,
4
+ taxonomy enums, hazard flags, do_not guardrails, calibrated confidence.
5
+ The Verifier turns that pipeline into a deterministic, per-example score
6
+ suitable for:
7
+
8
+ - Offline eval (compare model outputs head-to-head on a fixed test set)
9
+ - DPO/KTO data generation (rank N samples per prompt, build preference pairs)
10
+ - GRPO / RL with verifiable rewards (used directly as the reward function)
11
+ - Test-time scaling (best-of-N selection)
12
+
13
+ The verifier is split into two layers:
14
+
15
+ Structural (no ground truth needed) — usable for unsupervised RL:
16
+ - json_valid Did the output parse as a JSON object in the expected shape?
17
+ - enum_valid Are class and next_best_action.primary in the head's enums?
18
+ - do_not_compliance Is the proposed primary action NOT in the class's do_not set?
19
+ - hazard_completeness Are all class-required hazard flags present?
20
+
21
+ Semantic (needs a ground-truth Passport / dict) — used for eval and offline DPO:
22
+ - class_correct Does identity.class match GT?
23
+ - subclass_match Is subclass a string-match (exact or substring) of GT?
24
+ - nba_correct Does next_best_action.primary match GT?
25
+ - confidence_brier 1 - (predicted_confidence - is_correct)^2 — per-example
26
+ Brier-like calibration signal in [0, 1].
27
+
28
+ Aggregation:
29
+ total = (sum of w_i * score_i) / (sum of w_i scored)
30
+ Hard gate: if json_valid == 0, total = 0 (model emitted garbage; nothing else matters).
31
+
32
+ For RL, prefer `reward()` which returns a single scalar with sensible shaping
33
+ (garbage → low, structurally valid but wrong → mid, fully correct → high).
34
+ """
35
+
36
+ from __future__ import annotations
37
+
38
+ import json
39
+ import re
40
+ from dataclasses import asdict, dataclass, field
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from matter.guardrail import Rule, load_rules as load_safety_rules
45
+ from matter.hazard_flagger import HazardRule, load_hazard_rules
46
+ from matter.heads import HEADS
47
+
48
+
49
+ _SPEC_DIR = Path(__file__).resolve().parent.parent / "spec"
50
+ _SAFETY_PATH = _SPEC_DIR / "safety_rules_v1.json"
51
+ _HAZARD_PATH = _SPEC_DIR / "hazard_flags_v1.json"
52
+ _JSON_RE = re.compile(r"\{.*\}", re.DOTALL)
53
+
54
+
55
+ # Default reward weights. Tunable per experiment.
56
+ DEFAULT_WEIGHTS: dict[str, float] = {
57
+ # Structural — gates and shape (cheap, always scored)
58
+ "json_valid": 1.0,
59
+ "enum_valid": 1.0,
60
+ "do_not_compliance": 3.0,
61
+ "hazard_completeness": 2.0,
62
+ # Semantic — needs ground truth
63
+ "class_correct": 4.0,
64
+ "subclass_match": 1.0,
65
+ "nba_correct": 2.0,
66
+ "confidence_brier": 1.0,
67
+ }
68
+
69
+
70
+ @dataclass
71
+ class VerifierScore:
72
+ """Per-example scoring result.
73
+
74
+ Each component is in [0, 1] (or None when not scorable). `total` is the
75
+ weight-normalized aggregate over the components that were actually
76
+ scored. `parsed` carries the parsed dict on success, useful for callers
77
+ that want to inspect / cache.
78
+ """
79
+
80
+ # Structural
81
+ json_valid: float = 0.0
82
+ enum_valid: float = 0.0
83
+ do_not_compliance: float = 0.0
84
+ hazard_completeness: float = 0.0
85
+ # Semantic (None if no ground truth)
86
+ class_correct: float | None = None
87
+ subclass_match: float | None = None
88
+ nba_correct: float | None = None
89
+ confidence_brier: float | None = None
90
+ # Aggregates
91
+ structural: float = 0.0
92
+ semantic: float | None = None
93
+ total: float = 0.0
94
+ # Diagnostics
95
+ parsed: dict | None = None
96
+ parse_error: str | None = None
97
+ weights: dict[str, float] = field(default_factory=dict)
98
+
99
+ def to_dict(self) -> dict:
100
+ return asdict(self)
101
+
102
+
103
+ class Verifier:
104
+ """Score raw model outputs against the MIE specification.
105
+
106
+ Stateless across calls except for the loaded rule tables; safe to share
107
+ one instance across an entire training/eval run.
108
+ """
109
+
110
+ def __init__(
111
+ self,
112
+ weights: dict[str, float] | None = None,
113
+ safety_rules_path: Path = _SAFETY_PATH,
114
+ hazard_rules_path: Path = _HAZARD_PATH,
115
+ ):
116
+ self.weights = dict(weights or DEFAULT_WEIGHTS)
117
+ self.safety_rules: dict[str, Rule] = load_safety_rules(safety_rules_path)
118
+ self.hazard_rules: dict[str, HazardRule] = load_hazard_rules(hazard_rules_path)
119
+
120
+ # ----------------------------- public API -----------------------------
121
+
122
+ def score(
123
+ self,
124
+ raw: str,
125
+ head_name: str,
126
+ ground_truth: dict | None = None,
127
+ ) -> VerifierScore:
128
+ """Score a raw model output for a given head, optionally against GT.
129
+
130
+ Args:
131
+ raw: the model's untrimmed output text. May contain prose around
132
+ the JSON; we extract the first {...} block.
133
+ head_name: which head's enums + jurisdiction apply (e.g. "domestic").
134
+ ground_truth: optional dict shaped like a model output OR a full
135
+ Passport. We look at identity.class / .subclass and
136
+ next_best_action.primary.
137
+
138
+ Returns:
139
+ VerifierScore with per-component scores and a total in [0, 1].
140
+ """
141
+ if head_name not in HEADS:
142
+ raise ValueError(f"Unknown head: {head_name!r}. Known: {list(HEADS)}")
143
+ head = HEADS[head_name]
144
+ s = VerifierScore(weights=dict(self.weights))
145
+
146
+ # 1. JSON parse
147
+ parsed, err = _parse_json(raw)
148
+ if parsed is None:
149
+ s.parse_error = err
150
+ self._aggregate(s, has_gt=ground_truth is not None)
151
+ return s
152
+ s.json_valid = 1.0
153
+ s.parsed = parsed
154
+
155
+ # 2. Enum validity
156
+ cls = _get(parsed, "identity", "class") or parsed.get("class")
157
+ nba_primary = _get(parsed, "next_best_action", "primary")
158
+ s.enum_valid = float(
159
+ cls in head.identity_classes and (nba_primary is None or nba_primary in head.nba_classes)
160
+ )
161
+
162
+ # 3. do_not compliance — given the predicted class, is the proposed
163
+ # primary action allowed by the safety rules? If the class isn't in
164
+ # the rule table, it has no constraints and trivially complies.
165
+ s.do_not_compliance = self._score_do_not(cls, nba_primary)
166
+
167
+ # 4. Hazard completeness — predicted hazard_flags include all the
168
+ # canonical class-implied hazards. If the class has no required
169
+ # hazards, this trivially passes (1.0).
170
+ predicted_hazards = (
171
+ _get(parsed, "state", "hazard_flags") or parsed.get("hazard_flags") or []
172
+ )
173
+ s.hazard_completeness = self._score_hazards(cls, predicted_hazards)
174
+
175
+ # 5. Semantic — only if GT supplied
176
+ if ground_truth is not None:
177
+ gt_cls = _get(ground_truth, "identity", "class") or ground_truth.get("class")
178
+ gt_subclass = _get(ground_truth, "identity", "subclass") or ground_truth.get("subclass")
179
+ gt_nba = _get(ground_truth, "next_best_action", "primary") or ground_truth.get("primary")
180
+ pred_subclass = _get(parsed, "identity", "subclass") or parsed.get("subclass")
181
+ pred_conf = _confidence(parsed)
182
+
183
+ s.class_correct = float(cls is not None and cls == gt_cls)
184
+ s.subclass_match = _string_match(pred_subclass, gt_subclass)
185
+ s.nba_correct = (
186
+ float(nba_primary == gt_nba) if gt_nba is not None and nba_primary is not None else None
187
+ )
188
+ s.confidence_brier = (
189
+ _brier(pred_conf, s.class_correct) if pred_conf is not None else None
190
+ )
191
+
192
+ self._aggregate(s, has_gt=ground_truth is not None)
193
+ return s
194
+
195
+ def reward(
196
+ self,
197
+ raw: str,
198
+ head_name: str,
199
+ ground_truth: dict | None = None,
200
+ ) -> float:
201
+ """Single-scalar reward in [-1, 1] suitable for RL.
202
+
203
+ Shaping:
204
+ - garbage (json_valid == 0): -1.0 (strong signal: don't emit non-JSON)
205
+ - parsed but otherwise zero: 0.0
206
+ - perfect: 1.0
207
+
208
+ For unsupervised RL (no GT), structural score alone determines reward.
209
+ """
210
+ s = self.score(raw, head_name, ground_truth)
211
+ if s.json_valid == 0:
212
+ return -1.0
213
+ return s.total
214
+
215
+ # --------------------------- internals --------------------------------
216
+
217
+ def _score_do_not(self, cls: str | None, nba_primary: str | None) -> float:
218
+ if cls is None or nba_primary is None:
219
+ return 0.0
220
+ rule = self.safety_rules.get(cls)
221
+ if rule is None:
222
+ return 1.0 # class unconstrained, trivially compliant
223
+ return 0.0 if nba_primary in rule.do_not else 1.0
224
+
225
+ def _score_hazards(self, cls: str | None, predicted: list[str]) -> float:
226
+ if cls is None:
227
+ return 0.0
228
+ rule = self.hazard_rules.get(cls)
229
+ if rule is None or not rule.required:
230
+ return 1.0 # no required hazards for this class
231
+ present = set(predicted)
232
+ return float(all(h in present for h in rule.required))
233
+
234
+ def _aggregate(self, s: VerifierScore, *, has_gt: bool) -> None:
235
+ # Structural always contributes
236
+ struct_keys = ("json_valid", "enum_valid", "do_not_compliance", "hazard_completeness")
237
+ struct_w = sum(self.weights[k] for k in struct_keys)
238
+ struct_v = sum(self.weights[k] * getattr(s, k) for k in struct_keys)
239
+ s.structural = struct_v / struct_w if struct_w > 0 else 0.0
240
+
241
+ # Hard gate: garbage → 0
242
+ if s.json_valid == 0.0:
243
+ s.semantic = None
244
+ s.total = 0.0
245
+ return
246
+
247
+ if has_gt:
248
+ sem_pairs = [
249
+ ("class_correct", s.class_correct),
250
+ ("subclass_match", s.subclass_match),
251
+ ("nba_correct", s.nba_correct),
252
+ ("confidence_brier", s.confidence_brier),
253
+ ]
254
+ sem_w = sum(self.weights[k] for k, v in sem_pairs if v is not None)
255
+ sem_v = sum(self.weights[k] * v for k, v in sem_pairs if v is not None)
256
+ s.semantic = (sem_v / sem_w) if sem_w > 0 else None
257
+ total_w = struct_w + sem_w
258
+ total_v = struct_v + sem_v
259
+ else:
260
+ s.semantic = None
261
+ total_w = struct_w
262
+ total_v = struct_v
263
+ s.total = total_v / total_w if total_w > 0 else 0.0
264
+
265
+
266
+ # ----------------------------- helpers ------------------------------------
267
+
268
+
269
+ def _parse_json(raw: str) -> tuple[dict | None, str | None]:
270
+ """Extract and parse the first JSON object in raw. Returns (parsed, error)."""
271
+ if not isinstance(raw, str) or not raw.strip():
272
+ return None, "empty input"
273
+ m = _JSON_RE.search(raw)
274
+ if m is None:
275
+ return None, "no JSON object found"
276
+ try:
277
+ obj = json.loads(m.group(0))
278
+ except json.JSONDecodeError as e:
279
+ return None, f"JSONDecodeError: {e}"
280
+ if not isinstance(obj, dict):
281
+ return None, "top-level JSON is not an object"
282
+ return obj, None
283
+
284
+
285
+ def _get(d: dict | None, *keys: str) -> Any:
286
+ """Nested dict lookup, returning None if any step is missing."""
287
+ cur: Any = d
288
+ for k in keys:
289
+ if not isinstance(cur, dict):
290
+ return None
291
+ cur = cur.get(k)
292
+ return cur
293
+
294
+
295
+ def _confidence(parsed: dict) -> float | None:
296
+ """Pull a confidence value from common output shapes."""
297
+ for path in (("identity", "confidence"), ("confidence",)):
298
+ v = _get(parsed, *path)
299
+ if isinstance(v, (int, float)):
300
+ return float(v)
301
+ return None
302
+
303
+
304
+ def _string_match(pred: str | None, gt: str | None) -> float | None:
305
+ """Soft string match: 1.0 exact (case-insensitive), 0.5 substring, 0 else.
306
+ Returns None if either side is missing — caller decides whether to score it.
307
+ """
308
+ if not pred or not gt:
309
+ return None
310
+ p = pred.strip().lower()
311
+ g = gt.strip().lower()
312
+ if p == g:
313
+ return 1.0
314
+ if p in g or g in p:
315
+ return 0.5
316
+ return 0.0
317
+
318
+
319
+ def _brier(confidence: float, is_correct: float | None) -> float | None:
320
+ """Per-example Brier-like calibration signal in [0, 1].
321
+
322
+ Returns 1 - (confidence - is_correct)^2:
323
+ - confident & correct (1.0, 1) -> 1.0
324
+ - confident & wrong (1.0, 0) -> 0.0
325
+ - unsure & correct (0.5, 1) -> 0.75
326
+ - unsure & wrong (0.5, 0) -> 0.75
327
+ - underconfident & correct (0.0, 1) -> 0.0
328
+
329
+ Returns None if is_correct is None.
330
+ """
331
+ if is_correct is None:
332
+ return None
333
+ c = max(0.0, min(1.0, confidence))
334
+ return 1.0 - (c - is_correct) ** 2
335
+
336
+
337
+ __all__ = [
338
+ "Verifier",
339
+ "VerifierScore",
340
+ "DEFAULT_WEIGHTS",
341
+ ]