Skip to content

feat(cpu): add Qwen3.5 0.8B single-image multimodal support - #695

Merged
chenghuaWang merged 1 commit into
UbiquitousLearning:mainfrom
Aharrypotter:feat/qwen35-0.8b-single-image-multimodal-production
Aug 9, 2026
Merged

feat(cpu): add Qwen3.5 0.8B single-image multimodal support#695
chenghuaWang merged 1 commit into
UbiquitousLearning:mainfrom
Aharrypotter:feat/qwen35-0.8b-single-image-multimodal-production

Conversation

@Aharrypotter

@Aharrypotter Aharrypotter commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Adds an end-to-end Qwen3.5-0.8B single-image multimodal path to the existing
mllm mobile CPU backend:

  • Qwen3.5 vision tower with official single-image preprocessing and patch layout
  • one-image placeholder expansion and visual-embedding injection
  • block-major VisionRoPE and 3-axis interleaved text MRoPE
  • strict checkpoint/conversion validation for the multimodal model-file v2 artifact
  • desktop and Android runner support while preserving the 0.8B/4B text-only paths

This PR deliberately targets one production contract: one still image, batch
size 1, Qwen3.5-0.8B
. Video, multiple images, MTP, 4B multimodal, and
performance optimization are excluded.

End-to-end demo

Input image OnePlus 13T output
Two cats on a pink bed Prompt

What animals are shown in this image? Answer in one short sentence.

Qwen3.5-0.8B Multimodal

Two cats are sleeping on a pink bed.

The exact candidate generated 9 tokens on the primary OnePlus 13T after
reverifying the model/artifact identities and both loaded runtime libraries.

Review guide

Area Main files What to review
Model contract configuration_qwen3_5.hpp, config_0.8B_multimodal_w4a32_kai.json strict vision/text geometry, token IDs, MRoPE settings, image envelope, and text-only compatibility
Image preprocessing image_preprocessor_qwen3_5.hpp smart resize, normalization, temporal duplication, and block-major patch layout
Vision tower modeling_qwen3_5_vision.hpp Conv3D patch embedding, learned bilinear positions, dense vision attention, VisionRoPE, MLP blocks, and merger
Multimodal integration multimodal_qwen3_5.hpp, modeling_qwen3_5.hpp, tokenization_qwen3_5.hpp placeholder expansion, token-type span, 3-axis MRoPE, embedding replacement, decode advance, and reset/error paths
Conversion and tests validate_*.py, main.cpp, Qwen35MultimodalTest.cpp complete descriptor/offset audit, CLI behavior, oracle coverage, and fail-fast unsupported inputs

Suggested review order: model contract → preprocessing → vision tower →
multimodal integration → conversion/tests
.

Supported production contract
Surface Qwen3.5-0.8B multimodal
Input Exactly one still image; batch size 1
Image envelope Aspect ratio ≤ 200; smart-resized to 65,536–262,144 pixels; height/width divisible by 32
Vision tower 12 blocks; hidden size 768; 12 heads; patch size 16; temporal patch size 2; spatial merge size 2
Position encoding Block-major VisionRoPE plus 3-axis interleaved text MRoPE (11/11/10)
Runtime Existing 24-layer 0.8B text tower; cache length up to 2048 tokens
Quantization W4A8 through the existing KAI Linear path; non-Linear vision parameters remain FP32

The runner rejects images with a text-only config or benchmark mode and checks
the runtime/model contract before model construction.

Current-head validation

Candidate HEAD: f10ea711e27c53a6c4505686951897c71f4376e7
Merged-main base: 9a0a21ded8567076c37edb17f91f639a031500a3

Gate Result
macOS arm64 Release runner/tests built; 39/39 C++, 10/10 Python, and 7/7 pinned semantic-oracle checks passed
H20 Linux x86-64 Fresh GCC 13.3 Release build; 38 RUN / 37 PASS / 1 expected platform skip / 0 fail; 10/10 Python validators passed
Android cross-build NDK r28b/API 28 arm64-v8a Release build plus ELF, Build ID, dependency, symbol, and deployment-manifest audits passed
OnePlus 13T primary device 39 RUN / 38 PASS / 1 expected platform skip / 0 fail; candidate-library load proof and real-image generation passed
Pixel 9 Pro XL auxiliary device Same manifest/model hashes; 39 RUN / 38 PASS / 1 expected platform skip / 0 fail plus real-image smoke passed
Full validation matrix
Gate Status Evidence
Local Release build PASS runner and seven focused test binaries built on macOS arm64
Local C++ focused tests PASS GDN 6, GDN Conv 6, KAI packing 3, tokenizer 4, config 6, multimodal 10, benchmark harness 4: 39/39
Python validator tests PASS 10/10, including the official multimodal recipe/descriptors and contract-drift rejection
Pinned Transformers semantic oracle PASS 7/7: smart-resize geometry, vision positions, bilinear interpolation, single-image MRoPE positions, interleaved MRoPE, block-major patch layout, and exact merger GELU
Official pinned checkpoint config PASS official extra metadata accepted while runtime-relevant vision fields remain strict; 287 KAI-selected parameters
Existing 0.8B text-only regression PASS current runner plus SHA-bound model/tokenizer completed a real one-token prompt (TOKEN_ID:3833, One), preserving the #690 path
macOS single-image product smoke PASS descriptor-audited model plus SHA-bound tokenizer/config/image completed one visual prefill/decode token (TOKEN_ID:332)
Static and CLI checks PASS clang-format, Black, Python byte-compile, JSON syntax, diff check, runner --help, and negative image/config/benchmark cases
H20 source and Linux gate PASS immutable regular-file/symlink manifests; fresh GCC 13.3 build; 38 RUN / 37 PASS / 1 expected macOS-only skip / 0 fail; the ARM-specific third KAI case is not registered on x86
Android NDK gate PASS fresh NDK r28b/API 28 build; AArch64 ELF, Build ID, dependencies, dynamic symbols, and 16-entry SHA-256 deployment manifest verified
OnePlus 13T identity and focused gate PASS pinned host key; OnePlus/PKX110, UID 10388, Android 16/API 36; manifest-bound deployment; 39 RUN / 38 PASS / 1 expected macOS-only skip / 0 fail
OnePlus real-image runs PASS both runtime libraries loaded from the isolated candidate directory; one-token smoke returned TOKEN_ID:332; 9-token demo returned Two cats are sleeping on a pink bed.
Pixel auxiliary portability gate PASS identity-bound Google/Tensor G4 device; identical manifest/model hashes; 39 RUN / 38 PASS / 1 expected macOS-only skip / 0 fail, candidate-library load proof, and TOKEN_ID:332 smoke

The semantic oracle is pinned to Transformers commit
dfff6dc70d3fffadf539353743a9e176af8109e9. It proves the geometry and layout
contracts, not bitwise equality with PIL bicubic interpolation. Device smoke
and the demo are end-to-end integration evidence, not formal image-quality,
numerical-parity, or performance claims.

Implementation and quantization notes
  • Implements Conv3D patch embedding, learned bilinear position interpolation,
    dense vision attention, VisionRoPE, and the exact-GELU merger.
  • Expands exactly one image placeholder, constructs its token-type span, and
    replaces only that span with projected visual embeddings.
  • Builds official temporal/height/width positions for prefill and advances all
    three axes consistently during decode.
  • Audits KAI-packed Linear descriptors, FP32 descriptors, tensor geometry,
    offsets, and the absence of unsupported MTP tensors.
  • Clears the full-attention KV cache and all GDN recurrent/convolution state for
    every prompt.

The user-facing quantization contract is W4A8: KAI Linear dynamically
quantizes activations to INT8 and uses packed INT4 weights with FP32 operator
inputs/outputs. Existing w4a32_kai names remain unchanged for tooling
compatibility. Vision Conv3D, learned positions, LayerNorm, embeddings,
recurrent parameters, and convolution weights remain FP32.

How to use it

examples/qwen3_5/README.md documents checkpoint validation, multimodal
conversion, desktop/Android builds, and runner invocation.

Known limits

  • one image and batch size 1 only; no video, multiple images, deep-stack visual features, or MTP
  • Qwen3.5-4B multimodal support is intentionally deferred
  • cache length is limited to 2048 tokens; multimodal benchmark mode is rejected
  • no formal image-quality, numerical-parity, or performance claim is made

Tracks #644.

Extends the Qwen3.5-0.8B and 4B mobile CPU support merged in #690 and #691,
respectively, and is based on the merged GDN/benchmark work in #693.

Summary by CodeRabbit

  • New Features

    • Added single-image support for Qwen3.5 0.8B models, including image preprocessing, vision encoding, and multimodal generation.
    • Added CLI image input through --image_path.
    • Added official multimodal model configuration and quantization support.
    • Added validation for image dimensions, formats, model settings, and multimodal inputs.
  • Documentation

    • Updated Qwen3.5 documentation with supported modalities, image constraints, runtime examples, and conversion guidance.
  • Tests

    • Added comprehensive coverage for image processing, multimodal generation, configuration validation, and error handling.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Qwen3.5 0.8B now supports single-image inference. The change adds vision configuration, image preprocessing, a vision transformer, multimodal token and position handling, runtime integration, CLI options, checkpoint validation, conversion recipes, documentation, and tests.

Changes

Qwen3.5 multimodal support

Layer / File(s) Summary
Multimodal contracts and checkpoint validation
mllm/models/qwen3_5/configuration_qwen3_5.hpp, examples/qwen3_5/validate_*.py, examples/qwen3_5/config_0.8B_multimodal_w4a32_kai.json, examples/qwen3_5/quant_cfg_0.8B_multimodal_w4a32_kai.json, tests/cpu/Qwen35ConfigTest.cpp, examples/qwen3_5/test_validators.py
Adds vision configuration parsing, runtime contracts, quantization mappings, tensor-shape checks, multimodal checkpoint auditing, and contract tests.
Image and vision processing
mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp, mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp, mllm/models/qwen3_5/multimodal_qwen3_5.hpp, tests/cpu/Qwen35MultimodalTest.cpp
Adds image resizing, normalization, patch extraction, vision transformer components, rotary embeddings, position IDs, placeholder expansion, and mathematical validation tests.
Tokenizer and multimodal runtime flow
mllm/models/qwen3_5/tokenization_qwen3_5.hpp, mllm/models/qwen3_5/modeling_qwen3_5.hpp, examples/qwen3_5/main.cpp, examples/qwen3_5/README.md, tests/cpu/CMakeLists.txt, tests/cpu/Qwen35MultimodalTest.cpp
Connects image paths to tokenization, vision encoding, embedding-based forwarding, CLI validation, interactive inference, state reset, and runtime tests. The README documents the new commands and model limits.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Qwen3_5CLI
  participant Qwen3_5Tokenizer
  participant Qwen3_5Model
  participant Qwen3_5VisionModel
  Qwen3_5CLI->>Qwen3_5Tokenizer: convert prompt and image path
  Qwen3_5Tokenizer->>Qwen3_5Tokenizer: preprocess image and expand placeholders
  Qwen3_5Tokenizer->>Qwen3_5Model: tokens, pixel values, grid, and token types
  Qwen3_5Model->>Qwen3_5VisionModel: encode image
  Qwen3_5VisionModel->>Qwen3_5Model: image embeddings
  Qwen3_5Model->>Qwen3_5Model: replace image tokens and forward embeddings
Loading

Possibly related PRs

Suggested reviewers: chenghuawang, oreomaker

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the implementation, scope, supported contract, validation evidence, usage, and known limits.
Title check ✅ Passed The title clearly and concisely identifies the main change: Qwen3.5 0.8B single-image multimodal CPU support.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Aharrypotter
Aharrypotter marked this pull request as ready for review August 9, 2026 01:52

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (5)
tests/cpu/Qwen35MultimodalTest.cpp (1)

210-213: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Initialize the tensors used in the rejection test.

Tensor::empty(...).alloc() leaves the buffers uninitialized. Both cases currently throw before the model reads any element, so the test passes. If the validation order in Qwen3_5ForCausalLM::forward changes, the test reads indeterminate token IDs and becomes non-deterministic. Fill sequence, image_grid, and token_types with valid values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cpu/Qwen35MultimodalTest.cpp` around lines 210 - 213, The rejection
test must initialize its input tensors before invoking the model. In the test
setup around sequence, image_grid, and token_types, fill each allocated buffer
with valid deterministic values while preserving the existing tensor shapes and
types; pixel_values does not require changes unless the test reads it.
mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp (2)

269-270: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the vision LayerNorm epsilon into the config.

1.0e-6F is hard-coded in Qwen3_5VisionBlock and Qwen3_5VisionPatchMerger. The text tower reads cfg.rms_norm_eps. Add a vision_layer_norm_eps field to Qwen3_5Config and read it here. This keeps the epsilon aligned with the checkpoint vision_config and removes the magic value.

Also applies to: 295-295

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp` around lines 269 - 270, Add
a vision_layer_norm_eps field to Qwen3_5Config, populated from the checkpoint
vision_config, and replace the hard-coded 1.0e-6F epsilon in Qwen3_5VisionBlock
and Qwen3_5VisionPatchMerger LayerNorm construction with
cfg.vision_layer_norm_eps.

67-77: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Precompute the inverse frequencies outside the sequence loop.

std::pow runs once per (sequence, axis, dim) element, but the value depends only on d. Compute the inv_freq_dim values once and reuse them. This removes sequence * 2 redundant std::pow calls per element.

♻️ Proposed refactor
+  std::vector<float> inv_freqs(static_cast<size_t>(inv_freq_dim));
+  for (int32_t d = 0; d < inv_freq_dim; ++d) {
+    inv_freqs[d] = 1.0F / std::pow(theta, static_cast<float>(2 * d) / axis_dim);
+  }
   for (int32_t s = 0; s < sequence; ++s) {
     for (int32_t axis = 0; axis < 2; ++axis) {
       for (int32_t d = 0; d < inv_freq_dim; ++d) {
-        const float inv_freq = 1.0F / std::pow(theta, static_cast<float>(2 * d) / axis_dim);
-        const float value = static_cast<float>(positions[s * 2 + axis]) * inv_freq;
+        const float value = static_cast<float>(positions[s * 2 + axis]) * inv_freqs[d];

Add #include <vector> to the header includes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp` around lines 67 - 77, In the
vision rotary-frequency computation, precompute the `inv_freq_dim`
inverse-frequency values once before the sequence loop, storing them in a
vector, and reuse them for both axes and every sequence position instead of
calling `std::pow` inside the nested loops. Add the required vector include and
preserve the existing `sin_ptr`/`cos_ptr` indexing and outputs.
mllm/models/qwen3_5/multimodal_qwen3_5.hpp (1)

17-19: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Public API documentation is missing across the three new Qwen3.5 headers. Each header defines public entities with strict, undocumented tensor contracts. Callers must currently read the validation code to learn the required shapes, dtypes, layouts, and error conditions.

  • mllm/models/qwen3_5/multimodal_qwen3_5.hpp#L17-L19: document expandQwen3_5SingleImagePlaceholders, makeQwen3_5InterleavedRotaryEmbedding, makeQwen3_5SingleImagePositionIds, and advanceQwen3_5PositionIds, including the [3,1,S] position layout and the std::invalid_argument conditions.
  • mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp#L20-L24: document makeQwen3_5VisionPositionIds, makeQwen3_5VisionRotaryEmbedding, makeQwen3_5VisionBilinearPositionEmbedding, and qwen3_5ExactGelu, including the block-major output order.
  • mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp#L18-L30: document Qwen3_5ImagePreprocessor, its five geometry parameters, and the returned patch and grid tensors.

As per coding guidelines: "Ensure public APIs, classes, and functions have clear docstrings or comments explaining purpose, parameters, returns, and errors."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp` around lines 17 - 19, Add clear
API documentation to all three affected headers: in
mllm/models/qwen3_5/multimodal_qwen3_5.hpp lines 17-19, document
expandQwen3_5SingleImagePlaceholders, makeQwen3_5InterleavedRotaryEmbedding,
makeQwen3_5SingleImagePositionIds, and advanceQwen3_5PositionIds, including
tensor contracts, the [3,1,S] position layout, and std::invalid_argument
conditions; in mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp lines 20-24,
document makeQwen3_5VisionPositionIds, makeQwen3_5VisionRotaryEmbedding,
makeQwen3_5VisionBilinearPositionEmbedding, and qwen3_5ExactGelu, including
block-major output order; in mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp
lines 18-30, document Qwen3_5ImagePreprocessor, its five geometry parameters,
and the returned patch and grid tensors.

Source: Coding guidelines

mllm/models/qwen3_5/tokenization_qwen3_5.hpp (1)

385-388: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Derive the image token ID from one source.

The tokenizer resolves the image token through bpe_._lookup_vocab(L"<|image_pad|>"). Qwen3_5ForCausalLM compares against cfg.image_token_id. Two independent sources define the same value. A mismatch surfaces late as "Qwen3.5 image token IDs and modality token types disagree", which does not name the real cause.

Pass the configured image_token_id into the tokenizer, or validate the looked-up ID against the config once at construction and throw a message that names both values.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/tokenization_qwen3_5.hpp` around lines 385 - 388, Unify
the image token ID used by tokenization and Qwen3_5ForCausalLM by passing the
configured cfg.image_token_id into the tokenizer, or validating it against
bpe_._lookup_vocab(L"<|image_pad|>") during construction. If validating, throw
an error that explicitly includes both IDs; update
expandQwen3_5SingleImagePlaceholders usage to rely on the single
validated/configured value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp`:
- Around line 73-83: Update flattenNormalizedPatches to reject non-contiguous
image_hwc tensors alongside its existing dtype, device, and shape validation.
Use image_hwc.isContiguous(), matching the validation pattern in
qwen3_5ExactGelu, and preserve the existing invalid_argument behavior for
invalid inputs.

In `@mllm/models/qwen3_5/modeling_qwen3_5.hpp`:
- Around line 664-675: Update the validation loop in the Qwen3.5 single-image
path to require all image tokens marked by types[s] == 1 to form one contiguous
span starting at image_begin; reject any later image token after a non-image
token. Perform this check regardless of whether position_ids is supplied, before
the slice written at the subsequent image-feature assignment.
- Around line 515-530: The forwardEmbeddings entry path currently bypasses
module-level dispatch and tracing. Update Qwen3_5’s forwardEmbeddings method to
invoke the module’s __main dispatch, passing the embedding inputs and kv_cache
through it, while preserving forwardEmbeddingsImpl as the underlying
implementation used by the dispatched call.

In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp`:
- Around line 181-188: Update the next-position calculation in the cached
multimodal position flow to compute the maximum of the three final axis values,
add one, and assign that single value to every axis in the returned tensor. Keep
the existing shape validation and tensor allocation unchanged.

In `@mllm/models/qwen3_5/tokenization_qwen3_5.hpp`:
- Around line 359-366: Extend the kReservedMarkers list in the prompt validation
loop to include both "<|im_start|>" and "<|im_end|>", preserving the existing
invalid_argument behavior for any prompt containing reserved multimodal or
chat-control markers.

In `@tests/cpu/Qwen35MultimodalTest.cpp`:
- Around line 6-9: Update the include list in Qwen35MultimodalTest to explicitly
add the standard headers <algorithm> for std::fill, std::copy, and std::equal,
and <utility> for std::pair, without relying on transitive includes.
- Around line 156-159: Widen the EXPECT_NEAR tolerances in the exact-GELU
assertions within qwen3_5ExactGelu to 1.0e-6F for the nonzero expected outputs,
while leaving the EXPECT_FLOAT_EQ zero assertion unchanged.

---

Nitpick comments:
In `@mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp`:
- Around line 269-270: Add a vision_layer_norm_eps field to Qwen3_5Config,
populated from the checkpoint vision_config, and replace the hard-coded 1.0e-6F
epsilon in Qwen3_5VisionBlock and Qwen3_5VisionPatchMerger LayerNorm
construction with cfg.vision_layer_norm_eps.
- Around line 67-77: In the vision rotary-frequency computation, precompute the
`inv_freq_dim` inverse-frequency values once before the sequence loop, storing
them in a vector, and reuse them for both axes and every sequence position
instead of calling `std::pow` inside the nested loops. Add the required vector
include and preserve the existing `sin_ptr`/`cos_ptr` indexing and outputs.

In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp`:
- Around line 17-19: Add clear API documentation to all three affected headers:
in mllm/models/qwen3_5/multimodal_qwen3_5.hpp lines 17-19, document
expandQwen3_5SingleImagePlaceholders, makeQwen3_5InterleavedRotaryEmbedding,
makeQwen3_5SingleImagePositionIds, and advanceQwen3_5PositionIds, including
tensor contracts, the [3,1,S] position layout, and std::invalid_argument
conditions; in mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp lines 20-24,
document makeQwen3_5VisionPositionIds, makeQwen3_5VisionRotaryEmbedding,
makeQwen3_5VisionBilinearPositionEmbedding, and qwen3_5ExactGelu, including
block-major output order; in mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp
lines 18-30, document Qwen3_5ImagePreprocessor, its five geometry parameters,
and the returned patch and grid tensors.

In `@mllm/models/qwen3_5/tokenization_qwen3_5.hpp`:
- Around line 385-388: Unify the image token ID used by tokenization and
Qwen3_5ForCausalLM by passing the configured cfg.image_token_id into the
tokenizer, or validating it against bpe_._lookup_vocab(L"<|image_pad|>") during
construction. If validating, throw an error that explicitly includes both IDs;
update expandQwen3_5SingleImagePlaceholders usage to rely on the single
validated/configured value.

In `@tests/cpu/Qwen35MultimodalTest.cpp`:
- Around line 210-213: The rejection test must initialize its input tensors
before invoking the model. In the test setup around sequence, image_grid, and
token_types, fill each allocated buffer with valid deterministic values while
preserving the existing tensor shapes and types; pixel_values does not require
changes unless the test reads it.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: a9a4b884-85c1-443a-9b7c-b9595cfcbcaf

📥 Commits

Reviewing files that changed from the base of the PR and between 9a0a21d and f10ea71.

📒 Files selected for processing (16)
  • examples/qwen3_5/README.md
  • examples/qwen3_5/config_0.8B_multimodal_w4a32_kai.json
  • examples/qwen3_5/main.cpp
  • examples/qwen3_5/quant_cfg_0.8B_multimodal_w4a32_kai.json
  • examples/qwen3_5/test_validators.py
  • examples/qwen3_5/validate_checkpoint.py
  • examples/qwen3_5/validate_converted_model.py
  • mllm/models/qwen3_5/configuration_qwen3_5.hpp
  • mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp
  • mllm/models/qwen3_5/modeling_qwen3_5.hpp
  • mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp
  • mllm/models/qwen3_5/multimodal_qwen3_5.hpp
  • mllm/models/qwen3_5/tokenization_qwen3_5.hpp
  • tests/cpu/CMakeLists.txt
  • tests/cpu/Qwen35ConfigTest.cpp
  • tests/cpu/Qwen35MultimodalTest.cpp

Comment on lines +73 to +83
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& image_hwc) const {
const auto& shape = image_hwc.shape();
if (image_hwc.dtype() != kFloat32 || image_hwc.device() != kCPU || shape.size() != 3 || shape[2] != 3) {
throw std::invalid_argument("Qwen3.5 preprocessor expects a float32 CPU RGB tensor in HWC layout");
}
const int32_t height = shape[0];
const int32_t width = shape[1];
const int32_t factor = patch_size_ * merge_size_;
if (height <= 0 || width <= 0 || height % factor != 0 || width % factor != 0) {
throw std::invalid_argument("Qwen3.5 resized image dimensions must be divisible by patch_size * merge_size");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add a contiguity check on image_hwc.

The function reads image_hwc.ptr<float>() with computed HWC offsets. A non-contiguous input produces silently wrong patch data. qwen3_5ExactGelu in mllm/models/qwen3_5/modeling_qwen3_5_vision.hpp already validates isContiguous(). Apply the same check here, because this method is public and documented as test-facing.

🛡️ Proposed fix
-    if (image_hwc.dtype() != kFloat32 || image_hwc.device() != kCPU || shape.size() != 3 || shape[2] != 3) {
+    if (image_hwc.dtype() != kFloat32 || image_hwc.device() != kCPU || !image_hwc.isContiguous() || shape.size() != 3
+        || shape[2] != 3) {
       throw std::invalid_argument("Qwen3.5 preprocessor expects a float32 CPU RGB tensor in HWC layout");
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& image_hwc) const {
const auto& shape = image_hwc.shape();
if (image_hwc.dtype() != kFloat32 || image_hwc.device() != kCPU || shape.size() != 3 || shape[2] != 3) {
throw std::invalid_argument("Qwen3.5 preprocessor expects a float32 CPU RGB tensor in HWC layout");
}
const int32_t height = shape[0];
const int32_t width = shape[1];
const int32_t factor = patch_size_ * merge_size_;
if (height <= 0 || width <= 0 || height % factor != 0 || width % factor != 0) {
throw std::invalid_argument("Qwen3.5 resized image dimensions must be divisible by patch_size * merge_size");
}
[[nodiscard]] std::pair<Tensor, Tensor> flattenNormalizedPatches(const Tensor& image_hwc) const {
const auto& shape = image_hwc.shape();
if (image_hwc.dtype() != kFloat32 || image_hwc.device() != kCPU || !image_hwc.isContiguous() || shape.size() != 3
|| shape[2] != 3) {
throw std::invalid_argument("Qwen3.5 preprocessor expects a float32 CPU RGB tensor in HWC layout");
}
const int32_t height = shape[0];
const int32_t width = shape[1];
const int32_t factor = patch_size_ * merge_size_;
if (height <= 0 || width <= 0 || height % factor != 0 || width % factor != 0) {
throw std::invalid_argument("Qwen3.5 resized image dimensions must be divisible by patch_size * merge_size");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/image_preprocessor_qwen3_5.hpp` around lines 73 - 83,
Update flattenNormalizedPatches to reject non-contiguous image_hwc tensors
alongside its existing dtype, device, and shape validation. Use
image_hwc.isContiguous(), matching the validation pattern in qwen3_5ExactGelu,
and preserve the existing invalid_argument behavior for invalid inputs.

Comment on lines +515 to +530
Tensor embed(Tensor input_ids) { return embedding_(input_ids); }

std::vector<Tensor> forwardEmbeddings(Tensor x, Tensor llm_embedding_sin, Tensor llm_embedding_cos,
const AnyValue& kv_cache) {
std::vector<AnyValue> args = {kv_cache};
return forwardEmbeddingsImpl(x, llm_embedding_sin, llm_embedding_cos, args);
}

std::vector<Tensor> forward(const std::vector<Tensor>& inputs, const std::vector<AnyValue>& args) override {
auto x = embedding_(inputs[0]);
auto llm_embedding_sin = inputs[1];
auto llm_embedding_cos = inputs[2];
return forwardEmbeddingsImpl(x, inputs[1], inputs[2], args);
}

private:
std::vector<Tensor> forwardEmbeddingsImpl(Tensor x, Tensor llm_embedding_sin, Tensor llm_embedding_cos,
const std::vector<AnyValue>& args) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Inspect what nn::Module::__main does around forward().
ast-grep run --pattern '__main($$$) { $$$ }' --lang cpp mllm/nn/Module.hpp
rg -nP -C15 '__main' --glob '**/nn/Module.*'

Repository: UbiquitousLearning/mllm

Length of output: 161


🏁 Script executed:

#!/bin/bash
set -e
printf 'Tracked nn/ files:\n'
git ls-files 'mllm/nn/*' | sed -n '1,120p'

printf '\nModule-like files:\n'
fd -a 'Module|module' . | sed -n '1,120p'

printf '\nSearch __main occurrences:\n'
rg -n -C5 '__main' --glob '*.hpp' --glob '*.h' --glob '*.cpp' --glob '*.cc' . || true

printf '\nSearch operator() and forward implementations:\n'
rg -n -C6 'struct Module|class Module|operator\(|virtual Tensor forward|Tensor forward\(' --glob '*.hpp' --glob '*.h' --glob '*.cpp' --glob '*.cc' . | sed -n '1,240p'

Repository: UbiquitousLearning/mllm

Length of output: 28113


🏁 Script executed:

#!/bin/bash
set -e
printf 'module.cpp __main:\n'
sed -n '100,220p' mllm/nn/Module.cpp

printf '\nmodule.hpp operator/__main declarations:\n'
sed -n '90,230p' mllm/nn/Module.hpp

printf '\nqwen3_5 model section:\n'
sed -n '490,550p' mllm/models/qwen3_5/modeling_qwen3_5.hpp

printf '\ncontext profiling/trace definitions:\n'
rg -n -C7 'trace_mode|thread_tracer|profile|planner|Plan|SubmitTask|TaskTypes::kExecuteModule|send_graph' --glob '*.hpp' --glob '*.cpp' --glob '*.h' mllm | sed -n '1,260p'

Repository: UbiquitousLearning/mllm

Length of output: 30221


Route forwardEmbeddings through Module::__main.

llm(...) uses operator(), which calls __main, submitting kGraphBegin/kGraphEnd tasks and either tracing or submitting an execution task. forwardEmbeddings calls forwardEmbeddingsImpl directly, so the image prefill path bypasses that module-level dispatch/tracing bookkeeping. Use __main(...) for this entry path as well.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/modeling_qwen3_5.hpp` around lines 515 - 530, The
forwardEmbeddings entry path currently bypasses module-level dispatch and
tracing. Update Qwen3_5’s forwardEmbeddings method to invoke the module’s __main
dispatch, passing the embedding inputs and kv_cache through it, while preserving
forwardEmbeddingsImpl as the underlying implementation used by the dispatched
call.

Comment on lines +664 to +675
for (int32_t s = 0; s < seq_len; ++s) {
if (input_ids[s] == video_token_id_ || types[s] == 2) {
throw std::invalid_argument("Qwen3.5 CPU single-image support does not accept video tokens");
}
if ((input_ids[s] == image_token_id_) != (types[s] == 1)) {
throw std::invalid_argument("Qwen3.5 image token IDs and modality token types disagree");
}
if (types[s] == 1) {
if (image_begin < 0) image_begin = s;
++image_count;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reject a non-contiguous image token span.

The loop records image_begin and image_count, but does not verify that the image tokens are contiguous. Line 684 then writes into the slice {image_begin, image_begin + image_count}. For token types such as {1, 0, 1}, that slice covers a text position, and the text embedding is replaced by an image feature.

makeQwen3_5SingleImagePositionIds rejects a split span, but it runs only when position_ids is absent. A caller that supplies both position_ids and pixel_values reaches this code with no contiguity check.

🛡️ Proposed fix
         if (types[s] == 1) {
           if (image_begin < 0) image_begin = s;
+          if (s != image_begin + image_count) {
+            throw std::invalid_argument("Qwen3.5 supports exactly one contiguous image token span");
+          }
           ++image_count;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (int32_t s = 0; s < seq_len; ++s) {
if (input_ids[s] == video_token_id_ || types[s] == 2) {
throw std::invalid_argument("Qwen3.5 CPU single-image support does not accept video tokens");
}
if ((input_ids[s] == image_token_id_) != (types[s] == 1)) {
throw std::invalid_argument("Qwen3.5 image token IDs and modality token types disagree");
}
if (types[s] == 1) {
if (image_begin < 0) image_begin = s;
++image_count;
}
}
for (int32_t s = 0; s < seq_len; ++s) {
if (input_ids[s] == video_token_id_ || types[s] == 2) {
throw std::invalid_argument("Qwen3.5 CPU single-image support does not accept video tokens");
}
if ((input_ids[s] == image_token_id_) != (types[s] == 1)) {
throw std::invalid_argument("Qwen3.5 image token IDs and modality token types disagree");
}
if (types[s] == 1) {
if (image_begin < 0) image_begin = s;
if (s != image_begin + image_count) {
throw std::invalid_argument("Qwen3.5 supports exactly one contiguous image token span");
}
+image_count;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/modeling_qwen3_5.hpp` around lines 664 - 675, Update the
validation loop in the Qwen3.5 single-image path to require all image tokens
marked by types[s] == 1 to form one contiguous span starting at image_begin;
reject any later image token after a non-image token. Perform this check
regardless of whether position_ids is supplied, before the slice written at the
subsequent image-feature assignment.

Comment on lines +181 to +188
if (shape[0] != 3 || shape[1] != 1 || shape[2] <= 0) {
throw std::invalid_argument("Qwen3.5 cached multimodal positions must have shape [3,1,S]");
}
auto next = Tensor::empty({3, 1, 1}, kInt64, kCPU).alloc();
const auto* previous = previous_position_ids.ptr<int64_t>();
auto* output = next.ptr<int64_t>();
for (int32_t axis = 0; axis < 3; ++axis) { output[axis] = previous[axis * shape[2] + shape[2] - 1] + 1; }
return next;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Advance all three MRoPE axes from the maximum last position.

The reference MRoPE decode step computes one next position as max(last_t, last_h, last_w) + 1 and assigns it to all three axes. This code advances each axis from its own last value. The two forms agree only when the last prefill token is a text token, because the text tail writes the same value to all axes. If an image span ever ends the sequence, the axes diverge from the reference. The fixed image template currently prevents that case, so this is defensive hardening rather than a live defect.

🛡️ Proposed fix
   auto next = Tensor::empty({3, 1, 1}, kInt64, kCPU).alloc();
   const auto* previous = previous_position_ids.ptr<int64_t>();
   auto* output = next.ptr<int64_t>();
-  for (int32_t axis = 0; axis < 3; ++axis) { output[axis] = previous[axis * shape[2] + shape[2] - 1] + 1; }
+  int64_t last = previous[shape[2] - 1];
+  for (int32_t axis = 1; axis < 3; ++axis) { last = std::max(last, previous[axis * shape[2] + shape[2] - 1]); }
+  for (int32_t axis = 0; axis < 3; ++axis) { output[axis] = last + 1; }
   return next;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (shape[0] != 3 || shape[1] != 1 || shape[2] <= 0) {
throw std::invalid_argument("Qwen3.5 cached multimodal positions must have shape [3,1,S]");
}
auto next = Tensor::empty({3, 1, 1}, kInt64, kCPU).alloc();
const auto* previous = previous_position_ids.ptr<int64_t>();
auto* output = next.ptr<int64_t>();
for (int32_t axis = 0; axis < 3; ++axis) { output[axis] = previous[axis * shape[2] + shape[2] - 1] + 1; }
return next;
if (shape[0] != 3 || shape[1] != 1 || shape[2] <= 0) {
throw std::invalid_argument("Qwen3.5 cached multimodal positions must have shape [3,1,S]");
}
auto next = Tensor::empty({3, 1, 1}, kInt64, kCPU).alloc();
const auto* previous = previous_position_ids.ptr<int64_t>();
auto* output = next.ptr<int64_t>();
int64_t last = previous[shape[2] - 1];
for (int32_t axis = 1; axis < 3; ++axis) { last = std::max(last, previous[axis * shape[2] + shape[2] - 1]); }
for (int32_t axis = 0; axis < 3; ++axis) { output[axis] = last + 1; }
return next;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/multimodal_qwen3_5.hpp` around lines 181 - 188, Update
the next-position calculation in the cached multimodal position flow to compute
the maximum of the three final axis values, add one, and assign that single
value to every axis in the returned tensor. Keep the existing shape validation
and tensor allocation unchanged.

Comment on lines +359 to +366
static constexpr std::string_view kReservedMarkers[] = {
"<|vision_start|>", "<|vision_end|>", "<|vision_pad|>", "<|image_pad|>", "<|video_pad|>",
};
for (const auto marker : kReservedMarkers) {
if (message.prompt.find(marker) != std::string::npos) {
throw std::invalid_argument("Qwen3.5 prompt must not inject reserved multimodal markers");
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Also reject the chat-control markers.

kReservedMarkers blocks the vision markers but allows <|im_start|> and <|im_end|>. A prompt that contains <|im_end|> closes the user turn early and can inject a forged assistant or system turn into the template. Add both markers to the list.

🛡️ Proposed fix
     static constexpr std::string_view kReservedMarkers[] = {
-        "<|vision_start|>", "<|vision_end|>", "<|vision_pad|>", "<|image_pad|>", "<|video_pad|>",
+        "<|im_start|>",     "<|im_end|>",     "<|vision_start|>", "<|vision_end|>",
+        "<|vision_pad|>",   "<|image_pad|>",  "<|video_pad|>",
     };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
static constexpr std::string_view kReservedMarkers[] = {
"<|vision_start|>", "<|vision_end|>", "<|vision_pad|>", "<|image_pad|>", "<|video_pad|>",
};
for (const auto marker : kReservedMarkers) {
if (message.prompt.find(marker) != std::string::npos) {
throw std::invalid_argument("Qwen3.5 prompt must not inject reserved multimodal markers");
}
}
static constexpr std::string_view kReservedMarkers[] = {
"<|im_start|>", "<|im_end|>", "<|vision_start|>", "<|vision_end|>",
"<|vision_pad|>", "<|image_pad|>", "<|video_pad|>",
};
for (const auto marker : kReservedMarkers) {
if (message.prompt.find(marker) != std::string::npos) {
throw std::invalid_argument("Qwen3.5 prompt must not inject reserved multimodal markers");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mllm/models/qwen3_5/tokenization_qwen3_5.hpp` around lines 359 - 366, Extend
the kReservedMarkers list in the prompt validation loop to include both
"<|im_start|>" and "<|im_end|>", preserving the existing invalid_argument
behavior for any prompt containing reserved multimodal or chat-control markers.

Comment on lines +6 to +9
#include <array>
#include <cmath>
#include <stdexcept>
#include <vector>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Include <algorithm> and <utility> explicitly.

The tests call std::fill, std::copy, and std::equal, which come from <algorithm>, and construct std::pair from <utility>. Neither header is included. The build currently works through transitive includes, which differ between standard libraries and can break on Windows or a different toolchain.

As per coding guidelines: "Ensure code is portable across supported platforms (e.g., Linux, Windows) unless explicitly platform-specific."

🛠️ Proposed fix
+#include <algorithm>
 `#include` <array>
 `#include` <cmath>
 `#include` <stdexcept>
+#include <utility>
 `#include` <vector>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#include <array>
#include <cmath>
#include <stdexcept>
#include <vector>
`#include` <algorithm>
`#include` <array>
`#include` <cmath>
`#include` <stdexcept>
`#include` <utility>
`#include` <vector>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cpu/Qwen35MultimodalTest.cpp` around lines 6 - 9, Update the include
list in Qwen35MultimodalTest to explicitly add the standard headers <algorithm>
for std::fill, std::copy, and std::equal, and <utility> for std::pair, without
relying on transitive includes.

Source: Coding guidelines

Comment on lines +156 to +159
const auto output = qwen3_5ExactGelu(input);
EXPECT_NEAR(output.ptr<float>()[0], -0.15865526F, 1.0e-7F);
EXPECT_FLOAT_EQ(output.ptr<float>()[1], 0.0F);
EXPECT_NEAR(output.ptr<float>()[2], 0.84134477F, 1.0e-7F);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Widen the exact-GELU tolerance.

EXPECT_NEAR(..., 1.0e-7F) is about two float32 ULPs at magnitude 0.84. std::erf(float) results differ by one or two ULPs between libm implementations, for example glibc and Android bionic. The test can fail on a supported target. Use 1.0e-6F, which still detects a wrong activation formula.

💚 Proposed fix
-  EXPECT_NEAR(output.ptr<float>()[0], -0.15865526F, 1.0e-7F);
+  EXPECT_NEAR(output.ptr<float>()[0], -0.15865526F, 1.0e-6F);
   EXPECT_FLOAT_EQ(output.ptr<float>()[1], 0.0F);
-  EXPECT_NEAR(output.ptr<float>()[2], 0.84134477F, 1.0e-7F);
+  EXPECT_NEAR(output.ptr<float>()[2], 0.84134477F, 1.0e-6F);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const auto output = qwen3_5ExactGelu(input);
EXPECT_NEAR(output.ptr<float>()[0], -0.15865526F, 1.0e-7F);
EXPECT_FLOAT_EQ(output.ptr<float>()[1], 0.0F);
EXPECT_NEAR(output.ptr<float>()[2], 0.84134477F, 1.0e-7F);
const auto output = qwen3_5ExactGelu(input);
EXPECT_NEAR(output.ptr<float>()[0], -0.15865526F, 1.0e-6F);
EXPECT_FLOAT_EQ(output.ptr<float>()[1], 0.0F);
EXPECT_NEAR(output.ptr<float>()[2], 0.84134477F, 1.0e-6F);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/cpu/Qwen35MultimodalTest.cpp` around lines 156 - 159, Widen the
EXPECT_NEAR tolerances in the exact-GELU assertions within qwen3_5ExactGelu to
1.0e-6F for the nonzero expected outputs, while leaving the EXPECT_FLOAT_EQ zero
assertion unchanged.

@chenghuaWang
chenghuaWang merged commit cc86c4c into UbiquitousLearning:main Aug 9, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants