fnloom Plan 4: result caching¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Result caching that never returns a wrong value for a pure node, costs a hash and a lookup when it hits, and loads only what a run needs. It comes with an in-memory store and an on-disk store, per-node policies, and a way to explain every outcome.
Architecture: Caching is a layer in the Python front end and the PyO3 binding; the core does not change. At build, the front end computes each node's code version from code objects and folds it with the node's identity into a static key part. Per run:
- Inputs and overrides are fingerprinted, in Rust for the built-in types.
- Keys chain down the plan (Merkle style).
recomputenodes run first, so their results can be fingerprinted. - A backward walk from the outputs finds the hits.
- The run goes through the existing core with the hits as overrides.
- A per-node value callback stores what ran.
Tech Stack: as Plan 2, plus the blake3 crate in fnloom-py. hypothesis joins the dev group for the property
test. numpy and pandas, already in the dev environment through Hamilton, are used only when the user's values are
of their types.
Spec: docs/design/2026-10-27-caching-design.md, approved by the maintainer on 2026-10-27; section numbers below
are its own. The main design is docs/design/2026-10-26-fnloom-design.md. Plan 2 is
docs/plans/2026-10-27-plan-2-decorators-inspection-type-checks.md, and its "Plan 3" (config, extract_*, async)
is still unwritten. This plan does not depend on it, so it is Plan 4.
Out of this plan: the observer hook that carries each node's cache outcome (spec §6 leaves its shape to the hooks
plan); explain_cache reports the same outcomes now. Remote stores (spec §2).
Base: main after PR #4 (Plan 2), whose Plan::run_by_id, renames and plan cache this plan uses.
Global Constraints¶
- Every Global Constraint of Plans 1 and 2 applies unchanged: licence, naming, Python 3.14 and 3.14t, ty strict,
ruff ALL, Clippy pedantic,
#![deny(unsafe_code)]in the binding, one commit per task,scripts/check.shgreen on every commit, noreply author with aCo-Authored-By:trailer, nothing consumer-specific, no Hamilton code copied. - No new runtime Python dependency.
python/fnloomnever imports numpy, pandas or polars. It recognises their values bytype(value).__module__and uses the modules only when the value's module is already insys.modules. - The core crate does not change (spec §8). New Rust code lives in
crates/fnloom-py. - A hit is never wrong (spec §2, goal 1). When in doubt, a node runs and the reason is recorded, never guessed.
- The cache never fails a run (spec §2, anti-goals). A store error, a corrupt entry, an unpicklable result or a
full disk is a miss or a skipped write, reported through
warnings.warnwithfnloom.CacheWarning. A node's own exception still fails the run, as without the cache. - Nothing per run depends only on names (Q3, Q16). Code versions, static key parts, policies and the key plan are made once, at build or per prepared plan.
- Every difference from Hamilton is a quirk-register row with a
deliberate("Q<n>")test. New rows start at Q46.
Review Focus¶
- Float edge values and array layouts.
-0.0and0.0are different fingerprints, and every NaN is the same. A non-contiguous numpy view fingerprints like its C-ordered copy. A pandasobjectcolumn is fingerprinted element by element. Task 1 owns the tests. - A node that raises. Nothing is stored for it, and the next run tries it again. A store holding entries from a successful run is not touched by a failed one. Task 6.
- Overrides beside hits. A user override is still type-checked, and so is an override of a node that would hit; a hit's value is not checked again. The override's fingerprint is its version downstream. Task 6.
- Store failures. If
DiskCachecannot write (read-only directory, unpicklable value), the run returns the right result and warns once per node per run. Task 4 tests the store, Task 6 the run. - Threads sharing one driver and one store. Two threads running the same prepared plan with the cache on both get correct results, and the store holds one whole entry per key. Task 6, also on 3.14t in CI.
Task 1: Fingerprints¶
Files:
- Create:
crates/fnloom-py/src/fingerprint.rs,python/fnloom/_fingerprint.py,tests/test_fingerprint.py - Modify:
crates/fnloom-py/Cargo.toml(blake3 = "1"),crates/fnloom-py/src/lib.rs,python/fnloom/_core.pyi,python/fnloom/__init__.py
Interfaces:
-
Produces:
_core.fingerprint(value: object, fallback: Callable[[object], object | None]) -> bytes, 32 bytes._core.hash_parts(parts: Sequence[bytes]) -> bytes, a 32-byte BLAKE3 of length-prefixed parts.fnloom.fingerprint(value: object) -> bytes.fnloom.register_fingerprint[T](cls: type[T], fn: Callable[[T], object]) -> None.fnloom.UnfingerprintableError(TypeError), with attributespath: str(like"[3].model", empty at the top) andtype_name: str, created in Rust withcreate_exception!and re-exported.
-
[ ] Step 1: Write the failing tests in
tests/test_fingerprint.py:test_type_is_part_of_the_fingerprint: the 12 values1, 1.0, True, "1", b"1", bytearray(b"1"), (1,), [1], {1}, frozenset({1}), {1: 1}, {"1": 1}have 12 distinct fingerprints.test_equal_values_share_a_fingerprint:fingerprint([1, "a", (2.5, None)])equals itself rebuilt.fingerprint({2, 1}) == fingerprint({1, 2}). A fingerprint taken in a subprocess withPYTHONHASHSEED=1equals one taken with seed 2.test_float_bits:fingerprint(0.0) != fingerprint(-0.0), andfingerprint(float("nan")) == fingerprint(-float("nan")).test_dict_order_counts:fingerprint({"a": 1, "b": 2}) != fingerprint({"b": 2, "a": 1}).test_stdlib_values:datetime(2026, 1, 1, tzinfo=UTC)differs from the naive one;Decimal("1.0") != Decimal("1.00");PurePosixPath("a") != PureWindowsPath("a");- an
Enummember differs from its value; - a frozen dataclass
P(1)differs fromQ(1)with equal fields; - a named tuple differs from the plain tuple.
test_unknown_type_is_refused_at_any_depth:fingerprint([1, {"k": object()}])raisesUnfingerprintableErrorwithpath == '[1]["k"]'andtype_name == "object".test_protocol_and_registry: a class with__fnloom_fingerprint__returning(self.x,)fingerprints byx. Afterregister_fingerprint(Foreign, lambda f: f.v),Foreigninstances fingerprint byv, and so do subclass instances. A more specific registration for a subclass wins.test_numpy_arrays(pytest.importorskip("numpy")):zeros((3, 2))differs fromzeros((2, 3))and fromzeros((3, 2), dtype="float32");a[:, ::2]equalsnp.ascontiguousarray(a[:, ::2]);- an
objectarray of[object()]raisesUnfingerprintableError.
test_pandas_frames(pytest.importorskip("pandas")):DataFrame({"x": [1, 2]})differs from{"y": [1, 2]}and from the same frame with another index;- a Series differs from the same Series with a
name; - an
objectcolumn of strings fingerprints, and one holdingobject()raisesUnfingerprintableError.
test_polars_frames(pytest.importorskip("polars"), so it skips here and runs where polars is installed): frames that differ only in column names differ.test_python_never_imports_numpy: in a subprocess,import fnloom; fnloom.fingerprint([1])leavesnumpyout ofsys.modules.
-
[ ] Step 2: Run them:
UV_NO_SYNC=1 uv run pytest tests/test_fingerprint.py -q. Expected: fail withImportError: cannot import name 'fingerprint'. -
[ ] Step 3: Implement
fingerprintincrates/fnloom-py/src/fingerprint.rs. It walks the value. It checks exact types (is_exact_instance_of, soboolis notintand a named tuple is not atuple), handlesNone,bool,int(by its signed little-endian bytes),float(by bit pattern, with NaN canonicalised),complex,str(UTF-8),bytes,bytearray,tuple,list,dict(in order),setandfrozenset(sorted element digests), and any object exposing a C-contiguous buffer (by format, shape and bytes). For anything else it callsfallback(value):Noneback means "cannot be fingerprinted", which raisesUnfingerprintableErrorwith the path so far.- Any other return is a canonical form, and the walk fingerprints it in turn under a tag holding the value type's qualified name.
Each node of the walk hashes a one-byte kind tag, then its parts, length-prefixed.
hash_partsuses the same framing. -
[ ] Step 4: Implement
_fingerprint.py:fallback(value) -> object | None, the registry (register_fingerprint, most specific class through the MRO), and the publicfingerprint(value), which calls_core.fingerprint(value, fallback). The canonical forms are:datetime,date,timeandtimedeltaas(isoformat, tzname);Decimalasas_tuple();Fractionas(numerator, denominator);UUIDasbytes;PurePathas(type(value).__name__, parts); an enum member as(qualified name, value);- dataclasses as their field tuple; named tuples as
tuple(value); - a numpy
ndarrayas(dtype.str, shape, np.ascontiguousarray(value)), or, for theobjectdtype,(shape, list(value.flat)); - a pandas
DataFrameas(list(columns), [dtype.str per column], index, [column.to_numpy() per column]), aSeriesas(name, dtype.str, index, to_numpy())and anIndexas(name, to_numpy()); - a polars
DataFrameas(columns, [str(dtype) per column], [column.to_numpy() per column]), and aSeriesas(name, str(dtype), to_numpy()); - then
__fnloom_fingerprint__()and the registry.
Detect numpy and pandas values by
type(value).__module__.split(".")[0]and read the modules fromsys.modulesonly. -
[ ] Step 5: Run the tests: same command. Expected: pass. Then
bash scripts/check.sh: exit 0. -
[ ] Step 6: Commit
python: typed fingerprints for cache keys.
Task 2: Code versions¶
Files:
- Create:
python/fnloom/_codeversion.py,tests/test_codeversion.py
Interfaces:
-
Consumes:
fnloom.fingerprint,UnfingerprintableError,_core.hash_parts(Task 1). -
Produces:
code_version(fn: Callable[..., object]) -> bytes, 32 bytes.UncacheableError(Exception), withreason: str(for example"global 'RATES' of 'price' is a dict, which may change; give @cache(version=...)").is_user_module(module: ModuleType) -> bool.distribution_version(module_name: str) -> str | None.
-
[ ] Step 1: Write the failing tests in
tests/test_codeversion.py. Each builds modules withhelpers.make_module, takescode_versionof a function, rebuilds the module with one edit, and compares:test_editing_the_body_changes_itchanges it;test_formatting_comments_and_docstrings_do_notdoes not.test_a_helper_it_calls_counts:outcallshelperin the same module, then callshelpers.fin another user module. Editing either helper changesout's version. Mutual recursion between two helpers terminates.test_constants_and_defaults_count: changing a module-levelK = 1it reads, a defaultx: int = 1, a keyword-only default or a closure cell's value changes it.test_library_versions_count: a function callingjson.dumpshashes("json", <python version>). One calling a function of an installed distribution hashes that distribution's version. Patchdistribution_versionto check that a different version gives a different code version.test_mutable_globals_are_refused: reading a module-levellist,dict,setor plain object raisesUncacheableError, whosereasonnames the global and the function.test_nested_code_counts: editing a lambda or a comprehension inside the function changes it.test_a_user_class_counts_through_its_methods: a function constructing a user classModelchanges version when a method ofModelis edited.test_stable_across_processes: the same source gives the same version in two subprocesses with differentPYTHONHASHSEED.
-
[ ] Step 2: Run them:
UV_NO_SYNC=1 uv run pytest tests/test_codeversion.py -q. Expected: fail on import. -
[ ] Step 3: Implement
_codeversion.pyper spec §4.- Hash the code object recursively:
co_code,co_consts(nested code objects recursed, other constants fingerprinted),co_names,co_varnamesandco_freevars, without line tables, file names or docstrings (drop a leadingstrconstant when it is the docstring). - Then hash
__defaults__,__kwdefaults__, closure cells and each name inco_namesresolved in__globals__, following the rules of §4. is_user_moduleis true when the module has a__file__outside everysysconfig.get_paths()path (stdlib,platstdlib,purelib,platlib) and is not insys.builtin_module_names.distribution_versionmaps a top-level module to its distribution throughimportlib.metadata.packages_distributions(), read once per process. It returnsNonefor the standard library, which is versioned bysys.versioninstead.- Memoise per function object within one
code_versioncall, and cut cycles with a placeholder for a function whose version is in progress.
- Hash the code object recursively:
-
[ ] Step 4: Run the tests, then
bash scripts/check.sh. Expected: pass, exit 0. -
[ ] Step 5: Commit
python: code versions from code objects, helpers and constants.
Task 3: The @cache decorator¶
Files:
- Modify:
python/fnloom/_decorators.py(addcache, and aCachePolicyfield onDecorations),python/fnloom/__init__.py - Test:
tests/test_decorators.py
Interfaces:
-
Produces:
fnloom.cache[F](*, behavior: Literal["default", "recompute", "disable"] = "default", ignore: Iterable[str] = (), version: str | None = None) -> Callable[[F], F].CachePolicy(a frozen dataclass):behavior,ignore: frozenset[str],version: str | None.Decorations.cache: CachePolicy | None, which isNonewhen the decorator is not used.
-
[ ] Step 1: Write the failing tests:
test_cache_records_a_policy_and_returns_the_function:cache(behavior="recompute")(f) is f, anddecorations(f).cache == CachePolicy("recompute", frozenset(), None).test_cache_composes_with_parameterize_and_tag_in_either_order.test_cache_errors, each listing every culprit, sorted:- an unknown behavior is a
ValueErrornaming the three allowed values; - an
ignorename that is not a parameter is aValueError; - applying
@cachetwice is aValueError; version="", or a non-strversion, is aTypeError.
- an unknown behavior is a
-
[ ] Step 2: Run them:
UV_NO_SYNC=1 uv run pytest tests/test_decorators.py -q -k cache. Expected: fail. -
[ ] Step 3: Implement it. Store the metadata only. Check
ignoreagainst the signature asparameterizedoes, reading names without evaluating annotations. -
[ ] Step 4: Run the tests, then
bash scripts/check.sh. Expected: pass. -
[ ] Step 5: Commit
python: cache decorator recording a per-node policy.
Task 4: Stores¶
Files:
- Create:
python/fnloom/cache.py(public),tests/test_stores.py- As built: the module is the private
python/fnloom/_stores.py, every store name re-exported fromfnloom, sincefnloom.cacheis the decorator of Task 3 and a module of the same name would shadow it.
- As built: the module is the private
- Modify:
python/fnloom/__init__.py
Interfaces:
-
Produces, all re-exported from
fnloom:class CacheStore(Protocol), withget(key: bytes) -> object(returningMISSINGon a miss),put(key: bytes, value: object) -> Noneanddiscard(key: bytes) -> None.MISSING: Final, a unique sentinel.class CacheWarning(UserWarning).class MemoryCache(max_entries: int = 100_000, max_bytes: int | None = None).class DiskCache(path: str | os.PathLike[str], *, max_bytes: int | None = None, dumps: Callable[[object], bytes] = pickle.dumps, loads: Callable[[bytes], object] = pickle.loads, serializer: str = "pickle"), withclear() -> Noneandprune(max_bytes: int) -> None.
-
[ ] Step 1: Write the failing tests:
test_memory_round_trip_and_identity:put(k, v)thenget(k) is v, and an unknown key givesMISSING.test_memory_evicts_least_recently_used: withmax_entries=2, put a, put b, get a, put c. Then b isMISSING. Withmax_bytesset to just over two entries'sys.getsizeof, a third entry evicts the least recently used.test_memory_is_thread_safe: 8 threads eachput/get10,000 keys with no error or lost entries below the bound.test_disk_round_trip_and_layout:putwrites exactly<path>/v1/<hex[:2]>/<hex>, andgetreturns an equal object.test_disk_write_is_atomic: adumpsthat raises halfway leaves no file and no temporary file. Two processes writing one key leave one whole entry.test_disk_bad_entries_are_misses: a truncated file, random bytes, a header naming another Python version, and a header naming another serializer each giveMISSING. Each removes the entry and warnsCacheWarningonce.test_disk_unwritable_is_a_warning: a read-only directory or an unpicklable value makesputwarnCacheWarningand return.test_disk_prune: after 10 entries of known size,prune(max_bytes)keeps the most recently read ones within the bound. ADiskCache(max_bytes=...)prunes when it opens and after every 100 writes.
-
[ ] Step 2: Run them:
UV_NO_SYNC=1 uv run pytest tests/test_stores.py -q. Expected: fail on import. -
[ ] Step 3: Implement the stores per spec §7.
MemoryCache: anOrderedDictunder onethreading.Lock, held only for the dict operations.DiskCachefile format: the magic bytesb"FNLC", au16format version (1), then a length-prefixed UTF-8 JSON header (serializer,python,libraries: the value's type's top-level module mapped todistribution_version), then the payload.- Writes go to
tempfile.NamedTemporaryFile(dir=<shard>, delete=False), thenos.replace. - A
getupdates the file's access time withos.utime.
-
[ ] Step 4: Run the tests, then
bash scripts/check.sh. Expected: pass. -
[ ] Step 5: Commit
python: memory and disk cache stores.
Task 5: Binding support: key plans and the value callback¶
Files:
- Create:
crates/fnloom-py/src/keys.rs - Modify:
crates/fnloom-py/src/plan.rs,crates/fnloom-py/src/lib.rs,python/fnloom/_core.pyi - Test:
tests/test_binding.py
Interfaces:
-
Consumes: the framing of
_core.hash_parts(Task 1). -
Produces:
_core.KeyPlan(nodes: list[tuple[str, bytes | None, list[tuple[str, str | None, bytes | None]]]]). Each entry is one computed node, in plan order: its name, its static key part (Nonewhen the node is uncacheable), and its key parameters in declaration order. A key parameter is its name and then either the name of the node or input it reads, or the version of a constant. Ignored parameters are left out.KeyPlan.keys(versions: dict[str, bytes | None]) -> dict[str, bytes | None].versionsholds the version of every input, override and pre-runrecomputenode by name.- A computed node's key is
hash_parts([static, *for each param: name, version]). - The version of a source that is absent from both
versionsand the computed nodes is the fixed markerb"\x00absent". - A key is
Nonewhen the static part, or any version it reads, isNone.
- A computed node's key is
Plan.run(inputs, overrides, observers=(), on_value: Callable[[str, object], None] | None = None). The callback is called with each computed node's name and value right after that node succeeds, before downstream nodes run. An exception from it propagates like an observer's.
-
[ ] Step 1: Write the failing tests in
tests/test_binding.py:test_key_plan_chains_versions: a three-node chain's keys change when the input version changes. A node reading an input with versionNonegetsNone, and so does everything downstream of it. Equal inputs give equal keys across twoKeyPlanobjects.test_key_plan_marks_absent_optional_inputs: the key with the optional input absent differs from the key with it supplied as the fingerprint ofNone.test_on_value_sees_each_computed_node_once_in_order: with the observers' order, and never an input or override.
-
[ ] Step 2: Run them:
UV_NO_SYNC=1 uv run pytest tests/test_binding.py -q -k "key_plan or on_value". Expected: fail. -
[ ] Step 3: Implement
KeyPlaninkeys.rs(a frozen pyclass holding interned names and byte vectors). Addon_valuetoCaller, called afterfunction.call. -
[ ] Step 4: Run the tests, then
bash scripts/check.sh. Expected: pass, Clippy clean. -
[ ] Step 5: Commit
binding: key plans and a per-node value callback.
Task 6: Cached runs¶
Files:
- Create:
python/fnloom/_caching.py,tests/test_caching.py - Modify:
python/fnloom/driver.py(Builder.with_cache,Driver,PreparedPlan.run),python/fnloom/_scan.py(static key parts at build)
Interfaces:
-
Consumes: Tasks 1 to 5.
-
Produces:
Builder.with_cache(store: CacheStore) -> Builder._caching.NodeKey, a frozen dataclass ofstatic: bytes | None,reason: str | None,behavior: strandparams: tuple[tuple[str, str | None, bytes | None], ...], made once per node at build._caching.CacheRun.run(inputs, overrides) -> dict[str, object], whichPreparedPlan.runcalls when the driver has a store.
-
[ ] Step 1: Write the failing tests in
tests/test_caching.py. Use aCountingCache(MemoryCache)that recordsgetandput, and nodes that append to a call log.test_second_run_hits_and_calls_nothing: chaina -> b -> c; run twice with equal inputs. The second run calls no node and returns the same result.test_a_hit_on_the_output_loads_only_the_output: the second run makes exactly onegetthat returns a value, forc.test_changed_input_reruns_only_its_downstream: a diamond with inputsxandy. Changingyreruns only the nodes downstream ofy.test_two_nodes_with_equal_values_never_share_entries(F1):a() -> 1andb() -> "1"keep their own types across runs.test_recompute_runs_every_time_and_keys_downstream_by_result(F5): arecomputeloader reads a file through a path input. Editing the file reruns the loader and its downstream. Leaving it unchanged reruns the loader only.test_disable_never_stores_and_reruns_downstream: noputfor the node, and its consumers run every time, unless the consumer has@cache(ignore=("client",)). Then the consumer hits.test_uncacheable_inputs_and_globals_run: an input of an unknown type, or a node reading a mutable global, runs every time and returns the right value.test_overrides: an override is type-checked as before, its fingerprint keys its downstream, and a hit is not type-checked again.test_a_failing_node_stores_nothing: the next run calls it again (Review Focus 2).test_store_failures_never_fail_the_run: with aDiskCachein a read-only directory, the run returns the right value and warns (Review Focus 4).test_threads_share_a_plan_and_a_store: 8 threads, 200 runs each, over shuffled inputs, all equal to an uncached driver's results (Review Focus 5).test_rows_and_value_constants_are_keyed: two@parameterizerows of one function never share entries. Changing avalue()constant misses.
-
[ ] Step 2: Run them:
UV_NO_SYNC=1 uv run pytest tests/test_caching.py -q. Expected: fail. -
[ ] Step 3: Static key parts at build. In
Builder.build, when a store is set, give each node itsNodeKey.staticishash_parts([b"fnloom-cache-1", name, module, qualname, code version, behavior]).code versionisversion.encode()when the policy gives one.- The
paramsare the node's parameters minus the ignored ones. AValuebinding enters as the fingerprint of its constant. - An
UncacheableErrororUnfingerprintableErrorsetsstatic=Noneandreason.disablesetsstatic=Nonewith the reason"policy disable".
-
[ ] Step 4: Implement
CacheRun(spec §3), one perPreparedPlan, holding aKeyPlanover the plan's computed nodes and an LRU of core plans keyed by the hit set.run:- Run the user-facing checks as
PreparedPlan.rundoes now. - Fingerprint every input and override the plan reads. An
UnfingerprintableErrorgives versionNone. - If the plan contains
recomputenodes, run a nestedCacheRunwhose outputs are those nodes. Its results become overrides of this run, with their fingerprints as their versions. - Compute
keys = key_plan.keys(versions). - Walk back from the outputs. A node with a key and the
defaultbehavior whosestore.get(key)is notMISSINGis a hit, and the walk stops there. Any other computed node is needed, and the walk continues into its sources. - Prepare the core plan for (outputs, input names, user overrides plus hits plus recompute nodes), cached by that
set, and run it with the hit values as extra overrides and
on_valueset to a function thatputs each value whose key is notNoneand whose behavior is notdisable. - Return the outputs in requested order.
Store errors in
getandputare caught, warned asCacheWarningand treated as a miss or a skipped write. - Run the user-facing checks as
-
[ ] Step 5: Run the tests, then the whole suite and
bash scripts/check.sh. Expected: pass. A driver withoutwith_cacheis unchanged, and its per-run benchmark numbers stay within noise. -
[ ] Step 6: Commit
python: cached runs with keys, hit frontiers and policies.
Task 7: explain_cache¶
Files:
- Modify:
python/fnloom/_caching.py,python/fnloom/driver.py,python/fnloom/__init__.py - Test:
tests/test_caching.py
Interfaces:
-
Produces:
fnloom.CacheOutcome, aNamedTupleofstatus: Literal["hit", "miss", "uncacheable", "unknown"]andreason: str | None.Driver.explain_cache(final_vars, *, inputs=None, overrides=None) -> dict[str, CacheOutcome]. It covers every computed node of the plan, in name order, and runs nothing. A driver without a store raisesValueError.
-
[ ] Step 1: Write the failing tests:
test_explain_names_every_outcome_and_runs_nothing. Over one graph, the call log stays empty and the outcomes are:hitafter a run;miss(reasonNone) before it;uncacheablewith the reason naming the mutable global, the value path (items[0]: Model) orpolicy disable;unknownwith the reason"downstream of recompute node 'load'".
test_explain_without_a_store_is_a_value_error.
-
[ ] Step 2: Run them, expecting a failure. Step 3: implement it from steps 2, 4 and 5 of
CacheRun.run, without the nested recompute run. Step 4: run the tests andbash scripts/check.sh. -
[ ] Step 5: Commit
python: explain_cache.
Task 8: Register rows and the property test¶
Files:
- Modify:
docs/quirk-register.md,tests/differential/test_deliberate_differences.py,pyproject.toml(dev group:hypothesis>=6.150) - Create:
tests/test_caching_property.py
Interfaces:
-
Consumes: everything above. On Hamilton's side,
hamilton.driver.Builder().with_cache(path=tmp_path). -
[ ] Step 1: Write the
deliberatetests, each red against Hamilton 1.90.0 and green against fnloom, as in Plan 2:- Q46 (F1, F2):
a() -> 1,b() -> "1", run twice. Hamilton's second run givesb == 1; fnloom's gives"1". - Q47 (F3): an input list holding a
__slots__object whose field changes. Hamilton hits stale; fnloom reruns. - Q48 (F4): a node calling a helper that is edited between builds. Hamilton hits stale; fnloom reruns.
- Q49 (F6): a node returning a counter value, run default, then recompute, then default. Hamilton's third run returns the first value; fnloom's returns the second.
- Q50 (F10, F11): Hamilton's
ignoreon a producer of athreading.LockraisesTypeErroron pickling. fnloom'sdisableproducer with anignoreconsumer runs and hits. - Q51 (F12): a truncated stored entry makes Hamilton's run raise; fnloom's recomputes and warns.
- Q16: move it from DEFER to FIX, with text saying that code versions are made once at build.
- Q46 (F1, F2):
-
[ ] Step 2: Add the register rows, in the register's format, with fnloom's behaviour and the fault it fixes.
-
[ ] Step 3: Write
test_caching_property.py(spec §10). With hypothesis, generate graphs of 1 to 12 pure nodes, each a fixed arithmetic or string function of its parameters, and inputs overint,float,str,tupleandNone. Then run a sequence of runs with random input edits and randomvalue()and module-constant edits (rebuilding where code changes). Assert that every run with aMemoryCacheand every run with aDiskCacheover atmp_pathequals the run of an uncached driver. Use 200 examples, withdeadline=None. -
[ ] Step 4: Run
UV_NO_SYNC=1 uv run pytest tests/differential tests/test_caching_property.py -q, thenbash scripts/check.sh. Expected: pass. -
[ ] Step 5: Commit
tests: caching register rows and the equality property.
Task 9: Benchmarks and documentation¶
Files:
-
Create:
benches/cache.py,docs/caching.md -
Modify:
docs/benchmarks.md,zensical.toml(nav),CHANGELOG.md,docs/from-hamilton.md(caching leaves "Not there yet");docs/design/2026-10-26-fnloom-design.md: caching leaves the §2 non-goals, and a change-log line points to the caching design;docs/design/2026-10-27-caching-design.md: status becomes "approved; built in Plan 4".
-
[ ] Step 1: Write
benches/cache.py: a 200-node chain of trivial nodes and a 1,000-node layered graph. It times, as medians over fresh processes:- uncached;
- cold and warm with
MemoryCache; - cold and warm with
DiskCachein a temporary directory; - Hamilton 1.90.0 with
with_cache, cold and warm.
It prints JSON lines like
benches.run. -
[ ] Step 2: Run it on a release build and check the spec §8 targets:
- at most 1 µs of overhead per memory hit;
- a warm 200-node chain at most about twice the uncached run;
- a cold run at most 1.5 times uncached with
MemoryCache.
A target that is missed is fixed before the task ends, or becomes an issue to open, named in the PR.
-
[ ] Step 3: Write
docs/caching.md:- how to turn caching on;
- the purity contract, with
recomputestated first; - the policies,
ignoreandversion=; - what can be fingerprinted, and how to register more;
explain_cache;- the stores, the disk layout, and the pickle warning;
- the known limit on rebound globals.
Every Python block in it runs under
tests/test_docs.py. -
[ ] Step 4: Add a "Caching" section to
docs/benchmarks.mdwith the measured table, and the other edits listed under Files. Runbash scripts/check.sh. Expected: exit 0. -
[ ] Step 5: Commit
docs: caching guide and benchmarks.