Skip to main content

proxima_core/
serving_state.rs

1//! The serving control plane as one sans-IO state machine over the algebra: every transition performs exactly one program evaluation, or placement copies only, and the per-layer cache lives inside the variant a transition returns rather than in a cache object patched from outside.
2//!
3//! The tests below prove the control flow (drafting, acceptance counting, cache placement, rollback-to-resume) against a fake target function instead of a model program.
4
5use alloc::vec::Vec;
6
7use thiserror::Error;
8
9#[derive(Debug, Error, PartialEq, Eq)]
10pub enum ServingFsmError {
11    /// A transition was invoked on a variant it does not apply to (for
12    /// example, `ServingState::advance_decode` called on `Prefill`).
13    #[error("serving fsm: {attempted} is not legal from the current state")]
14    IllegalTransition { attempted: &'static str },
15}
16
17/// Which evaluation shape is legal right now. An enum whose transitions
18/// consume `self` states that directly; no existing proxima primitive (pipe,
19/// cache or config) expresses it more cheaply.
20#[derive(Debug, Clone, PartialEq)]
21pub enum ServingState<Entry, Cache> {
22    /// `positions`: the prompt token ids to place in one `M`-row program
23    /// evaluation. `cache` starts empty -- prefill is the only state that
24    /// does not inherit recurrent state from a predecessor.
25    Prefill { positions: Vec<Entry>, cache: Cache },
26    /// `last`: the most recently accepted token id, fed back as the next
27    /// single-row evaluation's input.
28    Decode { last: Entry, cache: Cache },
29    /// `draft`: candidate token ids proposed by a speculator, scored in one
30    /// `K`-row evaluation with logits returned at every row. `snapshot` is
31    /// the pre-draft `cache`, restored verbatim on rejection.
32    Verify {
33        draft: Vec<Entry>,
34        snapshot: Cache,
35        cache: Cache,
36    },
37    /// `n`: how many leading draft tokens the verifier accepted; `next` is
38    /// the token id decoding resumes from (the confirmed final draft token
39    /// when `n == draft.len()`, otherwise the target's own row-`n`
40    /// prediction that replaced the rejected draft token).
41    Accept { n: usize, next: Entry, cache: Cache },
42    /// `to`: the token id decoding resumes from after `snapshot` is
43    /// restored in place of a rejected draft's advanced cache.
44    Rollback { snapshot: Cache, to: Entry },
45    /// Terminal: no further evaluation is legal.
46    Done { cache: Cache },
47}
48
49impl<Entry, Cache> ServingState<Entry, Cache> {
50    /// Enter the state machine at `Prefill` with an empty `cache`.
51    pub fn start(positions: Vec<Entry>, cache: Cache) -> Self {
52        Self::Prefill { positions, cache }
53    }
54
55    /// `Prefill { positions } -> Decode { last }`: the one `M`-row program
56    /// evaluation prefill performs produced `next_token` and left `cache`
57    /// holding the resulting recurrent/KV state.
58    pub fn advance_prefill(self, next_token: Entry, cache: Cache) -> Result<Self, ServingFsmError> {
59        match self {
60            Self::Prefill { .. } => Ok(Self::Decode {
61                last: next_token,
62                cache,
63            }),
64            _ => Err(ServingFsmError::IllegalTransition {
65                attempted: "advance_prefill",
66            }),
67        }
68    }
69
70    /// `Decode { last } -> Decode { last }`: one single-row evaluation.
71    pub fn advance_decode(self, next_token: Entry, cache: Cache) -> Result<Self, ServingFsmError> {
72        match self {
73            Self::Decode { .. } => Ok(Self::Decode {
74                last: next_token,
75                cache,
76            }),
77            _ => Err(ServingFsmError::IllegalTransition {
78                attempted: "advance_decode",
79            }),
80        }
81    }
82
83    /// `Decode { last } -> Verify { draft, snapshot }`: the current `cache`
84    /// becomes `snapshot`, restorable verbatim if the draft is rejected.
85    pub fn enter_verify(self, draft: Vec<Entry>) -> Result<Self, ServingFsmError>
86    where
87        Cache: Clone,
88    {
89        match self {
90            Self::Decode { cache, .. } => Ok(Self::Verify {
91                draft,
92                snapshot: cache.clone(),
93                cache,
94            }),
95            _ => Err(ServingFsmError::IllegalTransition {
96                attempted: "enter_verify",
97            }),
98        }
99    }
100
101    /// `Accept { n, next, cache } -> Decode { last }`: no evaluation, just
102    /// unwrapping the placement `accept` already chose.
103    pub fn resume(self) -> Result<Self, ServingFsmError> {
104        match self {
105            Self::Accept { next, cache, .. } => Ok(Self::Decode { last: next, cache }),
106            _ => Err(ServingFsmError::IllegalTransition {
107                attempted: "resume",
108            }),
109        }
110    }
111
112    /// `Rollback { snapshot, to } -> Decode { last }`: placement copy only,
113    /// no program evaluation -- `snapshot` is whichever row's `Cache`
114    /// `accept` selected as the correct resume point.
115    pub fn rollback(self) -> Result<Self, ServingFsmError> {
116        match self {
117            Self::Rollback { snapshot, to } => Ok(Self::Decode {
118                last: to,
119                cache: snapshot,
120            }),
121            _ => Err(ServingFsmError::IllegalTransition {
122                attempted: "rollback",
123            }),
124        }
125    }
126
127    /// Any state `-> Done`: the caller has stopped requesting further
128    /// evaluations (max tokens reached, eos observed, error surfaced).
129    pub fn finish(self) -> Self {
130        let cache = match self {
131            Self::Prefill { cache, .. }
132            | Self::Decode { cache, .. }
133            | Self::Verify { cache, .. }
134            | Self::Accept { cache, .. }
135            | Self::Done { cache } => cache,
136            Self::Rollback { snapshot, .. } => snapshot,
137        };
138        Self::Done { cache }
139    }
140}
141
142impl<Entry: PartialEq + Clone, Cache> ServingState<Entry, Cache> {
143    /// `Verify { draft, snapshot } -> Accept { n, next } | Rollback { snapshot, to }`:
144    /// scores the `K`-row verify evaluation's per-row greedy argmax
145    /// (`row_tokens[i]` predicts the token that follows having consumed
146    /// input row `i`, where row `0` follows `last` and row `i>0` follows
147    /// `draft[i - 1]`) against `draft` itself. `row_caches[i]` is the exact
148    /// `Cache` the same evaluation produced at row `i` -- accepting `n`
149    /// draft tokens means resuming from `row_caches[n]` verbatim (a
150    /// placement copy the caller already computed), never replaying
151    /// anything. `n == draft.len()`: every drafted token matched, `Accept`
152    /// resumes from the confirmed final draft token. `n < draft.len()`:
153    /// `draft[n]` was wrong, so this returns `Rollback` carrying the
154    /// correct placement and the target's own token in its place.
155    pub fn accept(
156        self,
157        row_tokens: &[Entry],
158        row_caches: Vec<Cache>,
159    ) -> Result<Self, ServingFsmError> {
160        match self {
161            Self::Verify { draft, .. } => {
162                let accepted = draft
163                    .iter()
164                    .zip(row_tokens.iter())
165                    .take_while(|(drafted, predicted)| drafted == predicted)
166                    .count();
167                Self::settle(draft, accepted, row_tokens, row_caches, "accept")
168            }
169            _ => Err(ServingFsmError::IllegalTransition {
170                attempted: "accept",
171            }),
172        }
173    }
174
175    /// [`Self::accept`] with the accepted count supplied by the caller (see
176    /// [`crate::accept_rule::AcceptRule::accepted_rows`]). A verifier that stops
177    /// sampling at the first row that differs passes only the tokens it chose:
178    /// `row_tokens` needs to reach row `accepted` (and nothing past it) unless
179    /// every draft row was accepted, while `row_caches` always holds one entry
180    /// per draft row.
181    pub fn accept_rows(
182        self,
183        accepted: usize,
184        row_tokens: &[Entry],
185        row_caches: Vec<Cache>,
186    ) -> Result<Self, ServingFsmError> {
187        match self {
188            Self::Verify { draft, .. } => {
189                Self::settle(draft, accepted, row_tokens, row_caches, "accept_rows")
190            }
191            _ => Err(ServingFsmError::IllegalTransition {
192                attempted: "accept_rows",
193            }),
194        }
195    }
196
197    fn settle(
198        draft: Vec<Entry>,
199        accepted: usize,
200        row_tokens: &[Entry],
201        row_caches: Vec<Cache>,
202        attempted: &'static str,
203    ) -> Result<Self, ServingFsmError> {
204        if draft.is_empty()
205            || (accepted < draft.len() && row_tokens.len() <= accepted)
206            || row_caches.len() != draft.len()
207            || accepted > draft.len()
208        {
209            return Err(ServingFsmError::IllegalTransition { attempted });
210        }
211        let placement_index = if accepted == draft.len() {
212            accepted - 1
213        } else {
214            accepted
215        };
216        let Some(cache) = row_caches.into_iter().nth(placement_index) else {
217            return Err(ServingFsmError::IllegalTransition { attempted });
218        };
219        if accepted == draft.len() {
220            Ok(Self::Accept {
221                n: accepted,
222                next: draft[accepted - 1].clone(),
223                cache,
224            })
225        } else {
226            Ok(Self::Rollback {
227                snapshot: cache,
228                to: row_tokens[accepted].clone(),
229            })
230        }
231    }
232}
233
234// test-only environment; a failed transition here is a broken fixture, so expect names it
235#[cfg(test)]
236#[allow(clippy::unwrap_used, clippy::expect_used)]
237pub(crate) mod action_fixture;
238
239#[cfg(test)]
240// every transition here returns a typed error the assertions above it already
241// prove unreachable; `expect` documents which one, `unwrap_used`/`expect_used`
242// stay denied outside `#[cfg(test)]`
243#[allow(clippy::unwrap_used, clippy::expect_used)]
244mod tests {
245    use super::action_fixture::{Action, run_sequential, run_speculative};
246    use super::*;
247
248    #[test]
249    fn serving_fsm_error_names_the_attempted_transition() {
250        let accept = ServingFsmError::IllegalTransition {
251            attempted: "accept",
252        };
253        let resume = ServingFsmError::IllegalTransition {
254            attempted: "resume",
255        };
256
257        assert_eq!(
258            alloc::format!("{}", accept),
259            "serving fsm: accept is not legal from the current state"
260        );
261        assert_ne!(accept, resume);
262    }
263
264    #[test]
265    fn walk_prefill_decode_verify_finish() {
266        let prefilled = ServingState::start(alloc::vec![1_u32, 2], 0_u8);
267        let decoding = prefilled.advance_prefill(3, 1_u8).expect("legal");
268        let decoding = decoding.advance_decode(4, 2_u8).expect("legal");
269        let verifying = decoding.enter_verify(alloc::vec![5]).expect("legal");
270
271        assert_eq!(
272            verifying,
273            ServingState::Verify {
274                draft: alloc::vec![5],
275                snapshot: 2,
276                cache: 2
277            }
278        );
279        let done = verifying.finish();
280        assert_eq!(done, ServingState::Done { cache: 2 });
281        assert_eq!(
282            done.advance_decode(6, 3),
283            Err(ServingFsmError::IllegalTransition {
284                attempted: "advance_decode"
285            })
286        );
287    }
288
289    /// A minimal stand-in for `generate.rs`'s `LayerCacheState`/
290    /// `SsmLayerCache`: just enough to prove the FSM threads recurrent
291    /// state through transitions rather than patching a cache from outside.
292    #[derive(Debug, Clone, PartialEq)]
293    struct FakeCache {
294        conv_history_len: usize,
295        kv_len: usize,
296    }
297
298    impl FakeCache {
299        fn empty() -> Self {
300            Self {
301                conv_history_len: 0,
302                kv_len: 0,
303            }
304        }
305
306        fn advanced_by(&self, rows: usize) -> Self {
307            Self {
308                conv_history_len: self.conv_history_len + rows,
309                kv_len: self.kv_len + rows,
310            }
311        }
312    }
313
314    /// Drives every legal transition the FSM defines: `Prefill -> Decode ->
315    /// Decode -> Verify -> Accept -> Decode -> Verify -> Rollback -> Decode
316    /// -> Done`, and proves each illegal call at the wrong state is
317    /// rejected rather than silently accepted.
318    #[test]
319    fn serving_state_walkthrough_drives_every_legal_transition() {
320        let prompt = alloc::vec![1_u32, 2, 3];
321        let prompt_rows = prompt.len();
322        let state = ServingState::start(prompt, FakeCache::empty());
323        assert_eq!(
324            state,
325            ServingState::Prefill {
326                positions: alloc::vec![1, 2, 3],
327                cache: FakeCache::empty(),
328            }
329        );
330
331        let after_prefill_cache = FakeCache::empty().advanced_by(prompt_rows);
332        let state = state
333            .advance_prefill(4, after_prefill_cache.clone())
334            .expect("prefill -> decode is legal");
335        assert_eq!(
336            state,
337            ServingState::Decode {
338                last: 4,
339                cache: after_prefill_cache.clone(),
340            }
341        );
342
343        let after_first_decode_cache = after_prefill_cache.advanced_by(1);
344        let state = state
345            .advance_decode(5, after_first_decode_cache.clone())
346            .expect("decode -> decode is legal");
347        assert_eq!(
348            state,
349            ServingState::Decode {
350                last: 5,
351                cache: after_first_decode_cache.clone(),
352            }
353        );
354
355        let illegal_prefill = state
356            .clone()
357            .advance_prefill(6, after_first_decode_cache.clone());
358        assert_eq!(
359            illegal_prefill,
360            Err(ServingFsmError::IllegalTransition {
361                attempted: "advance_prefill"
362            })
363        );
364
365        let draft = alloc::vec![6_u32, 7];
366        let state = state
367            .enter_verify(draft.clone())
368            .expect("decode -> verify is legal");
369        assert_eq!(
370            state,
371            ServingState::Verify {
372                draft,
373                snapshot: after_first_decode_cache.clone(),
374                cache: after_first_decode_cache.clone(),
375            }
376        );
377
378        // full acceptance: both draft tokens (6, 7) match the target's own
379        // predictions, so accept -> resume lands back in Decode with the
380        // last draft token and row 1's placement.
381        let row_one_cache = after_first_decode_cache.advanced_by(1);
382        let row_two_cache = after_first_decode_cache.advanced_by(2);
383        let state = state
384            .accept(&[6, 7], alloc::vec![row_one_cache, row_two_cache.clone()])
385            .expect("verify -> accept is legal");
386        assert_eq!(
387            state,
388            ServingState::Accept {
389                n: 2,
390                next: 7,
391                cache: row_two_cache.clone(),
392            }
393        );
394        let state = state.resume().expect("accept -> decode is legal");
395        assert_eq!(
396            state,
397            ServingState::Decode {
398                last: 7,
399                cache: row_two_cache.clone()
400            }
401        );
402
403        let illegal_resume = state.clone().resume();
404        assert_eq!(
405            illegal_resume,
406            Err(ServingFsmError::IllegalTransition {
407                attempted: "resume"
408            })
409        );
410
411        // partial acceptance: draft proposes (100, 101), target predicts
412        // 100 (confirming draft[0]) then 55 instead of 101 -- row_caches[0]
413        // is the state after consuming `last` (used to predict draft[0]),
414        // row_caches[1] is the state after consuming draft[0] (used to
415        // predict row_tokens[1]=55); rejection at index 1 resumes from
416        // row_caches[1], since that is the state row_tokens[1] came from.
417        let draft = alloc::vec![100_u32, 101];
418        let state = state
419            .enter_verify(draft)
420            .expect("decode -> verify is legal");
421        let cache_after_last = row_two_cache.advanced_by(1);
422        let cache_after_draft_zero = row_two_cache.advanced_by(2);
423        let state = state
424            .accept(
425                &[100, 55],
426                alloc::vec![cache_after_last, cache_after_draft_zero.clone()],
427            )
428            .expect("verify -> rollback is legal on partial acceptance");
429        assert_eq!(
430            state,
431            ServingState::Rollback {
432                snapshot: cache_after_draft_zero.clone(),
433                to: 55,
434            }
435        );
436        let state = state.rollback().expect("rollback -> decode is legal");
437        assert_eq!(
438            state,
439            ServingState::Decode {
440                last: 55,
441                cache: cache_after_draft_zero
442            }
443        );
444
445        let done = state.finish();
446        assert!(matches!(done, ServingState::Done { .. }));
447
448        let past_done = done.advance_decode(8, FakeCache::empty());
449        assert_eq!(
450            past_done,
451            Err(ServingFsmError::IllegalTransition {
452                attempted: "advance_decode"
453            })
454        );
455    }
456
457    /// `accept` rejects a call whose `row_tokens`/`row_caches` lengths
458    /// don't match the draft it is scoring, and an empty draft never
459    /// reaches `accept` (verified separately since `enter_verify` accepts
460    /// any `Vec`, including empty, but `accept` must not panic on it).
461    #[test]
462    fn serving_state_accept_rejects_mismatched_or_empty_draft() {
463        let cache = FakeCache::empty();
464        let state = ServingState::start(alloc::vec![1], cache.clone())
465            .advance_prefill(2, cache.clone())
466            .expect("prefill -> decode")
467            .enter_verify(alloc::vec![3, 4])
468            .expect("decode -> verify");
469
470        let wrong_lengths = state.clone().accept(&[3], alloc::vec![cache.clone()]);
471        assert_eq!(
472            wrong_lengths,
473            Err(ServingFsmError::IllegalTransition {
474                attempted: "accept"
475            })
476        );
477
478        let empty_draft = ServingState::start(alloc::vec![1], cache.clone())
479            .advance_prefill(2, cache.clone())
480            .expect("prefill -> decode")
481            .enter_verify(Vec::new())
482            .expect("decode -> verify")
483            .accept(&[], Vec::new());
484        assert_eq!(
485            empty_draft,
486            Err(ServingFsmError::IllegalTransition {
487                attempted: "accept"
488            })
489        );
490    }
491
492    /// Prompt-lookup / n-gram drafting: find the most recent earlier occurrence
493    /// of `history`'s own last `ngram` tokens elsewhere in `history` (the
494    /// prompt+generated id sequence so far), and draft up to `max_k` tokens
495    /// (`max_k` up to 5) that followed that occurrence -- no second model, pure
496    /// string match over ids already produced. Returns an empty draft (never
497    /// entering `Verify`) when `history` is too short or no earlier occurrence
498    /// exists.
499    fn draft_prompt_lookup<Entry: PartialEq + Clone>(
500        history: &[Entry],
501        ngram: usize,
502        max_k: usize,
503    ) -> Vec<Entry> {
504        if ngram == 0 || max_k == 0 || history.len() <= ngram {
505            return Vec::new();
506        }
507        let needle = &history[history.len() - ngram..];
508        let search_end = history.len() - ngram;
509        for start in (0..search_end).rev() {
510            if &history[start..start + ngram] == needle {
511                let match_end = start + ngram;
512                let available = history.len() - match_end;
513                let take = available.min(max_k);
514                return history[match_end..match_end + take].to_vec();
515            }
516        }
517        Vec::new()
518    }
519
520    /// Worked example for [`draft_prompt_lookup`]: history `[5, 1, 2, 3, 9,
521    /// 1, 2, 3]`'s last `ngram=2` tokens are `[2, 3]`; scanning backward
522    /// from the trailing occurrence, the first EARLIER `[2, 3]` is at index
523    /// 2, so the draft is whatever followed it there -- `[9, 1, 2, 3]`, the
524    /// four tokens between that earlier match and the end of history.
525    /// `max_k=2` caps that same draft to its first two tokens; `max_k=0`,
526    /// too-short history, and no earlier occurrence all draft nothing.
527    #[test]
528    fn serving_state_draft_prompt_lookup_finds_the_earlier_occurrence() {
529        let history = alloc::vec![5_u32, 1, 2, 3, 9, 1, 2, 3];
530        assert_eq!(draft_prompt_lookup(&history, 2, 5), alloc::vec![9, 1, 2, 3]);
531        assert_eq!(draft_prompt_lookup(&history, 2, 2), alloc::vec![9, 1]);
532        assert_eq!(draft_prompt_lookup(&history, 2, 0), Vec::<u32>::new());
533        assert_eq!(draft_prompt_lookup(&[1, 2], 2, 5), Vec::<u32>::new());
534        assert_eq!(draft_prompt_lookup(&[9, 9, 9], 1, 1), alloc::vec![9]);
535    }
536
537    /// A pure, deterministic stand-in for the target model: `state` is the
538    /// running token count (`FakeCache`'s `kv_len` doubles as it), and the
539    /// "greedy argmax" for any input token is `input + 1` up to a `vocab`
540    /// ceiling, wrapping to keep every generated id in range -- enough to
541    /// drive both a plain sequential loop and a speculative loop through
542    /// identical arithmetic so their outputs are comparable byte-for-byte.
543    fn fake_greedy_next(input: u32, vocab: u32) -> u32 {
544        (input + 1) % vocab
545    }
546
547    /// The oracle at the FSM level: plain greedy decode
548    /// (one `advance_decode` per token, no drafting) produces the exact
549    /// same 32-token sequence as speculative decode (prompt-lookup drafts
550    /// up to `K=5`, scored via `accept`/`resume`/`rollback`) against the
551    /// same deterministic target function -- proving this module's own
552    /// control flow never changes what greedy decoding would have produced,
553    /// independent of any model program.
554    #[test]
555    fn serving_state_speculation_matches_plain_greedy_for_thirty_two_tokens() {
556        // a small vocab wraps `fake_greedy_next`'s output within the
557        // 32-token run, so its own 2-grams recur and `draft_prompt_lookup`
558        // actually finds matches -- proving the Accept/resume path, not
559        // just the empty-draft fallback (a wide vocab like 97 never repeats
560        // in 32 tokens from a single seed and would silently never draft).
561        const VOCAB: u32 = 11;
562        const TOKENS: usize = 32;
563        const K: usize = 5;
564        const NGRAM: usize = 2;
565        let seed_prompt = alloc::vec![3_u32, 5];
566
567        let mut plain_history = seed_prompt.clone();
568        let mut plain_state = ServingState::start(seed_prompt.clone(), FakeCache::empty());
569        let mut last = *plain_history.last().expect("seed prompt is non-empty");
570        plain_state = plain_state
571            .advance_prefill(last, FakeCache::empty())
572            .expect("prefill -> decode");
573        for _ in 0..TOKENS {
574            let next = fake_greedy_next(last, VOCAB);
575            plain_state = plain_state
576                .advance_decode(next, FakeCache::empty())
577                .expect("decode -> decode");
578            plain_history.push(next);
579            last = next;
580        }
581
582        let mut spec_history = seed_prompt.clone();
583        let mut spec_state = ServingState::start(seed_prompt.clone(), FakeCache::empty());
584        let mut last = *spec_history.last().expect("seed prompt is non-empty");
585        spec_state = spec_state
586            .advance_prefill(last, FakeCache::empty())
587            .expect("prefill -> decode");
588        let mut verify_rounds = 0usize;
589        let mut draft_tokens_accepted = 0usize;
590        while spec_history.len() - seed_prompt.len() < TOKENS {
591            let draft = draft_prompt_lookup(&spec_history, NGRAM, K);
592            if draft.is_empty() {
593                let next = fake_greedy_next(last, VOCAB);
594                spec_state = spec_state
595                    .advance_decode(next, FakeCache::empty())
596                    .expect("decode -> decode");
597                spec_history.push(next);
598                last = next;
599                continue;
600            }
601            let mut row_tokens = Vec::with_capacity(draft.len());
602            let mut feed = last;
603            for &drafted in &draft {
604                row_tokens.push(fake_greedy_next(feed, VOCAB));
605                feed = drafted;
606            }
607            let row_caches = alloc::vec![FakeCache::empty(); draft.len()];
608            verify_rounds += 1;
609            let verifying = spec_state
610                .enter_verify(draft.clone())
611                .expect("decode -> verify");
612            let outcome = verifying
613                .accept(&row_tokens, row_caches)
614                .expect("verify -> accept | rollback");
615            spec_state = match outcome {
616                ServingState::Accept { n, next, cache } => {
617                    draft_tokens_accepted += n;
618                    for token in &draft {
619                        spec_history.push(*token);
620                    }
621                    last = next;
622                    ServingState::Accept { n, next, cache }
623                        .resume()
624                        .expect("accept -> decode")
625                }
626                ServingState::Rollback { snapshot, to } => {
627                    let accepted = draft
628                        .iter()
629                        .zip(row_tokens.iter())
630                        .take_while(|(drafted, predicted)| drafted == predicted)
631                        .count();
632                    for token in &draft[..accepted] {
633                        spec_history.push(*token);
634                    }
635                    spec_history.push(to);
636                    last = to;
637                    ServingState::Rollback { snapshot, to }
638                        .rollback()
639                        .expect("rollback -> decode")
640                }
641                _ => unreachable!("accept only ever returns Accept or Rollback"),
642            };
643            spec_history
644                .truncate(seed_prompt.len() + TOKENS.min(spec_history.len() - seed_prompt.len()));
645        }
646
647        assert!(
648            verify_rounds > 0 && draft_tokens_accepted > 0,
649            "the small vocab must make draft_prompt_lookup find real matches -- a zero count here means \
650             this run degenerated to the empty-draft fallback and never exercised accept/resume at all"
651        );
652        assert_eq!(
653            spec_history, plain_history,
654            "speculative and plain greedy must produce identical token ids"
655        );
656        assert!(matches!(plain_state.finish(), ServingState::Done { .. }));
657        assert!(matches!(spec_state.finish(), ServingState::Done { .. }));
658    }
659
660    #[test]
661    fn accept_rows_takes_declared_count_not_prefix() {
662        let state = ServingState::start(alloc::vec![1_u32], FakeCache::empty())
663            .advance_prefill(2, FakeCache::empty().advanced_by(2))
664            .expect("prefill advances")
665            .enter_verify(alloc::vec![3, 4])
666            .expect("verify enters");
667        let cache_one = FakeCache::empty().advanced_by(1);
668        let cache_two = FakeCache::empty().advanced_by(2);
669        let caches = || alloc::vec![cache_one.clone(), cache_two.clone()];
670
671        assert_eq!(
672            state.clone().accept_rows(0, &[3, 4], caches()),
673            Ok(ServingState::Rollback {
674                snapshot: cache_one.clone(),
675                to: 3
676            })
677        );
678        assert_eq!(
679            state.clone().accept_rows(2, &[3, 4], caches()),
680            Ok(ServingState::Accept {
681                n: 2,
682                next: 4,
683                cache: cache_two.clone()
684            })
685        );
686        assert_eq!(
687            state.clone().accept_rows(3, &[3, 4], caches()),
688            Err(ServingFsmError::IllegalTransition {
689                attempted: "accept_rows"
690            })
691        );
692        assert_eq!(
693            ServingState::start(alloc::vec![1_u32], FakeCache::empty()).accept_rows(
694                0,
695                &[3, 4],
696                caches()
697            ),
698            Err(ServingFsmError::IllegalTransition {
699                attempted: "accept_rows"
700            })
701        );
702    }
703
704    #[test]
705    fn accept_rows_needs_tokens_only_up_to_the_first_differing_row() {
706        let state = ServingState::start(alloc::vec![1_u32], FakeCache::empty())
707            .advance_prefill(2, FakeCache::empty().advanced_by(2))
708            .expect("prefill advances")
709            .enter_verify(alloc::vec![3, 4, 5])
710            .expect("verify enters");
711        let caches = || {
712            alloc::vec![
713                FakeCache::empty().advanced_by(1),
714                FakeCache::empty().advanced_by(2),
715                FakeCache::empty().advanced_by(3),
716            ]
717        };
718
719        assert_eq!(
720            state.clone().accept_rows(1, &[3, 9], caches()),
721            Ok(ServingState::Rollback {
722                snapshot: FakeCache::empty().advanced_by(2),
723                to: 9
724            })
725        );
726        assert_eq!(
727            state.clone().accept_rows(3, &[], caches()),
728            Ok(ServingState::Accept {
729                n: 3,
730                next: 5,
731                cache: FakeCache::empty().advanced_by(3)
732            })
733        );
734        assert_eq!(
735            state.accept_rows(1, &[3], caches()),
736            Err(ServingFsmError::IllegalTransition {
737                attempted: "accept_rows"
738            })
739        );
740    }
741
742    #[test]
743    fn action_fixture_sequential_matches_hand_derivation() {
744        let expected = [
745            (0, 1, 0),
746            (1, 1, 1),
747            (2, 1, 2),
748            (0, 2, 3),
749            (1, 2, 4),
750            (2, 2, 5),
751            (0, 4, 6),
752            (1, 4, 7),
753            (2, 4, 8),
754            (0, 3, 9),
755            (1, 3, 10),
756            (2, 3, 11),
757        ]
758        .map(|(key, delta, version)| Action {
759            key,
760            delta,
761            version,
762        });
763
764        let trace = run_sequential();
765
766        assert_eq!(trace.history, expected);
767        assert_eq!(
768            trace.environment.values,
769            alloc::collections::BTreeMap::from([(0_u8, 10_i64), (1, 10), (2, 10)])
770        );
771        assert_eq!(trace.environment.version, 12);
772    }
773
774    fn leading_equal(draft: &[Action], choices: &[Action]) -> usize {
775        draft
776            .iter()
777            .zip(choices)
778            .take_while(|(drafted, chosen)| drafted == chosen)
779            .count()
780    }
781
782    fn equality_policy(draft: &[Action], choices: &[Action]) -> usize {
783        leading_equal(draft, choices)
784    }
785
786    fn verifier_exact_policy(draft: &[Action], choices: &[Action]) -> usize {
787        leading_equal(draft, choices)
788    }
789
790    fn anchor_macro_policy(draft: &[Action], choices: &[Action]) -> usize {
791        let aligned = leading_equal(draft, choices);
792        if aligned >= 3 { aligned } else { 0 }
793    }
794
795    #[test]
796    fn action_speculation_equality_matches_sequential() {
797        let sequential = run_sequential();
798        let speculative = run_speculative(equality_policy);
799
800        assert_eq!(speculative.history, sequential.history);
801        assert_eq!(speculative.environment, sequential.environment);
802        assert_eq!(
803            (
804                speculative.rounds,
805                speculative.rejected_rounds,
806                speculative.accepted_rows
807            ),
808            (4, 4, 8)
809        );
810    }
811
812    #[test]
813    fn action_speculation_verifier_exact_matches_sequential() {
814        let sequential = run_sequential();
815        let speculative = run_speculative(verifier_exact_policy);
816
817        assert_eq!(speculative.history, sequential.history);
818        assert_eq!(speculative.environment, sequential.environment);
819        assert_eq!(
820            (
821                speculative.rounds,
822                speculative.rejected_rounds,
823                speculative.accepted_rows
824            ),
825            (4, 4, 8)
826        );
827    }
828
829    #[test]
830    fn action_speculation_anchor_macro_matches_sequential() {
831        let sequential = run_sequential();
832        let speculative = run_speculative(anchor_macro_policy);
833
834        assert_eq!(speculative.history, sequential.history);
835        assert_eq!(speculative.environment, sequential.environment);
836        assert_eq!(
837            (
838                speculative.rounds,
839                speculative.rejected_rounds,
840                speculative.accepted_rows
841            ),
842            (6, 6, 6)
843        );
844    }
845}