Skip to content

feat(hip): one host protocol for the HIP OCR families - #2040

Open
laudney wants to merge 9 commits into
ashhart:mainfrom
laudney:pr-hip-ocr-plumbing
Open

laudney wants to merge 9 commits into
ashhart:mainfrom
laudney:pr-hip-ocr-plumbing

Conversation

@laudney

@laudney laudney commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Part of #1927. This is the shared HIP-OCR plumbing that you asked for on #1965. It gives one host protocol for every native HIP family, so that #1941 and #1965 stop carrying their own copies. GLM-OCR (#1940) and dots.mocr (#1938), both on main, are converted in this PR, so main keeps one path.

Concern main this PR
What a family gives the host three probe sets: @hasField on Loaded for GLM-OCR's vision, cache, cache_capacity; Serves (#1978, #2047): text_generation, embeddings, decides, rerank, readouts (set: no text generation), window; and dots.mocr's (#1938) own hooks, openVision (the --vision limits as an extra argument) and attach (writes cache, capacity, warm turns, images, policy, thinking budget, frame key and thread hook into the host after LaneHost.init) one hip_serve.Offer in Loaded.offer, with the same defaults; --vision reaches every family in hip_serve.Options.vision; no offer gives the empty one (text generation in the asked window)
Refusals before load the host reads tp_refusal, mtp_refusal, a refusal(o) hook (dots.mocr), then slide_refusal, confidence_refusal the family refusal(o) hook first (GLM-OCR: --tp, --mtp-drafts; Qwen3: --tp; dots.mocr: unchanged; same texts and order, on every rank), then the shared ones (--rope-yarn, slide, confidence)
Model types one for each family optional also_model_types aliases; api.Family.raw_text from F.raw_text when declared
--vision on a text family starts text-only without a word starts text-only and logs [tensorfold] --vision: the HIP engine has no image tower for <model_type>; serving text only, and image requests are refused
Structured output (structures) .structures = text_generation .structures = offer.text_generation and first_masked != null, as the CUDA host checks first_masked. Qwen3 embedding and reranker: false on both (no text generation, and its backend has no first_masked); the five text families and dots.mocr: true on both (all set first_masked). A new text family must set first_masked, or the host refuses its structured requests up front instead of failing them at run time with StructuresUnsupported
dots.mocr with its prompt cache off (--prompt-cache-gib 0 or --keep 0) attach hands the host the empty store; /status shows a cache line with 0 states no store, as GLM-OCR and Offer.cache's contract say; /status shows no cache line. Replies are the same: the empty store kept and found nothing
Image and cancel helpers none shared hip_serve.vision.offer(tower); hip_serve.cancel.poll and .drain on lanes.stream.CancelCheck (synchronize, then poll; a null hook does neither)
Kernel groups 12 paths; stub count written as 12 Source{ .path, .strict = false }; strict adds -O2 -fno-fast-math -fdenormal-fp-math=ieee; the stub count comes from groups.len
Family kernel contracts GLM-OCR's 18 cases inline in the full run contract_families.zig entries; tf-hip-kernel-contract image <group> [smoke|full]; the no-argument run checks every entry's records first, then runs every entry; GLM-OCR is the first entry

Unchanged for the families on main. Rebased on main fbcb10a3f (main has moved since only by files this PR doesn't touch). All runs used one Radeon AI PRO R9700 (gfx1201) and ROCm 10.1, with main fbcb10a3f and this PR's head 29f40344b, both built from one worktree path.

Check main this PR
hip-kernel-contract-objects: 52 code objects (12 groups and the controls, for gfx1030, gfx1100, gfx1151 and gfx1201) sha256 list byte-identical, all 52
tf-hip-kernel-contract (no argument) 95 kernels: 95 pass gates 1-4, 0 fail, 2 known findings; 12 controls: 12 red as planted, 0 missed; 2 limit problems the same line; the sorted log differs only by ENTRY glm_ocr: 18 kernels, 0 fail, 0 missed and by the first differing byte of the planted atomic-add control (red on both; it differs from run to run by design)
the 2 limit problems (on main too) tf_sum_partials, tf_sum_partials_act: gfx1201 compiles 21 and 22 VGPRs, the records say 23 and 24 the same
image glm_ocr full / smoke (no command) 18 kernels: 18 pass gates 1-4, 0 fail; 0 limit problems; exit 0
gate-5 records 182 182; a test pins 182 in all and 170 outside the family lists
dots-studio/dots.mocr: the #1938 receipt driver (--vision --context 16384 --parallel 2; 26 checks) 25 of 26 25 of 26; all 26 checks and all 22 replies equal main's, and equal the #1938 receipt of 2026-11-03
dots.mocr, in those 26 prompt ids of the three cases equal Transformers'; the demo's 4,634 ids to EOS equal Transformers', plain and drafted; the two-image case's 108 ids equal Transformers'; resumed, drafted, concurrent and seeded runs equal their solo plain runs; cancel in prefill and in decode, then an exact reply; the three 400s the same ids and hashes
the one failing check, on both the square page: 4 of 40 ids (box coordinates at positions 6, 10, 15, 19) differ from Transformers', as disclosed on #1938 the same 4
mlx-community/Qwen3.5-9B-4bit (its Loaded now offers readouts): greedy ids, 3 prompts at 1 stream, and the same 3 plus a repeat at 4 streams A e419a57dfa7f / 5d9afa7be90f / e910bc7c4265 equal in all 7 replies
--vision on Qwen3.5-9B text 200; image 400 this server accepts text parts only; image, audio and video inputs are unsupported; no log line the same, plus the log line
Cloudflare/clef-flash BF16 backbone (#2046), serve exit 1: BF16 Qwen3.5 serves as a decision backbone only, … the same exit and message
readouts through serve on main only tf-qwen35-test readout sets a readout (#2047 wires a fake one; the head comes in #2048), so every served Qwen3.5 model reports none the same; the host tests check an offer with readouts (no text generation, the limits passed through)

Rows from the rebase onto ae9def8a5, whose code paths these rebases did not change: Nemotron 3.5 Lightning greedy ids equal at 1 and 4 streams; Qwen3-Embedding-8B vectors and Qwen3-Reranker-8B scores equal; GLM-OCR's #1940 driver 65 of 65 with equal token ids.

Binaries: main tensorfold-native sha256 a41ce64aaa48fe1f…, this PR 9d80baff2d856dd5…; contract tool main fbe4ee3cc9fd6d79…, this PR 5241b6b2e054905e….

Host checks: zig fmt --check zig/src zig/tests zig/build build.zig; zig build test, without and with -Dhip=true -Dhip-arch=gfx1201; check-hip-kernel-contract-host, test-hip-contract-select-host, test-glm-ocr-native-host, test-hip-family-offers-host, test-hip-native-options-host, test-draft-hip-host, test-lane-api-host, test-qwen35-hip-host, test-hip-blas-host, test-qwen3-hip-host, test-rerank-host, test-embedding-lane-host, test-qwen35-bf16-contract-host, test-dots-native-host, test-dots-hip-host, test-dots-hip-weights-host, test-dots-hip-plan-fast, build-dots-hip-owner-test, build-dots-hip-cache-test, tf-dots-hip-check; tf-hip-kernel-contract and native-hip -Doptimize=ReleaseFast builds; lean 425 problems, equal to main.

Commits

Commit What
08977a177 hip_serve.Offer, hip_serve.vision.offer, hip_serve.cancel, with host tests
e2658b55f native/hip_families.zig (reads, list, refused, offerOf, textOnly); the host reads the offer; GLM-OCR moves to Loaded.offer and refusal(o), Qwen3 to refusal(o)
74708d1b2 Source{path, strict} groups and the derived stub count; no group is strict yet, so every command line is unchanged
671a3baad the contract registration: contract_entry.zig (types only, so the pure selector's host test imports no family code), contract_families.zig, contract_select.zig, image <group>, family records joined in hip_records_families.zig
a6567f9e8, c5944b16b the no-argument run also runs every entry, and it validates every entry's records before any GPU work
f7127c6ed GLM-OCR becomes the first entry: the same 18 cases and seed; its 12 records move to the family list; a test pins 182 records in all and 170 shared
1746f4c0d Serves folds into Offer: Qwen3 returns its routes and window in .offer, Qwen3.5 its readouts; hip_families.generates keeps main's rule that a family answering readouts generates no text; the Qwen3 module imports hip_serve
29f40344b dots.mocr moves onto the Offer: its open reads --vision from hip_serve.Options and returns what attach set in .offer; the host drops the openVision and attach probes; the device owner test passes hip_serve.Options

How the model PRs adopt it.

PR Adoption
#1938 dots.mocr done here (29f40344b). Its kernels stay in the no-argument contract run as on main; a dots_mocr image with an image dots_mocr entry can follow
#1941 Nemotron Parse rebases onto main after this lands: .offer; refusal(o); its vision.hpp kernels join the ops/vision.hpp that #1938 added (one LayerNorm kernel if both round the same)
#1965 LightOnOCR-3 rebases onto main after this lands: .offer with hip_serve.vision.offer (lighton and qwen3_5); hip_serve.cancel instead of its own cancel type; refusal(o); its contract modes become image entries

Offer.warm_turns, frame_key, on_thread, policy and thinking_budget now have a user on main: dots.mocr. Source.strict, hip_serve.vision.offer and hip_serve.cancel have no user on main yet; their first users are #1941 and #1965.

Overlap. Other open PRs touch the same files: #1877, #1622 and #2087 (hip_records.zig, contract_main.zig), #1886 (build/hip.zig, native/hip.zig), #2039 (also qwen3/native.zig), #2047 and #2048 (native/hip.zig), and the OCR PRs #1941 and #1965.

@ashhart

ashhart commented Nov 3, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. This gives every HIP family one host protocol, and it's a clean refactor. I applied it on #1940 over
current main, and all seven commits apply with the lean check at 424.

The families on main are unchanged:

  • GLM-OCR's offer carries exactly the three values its Loaded returned.
  • The text families get the empty offer, whose defaults equal the host's.
  • The refusals keep their texts and order.
  • The capabilities list is the same.
  • The 12 kernel groups build with the same command lines and stub count.

Your receipts match that: equal greedy ids on Qwen3.5-9B and Nemotron, 65 of 65 GLM-OCR checks with all 62 hashes
equal, and the same contract summary.

One small ask for the description: the lane host's structures now follows the backend's first_masked. It's still
true for all five HIP families on main, but please add a line to the table so the next family knows it matters.

The new Offer fields have their first users in #1938, #1941 and #1965, as you say, so we'll check them there. A
second-arch run (gfx1100 or gfx1151) would be welcome but isn't blocking.

@laudney

laudney commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @ashhart. I added the row to the description's first table:

Concern main this PR
Structured output (structures) .structures = true for every family .structures = loaded.backend.vtable.first_masked != null, as the CUDA host does (native/cuda.zig)

All five HIP families on main set first_masked, so nothing changes today. A new family must set it, or the host refuses its structured requests up front. On main, such a family would fail at run time with StructuresUnsupported instead.

Second arch: this host has only gfx1201 devices, so I can't do a gfx1100 or gfx1151 run here. I'll add one if I get access to one.

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney for adding the structures row. It now conflicts with main, mostly because #1978 (Qwen3-Embedding on HIP) and #2046 touched build/hip.zig, native/hip.zig, hip_records.zig and the contract's main and limits files. Could you:

  1. rebase onto current main;
  2. say how Qwen3-Embedding-8B (BF16) on native HIP: BF16 dense GEMM and a qwen3 embedding engine #1978's and qwen3_5: serve Clef-Flash's BF16 Qwen3.5 backbone on HIP #2046's families take the Offer and first_masked, so structured requests behave as they do on main;
  3. post the contract summary and equal ids for the families on main again, on the rebased head?

A run on a second GPU architecture still isn't blocking.

@laudney

laudney commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on main ae9def8a5, as you asked. The families on main give the same results, and the new ones from #1978 and #2046 are covered too.

before after
head 744752f31 b35a34512
base main c7952f398 main ae9def8a5 (main has moved since only by files this PR doesn't touch)
commits 7 8 (one new: b35a34512, below)
files 18 19

2. How #1978's and #2046's families take the Offer and first_masked

family Offer structures on main structures here refusals
Qwen3 embedding and reranker (#1978) #1978 added a second probe set, Serves (text_generation, embeddings, decides, rerank, window read from Loaded with @hasField). b35a34512 moves those five fields into Offer with the same defaults, and qwen3.native returns them in .offer, so the host has one path again text_generation = false offer.text_generation and first_masked != null = false (its backend has no first_masked either) tp_refusal became refusal(o), because refused calls only the hook; same text, on every rank (the --tp ... .rank = 1 test is kept)
BF16 Qwen3.5 backbone (#2046) none needed: it opens through qwen35.native, whose Loaded has no offer, so it gets the empty one true true (first_masked is set) unchanged; chat on the BF16 checkpoint still refuses by name
the other four (Qwen3.5 dense and MoE, Flash Next, Nemotron, GLM-OCR) unchanged from the version you reviewed true true unchanged

The description's table now has these rows. Main's OneGpu test stub became a refusal hook too.

3. Receipts on the rebased head (R9700, gfx1201, ROCm 10.1; main ae9def8a5 and b35a34512, both built from one worktree path)

check main this PR
hip-kernel-contract-objects 52 code objects byte-identical, all 52
tf-hip-kernel-contract (no argument) 84 kernels: 84 pass gates 1-4, 0 fail, 2 known findings; 8 controls: 8 red as planted, 0 missed; 2 limit problems the same line; the sorted log differs only by ENTRY glm_ocr: 18 kernels, 0 fail, 0 missed
the 2 limit problems tf_sum_partials / _act: 21 and 22 VGPRs against records of 23 and 24 the same
image glm_ocr full / smoke — 18 of 18 pass, 0 limit problems
Qwen3.5-9B MLX 4-bit, greedy ids at 1 and 4 streams e419a57dfa7f / 5d9afa7be90f / e910bc7c4265 equal in all 7 replies
Nemotron 3.5 Lightning MLX 4-bit, the same cells 0843a276c5cf / 22e5b36fae3d / 9d9079faf066 equal in all 7 replies
--vision on both text models image 400, no log line image 400, plus the text-only log line
Qwen3-Embedding-8B: 3 inputs, and 1 at dimensions: 64 vectors sha256 7bf52f1bff1cd448, 22c3874ec8a5d70f; chat, response_format, /v1/rerank: 400 the same bytes, the same 400s
Qwen3-Reranker-8B: 1 query, 4 documents scores 0.981757, 0.030917, 0.000010, 0.000006; chat, response_format, /v1/embeddings: 400 the same scores, the same 400s
Clef-Flash BF16 backbone, serve exit 1, BF16 Qwen3.5 serves as a decision backbone only, … the same
GLM-OCR: the #1940 receipt driver 65 of 65 65 of 65; the token ids of all 59 replies that carry them equal main's, and equal the run on c7952f398

Host checks: zig fmt --check; zig build test without and with -Dhip=true -Dhip-arch=gfx1201; 16 HIP host suites, now with test-qwen3-hip-host, test-rerank-host, test-embedding-lane-host and test-qwen35-bf16-contract-host; native-hip -Doptimize=ReleaseFast; lean 426, equal to main.

A second-arch run still isn't possible here (this host has only gfx1201).

@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney, all three points from our last comment are answered, and the table of how #1978's and #2046's families take the Offer is exactly what we needed. Since then #1938 has landed, as planned, with its own probes on Loaded, so main has two paths again and this conflicts. One more round of the same, please: rebase onto main, move dots.mocr onto the Offer, and post the contract summary and dots.mocr's model-card checks on the rebased head, equal to main. Then this goes in before #1941.

@laudney
laudney force-pushed the pr-hip-ocr-plumbing branch from b35a345 to a2cb1ff Compare November 4, 2026 16:49
@laudney

laudney commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

Rebased on main 6da340932, as you asked, and dots.mocr is on the Offer, so main has one path again.

before after
head b35a34512 a2cb1ff01
base main ae9def8a5 main 6da340932 (46 new commits, #1938 among them)
commits 8 9 (one new: a2cb1ff01)
files 19 21

1. dots.mocr onto the Offer (a2cb1ff01)

dots.mocr on main here
openVision(..., o.vision, ...), probed by the host open reads o.vision from hip_serve.Options, as GLM-OCR does; no probe
attach(h.family, &h.host, o) after LaneHost.init: cache, capacity, warm turns, images, policy line, thinking budget off, frame key, thread hook the same nine values in Loaded.offer; the host passes them to LaneHost.init and sets cache, frame key and thread hook from the offer, as for GLM-OCR; no probe
refusal(o) unchanged; fam.refused calls it first, so the texts and order stay main's (the existing dots refuses ... test passes unchanged)
structures = true text_generation and first_masked != null = true (dots sets first_masked)

One difference, on purpose: with dots' cache off (--prompt-cache-gib 0 or --keep 0), main's attach still handed the host the empty store, so /status showed a cache line with 0 states. Here the offer gives no store, as GLM-OCR does and as Offer.cache says. Replies don't change, because the empty store kept and found nothing.

The rebase itself only joined lines: dots' records next to the family list (the pin is now 182 in all, 170 shared), and the import and test lines in native/hip.zig. dots' kernels stay in the no-argument contract run as on main. A dots_mocr image with an image dots_mocr entry can follow, if you want one.

2. Receipts on the rebased head (R9700, gfx1201, ROCm 10.1; main 6da340932 and a2cb1ff01, both built from one worktree path)

check main this PR
hip-kernel-contract-objects 52 code objects byte-identical, all 52
tf-hip-kernel-contract (no argument) 95 kernels: 95 pass gates 1-4, 0 fail, 2 known findings; 12 controls: 12 red as planted, 0 missed; 2 limit problems the same line; the sorted log differs only by ENTRY glm_ocr: 18 kernels, 0 fail, 0 missed and by the first differing byte of the planted atomic-add control, which is red on both and changes from run to run by design
the 2 limit problems tf_sum_partials / _act: 21 and 22 VGPRs against records of 23 and 24 the same
image glm_ocr full / smoke — 18 of 18 pass, 0 limit problems
dots.mocr: the #1938 receipt driver, 26 checks (--vision --context 16384 --parallel 2) 25 of 26 25 of 26; all 26 checks and all 22 replies equal main's, and equal the receipt on #1938 from 2026-11-03
— the model-card demo 4,634 ids to EOS equal Transformers', plain and drafted the same ids
— the two-image case 108 ids equal Transformers'; resumed, drafted and concurrent equal the same ids
— prompt ids (demo 5,096, square 280, two-image 330) equal Transformers' the same
— seeded sampling solo, drafted and concurrent equal; differs from greedy the same hash
— cancel in prefill / in decode, then the square page exact exact
— three 400s (bad image bytes, excess context, video) the same messages the same
— the one failing check the square page: 4 of 40 box ids (positions 6, 10, 15, 19) differ from Transformers', as disclosed on #1938 the same 4

Host checks: zig fmt --check; zig build test without and with -Dhip=true -Dhip-arch=gfx1201; 20 HIP host and build steps, now with test-dots-native-host, test-dots-hip-host, test-dots-hip-weights-host, test-dots-hip-plan-fast, build-dots-hip-owner-test, build-dots-hip-cache-test and tf-dots-hip-check; native-hip -Doptimize=ReleaseFast; lean 425, equal to main.

I didn't run the other families again: this rebase changed no line on their paths since the receipts above in this thread. The description now has the dots rows, the new hashes and the commit list.

Families that serve images need one way to hand the host their image
offer, prompt cache and host settings, a way to turn a typed tower
into an api.Vision, and a cancellation checkpoint on the lanes' own
CancelCheck instead of new hook types. These helpers add that,
defaulted so a family without them changes nothing.
The HIP host now reads one optional offer from a family's Loaded and
copies its values into the lane host, takes a family refusal hook
before any load or group join, lets a family read extra model types,
and starts text-only with a log line when --vision meets a model
without images. GLM-OCR moves its image tower, prompt cache and cache
capacity into that offer and its --tp and --mtp-drafts refusals into
the hook, so the host keeps one path; the text families declare none
of these and get the empty offer and the same host values as before.
Two families that each add a kernel image no longer edit the same
literal: the stub's image count now comes from the group list, and
each group is a source with a `strict` flag that adds -O2
-fno-fast-math -fdenormal-fp-math=ieee after the common flags, so a
family image can ask for IEEE arithmetic without touching the others.
Every existing group keeps strict off, so its hipcc command is
unchanged, and `compile` keeps its signature for the controls.
Family kernels need gates 1-4 with their own fixtures, so the runner
gets a registration list (contract_families.zig, empty today, one
entry a group) and `image <group> [smoke|full]`, which resolves the
group's entry and its gate-5 records before any GPU access, runs the
entry, then gate 5 on those records only, and exits 1 on a failed
kernel, a missed control, a limit problem or a run with no kernel;
the no-argument run also runs every entry and merges its tally. A
pure selector (contract_select.zig) holds that logic with host
tests, family records join hip_records.records from one list file,
and the limits checks gain a variant over a given record slice.
The full run merged the family entries' tallies but judged them only
as one sum, so an entry that ran no kernel could hide beside a clean
one. Each entry now prints an ENTRY line and is judged on its own
tally (failed kernels, missed controls, and one problem for an empty
run); the full run adds those problems to today's and to gate 5, and
with no entry registered its output and exit are unchanged.
The full contract ran each registered family's gates 1-4 without
resolving its gate-5 record selection, so a family with missing or
empty records could pass the default run while `image` refused it.
The full run now validates every entry first, before any GPU access,
and exits 2 with the reason, as `image` does.
GLM-OCR's pointwise and norm kernels ran inline in the full contract
run, so `image glm_ocr` had no entry and the family list was empty.
They now form GLM-OCR's entry, which opens its own runtime and runs
the same 18 gate cases with the same seed, and its 12 gate-5 records
move from the shared list into the family list. The full run still
covers every kernel and all 167 records; `image glm_ocr` checks the
family alone, and a test pins the 167 total and 155 shared records.
Main's Serves read text_generation, embeddings, decides, rerank,
readouts and window from a family's Loaded field by field, beside the
Offer this branch reads for images and caches, so the host had two
probes for one question. The six fields move into hip_serve.Offer
with the same defaults; Qwen3 returns its routes and window in its
offer, Qwen3.5 its readouts, and hip_families.generates keeps main's
rule that a family answering readouts generates no text. The Qwen3
module imports hip_serve, structures needs text generation and
first_masked, and every family on main gets the values it had.
dots.mocr landed with two host hooks of its own, openVision and
attach, so main had two paths again. Its open now reads --vision
from the shared options and returns what attach set in Loaded.offer:
the prompt cache and its capacity, warm turns, images, policy line,
thinking budget, frame key and thread hook. The host drops both
probes, and the device owner test passes hip_serve.Options. With the
cache off, dots now hands the host no store, as GLM-OCR does.
@ashhart

ashhart commented Nov 4, 2026

Copy link
Copy Markdown
Owner

Thanks @laudney. All three points from our last comment are in: dots.mocr is on the Offer, and your gfx1201 receipts show the 52 code objects, the contract summary and all of dots.mocr's checks equal to main. Main moved once more since: #2047 landed and added readouts to Serves in native/hip.zig (a family that reports readouts generates no text). Could you rebase and carry readouts on the Offer with the same rule, then post the contract summary and the dots.mocr checks on that head again? #2048's Clef family is the first user, so it lands right after.

@laudney
laudney force-pushed the pr-hip-ocr-plumbing branch from a2cb1ff to 29f4034 Compare November 4, 2026 18:04
@laudney

laudney commented Nov 4, 2026

Copy link
Copy Markdown
Contributor Author

#2047 landed while the receipts above ran. It added a sixth field to main's Serves, readouts, so this conflicted again. Rebased on main fbcb10a3f, with readouts folded into the Offer in the same way, and the receipts run again on the new head.

before after
head a2cb1ff01 29f40344b
base main 6da340932 main fbcb10a3f (main has moved since only by files this PR doesn't touch; it merges clean)
commits 9 9 (1746f4c0d now folds readouts too)
files 21 22

readouts in the Offer

main (#2047) here
Serves.readouts, probed from Qwen3.5's Loaded.readouts; when set, Serves.of turns text_generation off Offer.readouts; Qwen3.5's Loaded returns .offer = .{ .readouts = own.backend.readouts() }; hip_families.generates(offer) = text_generation and readouts == null feeds text_generation and structures, so main's rule stays in one place
main's test: a Loaded with readouts generates no text and passes the limits through; Qwen3.5's Loaded carries the field the same cases on fam.offerOf / fam.generates, and a test that Qwen3.5's Loaded carries offer and no readouts field

Qwen3.5 now carries an offer. Its other fields are the defaults, so it gets the host's window, policy, cache and thinking budget as before. Through serve, no Qwen3.5 model reports readouts yet on main (only tf-qwen35-test readout sets one; the head comes in #2048), so the device rows below check the paths that serve reaches.

Receipts on 29f40344b (R9700, gfx1201, ROCm 10.1; main fbcb10a3f and this head, both built from one worktree path)

check main this PR
hip-kernel-contract-objects 52 code objects byte-identical, all 52
tf-hip-kernel-contract (no argument) 95 kernels: 95 pass gates 1-4, 0 fail, 2 known findings; 12 controls: 12 red as planted, 0 missed; 2 limit problems the same line; the sorted log differs only by ENTRY glm_ocr: 18 kernels, 0 fail, 0 missed and by the first differing byte of the planted atomic-add control (red on both)
image glm_ocr full / smoke — 18 of 18 pass, 0 limit problems
dots.mocr: the #1938 receipt driver, 26 checks 25 of 26 25 of 26; all 26 checks and all 22 replies equal main's and the #1938 receipt of 2026-11-03 (the one failure on all three is the disclosed square page: 4 of 40 box ids)
Qwen3.5-9B MLX 4-bit, greedy ids at 1 and 4 streams e419a57dfa7f / 5d9afa7be90f / e910bc7c4265 equal in all 7 replies
--vision on Qwen3.5-9B image 400, no log line image 400, plus the text-only log line
Clef-Flash BF16 backbone, serve exit 1, BF16 Qwen3.5 serves as a decision backbone only, … the same

Host checks: zig fmt --check; zig build test without and with -Dhip=true -Dhip-arch=gfx1201; the 20 HIP host and build steps listed in the description; native-hip -Doptimize=ReleaseFast; lean 425, equal to main.

The description has the new hashes, the readouts row and the Qwen3.5 rows. The dots.mocr adoption in the comment above is unchanged; only its hash moved (29f40344b).

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