Skip to main content

torsh_cli/commands/model/
pytorch_reader.rs

1//! Real reader for modern (zip-based) PyTorch checkpoints (`torch.save`, >= 1.6).
2//!
3//! This is a genuine deserializer: it reads the STORED zip entries of a `.pt`
4//! archive, runs a minimal but real pickle virtual machine over `data.pkl` to
5//! recover the `state_dict` structure (`torch._utils._rebuild_tensor_v2` over
6//! typed storages), then reads the raw storage bytes and reconstructs real
7//! tensors honouring `size`/`stride`. Nothing here fabricates tensor values.
8//!
9//! Supported storage dtypes: `FloatStorage` (f32), `DoubleStorage` (f64) and
10//! `LongStorage` (i64). Anything else — including DEFLATE-compressed archives —
11//! returns an honest error rather than guessing.
12
13use anyhow::{anyhow, bail, Result};
14use std::collections::HashMap;
15
16/// Element dtype of a reconstructed tensor.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum TensorDType {
19    /// 32-bit float (`torch.FloatStorage`).
20    F32,
21    /// 64-bit float (`torch.DoubleStorage`).
22    F64,
23    /// 64-bit signed integer (`torch.LongStorage`).
24    I64,
25}
26
27impl TensorDType {
28    /// Size in bytes of one element.
29    pub fn elem_size(self) -> usize {
30        match self {
31            TensorDType::F32 => 4,
32            TensorDType::F64 => 8,
33            TensorDType::I64 => 8,
34        }
35    }
36}
37
38/// Reconstructed dense tensor data in row-major (C-contiguous) order.
39#[derive(Debug, Clone)]
40pub enum TensorData {
41    /// 32-bit float values.
42    F32(Vec<f32>),
43    /// 64-bit float values.
44    F64(Vec<f64>),
45    /// 64-bit signed integer values.
46    I64(Vec<i64>),
47}
48
49/// A real tensor recovered from a PyTorch checkpoint.
50#[derive(Debug, Clone)]
51pub struct PytorchTensor {
52    /// Fully-qualified state-dict key (e.g. `layer1.0.weight`).
53    pub name: String,
54    /// Element dtype.
55    pub dtype: TensorDType,
56    /// Logical shape.
57    pub shape: Vec<usize>,
58    /// Whether the checkpoint marked this tensor as requiring gradients.
59    pub requires_grad: bool,
60    /// Dense row-major data.
61    pub data: TensorData,
62}
63
64impl PytorchTensor {
65    /// Number of real elements reconstructed from the storage.
66    pub fn element_count(&self) -> usize {
67        match &self.data {
68            TensorData::F32(v) => v.len(),
69            TensorData::F64(v) => v.len(),
70            TensorData::I64(v) => v.len(),
71        }
72    }
73}
74
75/// Read and reconstruct every tensor in a zip-based PyTorch checkpoint.
76///
77/// Returns tensors in the order they appear in the pickled `state_dict`.
78pub fn read_state_dict(file_bytes: &[u8]) -> Result<Vec<PytorchTensor>> {
79    if !(file_bytes.len() >= 4 && &file_bytes[0..4] == b"PK\x03\x04") {
80        bail!("not a zip-based PyTorch checkpoint (missing PK\\x03\\x04 signature); legacy pure-pickle .pt files are not supported");
81    }
82
83    let entries = read_stored_zip_entries(file_bytes)?;
84
85    // Locate the pickle stream (`.../data.pkl`).
86    let pickle = entries
87        .iter()
88        .find(|(name, _)| name.ends_with("data.pkl"))
89        .map(|(_, body)| body.clone())
90        .ok_or_else(|| anyhow!("checkpoint zip has no data.pkl entry"))?;
91
92    // Index storages by their numeric key (`.../data/<key>`).
93    let mut storages: HashMap<String, Vec<u8>> = HashMap::new();
94    for (name, body) in &entries {
95        if let Some(pos) = name.rfind("/data/") {
96            let key = &name[pos + "/data/".len()..];
97            if !key.is_empty() && key.bytes().all(|b| b.is_ascii_digit()) {
98                storages.insert(key.to_string(), body.clone());
99            }
100        }
101    }
102
103    let specs = run_pickle(&pickle)?;
104
105    let mut tensors = Vec::with_capacity(specs.len());
106    for spec in specs {
107        let storage = storages.get(&spec.storage_key).ok_or_else(|| {
108            anyhow!(
109                "state_dict references storage '{}' that is not present in the archive",
110                spec.storage_key
111            )
112        })?;
113        tensors.push(reconstruct_tensor(spec, storage)?);
114    }
115
116    Ok(tensors)
117}
118
119// ---------------------------------------------------------------------------
120// STORED zip reading (no compression, as produced by torch.save)
121// ---------------------------------------------------------------------------
122
123/// Read all STORED (uncompressed) entries from a zip via its local file headers.
124fn read_stored_zip_entries(data: &[u8]) -> Result<Vec<(String, Vec<u8>)>> {
125    const LOCAL_SIG: &[u8] = b"PK\x03\x04";
126    let mut entries = Vec::new();
127    let mut cursor = 0usize;
128
129    while cursor + 30 <= data.len() && &data[cursor..cursor + 4] == LOCAL_SIG {
130        let method = u16::from_le_bytes([data[cursor + 8], data[cursor + 9]]);
131        let comp_size = u32::from_le_bytes([
132            data[cursor + 18],
133            data[cursor + 19],
134            data[cursor + 20],
135            data[cursor + 21],
136        ]) as usize;
137        let uncomp_size = u32::from_le_bytes([
138            data[cursor + 22],
139            data[cursor + 23],
140            data[cursor + 24],
141            data[cursor + 25],
142        ]) as usize;
143        let name_len = u16::from_le_bytes([data[cursor + 26], data[cursor + 27]]) as usize;
144        let extra_len = u16::from_le_bytes([data[cursor + 28], data[cursor + 29]]) as usize;
145
146        let name_start = cursor + 30;
147        let name_end = name_start + name_len;
148        if name_end > data.len() {
149            bail!("corrupt zip: entry name runs past end of file");
150        }
151        let name = String::from_utf8_lossy(&data[name_start..name_end]).to_string();
152
153        let body_start = name_end + extra_len;
154        let body_end = body_start + comp_size;
155        if body_end > data.len() {
156            bail!("corrupt zip: entry '{}' body runs past end of file", name);
157        }
158
159        // Directory entries (trailing '/') have empty bodies; skip them.
160        if !name.ends_with('/') {
161            if method != 0 {
162                bail!(
163                    "zip entry '{}' uses compression method {} but only STORED (0) is supported; \
164                     torch.save archives are stored uncompressed",
165                    name,
166                    method
167                );
168            }
169            if comp_size != uncomp_size {
170                bail!("zip entry '{}' has inconsistent STORED sizes", name);
171            }
172            entries.push((name, data[body_start..body_end].to_vec()));
173        }
174
175        cursor = body_end;
176    }
177
178    if entries.is_empty() {
179        bail!("no readable STORED zip entries found");
180    }
181    Ok(entries)
182}
183
184// ---------------------------------------------------------------------------
185// Minimal pickle virtual machine (enough for torch state_dicts)
186// ---------------------------------------------------------------------------
187
188/// A tensor description recovered from the pickle stream.
189#[derive(Debug, Clone)]
190struct TensorSpec {
191    name: String,
192    storage_key: String,
193    dtype: TensorDType,
194    storage_offset: usize,
195    size: Vec<usize>,
196    stride: Vec<usize>,
197    requires_grad: bool,
198}
199
200/// Objects manipulated on the pickle stack.
201#[derive(Debug, Clone)]
202enum Obj {
203    None,
204    Bool(bool),
205    Int(i64),
206    Str(String),
207    Tuple(Vec<Obj>),
208    List(Vec<Obj>),
209    Dict(Vec<(Obj, Obj)>),
210    Global(String),
211    Mark,
212    /// A resolved persistent id describing a typed storage.
213    Storage {
214        dtype: TensorDType,
215        key: String,
216    },
217    /// A reconstructed tensor (result of `_rebuild_tensor_v2`).
218    Tensor(TensorSpecPartial),
219    /// An opaque reduce result we do not need to interpret.
220    Opaque,
221}
222
223/// Tensor spec before it is bound to a state-dict name.
224#[derive(Debug, Clone)]
225struct TensorSpecPartial {
226    storage_key: String,
227    dtype: TensorDType,
228    storage_offset: usize,
229    size: Vec<usize>,
230    stride: Vec<usize>,
231    requires_grad: bool,
232}
233
234/// Run the pickle VM and extract the ordered list of tensor specs.
235fn run_pickle(buf: &[u8]) -> Result<Vec<TensorSpec>> {
236    let mut stack: Vec<Obj> = Vec::new();
237    let mut memo: HashMap<u32, Obj> = HashMap::new();
238    let mut pos = 0usize;
239    let mut memo_counter: u32 = 0;
240
241    macro_rules! need {
242        ($n:expr) => {{
243            if pos + $n > buf.len() {
244                bail!("pickle stream truncated");
245            }
246        }};
247    }
248
249    let top: Obj = loop {
250        if pos >= buf.len() {
251            bail!("pickle stream ended without STOP");
252        }
253        let op = buf[pos];
254        pos += 1;
255
256        match op {
257            0x80 => {
258                // PROTO
259                need!(1);
260                pos += 1;
261            }
262            0x95 => {
263                // FRAME
264                need!(8);
265                pos += 8;
266            }
267            b'(' => stack.push(Obj::Mark), // MARK
268            b'.' => {
269                // STOP
270                break stack
271                    .pop()
272                    .ok_or_else(|| anyhow!("STOP with empty stack"))?;
273            }
274            b'0' => {
275                // POP
276                stack.pop();
277            }
278            b'}' => stack.push(Obj::Dict(Vec::new())), // EMPTY_DICT
279            b']' => stack.push(Obj::List(Vec::new())), // EMPTY_LIST
280            b')' => stack.push(Obj::Tuple(Vec::new())), // EMPTY_TUPLE
281            b'N' => stack.push(Obj::None),             // NONE
282            0x88 => stack.push(Obj::Bool(true)),       // NEWTRUE
283            0x89 => stack.push(Obj::Bool(false)),      // NEWFALSE
284            b'K' => {
285                // BININT1
286                need!(1);
287                stack.push(Obj::Int(buf[pos] as i64));
288                pos += 1;
289            }
290            b'M' => {
291                // BININT2
292                need!(2);
293                stack.push(Obj::Int(u16::from_le_bytes([buf[pos], buf[pos + 1]]) as i64));
294                pos += 2;
295            }
296            b'J' => {
297                // BININT (signed 4-byte)
298                need!(4);
299                stack.push(Obj::Int(i32::from_le_bytes([
300                    buf[pos],
301                    buf[pos + 1],
302                    buf[pos + 2],
303                    buf[pos + 3],
304                ]) as i64));
305                pos += 4;
306            }
307            0x8a => {
308                // LONG1
309                need!(1);
310                let n = buf[pos] as usize;
311                pos += 1;
312                need!(n);
313                stack.push(Obj::Int(read_signed_le(&buf[pos..pos + n])));
314                pos += n;
315            }
316            b'G' => {
317                // BINFLOAT (big-endian double): consumed but not needed for the
318                // tensor structure, so kept as an opaque value.
319                need!(8);
320                pos += 8;
321                stack.push(Obj::Opaque);
322            }
323            0x8c => {
324                // SHORT_BINUNICODE
325                need!(1);
326                let n = buf[pos] as usize;
327                pos += 1;
328                need!(n);
329                stack.push(Obj::Str(str_from(&buf[pos..pos + n])?));
330                pos += n;
331            }
332            b'X' => {
333                // BINUNICODE
334                need!(4);
335                let n = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]])
336                    as usize;
337                pos += 4;
338                need!(n);
339                stack.push(Obj::Str(str_from(&buf[pos..pos + n])?));
340                pos += n;
341            }
342            b'q' => {
343                // BINPUT
344                need!(1);
345                let idx = buf[pos] as u32;
346                pos += 1;
347                let obj = clone_top(&stack)?;
348                memo.insert(idx, obj);
349            }
350            b'r' => {
351                // LONG_BINPUT
352                need!(4);
353                let idx = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
354                pos += 4;
355                let obj = clone_top(&stack)?;
356                memo.insert(idx, obj);
357            }
358            0x94 => {
359                // MEMOIZE
360                let obj = clone_top(&stack)?;
361                memo.insert(memo_counter, obj);
362                memo_counter += 1;
363            }
364            b'h' => {
365                // BINGET
366                need!(1);
367                let idx = buf[pos] as u32;
368                pos += 1;
369                stack.push(memo_get(&memo, idx)?);
370            }
371            b'j' => {
372                // LONG_BINGET
373                need!(4);
374                let idx = u32::from_le_bytes([buf[pos], buf[pos + 1], buf[pos + 2], buf[pos + 3]]);
375                pos += 4;
376                stack.push(memo_get(&memo, idx)?);
377            }
378            b'c' => {
379                // GLOBAL: module\nname\n
380                let module = read_line(buf, &mut pos)?;
381                let name = read_line(buf, &mut pos)?;
382                stack.push(Obj::Global(format!("{module}.{name}")));
383            }
384            0x93 => {
385                // STACK_GLOBAL: pop name, module
386                let name = pop_str(&mut stack)?;
387                let module = pop_str(&mut stack)?;
388                stack.push(Obj::Global(format!("{module}.{name}")));
389            }
390            0x85 => tuple_n(&mut stack, 1)?, // TUPLE1
391            0x86 => tuple_n(&mut stack, 2)?, // TUPLE2
392            0x87 => tuple_n(&mut stack, 3)?, // TUPLE3
393            b't' => {
394                // TUPLE (mark based)
395                let items = pop_to_mark(&mut stack)?;
396                stack.push(Obj::Tuple(items));
397            }
398            b'a' => {
399                // APPEND
400                let v = stack.pop().ok_or_else(|| anyhow!("APPEND underflow"))?;
401                if let Some(Obj::List(list)) = stack.last_mut() {
402                    list.push(v);
403                } else {
404                    bail!("APPEND on non-list");
405                }
406            }
407            b'e' => {
408                // APPENDS
409                let items = pop_to_mark(&mut stack)?;
410                if let Some(Obj::List(list)) = stack.last_mut() {
411                    list.extend(items);
412                } else {
413                    bail!("APPENDS on non-list");
414                }
415            }
416            b's' => {
417                // SETITEM
418                let value = stack.pop().ok_or_else(|| anyhow!("SETITEM underflow"))?;
419                let key = stack.pop().ok_or_else(|| anyhow!("SETITEM underflow"))?;
420                if let Some(Obj::Dict(d)) = stack.last_mut() {
421                    d.push((key, value));
422                } else {
423                    bail!("SETITEM on non-dict");
424                }
425            }
426            b'u' => {
427                // SETITEMS
428                let items = pop_to_mark(&mut stack)?;
429                if items.len() % 2 != 0 {
430                    bail!("SETITEMS with odd number of items");
431                }
432                if let Some(Obj::Dict(d)) = stack.last_mut() {
433                    let mut it = items.into_iter();
434                    while let (Some(k), Some(v)) = (it.next(), it.next()) {
435                        d.push((k, v));
436                    }
437                } else {
438                    bail!("SETITEMS on non-dict");
439                }
440            }
441            b'Q' => {
442                // BINPERSID: pop pid, resolve to storage
443                let pid = stack.pop().ok_or_else(|| anyhow!("BINPERSID underflow"))?;
444                stack.push(resolve_persid(pid)?);
445            }
446            b'R' => {
447                // REDUCE
448                let args = stack.pop().ok_or_else(|| anyhow!("REDUCE underflow"))?;
449                let callable = stack.pop().ok_or_else(|| anyhow!("REDUCE underflow"))?;
450                stack.push(apply_reduce(callable, args)?);
451            }
452            b'b' => {
453                // BUILD: pop state, leave object (we ignore __setstate__ payloads)
454                stack.pop().ok_or_else(|| anyhow!("BUILD underflow"))?;
455            }
456            other => bail!("unsupported pickle opcode: 0x{other:02x}"),
457        }
458    };
459
460    // The top object is the state_dict (possibly nested). Collect tensor specs.
461    let mut specs = Vec::new();
462    collect_specs(&top, "", &mut specs)?;
463    if specs.is_empty() {
464        bail!("no tensors (via _rebuild_tensor_v2) were found in the checkpoint");
465    }
466    Ok(specs)
467}
468
469/// Recursively collect tensor specs from a (possibly nested) dict, prefixing
470/// nested keys with their dotted path.
471fn collect_specs(obj: &Obj, prefix: &str, out: &mut Vec<TensorSpec>) -> Result<()> {
472    match obj {
473        Obj::Dict(entries) => {
474            for (k, v) in entries {
475                let key = match k {
476                    Obj::Str(s) => s.clone(),
477                    Obj::Int(i) => i.to_string(),
478                    _ => continue,
479                };
480                let path = if prefix.is_empty() {
481                    key
482                } else {
483                    format!("{prefix}.{key}")
484                };
485                collect_specs(v, &path, out)?;
486            }
487            Ok(())
488        }
489        Obj::Tensor(p) => {
490            out.push(TensorSpec {
491                name: prefix.to_string(),
492                storage_key: p.storage_key.clone(),
493                dtype: p.dtype,
494                storage_offset: p.storage_offset,
495                size: p.size.clone(),
496                stride: p.stride.clone(),
497                requires_grad: p.requires_grad,
498            });
499            Ok(())
500        }
501        _ => Ok(()),
502    }
503}
504
505/// Resolve a persistent-id tuple `('storage', <StorageType>, <key>, <loc>, <numel>)`.
506fn resolve_persid(pid: Obj) -> Result<Obj> {
507    let items = match pid {
508        Obj::Tuple(items) => items,
509        _ => bail!("persistent id is not a tuple (unsupported checkpoint layout)"),
510    };
511    if items.len() < 3 {
512        bail!("persistent id tuple too short");
513    }
514    match &items[0] {
515        Obj::Str(s) if s == "storage" => {}
516        _ => bail!("unsupported persistent id kind (expected 'storage')"),
517    }
518    let dtype = match &items[1] {
519        Obj::Global(g) => storage_dtype(g)?,
520        _ => bail!("persistent id storage type is not a global"),
521    };
522    let key = match &items[2] {
523        Obj::Str(s) => s.clone(),
524        Obj::Int(i) => i.to_string(),
525        _ => bail!("persistent id storage key has unexpected type"),
526    };
527    Ok(Obj::Storage { dtype, key })
528}
529
530/// Map a `torch.*Storage` global to a supported dtype.
531fn storage_dtype(global: &str) -> Result<TensorDType> {
532    match global {
533        "torch.FloatStorage" => Ok(TensorDType::F32),
534        "torch.DoubleStorage" => Ok(TensorDType::F64),
535        "torch.LongStorage" => Ok(TensorDType::I64),
536        other => bail!(
537            "unsupported storage type '{}': only FloatStorage, DoubleStorage and LongStorage are supported",
538            other
539        ),
540    }
541}
542
543/// Apply a REDUCE for the callables we understand.
544fn apply_reduce(callable: Obj, args: Obj) -> Result<Obj> {
545    let name = match &callable {
546        Obj::Global(g) => g.as_str(),
547        _ => return Ok(Obj::Opaque),
548    };
549    match name {
550        "torch._utils._rebuild_tensor_v2" | "torch._utils._rebuild_tensor" => {
551            let a = match args {
552                Obj::Tuple(a) => a,
553                _ => bail!("_rebuild_tensor args are not a tuple"),
554            };
555            if a.len() < 4 {
556                bail!("_rebuild_tensor expects at least 4 args, got {}", a.len());
557            }
558            let (dtype, storage_key) = match &a[0] {
559                Obj::Storage { dtype, key } => (*dtype, key.clone()),
560                _ => bail!("_rebuild_tensor first arg is not a storage"),
561            };
562            let storage_offset = as_usize(&a[1])?;
563            let size = as_usize_tuple(&a[2])?;
564            let stride = as_usize_tuple(&a[3])?;
565            let requires_grad = matches!(a.get(4), Some(Obj::Bool(true)));
566            Ok(Obj::Tensor(TensorSpecPartial {
567                storage_key,
568                dtype,
569                storage_offset,
570                size,
571                stride,
572                requires_grad,
573            }))
574        }
575        // OrderedDict()/dict() constructors -> empty dict to be filled by SETITEMS.
576        "collections.OrderedDict" | "builtins.dict" | "__builtin__.dict" => {
577            Ok(Obj::Dict(Vec::new()))
578        }
579        _ => Ok(Obj::Opaque),
580    }
581}
582
583/// Reconstruct a dense, row-major tensor from a storage buffer, honouring
584/// `storage_offset`, `size` and `stride`.
585fn reconstruct_tensor(spec: TensorSpec, storage: &[u8]) -> Result<PytorchTensor> {
586    let elem = spec.dtype.elem_size();
587    if storage.len() % elem != 0 {
588        bail!(
589            "storage '{}' length {} is not a multiple of element size {}",
590            spec.storage_key,
591            storage.len(),
592            elem
593        );
594    }
595    let n_storage_elems = storage.len() / elem;
596    let numel: usize = spec.size.iter().product();
597
598    if spec.size.len() != spec.stride.len() {
599        bail!("tensor '{}' size/stride rank mismatch", spec.name);
600    }
601
602    // Row-major traversal of the logical index space, gathering from storage
603    // through the provided strides.
604    let mut indices = vec![0usize; spec.size.len()];
605    let mut flat = Vec::with_capacity(numel);
606    for _ in 0..numel {
607        let mut storage_index = spec.storage_offset;
608        for (dim, &idx) in indices.iter().enumerate() {
609            storage_index += idx * spec.stride[dim];
610        }
611        if storage_index >= n_storage_elems {
612            bail!(
613                "tensor '{}' indexes element {} beyond storage of {} elements",
614                spec.name,
615                storage_index,
616                n_storage_elems
617            );
618        }
619        flat.push(storage_index);
620
621        // Increment the multi-dimensional counter (row-major, last dim fastest).
622        for dim in (0..spec.size.len()).rev() {
623            indices[dim] += 1;
624            if indices[dim] < spec.size[dim] {
625                break;
626            }
627            indices[dim] = 0;
628        }
629    }
630
631    let data = match spec.dtype {
632        TensorDType::F32 => TensorData::F32(
633            flat.iter()
634                .map(|&i| {
635                    let b = &storage[i * 4..i * 4 + 4];
636                    f32::from_le_bytes([b[0], b[1], b[2], b[3]])
637                })
638                .collect(),
639        ),
640        TensorDType::F64 => TensorData::F64(
641            flat.iter()
642                .map(|&i| {
643                    let b = &storage[i * 8..i * 8 + 8];
644                    f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
645                })
646                .collect(),
647        ),
648        TensorDType::I64 => TensorData::I64(
649            flat.iter()
650                .map(|&i| {
651                    let b = &storage[i * 8..i * 8 + 8];
652                    i64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
653                })
654                .collect(),
655        ),
656    };
657
658    Ok(PytorchTensor {
659        name: spec.name,
660        dtype: spec.dtype,
661        shape: spec.size,
662        requires_grad: spec.requires_grad,
663        data,
664    })
665}
666
667// ---------------------------------------------------------------------------
668// Small helpers
669// ---------------------------------------------------------------------------
670
671fn read_signed_le(bytes: &[u8]) -> i64 {
672    if bytes.is_empty() {
673        return 0;
674    }
675    let mut val: i64 = 0;
676    for (i, &b) in bytes.iter().enumerate() {
677        val |= (b as i64) << (8 * i);
678    }
679    // Sign-extend from the top bit of the highest byte.
680    let bits = bytes.len() * 8;
681    if bits < 64 && (bytes[bytes.len() - 1] & 0x80) != 0 {
682        val |= -1i64 << bits;
683    }
684    val
685}
686
687fn str_from(bytes: &[u8]) -> Result<String> {
688    String::from_utf8(bytes.to_vec()).map_err(|_| anyhow!("invalid UTF-8 in pickle string"))
689}
690
691fn read_line(buf: &[u8], pos: &mut usize) -> Result<String> {
692    let start = *pos;
693    while *pos < buf.len() && buf[*pos] != b'\n' {
694        *pos += 1;
695    }
696    if *pos >= buf.len() {
697        bail!("unterminated GLOBAL line in pickle stream");
698    }
699    let s = str_from(&buf[start..*pos])?;
700    *pos += 1; // consume '\n'
701    Ok(s)
702}
703
704fn clone_top(stack: &[Obj]) -> Result<Obj> {
705    stack
706        .last()
707        .cloned()
708        .ok_or_else(|| anyhow!("memoize on empty stack"))
709}
710
711fn memo_get(memo: &HashMap<u32, Obj>, idx: u32) -> Result<Obj> {
712    memo.get(&idx)
713        .cloned()
714        .ok_or_else(|| anyhow!("BINGET of unknown memo index {idx}"))
715}
716
717fn pop_str(stack: &mut Vec<Obj>) -> Result<String> {
718    match stack.pop() {
719        Some(Obj::Str(s)) => Ok(s),
720        _ => bail!("expected a string on the pickle stack"),
721    }
722}
723
724fn tuple_n(stack: &mut Vec<Obj>, n: usize) -> Result<()> {
725    if stack.len() < n {
726        bail!("TUPLE{n} underflow");
727    }
728    let items = stack.split_off(stack.len() - n);
729    stack.push(Obj::Tuple(items));
730    Ok(())
731}
732
733fn pop_to_mark(stack: &mut Vec<Obj>) -> Result<Vec<Obj>> {
734    let mut items = Vec::new();
735    while let Some(obj) = stack.pop() {
736        if matches!(obj, Obj::Mark) {
737            items.reverse();
738            return Ok(items);
739        }
740        items.push(obj);
741    }
742    bail!("no MARK found on the pickle stack")
743}
744
745fn as_usize(obj: &Obj) -> Result<usize> {
746    match obj {
747        Obj::Int(i) if *i >= 0 => Ok(*i as usize),
748        _ => bail!("expected a non-negative integer"),
749    }
750}
751
752fn as_usize_tuple(obj: &Obj) -> Result<Vec<usize>> {
753    match obj {
754        Obj::Tuple(items) => items.iter().map(as_usize).collect(),
755        _ => bail!("expected a tuple of integers (size/stride)"),
756    }
757}