subirmansukhani commited on
Commit
26bbb13
·
1 Parent(s): 7911321

Add semantic representation probes and QK similarity analysis

Browse files

- Add semantic_probe.py for probing internal representations via cosine similarity to concept labels
- Add QK probe to measure query-key alignment across expert cross-attention layers
- Integrate representation probes as Phase 5/9 in the diagnostic pipeline
- Update DiagnosticPanel frontend to render semantic and QK probe results
- Enhance spatial object diagnosis with probe-informed evidence
- Update report rendering with representation probe sections
- Fix gradient attribution to support probe-compatible activation extraction

smolvla_inspect/diagnostic/agent.py CHANGED
@@ -21,6 +21,14 @@ from .spatial_object import (
21
  choose_target_object,
22
  summarize_spatial_object_diagnosis,
23
  )
 
 
 
 
 
 
 
 
24
  from .prompts import (
25
  build_hypothesis_prompt, build_synthesis_prompt, build_triage_selection_prompt,
26
  format_diversity_summary, format_anomalies_json, format_hypotheses_with_results,
@@ -227,15 +235,16 @@ class DiagnosticAgent:
227
  """Run the full diagnostic pipeline."""
228
 
229
  _PHASE_LABELS = {
230
- "scene_understanding": ("1/8", "Scene Understanding"),
231
- "dataset_diversity": ("2/8", "Dataset Diversity"),
232
- "triage": ("3/8", "Signal Triage"),
233
- "matrix": ("4/8", "Diagnostic Matrix"),
234
- "hypothesize": ("5/8", "Hypothesis Formation"),
235
- "counterfactuals": ("6/8", "Counterfactual Tests"),
236
- "iteration": ("6/8", "Follow-up Iteration"),
237
- "disambiguation": ("7/8", "Spatial vs Object Diagnosis"),
238
- "synthesis": ("8/8", "Report Synthesis"),
 
239
  "complete": ("OK", "Complete"),
240
  }
241
  _run_start = time.time()
@@ -390,11 +399,40 @@ class DiagnosticAgent:
390
  data={"anomaly_types": [a.type for a in anomalies]},
391
  ))
392
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
393
  preliminary_spatial_object = build_spatial_object_diagnosis(
394
  matrix,
395
  target_object=target_object,
396
  cf_results=[],
397
  dataset_diversity=diversity,
 
398
  )
399
 
400
  # ── Phase 5: LLM Hypothesis Formation ──────────────────
@@ -404,7 +442,7 @@ class DiagnosticAgent:
404
  _progress("hypothesize", f"Forming hypotheses via {llm_label}...")
405
  hypotheses = await self._form_hypotheses(
406
  task_string, scene, diversity, matrix, anomalies,
407
- preliminary_spatial_object)
408
  _progress("hypothesize", f"Formed {len(hypotheses)} hypotheses:")
409
  for h in hypotheses:
410
  test_label = f" -> test: {h.test_type}" if h.test_type != "none" else " (no test needed)"
@@ -472,7 +510,11 @@ class DiagnosticAgent:
472
  if surprising:
473
  _progress("iteration", f"Found {len(surprising)} surprising results, forming follow-up hypotheses...")
474
  followup_hypotheses = await self._form_hypotheses(
475
- task_string, scene, diversity, matrix, anomalies)
 
 
 
 
476
  # Filter to only genuinely new hypotheses
477
  existing_ids = {h.id for h in hypotheses}
478
  existing_tests = {(h.test_type, str(h.test_params)) for h in hypotheses}
@@ -552,24 +594,78 @@ class DiagnosticAgent:
552
  except Exception as e:
553
  _progress("counterfactuals", f" -> FAILED: {e}")
554
 
555
- # ── Phase 7: Spatial vs Object Diagnosis ─────────────────
 
 
556
  _progress("disambiguation", "Summarizing spatial priors vs object grounding...")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
557
  spatial_object_diagnosis = build_spatial_object_diagnosis(
558
  matrix,
559
  target_object=target_object,
560
  cf_results=cf_results,
561
  dataset_diversity=diversity,
 
 
562
  )
563
  _progress(
564
  "disambiguation",
565
  summarize_spatial_object_diagnosis(spatial_object_diagnosis),
566
  )
567
 
568
- # ── Phase 8: LLM Synthesis ─────────────────────────────
569
  _progress("synthesis", f"Synthesizing report via {llm_label}...")
570
  findings, narrative = await self._synthesize_report(
571
  task_string, scene, diversity, matrix, anomalies,
572
  hypotheses, cf_results, spatial_object_diagnosis,
 
 
573
  cf_skip_reason=cf_skip_reason)
574
  _progress("synthesis", f"Generated {len(findings)} findings:")
575
  for f in findings:
@@ -608,6 +704,8 @@ class DiagnosticAgent:
608
  scene=scene,
609
  dataset_diversity=diversity,
610
  matrix=matrix,
 
 
611
  spatial_object_diagnosis=spatial_object_diagnosis,
612
  anomalies=anomalies,
613
  hypotheses=hypotheses,
@@ -1121,7 +1219,9 @@ class DiagnosticAgent:
1121
  async def _form_hypotheses(self, task_string: str, scene: SceneSegmentation,
1122
  diversity, matrix: DiagnosticMatrix,
1123
  anomalies: list[Anomaly],
1124
- spatial_object_diagnosis=None) -> list[Hypothesis]:
 
 
1125
  """Use LLM to form hypotheses from the diagnostic data."""
1126
  prompt = build_hypothesis_prompt(
1127
  task_string=task_string,
@@ -1130,6 +1230,8 @@ class DiagnosticAgent:
1130
  matrix_markdown=matrix.to_markdown(),
1131
  anomalies_json=format_anomalies_json(anomalies),
1132
  spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
 
 
1133
  )
1134
 
1135
  response = await self._call_llm(prompt)
@@ -1293,6 +1395,8 @@ class DiagnosticAgent:
1293
  hypotheses: list[Hypothesis],
1294
  cf_results: list[CounterfactualResult],
1295
  spatial_object_diagnosis=None,
 
 
1296
  cf_skip_reason: str = "",
1297
  ) -> tuple[list[Finding], str]:
1298
  """Use LLM to synthesize findings and narrative from all evidence."""
@@ -1301,6 +1405,8 @@ class DiagnosticAgent:
1301
  detected_objects=scene.region_names(),
1302
  matrix_markdown=matrix.to_markdown(),
1303
  spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
 
 
1304
  anomalies_summary=format_anomalies_json(anomalies),
1305
  diversity_summary=format_diversity_summary(diversity),
1306
  hypotheses_with_results=format_hypotheses_with_results(
 
21
  choose_target_object,
22
  summarize_spatial_object_diagnosis,
23
  )
24
+ from .semantic_probe import (
25
+ build_qk_probe_report,
26
+ build_semantic_probe_report,
27
+ probe_semantic_frame,
28
+ semantic_candidate_labels,
29
+ summarize_qk_probe,
30
+ summarize_semantic_probe,
31
+ )
32
  from .prompts import (
33
  build_hypothesis_prompt, build_synthesis_prompt, build_triage_selection_prompt,
34
  format_diversity_summary, format_anomalies_json, format_hypotheses_with_results,
 
235
  """Run the full diagnostic pipeline."""
236
 
237
  _PHASE_LABELS = {
238
+ "scene_understanding": ("1/9", "Scene Understanding"),
239
+ "dataset_diversity": ("2/9", "Dataset Diversity"),
240
+ "triage": ("3/9", "Signal Triage"),
241
+ "matrix": ("4/9", "Diagnostic Matrix"),
242
+ "representation": ("5/9", "Representation Probes"),
243
+ "hypothesize": ("6/9", "Hypothesis Formation"),
244
+ "counterfactuals": ("7/9", "Counterfactual Tests"),
245
+ "iteration": ("7/9", "Follow-up Iteration"),
246
+ "disambiguation": ("8/9", "Spatial vs Object Diagnosis"),
247
+ "synthesis": ("9/9", "Report Synthesis"),
248
  "complete": ("OK", "Complete"),
249
  }
250
  _run_start = time.time()
 
399
  data={"anomaly_types": [a.type for a in anomalies]},
400
  ))
401
 
402
+ primary_semantic_frame = None
403
+ semantic_internal = None
404
+ semantic_probe = None
405
+ qk_probe = None
406
+ if self.policy is not None and target_object is not None:
407
+ _progress("representation", "Running semantic patch-to-text probe on the primary frame...")
408
+ try:
409
+ primary_semantic_frame, semantic_internal = probe_semantic_frame(
410
+ self.policy,
411
+ sample,
412
+ self.dataset,
413
+ self.image_key,
414
+ self.device,
415
+ target_object=target_object,
416
+ candidate_labels=semantic_candidate_labels(scene, target_object),
417
+ attention_heatmap=(signals.get("attention") or [None])[0],
418
+ gradcam_map=(signals.get("gradcam_siglip") or [None])[0],
419
+ image_map=self.image_map,
420
+ frame_id="primary",
421
+ )
422
+ semantic_probe = build_semantic_probe_report(target_object, primary_semantic_frame, [])
423
+ if semantic_probe is not None:
424
+ _progress("representation", summarize_semantic_probe(semantic_probe))
425
+ except Exception as e:
426
+ _progress("representation", f"Semantic probe skipped: {e}")
427
+ else:
428
+ _progress("representation", "Skipping semantic probe (no model or target object)")
429
+
430
  preliminary_spatial_object = build_spatial_object_diagnosis(
431
  matrix,
432
  target_object=target_object,
433
  cf_results=[],
434
  dataset_diversity=diversity,
435
+ semantic_probe=semantic_probe,
436
  )
437
 
438
  # ── Phase 5: LLM Hypothesis Formation ──────────────────
 
442
  _progress("hypothesize", f"Forming hypotheses via {llm_label}...")
443
  hypotheses = await self._form_hypotheses(
444
  task_string, scene, diversity, matrix, anomalies,
445
+ preliminary_spatial_object, semantic_probe, None)
446
  _progress("hypothesize", f"Formed {len(hypotheses)} hypotheses:")
447
  for h in hypotheses:
448
  test_label = f" -> test: {h.test_type}" if h.test_type != "none" else " (no test needed)"
 
510
  if surprising:
511
  _progress("iteration", f"Found {len(surprising)} surprising results, forming follow-up hypotheses...")
512
  followup_hypotheses = await self._form_hypotheses(
513
+ task_string, scene, diversity, matrix, anomalies,
514
+ pre_qk_spatial_object if 'pre_qk_spatial_object' in locals() else preliminary_spatial_object,
515
+ semantic_probe,
516
+ qk_probe,
517
+ )
518
  # Filter to only genuinely new hypotheses
519
  existing_ids = {h.id for h in hypotheses}
520
  existing_tests = {(h.test_type, str(h.test_params)) for h in hypotheses}
 
594
  except Exception as e:
595
  _progress("counterfactuals", f" -> FAILED: {e}")
596
 
597
+ semantic_probe = build_semantic_probe_report(target_object, primary_semantic_frame, cf_results)
598
+
599
+ # ── Phase 8: Spatial vs Object Diagnosis ─────────────────
600
  _progress("disambiguation", "Summarizing spatial priors vs object grounding...")
601
+ pre_qk_spatial_object = build_spatial_object_diagnosis(
602
+ matrix,
603
+ target_object=target_object,
604
+ cf_results=cf_results,
605
+ dataset_diversity=diversity,
606
+ semantic_probe=semantic_probe,
607
+ )
608
+ semantic_ambiguous = (
609
+ primary_semantic_frame is not None
610
+ and (
611
+ primary_semantic_frame.target_margin_over_best_non_target is None
612
+ or abs(primary_semantic_frame.target_margin_over_best_non_target) < 0.08
613
+ )
614
+ )
615
+ if (
616
+ self.policy is not None
617
+ and target_object is not None
618
+ and semantic_internal is not None
619
+ and (pre_qk_spatial_object.verdict in {"mixed", "inconclusive"} or semantic_ambiguous)
620
+ ):
621
+ _progress("disambiguation", "Running last-layer SigLIP QK decomposition...")
622
+ try:
623
+ relocation_result = next(
624
+ (
625
+ result for result in cf_results
626
+ if result.test_type == "object_relocation"
627
+ and (result.metrics or {}).get("target_object") == target_object
628
+ ),
629
+ None,
630
+ )
631
+ qk_probe = build_qk_probe_report(
632
+ self.policy,
633
+ sample,
634
+ self.dataset,
635
+ self.image_key,
636
+ self.device,
637
+ target_object=target_object,
638
+ scene=scene,
639
+ target_semantic_map=semantic_internal["target_map"],
640
+ positional_baseline=signals.get("positional_baseline"),
641
+ relocation_result=relocation_result,
642
+ image_map=self.image_map,
643
+ )
644
+ if qk_probe is not None:
645
+ _progress("disambiguation", summarize_qk_probe(qk_probe))
646
+ except Exception as e:
647
+ _progress("disambiguation", f"QK probe skipped: {e}")
648
+
649
  spatial_object_diagnosis = build_spatial_object_diagnosis(
650
  matrix,
651
  target_object=target_object,
652
  cf_results=cf_results,
653
  dataset_diversity=diversity,
654
+ semantic_probe=semantic_probe,
655
+ qk_probe=qk_probe,
656
  )
657
  _progress(
658
  "disambiguation",
659
  summarize_spatial_object_diagnosis(spatial_object_diagnosis),
660
  )
661
 
662
+ # ── Phase 9: LLM Synthesis ─────────────────────────────
663
  _progress("synthesis", f"Synthesizing report via {llm_label}...")
664
  findings, narrative = await self._synthesize_report(
665
  task_string, scene, diversity, matrix, anomalies,
666
  hypotheses, cf_results, spatial_object_diagnosis,
667
+ semantic_probe,
668
+ qk_probe,
669
  cf_skip_reason=cf_skip_reason)
670
  _progress("synthesis", f"Generated {len(findings)} findings:")
671
  for f in findings:
 
704
  scene=scene,
705
  dataset_diversity=diversity,
706
  matrix=matrix,
707
+ semantic_probe=semantic_probe,
708
+ qk_probe=qk_probe,
709
  spatial_object_diagnosis=spatial_object_diagnosis,
710
  anomalies=anomalies,
711
  hypotheses=hypotheses,
 
1219
  async def _form_hypotheses(self, task_string: str, scene: SceneSegmentation,
1220
  diversity, matrix: DiagnosticMatrix,
1221
  anomalies: list[Anomaly],
1222
+ spatial_object_diagnosis=None,
1223
+ semantic_probe=None,
1224
+ qk_probe=None) -> list[Hypothesis]:
1225
  """Use LLM to form hypotheses from the diagnostic data."""
1226
  prompt = build_hypothesis_prompt(
1227
  task_string=task_string,
 
1230
  matrix_markdown=matrix.to_markdown(),
1231
  anomalies_json=format_anomalies_json(anomalies),
1232
  spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
1233
+ semantic_probe_summary=summarize_semantic_probe(semantic_probe),
1234
+ qk_probe_summary=summarize_qk_probe(qk_probe),
1235
  )
1236
 
1237
  response = await self._call_llm(prompt)
 
1395
  hypotheses: list[Hypothesis],
1396
  cf_results: list[CounterfactualResult],
1397
  spatial_object_diagnosis=None,
1398
+ semantic_probe=None,
1399
+ qk_probe=None,
1400
  cf_skip_reason: str = "",
1401
  ) -> tuple[list[Finding], str]:
1402
  """Use LLM to synthesize findings and narrative from all evidence."""
 
1405
  detected_objects=scene.region_names(),
1406
  matrix_markdown=matrix.to_markdown(),
1407
  spatial_object_summary=summarize_spatial_object_diagnosis(spatial_object_diagnosis),
1408
+ semantic_probe_summary=summarize_semantic_probe(semantic_probe),
1409
+ qk_probe_summary=summarize_qk_probe(qk_probe),
1410
  anomalies_summary=format_anomalies_json(anomalies),
1411
  diversity_summary=format_diversity_summary(diversity),
1412
  hypotheses_with_results=format_hypotheses_with_results(
smolvla_inspect/diagnostic/counterfactual.py CHANGED
@@ -22,6 +22,7 @@ from ..data import build_policy_batch_from_sample
22
  from ..gradient import _patch_eager_attention_bool_mask
23
  from .models import CounterfactualResult, SceneSegmentation
24
  from .registry import register_primitive
 
25
 
26
 
27
  # ---------------------------------------------------------------------------
@@ -255,6 +256,38 @@ def _mask_share(heatmap: np.ndarray | None, mask: np.ndarray) -> float:
255
  return float(np.sum(hm * mask.astype(np.float32)) / total)
256
 
257
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  def _shift_mask(mask: np.ndarray, shift_pixels: tuple[int, int]) -> np.ndarray:
259
  """Translate a binary mask by ``(dx, dy)`` with clipping."""
260
  dx, dy = shift_pixels
@@ -588,6 +621,40 @@ def object_relocation(
588
  "focus_shift_gap": moved_target_share - old_anchor_share,
589
  }
590
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
591
  return _compute_result(
592
  baseline_actions, modified_actions,
593
  img_hwc, modified_hwc,
@@ -1144,11 +1211,35 @@ def occlusion_targeted(
1144
  policy.reset()
1145
  modified_actions = _get_actions(policy, new_sample, dataset, image_key, device, image_map)
1146
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1147
  return _compute_result(
1148
  baseline_actions, modified_actions,
1149
  img_hwc, modified_hwc,
1150
  hypothesis_id=f"occlusion_targeted_{target_object}",
1151
  test_type="occlusion_targeted",
1152
  affected_mask=obj_mask,
1153
- metrics={"target_object": target_object, "fill": fill},
1154
  )
 
22
  from ..gradient import _patch_eager_attention_bool_mask
23
  from .models import CounterfactualResult, SceneSegmentation
24
  from .registry import register_primitive
25
+ from .semantic_probe import compute_patch_text_similarity
26
 
27
 
28
  # ---------------------------------------------------------------------------
 
256
  return float(np.sum(hm * mask.astype(np.float32)) / total)
257
 
258
 
259
+ def _normalize_similarity_map(similarity_map: np.ndarray, valid_mask: np.ndarray) -> np.ndarray:
260
+ out = np.zeros_like(similarity_map, dtype=np.float32)
261
+ if not valid_mask.any():
262
+ return out
263
+ valid = similarity_map[valid_mask].astype(np.float32)
264
+ vmin = float(valid.min())
265
+ vmax = float(valid.max())
266
+ if vmax > vmin:
267
+ out[valid_mask] = (valid - vmin) / (vmax - vmin)
268
+ return out
269
+
270
+
271
+ def _semantic_region_mean(similarity_map: np.ndarray | None, region_mask: np.ndarray,
272
+ grid_shape: tuple[int, int], valid_mask: np.ndarray) -> float | None:
273
+ if similarity_map is None:
274
+ return None
275
+ region_grid = _resize_mask(region_mask, grid_shape) & valid_mask
276
+ if not region_grid.any():
277
+ return None
278
+ return float(similarity_map[region_grid].mean())
279
+
280
+
281
+ def _semantic_region_peak(similarity_map: np.ndarray | None, region_mask: np.ndarray,
282
+ grid_shape: tuple[int, int], valid_mask: np.ndarray) -> float | None:
283
+ if similarity_map is None:
284
+ return None
285
+ region_grid = _resize_mask(region_mask, grid_shape) & valid_mask
286
+ if not region_grid.any():
287
+ return None
288
+ return float(similarity_map[region_grid].max())
289
+
290
+
291
  def _shift_mask(mask: np.ndarray, shift_pixels: tuple[int, int]) -> np.ndarray:
292
  """Translate a binary mask by ``(dx, dy)`` with clipping."""
293
  dx, dy = shift_pixels
 
621
  "focus_shift_gap": moved_target_share - old_anchor_share,
622
  }
623
 
624
+ original_semantics = compute_patch_text_similarity(
625
+ policy, sample, dataset, image_key, device, [target_object], image_map=image_map,
626
+ )
627
+ modified_semantics = compute_patch_text_similarity(
628
+ policy, new_sample, dataset, image_key, device, [target_object], image_map=image_map,
629
+ )
630
+ if original_semantics is not None and modified_semantics is not None:
631
+ orig_map = original_semantics["similarity_maps"].get(target_object)
632
+ mod_map = modified_semantics["similarity_maps"].get(target_object)
633
+ if orig_map is not None and mod_map is not None:
634
+ orig_valid = original_semantics["valid_patch_mask"]
635
+ mod_valid = modified_semantics["valid_patch_mask"]
636
+ orig_grid = original_semantics["grid_size"]
637
+ mod_grid = modified_semantics["grid_size"]
638
+ orig_peak = _semantic_region_peak(orig_map, obj_mask, orig_grid, orig_valid)
639
+ old_anchor_sem = _semantic_region_mean(mod_map, old_anchor_mask, mod_grid, mod_valid)
640
+ moved_target_sem = _semantic_region_mean(mod_map, moved_target_mask, mod_grid, mod_valid)
641
+ mod_norm = _normalize_similarity_map(mod_map, mod_valid)
642
+ old_anchor_norm = _semantic_region_mean(mod_norm, old_anchor_mask, mod_grid, mod_valid) or 0.0
643
+ moved_target_norm = _semantic_region_mean(mod_norm, moved_target_mask, mod_grid, mod_valid) or 0.0
644
+ semantic_total = old_anchor_norm + moved_target_norm + 1e-8
645
+ metrics.update({
646
+ "original_target_semantic_peak": orig_peak if orig_peak is not None else 0.0,
647
+ "modified_old_anchor_target_semantic": (
648
+ old_anchor_sem if old_anchor_sem is not None else 0.0
649
+ ),
650
+ "modified_new_object_target_semantic": (
651
+ moved_target_sem if moved_target_sem is not None else 0.0
652
+ ),
653
+ "semantic_follow_ratio": moved_target_norm / semantic_total,
654
+ "semantic_anchor_ratio": old_anchor_norm / semantic_total,
655
+ "semantic_shift_gap": moved_target_norm - old_anchor_norm,
656
+ })
657
+
658
  return _compute_result(
659
  baseline_actions, modified_actions,
660
  img_hwc, modified_hwc,
 
1211
  policy.reset()
1212
  modified_actions = _get_actions(policy, new_sample, dataset, image_key, device, image_map)
1213
 
1214
+ metrics = {"target_object": target_object, "fill": fill}
1215
+ original_semantics = compute_patch_text_similarity(
1216
+ policy, sample, dataset, image_key, device, [target_object], image_map=image_map,
1217
+ )
1218
+ occluded_semantics = compute_patch_text_similarity(
1219
+ policy, new_sample, dataset, image_key, device, [target_object], image_map=image_map,
1220
+ )
1221
+ if original_semantics is not None and occluded_semantics is not None:
1222
+ orig_map = original_semantics["similarity_maps"].get(target_object)
1223
+ occ_map = occluded_semantics["similarity_maps"].get(target_object)
1224
+ if orig_map is not None and occ_map is not None:
1225
+ orig_valid = original_semantics["valid_patch_mask"]
1226
+ occ_valid = occluded_semantics["valid_patch_mask"]
1227
+ orig_grid = original_semantics["grid_size"]
1228
+ occ_grid = occluded_semantics["grid_size"]
1229
+ orig_peak = _semantic_region_peak(orig_map, obj_mask, orig_grid, orig_valid)
1230
+ occ_peak = _semantic_region_peak(occ_map, obj_mask, occ_grid, occ_valid)
1231
+ if orig_peak is not None and occ_peak is not None:
1232
+ metrics.update({
1233
+ "original_target_semantic_peak": orig_peak,
1234
+ "occluded_target_semantic_peak": occ_peak,
1235
+ "occlusion_target_semantic_drop": orig_peak - occ_peak,
1236
+ })
1237
+
1238
  return _compute_result(
1239
  baseline_actions, modified_actions,
1240
  img_hwc, modified_hwc,
1241
  hypothesis_id=f"occlusion_targeted_{target_object}",
1242
  test_type="occlusion_targeted",
1243
  affected_mask=obj_mask,
1244
+ metrics=metrics,
1245
  )
smolvla_inspect/diagnostic/models.py CHANGED
@@ -329,6 +329,70 @@ class SpatialObjectDiagnosis:
329
  object_evidence: list[DiagnosticEvidence] = field(default_factory=list)
330
 
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  @dataclass
333
  class EvidenceEntry:
334
  """A single piece of evidence collected during a diagnostic phase."""
@@ -380,6 +444,8 @@ class DiagnosticReport:
380
  scene: SceneSegmentation
381
  dataset_diversity: DatasetDiversityReport | None
382
  matrix: DiagnosticMatrix
 
 
383
  spatial_object_diagnosis: SpatialObjectDiagnosis | None
384
  anomalies: list[Anomaly]
385
  hypotheses: list[Hypothesis]
@@ -439,6 +505,8 @@ class DiagnosticReport:
439
  "scene": self._serialisable_scene(),
440
  "dataset_diversity": asdict(self.dataset_diversity) if self.dataset_diversity else None,
441
  "matrix": self.matrix.to_dict(),
 
 
442
  "spatial_object_diagnosis": (
443
  asdict(self.spatial_object_diagnosis)
444
  if self.spatial_object_diagnosis
@@ -665,6 +733,73 @@ class DiagnosticReport:
665
  sections.append(f"- {item.summary} (score={item.score:.2f}, source={item.source})")
666
  sections.append("")
667
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
668
  # ── Anomalies ─────────────────────────────────────────────
669
  sections.append("## Detected Anomalies")
670
  sections.append("")
 
329
  object_evidence: list[DiagnosticEvidence] = field(default_factory=list)
330
 
331
 
332
+ @dataclass
333
+ class SemanticFrameSummary:
334
+ """Semantic-probe summary for a single image/frame."""
335
+
336
+ frame_id: str
337
+ target_object: str | None
338
+ candidate_labels: list[str] = field(default_factory=list)
339
+ best_non_target_label: str | None = None
340
+ target_semantic_peak: float | None = None
341
+ target_semantic_mean_on_causal_patches: float | None = None
342
+ target_margin_over_best_non_target: float | None = None
343
+ causal_semantic_alignment: float | None = None
344
+ background_semantic_gap: float | None = None
345
+ summary: str = ""
346
+
347
+
348
+ @dataclass
349
+ class SemanticCounterfactualSummary:
350
+ """Semantic summary derived from a counterfactual probe."""
351
+
352
+ test_type: str
353
+ summary: str
354
+ metrics: dict = field(default_factory=dict)
355
+
356
+
357
+ @dataclass
358
+ class SemanticProbeReport:
359
+ """Top-level semantic object-vs-location probe report."""
360
+
361
+ target_object: str | None
362
+ summary: str
363
+ primary_frame: SemanticFrameSummary | None = None
364
+ frames: list[SemanticFrameSummary] = field(default_factory=list)
365
+ counterfactuals: dict[str, SemanticCounterfactualSummary] = field(default_factory=dict)
366
+
367
+
368
+ @dataclass
369
+ class QKHeadSummary:
370
+ """Per-head QK decomposition summary for the final SigLIP layer."""
371
+
372
+ head_index: int
373
+ head_type: str
374
+ score: float
375
+ semantic_map_correlation: float
376
+ positional_baseline_correlation: float
377
+ target_region_logit_mass: float
378
+ background_logit_mass: float
379
+ old_anchor_logit_mass: float | None = None
380
+ moved_object_logit_mass: float | None = None
381
+
382
+
383
+ @dataclass
384
+ class QKProbeReport:
385
+ """Compact summary of last-layer SigLIP QK behavior."""
386
+
387
+ layer: str
388
+ summary: str
389
+ dominant_head_type: str
390
+ semantic_head_fraction: float
391
+ positional_head_fraction: float
392
+ mixed_head_fraction: float
393
+ top_heads: list[QKHeadSummary] = field(default_factory=list)
394
+
395
+
396
  @dataclass
397
  class EvidenceEntry:
398
  """A single piece of evidence collected during a diagnostic phase."""
 
444
  scene: SceneSegmentation
445
  dataset_diversity: DatasetDiversityReport | None
446
  matrix: DiagnosticMatrix
447
+ semantic_probe: SemanticProbeReport | None
448
+ qk_probe: QKProbeReport | None
449
  spatial_object_diagnosis: SpatialObjectDiagnosis | None
450
  anomalies: list[Anomaly]
451
  hypotheses: list[Hypothesis]
 
505
  "scene": self._serialisable_scene(),
506
  "dataset_diversity": asdict(self.dataset_diversity) if self.dataset_diversity else None,
507
  "matrix": self.matrix.to_dict(),
508
+ "semantic_probe": asdict(self.semantic_probe) if self.semantic_probe else None,
509
+ "qk_probe": asdict(self.qk_probe) if self.qk_probe else None,
510
  "spatial_object_diagnosis": (
511
  asdict(self.spatial_object_diagnosis)
512
  if self.spatial_object_diagnosis
 
733
  sections.append(f"- {item.summary} (score={item.score:.2f}, source={item.source})")
734
  sections.append("")
735
 
736
+ # ── Semantic Probe ───────────────────────────────────────
737
+ if self.semantic_probe is not None:
738
+ probe = self.semantic_probe
739
+ sections.append("## Semantic Feature Probe")
740
+ sections.append("")
741
+ sections.append(probe.summary)
742
+ sections.append("")
743
+
744
+ if probe.primary_frame is not None:
745
+ frame = probe.primary_frame
746
+ sections.append("### Primary Frame")
747
+ sections.append("")
748
+ sections.append("| Metric | Value |")
749
+ sections.append("|---|---|")
750
+ sections.append(f"| Target object | {frame.target_object or 'N/A'} |")
751
+ sections.append(f"| Candidate labels | {', '.join(frame.candidate_labels)} |")
752
+ sections.append(f"| Best non-target label | {frame.best_non_target_label or 'N/A'} |")
753
+ for key in (
754
+ "target_semantic_peak",
755
+ "target_semantic_mean_on_causal_patches",
756
+ "target_margin_over_best_non_target",
757
+ "causal_semantic_alignment",
758
+ "background_semantic_gap",
759
+ ):
760
+ value = getattr(frame, key)
761
+ rendered = f"{value:.4f}" if isinstance(value, float) else "N/A"
762
+ sections.append(f"| {key.replace('_', ' ').title()} | {rendered} |")
763
+ if frame.summary:
764
+ sections.append("")
765
+ sections.append(frame.summary)
766
+ sections.append("")
767
+
768
+ if probe.counterfactuals:
769
+ sections.append("### Counterfactual Semantic Follow-Through")
770
+ sections.append("")
771
+ for name, summary in probe.counterfactuals.items():
772
+ sections.append(f"- **{name.replace('_', ' ')}**: {summary.summary}")
773
+ sections.append("")
774
+
775
+ # ── QK Probe ─────────────────────────────────────────────
776
+ if self.qk_probe is not None:
777
+ probe = self.qk_probe
778
+ sections.append("## QK Decomposition")
779
+ sections.append("")
780
+ sections.append(probe.summary)
781
+ sections.append("")
782
+ sections.append("| Metric | Value |")
783
+ sections.append("|---|---|")
784
+ sections.append(f"| Layer | {probe.layer} |")
785
+ sections.append(f"| Dominant head type | {probe.dominant_head_type} |")
786
+ sections.append(f"| Semantic head fraction | {probe.semantic_head_fraction:.4f} |")
787
+ sections.append(f"| Positional head fraction | {probe.positional_head_fraction:.4f} |")
788
+ sections.append(f"| Mixed head fraction | {probe.mixed_head_fraction:.4f} |")
789
+ sections.append("")
790
+ if probe.top_heads:
791
+ sections.append("### Top Heads")
792
+ sections.append("")
793
+ sections.append("| Head | Type | Score | Semantic Corr | Positional Corr | Target Mass | Background Mass |")
794
+ sections.append("|---|---|---|---|---|---|---|")
795
+ for head in probe.top_heads:
796
+ sections.append(
797
+ f"| {head.head_index} | {head.head_type} | {head.score:.4f} | "
798
+ f"{head.semantic_map_correlation:.4f} | {head.positional_baseline_correlation:.4f} | "
799
+ f"{head.target_region_logit_mass:.4f} | {head.background_logit_mass:.4f} |"
800
+ )
801
+ sections.append("")
802
+
803
  # ── Anomalies ─────────────────────────────────────────────
804
  sections.append("## Detected Anomalies")
805
  sections.append("")
smolvla_inspect/diagnostic/prompts.py CHANGED
@@ -31,6 +31,12 @@ HYPOTHESIS_PROMPT = """You are an expert robotics ML researcher diagnosing a vis
31
  **Spatial vs Object Diagnosis:**
32
  {spatial_object_summary}
33
 
 
 
 
 
 
 
34
  **Detected Anomalies:**
35
  {anomalies_json}
36
 
@@ -91,6 +97,12 @@ SYNTHESIS_PROMPT = """You are an expert robotics ML researcher writing a diagnos
91
  **Spatial vs Object Diagnosis:**
92
  {spatial_object_summary}
93
 
 
 
 
 
 
 
94
  **Anomalies detected:** {anomalies_summary}
95
 
96
  **Dataset diversity:** {diversity_summary}
@@ -136,6 +148,8 @@ def build_hypothesis_prompt(task_string: str, detected_objects: list[str],
136
  diversity_summary: str, matrix_markdown: str,
137
  anomalies_json: str,
138
  spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
 
 
139
  ) -> str:
140
  """Build the hypothesis formation prompt with all context filled in."""
141
  return HYPOTHESIS_PROMPT.format(
@@ -145,6 +159,8 @@ def build_hypothesis_prompt(task_string: str, detected_objects: list[str],
145
  diversity_summary=diversity_summary,
146
  matrix_markdown=matrix_markdown,
147
  spatial_object_summary=spatial_object_summary,
 
 
148
  anomalies_json=anomalies_json,
149
  )
150
 
@@ -154,6 +170,8 @@ def build_synthesis_prompt(task_string: str, detected_objects: list[str],
154
  diversity_summary: str,
155
  hypotheses_with_results: str,
156
  spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
 
 
157
  ) -> str:
158
  """Build the synthesis/report prompt with all context filled in."""
159
  return SYNTHESIS_PROMPT.format(
@@ -162,6 +180,8 @@ def build_synthesis_prompt(task_string: str, detected_objects: list[str],
162
  detected_objects=", ".join(detected_objects),
163
  matrix_markdown=matrix_markdown,
164
  spatial_object_summary=spatial_object_summary,
 
 
165
  anomalies_summary=anomalies_summary,
166
  diversity_summary=diversity_summary,
167
  hypotheses_with_results=hypotheses_with_results,
 
31
  **Spatial vs Object Diagnosis:**
32
  {spatial_object_summary}
33
 
34
+ **Semantic Probe:**
35
+ {semantic_probe_summary}
36
+
37
+ **QK Decomposition:**
38
+ {qk_probe_summary}
39
+
40
  **Detected Anomalies:**
41
  {anomalies_json}
42
 
 
97
  **Spatial vs Object Diagnosis:**
98
  {spatial_object_summary}
99
 
100
+ **Semantic Probe:**
101
+ {semantic_probe_summary}
102
+
103
+ **QK Decomposition:**
104
+ {qk_probe_summary}
105
+
106
  **Anomalies detected:** {anomalies_summary}
107
 
108
  **Dataset diversity:** {diversity_summary}
 
148
  diversity_summary: str, matrix_markdown: str,
149
  anomalies_json: str,
150
  spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
151
+ semantic_probe_summary: str = "Semantic patch-to-text probe unavailable.",
152
+ qk_probe_summary: str = "QK decomposition unavailable.",
153
  ) -> str:
154
  """Build the hypothesis formation prompt with all context filled in."""
155
  return HYPOTHESIS_PROMPT.format(
 
159
  diversity_summary=diversity_summary,
160
  matrix_markdown=matrix_markdown,
161
  spatial_object_summary=spatial_object_summary,
162
+ semantic_probe_summary=semantic_probe_summary,
163
+ qk_probe_summary=qk_probe_summary,
164
  anomalies_json=anomalies_json,
165
  )
166
 
 
170
  diversity_summary: str,
171
  hypotheses_with_results: str,
172
  spatial_object_summary: str = "Spatial-vs-object diagnosis unavailable.",
173
+ semantic_probe_summary: str = "Semantic patch-to-text probe unavailable.",
174
+ qk_probe_summary: str = "QK decomposition unavailable.",
175
  ) -> str:
176
  """Build the synthesis/report prompt with all context filled in."""
177
  return SYNTHESIS_PROMPT.format(
 
180
  detected_objects=", ".join(detected_objects),
181
  matrix_markdown=matrix_markdown,
182
  spatial_object_summary=spatial_object_summary,
183
+ semantic_probe_summary=semantic_probe_summary,
184
+ qk_probe_summary=qk_probe_summary,
185
  anomalies_summary=anomalies_summary,
186
  diversity_summary=diversity_summary,
187
  hypotheses_with_results=hypotheses_with_results,
smolvla_inspect/diagnostic/report.py CHANGED
@@ -104,6 +104,23 @@ def save_diagnostic_report(report: DiagnosticReport, run_dir: str) -> str:
104
  "metrics": cf.metrics,
105
  })
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  if report.spatial_object_diagnosis is not None:
108
  diag = report.spatial_object_diagnosis
109
  evidence_data.append({
 
104
  "metrics": cf.metrics,
105
  })
106
 
107
+ if report.semantic_probe is not None:
108
+ evidence_data.append({
109
+ "phase": "representation",
110
+ "type": "semantic_probe",
111
+ "target_object": report.semantic_probe.target_object,
112
+ "summary": report.semantic_probe.summary,
113
+ })
114
+
115
+ if report.qk_probe is not None:
116
+ evidence_data.append({
117
+ "phase": "representation",
118
+ "type": "qk_probe",
119
+ "layer": report.qk_probe.layer,
120
+ "summary": report.qk_probe.summary,
121
+ "dominant_head_type": report.qk_probe.dominant_head_type,
122
+ })
123
+
124
  if report.spatial_object_diagnosis is not None:
125
  diag = report.spatial_object_diagnosis
126
  evidence_data.append({
smolvla_inspect/diagnostic/semantic_probe.py ADDED
@@ -0,0 +1,685 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Semantic and QK probes for spatial-vs-object diagnostics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import torch
7
+
8
+ from ..gradient import compute_gradcam_map, extract_siglip_last_layer_features
9
+ from .models import (
10
+ CounterfactualResult,
11
+ QKHeadSummary,
12
+ QKProbeReport,
13
+ SceneSegmentation,
14
+ SemanticCounterfactualSummary,
15
+ SemanticFrameSummary,
16
+ SemanticProbeReport,
17
+ )
18
+
19
+ _DEFAULT_SIGLIP_MODEL = "google/siglip-base-patch16-512"
20
+ _SIGLIP_TEXT_CACHE: dict[tuple[str, str], tuple[object, object]] = {}
21
+
22
+
23
+ def semantic_candidate_labels(
24
+ scene: SceneSegmentation,
25
+ target_object: str | None = None,
26
+ ) -> list[str]:
27
+ """Collect unique non-gripper labels, keeping *target_object* first."""
28
+ labels = []
29
+ if target_object is not None:
30
+ labels.append(target_object)
31
+ for obj in scene.objects:
32
+ label = obj.label
33
+ if "gripper" in label.lower():
34
+ continue
35
+ if label not in labels:
36
+ labels.append(label)
37
+ return labels
38
+
39
+
40
+ def _infer_siglip_model_id(policy) -> str:
41
+ """Best-effort guess of the matching SigLIP text tower."""
42
+ try:
43
+ from ..data import find_vision_encoder
44
+
45
+ vision_encoder = find_vision_encoder(policy)
46
+ except Exception:
47
+ vision_encoder = None
48
+
49
+ if vision_encoder is None:
50
+ return _DEFAULT_SIGLIP_MODEL
51
+
52
+ cfg = getattr(vision_encoder, "config", None)
53
+ image_size = getattr(cfg, "image_size", 512)
54
+ patch_size = getattr(cfg, "patch_size", 16)
55
+ hidden_size = getattr(cfg, "hidden_size", 768)
56
+ if hidden_size >= 1024:
57
+ return f"google/siglip-large-patch{patch_size}-{image_size}"
58
+ return f"google/siglip-base-patch{patch_size}-{image_size}"
59
+
60
+
61
+ def _text_device(device: str | torch.device) -> str:
62
+ device_str = str(device)
63
+ if device_str.startswith(("cuda", "cpu")):
64
+ return device_str
65
+ return "cpu"
66
+
67
+
68
+ def _get_siglip_text_encoder(model_id: str, device: str):
69
+ key = (model_id, device)
70
+ if key in _SIGLIP_TEXT_CACHE:
71
+ return _SIGLIP_TEXT_CACHE[key]
72
+
73
+ from transformers import AutoModel, AutoTokenizer
74
+
75
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
76
+ model = AutoModel.from_pretrained(model_id).to(device).eval()
77
+ _SIGLIP_TEXT_CACHE[key] = (tokenizer, model)
78
+ return tokenizer, model
79
+
80
+
81
+ def _unique_labels(labels: list[str]) -> list[str]:
82
+ seen: set[str] = set()
83
+ result = []
84
+ for label in labels:
85
+ if not label or label in seen:
86
+ continue
87
+ seen.add(label)
88
+ result.append(label)
89
+ return result
90
+
91
+
92
+ def _resize_map(arr: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray:
93
+ if arr.shape == target_shape:
94
+ return arr.astype(np.float32, copy=False)
95
+ tensor = torch.from_numpy(arr.astype(np.float32)).unsqueeze(0).unsqueeze(0)
96
+ resized = torch.nn.functional.interpolate(
97
+ tensor, size=target_shape, mode="bilinear", align_corners=False,
98
+ )
99
+ return resized.squeeze(0).squeeze(0).cpu().numpy()
100
+
101
+
102
+ def _resize_mask(mask: np.ndarray, target_shape: tuple[int, int]) -> np.ndarray:
103
+ if mask.shape == target_shape:
104
+ return mask.astype(bool, copy=False)
105
+ tensor = torch.from_numpy(mask.astype(np.float32)).unsqueeze(0).unsqueeze(0)
106
+ resized = torch.nn.functional.interpolate(
107
+ tensor, size=target_shape, mode="nearest",
108
+ )
109
+ return resized.squeeze(0).squeeze(0).cpu().numpy() > 0.5
110
+
111
+
112
+ def _normalize_map(values: np.ndarray, valid_mask: np.ndarray) -> np.ndarray:
113
+ out = np.zeros_like(values, dtype=np.float32)
114
+ if not valid_mask.any():
115
+ return out
116
+ valid = values[valid_mask].astype(np.float32)
117
+ vmin = float(valid.min())
118
+ vmax = float(valid.max())
119
+ if vmax > vmin:
120
+ out[valid_mask] = (valid - vmin) / (vmax - vmin)
121
+ return out
122
+
123
+
124
+ def _topk_mask(values: np.ndarray, valid_mask: np.ndarray, pct: float = 0.05) -> np.ndarray:
125
+ mask = np.zeros_like(values, dtype=bool)
126
+ if not valid_mask.any():
127
+ return mask
128
+ flat = values[valid_mask]
129
+ k = max(4, int(np.ceil(flat.size * pct)))
130
+ k = min(k, flat.size)
131
+ threshold = np.partition(flat, flat.size - k)[flat.size - k]
132
+ mask[valid_mask] = values[valid_mask] >= threshold
133
+ return mask
134
+
135
+
136
+ def _masked_mean(values: np.ndarray, mask: np.ndarray) -> float | None:
137
+ region = mask.astype(bool)
138
+ if not region.any():
139
+ return None
140
+ return float(values[region].mean())
141
+
142
+
143
+ def _masked_max(values: np.ndarray, mask: np.ndarray) -> float | None:
144
+ region = mask.astype(bool)
145
+ if not region.any():
146
+ return None
147
+ return float(values[region].max())
148
+
149
+
150
+ def _weighted_mean(values: np.ndarray, weights: np.ndarray, valid_mask: np.ndarray) -> float | None:
151
+ weights = np.where(valid_mask, np.maximum(weights, 0.0), 0.0).astype(np.float32)
152
+ total = float(weights.sum())
153
+ if total <= 1e-8:
154
+ return None
155
+ return float((values * weights).sum() / total)
156
+
157
+
158
+ def _pearson_corr(a: np.ndarray, b: np.ndarray, valid_mask: np.ndarray) -> float:
159
+ mask = valid_mask.astype(bool)
160
+ if mask.sum() < 4:
161
+ return 0.0
162
+ av = a[mask].astype(np.float32)
163
+ bv = b[mask].astype(np.float32)
164
+ av = av - av.mean()
165
+ bv = bv - bv.mean()
166
+ denom = float(np.linalg.norm(av) * np.linalg.norm(bv))
167
+ if denom <= 1e-8:
168
+ return 0.0
169
+ return float(np.dot(av, bv) / denom)
170
+
171
+
172
+ def _map_mass(values: np.ndarray, region_mask: np.ndarray, valid_mask: np.ndarray) -> float:
173
+ if not valid_mask.any():
174
+ return 0.0
175
+ norm = _normalize_map(values, valid_mask)
176
+ total = float(norm[valid_mask].sum())
177
+ if total <= 1e-8:
178
+ return 0.0
179
+ return float(norm[region_mask & valid_mask].sum() / total)
180
+
181
+
182
+ def _clone_sample(sample: dict, image_key: str) -> dict:
183
+ cloned = dict(sample)
184
+ cloned[image_key] = sample[image_key].clone()
185
+ return cloned
186
+
187
+
188
+ def _tensor_to_hwc(tensor: torch.Tensor) -> np.ndarray:
189
+ arr = tensor.detach().cpu().numpy()
190
+ if arr.ndim == 3 and arr.shape[0] in (1, 3):
191
+ arr = np.transpose(arr, (1, 2, 0))
192
+ return arr
193
+
194
+
195
+ def _hwc_to_tensor(arr: np.ndarray, device: str = "cpu") -> torch.Tensor:
196
+ if arr.ndim == 3 and arr.shape[2] in (1, 3):
197
+ arr = np.transpose(arr, (2, 0, 1))
198
+ return torch.from_numpy(arr.astype(np.float32)).to(device)
199
+
200
+
201
+ def _shift_mask(mask: np.ndarray, shift_pixels: tuple[int, int]) -> np.ndarray:
202
+ dx, dy = shift_pixels
203
+ shifted = np.zeros_like(mask, dtype=bool)
204
+ ys, xs = np.where(mask)
205
+ if len(ys) == 0:
206
+ return shifted
207
+ ny = ys + dy
208
+ nx = xs + dx
209
+ valid = (ny >= 0) & (ny < mask.shape[0]) & (nx >= 0) & (nx < mask.shape[1])
210
+ shifted[ny[valid], nx[valid]] = True
211
+ return shifted
212
+
213
+
214
+ def _make_relocated_sample(sample: dict, image_key: str, segmentation: SceneSegmentation,
215
+ target_object: str, shift_pixels: tuple[int, int]) -> tuple[dict | None, dict]:
216
+ img_tensor = sample[image_key]
217
+ img_hwc = _tensor_to_hwc(img_tensor)
218
+ h, w = img_hwc.shape[:2]
219
+ obj_mask = segmentation.get_mask(target_object)
220
+ if obj_mask is None:
221
+ return None, {}
222
+ obj_mask = _resize_mask(obj_mask, (h, w))
223
+ dx, dy = shift_pixels
224
+ modified_hwc = img_hwc.copy()
225
+ ys, xs = np.where(obj_mask)
226
+ if len(ys) == 0:
227
+ return None, {}
228
+
229
+ fill_color = np.array([0.5, 0.5, 0.5], dtype=np.float32)
230
+ modified_hwc[obj_mask] = fill_color
231
+ for oy, ox in zip(ys, xs):
232
+ ny = oy + dy
233
+ nx = ox + dx
234
+ if 0 <= ny < h and 0 <= nx < w:
235
+ modified_hwc[ny, nx] = img_hwc[oy, ox]
236
+ modified_hwc = np.clip(modified_hwc, 0.0, 1.0)
237
+
238
+ moved_mask = _shift_mask(obj_mask, shift_pixels)
239
+ old_anchor_mask = obj_mask & ~moved_mask
240
+ moved_target_mask = moved_mask & ~obj_mask
241
+ if not old_anchor_mask.any():
242
+ old_anchor_mask = obj_mask
243
+ if not moved_target_mask.any():
244
+ moved_target_mask = moved_mask
245
+
246
+ new_sample = _clone_sample(sample, image_key)
247
+ new_sample[image_key] = _hwc_to_tensor(modified_hwc, device=str(img_tensor.device))
248
+ return new_sample, {
249
+ "original_mask": obj_mask,
250
+ "old_anchor_mask": old_anchor_mask,
251
+ "moved_target_mask": moved_target_mask,
252
+ }
253
+
254
+
255
+ def compute_patch_text_similarity(
256
+ policy,
257
+ sample,
258
+ dataset,
259
+ image_key,
260
+ device,
261
+ labels: list[str],
262
+ *,
263
+ image_map=None,
264
+ capture_qk: bool = False,
265
+ ):
266
+ """Extract final-layer patch features and cosine maps for *labels*."""
267
+ labels = _unique_labels(labels)
268
+ if not labels:
269
+ return None
270
+
271
+ features = extract_siglip_last_layer_features(
272
+ policy, sample, dataset, image_key, device,
273
+ image_map=image_map, capture_qk=capture_qk,
274
+ )
275
+ if features is None:
276
+ return None
277
+
278
+ model_id = _infer_siglip_model_id(policy)
279
+ device_str = _text_device(device)
280
+ try:
281
+ tokenizer, text_model = _get_siglip_text_encoder(model_id, device_str)
282
+ except Exception as exc:
283
+ if model_id != _DEFAULT_SIGLIP_MODEL:
284
+ try:
285
+ model_id = _DEFAULT_SIGLIP_MODEL
286
+ tokenizer, text_model = _get_siglip_text_encoder(model_id, device_str)
287
+ except Exception:
288
+ print(f" WARNING: SigLIP text encoder unavailable for semantic probe ({exc})")
289
+ return None
290
+ else:
291
+ print(f" WARNING: SigLIP text encoder unavailable for semantic probe ({exc})")
292
+ return None
293
+
294
+ with torch.no_grad():
295
+ inputs = tokenizer(labels, padding=True, truncation=True, return_tensors="pt")
296
+ inputs = {key: value.to(device_str) for key, value in inputs.items()}
297
+ text_embeddings = text_model.get_text_features(**inputs).float()
298
+ text_embeddings = torch.nn.functional.normalize(text_embeddings, dim=-1)
299
+
300
+ patch_features = torch.from_numpy(features["patch_features"]).float()
301
+ if patch_features.shape[-1] != text_embeddings.shape[-1]:
302
+ print(
303
+ " WARNING: SigLIP patch/text dimensions do not match for semantic probe "
304
+ f"({patch_features.shape[-1]} vs {text_embeddings.shape[-1]})"
305
+ )
306
+ return None
307
+ similarity_maps: dict[str, np.ndarray] = {}
308
+ for idx, label in enumerate(labels):
309
+ sim = torch.tensordot(patch_features, text_embeddings[idx].cpu(), dims=([-1], [0]))
310
+ similarity_maps[label] = sim.cpu().numpy()
311
+
312
+ result = dict(features)
313
+ result["labels"] = labels
314
+ result["model_id"] = model_id
315
+ result["similarity_maps"] = similarity_maps
316
+ return result
317
+
318
+
319
+ def probe_semantic_frame(
320
+ policy,
321
+ sample,
322
+ dataset,
323
+ image_key,
324
+ device,
325
+ *,
326
+ target_object: str | None,
327
+ candidate_labels: list[str],
328
+ attention_heatmap: np.ndarray | None = None,
329
+ gradcam_map: np.ndarray | None = None,
330
+ image_map=None,
331
+ frame_id: str = "primary",
332
+ ):
333
+ """Build a semantic summary for one frame and return internal artifacts."""
334
+ if target_object is None:
335
+ return None, None
336
+
337
+ artifact = compute_patch_text_similarity(
338
+ policy, sample, dataset, image_key, device, candidate_labels,
339
+ image_map=image_map, capture_qk=False,
340
+ )
341
+ if artifact is None:
342
+ return None, None
343
+
344
+ similarity_maps = artifact["similarity_maps"]
345
+ valid_mask = artifact["valid_patch_mask"]
346
+ grid_shape = artifact["grid_size"]
347
+ target_map = similarity_maps.get(target_object)
348
+ if target_map is None:
349
+ return None, None
350
+
351
+ if gradcam_map is None:
352
+ try:
353
+ gradcam_map = compute_gradcam_map(
354
+ policy, sample, dataset, image_key, device, image_map=image_map,
355
+ )
356
+ except Exception:
357
+ gradcam_map = None
358
+
359
+ gradcam_grid = _resize_map(gradcam_map, grid_shape) if gradcam_map is not None else None
360
+ attention_grid = _resize_map(attention_heatmap, grid_shape) if attention_heatmap is not None else None
361
+
362
+ if gradcam_grid is not None:
363
+ gradcam_top = _topk_mask(gradcam_grid, valid_mask)
364
+ else:
365
+ gradcam_top = np.zeros(grid_shape, dtype=bool)
366
+ if attention_grid is not None:
367
+ attention_top = _topk_mask(attention_grid, valid_mask)
368
+ else:
369
+ attention_top = np.zeros(grid_shape, dtype=bool)
370
+
371
+ causal_mask = np.zeros(grid_shape, dtype=bool)
372
+ if gradcam_top.any() and attention_top.any():
373
+ causal_mask = gradcam_top & attention_top
374
+ if not causal_mask.any():
375
+ causal_mask = gradcam_top
376
+ elif gradcam_top.any():
377
+ causal_mask = gradcam_top
378
+ elif attention_top.any():
379
+ causal_mask = attention_top
380
+ else:
381
+ causal_mask = valid_mask.copy()
382
+
383
+ if gradcam_grid is not None:
384
+ causal_weights = _normalize_map(gradcam_grid, valid_mask)
385
+ elif attention_grid is not None:
386
+ causal_weights = _normalize_map(attention_grid, valid_mask)
387
+ else:
388
+ causal_weights = valid_mask.astype(np.float32)
389
+ causal_weights = np.where(causal_mask, causal_weights, 0.0)
390
+
391
+ target_peak = _masked_max(target_map, valid_mask)
392
+ target_mean_causal = _masked_mean(target_map, causal_mask & valid_mask)
393
+ causal_alignment = _weighted_mean(target_map, causal_weights, valid_mask)
394
+ background_mask = valid_mask & ~causal_mask
395
+ background_mean = _masked_mean(target_map, background_mask)
396
+ background_gap = None
397
+ if target_mean_causal is not None and background_mean is not None:
398
+ background_gap = float(target_mean_causal - background_mean)
399
+
400
+ non_target_scores = []
401
+ for label, sim_map in similarity_maps.items():
402
+ if label == target_object:
403
+ continue
404
+ score = _masked_mean(sim_map, causal_mask & valid_mask)
405
+ if score is not None:
406
+ non_target_scores.append((label, score))
407
+
408
+ best_non_target_label = None
409
+ target_margin = None
410
+ if target_mean_causal is not None and non_target_scores:
411
+ best_non_target_label, best_non_target_score = max(non_target_scores, key=lambda item: item[1])
412
+ target_margin = float(target_mean_causal - best_non_target_score)
413
+
414
+ summary_bits = []
415
+ if target_margin is not None:
416
+ if target_margin > 0.10:
417
+ summary_bits.append(
418
+ f"Causal patches align more strongly with '{target_object}' than other detected objects "
419
+ f"(margin={target_margin:.3f})."
420
+ )
421
+ elif target_margin < 0.0:
422
+ summary_bits.append(
423
+ f"Causal patches are not semantically distinctive for '{target_object}' "
424
+ f"(margin={target_margin:.3f})."
425
+ )
426
+ if background_gap is not None:
427
+ if background_gap > 0.10:
428
+ summary_bits.append(
429
+ f"Target semantics concentrate on causal patches rather than the low-causal background "
430
+ f"(gap={background_gap:.3f})."
431
+ )
432
+ elif background_gap < 0.0:
433
+ summary_bits.append(
434
+ f"Target semantics are no stronger on causal patches than on the low-causal background "
435
+ f"(gap={background_gap:.3f})."
436
+ )
437
+
438
+ frame = SemanticFrameSummary(
439
+ frame_id=frame_id,
440
+ target_object=target_object,
441
+ candidate_labels=candidate_labels,
442
+ best_non_target_label=best_non_target_label,
443
+ target_semantic_peak=target_peak,
444
+ target_semantic_mean_on_causal_patches=target_mean_causal,
445
+ target_margin_over_best_non_target=target_margin,
446
+ causal_semantic_alignment=causal_alignment,
447
+ background_semantic_gap=background_gap,
448
+ summary=" ".join(summary_bits) if summary_bits else "Semantic evidence was weak or unavailable on the causal patches.",
449
+ )
450
+ internal = {
451
+ "target_map": target_map,
452
+ "valid_patch_mask": valid_mask,
453
+ "causal_mask": causal_mask,
454
+ "causal_weights": causal_weights,
455
+ "similarity_maps": similarity_maps,
456
+ "artifact": artifact,
457
+ }
458
+ return frame, internal
459
+
460
+
461
+ def summarize_semantic_probe(report: SemanticProbeReport | None) -> str:
462
+ """Short summary string for prompts and logs."""
463
+ if report is None or report.primary_frame is None:
464
+ return "Semantic patch-to-text probe unavailable."
465
+ frame = report.primary_frame
466
+ bits = [f"Target object: {report.target_object or 'N/A'}."]
467
+ if frame.target_margin_over_best_non_target is not None:
468
+ bits.append(f"Target margin over best non-target: {frame.target_margin_over_best_non_target:.3f}.")
469
+ if frame.background_semantic_gap is not None:
470
+ bits.append(f"Background gap: {frame.background_semantic_gap:.3f}.")
471
+ bits.append(frame.summary)
472
+ return " ".join(bits)
473
+
474
+
475
+ def build_semantic_probe_report(
476
+ target_object: str | None,
477
+ primary_frame: SemanticFrameSummary | None,
478
+ cf_results: list[CounterfactualResult],
479
+ ) -> SemanticProbeReport | None:
480
+ """Assemble the primary-frame and counterfactual semantic summaries."""
481
+ if target_object is None and primary_frame is None:
482
+ return None
483
+
484
+ counterfactuals: dict[str, SemanticCounterfactualSummary] = {}
485
+ for result in cf_results:
486
+ metrics = result.metrics or {}
487
+ if result.test_type == "object_relocation" and "semantic_follow_ratio" in metrics:
488
+ follow = float(metrics.get("semantic_follow_ratio", 0.0))
489
+ anchor = float(metrics.get("semantic_anchor_ratio", 0.0))
490
+ summary = (
491
+ f"After relocation, target semantics follow the moved object "
492
+ f"(follow={follow:.2f}, anchor={anchor:.2f})."
493
+ if follow >= anchor
494
+ else f"After relocation, target semantics stay closer to the old anchor "
495
+ f"(anchor={anchor:.2f}, follow={follow:.2f})."
496
+ )
497
+ counterfactuals[result.test_type] = SemanticCounterfactualSummary(
498
+ test_type=result.test_type,
499
+ summary=summary,
500
+ metrics=metrics,
501
+ )
502
+ elif result.test_type == "occlusion_targeted" and "occlusion_target_semantic_drop" in metrics:
503
+ drop = float(metrics.get("occlusion_target_semantic_drop", 0.0))
504
+ summary = (
505
+ f"Occluding the target reduces target-semantic evidence in the target region "
506
+ f"(drop={drop:.3f})."
507
+ if drop > 0
508
+ else f"Occluding the target does not reduce target-semantic evidence "
509
+ f"(drop={drop:.3f})."
510
+ )
511
+ counterfactuals[result.test_type] = SemanticCounterfactualSummary(
512
+ test_type=result.test_type,
513
+ summary=summary,
514
+ metrics=metrics,
515
+ )
516
+
517
+ if primary_frame is not None:
518
+ summary = primary_frame.summary
519
+ elif counterfactuals:
520
+ summary = "Semantic evidence is available only through counterfactual follow-through probes."
521
+ else:
522
+ summary = "Semantic patch-to-text probe unavailable."
523
+
524
+ frames = [primary_frame] if primary_frame is not None else []
525
+ return SemanticProbeReport(
526
+ target_object=target_object,
527
+ summary=summary,
528
+ primary_frame=primary_frame,
529
+ frames=frames,
530
+ counterfactuals=counterfactuals,
531
+ )
532
+
533
+
534
+ def build_qk_probe_report(
535
+ policy,
536
+ sample,
537
+ dataset,
538
+ image_key,
539
+ device,
540
+ *,
541
+ target_object: str | None,
542
+ scene: SceneSegmentation,
543
+ target_semantic_map: np.ndarray | None,
544
+ positional_baseline: np.ndarray | None = None,
545
+ relocation_result: CounterfactualResult | None = None,
546
+ image_map=None,
547
+ ) -> QKProbeReport | None:
548
+ """Summarize last-layer SigLIP QK behavior with semantic-vs-positional evidence."""
549
+ if target_object is None or target_semantic_map is None:
550
+ return None
551
+
552
+ artifact = compute_patch_text_similarity(
553
+ policy, sample, dataset, image_key, device, [target_object],
554
+ image_map=image_map, capture_qk=True,
555
+ )
556
+ if artifact is None or "qk_key_maps" not in artifact:
557
+ return None
558
+
559
+ valid_mask = artifact["valid_patch_mask"]
560
+ grid_shape = artifact["grid_size"]
561
+ qk_maps = artifact["qk_key_maps"]
562
+ target_map = _resize_map(target_semantic_map, grid_shape)
563
+ positional_grid = (
564
+ _resize_map(positional_baseline, grid_shape)
565
+ if positional_baseline is not None
566
+ else np.zeros(grid_shape, dtype=np.float32)
567
+ )
568
+
569
+ target_mask = scene.get_mask(target_object)
570
+ target_grid_mask = (
571
+ _resize_mask(target_mask, grid_shape) & valid_mask
572
+ if target_mask is not None
573
+ else np.zeros(grid_shape, dtype=bool)
574
+ )
575
+ background_grid_mask = _resize_mask(scene.background_mask, grid_shape) & valid_mask
576
+
577
+ relocation_old_mask = None
578
+ relocation_new_mask = None
579
+ relocated_qk_maps = None
580
+ if relocation_result is not None:
581
+ metrics = relocation_result.metrics or {}
582
+ shift = metrics.get("shift_pixels")
583
+ if isinstance(shift, (list, tuple)) and len(shift) == 2:
584
+ relocated_sample, relocated_masks = _make_relocated_sample(
585
+ sample, image_key, scene, target_object, (int(shift[0]), int(shift[1])),
586
+ )
587
+ if relocated_sample is not None:
588
+ relocated_artifact = compute_patch_text_similarity(
589
+ policy, relocated_sample, dataset, image_key, device, [target_object],
590
+ image_map=image_map, capture_qk=True,
591
+ )
592
+ if relocated_artifact is not None and "qk_key_maps" in relocated_artifact:
593
+ relocated_qk_maps = relocated_artifact["qk_key_maps"]
594
+ relocation_old_mask = _resize_mask(relocated_masks["old_anchor_mask"], grid_shape) & valid_mask
595
+ relocation_new_mask = _resize_mask(relocated_masks["moved_target_mask"], grid_shape) & valid_mask
596
+
597
+ head_summaries: list[QKHeadSummary] = []
598
+ for head_index, head_map in enumerate(qk_maps):
599
+ semantic_corr = _pearson_corr(head_map, target_map, valid_mask)
600
+ positional_corr = _pearson_corr(head_map, positional_grid, valid_mask)
601
+ target_mass = _map_mass(head_map, target_grid_mask, valid_mask)
602
+ background_mass = _map_mass(head_map, background_grid_mask, valid_mask)
603
+
604
+ old_anchor_mass = None
605
+ moved_object_mass = None
606
+ relocation_bias = 0.0
607
+ if relocated_qk_maps is not None and relocation_old_mask is not None and relocation_new_mask is not None:
608
+ old_anchor_mass = _map_mass(relocated_qk_maps[head_index], relocation_old_mask, valid_mask)
609
+ moved_object_mass = _map_mass(relocated_qk_maps[head_index], relocation_new_mask, valid_mask)
610
+ relocation_bias = moved_object_mass - old_anchor_mass
611
+
612
+ semantic_adv = semantic_corr - positional_corr
613
+ if relocation_bias > 0.05:
614
+ semantic_adv += 0.10
615
+ elif relocation_bias < -0.05:
616
+ semantic_adv -= 0.10
617
+
618
+ if semantic_adv > 0.08:
619
+ head_type = "semantic"
620
+ elif semantic_adv < -0.08:
621
+ head_type = "positional"
622
+ else:
623
+ head_type = "mixed"
624
+
625
+ score = max(abs(semantic_adv), abs(relocation_bias), abs(target_mass - background_mass))
626
+ head_summaries.append(QKHeadSummary(
627
+ head_index=head_index,
628
+ head_type=head_type,
629
+ score=score,
630
+ semantic_map_correlation=semantic_corr,
631
+ positional_baseline_correlation=positional_corr,
632
+ target_region_logit_mass=target_mass,
633
+ background_logit_mass=background_mass,
634
+ old_anchor_logit_mass=old_anchor_mass,
635
+ moved_object_logit_mass=moved_object_mass,
636
+ ))
637
+
638
+ if not head_summaries:
639
+ return None
640
+
641
+ semantic_count = sum(1 for head in head_summaries if head.head_type == "semantic")
642
+ positional_count = sum(1 for head in head_summaries if head.head_type == "positional")
643
+ mixed_count = sum(1 for head in head_summaries if head.head_type == "mixed")
644
+ total = len(head_summaries)
645
+
646
+ semantic_fraction = semantic_count / total
647
+ positional_fraction = positional_count / total
648
+ mixed_fraction = mixed_count / total
649
+
650
+ if semantic_fraction > positional_fraction + 0.15:
651
+ dominant = "semantic"
652
+ summary = (
653
+ f"Most last-layer SigLIP heads align more with target semantics than with the positional baseline "
654
+ f"({semantic_fraction:.0%} semantic vs {positional_fraction:.0%} positional)."
655
+ )
656
+ elif positional_fraction > semantic_fraction + 0.15:
657
+ dominant = "positional"
658
+ summary = (
659
+ f"Most last-layer SigLIP heads align more with the positional baseline than with target semantics "
660
+ f"({positional_fraction:.0%} positional vs {semantic_fraction:.0%} semantic)."
661
+ )
662
+ else:
663
+ dominant = "mixed"
664
+ summary = (
665
+ f"Last-layer SigLIP heads split between semantic and positional signals "
666
+ f"({semantic_fraction:.0%} semantic, {positional_fraction:.0%} positional, {mixed_fraction:.0%} mixed)."
667
+ )
668
+
669
+ top_heads = sorted(head_summaries, key=lambda head: head.score, reverse=True)[:3]
670
+ return QKProbeReport(
671
+ layer="siglip_last",
672
+ summary=summary,
673
+ dominant_head_type=dominant,
674
+ semantic_head_fraction=semantic_fraction,
675
+ positional_head_fraction=positional_fraction,
676
+ mixed_head_fraction=mixed_fraction,
677
+ top_heads=top_heads,
678
+ )
679
+
680
+
681
+ def summarize_qk_probe(report: QKProbeReport | None) -> str:
682
+ """Short summary string for prompts and logs."""
683
+ if report is None:
684
+ return "QK decomposition unavailable."
685
+ return report.summary
smolvla_inspect/diagnostic/spatial_object.py CHANGED
@@ -7,7 +7,9 @@ from .models import (
7
  DatasetDiversityReport,
8
  DiagnosticEvidence,
9
  DiagnosticMatrix,
 
10
  SceneSegmentation,
 
11
  SpatialObjectDiagnosis,
12
  )
13
 
@@ -100,6 +102,8 @@ def build_spatial_object_diagnosis(
100
  target_object: str | None,
101
  cf_results: list[CounterfactualResult],
102
  dataset_diversity: DatasetDiversityReport | None = None,
 
 
103
  ) -> SpatialObjectDiagnosis:
104
  """Summarize whether the policy is reading a location or an object."""
105
  spatial_evidence: list[DiagnosticEvidence] = []
@@ -182,6 +186,72 @@ def build_spatial_object_diagnosis(
182
  {"target_object": target_object, "target_attention_share": attention_share},
183
  )
184
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
185
  for traj in matrix.temporal_trajectories or []:
186
  corr = float(traj.object_tracking_correlation)
187
  key_metrics["object_tracking_correlation"] = max(
@@ -228,10 +298,16 @@ def build_spatial_object_diagnosis(
228
  metrics = relocation.metrics or {}
229
  follow_ratio = metrics.get("focus_follow_ratio")
230
  anchor_ratio = metrics.get("anchor_retention_ratio")
 
 
231
  if follow_ratio is not None:
232
  key_metrics["relocation_follow_ratio"] = float(follow_ratio)
233
  if anchor_ratio is not None:
234
  key_metrics["relocation_anchor_ratio"] = float(anchor_ratio)
 
 
 
 
235
 
236
  if follow_ratio is not None and anchor_ratio is not None:
237
  add_object(
@@ -248,6 +324,23 @@ def build_spatial_object_diagnosis(
248
  f"After relocation, GradCAM stays anchored to the old location more than the moved object (anchor={float(anchor_ratio):.2f}, follow={float(follow_ratio):.2f}).",
249
  metrics,
250
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
  occlusion = _pick_counterfactual(
253
  cf_results,
@@ -257,6 +350,7 @@ def build_spatial_object_diagnosis(
257
  if occlusion is not None:
258
  delta = float(occlusion.action_delta_l2)
259
  key_metrics["occlusion_action_delta"] = delta
 
260
  add_object(
261
  "target_occlusion_response",
262
  _scale(delta, 0.02, 0.10),
@@ -271,6 +365,25 @@ def build_spatial_object_diagnosis(
271
  f"Occluding '{target_object}' barely changes the predicted action (delta={delta:.4f}).",
272
  {"target_object": target_object, "action_delta_l2": delta},
273
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
 
275
  background = _pick_counterfactual(cf_results, "background_substitution")
276
  if background is not None:
@@ -300,6 +413,34 @@ def build_spatial_object_diagnosis(
300
  {"target_object": target_object, "action_delta_l2": delta},
301
  )
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  spatial_score = spatial_sum / spatial_weight if spatial_weight > 1e-8 else 0.0
304
  object_score = object_sum / object_weight if object_weight > 1e-8 else 0.0
305
 
@@ -340,7 +481,7 @@ def build_spatial_object_diagnosis(
340
  summary = (
341
  f"The available evidence is not strong enough to cleanly separate spatial-prior use from "
342
  f"object grounding for '{target_object}'. The model may be weakly grounded, weakly shortcut-driven, "
343
- "or the available probes may be too noisy."
344
  )
345
 
346
  return SpatialObjectDiagnosis(
 
7
  DatasetDiversityReport,
8
  DiagnosticEvidence,
9
  DiagnosticMatrix,
10
+ QKProbeReport,
11
  SceneSegmentation,
12
+ SemanticProbeReport,
13
  SpatialObjectDiagnosis,
14
  )
15
 
 
102
  target_object: str | None,
103
  cf_results: list[CounterfactualResult],
104
  dataset_diversity: DatasetDiversityReport | None = None,
105
+ semantic_probe: SemanticProbeReport | None = None,
106
+ qk_probe: QKProbeReport | None = None,
107
  ) -> SpatialObjectDiagnosis:
108
  """Summarize whether the policy is reading a location or an object."""
109
  spatial_evidence: list[DiagnosticEvidence] = []
 
186
  {"target_object": target_object, "target_attention_share": attention_share},
187
  )
188
 
189
+ if semantic_probe is not None and semantic_probe.primary_frame is not None:
190
+ frame = semantic_probe.primary_frame
191
+ if frame.target_semantic_peak is not None:
192
+ key_metrics["target_semantic_peak"] = float(frame.target_semantic_peak)
193
+ if frame.target_semantic_mean_on_causal_patches is not None:
194
+ key_metrics["target_semantic_mean_on_causal_patches"] = float(
195
+ frame.target_semantic_mean_on_causal_patches
196
+ )
197
+ if frame.target_margin_over_best_non_target is not None:
198
+ margin = float(frame.target_margin_over_best_non_target)
199
+ key_metrics["target_margin_over_best_non_target"] = margin
200
+ add_object(
201
+ "semantic_target_margin",
202
+ _scale(margin, 0.02, 0.18),
203
+ 0.18,
204
+ f"Causal patches look more like '{target_object}' than other detected objects "
205
+ f"(margin={margin:.3f}).",
206
+ {
207
+ "target_object": target_object,
208
+ "best_non_target_label": frame.best_non_target_label,
209
+ "target_margin_over_best_non_target": margin,
210
+ },
211
+ )
212
+ add_spatial(
213
+ "weak_semantic_target_margin",
214
+ _scale(0.05 - margin, 0.01, 0.10),
215
+ 0.14,
216
+ f"Causal patches are not semantically distinctive for '{target_object}' "
217
+ f"(margin={margin:.3f}).",
218
+ {
219
+ "target_object": target_object,
220
+ "best_non_target_label": frame.best_non_target_label,
221
+ "target_margin_over_best_non_target": margin,
222
+ },
223
+ )
224
+ if frame.causal_semantic_alignment is not None:
225
+ alignment = float(frame.causal_semantic_alignment)
226
+ key_metrics["causal_semantic_alignment"] = alignment
227
+ add_object(
228
+ "causal_semantic_alignment",
229
+ _scale(alignment, 0.15, 0.45),
230
+ 0.10,
231
+ f"High-causal patches contain clear target semantics for '{target_object}' "
232
+ f"(alignment={alignment:.3f}).",
233
+ {"target_object": target_object, "causal_semantic_alignment": alignment},
234
+ )
235
+ if frame.background_semantic_gap is not None:
236
+ bg_gap = float(frame.background_semantic_gap)
237
+ key_metrics["background_semantic_gap"] = bg_gap
238
+ add_object(
239
+ "background_semantic_gap",
240
+ _scale(bg_gap, 0.03, 0.18),
241
+ 0.10,
242
+ f"Target semantics are stronger on high-causal patches than on the low-causal background "
243
+ f"(gap={bg_gap:.3f}).",
244
+ {"target_object": target_object, "background_semantic_gap": bg_gap},
245
+ )
246
+ add_spatial(
247
+ "low_background_semantic_gap",
248
+ _scale(0.04 - bg_gap, 0.01, 0.08),
249
+ 0.10,
250
+ f"Target semantics are no stronger on causal patches than on the surrounding background "
251
+ f"(gap={bg_gap:.3f}).",
252
+ {"target_object": target_object, "background_semantic_gap": bg_gap},
253
+ )
254
+
255
  for traj in matrix.temporal_trajectories or []:
256
  corr = float(traj.object_tracking_correlation)
257
  key_metrics["object_tracking_correlation"] = max(
 
298
  metrics = relocation.metrics or {}
299
  follow_ratio = metrics.get("focus_follow_ratio")
300
  anchor_ratio = metrics.get("anchor_retention_ratio")
301
+ semantic_follow_ratio = metrics.get("semantic_follow_ratio")
302
+ semantic_anchor_ratio = metrics.get("semantic_anchor_ratio")
303
  if follow_ratio is not None:
304
  key_metrics["relocation_follow_ratio"] = float(follow_ratio)
305
  if anchor_ratio is not None:
306
  key_metrics["relocation_anchor_ratio"] = float(anchor_ratio)
307
+ if semantic_follow_ratio is not None:
308
+ key_metrics["relocation_semantic_follow_ratio"] = float(semantic_follow_ratio)
309
+ if semantic_anchor_ratio is not None:
310
+ key_metrics["relocation_semantic_anchor_ratio"] = float(semantic_anchor_ratio)
311
 
312
  if follow_ratio is not None and anchor_ratio is not None:
313
  add_object(
 
324
  f"After relocation, GradCAM stays anchored to the old location more than the moved object (anchor={float(anchor_ratio):.2f}, follow={float(follow_ratio):.2f}).",
325
  metrics,
326
  )
327
+ if semantic_follow_ratio is not None and semantic_anchor_ratio is not None:
328
+ add_object(
329
+ "semantic_relocation_follow_probe",
330
+ _scale(float(semantic_follow_ratio) - float(semantic_anchor_ratio), 0.05, 0.35),
331
+ 0.20,
332
+ f"After relocation, target semantics follow the moved object more than the old anchor "
333
+ f"(follow={float(semantic_follow_ratio):.2f}, anchor={float(semantic_anchor_ratio):.2f}).",
334
+ metrics,
335
+ )
336
+ add_spatial(
337
+ "semantic_relocation_anchor_probe",
338
+ _scale(float(semantic_anchor_ratio) - float(semantic_follow_ratio), 0.05, 0.35),
339
+ 0.20,
340
+ f"After relocation, target semantics stay more anchored to the old location than to the moved object "
341
+ f"(anchor={float(semantic_anchor_ratio):.2f}, follow={float(semantic_follow_ratio):.2f}).",
342
+ metrics,
343
+ )
344
 
345
  occlusion = _pick_counterfactual(
346
  cf_results,
 
350
  if occlusion is not None:
351
  delta = float(occlusion.action_delta_l2)
352
  key_metrics["occlusion_action_delta"] = delta
353
+ semantic_drop = occlusion.metrics.get("occlusion_target_semantic_drop") if occlusion.metrics else None
354
  add_object(
355
  "target_occlusion_response",
356
  _scale(delta, 0.02, 0.10),
 
365
  f"Occluding '{target_object}' barely changes the predicted action (delta={delta:.4f}).",
366
  {"target_object": target_object, "action_delta_l2": delta},
367
  )
368
+ if semantic_drop is not None:
369
+ semantic_drop = float(semantic_drop)
370
+ key_metrics["occlusion_target_semantic_drop"] = semantic_drop
371
+ add_object(
372
+ "target_occlusion_semantic_drop",
373
+ _scale(semantic_drop, 0.03, 0.20),
374
+ 0.12,
375
+ f"Occluding '{target_object}' removes target-semantic evidence from the target region "
376
+ f"(drop={semantic_drop:.3f}).",
377
+ {"target_object": target_object, "occlusion_target_semantic_drop": semantic_drop},
378
+ )
379
+ add_spatial(
380
+ "target_occlusion_semantic_insensitivity",
381
+ _scale(0.02 - semantic_drop, 0.005, 0.02),
382
+ 0.08,
383
+ f"Occluding '{target_object}' does not materially reduce target-semantic evidence "
384
+ f"(drop={semantic_drop:.3f}).",
385
+ {"target_object": target_object, "occlusion_target_semantic_drop": semantic_drop},
386
+ )
387
 
388
  background = _pick_counterfactual(cf_results, "background_substitution")
389
  if background is not None:
 
413
  {"target_object": target_object, "action_delta_l2": delta},
414
  )
415
 
416
+ if qk_probe is not None:
417
+ key_metrics["qk_semantic_head_fraction"] = qk_probe.semantic_head_fraction
418
+ key_metrics["qk_positional_head_fraction"] = qk_probe.positional_head_fraction
419
+ if qk_probe.dominant_head_type == "semantic":
420
+ add_object(
421
+ "qk_semantic_heads",
422
+ _scale(
423
+ qk_probe.semantic_head_fraction - qk_probe.positional_head_fraction,
424
+ 0.05,
425
+ 0.40,
426
+ ),
427
+ 0.14,
428
+ qk_probe.summary,
429
+ {"dominant_head_type": qk_probe.dominant_head_type},
430
+ )
431
+ elif qk_probe.dominant_head_type == "positional":
432
+ add_spatial(
433
+ "qk_positional_heads",
434
+ _scale(
435
+ qk_probe.positional_head_fraction - qk_probe.semantic_head_fraction,
436
+ 0.05,
437
+ 0.40,
438
+ ),
439
+ 0.14,
440
+ qk_probe.summary,
441
+ {"dominant_head_type": qk_probe.dominant_head_type},
442
+ )
443
+
444
  spatial_score = spatial_sum / spatial_weight if spatial_weight > 1e-8 else 0.0
445
  object_score = object_sum / object_weight if object_weight > 1e-8 else 0.0
446
 
 
481
  summary = (
482
  f"The available evidence is not strong enough to cleanly separate spatial-prior use from "
483
  f"object grounding for '{target_object}'. The model may be weakly grounded, weakly shortcut-driven, "
484
+ "or the available probes may be too noisy."
485
  )
486
 
487
  return SpatialObjectDiagnosis(
smolvla_inspect/gradient.py CHANGED
@@ -11,6 +11,7 @@ import warnings
11
 
12
  import numpy as np
13
  import torch
 
14
 
15
  try:
16
  from tqdm import tqdm
@@ -25,6 +26,8 @@ from .data import (
25
  _resolve_task_string,
26
  find_vision_encoder,
27
  )
 
 
28
 
29
 
30
  # ---------------------------------------------------------------------------
@@ -234,6 +237,196 @@ def _gradcam_from_captured(activations, gradients, spatial_shape=None):
234
  return cam.detach().float().cpu().numpy()
235
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  # ---------------------------------------------------------------------------
238
  # Saliency map (vanilla input-gradient)
239
  # ---------------------------------------------------------------------------
 
11
 
12
  import numpy as np
13
  import torch
14
+ import torch.nn.functional as F
15
 
16
  try:
17
  from tqdm import tqdm
 
26
  _resolve_task_string,
27
  find_vision_encoder,
28
  )
29
+ from ._compat import resize_with_pad
30
+ from .heatmap import compute_padding_patches
31
 
32
 
33
  # ---------------------------------------------------------------------------
 
237
  return cam.detach().float().cpu().numpy()
238
 
239
 
240
+ def _prepare_siglip_vision_inputs(sample, image_key, vision_encoder, device):
241
+ """Normalize a sample image for direct SigLIP vision-encoder forward passes."""
242
+ img = sample[image_key].unsqueeze(0).to(device)
243
+ if img.max() > 1.0:
244
+ img = img.float() / 255.0
245
+
246
+ original_hw = (int(img.shape[-2]), int(img.shape[-1]))
247
+ target_size = getattr(
248
+ getattr(vision_encoder, "config", None), "image_size", None,
249
+ ) or getattr(vision_encoder, "image_size", 384)
250
+ if img.shape[-2] != target_size or img.shape[-1] != target_size:
251
+ img_resized = resize_with_pad(img, target_size, target_size, pad_value=0)
252
+ else:
253
+ img_resized = img
254
+ img_resized = img_resized * 2.0 - 1.0
255
+
256
+ try:
257
+ enc_dtype = next(vision_encoder.parameters()).dtype
258
+ img_resized = img_resized.to(enc_dtype)
259
+ except StopIteration:
260
+ pass
261
+
262
+ patch_size = getattr(vision_encoder, "patch_size", None) or getattr(
263
+ getattr(vision_encoder, "config", None), "patch_size", 14,
264
+ )
265
+ grid_h = int(img_resized.size(2) // patch_size)
266
+ grid_w = int(img_resized.size(3) // patch_size)
267
+ patch_mask = torch.ones(1, grid_h, grid_w, dtype=torch.bool, device=device)
268
+
269
+ crop_h, crop_w = compute_padding_patches(original_hw, target_size, patch_size)
270
+ valid_patch_mask = np.ones((grid_h, grid_w), dtype=bool)
271
+ if crop_h > 0:
272
+ valid_patch_mask[:crop_h, :] = False
273
+ if crop_w > 0:
274
+ valid_patch_mask[:, :crop_w] = False
275
+
276
+ return img_resized, patch_mask, {
277
+ "grid_size": (grid_h, grid_w),
278
+ "image_shape": original_hw,
279
+ "patch_size": patch_size,
280
+ "content_crop": (crop_h, crop_w),
281
+ "valid_patch_mask": valid_patch_mask,
282
+ }
283
+
284
+
285
+ def _forward_siglip_vision_encoder(vision_encoder, img_resized, patch_mask):
286
+ """Run a direct forward pass through the SigLIP vision encoder."""
287
+ if hasattr(vision_encoder, "embeddings") and hasattr(vision_encoder, "encoder"):
288
+ embeddings = vision_encoder.embeddings(img_resized, patch_mask)
289
+ return vision_encoder.encoder(embeddings)
290
+ if hasattr(vision_encoder, "forward"):
291
+ return vision_encoder(img_resized)
292
+ return vision_encoder(pixel_values=img_resized, patch_attention_mask=patch_mask)
293
+
294
+
295
+ def extract_siglip_last_layer_features(
296
+ policy,
297
+ sample,
298
+ dataset,
299
+ image_key,
300
+ device,
301
+ *,
302
+ image_map=None,
303
+ capture_qk: bool = False,
304
+ ):
305
+ """Extract final-layer SigLIP patch features, with optional QK key-logit maps."""
306
+ vision_encoder = find_vision_encoder(policy)
307
+ if vision_encoder is None:
308
+ print(" WARNING: Could not find vision encoder for semantic probe")
309
+ return None
310
+
311
+ try:
312
+ last_layer = vision_encoder.encoder.layers[-1]
313
+ except (AttributeError, IndexError):
314
+ print(" WARNING: Could not access last SigLIP encoder layer")
315
+ return None
316
+
317
+ last_attn = getattr(last_layer, "self_attn", None) or getattr(last_layer, "attention", None)
318
+ if capture_qk and last_attn is None:
319
+ print(" WARNING: Could not access last-layer attention module for QK probe")
320
+ capture_qk = False
321
+
322
+ img_resized, patch_mask, meta = _prepare_siglip_vision_inputs(
323
+ sample, image_key, vision_encoder, device,
324
+ )
325
+
326
+ activations: dict[str, torch.Tensor] = {}
327
+ q_capture: dict[str, torch.Tensor] = {}
328
+ k_capture: dict[str, torch.Tensor] = {}
329
+ attn_input: dict[str, torch.Tensor] = {}
330
+ handles = []
331
+
332
+ def _layer_hook(module, input_args, output):
333
+ out = output[0] if isinstance(output, tuple) else output
334
+ activations["value"] = out.detach()
335
+ return output
336
+
337
+ handles.append(last_layer.register_forward_hook(_layer_hook))
338
+
339
+ if capture_qk:
340
+ if hasattr(last_attn, "q_proj") and hasattr(last_attn, "k_proj"):
341
+ def _q_hook(module, input_args, output):
342
+ q_capture.setdefault("value", output.detach())
343
+ return output
344
+
345
+ def _k_hook(module, input_args, output):
346
+ k_capture.setdefault("value", output.detach())
347
+ return output
348
+
349
+ handles.append(last_attn.q_proj.register_forward_hook(_q_hook))
350
+ handles.append(last_attn.k_proj.register_forward_hook(_k_hook))
351
+ elif isinstance(last_attn, torch.nn.MultiheadAttention):
352
+ def _pre_hook(module, input_args):
353
+ attn_input.setdefault("value", input_args[0].detach())
354
+ return None
355
+
356
+ handles.append(last_attn.register_forward_pre_hook(_pre_hook))
357
+
358
+ try:
359
+ with torch.no_grad():
360
+ try:
361
+ _forward_siglip_vision_encoder(vision_encoder, img_resized, patch_mask)
362
+ except Exception as direct_error:
363
+ batch, _ = build_policy_batch_from_sample(
364
+ sample, policy, device, batch_size=1,
365
+ image_key_for_grad=None, dataset=dataset,
366
+ image_map=image_map,
367
+ )
368
+ try:
369
+ policy.reset()
370
+ policy.select_action(batch)
371
+ except Exception as fallback_error:
372
+ print(
373
+ " WARNING: SigLIP feature extraction failed "
374
+ f"(direct={direct_error}, fallback={fallback_error})"
375
+ )
376
+ return None
377
+
378
+ if "value" not in activations:
379
+ print(" WARNING: Last-layer feature hook did not fire")
380
+ return None
381
+
382
+ patch_features = activations["value"]
383
+ if patch_features.dim() == 3:
384
+ patch_features = patch_features[0]
385
+ patch_features = F.normalize(patch_features.float(), dim=-1)
386
+
387
+ grid_h, grid_w = meta["grid_size"]
388
+ patch_grid = patch_features.reshape(grid_h, grid_w, -1).detach().cpu().numpy()
389
+ result = {
390
+ "patch_features": patch_grid,
391
+ "grid_size": meta["grid_size"],
392
+ "image_shape": meta["image_shape"],
393
+ "patch_size": meta["patch_size"],
394
+ "content_crop": meta["content_crop"],
395
+ "valid_patch_mask": meta["valid_patch_mask"],
396
+ }
397
+
398
+ if capture_qk:
399
+ q_tensor = q_capture.get("value")
400
+ k_tensor = k_capture.get("value")
401
+ if q_tensor is None or k_tensor is None:
402
+ hidden = attn_input.get("value")
403
+ if hidden is not None and isinstance(last_attn, torch.nn.MultiheadAttention):
404
+ qkv = F.linear(hidden, last_attn.in_proj_weight, last_attn.in_proj_bias)
405
+ q_tensor, k_tensor, _ = qkv.chunk(3, dim=-1)
406
+
407
+ if q_tensor is not None and k_tensor is not None:
408
+ if q_tensor.dim() == 3:
409
+ q_tensor = q_tensor[0]
410
+ if k_tensor.dim() == 3:
411
+ k_tensor = k_tensor[0]
412
+ num_heads = getattr(last_attn, "num_heads", None) or getattr(
413
+ last_attn, "num_attention_heads", None,
414
+ )
415
+ if num_heads is None or num_heads <= 0:
416
+ num_heads = 1
417
+ head_dim = int(q_tensor.shape[-1] // num_heads)
418
+ q = q_tensor.reshape(-1, num_heads, head_dim).permute(1, 0, 2).float()
419
+ k = k_tensor.reshape(-1, num_heads, head_dim).permute(1, 0, 2).float()
420
+ scores = torch.matmul(q, k.transpose(1, 2)) * (head_dim ** -0.5)
421
+ key_maps = scores.mean(dim=1).detach().cpu().numpy()
422
+ result["qk_key_maps"] = key_maps.reshape(num_heads, grid_h, grid_w)
423
+
424
+ return result
425
+ finally:
426
+ for handle in handles:
427
+ handle.remove()
428
+
429
+
430
  # ---------------------------------------------------------------------------
431
  # Saliency map (vanilla input-gradient)
432
  # ---------------------------------------------------------------------------
web/frontend/src/components/DiagnosticPanel.tsx CHANGED
@@ -6,6 +6,46 @@ import CounterfactualComparison from "./CounterfactualComparison";
6
 
7
  interface DiagnosticReport {
8
  metadata: Record<string, unknown>;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  spatial_object_diagnosis?: {
10
  target_object: string | null;
11
  verdict: string;
@@ -290,7 +330,11 @@ export default function DiagnosticPanel() {
290
  </div>
291
 
292
  {report.spatial_object_diagnosis && (
293
- <SpatialObjectDiagnosisCard diagnosis={report.spatial_object_diagnosis} />
 
 
 
 
294
  )}
295
 
296
  {/* Scene Understanding */}
@@ -426,8 +470,12 @@ export default function DiagnosticPanel() {
426
 
427
  function SpatialObjectDiagnosisCard({
428
  diagnosis,
 
 
429
  }: {
430
  diagnosis: NonNullable<DiagnosticReport["spatial_object_diagnosis"]>;
 
 
431
  }) {
432
  const verdictLabel: Record<string, string> = {
433
  spatial_prior: "Spatial Prior",
@@ -480,6 +528,72 @@ function SpatialObjectDiagnosisCard({
480
  <EvidenceList title="Evidence For Spatial Priors" items={diagnosis.spatial_evidence} emptyText="No strong spatial-prior evidence." />
481
  <EvidenceList title="Evidence For Object Grounding" items={diagnosis.object_evidence} emptyText="No strong object-grounding evidence." />
482
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
483
  </div>
484
  );
485
  }
@@ -536,6 +650,11 @@ function formatMetricValue(value: number | string) {
536
  return String(value);
537
  }
538
 
 
 
 
 
 
539
  function SeverityBadge({ severity }: { severity: string }) {
540
  const styles: Record<string, { bg: string; color: string }> = {
541
  critical: { bg: "#dc3545", color: "#fff" },
 
6
 
7
  interface DiagnosticReport {
8
  metadata: Record<string, unknown>;
9
+ semantic_probe?: {
10
+ target_object: string | null;
11
+ summary: string;
12
+ primary_frame?: {
13
+ frame_id: string;
14
+ target_object: string | null;
15
+ candidate_labels: string[];
16
+ best_non_target_label: string | null;
17
+ target_semantic_peak: number | null;
18
+ target_semantic_mean_on_causal_patches: number | null;
19
+ target_margin_over_best_non_target: number | null;
20
+ causal_semantic_alignment: number | null;
21
+ background_semantic_gap: number | null;
22
+ summary: string;
23
+ } | null;
24
+ counterfactuals?: Record<string, {
25
+ test_type: string;
26
+ summary: string;
27
+ metrics: Record<string, number | string | boolean | number[] | string[]>;
28
+ }>;
29
+ };
30
+ qk_probe?: {
31
+ layer: string;
32
+ summary: string;
33
+ dominant_head_type: string;
34
+ semantic_head_fraction: number;
35
+ positional_head_fraction: number;
36
+ mixed_head_fraction: number;
37
+ top_heads: Array<{
38
+ head_index: number;
39
+ head_type: string;
40
+ score: number;
41
+ semantic_map_correlation: number;
42
+ positional_baseline_correlation: number;
43
+ target_region_logit_mass: number;
44
+ background_logit_mass: number;
45
+ old_anchor_logit_mass?: number | null;
46
+ moved_object_logit_mass?: number | null;
47
+ }>;
48
+ };
49
  spatial_object_diagnosis?: {
50
  target_object: string | null;
51
  verdict: string;
 
330
  </div>
331
 
332
  {report.spatial_object_diagnosis && (
333
+ <SpatialObjectDiagnosisCard
334
+ diagnosis={report.spatial_object_diagnosis}
335
+ semanticProbe={report.semantic_probe}
336
+ qkProbe={report.qk_probe}
337
+ />
338
  )}
339
 
340
  {/* Scene Understanding */}
 
470
 
471
  function SpatialObjectDiagnosisCard({
472
  diagnosis,
473
+ semanticProbe,
474
+ qkProbe,
475
  }: {
476
  diagnosis: NonNullable<DiagnosticReport["spatial_object_diagnosis"]>;
477
+ semanticProbe?: DiagnosticReport["semantic_probe"];
478
+ qkProbe?: DiagnosticReport["qk_probe"];
479
  }) {
480
  const verdictLabel: Record<string, string> = {
481
  spatial_prior: "Spatial Prior",
 
528
  <EvidenceList title="Evidence For Spatial Priors" items={diagnosis.spatial_evidence} emptyText="No strong spatial-prior evidence." />
529
  <EvidenceList title="Evidence For Object Grounding" items={diagnosis.object_evidence} emptyText="No strong object-grounding evidence." />
530
  </div>
531
+
532
+ {(semanticProbe || qkProbe) && (
533
+ <div style={{ marginTop: 12, display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(260px, 1fr))", gap: 12 }}>
534
+ {semanticProbe && (
535
+ <div style={{ background: "#FCFDFD", border: "1px solid #DBE4E8", borderRadius: 8, padding: 12 }}>
536
+ <div style={{ fontWeight: 600, color: "var(--text-heading)", fontSize: 12, marginBottom: 8 }}>
537
+ Semantic Probe
538
+ </div>
539
+ <div style={{ color: "var(--text-body)", fontSize: 13, lineHeight: 1.5, marginBottom: 8 }}>
540
+ {semanticProbe.summary}
541
+ </div>
542
+ {semanticProbe.primary_frame && (
543
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))", gap: 8, marginBottom: 8 }}>
544
+ <MetricPill label="Semantic peak" value={formatOptionalMetric(semanticProbe.primary_frame.target_semantic_peak)} />
545
+ <MetricPill label="Causal mean" value={formatOptionalMetric(semanticProbe.primary_frame.target_semantic_mean_on_causal_patches)} />
546
+ <MetricPill label="Target margin" value={formatOptionalMetric(semanticProbe.primary_frame.target_margin_over_best_non_target)} />
547
+ <MetricPill label="Background gap" value={formatOptionalMetric(semanticProbe.primary_frame.background_semantic_gap)} />
548
+ </div>
549
+ )}
550
+ {semanticProbe.counterfactuals && Object.keys(semanticProbe.counterfactuals).length > 0 && (
551
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
552
+ {Object.entries(semanticProbe.counterfactuals).map(([key, value]) => (
553
+ <div key={key} style={{ fontSize: 12 }}>
554
+ <div style={{ color: "var(--text-heading)", fontWeight: 500 }}>
555
+ {key.replace(/_/g, " ")}
556
+ </div>
557
+ <div style={{ color: "var(--text-body)" }}>{value.summary}</div>
558
+ </div>
559
+ ))}
560
+ </div>
561
+ )}
562
+ </div>
563
+ )}
564
+
565
+ {qkProbe && (
566
+ <div style={{ background: "#FCFDFD", border: "1px solid #DBE4E8", borderRadius: 8, padding: 12 }}>
567
+ <div style={{ fontWeight: 600, color: "var(--text-heading)", fontSize: 12, marginBottom: 8 }}>
568
+ QK Decomposition
569
+ </div>
570
+ <div style={{ color: "var(--text-body)", fontSize: 13, lineHeight: 1.5, marginBottom: 8 }}>
571
+ {qkProbe.summary}
572
+ </div>
573
+ <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(140px, 1fr))", gap: 8, marginBottom: 8 }}>
574
+ <MetricPill label="Layer" value={qkProbe.layer} />
575
+ <MetricPill label="Dominant" value={qkProbe.dominant_head_type} />
576
+ <MetricPill label="Semantic heads" value={qkProbe.semantic_head_fraction.toFixed(3)} />
577
+ <MetricPill label="Positional heads" value={qkProbe.positional_head_fraction.toFixed(3)} />
578
+ </div>
579
+ {qkProbe.top_heads.length > 0 && (
580
+ <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
581
+ {qkProbe.top_heads.map((head) => (
582
+ <div key={head.head_index} style={{ fontSize: 12 }}>
583
+ <div style={{ color: "var(--text-heading)", fontWeight: 500 }}>
584
+ Head {head.head_index} · {head.head_type}
585
+ </div>
586
+ <div style={{ color: "var(--text-body)" }}>
587
+ semantic corr={head.semantic_map_correlation.toFixed(3)} · positional corr={head.positional_baseline_correlation.toFixed(3)} · score={head.score.toFixed(3)}
588
+ </div>
589
+ </div>
590
+ ))}
591
+ </div>
592
+ )}
593
+ </div>
594
+ )}
595
+ </div>
596
+ )}
597
  </div>
598
  );
599
  }
 
650
  return String(value);
651
  }
652
 
653
+ function formatOptionalMetric(value: number | null | undefined) {
654
+ if (typeof value !== "number") return "N/A";
655
+ return Math.abs(value) >= 1 ? value.toFixed(3) : value.toFixed(4);
656
+ }
657
+
658
  function SeverityBadge({ severity }: { severity: string }) {
659
  const styles: Record<string, { bg: string; color: string }> = {
660
  critical: { bg: "#dc3545", color: "#fff" },