Skip to content

runtime::mtp: the MTP draft's policy and MtpDraft move to runtime behind a Host trait - #1155

Merged
midagedev merged 4 commits into
mainfrom
runtime-mtp-r0
Nov 4, 2026
Merged

midagedev merged 4 commits into
mainfrom
runtime-mtp-r0

Conversation

@midagedev

@midagedev midagedev commented Nov 3, 2026 •

Copy link
Copy Markdown
Owner

MTP draft의 창 정책과 MtpDraft 상태 기계를 crates/app에서 host 전용 runtime::mtp로 옮기고, 백엔드는 Host trait 하나만 구현하게 합니다. Metal이 CUDA와 같은 draft 코드로 MTP를 쓰기 위한 #1135의 R0(R0a + R0b)이며, 동작과 커널은 바뀌지 않습니다.

What and why

The MTP draft lived in crates/app, which links the card engine, so the Metal backend could not use it. This PR moves it to the host-only runtime crate in two commits (plus a gate-paths row fix and the review's fixes), beside runtime::lookup and runtime::dspark. It supersedes #1148, which held the first commit; that branch could not be updated after the rebase onto the uutils fix (0e763b8).

Commit 1 (R0a): the card-free window policy. Policy, Shape, Refresh, Chain, Call, Walk, Exec, Fail, Refused and SKIPS move to runtime::mtp::policy with their 21 tests. Hidden, WalkMode, Join and Parked move to runtime::mtp. The old paths re-export them. Only visibility, use lines and two doc links change.

Commit 2 (R0b): MtpDraft over a Host trait. runtime::mtp::MtpDraft<H: Host> and its Draft impl hold the state machine. Host: Verify carries:

  • the arena and path kinds;
  • the window constants;
  • six calls: held, walk, chain, prompt_with, retain_prompt_row and prompt_scope.

app keeps the CUDA side:

  • impl Host for Session<B> over MtpBody;
  • the alias MtpDraft<B> = runtime::mtp::MtpDraft<Session<B>> and open_draft;
  • the media prompt path;
  • the slot paths, which now read the draft through accessors.

The text and media prompt calls share one path, MtpDraft::prompt_joined. Media changes only the executor it is given, so Host has no media concept.

prompt_scope keeps prompt_busy over the join walk and the call, as before. The join walk blocks on the inbox copy (store_rows → Inbox::host_mut → synchronize).

The From<Fail<GpuError>> impl had no caller and is removed. drafts_mtp in tools/owners.tsv now names the runtime files.

What still names a card type stays in app or gpu: Feed (gpu::media::CallRows), DraftHead, From<StepMode>, and Parked's byte codec (the ParkedBytes trait).

Release notes: internal — the MTP draft is shared code in runtime, ready for a second backend.

Code shape

  • No second copy. The code is moved, not copied. Text and media share one prompt-call path. The slot paths read the one window state through accessors.
  • Model code holds only architecture facts. No model code changed.
  • Dead code is gone. The uncalled From<Fail<GpuError>> impl is removed.
  • No silent failure. Unchanged; refusals stay named.
  • Comments state what is true now.
  • No relaxed gate. No test removed. Test counts:
    • runtime: 336 → 345 (nine mock-Host tests);
    • app: 13;
    • gpu-gates: 492.
  • cargo fmt; just mac-lint ratchet 0.

Duplication found or removed: the text and media prompt calls had about 20 shared lines; they now share one path.

What I ran

The move proof uses just narrow: --no-jit scans of the covering bin set on the box, base 99a6bfd, head 0826a3941 (both commits, before the rebase).

narrow: ptx-scan bloomery-serve / gate_gpu_device / gate_kquant / gate_swap: identical (--no-jit both)
affected: 99a6bfd5c..0826a3941 — 22 changed files, 150 of 242 gate-recipes selected (narrowed from 221 by ptx-scan)

The box batch for that list ran on the fixture tier, at tools/gate-batch.sh --tier fixture --ledger:

DONE total=174 red=1 tier=fixture wall=4347s deferred=256 real_only=34 real_file=20 needs_env=7
gate-gpu-qwen4exp-nvfp4-cut rc=1  gate_qwen4exp_nvfp4: fixture: cannot list /models/fixtures/qwen38fp4l: No such file or directory

The one red was our box's gap, not this change: the FP4-Local cut folder (from #1116) had not been made on our box. It is made now (fixture cut + fixture verify: 31111 tensors, 37.16 GB), and the gate is green (below).

After the rebase onto main (head 1adccd3), main's crates/levers/src/registry.rs and tools/gate-bound.sh moved every item's key, so the same list ran again there:

DONE total=174 red=1 tier=fixture wall=2630s deferred=256   (the red: the same missing cut folder)
gate-gpu-qwen4exp-nvfp4-cut rc=0 41s tier=fixture           (rerun after the cut was made)

The third commit fixes a tools/gate-paths.tsv row whose crates/app/src/mtp/** glob matched nothing after the move (CI's gate-paths self-test). A ledger dry-run at that head skips 113 of 113 items: no key moved.

The fourth commit applies the review's three fixes. The proof at that head (15a8421, base 0e763b8):

narrow: ptx-scan bloomery-serve / gate_gpu_device / gate_kquant / gate_swap: identical (--no-jit both)
affected: 0e763b848..15a84216a — 23 changed files, 155 of 242 gate-recipes selected
DONE total=179 red=0 tier=fixture wall=2189s deferred=258 real_only=36 real_file=20 needs_env=7
cargo test -p bloomery-runtime: ok. 345 passed; 0 failed
just mac-lint: ^warning: 0 = the ratchet 0

FAIL-first for the strengthened test: removing w.pending = None from unpark panics it at draft.rs:1088.

The weekly recipes the change triggers (weekly-gpu-ds41-serve, weekly-gpu-glm5next-e2e-long, weekly-gpu-glm5next-media-serve) run after the merge.

On the Mac:

cargo test -p bloomery-runtime            ok. 345 passed; 0 failed
just mac-check                            combos rc 0; 86 shapes run, 0 red
just mac-lint                             ^warning: 0 = the ratchet 0
check-comments / check-arch / check-unsafe / fmt --check: ok; every tools/check-*.sh rc 0 after the rebase

FAIL-first:

  • Commit 1: changing Policy::chain's ctx as usize - 1 - end to ctx as usize - end fails a_chain_takes_the_refresh_and_caps_its_own_walks and no_call_sequence_leaves_the_store_past_the_target_or_refuses.
  • Commit 2: before_plain with an empty body fails 2 mock-Host tests. prompt_call passing None for the sink fails 8.

Every mutant was reverted.

The Metal side that uses this (impl Host for Session and generate_metal --draft mtp) follows in its own PR.

AI use: Claude Code wrote this change (Sonnet rounds, reviewed by the lead session, which ran the box proof).

🤖 Generated with Claude Code

https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj

midagedev and others added 2 commits November 4, 2026 07:18
… so a backend without the card engine can draft with it

The MTP window policy and its tests now live in the host-only runtime crate,
beside runtime::lookup and runtime::dspark. Metal will use them. It cannot
link crates/app, which links the card engine.

Moved, code unchanged except visibility, use lines and doc links:
- app mtp/policy.rs: Policy, Shape, Refresh, Chain, Call, Walk, Exec, Fail,
  Refused, SKIPS, and 21 policy tests.
- gpu draft.rs: Hidden and WalkMode.
- app mtp.rs: the Join and Parked structs, and WHAT.

The old paths re-export them. What names a card type stays:
- Feed (CallRows);
- DraftHead (check_opened returns GpuError);
- From<StepMode>.

Parked's byte codec (put/take/check, gpu::checkpoint::bytes) stays in app as
the ParkedBytes trait. Refresh::feed stays as a private app trait. Its two
byte tests stay in app. The only caller edit is drafted.rs's `use` line.

Tests 24 before (policy 23, app 1) and 24 after (runtime 21, app 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj
…te machine has one owner for every backend

MtpDraft moves from app to runtime::mtp::MtpDraft<H: Host>, with its Draft impl.
The new Host: Verify trait carries:
- the arena and path kinds;
- the window constants;
- six calls: held, walk, chain, prompt_with, retain_prompt_row, prompt_scope.

app keeps the CUDA side:
- impl Host for Session<B> over MtpBody;
- the alias MtpDraft<B> = runtime::mtp::MtpDraft<Session<B>>;
- open_draft;
- the media prompt path;
- the slot paths, which now read the draft through accessors.

The text and media prompt calls share one path, MtpDraft::prompt_joined. Media
changes only the executor it is given, and Host has no media concept.
prompt_scope keeps prompt_busy over the join walk and the call, as before. The
join walk blocks on the inbox copy (store_rows -> Inbox::host_mut ->
synchronize).

The From<Fail<GpuError>> impl, which had no caller, is gone. drafts_mtp in
tools/owners.tsv names the runtime files.

Nine mock-Host tests cover the moved state machine on a machine with no card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj
@github-actions github-actions Bot added gpu The card engine: crates/gpu*, crates/app host Host crates: qdot, threads, engram, engram-lab, runtime, sampler, levers, decision, vision, refset needs-gpu Changes code a card must check, and carries no gate run on a card tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo labels Nov 3, 2026

@github-actions github-actions 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.

Verdict: MUST_FIX: the reset test misses its stated contract, the text adapter duplicates an existing owner, and the conversion trait is unnecessary.

Must fix

1. Assert that unpark clears the pending proposal

crates/runtime/src/mtp/draft.rs:1087: removing w.pending = None from unpark at line 147 leaves this test green. restart also clears pending; the final assertion only reads kept through take_windows, and this test never fills kept.
Kind B: the new test cannot detect a violation of its stated reset contract. Assert the pending state before and after each reset so this regression is caught now. Replace the test function, retaining its #[test] attribute:

fn a_parked_draft_keeps_what_waits_and_an_unpark_drops_the_kept_proposal() {
    let (mut d, mut t, first) = begun::<true>();
    d.keep_windows();
    let parked = d.park();
    assert!(parked.next.is_none() && parked.skip.is_some());
    d.retain_prompt(&mut t).unwrap();
    assert_eq!(t.take(), [Ev::Retain(Ar::Ubatch, 0, 9)]);
    let mut out = [0u32; 3];
    let n = d.propose(&mut t, first, &mut out).unwrap();
    assert!(d.keeps_windows());
    assert!(d.windows.as_ref().unwrap().pending.is_some());
    d.unpark(&parked);
    assert!(d.windows.as_ref().unwrap().pending.is_none());
    d.proposed(&out[..n]);
    assert!(d.windows.as_ref().unwrap().pending.is_some());
    d.restart();
    assert!(d.windows.as_ref().unwrap().pending.is_none());
    assert!(d.take_windows().is_empty());
}

2. Reuse Card for the text host walk

crates/app/src/mtp.rs:535 repeats crates/app/src/mtp.rs:622: both translate the same Walk into Feed and invoke B::walk. The text case supplies media: None.
Kind B, R14. Delegating leaves one adapter to update when the feed changes and adds no owner. Replace the new method with:

fn walk(&mut self, w: &Walk<'_, B::Arena>, head: DraftHead) -> Result<(), GpuError> {
    Card {
        m: self.model_mut(),
        head,
        media: None,
    }
    .walk(w)
}

3. Remove the single-implementation RefreshFeed trait

crates/app/src/mtp.rs:92 declares a trait used only by its Refresh<A> implementation. No caller has a trait bound or dispatches through it.
Kind B, fewest elements. Remove the extra declaration and impl while this conversion has only three callers. Replace both definitions with:

fn refresh_feed<A: Copy>(r: &Refresh<A>) -> Feed<'_, A> {
    Feed {
        tokens: &r.tokens,
        pos0: r.pos0,
        hidden: Hidden::Target {
            walk: r.walk,
            first: r.first,
        },
        media: None,
    }
}

Replace both c.refresh.feed() calls with refresh_feed(&c.refresh), and r.feed() with refresh_feed(r).

Non-blocking

⚠ Supply the move proof for the reviewed head: crates/runtime/src/mtp/draft.rs:254. The prompt dispatch moved here, but the identical scans in the PR body compare pre-rebase commits. The body says the replacement batch is running and contains no result.
Attach just narrow 6490d03f3 1adccd30f output and the selected host structural/e2e results. AGENTS.md's move class requires identical scans and host dispatch structural lines for the code being merged.

Checked

Duplicates: both Walk adapters and all three RefreshFeed callers confirmed.
Complexity: the cited adapters add small dispatch matches; crapper did not run.
Silent failure: the cited walk and chain adapters propagate backend errors.
Tests: the unpark reset mutant survives by inspection. Tests were not executed here.
Gates: no thresholds relaxed in the cited hunks.
Escape catalog: C1–C4 do not match the cited code.

Notes

Dryer and crapper did not run because their Python modules are missing. GPU evidence lacks the card/driver line and model file identification.

Card check: needs-gpu. This pull request changes crates/app/src/mtp.rs, crates/gpu-gates/src/bin/gate_glm5next_e2e.rs, crates/gpu-gates/src/bin/gate_glm5next_mtp.rs … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

midagedev and others added 2 commits November 4, 2026 08:13
…moved to runtime

The policy module left crates/app/src/mtp/, so the row's mtp/** glob matched
no file and the real-table self-test refused it. The runtime files have no row:
the derivation reads them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj
…xt walk runs through Card, and the refresh feed is a function

The review found three things:
- the unpark test never read `pending`, so dropping its reset left it green;
- Host::walk repeated Card's Exec::walk;
- RefreshFeed was a trait with one impl and no bound.

The test now asserts `pending` after the propose, the unpark, a second proposal and the restart. Dropping `w.pending = None` from unpark fails it at draft.rs:1088. Host::walk builds a Card with no media and walks it. refresh_feed is a free function at its three callers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CjHkkntc2L1xRN21xB39mj
github-actions[bot]
github-actions Bot previously approved these changes Nov 3, 2026

@github-actions github-actions 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.

Since the last review

Verdict: CLEAN: no findings remain within this increment, which changes only the MTP gate-path row.

Checked

Duplicates: no new functions, types or constants.
Complexity: no functions changed.
Silent failure: no error-handling code changed.
Tests: none changed; tests were not executed.
Gates: the row removes the mtp/** alternative and updates its description. No thresholds or pins changed.
Escape catalog: C1–C4 do not match the changed row.

Notes

Dryer and crapper did not run because their Python modules are missing.

Card check: needs-gpu. This pull request changes crates/app/src/mtp.rs, crates/gpu-gates/src/bin/gate_glm5next_e2e.rs, crates/gpu-gates/src/bin/gate_glm5next_mtp.rs … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@github-actions github-actions 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.

Since the last review: no must-fix or non-blocking items were open.

Verdict: CLEAN: this increment preserves feed construction and walk behavior, and strengthens pending-proposal checks.

Checked

Duplicates: 1 replacement function checked by Grep; no new duplicate. Host::walk reuses the existing Card executor. Dryer produced no pairs because it did not run.
Complexity: the adapter changes add no branches. Crapper produced no function results because it did not run.
Silent failure: 1 changed error-returning delegation followed through Card::walk to MtpBody::walk; callers receive the same GPU error.
Tests: 1 changed test inspected. Removing the pending clear at crates/runtime/src/mtp/draft.rs:113 or crates/runtime/src/mtp/draft.rs:147 fails the corresponding new assertion by inspection. Tests and mutants were not executed.
Gates: no thresholds, pins, tests or cases removed or relaxed.
Performance: decode and prompt adapters retain the same backend calls, allocations, launches and copies.
Adversarial checks: host-backed hidden rows, target arena offsets, absent media and walk errors retain their previous handling.
Escape catalog: C1–C4 do not match this increment.

Notes

Dryer and crapper did not run because their Python modules are missing.

Card check: needs-gpu. This pull request changes crates/app/src/mtp.rs, crates/gpu-gates/src/bin/gate_glm5next_e2e.rs, crates/gpu-gates/src/bin/gate_glm5next_mtp.rs … and carries no gate run on a card (CONTRIBUTING's evidence list); a maintainer adds gpu-verified after one.

🔨 Shingler, bloomery's review bot: approves a clean pull request and requests changes on a must-fix (how it works)

@midagedev
midagedev merged commit c9068b5 into main Nov 4, 2026
8 checks passed
@midagedev
midagedev deleted the runtime-mtp-r0 branch November 4, 2026 01:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

gpu The card engine: crates/gpu*, crates/app host Host crates: qdot, threads, engram, engram-lab, runtime, sampler, levers, decision, vision, refset needs-gpu Changes code a card must check, and carries no gate run on a card tools Tools, recipes, CI and the toolchain: tools/, justfile, .github/, Cargo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant