Skip to content

Serializing computations

Loman can serialize computations to a JSON file for later inspection or post-mortem debugging. This is useful when a scheduled job should capture its inputs, intermediates, and results so they can be examined if something goes wrong.

>>> import math
>>> from loman import Computation
>>> comp = Computation()
>>> comp.add_node("x", value=4.0)
>>> def area(x):
...     return math.pi * x**2
>>> comp.add_node("area", area)
>>> comp.compute_all()
>>> comp.to_dict()
{NodeKey('x'): 4.0, NodeKey('area'): 50.26548245743669}

To save and reload the computation:

>>> comp.write_json("comp.json")
>>> comp2 = Computation.read_json("comp.json")
>>> comp2.v.area
50.26548245743669

The output is a plain JSON text file, so it is human-readable and can be inspected with any text editor.

Excluding nodes from serialization

Sometimes a node holds a value that should not (or cannot) be saved — for example a database connection, a licensed dataset, or an object that does not support JSON serialization. Pass serialize=False when adding the node:

>>> import threading
>>> comp = Computation()
>>> comp.add_node("lock", value=threading.Lock(), serialize=False)
>>> comp.add_node("result", value=42)
>>> comp.write_json("comp.json")
>>> comp2 = Computation.read_json("comp.json")
>>> comp2.state("lock")
<States.UNINITIALIZED: 1>
>>> comp2.v.result
42

A database engine or an open connection behaves the same way — anything whose value is tied to the running process rather than to the data.

The excluded node is preserved in the file with UNINITIALIZED state and no value; all other nodes round-trip normally.

Lambdas are not serializable by default

A lambda cannot be serialized because it has no importable module path. Use a module-level function instead:

>>> from loman import Computation, ComputationSerializer, SerializationError
>>> comp = Computation()
>>> comp.add_node("a", value=1)
>>> comp.add_node("b", lambda a: a + 1)
>>> comp.compute_all()
>>> import io
>>> try:
...     comp.write_json(io.StringIO())
... except SerializationError as e:
...     print(e)
Cannot serialize lambda function on node NodeKey('b'). Use a module-level importable function, serialize=False, or ComputationSerializer(use_dill_for_functions=True).

Replace the lambda with a named function defined at module level:

>>> def increment(a):
...     return a + 1
>>> comp.add_node("b", increment)
>>> comp.compute_all()
>>> comp.write_json("comp.json")  # now succeeds

Using dill to serialize lambdas and closures

When refactoring to named functions is impractical, pass use_dill_for_functions=True to ComputationSerializer. This serializes any callable — including lambdas and closures that capture local variables — as a base64-encoded dill blob inside the JSON:

>>> s = ComputationSerializer(use_dill_for_functions=True)
>>> comp = Computation()
>>> comp.add_node("a", value=3)
>>> comp.add_node("b", lambda a: a * 2)
>>> comp.compute_all()
>>> buf = io.StringIO()
>>> comp.write_json(buf, serializer=s)
>>> _ = buf.seek(0)
>>> comp2 = Computation.read_json(buf, serializer=s)
>>> comp2.v.b
6
>>> comp2.insert("a", 10)
>>> comp2.compute_all()
>>> comp2.v.b
20

The same serializer instance must be passed to both write_json and read_json.

Warning

The dill blob embedded in the JSON is not portable across Python versions and shares the same stability caveats as the deprecated write_dill. Prefer named functions when long-term compatibility matters.

File objects and strings

Both write_json and read_json accept either a file path (string) or any text-mode file-like object:

>>> import io
>>> comp = Computation()
>>> comp.add_node("a", value=1)
>>> comp.add_node("b", increment)
>>> comp.compute_all()
>>> buf = io.StringIO()
>>> comp.write_json(buf)
>>> _ = buf.seek(0)
>>> comp3 = Computation.read_json(buf)
>>> comp3.v.b
2

PINNED nodes

Pinned nodes round-trip correctly — their PINNED state and value are preserved:

>>> comp = Computation()
>>> comp.add_node("a", value=10)
>>> comp.pin("a")
>>> comp.write_json("comp.json")
>>> comp2 = Computation.read_json("comp.json")
>>> comp2.state("a")
<States.PINNED: 6>
>>> comp2.v.a
10

ERROR nodes

If a node is in ERROR state, its exception type, message, and traceback are preserved so they can be read back for post-mortem inspection. Builtin exception types are rebuilt as themselves, so except ValueError still matches after a round-trip:

>>> def bad_func():
...     raise ValueError("something went wrong")
>>> comp = Computation()
>>> comp.add_node("result", bad_func)
>>> comp.compute_all()
>>> comp.state("result")
<States.ERROR: 5>
>>> comp.write_json("comp.json")
>>> comp2 = Computation.read_json("comp.json")
>>> comp2.state("result")
<States.ERROR: 5>
>>> comp2["result"].value.exception
ValueError('something went wrong')

A non-builtin exception class is not rebuilt: doing so would mean importing whatever module the file names, which is running code the file chose. Those come back as loman.exception.DeserializedError, carrying the original type name and module as attributes so the post-mortem still tells you what was raised:

>>> from loman.exception import DeserializedError
>>> class MyLibraryError(Exception):
...     pass
>>> def also_bad():
...     raise MyLibraryError("domain-specific failure")
>>> comp = Computation()
>>> comp.add_node("result", also_bad)
>>> comp.compute_all()
>>> comp.write_json("comp.json")
>>> exc = Computation.read_json("comp.json")["result"].value.exception
>>> isinstance(exc, DeserializedError)
True
>>> exc.exception_type
'MyLibraryError'
>>> str(exc)
'domain-specific failure'

Custom serialization for user-defined types

For types that are not handled by the default serializer, pass a custom ComputationSerializer instance with additional transformers registered:

>>> import io
>>> from loman import Computation, ComputationSerializer
>>> from loman.serialization import SimpleTransformer
>>> class Point:
...     def __init__(self, x, y):
...         self.x = x
...         self.y = y
>>> point_transformer = SimpleTransformer(
...     "point",
...     Point,
...     to_dict=lambda v: {"x": v.x, "y": v.y},
...     from_dict=lambda d: Point(d["x"], d["y"]),
... )
>>> s = ComputationSerializer()
>>> s.register(point_transformer)
>>> comp = Computation()
>>> comp.add_node("origin", value=Point(0, 0))
>>> buf = io.StringIO()
>>> comp.write_json(buf, serializer=s)
>>> _ = buf.seek(0)
>>> comp2 = Computation.read_json(buf, serializer=s)
>>> comp2.v.origin.x
0

The first argument is the discriminator written as the value's "type" field, so it must be unique within a serializer and stable across releases — changing it makes previously written files unreadable.

SimpleTransformer passes values straight to your callables without recursing. When the encoding needs to nest other transformable values inside it, or to write bytes out-of-line, subclass CustomTransformer instead:

>>> from loman.serialization import CustomTransformer
>>> class PolygonTransformer(CustomTransformer):
...     @property
...     def name(self):
...         return "polygon"
...
...     def to_dict(self, transformer, o):
...         return {"points": transformer.to_dict(o.points)}
...
...     def from_dict(self, transformer, d):
...         return Polygon(transformer.from_dict(d["points"]))
...
...     @property
...     def supported_direct_types(self):
...         return [Polygon]

Constant arguments that cannot be encoded

A constant argument given as C(...) is held on the node rather than on an edge, and the node's function cannot be called without it. So where an unencodable function is stored as null and the node merely loses the ability to recalculate, an unencodable constant raises SerializationError naming the node and the parameter, rather than writing a file that would raise TypeError from the missing argument on the first recalculation:

>>> from loman import Computation, C, ComputationSerializer
>>> comp = Computation()
>>> comp.add_node("number", value=1.2345)
>>> comp.add_node("rounded", round, kwds={"ndigits": C(object())})
>>> comp.write_json(io.StringIO())
Traceback (most recent call last):
    ...
loman.exception.SerializationError: Cannot serialize constant argument 'ndigits' on node NodeKey('rounded') ...

Note

The check only reaches a node's constants when the node's function could itself be encoded. A function that is not importable — a lambda, a closure, or anything defined interactively — is stored as null first, and its constants are then never examined. This is why the example above uses a builtin: in a module-level script your own functions are importable and behave the same way, but pasted into a REPL they are not.

The remedy is usually to register a transformer for the type, as in Custom serialization for user-defined types above, or to set serialize=False on the node.

Releases before this behaviour existed dropped such a constant silently. To keep writing files while an existing codebase is fixed, ask for the old behaviour explicitly — the constant is omitted and an UnserializableConstantWarning says so, which is a step towards fixing it rather than a setting to leave in place:

>>> serializer = ComputationSerializer(on_unserializable_constant="drop")
>>> comp.write_json(io.StringIO(), serializer=serializer)  # doctest: +SKIP
UnserializableConstantWarning: Cannot serialize constant argument 'ndigits' on node
NodeKey('rounded') ... Dropping it, because on_unserializable_constant='drop'. The
saved graph will raise TypeError from the missing argument when this node is
recalculated.

Pandas support

DataFrames and Series are serialized automatically:

>>> import pandas as pd
>>> comp = Computation()
>>> comp.add_node("df", value=pd.DataFrame({"a": [1, 2], "b": [3, 4]}))
>>> buf = io.StringIO()
>>> comp.write_json(buf)
>>> _ = buf.seek(0)
>>> comp2 = Computation.read_json(buf)
>>> comp2.v.df.shape
(2, 2)

JSON format reference

The file is a single JSON object with three top-level keys:

{
  "version": 2,
  "nodes": [ ... ],
  "edges": [ ... ]
}

Version 2 added the node args and kwds maps described below. The two versions are readable in either direction: a version 1 file loads here, its missing maps read as "no constant arguments", and a version 2 file loads under releases that predate them, which ignore the new fields. Neither direction repairs the other — a graph saved as version 1 has no record of its constants, so it must be saved again to gain them.

Node object

Each entry in nodes has:

Field Type Description
key string Node name. Hierarchical keys use / as separator.
state string | null States enum name: "UPTODATE", "STALE", "UNINITIALIZED", "ERROR", "PINNED", …
value any Encoded value (see below), or null when absent.
has_value bool true when value should be restored; false when the node has no value.
func object | null Encoded callable (see below), or null.
args object Constant positional arguments, keyed by stringified index. Empty when the node has none.
kwds object Constant keyword arguments, keyed by parameter name. Empty when the node has none.
serialize bool Whether the node carries the __serialize__ tag.
tags list[string] Non-system user tags.

A node's function arguments come from two places, and a reader needs both to call it. An argument taken from another node is recorded on an edge; an argument given as a ConstantValue (C(...)) belongs to no node, so it is held on the node itself in args and kwds. Dropping the constants leaves the function short of arguments, which is why an unencodable one raises rather than being skipped — see Constant arguments that cannot be encoded.

Edge object

Each entry in edges has:

Field Type Description
src string Source node key.
dst string Destination node key.
param_type "arg" | "kwd" | null How the value is passed to the function.
param int | string | null Positional index for "arg", parameter name for "kwd".

Value encoding

Plain Python scalars (int, float, str, bool, None) are stored as-is. Compound types use a tagged object with a "type" discriminator:

NumPy array

{
  "type": "ndarray",
  "shape": [3],
  "dtype": "<f8",
  "data": [1.0, 2.0, 3.0]
}

Pandas DataFrame (split orientation, column dtypes preserved)

{
  "type": "dataframe",
  "columns": ["x", "y"],
  "index": [0, 1],
  "data": [[1.0, 3.0], [2.0, 4.0]],
  "dtypes": {"x": "int64", "y": "float64"}
}

ERROR node value (exception preserved as strings for post-mortem)

{
  "__loman_error__": true,
  "exception_type": "ValueError",
  "exception_str": "something went wrong",
  "traceback": "Traceback (most recent call last):\n  ..."
}

Function encoding

Importable module-level function (default)

{
  "type": "func_ref",
  "module": "mypackage.calcs",
  "qualname": "compute_result"
}

Lambda or closure (only when use_dill_for_functions=True)

{
  "type": "dill_func",
  "blob": "gASVyQAAAAAAAACMCmRpbGwuX2RpbGyU..."
}

The blob field is a base64-encoded dill byte string. It is not portable across Python versions.

How durable is a saved computation?

The blanket answer used to be "not for long-term storage". That is no longer accurate, and the honest answer splits in two.

Values are durable. The format carries a version, changes to it are additive, and files written by every earlier version are held as fixtures in the test suite and asserted to still load and still recompute. Arrays and frames are stored as .npy or parquet, both of which outlive this library. A file written today is expected to keep loading.

Functions are only as durable as your code. A node's function is stored as a module path and a qualified name, so it resolves only while that module still exists and still exports that name — rename it, move it, or uninstall the package and the value still loads but the node can no longer be recalculated. A function stored via use_dill_for_functions=True is worse: dill blobs are not portable across Python versions.

So a saved computation is a durable record of what was computed, and a best-effort record of how. For an archive you expect to read years later, prefer named functions in a stable module, and consider that being able to recalculate may not survive a refactor even when the numbers do.

Two further caveats that are not about time:

  • The file is not safe to load from an untrusted source — see Loading untrusted files.
  • pandas 2 and pandas 3 differ in default datetime resolution. Each value's resolution is recorded, so a file written under one loads correctly under the other; loman requires pandas 2.0 or later precisely because that recording depends on APIs introduced there.