spark_model/speculative/
tree_shape.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Spine+hedge draft-tree shapes for tree speculative decoding (Phase 1 of
4//! the tree-spec plan).
5//!
6//! A tree is a **spine** (the drafter's top-1 chain, exactly today's chain
7//! draft) plus **hedge leaves**: rank-2..k siblings of spine nodes with no
8//! children. The constraint is load-bearing — every node's ancestors are
9//! spine nodes, so per-row attention visibility is "committed prefix +
10//! spine(1..d-1) + self", the drafter KV stays a linear chain, and GDN
11//! verification decomposes into one spine pass + one 1-token pass per hedge.
12//!
13//! Shape notation (`ATLAS_TREE_SHAPE="1,2,2,2"`): per-depth node counts;
14//! depth d contributes 1 spine node + (count_d - 1) hedges. Verify width
15//! M = 1 (root row) + total nodes.
16//!
17//! Row layout (fixed): row 0 = root (last committed token, the bonus row),
18//! rows 1..=L = spine in depth order, then hedges in (depth, rank) order.
19//! The spine being a contiguous prefix is what lets the GDN layer run one
20//! existing wy-kernel pass over rows 0..=L unchanged.
21
22use anyhow::{Result, bail};
23
24/// Maximum candidate rank per position (drafter shadow/top-k width).
25pub const MAX_RANK: usize = 4;
26/// Maximum tree nodes (excl. root row); M = nodes + 1 <= 8 keeps every
27/// projection on the batched-GEMV path once the batch8 kernel lands.
28pub const MAX_NODES: usize = 7;
29
30/// Static tree shape: per-depth node counts.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct TreeShape {
33    /// counts[d-1] = number of candidate nodes at depth d (1 = spine only).
34    pub counts: Vec<u8>,
35}
36
37impl TreeShape {
38    pub fn parse(s: &str) -> Result<Self> {
39        let counts: Vec<u8> = s
40            .split(',')
41            .map(|t| t.trim().parse::<u8>())
42            .collect::<std::result::Result<_, _>>()
43            .map_err(|e| anyhow::anyhow!("ATLAS_TREE_SHAPE parse: {e}"))?;
44        let shape = Self { counts };
45        shape.validate()?;
46        Ok(shape)
47    }
48
49    pub fn validate(&self) -> Result<()> {
50        if self.counts.is_empty() {
51            bail!("tree shape: empty");
52        }
53        if self.counts.iter().any(|&c| c == 0 || c as usize > MAX_RANK) {
54            bail!("tree shape: per-depth count must be 1..={MAX_RANK}");
55        }
56        if self.nodes() > MAX_NODES {
57            bail!("tree shape: {} nodes > max {MAX_NODES}", self.nodes());
58        }
59        Ok(())
60    }
61
62    pub fn spine_len(&self) -> usize {
63        self.counts.len()
64    }
65
66    /// Total tree nodes (spine + hedges), excluding the root row.
67    pub fn nodes(&self) -> usize {
68        self.counts.iter().map(|&c| c as usize).sum()
69    }
70
71    /// Verify width M = root row + nodes.
72    pub fn verify_width(&self) -> usize {
73        self.nodes() + 1
74    }
75
76    /// Stable id for CUDA-graph keying: counts packed 4 bits per depth.
77    pub fn shape_id(&self) -> u64 {
78        self.counts
79            .iter()
80            .fold(0u64, |acc, &c| (acc << 4) | (c as u64))
81    }
82
83    /// A pure chain (no hedges) — must reproduce the chain drafter exactly.
84    pub fn is_chain(&self) -> bool {
85        self.counts.iter().all(|&c| c == 1)
86    }
87}
88
89/// One hedge leaf: the drafter's rank-`rank` candidate at `depth`.
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
91pub struct HedgeNode {
92    pub depth: usize,
93    /// Candidate rank (2-based: rank 2 = drafter's second choice).
94    pub rank: usize,
95    pub token: u32,
96}
97
98/// A proposed draft tree: spine tokens + hedge leaves for one verify step.
99#[derive(Debug, Clone)]
100pub struct TreeDraft {
101    pub shape: TreeShape,
102    /// Spine tokens, spine[d-1] = top-1 draft at depth d.
103    pub spine: Vec<u32>,
104    /// Hedges sorted by (depth, rank) — the row-layout order.
105    pub hedges: Vec<HedgeNode>,
106}
107
108/// One verify row: (token, depth, parent_row). Row 0 is the root.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct TreeRow {
111    pub token: u32,
112    pub depth: usize,
113    pub parent_row: usize,
114}
115
116impl TreeDraft {
117    /// Verify-row layout: [root, spine..., hedges...]. `root_token` is the
118    /// last committed token (the chain paths' `a.last_token`).
119    pub fn rows(&self, root_token: u32) -> Vec<TreeRow> {
120        let l = self.spine.len();
121        let mut rows = Vec::with_capacity(1 + l + self.hedges.len());
122        rows.push(TreeRow {
123            token: root_token,
124            depth: 0,
125            parent_row: 0,
126        });
127        for (i, &t) in self.spine.iter().enumerate() {
128            // spine row d's parent is spine row d-1 (row 0 = root).
129            rows.push(TreeRow {
130                token: t,
131                depth: i + 1,
132                parent_row: i,
133            });
134        }
135        for h in &self.hedges {
136            // spine+hedge invariant: a depth-d hedge's parent is the spine
137            // node at depth d-1 (= row d-1 in this layout).
138            rows.push(TreeRow {
139                token: h.token,
140                depth: h.depth,
141                parent_row: h.depth - 1,
142            });
143        }
144        rows
145    }
146
147    /// Longest root-to-leaf accepted path given per-row target argmaxes
148    /// `v[row]` (the target's next token after that row). Returns the
149    /// accepted rows in order plus the bonus token (the target argmax at
150    /// the last accepted row — row 0 if nothing accepted). Byte-identical
151    /// to greedy by induction: children of a row hold distinct tokens, so
152    /// at most one matches `v[parent]`.
153    pub fn accept_path(&self, rows: &[TreeRow], v: &[u32]) -> (Vec<usize>, u32) {
154        let l = self.spine.len();
155        let mut path = Vec::with_capacity(l);
156        let mut cur = 0usize; // root row
157        for d in 1..=l {
158            let want = v[cur];
159            // Children of `cur` (a spine row at depth d-1): the spine row at
160            // depth d and every hedge at depth d.
161            let spine_row = d; // rows[1..=l] are the spine
162            let mut next = None;
163            if rows[spine_row].token == want {
164                next = Some(spine_row);
165            } else {
166                for (i, h) in self.hedges.iter().enumerate() {
167                    if h.depth == d && h.token == want {
168                        next = Some(1 + l + i);
169                        break;
170                    }
171                }
172            }
173            match next {
174                Some(r) => {
175                    path.push(r);
176                    cur = r;
177                    // A hedge is a leaf — the path cannot extend below it.
178                    if r > l {
179                        break;
180                    }
181                }
182                None => break,
183            }
184        }
185        (path, v[cur])
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    fn draft(shape: &str, spine: &[u32], hedges: &[(usize, usize, u32)]) -> TreeDraft {
194        TreeDraft {
195            shape: TreeShape::parse(shape).unwrap(),
196            spine: spine.to_vec(),
197            hedges: hedges
198                .iter()
199                .map(|&(depth, rank, token)| HedgeNode { depth, rank, token })
200                .collect(),
201        }
202    }
203
204    #[test]
205    fn parse_and_validate() {
206        let s = TreeShape::parse("1,2,2,2").unwrap();
207        assert_eq!(s.spine_len(), 4);
208        assert_eq!(s.nodes(), 7);
209        assert_eq!(s.verify_width(), 8);
210        assert!(!s.is_chain());
211        assert!(TreeShape::parse("1,1").unwrap().is_chain());
212        assert!(TreeShape::parse("").is_err());
213        assert!(TreeShape::parse("1,0").is_err());
214        assert!(TreeShape::parse("5").is_err()); // rank > MAX_RANK
215        assert!(TreeShape::parse("2,2,2,2").is_err()); // 8 nodes > 7
216        assert_ne!(
217            TreeShape::parse("1,2").unwrap().shape_id(),
218            TreeShape::parse("2,1").unwrap().shape_id()
219        );
220    }
221
222    #[test]
223    fn row_layout_spine_prefix() {
224        let t = draft("2,2", &[10, 20], &[(1, 2, 11), (2, 2, 21)]);
225        let rows = t.rows(5);
226        // [root, spine1, spine2, hedge(d1), hedge(d2)]
227        assert_eq!(rows.len(), 5);
228        assert_eq!(
229            rows[0],
230            TreeRow {
231                token: 5,
232                depth: 0,
233                parent_row: 0
234            }
235        );
236        assert_eq!(
237            rows[1],
238            TreeRow {
239                token: 10,
240                depth: 1,
241                parent_row: 0
242            }
243        );
244        assert_eq!(
245            rows[2],
246            TreeRow {
247                token: 20,
248                depth: 2,
249                parent_row: 1
250            }
251        );
252        assert_eq!(
253            rows[3],
254            TreeRow {
255                token: 11,
256                depth: 1,
257                parent_row: 0
258            }
259        );
260        assert_eq!(
261            rows[4],
262            TreeRow {
263                token: 21,
264                depth: 2,
265                parent_row: 1
266            }
267        );
268    }
269
270    #[test]
271    fn accept_full_spine() {
272        let t = draft("1,1", &[10, 20], &[]);
273        let rows = t.rows(5);
274        // v[root]=10 (spine1 ok), v[spine1]=20 (spine2 ok), v[spine2]=99 bonus
275        let (path, bonus) = t.accept_path(&rows, &[10, 20, 99]);
276        assert_eq!(path, vec![1, 2]);
277        assert_eq!(bonus, 99);
278    }
279
280    #[test]
281    fn accept_hedge_rescue_is_terminal() {
282        // spine [10, 20], hedge at depth1 = 11. Its bonus deliberately
283        // equals spine2, proving the accepted hedge remains a leaf.
284        let t = draft("2,1", &[10, 20], &[(1, 2, 11)]);
285        let rows = t.rows(5);
286        // v[root]=11 → spine1(10) misses, hedge(11) rescues (row 3).
287        // Hedge is a leaf: path ends there; bonus = v[hedge row]=20.
288        let (path, bonus) = t.accept_path(&rows, &[11, 55, 66, 20]);
289        assert_eq!(path, vec![3]);
290        assert_eq!(bonus, 20);
291    }
292
293    #[test]
294    fn accept_hedge_after_spine_prefix_is_terminal() {
295        // The first spine token is accepted before a depth-2 hedge rescues
296        // the path. This reaches hedge lookup after `cur` has advanced.
297        let t = draft("1,2,1", &[10, 20, 30], &[(2, 2, 21)]);
298        let rows = t.rows(5);
299        let (path, bonus) = t.accept_path(&rows, &[10, 21, 66, 77, 88]);
300        assert_eq!(path, vec![1, 4]);
301        assert_eq!(bonus, 88);
302    }
303
304    #[test]
305    fn accept_reject_all_gives_root_bonus() {
306        let t = draft("2,1", &[10, 20], &[(1, 2, 11)]);
307        let rows = t.rows(5);
308        let (path, bonus) = t.accept_path(&rows, &[42, 0, 0, 0]);
309        assert!(path.is_empty());
310        assert_eq!(bonus, 42);
311    }
312}