Skip to main content

geographdb_core/storage/
spatial_page.rs

1//! Octree-Native Spatial Page Storage
2//!
3//! Groups NodeRec records into spatially-clustered pages ordered by Morton code.
4//! Each page is a self-contained unit: header + sorted nodes + local properties + edge cluster.
5//!
6//! # Binary Layout
7//!
8//! ```text
9//! [PageHeader: 64 bytes]
10//! [NodeRec[]: node_count * 72 bytes, Morton-sorted]
11//! [pad to 8-byte alignment]
12//! [PropertyBlock: length-prefixed key-value pairs]
13//! [pad to 8-byte alignment]
14//! [EdgeCluster: PageLocalEdge[]]
15//! ```
16//!
17//! # Design Decisions
18//!
19//! - **Page size**: 4KB default, tunable. Fits in one OS page for zero-copy mmap.
20//! - **NodeRec copy**: Pages duplicate NodeRec from `nodes.dat` for spatial locality.
21//!   `nodes.dat` remains the append-only MVCC source of truth; pages are a read-optimized
22//!   spatial index rebuilt on demand.
23//! - **Properties**: Stored locally per page to avoid separate file lookups.
24//! - **Cross-page edges**: Store `dst_node_id` as global u64; reader follows pointer.
25
26use crate::storage::data_structures::NodeRec;
27use anyhow::{bail, Result};
28use bytemuck::{bytes_of, from_bytes, Pod, Zeroable};
29use std::collections::HashMap;
30
31pub const SPATIAL_PAGE_MAGIC: u32 = 0x5347_5041; // "SGPA"
32pub const SPATIAL_PAGE_VERSION: u32 = 1;
33
34/// Default max nodes per page. At 72 bytes/NodeRec, 56 nodes = 4032 bytes + header ≈ 4KB.
35pub const DEFAULT_MAX_NODES_PER_PAGE: usize = 56;
36
37/// Bounding box for 3D spatial queries.
38#[derive(Debug, Clone, Copy, PartialEq)]
39pub struct BoundingBox {
40    pub min_x: f32,
41    pub max_x: f32,
42    pub min_y: f32,
43    pub max_y: f32,
44    pub min_z: f32,
45    pub max_z: f32,
46}
47
48impl BoundingBox {
49    pub fn new(min_x: f32, max_x: f32, min_y: f32, max_y: f32, min_z: f32, max_z: f32) -> Self {
50        Self {
51            min_x,
52            max_x,
53            min_y,
54            max_y,
55            min_z,
56            max_z,
57        }
58    }
59
60    pub fn from_node(node: &NodeRec) -> Self {
61        Self {
62            min_x: node.x,
63            max_x: node.x,
64            min_y: node.y,
65            max_y: node.y,
66            min_z: node.z,
67            max_z: node.z,
68        }
69    }
70
71    pub fn expand(&mut self, other: &BoundingBox) {
72        self.min_x = self.min_x.min(other.min_x);
73        self.max_x = self.max_x.max(other.max_x);
74        self.min_y = self.min_y.min(other.min_y);
75        self.max_y = self.max_y.max(other.max_y);
76        self.min_z = self.min_z.min(other.min_z);
77        self.max_z = self.max_z.max(other.max_z);
78    }
79
80    pub fn intersects(&self, other: &BoundingBox) -> bool {
81        self.min_x <= other.max_x
82            && self.max_x >= other.min_x
83            && self.min_y <= other.max_y
84            && self.max_y >= other.min_y
85            && self.min_z <= other.max_z
86            && self.max_z >= other.min_z
87    }
88
89    pub fn contains_point(&self, x: f32, y: f32, z: f32) -> bool {
90        x >= self.min_x
91            && x <= self.max_x
92            && y >= self.min_y
93            && y <= self.max_y
94            && z >= self.min_z
95            && z <= self.max_z
96    }
97}
98
99/// Fixed-size page header (88 bytes, no padding).
100#[repr(C)]
101#[derive(Clone, Copy, Debug, Pod, Zeroable)]
102pub struct SpatialPageHeader {
103    pub magic: u32,
104    pub version: u32,
105    pub morton_prefix: u64,
106    pub node_count: u16,  // offset 16 (2-aligned)
107    pub morton_shift: u8, // offset 18 (1-aligned)
108    pub _pad1: [u8; 5],
109    /// Bounding box of all nodes in this page.
110    pub min_x: f32,
111    pub max_x: f32,
112    pub min_y: f32,
113    pub max_y: f32,
114    pub min_z: f32,
115    pub max_z: f32,
116    pub _pad2: [u8; 8],
117    /// Offset within page body (after header) to property block, in bytes.
118    pub property_block_offset: u32,
119    /// Length of property block in bytes.
120    pub property_block_len: u32,
121    /// Offset within page body to edge cluster, in bytes.
122    pub edge_cluster_offset: u32,
123    /// Number of edges in this page's edge cluster.
124    pub edge_cluster_len: u32,
125    pub _reserved: [u8; 16],
126}
127
128impl SpatialPageHeader {
129    pub const LEN: usize = std::mem::size_of::<Self>();
130
131    pub fn validate(&self) -> Result<()> {
132        if self.magic != SPATIAL_PAGE_MAGIC {
133            bail!(
134                "SpatialPageHeader: bad magic (expected 0x{:08x}, got 0x{:08x})",
135                SPATIAL_PAGE_MAGIC,
136                self.magic
137            );
138        }
139        if self.version != SPATIAL_PAGE_VERSION {
140            bail!(
141                "SpatialPageHeader: unsupported version {} (expected {})",
142                self.version,
143                SPATIAL_PAGE_VERSION
144            );
145        }
146        Ok(())
147    }
148
149    pub fn bbox(&self) -> BoundingBox {
150        BoundingBox::new(
151            self.min_x, self.max_x, self.min_y, self.max_y, self.min_z, self.max_z,
152        )
153    }
154}
155
156/// Edge record stored locally within a page.
157/// src_node_idx is page-local; dst_node_id is global.
158#[repr(C)]
159#[derive(Clone, Copy, Debug, Pod, Zeroable)]
160pub struct PageLocalEdge {
161    pub src_node_idx: u16,
162    pub _pad1: [u8; 6],
163    pub dst_node_id: u64,
164    pub weight: f32,
165    pub flags: u32,
166}
167
168/// A spatial page: header + nodes + properties + edges, packed into a contiguous byte slice.
169#[derive(Debug, Clone)]
170pub struct SpatialPage {
171    pub header: SpatialPageHeader,
172    pub nodes: Vec<NodeRec>,
173    /// node_id -> properties for nodes in this page.
174    pub properties: HashMap<u64, HashMap<String, String>>,
175    /// Edges where source node is in this page.
176    pub edges: Vec<PageLocalEdge>,
177}
178
179impl SpatialPage {
180    /// Create an empty page with given Morton prefix and shift.
181    pub fn new(morton_prefix: u64, morton_shift: u8) -> Self {
182        Self {
183            header: SpatialPageHeader {
184                magic: SPATIAL_PAGE_MAGIC,
185                version: SPATIAL_PAGE_VERSION,
186                morton_prefix,
187                morton_shift,
188                node_count: 0,
189                _pad1: [0; 5],
190                min_x: f32::MAX,
191                max_x: f32::MIN,
192                min_y: f32::MAX,
193                max_y: f32::MIN,
194                min_z: f32::MAX,
195                max_z: f32::MIN,
196                _pad2: [0; 8],
197                property_block_offset: 0,
198                property_block_len: 0,
199                edge_cluster_offset: 0,
200                edge_cluster_len: 0,
201                _reserved: [0; 16],
202            },
203            nodes: vec![],
204            properties: HashMap::new(),
205            edges: vec![],
206        }
207    }
208
209    /// Pack this page into a byte vector. Zero-allocation layout: one vec, direct slices.
210    pub fn pack(&self) -> Result<Vec<u8>> {
211        // Compute total size.
212        let node_bytes = self.nodes.len() * std::mem::size_of::<NodeRec>();
213        let node_bytes_aligned = align_up(node_bytes, 8);
214
215        // Encode properties into a temp buffer.
216        let mut prop_buf = Vec::new();
217        for (node_id, props) in &self.properties {
218            prop_buf.extend_from_slice(&node_id.to_le_bytes());
219            let entry_bytes = encode_property_entry(props)?;
220            prop_buf.extend_from_slice(&(entry_bytes.len() as u32).to_le_bytes());
221            prop_buf.extend_from_slice(&entry_bytes);
222        }
223        let prop_bytes_aligned = align_up(prop_buf.len(), 8);
224
225        let edge_bytes = self.edges.len() * std::mem::size_of::<PageLocalEdge>();
226
227        let total = SpatialPageHeader::LEN + node_bytes_aligned + prop_bytes_aligned + edge_bytes;
228        let mut buf = Vec::with_capacity(total);
229
230        // Reserve space for header (will mutate offsets before writing).
231        let mut header = self.header;
232        header.node_count = self.nodes.len() as u16;
233        header.edge_cluster_len = self.edges.len() as u32;
234
235        // Layout: header | nodes | pad | properties | pad | edges
236        let mut offset = SpatialPageHeader::LEN;
237
238        // After nodes.
239        offset += node_bytes_aligned;
240
241        header.property_block_offset = offset as u32;
242        header.property_block_len = prop_buf.len() as u32;
243        offset += prop_bytes_aligned;
244
245        header.edge_cluster_offset = offset as u32;
246
247        buf.extend_from_slice(bytes_of(&header));
248
249        // Write nodes.
250        for node in &self.nodes {
251            buf.extend_from_slice(bytes_of(node));
252        }
253        pad_to_alignment(&mut buf, 8);
254
255        // Write properties.
256        buf.extend_from_slice(&prop_buf);
257        pad_to_alignment(&mut buf, 8);
258
259        // Write edges.
260        for edge in &self.edges {
261            buf.extend_from_slice(bytes_of(edge));
262        }
263
264        Ok(buf)
265    }
266
267    /// Unpack a single spatial page from a byte slice. Zero-copy: nodes and edges are slices.
268    pub fn unpack(data: &[u8]) -> Result<Self> {
269        if data.len() < SpatialPageHeader::LEN {
270            bail!(
271                "SpatialPage unpack: data too short for header ({} < {})",
272                data.len(),
273                SpatialPageHeader::LEN
274            );
275        }
276        let header: &SpatialPageHeader = from_bytes(&data[0..SpatialPageHeader::LEN]);
277        header.validate()?;
278
279        let node_count = header.node_count as usize;
280        let edge_count = header.edge_cluster_len as usize;
281
282        // Nodes: immediately after header.
283        let node_start = SpatialPageHeader::LEN;
284        let node_bytes = node_count * std::mem::size_of::<NodeRec>();
285        let node_end = node_start + node_bytes;
286        if data.len() < node_end {
287            bail!("SpatialPage unpack: data too short for nodes");
288        }
289        let nodes: &[NodeRec] = bytemuck::cast_slice(&data[node_start..node_end]);
290        let nodes = nodes.to_vec(); // We own the page data.
291
292        // Properties: after aligned node block.
293        let prop_offset = header.property_block_offset as usize;
294        let prop_len = header.property_block_len as usize;
295        let mut properties = HashMap::new();
296        if prop_len > 0 {
297            let prop_end = prop_offset + prop_len;
298            if data.len() < prop_end {
299                bail!("SpatialPage unpack: data too short for properties");
300            }
301            let mut cursor = prop_offset;
302            while cursor < prop_end {
303                if cursor + 12 > data.len() {
304                    bail!("SpatialPage unpack: truncated property entry");
305                }
306                let node_id = u64::from_le_bytes([
307                    data[cursor],
308                    data[cursor + 1],
309                    data[cursor + 2],
310                    data[cursor + 3],
311                    data[cursor + 4],
312                    data[cursor + 5],
313                    data[cursor + 6],
314                    data[cursor + 7],
315                ]);
316                cursor += 8;
317                let entry_len = u32::from_le_bytes([
318                    data[cursor],
319                    data[cursor + 1],
320                    data[cursor + 2],
321                    data[cursor + 3],
322                ]) as usize;
323                cursor += 4;
324                if cursor + entry_len > data.len() {
325                    bail!("SpatialPage unpack: truncated property data");
326                }
327                let entry = decode_property_entry(&data[cursor..cursor + entry_len])?;
328                cursor += entry_len;
329                properties.insert(node_id, entry);
330            }
331        }
332
333        // Edges: after property block.
334        let edge_offset = header.edge_cluster_offset as usize;
335        let edge_bytes = edge_count * std::mem::size_of::<PageLocalEdge>();
336        let edge_end = edge_offset + edge_bytes;
337        if data.len() < edge_end {
338            bail!("SpatialPage unpack: data too short for edges");
339        }
340        let edges: &[PageLocalEdge] = bytemuck::cast_slice(&data[edge_offset..edge_end]);
341        let edges = edges.to_vec();
342
343        Ok(SpatialPage {
344            header: *header,
345            nodes,
346            properties,
347            edges,
348        })
349    }
350
351    /// Find a node by its global ID using binary search (nodes are Morton-sorted).
352    /// O(log n) within the page.
353    pub fn find_node_by_id(&self, id: u64) -> Option<usize> {
354        self.nodes.binary_search_by_key(&id, |n| n.id).ok()
355    }
356
357    /// Get properties for a node in this page.
358    pub fn get_properties(&self, node_id: u64) -> Option<&HashMap<String, String>> {
359        self.properties.get(&node_id)
360    }
361
362    /// Get edges where source is a given node in this page.
363    pub fn get_edges_for_node(&self, node_id: u64) -> Vec<&PageLocalEdge> {
364        if let Some(idx) = self.find_node_by_id(node_id) {
365            let idx = idx as u16;
366            self.edges
367                .iter()
368                .filter(|e| e.src_node_idx == idx)
369                .collect()
370        } else {
371            vec![]
372        }
373    }
374}
375
376// ─────────────────────────────────────────────────────────────────────────────
377// Builder: Group NodeRecs into spatial pages by Morton code
378// ─────────────────────────────────────────────────────────────────────────────
379
380/// Build spatial pages from a collection of nodes, grouping by Morton prefix.
381pub fn build_spatial_pages(
382    mut nodes: Vec<NodeRec>,
383    properties: &HashMap<u64, HashMap<String, String>>,
384    edges: &[(u64, u64, f32, u32)], // (src_id, dst_id, weight, flags)
385    max_nodes_per_page: usize,
386) -> Vec<SpatialPage> {
387    if nodes.is_empty() {
388        return vec![];
389    }
390
391    nodes.sort_by_key(|n| n.morton_code);
392
393    let mut pages = vec![];
394    let mut start = 0;
395
396    while start < nodes.len() {
397        let base_morton = nodes[start].morton_code;
398        let mut shift: u8 = 64;
399
400        let (end, final_shift) = loop {
401            let prefix = if shift == 0 || shift >= 64 {
402                0u64
403            } else {
404                (base_morton >> shift) << shift
405            };
406
407            let mut end = start;
408            while end < nodes.len() {
409                let candidate = if shift == 0 || shift >= 64 {
410                    0u64
411                } else {
412                    (nodes[end].morton_code >> shift) << shift
413                };
414                if candidate != prefix || end - start >= max_nodes_per_page {
415                    break;
416                }
417                end += 1;
418            }
419
420            if end - start <= max_nodes_per_page || shift == 0 {
421                break (end, shift);
422            }
423            shift = shift.saturating_sub(1);
424        };
425
426        let page_nodes = nodes[start..end].to_vec();
427        let bbox = compute_bbox(&page_nodes);
428
429        // Edges where src is in this page.
430        let mut page_edges = vec![];
431        for &(src_id, dst_id, weight, flags) in edges {
432            if let Some(src_idx) = page_nodes.iter().position(|n| n.id == src_id) {
433                page_edges.push(PageLocalEdge {
434                    src_node_idx: src_idx as u16,
435                    _pad1: [0; 6],
436                    dst_node_id: dst_id,
437                    weight,
438                    flags,
439                });
440            }
441        }
442
443        // Properties for nodes in this page.
444        let mut page_props = HashMap::new();
445        for node in &page_nodes {
446            if let Some(props) = properties.get(&node.id) {
447                page_props.insert(node.id, props.clone());
448            }
449        }
450
451        let prefix = if final_shift == 0 || final_shift >= 64 {
452            0u64
453        } else {
454            (base_morton >> final_shift) << final_shift
455        };
456
457        pages.push(SpatialPage {
458            header: SpatialPageHeader {
459                magic: SPATIAL_PAGE_MAGIC,
460                version: SPATIAL_PAGE_VERSION,
461                morton_prefix: prefix,
462                morton_shift: final_shift,
463                node_count: page_nodes.len() as u16,
464                _pad1: [0; 5],
465                min_x: bbox.min_x,
466                max_x: bbox.max_x,
467                min_y: bbox.min_y,
468                max_y: bbox.max_y,
469                min_z: bbox.min_z,
470                max_z: bbox.max_z,
471                _pad2: [0; 8],
472                property_block_offset: 0,
473                property_block_len: 0,
474                edge_cluster_offset: 0,
475                edge_cluster_len: page_edges.len() as u32,
476                _reserved: [0; 16],
477            },
478            nodes: page_nodes,
479            properties: page_props,
480            edges: page_edges,
481        });
482
483        start = end;
484    }
485
486    pages
487}
488
489/// Compute bounding box for a slice of nodes.
490fn compute_bbox(nodes: &[NodeRec]) -> BoundingBox {
491    let mut bbox = BoundingBox::from_node(&nodes[0]);
492    for node in &nodes[1..] {
493        let nbb = BoundingBox::from_node(node);
494        bbox.expand(&nbb);
495    }
496    bbox
497}
498
499// ─────────────────────────────────────────────────────────────────────────────
500// Property encoding (compact binary, no JSON, no serde)
501// ─────────────────────────────────────────────────────────────────────────────
502
503/// Count how many dimensions have positive overlap between two bounding boxes.
504pub fn overlaps(a: &BoundingBox, b: &BoundingBox) -> usize {
505    (if a.max_x > b.min_x && a.min_x < b.max_x {
506        1
507    } else {
508        0
509    } + if a.max_y > b.min_y && a.min_y < b.max_y {
510        1
511    } else {
512        0
513    } + if a.max_z > b.min_z && a.min_z < b.max_z {
514        1
515    } else {
516        0
517    })
518}
519
520fn encode_property_entry(props: &HashMap<String, String>) -> Result<Vec<u8>> {
521    let count = props.len();
522    let mut size = 4; // count: u32
523    for (k, v) in props {
524        if k.len() > u16::MAX as usize || v.len() > u16::MAX as usize {
525            bail!("property key/value exceeds 64KB");
526        }
527        size += 2 + k.len() + 2 + v.len();
528    }
529    let mut buf = Vec::with_capacity(size);
530    buf.extend_from_slice(&(count as u32).to_le_bytes());
531    for (k, v) in props {
532        buf.extend_from_slice(&(k.len() as u16).to_le_bytes());
533        buf.extend_from_slice(k.as_bytes());
534        buf.extend_from_slice(&(v.len() as u16).to_le_bytes());
535        buf.extend_from_slice(v.as_bytes());
536    }
537    Ok(buf)
538}
539
540fn decode_property_entry(data: &[u8]) -> Result<HashMap<String, String>> {
541    if data.len() < 4 {
542        bail!("property entry too short");
543    }
544    let count = u32::from_le_bytes([data[0], data[1], data[2], data[3]]) as usize;
545    let mut props = HashMap::with_capacity(count);
546    let mut cursor = 4usize;
547
548    for _ in 0..count {
549        if cursor + 2 > data.len() {
550            bail!("truncated property key length");
551        }
552        let klen = u16::from_le_bytes([data[cursor], data[cursor + 1]]) as usize;
553        cursor += 2;
554        if cursor + klen > data.len() {
555            bail!("truncated property key");
556        }
557        let key = String::from_utf8(data[cursor..cursor + klen].to_vec())?;
558        cursor += klen;
559
560        if cursor + 2 > data.len() {
561            bail!("truncated property value length");
562        }
563        let vlen = u16::from_le_bytes([data[cursor], data[cursor + 1]]) as usize;
564        cursor += 2;
565        if cursor + vlen > data.len() {
566            bail!("truncated property value");
567        }
568        let val = String::from_utf8(data[cursor..cursor + vlen].to_vec())?;
569        cursor += vlen;
570
571        props.insert(key, val);
572    }
573
574    Ok(props)
575}
576
577// ─────────────────────────────────────────────────────────────────────────────
578// Helpers
579// ─────────────────────────────────────────────────────────────────────────────
580
581#[inline]
582fn align_up(n: usize, align: usize) -> usize {
583    (n + align - 1) & !(align - 1)
584}
585
586fn pad_to_alignment(buf: &mut Vec<u8>, align: usize) {
587    let aligned = align_up(buf.len(), align);
588    buf.resize(aligned, 0);
589}
590
591// ─────────────────────────────────────────────────────────────────────────────
592// Tests
593// ─────────────────────────────────────────────────────────────────────────────
594
595#[cfg(test)]
596mod tests {
597    use super::*;
598
599    fn make_node(id: u64, morton: u64, x: f32, y: f32, z: f32) -> NodeRec {
600        NodeRec {
601            id,
602            morton_code: morton,
603            x,
604            y,
605            z,
606            edge_off: 0,
607            edge_len: 0,
608            flags: 0,
609            begin_ts: 1,
610            end_ts: 0,
611            tx_id: 1,
612            visibility: 1,
613            _padding: [0; 7],
614        }
615    }
616
617    #[test]
618    fn test_pack_unpack_empty() {
619        let page = SpatialPage::new(0, 64);
620        let packed = page.pack().unwrap();
621        let unpacked = SpatialPage::unpack(&packed).unwrap();
622        assert_eq!(unpacked.header.magic, SPATIAL_PAGE_MAGIC);
623        assert_eq!(unpacked.nodes.len(), 0);
624        assert_eq!(unpacked.edges.len(), 0);
625    }
626
627    #[test]
628    fn test_pack_unpack_with_nodes() {
629        let mut page = SpatialPage::new(0, 64);
630        page.nodes.push(make_node(1, 0b0000, 0.0, 0.0, 0.0));
631        page.nodes.push(make_node(2, 0b0001, 1.0, 1.0, 1.0));
632
633        let mut props = HashMap::new();
634        props.insert(1u64, {
635            let mut p = HashMap::new();
636            p.insert("kind".to_string(), "sensor".to_string());
637            p
638        });
639
640        page.properties = props;
641
642        page.edges.push(PageLocalEdge {
643            src_node_idx: 0,
644            _pad1: [0; 6],
645            dst_node_id: 2,
646            weight: 1.5,
647            flags: 0,
648        });
649
650        let packed = page.pack().unwrap();
651        let unpacked = SpatialPage::unpack(&packed).unwrap();
652
653        assert_eq!(unpacked.nodes.len(), 2);
654        assert_eq!(unpacked.nodes[0].id, 1);
655        assert_eq!(unpacked.nodes[1].id, 2);
656        assert_eq!(
657            unpacked.properties.get(&1).unwrap().get("kind").unwrap(),
658            "sensor"
659        );
660        assert_eq!(unpacked.edges.len(), 1);
661        assert_eq!(unpacked.edges[0].dst_node_id, 2);
662        assert_eq!(unpacked.edges[0].weight, 1.5);
663    }
664
665    #[test]
666    fn test_build_spatial_pages_grouping() {
667        let nodes = vec![
668            make_node(1, 0b0000_0000, 0.0, 0.0, 0.0),
669            make_node(2, 0b0000_0001, 1.0, 0.0, 0.0),
670            make_node(3, 0b1111_0000, 10.0, 10.0, 10.0),
671            make_node(4, 0b1111_0001, 11.0, 10.0, 10.0),
672        ];
673
674        let pages = build_spatial_pages(nodes, &HashMap::new(), &[], 2);
675        assert_eq!(pages.len(), 2, "Should split into 2 pages by morton prefix");
676        assert_eq!(pages[0].nodes.len(), 2);
677        assert_eq!(pages[1].nodes.len(), 2);
678    }
679
680    #[test]
681    fn test_bounding_box_intersects() {
682        let a = BoundingBox::new(0.0, 10.0, 0.0, 10.0, 0.0, 10.0);
683        let b = BoundingBox::new(5.0, 15.0, 5.0, 15.0, 5.0, 15.0);
684        assert!(a.intersects(&b));
685
686        let c = BoundingBox::new(20.0, 30.0, 0.0, 10.0, 0.0, 10.0);
687        assert!(!a.intersects(&c));
688    }
689
690    #[test]
691    fn test_binary_search_in_page() {
692        let mut page = SpatialPage::new(0, 64);
693        page.nodes.push(make_node(10, 0b0010, 0.0, 0.0, 0.0));
694        page.nodes.push(make_node(20, 0b0100, 1.0, 1.0, 1.0));
695        page.nodes.push(make_node(30, 0b1000, 2.0, 2.0, 2.0));
696
697        assert_eq!(page.find_node_by_id(20), Some(1));
698        assert_eq!(page.find_node_by_id(99), None);
699    }
700}