Skip to content

Commit af127d8

Browse files
committed
feat(joint): Phase 28 — joint intra-modal + cross-modal attribution (v1.11.0)
Blends per-patch intra-visual attention with cross-modal attention via joint = α·intra + (1−α)·cross, min-max normalised to [0,1]. Informed by arXiv:2509.22415 (cross-attention captures 52-75% of VLM saliency; the remaining variance lives in the intra-visual pathway). - miru/joint_attribution.py: JointAttribution, JointAttributionResult - miru/models/base.py: VLMOutput.intra_visual_weights optional field - miru/models/mock.py: synthetic intra-visual weights (XOR-seeded Gaussian) - api/main.py: method="joint", intra_weight field on ExplainRequest - tests/test_joint_attribution.py: 26 unit + API tests - Version 1.10.0 → 1.11.0 — 775 tests passing https://claude.ai/code/session_01Gey8ZzBsm7sDFttN3YRyZa
1 parent 2cccd6b commit af127d8

11 files changed

Lines changed: 552 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,53 @@ Format: [Conventional Commits](https://www.conventionalcommits.org/) + [Keep a C
55

66
---
77

8+
## [1.11.0] — Phase 28: joint intra-modal + cross-modal attribution
9+
10+
### Added
11+
12+
#### `miru/joint_attribution.py` — joint attribution explainer
13+
- `JointAttribution(intra_weight, extractor)` — blends per-patch intra-visual
14+
attention (`VLMOutput.intra_visual_weights`) with cross-modal attention
15+
(`VLMOutput.attention_weights`) via `joint = α·intra + (1−α)·cross`,
16+
min-max normalised to `[0, 1]`. Degrades gracefully to cross-modal only
17+
when `intra_visual_weights` is absent (logs a warning).
18+
- `JointAttributionResult` frozen dataclass: `joint_grid`, `intra_weight`,
19+
`cross_weight`, `used_intra`, `grid_h`, `grid_w`.
20+
- `DEFAULT_INTRA_WEIGHT = 0.4` exported constant.
21+
- Validation: `intra_weight ∈ [0, 1]`, raises `ValueError` otherwise.
22+
23+
#### `VLMOutput` (`miru/models/base.py`)
24+
- New optional field `intra_visual_weights: np.ndarray | None = None`
25+
per-patch intra-visual saliency. Backward-compatible (defaults to `None`).
26+
27+
#### `MockVLMBackend` (`miru/models/mock.py`)
28+
- `infer()` now populates `intra_visual_weights` with a second Gaussian blob
29+
(XOR-seeded center, wider sigma=5.0) — distinct signal from cross-modal.
30+
31+
#### `POST /explain``api/main.py`
32+
- `method="joint"` dispatched via `_run_method`.
33+
- `ExplainRequest` extended with `intra_weight` (float, ge=0.0, le=1.0,
34+
default 0.4).
35+
- `"joint"` added to `IMPLEMENTED_METHODS` and `_METHOD_DESCRIPTIONS`.
36+
- `GET /methods` now lists `joint` with status `implemented`.
37+
38+
### Tests
39+
- `tests/test_joint_attribution.py` — 26 tests: unit (dtype, value range,
40+
shape, custom resolution, used_intra flag, weight reporting, intra=0
41+
equals cross-modal, determinism, different weights differ, fallback with
42+
no intra weights, boundary values, VLMOutput defaults, mock provides
43+
intra weights), API (200, response shape, overlay, custom intra weight,
44+
weight=0, /methods listing, error contracts, health regression).
45+
46+
### Changed
47+
- Version bumped to `1.11.0` across `pyproject.toml`, `miru/__init__.py`,
48+
`miru/config.py`. `tests/test_api.py` health-version assertion → `1.11.0`.
49+
50+
### Test results
51+
- 775 passed, 20 skipped, 1 warning.
52+
53+
---
54+
855
## [1.10.0] — Phase 27: EU AI Act compliance harden
956

1057
### Added

PLAN.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
# PLAN.md — Miru Roadmap
22

33
**Project:** Miru — Multimodal Reasoning Tracer
4-
**Current version:** v1.10.0
5-
**Status:** EU AI Act compliance harden (Phase 27), synergistic-faithfulness probe / F_syn (Phase 26), explanation alert rules (Phase 25), ROI-targeted explanation (Phase 24), integrated attention explainer (Phase 22), 748 tests passing (5 skipped without MIRU_TEST_REAL_BACKENDS=1)
4+
**Current version:** v1.11.0
5+
**Status:** Joint intra-modal + cross-modal attribution (Phase 28), EU AI Act compliance harden (Phase 27), synergistic-faithfulness probe / F_syn (Phase 26), 775 tests passing (20 skipped without MIRU_TEST_REAL_BACKENDS=1)
66

77
---
88

api/main.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
from miru import gradcam_explainer, lime_explainer
4545
from miru.cross_modal import CrossModalTracer
4646
from miru.integrated_attention import IntegratedAttention
47+
from miru.joint_attribution import JointAttribution
4748
from miru.shap_explainer import SHAPConfig, SHAPExplainer
4849
from miru.attention.extractor import AttentionExtractor
4950
from miru.bench.comparison import compare_backends
@@ -85,7 +86,7 @@
8586
DEFAULT_BENCH_SIZE = 64
8687
MAX_BENCH_SIZE = 128
8788

88-
IMPLEMENTED_METHODS: tuple[str, ...] = ("attention", "lime", "gradcam", "shap", "integrated")
89+
IMPLEMENTED_METHODS: tuple[str, ...] = ("attention", "lime", "gradcam", "shap", "integrated", "joint")
8990
ROADMAP_METHODS: tuple[str, ...] = ()
9091

9192
# Bound the budgets on the perturbation-based methods so a public deploy
@@ -226,6 +227,12 @@ class ExplainRequest(BaseModel):
226227
)
227228
n_steps: int = Field(20, ge=2, le=100, description="Integrated-attention interpolation steps.")
228229
integrated_baseline: str = Field("black", description="Integrated-attention baseline: 'black' or 'mean'.")
230+
intra_weight: float = Field(
231+
0.4,
232+
ge=0.0,
233+
le=1.0,
234+
description="Joint-attribution intra-visual weight α ∈ [0, 1]. Cross-modal receives 1−α.",
235+
)
229236
roi: BoundingBox | None = Field(
230237
None,
231238
description=(
@@ -793,6 +800,13 @@ def health() -> HealthResponse:
793800
"Highlights regions whose attention rises most consistently as the image "
794801
"is revealed. Backend-agnostic; no gradients required."
795802
),
803+
"joint": (
804+
"Joint intra-modal + cross-modal attribution (arXiv:2509.22415): blends "
805+
"per-patch intra-visual attention (patches attending to each other) with "
806+
"cross-modal attention (language attending to patches) via a configurable "
807+
"weight α. Produces more faithful saliency than cross-modal alone when "
808+
"intra-visual weights are available."
809+
),
796810
}
797811

798812

@@ -1543,6 +1557,14 @@ def _run_method(method: str, backend, image_array: np.ndarray, req):
15431557
).explain(backend, image_array, req.question)
15441558
return baseline, result.integrated_grid
15451559

1560+
if method == "joint":
1561+
baseline = backend.infer(image_array, req.question)
1562+
intra_w = getattr(req, "intra_weight", 0.4)
1563+
result = JointAttribution(intra_weight=intra_w).explain(
1564+
backend, image_array, req.question
1565+
)
1566+
return baseline, result.joint_grid
1567+
15461568
raise HTTPException(status_code=400, detail=f"unsupported method: {method}")
15471569

15481570

miru/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Miru — multimodal reasoning tracer and VLM explainability engine."""
22
from __future__ import annotations
33

4-
__version__ = "1.10.0"
4+
__version__ = "1.11.0"
55

66
from miru.attention.extractor import AttentionExtractor
77
from miru.gradcam import GradCAMExplainer, GradCAMResult, compute_gradcam

miru/config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class Settings(BaseModel):
88
model_config = ConfigDict(frozen=True)
99

1010
app_name: str = "miru"
11-
version: str = "1.10.0"
11+
version: str = "1.11.0"
1212
default_backend: str = "mock"
1313
max_image_size_bytes: int = 10 * 1024 * 1024 # 10 MB
1414
attention_resolution: int = 16 # NxN grid size

miru/joint_attribution.py

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
"""Joint intra-modal + cross-modal attention attribution.
2+
3+
Combines two complementary attention signals that a ViT-based VLM exposes:
4+
5+
- **Cross-modal attention** (``VLMOutput.attention_weights``): language tokens
6+
attending to visual patches — the signal already used by the single-step
7+
attention explainer.
8+
- **Intra-visual attention** (``VLMOutput.intra_visual_weights``): visual
9+
patches attending to each other — captures local texture / spatial grouping
10+
information that cross-modal attention under-weights.
11+
12+
The joint saliency map is the convex combination
13+
14+
joint = α · intra_visual + (1 − α) · cross_modal
15+
16+
then min-max normalised to ``[0, 1]``. When ``intra_visual_weights`` is
17+
absent the method degrades gracefully to pure cross-modal attention.
18+
19+
References
20+
----------
21+
arXiv:2509.22415 — cross-attention captures 52–75 % of VLM saliency; the
22+
remaining variance lives in the intra-visual pathway.
23+
"""
24+
from __future__ import annotations
25+
26+
import logging
27+
from dataclasses import dataclass
28+
29+
import numpy as np
30+
31+
from miru.attention.extractor import AttentionExtractor
32+
from miru.models.base import VLMBackend
33+
34+
logger = logging.getLogger(__name__)
35+
36+
DEFAULT_INTRA_WEIGHT: float = 0.4
37+
MIN_INTRA_WEIGHT: float = 0.0
38+
MAX_INTRA_WEIGHT: float = 1.0
39+
40+
41+
@dataclass(frozen=True)
42+
class JointAttributionResult:
43+
"""Output of the joint attribution explainer.
44+
45+
Attributes:
46+
joint_grid: Blended saliency map, float32 ``(grid_h, grid_w)`` in
47+
``[0, 1]``.
48+
intra_weight: Actual weight applied to the intra-visual signal.
49+
cross_weight: Actual weight applied to the cross-modal signal
50+
(``1 − intra_weight``).
51+
used_intra: Whether intra-visual weights were available and used.
52+
grid_h: Height of the output grid.
53+
grid_w: Width of the output grid.
54+
"""
55+
56+
joint_grid: np.ndarray
57+
intra_weight: float
58+
cross_weight: float
59+
used_intra: bool
60+
grid_h: int
61+
grid_w: int
62+
63+
64+
class JointAttribution:
65+
"""Joint intra-modal + cross-modal attribution explainer.
66+
67+
Args:
68+
intra_weight: Weight for the intra-visual signal ``α ∈ [0, 1]``.
69+
The cross-modal signal receives weight ``1 − α``.
70+
Defaults to ``0.4`` (60 % cross-modal, 40 % intra-visual).
71+
extractor: :class:`~miru.attention.extractor.AttentionExtractor`
72+
for normalisation and grid resizing.
73+
"""
74+
75+
def __init__(
76+
self,
77+
intra_weight: float = DEFAULT_INTRA_WEIGHT,
78+
extractor: AttentionExtractor | None = None,
79+
) -> None:
80+
if not MIN_INTRA_WEIGHT <= intra_weight <= MAX_INTRA_WEIGHT:
81+
raise ValueError(
82+
f"intra_weight must be in [{MIN_INTRA_WEIGHT}, {MAX_INTRA_WEIGHT}],"
83+
f" got {intra_weight}"
84+
)
85+
self._intra_weight = intra_weight
86+
self._extractor = extractor or AttentionExtractor()
87+
88+
def explain(
89+
self,
90+
backend: VLMBackend,
91+
image_array: np.ndarray,
92+
question: str,
93+
) -> JointAttributionResult:
94+
"""Compute joint attribution for *image_array*.
95+
96+
Args:
97+
backend: Any registered :class:`~miru.models.base.VLMBackend`.
98+
image_array: float32 ``(H, W, 3)`` image in ``[0, 1]``.
99+
question: Natural-language question.
100+
101+
Returns:
102+
:class:`JointAttributionResult` with the blended saliency grid.
103+
"""
104+
out = backend.infer(image_array, question)
105+
cross_grid = self._extractor.extract(out.attention_weights)
106+
107+
used_intra = out.intra_visual_weights is not None
108+
if not used_intra:
109+
logger.warning(
110+
"joint_attribution: backend '%s' did not supply "
111+
"intra_visual_weights; falling back to cross-modal only.",
112+
backend.name,
113+
)
114+
joint = cross_grid
115+
actual_intra_w = 0.0
116+
else:
117+
intra_grid = self._extractor.extract(out.intra_visual_weights) # type: ignore[arg-type]
118+
joint = (
119+
self._intra_weight * intra_grid
120+
+ (1.0 - self._intra_weight) * cross_grid
121+
).astype(np.float32)
122+
actual_intra_w = self._intra_weight
123+
124+
lo, hi = float(joint.min()), float(joint.max())
125+
if hi - lo > 1e-8:
126+
joint = ((joint - lo) / (hi - lo)).astype(np.float32)
127+
128+
h, w = joint.shape
129+
return JointAttributionResult(
130+
joint_grid=joint,
131+
intra_weight=actual_intra_w,
132+
cross_weight=1.0 - actual_intra_w,
133+
used_intra=used_intra,
134+
grid_h=h,
135+
grid_w=w,
136+
)

miru/models/base.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,16 @@ class VLMOutput:
1616
attention_weights: 2-D float32 array (H × W) with non-negative values;
1717
will be normalized downstream by AttentionExtractor.
1818
reasoning_steps: Ordered list of intermediate reasoning descriptions.
19+
intra_visual_weights: Optional 2-D float32 array (H × W) of per-patch
20+
intra-visual saliency (patches attending to each other). ``None``
21+
when the backend does not expose intra-visual attention.
1922
"""
2023

2124
answer: str
2225
confidence: float
2326
attention_weights: np.ndarray
2427
reasoning_steps: list[str]
28+
intra_visual_weights: Optional[np.ndarray] = None
2529

2630

2731
@dataclass(frozen=True)

miru/models/mock.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def infer(self, image_array: np.ndarray, question: str) -> VLMOutput: # noqa: A
5353
confidence = float(0.70 + 0.29 * norm_len)
5454

5555
attention_weights = self._make_gaussian_map(q_key)
56+
intra_visual_weights = self._make_gaussian_map(q_key ^ 0xA5A5, sigma=5.0)
5657

5758
reasoning_steps = list(_REASONING_TEMPLATES[0])
5859

@@ -61,6 +62,7 @@ def infer(self, image_array: np.ndarray, question: str) -> VLMOutput: # noqa: A
6162
confidence=confidence,
6263
attention_weights=attention_weights,
6364
reasoning_steps=reasoning_steps,
65+
intra_visual_weights=intra_visual_weights,
6466
)
6567

6668
# ------------------------------------------------------------------

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "miru"
7-
version = "1.10.0"
7+
version = "1.11.0"
88
description = "Multimodal reasoning tracer and VLM explainability engine"
99
license = { text = "BUSL-1.1" }
1010
requires-python = ">=3.10"

tests/test_api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ def test_health_version(client: TestClient) -> None:
2020
data = resp.json()
2121
assert "version" in data
2222
assert isinstance(data["version"], str)
23-
assert data["version"] == "1.10.0"
23+
assert data["version"] == "1.11.0"
2424

2525

2626
def test_health_backends(client: TestClient) -> None:

0 commit comments

Comments
 (0)