Skip to main content

prolly/prolly/
debug.rs

1//! Debug views for inspecting Prolly tree shape and structural sharing.
2
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, HashMap, HashSet};
5
6use super::key::debug_key;
7#[cfg(feature = "async-store")]
8use super::store::AsyncStore;
9#[cfg(feature = "async-store")]
10use super::AsyncProlly;
11use super::{child_cid_at, Cid, Error, Prolly, Store, Tree};
12
13/// Inspectable node metadata for debug tooling.
14#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
15pub struct TreeDebugNode {
16    /// Content identifier for this node.
17    pub cid: Cid,
18    /// Whether this node stores leaf values instead of child CIDs.
19    pub leaf: bool,
20    /// Tree level where `0` is the leaf level.
21    pub level: u8,
22    /// Number of entries in the node.
23    pub entry_count: usize,
24    /// Maximum entries configured for this node.
25    pub max_entries: usize,
26    /// `entry_count / max_entries`, or `0.0` when `max_entries` is zero.
27    pub fill_factor: f64,
28    /// Compact encoded byte size of the node.
29    pub encoded_bytes: usize,
30    /// First separator or leaf key in this node.
31    pub first_key: Option<Vec<u8>>,
32    /// Last separator or leaf key in this node.
33    pub last_key: Option<Vec<u8>>,
34}
35
36/// Debug metadata for all nodes at one tree level.
37#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
38pub struct TreeDebugLevel {
39    /// Tree level where `0` is the leaf level.
40    pub level: u8,
41    /// Nodes at this level in deterministic traversal order.
42    pub nodes: Vec<TreeDebugNode>,
43}
44
45/// Debug view of a tree grouped by level.
46#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
47pub struct TreeDebugView {
48    /// Levels ordered from root to leaves.
49    pub levels: Vec<TreeDebugLevel>,
50}
51
52/// Whether a compared subtree is shared or unique to one side.
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub enum TreeDebugNodeStatus {
55    /// The same CID is reachable from both compared roots.
56    Shared,
57    /// The CID is reachable only from the left tree.
58    LeftOnly,
59    /// The CID is reachable only from the right tree.
60    RightOnly,
61}
62
63/// A compared node annotated with its sharing status.
64#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
65pub struct TreeDebugComparedNode {
66    /// Sharing status for this node.
67    pub status: TreeDebugNodeStatus,
68    /// Node metadata.
69    pub node: TreeDebugNode,
70}
71
72/// Per-level structural sharing summary.
73#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
74pub struct TreeDebugComparisonLevel {
75    /// Tree level where `0` is the leaf level.
76    pub level: u8,
77    /// Nodes reachable from both sides at this level.
78    pub shared_nodes: usize,
79    /// Nodes reachable only from the left side at this level.
80    pub left_only_nodes: usize,
81    /// Nodes reachable only from the right side at this level.
82    pub right_only_nodes: usize,
83    /// Encoded bytes for shared nodes at this level, counted once.
84    pub shared_bytes: usize,
85    /// Encoded bytes for left-only nodes at this level.
86    pub left_only_bytes: usize,
87    /// Encoded bytes for right-only nodes at this level.
88    pub right_only_bytes: usize,
89    /// Compared nodes at this level.
90    pub nodes: Vec<TreeDebugComparedNode>,
91}
92
93/// Structural sharing comparison between two tree roots.
94#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
95pub struct TreeDebugComparison {
96    /// Unique CIDs reachable from both roots.
97    pub shared_nodes: usize,
98    /// Unique CIDs reachable only from the left root.
99    pub left_only_nodes: usize,
100    /// Unique CIDs reachable only from the right root.
101    pub right_only_nodes: usize,
102    /// Encoded bytes for shared nodes, counted once.
103    pub shared_bytes: usize,
104    /// Encoded bytes for left-only nodes.
105    pub left_only_bytes: usize,
106    /// Encoded bytes for right-only nodes.
107    pub right_only_bytes: usize,
108    /// Per-level summaries ordered from root to leaves.
109    pub levels: Vec<TreeDebugComparisonLevel>,
110}
111
112impl TreeDebugNode {
113    fn from_node(cid: Cid, node: &super::Node) -> Self {
114        let entry_count = node.len();
115        let max_entries = node.max_chunk_size();
116        let fill_factor = if max_entries == 0 {
117            0.0
118        } else {
119            entry_count as f64 / max_entries as f64
120        };
121
122        Self {
123            cid,
124            leaf: node.leaf,
125            level: node.level,
126            entry_count,
127            max_entries,
128            fill_factor,
129            encoded_bytes: node.encoded_len(),
130            first_key: node.keys.first().cloned(),
131            last_key: node.keys.last().cloned(),
132        }
133    }
134}
135
136impl TreeDebugView {
137    /// Return a deterministic, human-readable tree-level rendering.
138    pub fn to_text(&self) -> String {
139        if self.levels.is_empty() {
140            return "empty tree".to_string();
141        }
142
143        let mut lines = Vec::new();
144        for (idx, level) in self.levels.iter().enumerate() {
145            let label = if idx == 0 { " (root)" } else { "" };
146            lines.push(format!(
147                "level {}{}: nodes={}",
148                level.level,
149                label,
150                level.nodes.len()
151            ));
152            for node in &level.nodes {
153                lines.push(format_node_line("  ", None, node));
154            }
155        }
156        lines.join("\n")
157    }
158}
159
160impl TreeDebugComparison {
161    /// Return a deterministic, human-readable structural sharing rendering.
162    pub fn to_text(&self) -> String {
163        if self.shared_nodes == 0 && self.left_only_nodes == 0 && self.right_only_nodes == 0 {
164            return "empty comparison".to_string();
165        }
166
167        let mut lines = vec![format!(
168            "shared={} ({} bytes), left_only={} ({} bytes), right_only={} ({} bytes)",
169            self.shared_nodes,
170            self.shared_bytes,
171            self.left_only_nodes,
172            self.left_only_bytes,
173            self.right_only_nodes,
174            self.right_only_bytes
175        )];
176
177        for level in &self.levels {
178            lines.push(format!(
179                "level {}: shared={} left_only={} right_only={}",
180                level.level, level.shared_nodes, level.left_only_nodes, level.right_only_nodes
181            ));
182            for compared in &level.nodes {
183                lines.push(format_node_line(
184                    "  ",
185                    Some(compared.status.as_str()),
186                    &compared.node,
187                ));
188            }
189        }
190
191        lines.join("\n")
192    }
193}
194
195impl TreeDebugNodeStatus {
196    fn as_str(&self) -> &'static str {
197        match self {
198            Self::Shared => "shared",
199            Self::LeftOnly => "left",
200            Self::RightOnly => "right",
201        }
202    }
203}
204
205pub(crate) fn collect_tree_debug_view<S: Store>(
206    prolly: &Prolly<S>,
207    tree: &Tree,
208) -> Result<TreeDebugView, Error> {
209    let Some(root_cid) = &tree.root else {
210        return Ok(TreeDebugView::default());
211    };
212
213    let mut grouped = BTreeMap::new();
214    let mut seen = HashSet::new();
215    let mut frontier = vec![root_cid.clone()];
216
217    while !frontier.is_empty() {
218        let nodes = prolly.load_many_ordered_with_parallelism(&frontier, 1)?;
219        let mut next_frontier = Vec::new();
220
221        for (cid, node) in frontier.iter().cloned().zip(nodes) {
222            if !seen.insert(cid.clone()) {
223                continue;
224            }
225            if node.keys.len() != node.vals.len() {
226                return Err(Error::InvalidNode);
227            }
228
229            grouped
230                .entry(node.level)
231                .or_insert_with(Vec::new)
232                .push(TreeDebugNode::from_node(cid, &node));
233
234            if !node.leaf {
235                next_frontier.reserve(node.vals.len());
236                for idx in 0..node.len() {
237                    next_frontier.push(child_cid_at(&node, idx)?);
238                }
239            }
240        }
241
242        frontier = next_frontier;
243    }
244
245    Ok(view_from_grouped(grouped))
246}
247
248pub(crate) fn compare_tree_debug_views<S: Store>(
249    prolly: &Prolly<S>,
250    left: &Tree,
251    right: &Tree,
252) -> Result<TreeDebugComparison, Error> {
253    let left = collect_tree_debug_view(prolly, left)?;
254    let right = collect_tree_debug_view(prolly, right)?;
255    Ok(compare_views(left, right))
256}
257
258#[cfg(feature = "async-store")]
259pub(crate) async fn collect_tree_debug_view_async<S>(
260    prolly: &AsyncProlly<S>,
261    tree: &Tree,
262) -> Result<TreeDebugView, Error>
263where
264    S: AsyncStore,
265    S::Error: Send + Sync,
266{
267    let Some(root_cid) = &tree.root else {
268        return Ok(TreeDebugView::default());
269    };
270
271    let mut grouped = BTreeMap::new();
272    let mut seen = HashSet::new();
273    let mut frontier = vec![root_cid.clone()];
274
275    while !frontier.is_empty() {
276        let nodes = prolly.load_child_frontier_ordered(&frontier).await?;
277        let mut next_frontier = Vec::new();
278
279        for (cid, node) in frontier.iter().cloned().zip(nodes) {
280            if !seen.insert(cid.clone()) {
281                continue;
282            }
283            if node.keys.len() != node.vals.len() {
284                return Err(Error::InvalidNode);
285            }
286
287            grouped
288                .entry(node.level)
289                .or_insert_with(Vec::new)
290                .push(TreeDebugNode::from_node(cid, &node));
291
292            if !node.leaf {
293                next_frontier.reserve(node.vals.len());
294                for idx in 0..node.len() {
295                    next_frontier.push(child_cid_at(&node, idx)?);
296                }
297            }
298        }
299
300        frontier = next_frontier;
301    }
302
303    Ok(view_from_grouped(grouped))
304}
305
306#[cfg(feature = "async-store")]
307pub(crate) async fn compare_tree_debug_views_async<S>(
308    prolly: &AsyncProlly<S>,
309    left: &Tree,
310    right: &Tree,
311) -> Result<TreeDebugComparison, Error>
312where
313    S: AsyncStore,
314    S::Error: Send + Sync,
315{
316    let left = collect_tree_debug_view_async(prolly, left).await?;
317    let right = collect_tree_debug_view_async(prolly, right).await?;
318    Ok(compare_views(left, right))
319}
320
321fn view_from_grouped(grouped: BTreeMap<u8, Vec<TreeDebugNode>>) -> TreeDebugView {
322    let mut levels: Vec<_> = grouped
323        .into_iter()
324        .map(|(level, nodes)| TreeDebugLevel { level, nodes })
325        .collect();
326    levels.reverse();
327    TreeDebugView { levels }
328}
329
330fn compare_views(left: TreeDebugView, right: TreeDebugView) -> TreeDebugComparison {
331    let left_nodes = flatten_view(left);
332    let right_nodes = flatten_view(right);
333    let mut grouped: BTreeMap<u8, TreeDebugComparisonLevel> = BTreeMap::new();
334    let mut comparison = TreeDebugComparison::default();
335
336    let mut left_cids: Vec<_> = left_nodes.keys().cloned().collect();
337    left_cids.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
338
339    for cid in left_cids {
340        let node = left_nodes
341            .get(&cid)
342            .expect("CID collected from map keys must exist")
343            .clone();
344        if right_nodes.contains_key(&cid) {
345            comparison.shared_nodes += 1;
346            comparison.shared_bytes += node.encoded_bytes;
347            push_compared_node(&mut grouped, TreeDebugNodeStatus::Shared, node);
348        } else {
349            comparison.left_only_nodes += 1;
350            comparison.left_only_bytes += node.encoded_bytes;
351            push_compared_node(&mut grouped, TreeDebugNodeStatus::LeftOnly, node);
352        }
353    }
354
355    let mut right_cids: Vec<_> = right_nodes.keys().cloned().collect();
356    right_cids.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
357
358    for cid in right_cids {
359        if left_nodes.contains_key(&cid) {
360            continue;
361        }
362        let node = right_nodes
363            .get(&cid)
364            .expect("CID collected from map keys must exist")
365            .clone();
366        comparison.right_only_nodes += 1;
367        comparison.right_only_bytes += node.encoded_bytes;
368        push_compared_node(&mut grouped, TreeDebugNodeStatus::RightOnly, node);
369    }
370
371    comparison.levels = grouped.into_values().collect();
372    comparison.levels.reverse();
373    comparison
374}
375
376fn flatten_view(view: TreeDebugView) -> HashMap<Cid, TreeDebugNode> {
377    view.levels
378        .into_iter()
379        .flat_map(|level| level.nodes)
380        .map(|node| (node.cid.clone(), node))
381        .collect()
382}
383
384fn push_compared_node(
385    grouped: &mut BTreeMap<u8, TreeDebugComparisonLevel>,
386    status: TreeDebugNodeStatus,
387    node: TreeDebugNode,
388) {
389    let level = grouped
390        .entry(node.level)
391        .or_insert_with(|| TreeDebugComparisonLevel {
392            level: node.level,
393            ..TreeDebugComparisonLevel::default()
394        });
395
396    match status {
397        TreeDebugNodeStatus::Shared => {
398            level.shared_nodes += 1;
399            level.shared_bytes += node.encoded_bytes;
400        }
401        TreeDebugNodeStatus::LeftOnly => {
402            level.left_only_nodes += 1;
403            level.left_only_bytes += node.encoded_bytes;
404        }
405        TreeDebugNodeStatus::RightOnly => {
406            level.right_only_nodes += 1;
407            level.right_only_bytes += node.encoded_bytes;
408        }
409    }
410
411    level.nodes.push(TreeDebugComparedNode { status, node });
412    level.nodes.sort_by(|a, b| {
413        status_rank(&a.status)
414            .cmp(&status_rank(&b.status))
415            .then_with(|| a.node.cid.as_bytes().cmp(b.node.cid.as_bytes()))
416    });
417}
418
419fn status_rank(status: &TreeDebugNodeStatus) -> u8 {
420    match status {
421        TreeDebugNodeStatus::Shared => 0,
422        TreeDebugNodeStatus::LeftOnly => 1,
423        TreeDebugNodeStatus::RightOnly => 2,
424    }
425}
426
427fn format_node_line(prefix: &str, status: Option<&str>, node: &TreeDebugNode) -> String {
428    let kind = if node.leaf { "L" } else { "I" };
429    let status = status.map(|value| format!("{value} ")).unwrap_or_default();
430    format!(
431        "{prefix}{status}{kind} {} entries={}/{} fill={:.1}% bytes={} keys={}..{}",
432        short_cid(&node.cid),
433        node.entry_count,
434        node.max_entries,
435        node.fill_factor * 100.0,
436        node.encoded_bytes,
437        format_optional_key(node.first_key.as_deref()),
438        format_optional_key(node.last_key.as_deref())
439    )
440}
441
442fn format_optional_key(key: Option<&[u8]>) -> String {
443    key.map(debug_key).unwrap_or_else(|| "-".to_string())
444}
445
446fn short_cid(cid: &Cid) -> String {
447    const HEX: &[u8; 16] = b"0123456789abcdef";
448    let mut out = String::with_capacity(12);
449    for byte in cid.as_bytes().iter().take(6) {
450        out.push(HEX[(byte >> 4) as usize] as char);
451        out.push(HEX[(byte & 0x0f) as usize] as char);
452    }
453    out
454}