metrale_model_engine/traits/model.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The `Model` trait: the interface between the scheduler and a model.
4//!
5//! Per request the scheduler calls `prefill` or `prefill_chunk` (logits at the last prompt
6//! position), then `decode` or `decode_batch` once per emitted token. With speculative decoding
7//! a verify call (`decode_verify_graphed`, `_k3`, `_k4`, `_kgamma`, `decode_verify_batched`)
8//! takes `[last_token, draft0, ..]` and returns per-position argmax ids, and the scheduler rolls
9//! the state back past a rejected draft. `mixed_forward` and `mixed_forward_batch` run decode
10//! rows and prefill chunks in one pass.
11//!
12//! `Model: Send + Sync` so a model can move to the scheduler thread as a `Box<dyn Model>`.
13//! `TransformerModel` relies on every call then coming from that one thread (its
14//! `unsafe impl Sync` in `model/types.rs`).
15//!
16//! Owner: model-engine.
17//! Invariants: none beyond the types.
18
19pub use metrale_scheduler::{FeedSource, RowMask};
20
21/// 2026-09-26: Per-call options for [`ModelVerify::decode_verify_batched`].
22#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
23pub struct VerifyBatchedOpts {
24 /// 2026-09-26: Ask the GDN layers for the write-on-accept verify. The caller must then run
25 /// [`ModelVerify::gdn_fold_accepted`] with every verdict before its `commit_accepted_prefix`
26 /// calls. `false` in `Default`.
27 pub write_on_accept: bool,
28}
29
30/// 2026-09-26: One beam-search request for a translation model (NLLB): the per-request
31/// parameters the scheduler copies from the sequence. [`ModelForward::generate_beam_batch`] runs
32/// the search to completion.
33#[derive(Debug, Clone)]
34pub struct BeamReq {
35 /// 2026-09-25: Source subword ids; the model adds the source-language id and EOS itself.
36 pub prompt_tokens: Vec<u32>,
37 /// 2026-09-25: Source and target language token ids; `0` uses the deployment default.
38 pub src_lang_id: u32,
39 pub tgt_lang_id: u32,
40 /// 2026-09-25: LoRA slot: `>= 0` applies the adapter, `-1` runs the base model.
41 pub adapter_slot: i32,
42 pub num_beams: usize,
43 pub max_new: usize,
44 pub length_penalty: f32,
45 pub early_stopping: bool,
46}
47
48/// 2026-09-25: The padded batch size for `n` live sequences: the smallest rung of the ladder
49/// that is at least `n`, or `n` itself above 128. Batched decode pads to a rung so its CUDA
50/// graphs are keyed by a few batch shapes instead of one per `n`.
51#[inline]
52pub fn padded_batch_n(n: usize) -> usize {
53 DECODE_BATCH_LADDER
54 .iter()
55 .copied()
56 .find(|&s| s >= n)
57 .unwrap_or(n)
58}
59
60/// 2026-09-28: The rungs [`padded_batch_n`] pads to.
61pub const DECODE_BATCH_LADDER: [usize; 11] = [2, 4, 8, 12, 16, 24, 32, 48, 64, 96, 128];
62
63mod adapters;
64mod circuit;
65mod device_feed;
66mod draft;
67mod ep;
68mod forward;
69mod lifecycle;
70mod logits;
71mod ssm_state;
72mod streams;
73mod verify;
74mod vision;
75
76pub use adapters::ModelAdapters;
77pub use circuit::{ForwardDisclosure, ForwardSelect, ModelCircuit};
78pub use device_feed::ModelDeviceFeed;
79pub use draft::ModelDraft;
80pub use ep::ModelEp;
81pub use forward::ModelForward;
82pub use lifecycle::{ModelLifecycle, PrefixReclaim};
83pub use logits::ModelLogits;
84pub use ssm_state::ModelSsmState;
85pub use streams::ModelStreams;
86pub use verify::ModelVerify;
87pub use vision::ModelVision;
88
89/// 2026-09-26: The whole scheduler-facing model interface: `Send + Sync` and every supertrait
90/// below. An implementor writes one `impl` per supertrait and an empty `impl Model`.
91pub trait Model:
92 Send
93 + Sync
94 + ModelLifecycle
95 + ModelForward
96 + ModelLogits
97 + ModelAdapters
98 + ModelSsmState
99 + ModelVerify
100 + ModelDraft
101 + ModelVision
102 + ModelEp
103 + ModelStreams
104 + ModelDeviceFeed
105 + ModelCircuit
106{
107}
108
109#[cfg(test)]
110mod padded_batch_n_tests {
111 use super::padded_batch_n;
112
113 /// 2026-09-25: The rung for each `n` up to 128, and `n` itself above.
114 #[test]
115 fn ladder_rungs() {
116 for (n, want) in [
117 (1usize, 2usize),
118 (2, 2),
119 (3, 4),
120 (5, 8),
121 (9, 12),
122 (13, 16),
123 (16, 16),
124 (17, 24),
125 (25, 32),
126 (32, 32),
127 ] {
128 assert_eq!(padded_batch_n(n), want, "n={n}");
129 }
130 for (n, want) in [
131 (33usize, 48usize),
132 (48, 48),
133 (49, 64),
134 (64, 64),
135 (65, 96),
136 (96, 96),
137 (97, 128),
138 (128, 128),
139 ] {
140 assert_eq!(padded_batch_n(n), want, "n={n}");
141 }
142 assert_eq!(padded_batch_n(129), 129);
143 }
144}
145
146/// 2026-09-25: An EP worker command that was received and then failed to execute. The worker
147/// loop (`serve_phases/build.rs`) logs it and keeps running, since the head raises the same
148/// error for the same command and answers the client with it. Any other error from
149/// `Model::ep_worker_step` came from the receive, and the worker exits.
150#[derive(Debug)]
151pub struct EpCommandFailed(pub anyhow::Error);
152
153impl std::fmt::Display for EpCommandFailed {
154 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
155 write!(f, "{:#}", self.0)
156 }
157}
158
159impl std::error::Error for EpCommandFailed {}
160
161#[cfg(test)]
162mod ep_command_failed_tests {
163 use super::EpCommandFailed;
164
165 /// 2026-09-25: The worker loop classifies by downcast, so the tag and the original message
166 /// must both survive being boxed into an `anyhow::Error`.
167 #[test]
168 fn the_tag_and_its_message_survive_anyhow() {
169 let inner = anyhow::anyhow!("Prefill chunk layer 3 failed: DSA indexer cache: 16385");
170 let tagged = anyhow::Error::new(EpCommandFailed(inner));
171 assert!(
172 tagged.downcast_ref::<EpCommandFailed>().is_some(),
173 "the worker loop cannot tell a command failure from a dead link without this"
174 );
175 assert!(format!("{tagged:#}").contains("DSA indexer cache: 16385"));
176 }
177
178 /// 2026-09-25: An untagged error, as from a failed receive, is not a command failure.
179 #[test]
180 fn an_untagged_error_stays_fatal() {
181 let recv = anyhow::anyhow!("ep_recv_seq_and_cmd: peer closed");
182 assert!(recv.downcast_ref::<EpCommandFailed>().is_none());
183 }
184}