Repository: marimo-team/marimo
Pull request: fix: Expand and test hashing data "arrays"
Should fix: https://github.com/marimo-team/marimo/issues/2535

Putting as draft, since I think this is a good opportunity to put in testing for heavier dependencies as well (jax, torch, etc)

Problem statement:
Error when calling function with @mo.cache decorator
### Describe the bug

When adding the `@mo.cache` decorator to my `local_alignment_search` function, the following exception occurs on function call.

I'm not sure what the image type is for; `current_queries` should be an array of `skbio.sequence._dna.DNA` objects.

```
ValueError: This cell raised an exception: ValueError('Expected an image object, but got <class 'skbio.sequence._dna.DNA'> instead.')
Traceback (most recent call last):
  File "/Users/wasim/Downloads/marimo/marimo/_runtime/executor.py", line 170, in execute_cell
    exec(cell.body, glbls)
  Cell marimo://sequence_homology_searching.py#cell=cell-1, line 4, in <module>
    results = local_alignment_search(current_queries, reference_db)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/save.py", line 191, in __call__
    attempt = content_cache_attempt_from_base(
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 946, in content_cache_attempt_from_base
    refs, content, tmp_stateful_refs = hasher.collect_for_content_hash(
                                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 464, in collect_for_content_hash
    self.serialize_and_dequeue_content_refs(refs, scope)
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 674, in serialize_and_dequeue_content_refs
    serial_value = common_container_to_bytes(value)
                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 194, in common_container_to_bytes
    return recurse_container(value)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 182, in recurse_container
    return iterable_sign(map(recurse_container, value), "list")
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 149, in iterable_sign
    values = list(value)
             ^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 192, in recurse_container
    return data_to_buffer(value)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 198, in data_to_buffer
    data = standardize_tensor(data)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/Users/wasim/Downloads/marimo/marimo/_save/hash.py", line 123, in standardize_tensor
    raise ValueError(
ValueError: Expected an image object, but got <class 'skbio.sequence._dna.DNA'> instead.
```

This exception does not occur when @mo.cache is removed. I tried a few different things to see if I could reproduce this in a new notebook (passing a return value from one cached function to another) but couldn't.

### Environment

```json
{
  "marimo": "0.9.1",
  "OS": "Darwin",
  "OS Version": "23.6.0",
  "Processor": "arm",
  "Python Version": "3.12.3",
  "Binaries": {
    "Browser": "129.0.6668.90",
    "Node": "v22.2.0"
  },
  "Dependencies": {
    "click": "8.1.3",
    "importlib-resources": "missing",
    "jedi": "0.19.1",
    "markdown": "3.7",
    "pygments": "2.18.0",
    "pymdown-extensions": "10.9",
    "ruff": "0.6.5",
    "starlette": "0.38.5",
    "tomlkit": "0.13.2",
    "typing-extensions": "4.12.2",
    "uvicorn": "0.30.6",
    "websockets": "12.0"
  },
  "Optional Dependencies": {
    "altair": "5.4.1",
    "anywidget": "0.9.13",
    "duckdb": "1.1.1",
    "ibis-framework": "9.5.0",
    "pandas": "2.2.2",
    "polars": "1.5.0",
    "pyarrow": "17.0.0"
  }
}
```

### Code to reproduce

```python
import marimo

__generated_with = "0.9.1"
app = marimo.App(app_title="Sequence Homology Searching")


@app.cell(column=0)
def __(
    load_taxonomy_query_sequences,
    load_taxonomy_reference_database,
    random,
):
    reference_taxonomy, reference_db = load_taxonomy_reference_database()
    reference_db = random.sample(reference_db, k=5000)

    queries = load_taxonomy_query_sequences()
    queries = random.sample(queries, k=50)

    queries[:1]
    return queries, reference_db, reference_taxonomy


@app.cell
def __(
    local_alignment_search,
    queries,
    random,
    reference_db,
    time,
    warnings,
):
    start_time = time.time()
    current_queries = random.sample(queries, k=4)
    with warnings.catch_warnings(action="ignore"):
        results = local_alignment_search(current_queries, reference_db)
    stop_time = time.time()
    print(
        "Runtime: %1.4f sec per query"
        % ((stop_time - start_time) / len(current_queries))
    )
    return current_queries, results, start_time, stop_time


@app.cell
def __(current_queries, reference_taxonomy):
    for q in current_queries:
        q_id = q.metadata["id"]
        print(
            "Known taxonomy for query %s:\n %s\n"
            % (q_id, reference_taxonomy[q_id])
        )
    return q, q_id


@app.cell
def __(results):
    results
    return


@app.cell
def __():
    import skbio
    import pandas as pd
    import marimo as mo
    import numpy as np
    import qiime_default_reference as qdr
    from skbio.alignment import local_pairwise_align_ssw
    import time
    import warnings
    import random
    import functools
    return (
        functools,
        local_pairwise_align_ssw,
        mo,
        np,
        pd,
        qdr,
        random,
        skbio,
        time,
        warnings,
    )


@app.cell
def __(mo, qdr, skbio):
    @mo.cache
    def load_taxonomy_reference_database(verbose=True):
        reference_taxonomy = {}
        for e in open(qdr.get_reference_taxonomy()):
            seq_id, seq_tax = e.strip().split("\t")
            reference_taxonomy[seq_id] = seq_tax

        reference_db = []
        for e in skbio.io.read(
            qdr.get_reference_sequences(), format="fasta", constructor=skbio.DNA
        ):
            if e.has_degenerates():
                continue
            seq_tax = reference_taxonomy[e.metadata["id"]]
            e.metadata["taxonomy"] = seq_tax
            reference_db.append(e)

        if verbose:
            print(
                "%s sequences were loaded from the reference database."
                % len(reference_db)
            )

        return reference_taxonomy, reference_db
    return (load_taxonomy_reference_database,)


@app.cell
def __(mo, qdr, skbio):
    @mo.cache
    def load_taxonomy_query_sequences(start_position=100, length=200):
        queries = []
        for e in skbio.io.read(
            qdr.get_reference_sequences(), format="fasta", constructor=skbio.DNA
        ):
            if e.has_degenerates():
                continue
            e = e[start_position : start_position + length]
            queries.append(e)
        return queries
    return (load_taxonomy_query_sequences,)


@app.cell
def __(local_pairwise_align_ssw, mo, np, pd):
    @mo.cache
    def local_alignment_search(
        queries, reference_db, n=10, aligner=local_pairwise_align_ssw
    ):
        results = []
        indices = []
        for q in queries:
            # first we'll compute all of the alignments and their associated scores
            hits = []
            for r in reference_db:
                aln, score, _ = aligner(q, r)
                hits.append([r.metadata["id"], score, aln, r.metadata["taxonomy"]])
            # then we reverse-sort them by score, and return the n highest
            # scoring alignments (this needs to be updated so we only
            # ever keep track of the n highest scoring alignments)
            best_hits = sorted(hits, key=lambda e: e[1], reverse=True)[:n]
            if len(best_hits) == 0:
                # if there are no hits, log that information
                indices.append((q.metadata["id"], "no matches"))
                results.append(("n/a", np.nan, np.nan, np.nan))
            else:
                # otherwise compile and track some information about the n
                # best hits
                for r_id, score, aln, r_tax in best_hits:
                    percent_similarity = 100 * (1.0 - aln[0].distance(aln[1]))
                    aln_length = aln.shape[1]
                    indices.append((q.metadata["id"], r_id))
                    results.append((r_tax, percent_similarity, aln_length, score))
        index = pd.MultiIndex.from_tuples(indices, names=["query", "reference"])
        columns = [
            "reference taxonomy",
            "percent similarity",
            "alignment length",
            "score",
        ]
        results = pd.DataFrame(results, index=index, columns=columns)
        return results
    return (local_alignment_search,)


if __name__ == "__main__":
    app.run()

```

Hints from issue discussion:
cc @dmadisetti

Commit message: fix: Standardize tensor should capture __array_interface__ case

Patch to review:
diff --git a/marimo/_save/hash.py b/marimo/_save/hash.py
index 8356ae82721..36216d23564 100644
--- a/marimo/_save/hash.py
+++ b/marimo/_save/hash.py
@@ -107,21 +107,24 @@ def hash_cell_impl(cell: CellImpl, hash_type: str = DEFAULT_HASH) -> bytes:
 
 
 def standardize_tensor(tensor: Tensor) -> Optional[Tensor]:
-    # TODO: Consider moving to a more general utility module.
-    if hasattr(tensor, "__array__") or hasattr(tensor, "toarray"):
-        if not hasattr(tensor, "__array_interface__"):
-            DependencyManager.numpy.require(
-                "to access data buffer for hashing."
-            )
-            import numpy
+    if (
+        hasattr(tensor, "__array__")
+        or hasattr(tensor, "toarray")
+        or hasattr(tensor, "__array_interface__")
+    ):
+        DependencyManager.numpy.require("to access data buffer for hashing.")
+        import numpy
 
+        if not hasattr(tensor, "__array_interface__"):
             # Capture those sparse cases
             if hasattr(tensor, "toarray"):
                 tensor = tensor.toarray()
-            tensor = numpy.array(tensor)
-        return tensor
+        # As array should not perform copy
+        return numpy.asarray(tensor)
     raise ValueError(
-        f"Expected an image object, but got {type(tensor)} instead."
+        f"Expected a data primitive object, but got {type(tensor)} instead."
+        "This maybe an in internal marimo issue. Please report to "
+        "https://github.com/marimo-team/marimo/issues."
     )
 
 


Code review comments:
diff_hunk: @@ -107,21 +107,24 @@ def hash_cell_impl(cell: CellImpl, hash_type: str = DEFAULT_HASH) -> bytes:
 
 
 def standardize_tensor(tensor: Tensor) -> Optional[Tensor]:
-    # TODO: Consider moving to a more general utility module.
-    if hasattr(tensor, "__array__") or hasattr(tensor, "toarray"):
-        if not hasattr(tensor, "__array_interface__"):
-            DependencyManager.numpy.require(
-                "to access data buffer for hashing."
-            )
-            import numpy
+    if (
+        hasattr(tensor, "__array__")
+        or hasattr(tensor, "toarray")
+        or hasattr(tensor, "__array_interface__")
+    ):
+        DependencyManager.numpy.require("to access data buffer for hashing.")
+        import numpy
 
+        if not hasattr(tensor, "__array_interface__"):
             # Capture those sparse cases
             if hasattr(tensor, "toarray"):
                 tensor = tensor.toarray()
-            tensor = numpy.array(tensor)
-        return tensor
+        # As array should not perform copy
+        return numpy.asarray(tensor)
     raise ValueError(
-        f"Expected an image object, but got {type(tensor)} instead."
+        f"Expected a data primitive object, but got {type(tensor)} instead."
+        "This maybe an in internal marimo issue. Please report to "
path: marimo/_save/hash.py
text: @user1:
```suggestion
        "This maybe is an internal marimo issue. Please report to "
```