Skip to main content

hawdb_executor/
binding.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Executor-owned row bindings and deterministic memory accounting.
16
17mod graph_slots;
18pub use graph_slots::GraphSlots;
19
20use hawdb_core::{LabelId, Value};
21use hawdb_plan_cypher::SortDirection;
22use hawdb_storage::{NodeRecord, RelRecord};
23use std::cmp::Ordering;
24use std::collections::{BTreeMap, BTreeSet};
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Binding {
28    pub values: BTreeMap<String, Value>,
29    pub nodes: GraphSlots<NodeRecord>,
30    pub relationships: GraphSlots<RelRecord>,
31}
32
33impl Binding {
34    pub fn values(values: BTreeMap<String, Value>) -> Self {
35        Self {
36            values,
37            nodes: GraphSlots::new(),
38            relationships: GraphSlots::new(),
39        }
40    }
41
42    pub fn scalar(name: impl Into<String>, value: Value) -> Self {
43        Self::values(BTreeMap::from([(name.into(), value)]))
44    }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct TopNBinding {
49    pub sort_values: Vec<(Value, SortDirection)>,
50    pub ordinal: u64,
51    pub binding: Binding,
52}
53
54impl TopNBinding {
55    pub fn memory_bytes(&self) -> usize {
56        binding_memory_bytes(&self.binding)
57            .saturating_add(sort_values_memory_bytes(&self.sort_values))
58    }
59}
60
61pub(crate) fn sort_values_memory_bytes(values: &Vec<(Value, SortDirection)>) -> usize {
62    let layout = std::mem::size_of::<Vec<(Value, SortDirection)>>().saturating_add(
63        values
64            .capacity()
65            .saturating_mul(std::mem::size_of::<(Value, SortDirection)>()),
66    );
67    values.iter().fold(layout, |total, (value, _)| {
68        total.saturating_add(value_memory_bytes(value).saturating_sub(std::mem::size_of::<Value>()))
69    })
70}
71
72impl Ord for TopNBinding {
73    fn cmp(&self, other: &Self) -> Ordering {
74        for ((left, direction), (right, other_direction)) in
75            self.sort_values.iter().zip(&other.sort_values)
76        {
77            debug_assert_eq!(direction, other_direction);
78            // openCypher NULL placement; see `SortDirection::compare`.
79            let ordering = direction.compare(left, right);
80            if ordering != Ordering::Equal {
81                return ordering;
82            }
83        }
84        self.ordinal.cmp(&other.ordinal)
85    }
86}
87
88impl PartialOrd for TopNBinding {
89    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
90        Some(self.cmp(other))
91    }
92}
93
94pub fn map_payload_bytes(values: &BTreeMap<String, Value>) -> usize {
95    values.iter().fold(0usize, |total, (name, value)| {
96        total
97            .saturating_add(name.len())
98            .saturating_add(value_payload_bytes(value))
99    })
100}
101
102pub fn map_memory_bytes(values: &BTreeMap<String, Value>) -> usize {
103    map_memory_bytes_with_entries(values.iter().map(|(name, value)| (name.as_str(), value)))
104}
105
106fn map_memory_bytes_with_entries<'a>(values: impl Iterator<Item = (&'a str, &'a Value)>) -> usize {
107    std::mem::size_of::<BTreeMap<String, Value>>().saturating_add(values.fold(
108        0usize,
109        |total, (name, value)| {
110            total
111                .saturating_add(std::mem::size_of::<(String, Value)>() * 3)
112                .saturating_add(name.len())
113                .saturating_add(value_memory_bytes(value))
114        },
115    ))
116}
117
118/// Estimate the selected owned scan input with the same rules as its final
119/// node-only Binding, while all property payloads still belong to storage.
120pub(crate) fn projected_node_binding_memory_bytes(
121    variable: &str,
122    node: &NodeRecord,
123    required_properties: &BTreeSet<String>,
124) -> usize {
125    std::mem::size_of::<Binding>()
126        .saturating_add(variable.len())
127        .saturating_add(std::mem::size_of_val(&node.id))
128        .saturating_add(
129            node.labels
130                .len()
131                .saturating_mul(std::mem::size_of::<LabelId>()),
132        )
133        .saturating_add(map_memory_bytes_with_entries(
134            required_properties.iter().filter_map(|property| {
135                node.properties
136                    .get(property)
137                    .map(|value| (property.as_str(), value))
138            }),
139        ))
140        .saturating_add(graph_slot_memory_bytes(1))
141}
142
143pub fn value_memory_bytes(value: &Value) -> usize {
144    std::mem::size_of::<Value>().saturating_add(match value {
145        Value::Null | Value::Bool(_) | Value::Int(_) | Value::Float(_) | Value::Decimal(_) => 0,
146        Value::String(value) => value.len(),
147        Value::Binary(value) => value.len(),
148        Value::Vector(value) => value.encoded().len(),
149        Value::Uuid(_) => 16,
150        // Temporal values are stored inline in the `Value` slot, except
151        // durations, which are boxed to keep `Value` at 32 bytes.
152        Value::Date(_) | Value::LocalTime(_) | Value::LocalDateTime(_) | Value::DateTime(_) => 0,
153        Value::Duration(_) => std::mem::size_of::<hawdb_core::temporal::Duration>(),
154        Value::List(values) => values
155            .iter()
156            .fold(std::mem::size_of::<Vec<Value>>(), |total, value| {
157                total.saturating_add(value_memory_bytes(value))
158            }),
159        Value::Map(values) => map_memory_bytes(values),
160    })
161}
162
163pub fn binding_payload_bytes(binding: &Binding) -> usize {
164    map_payload_bytes(&binding.values).saturating_add(graph_binding_payload_bytes(binding))
165}
166
167fn graph_binding_payload_bytes(binding: &Binding) -> usize {
168    graph_binding_bytes(binding, map_payload_bytes)
169}
170
171fn graph_binding_bytes(
172    binding: &Binding,
173    property_bytes: fn(&BTreeMap<String, Value>) -> usize,
174) -> usize {
175    graph_binding_parts_bytes(
176        binding
177            .nodes
178            .iter()
179            .map(|(name, node)| (name.as_str(), node)),
180        binding
181            .relationships
182            .iter()
183            .map(|(name, row)| (name.as_str(), row)),
184        property_bytes,
185    )
186}
187
188fn graph_binding_parts_bytes<'a>(
189    nodes: impl Iterator<Item = (&'a str, &'a NodeRecord)>,
190    relationships: impl Iterator<Item = (&'a str, &'a RelRecord)>,
191    property_bytes: fn(&BTreeMap<String, Value>) -> usize,
192) -> usize {
193    nodes
194        .fold(0usize, |total, (name, node)| {
195            total
196                .saturating_add(name.len())
197                .saturating_add(std::mem::size_of_val(&node.id))
198                .saturating_add(
199                    node.labels
200                        .len()
201                        .saturating_mul(std::mem::size_of::<LabelId>()),
202                )
203                .saturating_add(property_bytes(&node.properties))
204        })
205        .saturating_add(relationships.fold(0usize, |total, (name, row)| {
206            total
207                .saturating_add(name.len())
208                .saturating_add(std::mem::size_of_val(&row.id))
209                .saturating_add(std::mem::size_of_val(&row.source))
210                .saturating_add(std::mem::size_of_val(&row.target))
211                .saturating_add(std::mem::size_of_val(&row.rel_type))
212                .saturating_add(property_bytes(&row.properties))
213        }))
214}
215
216pub(crate) fn binding_payload_bytes_with_parts<'a>(
217    values: impl Iterator<Item = (&'a str, &'a Value)>,
218    nodes: impl Iterator<Item = (&'a str, &'a NodeRecord)>,
219    relationships: impl Iterator<Item = (&'a str, &'a RelRecord)>,
220) -> usize {
221    values
222        .fold(0usize, |total, (name, value)| {
223            total
224                .saturating_add(name.len())
225                .saturating_add(value_payload_bytes(value))
226        })
227        .saturating_add(graph_binding_parts_bytes(
228            nodes,
229            relationships,
230            map_payload_bytes,
231        ))
232}
233
234pub fn binding_memory_bytes(binding: &Binding) -> usize {
235    binding_memory_bytes_with_values(
236        binding,
237        binding
238            .values
239            .iter()
240            .map(|(name, value)| (name.as_str(), value)),
241    )
242}
243
244/// Estimate an overwritten column from the final borrowed map. Resident
245/// container storage differs from transport payload and must be removed in full.
246pub(crate) fn binding_memory_bytes_replacing_value(
247    binding: &Binding,
248    name: &str,
249    value: &Value,
250) -> usize {
251    binding_memory_bytes_with_values(
252        binding,
253        binding
254            .values
255            .iter()
256            .filter(|(key, _)| key.as_str() != name)
257            .map(|(key, value)| (key.as_str(), value))
258            .chain(std::iter::once((name, value))),
259    )
260}
261
262/// Account a projection's borrowed values with the same retained graph and
263/// entry rules as its final owned Binding, before copying any of those values.
264pub(crate) fn binding_memory_bytes_with_values<'a>(
265    binding: &'a Binding,
266    values: impl Iterator<Item = (&'a str, &'a Value)>,
267) -> usize {
268    binding_memory_bytes_with_parts(
269        values,
270        binding
271            .nodes
272            .iter()
273            .map(|(name, node)| (name.as_str(), node)),
274        binding
275            .relationships
276            .iter()
277            .map(|(name, row)| (name.as_str(), row)),
278    )
279}
280
281pub(crate) fn binding_memory_bytes_with_parts<'a>(
282    values: impl Iterator<Item = (&'a str, &'a Value)>,
283    nodes: impl Iterator<Item = (&'a str, &'a NodeRecord)>,
284    relationships: impl Iterator<Item = (&'a str, &'a RelRecord)>,
285) -> usize {
286    let nodes_count = std::cell::Cell::new(0usize);
287    let relationships_count = std::cell::Cell::new(0usize);
288    let graph_bytes = graph_binding_parts_bytes(
289        nodes.inspect(|_| nodes_count.set(nodes_count.get().saturating_add(1))),
290        relationships
291            .inspect(|_| relationships_count.set(relationships_count.get().saturating_add(1))),
292        map_memory_bytes,
293    );
294    std::mem::size_of::<Binding>()
295        .saturating_add(graph_bytes)
296        // Charge every row as if its graph records were uniquely retained.
297        // Sharing reduces allocator traffic but never weakens admission. Include
298        // slot vectors, Arc headers, String/label layouts and record headers.
299        .saturating_add(graph_slot_memory_bytes(nodes_count.get()))
300        .saturating_add(graph_slot_memory_bytes(relationships_count.get()))
301        .saturating_add(values.fold(0usize, |total, (name, value)| {
302            total
303                .saturating_add(name.len())
304                .saturating_add(match value {
305                    Value::List(_) | Value::Map(_) => {
306                        value_memory_bytes(value).saturating_sub(std::mem::size_of::<Value>())
307                    }
308                    _ => value_payload_bytes(value),
309                })
310                .saturating_add(std::mem::size_of::<usize>() * 6)
311        }))
312}
313
314pub(crate) fn graph_slot_memory_bytes(entries: usize) -> usize {
315    if entries == 0 {
316        return 0;
317    }
318    // A Vec grown one slot at a time has capacity at most max(4, 2*len).
319    std::mem::size_of::<Vec<usize>>()
320        .saturating_add(2 * std::mem::size_of::<usize>())
321        .saturating_add(
322            entries
323                .saturating_mul(2)
324                .max(4)
325                .saturating_mul(2 * std::mem::size_of::<usize>()),
326        )
327        .saturating_add(entries.saturating_mul(10 * std::mem::size_of::<usize>()))
328}
329
330pub fn node_memory_bytes(node: &NodeRecord) -> usize {
331    std::mem::size_of::<NodeRecord>()
332        .saturating_add(
333            node.labels
334                .len()
335                .saturating_mul(std::mem::size_of::<LabelId>() * 3),
336        )
337        .saturating_add(map_memory_bytes(&node.properties))
338}
339
340pub fn relationship_memory_bytes(relationship: &RelRecord) -> usize {
341    std::mem::size_of::<RelRecord>().saturating_add(map_memory_bytes(&relationship.properties))
342}
343
344pub fn value_payload_bytes(value: &Value) -> usize {
345    // Match the owned value directly: the same table as
346    // `value_ref_payload_bytes`, without building a `ValueRef` per value (#1615).
347    match value {
348        Value::Null => 0,
349        Value::Bool(_) => std::mem::size_of::<bool>(),
350        Value::Int(_) => std::mem::size_of::<i64>(),
351        Value::Float(_) => std::mem::size_of::<f64>(),
352        Value::Decimal(_) => 17,
353        Value::String(value) => value.len(),
354        Value::Binary(value) => value.len(),
355        Value::Vector(value) => value.encoded().len(),
356        Value::Uuid(_) => 16,
357        Value::Date(_) => 4,
358        Value::LocalTime(_) => 8,
359        Value::LocalDateTime(_) => 12,
360        Value::DateTime(_) => 16,
361        Value::Duration(_) => 28,
362        Value::List(values) => values.iter().fold(0usize, |total, value| {
363            total.saturating_add(value_payload_bytes(value))
364        }),
365        Value::Map(values) => map_payload_bytes(values),
366    }
367}
368
369/// A row's resident bytes (`binding_memory_bytes`, the ledger charge) and
370/// transport bytes (`binding_payload_bytes`, the observer statistic).
371#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372pub(crate) struct BindingSizes {
373    pub(crate) memory: usize,
374    pub(crate) payload: usize,
375}
376
377/// Both sizes of one row from a single walk of its values and graph records
378/// (#1615). Every term and saturating sum matches the separate walks, so the
379/// results are identical to `binding_memory_bytes` and `binding_payload_bytes`.
380pub(crate) fn binding_sizes(binding: &Binding) -> BindingSizes {
381    let mut graph_memory = 0usize;
382    let mut graph_payload = 0usize;
383    let mut nodes = 0usize;
384    for (name, node) in binding.nodes.iter() {
385        nodes = nodes.saturating_add(1);
386        let fixed = |total: usize| {
387            total
388                .saturating_add(name.len())
389                .saturating_add(std::mem::size_of_val(&node.id))
390                .saturating_add(
391                    node.labels
392                        .len()
393                        .saturating_mul(std::mem::size_of::<LabelId>()),
394                )
395        };
396        let (memory, payload) = map_sizes(&node.properties);
397        graph_memory = fixed(graph_memory).saturating_add(memory);
398        graph_payload = fixed(graph_payload).saturating_add(payload);
399    }
400    let mut relationships = 0usize;
401    for (name, row) in binding.relationships.iter() {
402        relationships = relationships.saturating_add(1);
403        let fixed = |total: usize| {
404            total
405                .saturating_add(name.len())
406                .saturating_add(std::mem::size_of_val(&row.id))
407                .saturating_add(std::mem::size_of_val(&row.source))
408                .saturating_add(std::mem::size_of_val(&row.target))
409                .saturating_add(std::mem::size_of_val(&row.rel_type))
410        };
411        let (memory, payload) = map_sizes(&row.properties);
412        graph_memory = fixed(graph_memory).saturating_add(memory);
413        graph_payload = fixed(graph_payload).saturating_add(payload);
414    }
415    let mut values_memory = 0usize;
416    let mut values_payload = 0usize;
417    for (name, value) in &binding.values {
418        let (resident, payload) = match value {
419            Value::List(_) | Value::Map(_) => {
420                let (memory, payload) = value_sizes(value);
421                (memory.saturating_sub(std::mem::size_of::<Value>()), payload)
422            }
423            _ => {
424                let payload = value_payload_bytes(value);
425                (payload, payload)
426            }
427        };
428        values_memory = values_memory
429            .saturating_add(name.len())
430            .saturating_add(resident)
431            .saturating_add(std::mem::size_of::<usize>() * 6);
432        values_payload = values_payload
433            .saturating_add(name.len())
434            .saturating_add(payload);
435    }
436    BindingSizes {
437        memory: std::mem::size_of::<Binding>()
438            .saturating_add(graph_memory)
439            .saturating_add(graph_slot_memory_bytes(nodes))
440            .saturating_add(graph_slot_memory_bytes(relationships))
441            .saturating_add(values_memory),
442        payload: values_payload.saturating_add(graph_payload),
443    }
444}
445
446/// `(value_memory_bytes, value_payload_bytes)` from one walk.
447fn value_sizes(value: &Value) -> (usize, usize) {
448    match value {
449        Value::List(values) => {
450            let (memory, payload) = values.iter().fold(
451                (std::mem::size_of::<Vec<Value>>(), 0usize),
452                |(memory, payload), value| {
453                    let (value_memory, value_payload) = value_sizes(value);
454                    (
455                        memory.saturating_add(value_memory),
456                        payload.saturating_add(value_payload),
457                    )
458                },
459            );
460            (std::mem::size_of::<Value>().saturating_add(memory), payload)
461        }
462        Value::Map(values) => {
463            let (memory, payload) = map_sizes(values);
464            (std::mem::size_of::<Value>().saturating_add(memory), payload)
465        }
466        // Scalars do not recurse; each walk is a constant-time match.
467        scalar => (value_memory_bytes(scalar), value_payload_bytes(scalar)),
468    }
469}
470
471/// `(map_memory_bytes, map_payload_bytes)` from one walk.
472fn map_sizes(values: &BTreeMap<String, Value>) -> (usize, usize) {
473    values.iter().fold(
474        (std::mem::size_of::<BTreeMap<String, Value>>(), 0usize),
475        |(memory, payload), (name, value)| {
476            let (value_memory, value_payload) = value_sizes(value);
477            (
478                memory
479                    .saturating_add(std::mem::size_of::<(String, Value)>() * 3)
480                    .saturating_add(name.len())
481                    .saturating_add(value_memory),
482                payload
483                    .saturating_add(name.len())
484                    .saturating_add(value_payload),
485            )
486        },
487    )
488}
489
490pub fn value_ref_payload_bytes(value: hawdb_core::ValueRef<'_>) -> usize {
491    use hawdb_core::ValueRef;
492    match value {
493        ValueRef::Null => 0,
494        ValueRef::Bool(_) => std::mem::size_of::<bool>(),
495        ValueRef::Int(_) => std::mem::size_of::<i64>(),
496        ValueRef::Float(_) => std::mem::size_of::<f64>(),
497        ValueRef::Decimal(_) => 17,
498        ValueRef::String(value) => value.len(),
499        ValueRef::Binary(value) => value.len(),
500        ValueRef::Vector(value) => value.encoded().len(),
501        ValueRef::Uuid(_) => 16,
502        ValueRef::Date(_) => 4,
503        ValueRef::LocalTime(_) => 8,
504        ValueRef::LocalDateTime(_) => 12,
505        ValueRef::DateTime(_) => 16,
506        ValueRef::Duration(_) => 28,
507        ValueRef::List(values) => values.iter().fold(0usize, |total, value| {
508            total.saturating_add(value_payload_bytes(value))
509        }),
510        ValueRef::Map(values) => map_payload_bytes(values),
511    }
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use hawdb_core::RelTypeId;
518    use hawdb_storage::{NodeId, RelId};
519    use std::collections::BTreeSet;
520
521    #[test]
522    fn value_only_constructors_leave_graph_bindings_empty() {
523        let values = Binding::values(BTreeMap::from([("left".to_string(), Value::Int(1))]));
524        assert_eq!(values.values["left"], Value::Int(1));
525        assert!(values.nodes.is_empty());
526        assert!(values.relationships.is_empty());
527
528        let scalar = Binding::scalar("right", Value::Bool(true));
529        assert_eq!(scalar.values["right"], Value::Bool(true));
530        assert!(scalar.nodes.is_empty());
531        assert!(scalar.relationships.is_empty());
532    }
533
534    #[test]
535    fn binding_accounting_includes_nested_values_and_graph_records() {
536        let binding = Binding {
537            values: BTreeMap::from([(
538                "nested".to_string(),
539                Value::List(vec![Value::Int(1), Value::String("two".to_string())]),
540            )]),
541            nodes: BTreeMap::from([(
542                "n".to_string(),
543                NodeRecord {
544                    id: NodeId(7),
545                    labels: BTreeSet::from([LabelId(3)]),
546                    properties: BTreeMap::new(),
547                },
548            )])
549            .into(),
550            relationships: BTreeMap::from([(
551                "r".to_string(),
552                RelRecord {
553                    id: RelId(11),
554                    source: NodeId(7),
555                    target: NodeId(8),
556                    rel_type: RelTypeId(4),
557                    properties: BTreeMap::new(),
558                },
559            )])
560            .into(),
561        };
562
563        assert!(binding_payload_bytes(&binding) > "nested".len() + "two".len());
564        assert!(binding_memory_bytes(&binding) > binding_payload_bytes(&binding));
565    }
566
567    #[test]
568    fn projected_node_admission_matches_graph_slot_ownership() {
569        let node = NodeRecord {
570            id: NodeId(3),
571            labels: BTreeSet::from([LabelId(1)]),
572            properties: BTreeMap::from([
573                ("id".into(), Value::String("chosen".into())),
574                ("body".into(), Value::String("x".repeat(65536))),
575            ]),
576        };
577        let required = BTreeSet::from(["id".to_string()]);
578        let bytes = projected_node_binding_memory_bytes("n", &node, &required);
579        let mut projected = node.clone();
580        projected
581            .properties
582            .retain(|name, _| required.contains(name));
583        let binding = Binding {
584            values: BTreeMap::new(),
585            nodes: BTreeMap::from([("n".into(), projected)]).into(),
586            relationships: GraphSlots::new(),
587        };
588        assert_eq!(bytes, binding_memory_bytes(&binding));
589    }
590
591    #[test]
592    fn replacement_admission_matches_final_rows_without_mutating_input() {
593        let column = hawdb_plan_cypher::SCORING_RERANK_SCORE_COLUMN;
594        for previous in [
595            None,
596            Some(Value::Null),
597            Some(Value::Float(-1.0)),
598            Some(Value::List(vec![Value::Map(BTreeMap::from([(
599                "nested".into(),
600                Value::String("x".repeat(1_024)),
601            )]))])),
602        ] {
603            let mut input = Binding::scalar("score", Value::Float(0.5));
604            if let Some(previous) = previous {
605                input.values.insert(column.into(), previous);
606            }
607            let original = input.clone();
608            let estimate =
609                binding_memory_bytes_replacing_value(&input, column, &Value::Float(0.25));
610            assert_eq!(input, original, "admission must not alter the candidate");
611            let mut output = input;
612            output.values.insert(column.into(), Value::Float(0.25));
613            assert_eq!(estimate, binding_memory_bytes(&output));
614        }
615    }
616
617    /// #1615: rows are charged from previews of their final shape before the
618    /// values are materialized. Across a sequence of row mutations every
619    /// preview must equal a full walk of the resulting row, and charging the
620    /// walked size through the ledger must settle to exactly zero.
621    #[test]
622    fn row_mutation_previews_equal_full_walks_and_settle_the_ledger() {
623        use crate::memory_ledger::{QueryMemoryClass, QueryMemoryLedger};
624        use std::num::NonZeroUsize;
625
626        let budget = NonZeroUsize::new(1 << 20).unwrap();
627        let ledger = QueryMemoryLedger::new(budget);
628        let account = ledger.account(QueryMemoryClass::PipelineBatch, "rows", budget);
629        let mut row = Binding {
630            values: BTreeMap::from([("id".to_string(), Value::Int(1))]),
631            nodes: BTreeMap::from([(
632                "n".to_string(),
633                NodeRecord {
634                    id: NodeId(1),
635                    labels: BTreeSet::from([LabelId(2)]),
636                    properties: BTreeMap::from([("name".into(), Value::String("ann".into()))]),
637                },
638            )])
639            .into(),
640            relationships: GraphSlots::new(),
641        };
642        let mut lease = account.reserve(binding_memory_bytes(&row)).unwrap();
643        let mut list = Vec::new();
644        let mut map = BTreeMap::new();
645        for step in 0..24usize {
646            // SET-like updates: overwrite, grow a list, grow a map, null out.
647            let (name, value) = match step % 4 {
648                0 => ("id", Value::String("x".repeat(step))),
649                1 => {
650                    list.push(Value::List(vec![Value::Int(step as i64); step % 3]));
651                    ("list", Value::List(list.clone()))
652                }
653                2 => {
654                    map.insert(format!("k{step}"), Value::String("v".repeat(step)));
655                    ("map", Value::Map(map.clone()))
656                }
657                _ => ("id", Value::Null),
658            };
659            let preview = binding_memory_bytes_replacing_value(&row, name, &value);
660            row.values.insert(name.to_string(), value);
661            let walked = binding_memory_bytes(&row);
662            assert_eq!(preview, walked, "step {step}: replacement");
663            // Re-charge the row to its walked size; the ledger tracks it exactly.
664            match walked.checked_sub(lease.bytes()) {
665                Some(growth) => lease.grow(growth).unwrap(),
666                None => lease.shrink(lease.bytes() - walked),
667            }
668            assert_eq!(ledger.snapshot().used_bytes, walked, "step {step}");
669
670            // Projection: keep a subset of columns plus a computed column.
671            let computed = Value::Map(BTreeMap::from([(
672                "wrapped".into(),
673                Value::List(list.clone()),
674            )]));
675            let projected: Vec<(&str, &Value)> = row
676                .values
677                .iter()
678                .filter(|(key, _)| key.as_str() != "id")
679                .map(|(key, value)| (key.as_str(), value))
680                .chain(std::iter::once(("computed", &computed)))
681                .collect();
682            let preview = binding_memory_bytes_with_values(&row, projected.iter().copied());
683            let mut materialized = row.clone();
684            materialized.values = projected
685                .iter()
686                .map(|(key, value)| (key.to_string(), (*value).clone()))
687                .collect();
688            assert_eq!(
689                preview,
690                binding_memory_bytes(&materialized),
691                "step {step}: projection"
692            );
693        }
694        // Drops: remove columns and the graph slot, then release the remainder.
695        for name in ["map", "list", "id"] {
696            row.values.remove(name);
697            lease.shrink(lease.bytes() - binding_memory_bytes(&row));
698            assert_eq!(ledger.snapshot().used_bytes, binding_memory_bytes(&row));
699        }
700        row.nodes.remove("n");
701        assert_eq!(binding_memory_bytes(&row), std::mem::size_of::<Binding>());
702        drop(lease);
703        assert_eq!(ledger.snapshot().used_bytes, 0);
704    }
705
706    /// Every kind of value, nested in lists and maps, and the node,
707    /// relationship and path values the executor builds for graph entities.
708    fn sized_values() -> Vec<Value> {
709        use hawdb_core::temporal;
710        let scalars = vec![
711            Value::Null,
712            Value::Bool(true),
713            Value::Int(-7),
714            Value::Float(2.5),
715            Value::Decimal(hawdb_core::Decimal::new(12_345, 2).unwrap()),
716            Value::String(String::new()),
717            Value::String("text".repeat(33)),
718            Value::Binary(vec![0xa5; 19]),
719            Value::Vector(hawdb_core::Vector::new(&[1.0, -2.0, 3.5]).unwrap()),
720            Value::Uuid(hawdb_core::Uuid::from_bytes([0x5a; 16])),
721            Value::Date(temporal::parse_date("2026-11-09").unwrap()),
722            Value::LocalTime(temporal::parse_local_time("23:59:59.999999999").unwrap()),
723            Value::LocalDateTime(temporal::parse_local_datetime("2026-10-30T12:00").unwrap()),
724            Value::DateTime(temporal::parse_datetime("2026-10-30T12:00:00-05:30").unwrap()),
725            Value::duration(temporal::parse_duration("-P1Y2M3DT4.5S").unwrap()),
726        ];
727        let mut catalog = hawdb_core::Catalog::default();
728        let label = catalog.get_or_create_label("Post");
729        let rel_type = catalog.get_or_create_rel_type("HAS_CREATOR");
730        let node = NodeRecord {
731            id: NodeId(41),
732            labels: BTreeSet::from([label]),
733            properties: BTreeMap::from([
734                ("content".into(), Value::String("x".repeat(300))),
735                ("tags".into(), Value::List(scalars.clone())),
736                ("empty".into(), Value::Null),
737            ]),
738        };
739        let relationship = RelRecord {
740            id: RelId(9),
741            source: NodeId(41),
742            target: NodeId(42),
743            rel_type,
744            properties: BTreeMap::from([("since".into(), Value::Int(2010))]),
745        };
746        let node_value = crate::expression::node_value(&node, &catalog);
747        let relationship_value = crate::expression::relationship_value(&relationship, &catalog);
748        let path = crate::expression::path_value(
749            vec![node_value.clone(), node_value.clone()],
750            vec![relationship_value.clone()],
751        );
752        let nested_map = Value::Map(
753            scalars
754                .iter()
755                .enumerate()
756                .map(|(index, value)| (format!("k{index}"), value.clone()))
757                .collect(),
758        );
759        let deep = Value::List(vec![
760            Value::List(vec![]),
761            Value::Map(BTreeMap::new()),
762            Value::List(vec![nested_map.clone(), Value::List(scalars.clone())]),
763            Value::Map(BTreeMap::from([("inner".into(), path.clone())])),
764        ]);
765        scalars
766            .into_iter()
767            .chain([
768                Value::List(Vec::with_capacity(8)),
769                nested_map,
770                node_value,
771                relationship_value,
772                path,
773                deep,
774            ])
775            .collect()
776    }
777
778    #[test]
779    fn single_walk_sizes_equal_separate_walks_across_value_kinds() {
780        let values = sized_values();
781        for value in &values {
782            assert_eq!(
783                value_payload_bytes(value),
784                value_ref_payload_bytes(value.as_ref()),
785                "{value:?}"
786            );
787            assert_eq!(
788                value_sizes(value),
789                (value_memory_bytes(value), value_payload_bytes(value)),
790                "{value:?}"
791            );
792            if let Value::Map(map) = value {
793                assert_eq!(
794                    map_sizes(map),
795                    (map_memory_bytes(map), map_payload_bytes(map))
796                );
797            }
798        }
799        let record = |id: u64, properties: BTreeMap<String, Value>| NodeRecord {
800            id: NodeId(id),
801            labels: BTreeSet::from([LabelId(1), LabelId(2)]),
802            properties,
803        };
804        let mut rows = vec![
805            Binding::values(BTreeMap::new()),
806            Binding::scalar("only", Value::Null),
807        ];
808        for (index, value) in values.iter().enumerate() {
809            let mut properties = BTreeMap::from([("p".to_string(), value.clone())]);
810            properties.insert("name".into(), Value::String(format!("row-{index}")));
811            rows.push(Binding {
812                values: BTreeMap::from([
813                    (format!("v{index}"), value.clone()),
814                    ("all".to_string(), Value::List(values.clone())),
815                ]),
816                nodes: BTreeMap::from([
817                    ("a".to_string(), record(index as u64, properties.clone())),
818                    ("b".to_string(), record(7, BTreeMap::new())),
819                ])
820                .into(),
821                relationships: BTreeMap::from([(
822                    "r".to_string(),
823                    RelRecord {
824                        id: RelId(index as u64),
825                        source: NodeId(1),
826                        target: NodeId(2),
827                        rel_type: RelTypeId(3),
828                        properties,
829                    },
830                )])
831                .into(),
832            });
833        }
834        for row in &rows {
835            assert_eq!(
836                binding_sizes(row),
837                BindingSizes {
838                    memory: binding_memory_bytes(row),
839                    payload: binding_payload_bytes(row),
840                },
841                "{row:?}"
842            );
843        }
844    }
845}