Skip to content

Commit aefa2e8

Browse files
justinchubyCopilot
andcommitted
fix(gemma4): close text-only image modality
Resolve Joi gap #1 by emitting an optional image_features fallback and gating vision_encoder on the generic image presence key. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3861631 commit aefa2e8

3 files changed

Lines changed: 51 additions & 2 deletions

File tree

src/mobius/integrations/onnx_genai/inference_metadata_test.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,20 @@ class _VlmConfig:
121121
def _embedding_model(
122122
outputs: list[tuple[str, ir.DataType, list[int | str]]],
123123
*,
124+
optional_image: bool = False,
124125
include_audio: bool = False,
125126
optional_audio: bool = False,
126127
) -> ir.Model:
128+
image_features = _value("image_features", ir.DataType.FLOAT, ["image_tokens", 64])
129+
if optional_image:
130+
declare_optional_input(
131+
image_features,
132+
presence="image",
133+
absent_shape=[0, 64],
134+
)
127135
inputs = [
128136
_value("input_ids", ir.DataType.INT64, ["batch", "sequence"]),
129-
_value("image_features", ir.DataType.FLOAT, ["image_tokens", 64]),
137+
image_features,
130138
]
131139
if include_audio:
132140
audio_features = _value("audio_features", ir.DataType.FLOAT, ["audio_tokens", 64])
@@ -359,6 +367,7 @@ def test_gemma4_routes_all_embedding_outputs(self, tmp_path):
359367
["batch", "sequence", 128],
360368
),
361369
],
370+
optional_image=True,
362371
include_audio=True,
363372
optional_audio=True,
364373
),
@@ -392,6 +401,7 @@ def test_gemma4_routes_all_embedding_outputs(self, tmp_path):
392401
},
393402
config=config,
394403
)
404+
declare_component_presence(package["vision_encoder"].graph, "image")
395405
declare_component_presence(package["audio_encoder"].graph, "audio")
396406

397407
source = tmp_path / "gemma"
@@ -456,15 +466,29 @@ def test_gemma4_routes_all_embedding_outputs(self, tmp_path):
456466
"from": "audio_encoder.audio_features",
457467
}
458468
assert emitted_yaml["pipeline"]["models"]["embedding"]["io"]["optional_inputs"] == {
469+
"image_features": {
470+
"presence": "image",
471+
"absent": {"kind": "zeros", "shape": [0, 64]},
472+
},
459473
"audio_features": {
460474
"presence": "audio",
461475
"absent": {"kind": "zeros", "shape": [0, 64]},
462-
}
476+
},
477+
}
478+
assert emitted_yaml["pipeline"]["phases"]["vision_encoder"] == {
479+
"run_on": "prompt_only",
480+
"when_present": "image",
463481
}
464482
assert emitted_yaml["pipeline"]["phases"]["audio_encoder"] == {
465483
"run_on": "prompt_only",
466484
"when_present": "audio",
467485
}
486+
vision_stage = next(
487+
stage
488+
for stage in metadata["pipeline"]["strategy"]["stages"]
489+
if stage["strategy"].get("model") == "vision_encoder"
490+
)
491+
assert vision_stage["run_on"] == "prompt_only"
468492
audio_stage = next(
469493
stage
470494
for stage in metadata["pipeline"]["strategy"]["stages"]

src/mobius/tasks/_gemma4.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ def _build_vision(
362362

363363
builder.add_output(image_features, "image_features")
364364

365+
declare_component_presence(graph, "image")
365366
return _make_model(graph)
366367

367368
def _build_audio(
@@ -455,6 +456,11 @@ def _build_embedding(
455456
dtype=config.dtype,
456457
shape=[num_image_tokens, config.hidden_size],
457458
)
459+
declare_optional_input(
460+
image_features,
461+
presence="image",
462+
absent_shape=[0, config.hidden_size],
463+
)
458464

459465
audio_features_val: ir.Value | None = None
460466

@@ -547,6 +553,7 @@ def _build_vision(
547553
pixel_position_ids=pixel_position_ids,
548554
)
549555
builder.add_output(image_features, "image_features")
556+
declare_component_presence(graph, "image")
550557
return _make_model(graph)
551558

552559
def _build_audio(

tests/build_graph_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1255,12 +1255,18 @@ def test_gemma4_multimodal_graph(self):
12551255
assert "pixel_values" in vision_input_names
12561256
assert "pixel_position_ids" in vision_input_names
12571257
assert "image_features" in {o.name for o in vision.graph.outputs}
1258+
assert component_presence(vision.graph) == "image"
12581259
# Embedding: input_ids + image_features (no audio) -> inputs_embeds
12591260
embedding = pkg["embedding"]
12601261
emb_input_names = {i.name for i in embedding.graph.inputs}
12611262
assert "input_ids" in emb_input_names
12621263
assert "image_features" in emb_input_names
12631264
assert "audio_features" not in emb_input_names
1265+
embedding_image = next(i for i in embedding.graph.inputs if i.name == "image_features")
1266+
assert optional_input_contract(embedding_image) == {
1267+
"presence": "image",
1268+
"absent": {"kind": "zeros", "shape": [0, config.hidden_size]},
1269+
}
12641270
assert "inputs_embeds" in {o.name for o in embedding.graph.outputs}
12651271

12661272
def test_gemma4_kv_shared_fallback_attention_is_causal_zero(self):
@@ -1657,6 +1663,7 @@ def test_gemma4_any_to_any_graph(self):
16571663
assert "pixel_values" in vision_input_names
16581664
assert "pixel_position_ids" in vision_input_names
16591665
assert "image_features" in {o.name for o in vision.graph.outputs}
1666+
assert component_presence(vision.graph) == "image"
16601667
# Audio encoder
16611668
audio = pkg["audio_encoder"]
16621669
audio_input_names = {i.name for i in audio.graph.inputs}
@@ -1672,6 +1679,11 @@ def test_gemma4_any_to_any_graph(self):
16721679
assert "input_ids" in emb_input_names
16731680
assert "image_features" in emb_input_names
16741681
assert "audio_features" in emb_input_names
1682+
embedding_image = next(i for i in embedding.graph.inputs if i.name == "image_features")
1683+
assert optional_input_contract(embedding_image) == {
1684+
"presence": "image",
1685+
"absent": {"kind": "zeros", "shape": [0, config.hidden_size]},
1686+
}
16751687
embedding_audio = next(i for i in embedding.graph.inputs if i.name == "audio_features")
16761688
assert optional_input_contract(embedding_audio) == {
16771689
"presence": "audio",
@@ -1796,6 +1808,7 @@ def test_gemma4_unified_multimodal_graph(self):
17961808
v_inputs = {i.name for i in vision.graph.inputs}
17971809
assert v_inputs == {"pixel_values", "pixel_position_ids"}
17981810
assert "image_features" in {o.name for o in vision.graph.outputs}
1811+
assert component_presence(vision.graph) == "image"
17991812

18001813
# Audio embedder: raw frames + mask → audio_features
18011814
audio = pkg["audio_encoder"]
@@ -1809,6 +1822,11 @@ def test_gemma4_unified_multimodal_graph(self):
18091822
embedding = pkg["embedding"]
18101823
e_inputs = {i.name for i in embedding.graph.inputs}
18111824
assert {"input_ids", "image_features", "audio_features"} <= e_inputs
1825+
embedding_image = next(i for i in embedding.graph.inputs if i.name == "image_features")
1826+
assert optional_input_contract(embedding_image) == {
1827+
"presence": "image",
1828+
"absent": {"kind": "zeros", "shape": [0, config.hidden_size]},
1829+
}
18121830
embedding_audio = next(i for i in embedding.graph.inputs if i.name == "audio_features")
18131831
assert optional_input_contract(embedding_audio) == {
18141832
"presence": "audio",

0 commit comments

Comments
 (0)