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}