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;
7use super::store::AsyncStore;
8use super::AsyncProlly;
9use super::{child_cid_at, Cid, Error, Tree};
10#[cfg(test)]
11use super::{Prolly, Store};
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
205#[cfg(test)]
206#[expect(
207    dead_code,
208    reason = "retained only as a correctness oracle for async diagnostics"
209)]
210pub(crate) fn collect_tree_debug_view<S: Store>(
211    prolly: &Prolly<S>,
212    tree: &Tree,
213) -> Result<TreeDebugView, Error> {
214    let Some(root_cid) = &tree.root else {
215        return Ok(TreeDebugView::default());
216    };
217
218    let mut grouped = BTreeMap::new();
219    let mut seen = HashSet::new();
220    let mut frontier = vec![root_cid.clone()];
221
222    while !frontier.is_empty() {
223        let nodes = prolly.load_many_ordered_with_parallelism(&frontier, 1)?;
224        let mut next_frontier = Vec::new();
225
226        for (cid, node) in frontier.iter().cloned().zip(nodes) {
227            if !seen.insert(cid.clone()) {
228                continue;
229            }
230            if node.keys.len() != node.vals.len() {
231                return Err(Error::InvalidNode);
232            }
233
234            grouped
235                .entry(node.level)
236                .or_insert_with(Vec::new)
237                .push(TreeDebugNode::from_node(cid, &node));
238
239            if !node.leaf {
240                next_frontier.reserve(node.vals.len());
241                for idx in 0..node.len() {
242                    next_frontier.push(child_cid_at(&node, idx)?);
243                }
244            }
245        }
246
247        frontier = next_frontier;
248    }
249
250    Ok(view_from_grouped(grouped))
251}
252
253#[cfg(test)]
254#[expect(
255    dead_code,
256    reason = "retained only as a correctness oracle for async diagnostics"
257)]
258pub(crate) fn compare_tree_debug_views<S: Store>(
259    prolly: &Prolly<S>,
260    left: &Tree,
261    right: &Tree,
262) -> Result<TreeDebugComparison, Error> {
263    let left = collect_tree_debug_view(prolly, left)?;
264    let right = collect_tree_debug_view(prolly, right)?;
265    Ok(compare_views(left, right))
266}
267pub(crate) async fn collect_tree_debug_view_async<S>(
268    prolly: &AsyncProlly<S>,
269    tree: &Tree,
270) -> Result<TreeDebugView, Error>
271where
272    S: AsyncStore,
273    S::Error: Send + Sync,
274{
275    let Some(root_cid) = &tree.root else {
276        return Ok(TreeDebugView::default());
277    };
278
279    let mut grouped = BTreeMap::new();
280    let mut seen = HashSet::new();
281    let mut frontier = vec![root_cid.clone()];
282
283    while !frontier.is_empty() {
284        let nodes = prolly
285            .load_child_frontier_ordered_for_format(&frontier, &tree.config.format)
286            .await?;
287        let mut next_frontier = Vec::new();
288
289        for (cid, node) in frontier.iter().cloned().zip(nodes) {
290            if !seen.insert(cid.clone()) {
291                continue;
292            }
293            if node.keys.len() != node.vals.len() {
294                return Err(Error::InvalidNode);
295            }
296
297            grouped
298                .entry(node.level)
299                .or_insert_with(Vec::new)
300                .push(TreeDebugNode::from_node(cid, &node));
301
302            if !node.leaf {
303                next_frontier.reserve(node.vals.len());
304                for idx in 0..node.len() {
305                    next_frontier.push(child_cid_at(&node, idx)?);
306                }
307            }
308        }
309
310        frontier = next_frontier;
311    }
312
313    Ok(view_from_grouped(grouped))
314}
315pub(crate) async fn compare_tree_debug_views_async<S>(
316    prolly: &AsyncProlly<S>,
317    left: &Tree,
318    right: &Tree,
319) -> Result<TreeDebugComparison, Error>
320where
321    S: AsyncStore,
322    S::Error: Send + Sync,
323{
324    let left = collect_tree_debug_view_async(prolly, left).await?;
325    let right = collect_tree_debug_view_async(prolly, right).await?;
326    Ok(compare_views(left, right))
327}
328
329fn view_from_grouped(grouped: BTreeMap<u8, Vec<TreeDebugNode>>) -> TreeDebugView {
330    let mut levels: Vec<_> = grouped
331        .into_iter()
332        .map(|(level, nodes)| TreeDebugLevel { level, nodes })
333        .collect();
334    levels.reverse();
335    TreeDebugView { levels }
336}
337
338fn compare_views(left: TreeDebugView, right: TreeDebugView) -> TreeDebugComparison {
339    let left_nodes = flatten_view(left);
340    let right_nodes = flatten_view(right);
341    let mut grouped: BTreeMap<u8, TreeDebugComparisonLevel> = BTreeMap::new();
342    let mut comparison = TreeDebugComparison::default();
343
344    let mut left_cids: Vec<_> = left_nodes.keys().cloned().collect();
345    left_cids.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
346
347    for cid in left_cids {
348        let node = left_nodes
349            .get(&cid)
350            .expect("CID collected from map keys must exist")
351            .clone();
352        if right_nodes.contains_key(&cid) {
353            comparison.shared_nodes += 1;
354            comparison.shared_bytes += node.encoded_bytes;
355            push_compared_node(&mut grouped, TreeDebugNodeStatus::Shared, node);
356        } else {
357            comparison.left_only_nodes += 1;
358            comparison.left_only_bytes += node.encoded_bytes;
359            push_compared_node(&mut grouped, TreeDebugNodeStatus::LeftOnly, node);
360        }
361    }
362
363    let mut right_cids: Vec<_> = right_nodes.keys().cloned().collect();
364    right_cids.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
365
366    for cid in right_cids {
367        if left_nodes.contains_key(&cid) {
368            continue;
369        }
370        let node = right_nodes
371            .get(&cid)
372            .expect("CID collected from map keys must exist")
373            .clone();
374        comparison.right_only_nodes += 1;
375        comparison.right_only_bytes += node.encoded_bytes;
376        push_compared_node(&mut grouped, TreeDebugNodeStatus::RightOnly, node);
377    }
378
379    comparison.levels = grouped.into_values().collect();
380    comparison.levels.reverse();
381    comparison
382}
383
384fn flatten_view(view: TreeDebugView) -> HashMap<Cid, TreeDebugNode> {
385    view.levels
386        .into_iter()
387        .flat_map(|level| level.nodes)
388        .map(|node| (node.cid.clone(), node))
389        .collect()
390}
391
392fn push_compared_node(
393    grouped: &mut BTreeMap<u8, TreeDebugComparisonLevel>,
394    status: TreeDebugNodeStatus,
395    node: TreeDebugNode,
396) {
397    let level = grouped
398        .entry(node.level)
399        .or_insert_with(|| TreeDebugComparisonLevel {
400            level: node.level,
401            ..TreeDebugComparisonLevel::default()
402        });
403
404    match status {
405        TreeDebugNodeStatus::Shared => {
406            level.shared_nodes += 1;
407            level.shared_bytes += node.encoded_bytes;
408        }
409        TreeDebugNodeStatus::LeftOnly => {
410            level.left_only_nodes += 1;
411            level.left_only_bytes += node.encoded_bytes;
412        }
413        TreeDebugNodeStatus::RightOnly => {
414            level.right_only_nodes += 1;
415            level.right_only_bytes += node.encoded_bytes;
416        }
417    }
418
419    level.nodes.push(TreeDebugComparedNode { status, node });
420    level.nodes.sort_by(|a, b| {
421        status_rank(&a.status)
422            .cmp(&status_rank(&b.status))
423            .then_with(|| a.node.cid.as_bytes().cmp(b.node.cid.as_bytes()))
424    });
425}
426
427fn status_rank(status: &TreeDebugNodeStatus) -> u8 {
428    match status {
429        TreeDebugNodeStatus::Shared => 0,
430        TreeDebugNodeStatus::LeftOnly => 1,
431        TreeDebugNodeStatus::RightOnly => 2,
432    }
433}
434
435fn format_node_line(prefix: &str, status: Option<&str>, node: &TreeDebugNode) -> String {
436    let kind = if node.leaf { "L" } else { "I" };
437    let status = status.map(|value| format!("{value} ")).unwrap_or_default();
438    format!(
439        "{prefix}{status}{kind} {} entries={}/{} fill={:.1}% bytes={} keys={}..{}",
440        short_cid(&node.cid),
441        node.entry_count,
442        node.max_entries,
443        node.fill_factor * 100.0,
444        node.encoded_bytes,
445        format_optional_key(node.first_key.as_deref()),
446        format_optional_key(node.last_key.as_deref())
447    )
448}
449
450fn format_optional_key(key: Option<&[u8]>) -> String {
451    key.map(debug_key).unwrap_or_else(|| "-".to_string())
452}
453
454fn short_cid(cid: &Cid) -> String {
455    const HEX: &[u8; 16] = b"0123456789abcdef";
456    let mut out = String::with_capacity(12);
457    for byte in cid.as_bytes().iter().take(6) {
458        out.push(HEX[(byte >> 4) as usize] as char);
459        out.push(HEX[(byte & 0x0f) as usize] as char);
460    }
461    out
462}