Cache keys for runs: hash of the non-default config fields without duration, energy-report cadence and output naming, with MODEL_VERSION; cymsim cache-key
6 changed files+183−9
README.mdmodified+3−1View file
@@ -22,6 +22,7 @@ cymsim run my_run.yaml -o run.cymsim --save-every 60 # also write a .cymsim file
2222 cymsim run my_run.yaml -o run.cymsim --record-acceleration # include the audio-rate normal acceleration of every node
2323 cymsim run my_run.yaml --resume run.cymsim --duration 4 -o run4.cymsim # continue a saved run to 4 s
2424 cymsim info run.cymsim # summarise a .cymsim file
25+cymsim cache-key my_run.yaml --show # the run's cache key (independent of the duration) and what it hashes
2526 ```
2627
2728 A `.cymsim` file is an HDF5 file holding the configuration, the mesh, the listener signals, displacement frames at a low rate, optionally the normal acceleration of every node at the audio rate, and the integrator state; the layout is specified in [docs/format.md](docs/format.md). It is meant for taking the shell's motion into other programs (other listeners, rooms, acoustic solvers) and is the file the web companion cymsimweb saves and loads. A partial file written during a run is a consistent prefix of the complete one.
@@ -64,8 +65,9 @@ A run is described by one `SimulationConfig` made of `Material`, `Profile`, `Imp
6465 | [decimation.py](src/cymsim/decimation.py) | the anti-aliasing kernel that takes accelerations and pressure from the sub-step rate to the audio rate |
6566 | [simulate.py](src/cymsim/simulate.py) | leapfrog time loop, block compilation, acceleration and displacement records, result container |
6667 | [cymsim_file.py](src/cymsim/cymsim_file.py) | the .cymsim HDF5 file: streaming writer, loader, summary |
68+| [cache.py](src/cymsim/cache.py) | cache keys: hash of the non-default config fields (without duration and output naming) and `MODEL_VERSION` |
6769 | [diagnostics.py](src/cymsim/diagnostics.py) | band envelopes, onset times, spectral centroid, cascade plot |
68-| [cli.py](src/cymsim/cli.py) | `cymsim simulate` (`run`), `cymsim check`, `cymsim info`, `cymsim example-config` |
70+| [cli.py](src/cymsim/cli.py) | `cymsim simulate` (`run`), `cymsim check`, `cymsim info`, `cymsim cache-key`, `cymsim example-config` |
6971
7072 `prototype.py` is the original single-file prototype that this package reorganizes; it is kept for reference and is not used by the package.
7173
src/cymsim/__init__.pymodified+2−1View file
@@ -36,6 +36,7 @@ from .config import ( # noqa: E402
3636 SimulationConfig,
3737 StickExciter,
3838 )
39+from .cache import MODEL_VERSION, cache_key # noqa: E402
3940 from .cymsim_file import describe, load_cymsim, save_cymsim # noqa: E402
4041 from .simulate import ( # noqa: E402
4142 AccelerationRecord,
@@ -51,5 +52,5 @@ __all__ = [
5152 "Hit", "Imperfections", "Listener", "MalletExciter", "Material", "MeshConfig", "Numerics",
5253 "Output", "Profile", "SimulationConfig", "StickExciter",
5354 "AccelerationRecord", "DisplacementRecord", "SimulationResult", "Simulator", "simulate",
54- "describe", "load_cymsim", "save_cymsim",
55+ "describe", "load_cymsim", "save_cymsim", "MODEL_VERSION", "cache_key",
5556 ]
src/cymsim/cache.pyadded+54−0View file
@@ -0,0 +1,54 @@
1+"""Cache keys for runs.
2+
3+A cached run is identified by its configuration without CONTINUATION_FIELDS (the duration, the
4+energy-report cadence and the output naming), so that one cache entry serves every duration: a
5+shorter request is cut from it, a longer one continues it (Simulator.run(resume=...)).
6+
7+The key hashes only the fields that differ from their defaults, together with MODEL_VERSION. A
8+new config field whose default reproduces the old behaviour therefore leaves every existing key
9+unchanged. Bump MODEL_VERSION by hand whenever the results of some existing configuration change:
10+a change to the numerics, the mesher, the radiation model, or the value of a default. The test
11+suite pins a fingerprint of the defaults as a reminder.
12+"""
13+from __future__ import annotations
14+
15+import dataclasses
16+import hashlib
17+import json
18+
19+from .config import CONTINUATION_FIELDS, SimulationConfig
20+
21+MODEL_VERSION = 1
22+
23+
24+def _non_default(obj):
25+ """Plain data holding the fields of a config dataclass that differ from that class's defaults
26+ (recursively). An exciter keeps its ``kind`` so that two exciters at their defaults differ."""
27+ if dataclasses.is_dataclass(obj):
28+ default = type(obj)()
29+ out = {}
30+ for f in dataclasses.fields(obj):
31+ v, d = getattr(obj, f.name), getattr(default, f.name)
32+ if f.name == "kind":
33+ out[f.name] = v
34+ elif v != d:
35+ out[f.name] = _non_default(v)
36+ return out
37+ if isinstance(obj, (list, tuple)):
38+ return [_non_default(v) for v in obj]
39+ return obj
40+
41+
42+def key_fields(cfg: SimulationConfig) -> dict:
43+ """The part of the configuration that identifies a cached run."""
44+ d = _non_default(cfg)
45+ for section, name in CONTINUATION_FIELDS:
46+ d.get(section, {}).pop(name, None)
47+ return {k: v for k, v in d.items() if v != {}}
48+
49+
50+def cache_key(cfg: SimulationConfig) -> str:
51+ """``v<MODEL_VERSION>/<sha256>`` of the identifying fields."""
52+ text = json.dumps({"model_version": MODEL_VERSION, "config": key_fields(cfg)}, sort_keys=True,
53+ separators=(",", ":"))
54+ return f"v{MODEL_VERSION}/{hashlib.sha256(text.encode()).hexdigest()}"
src/cymsim/cli.pymodified+22−2View file
@@ -1,5 +1,5 @@
1-"""Command line interface: ``cymsim simulate`` (alias ``run``), ``cymsim check``, ``cymsim info`` and
2-``cymsim example-config``."""
1+"""Command line interface: ``cymsim simulate`` (alias ``run``), ``cymsim check``, ``cymsim info``,
2+``cymsim cache-key`` and ``cymsim example-config``."""
33 from __future__ import annotations
44
55 import argparse
@@ -83,6 +83,18 @@ def cmd_info(args) -> int:
8383 return 0
8484
8585
86+def cmd_cache_key(args) -> int:
87+ import json
88+
89+ from .cache import cache_key, key_fields
90+
91+ cfg = _load(args.config, args)
92+ print(cache_key(cfg))
93+ if args.show:
94+ print(json.dumps(key_fields(cfg), indent=2, sort_keys=True))
95+ return 0
96+
97+
8698 def cmd_example_config(args) -> int:
8799 cfg = default_config(args.exciter)
88100 if args.path:
@@ -124,6 +136,14 @@ def main(argv=None) -> int:
124136 p.add_argument("--quiet", action="store_true")
125137 p.set_defaults(func=cmd_info)
126138
139+ p = sub.add_parser("cache-key", help="print the cache key of a config (independent of the duration)")
140+ p.add_argument("config")
141+ p.add_argument("--show", action="store_true", help="also print the fields that the key hashes")
142+ p.add_argument("--linear", action="store_true", help="the key of the linear reference run")
143+ p.add_argument("--record-acceleration", action="store_true")
144+ p.add_argument("--quiet", action="store_true")
145+ p.set_defaults(func=cmd_cache_key)
146+
127147 p = sub.add_parser("example-config", help="print or write a default config")
128148 p.add_argument("path", nargs="?")
129149 p.add_argument("--exciter", choices=("mallet", "stick"), default="mallet")
src/cymsim/config.pymodified+6−5View file
@@ -153,11 +153,12 @@ class Output:
153153 probes: list[tuple[float, float]] = field(default_factory=list) # points (x, y) [m] where the normal acceleration is kept at the audio rate
154154
155155
156-# The fields in which a continued run may differ from the run it continues: the length of the run
157-# and the naming of the output files. Two configurations that agree on everything else describe
158-# runs that are prefixes of one another (Simulator.run(resume=...)).
159-CONTINUATION_FIELDS = (("numerics", "duration"), ("output", "prefix"), ("output", "wav"), ("output", "npz"),
160- ("output", "cascade_plot"))
156+# The fields in which a continued run may differ from the run it continues: the length of the run,
157+# how often energy is reported, and the naming of the output files. Two configurations that agree
158+# on everything else describe runs that are prefixes of one another (Simulator.run(resume=...)),
159+# and share a cache key (cache.py).
160+CONTINUATION_FIELDS = (("numerics", "duration"), ("numerics", "n_progress"), ("output", "prefix"),
161+ ("output", "wav"), ("output", "npz"), ("output", "cascade_plot"))
161162
162163
163164 # ----------------------------------------------------------------------------- simulation
tests/test_cache.pyadded+96−0View file
@@ -0,0 +1,96 @@
1+"""Cache keys: independent of the duration and the output naming, of whether a default is written
2+out, and consistent with which runs can continue one another."""
3+import copy
4+import hashlib
5+import json
6+import re
7+
8+import yaml
9+
10+from cymsim.cache import MODEL_VERSION, cache_key, key_fields
11+from cymsim.cli import main
12+from cymsim.config import ForceExciter, Hit, MalletExciter, SimulationConfig, StickExciter, default_config
13+
14+# sha256 of the full default configuration. When this test fails, a default has changed or a field
15+# was added. If the results of some existing configuration change, bump MODEL_VERSION in cache.py;
16+# either way, update the fingerprint.
17+DEFAULTS_FINGERPRINT = "4b9d969c01b47ddabc6ee40ac7315d0c1ed1ac998f4a95b120ed4e668268672c"
18+
19+
20+def test_key_format():
21+ assert re.fullmatch(rf"v{MODEL_VERSION}/[0-9a-f]{{64}}", cache_key(SimulationConfig()))
22+
23+
24+def test_continuation_fields_do_not_change_the_key():
25+ a = default_config("stick")
26+ b = copy.deepcopy(a)
27+ b.numerics.duration = 7.5
28+ b.numerics.n_progress = 3
29+ b.output.prefix, b.output.wav, b.output.npz, b.output.cascade_plot = "other", False, False, False
30+ assert cache_key(a) == cache_key(b)
31+
32+
33+def test_written_out_defaults_do_not_change_the_key(tmp_path):
34+ full = tmp_path / "full.yaml"
35+ default_config("stick").to_yaml(full) # every field written out
36+ short = tmp_path / "short.yaml"
37+ h = default_config("stick").hits[0]
38+ short.write_text(yaml.safe_dump({"hits": [{"r": h.r, "phi": h.phi, "velocity": 7, "exciter": {"kind": "stick"}}]}))
39+ assert cache_key(SimulationConfig.from_yaml(full)) == cache_key(SimulationConfig.from_yaml(short))
40+ assert cache_key(SimulationConfig.from_yaml(full)) == cache_key(default_config("stick"))
41+
42+
43+def test_physical_and_numerical_changes_change_the_key():
44+ base = default_config("mallet")
45+ variants = []
46+ for change in (lambda c: setattr(c.hits[0], "velocity", 8.0001),
47+ lambda c: setattr(c.hits[0], "exciter", StickExciter()),
48+ lambda c: setattr(c.hits[0], "exciter", ForceExciter()),
49+ lambda c: c.hits.append(Hit(time=0.5)),
50+ lambda c: setattr(c.material, "E", 1.2e11),
51+ lambda c: setattr(c.imperfections, "seed", 2),
52+ lambda c: setattr(c.numerics, "dtype", "float64"),
53+ lambda c: setattr(c.numerics, "steps_per_block", 10000),
54+ lambda c: setattr(c.output, "probes", [(0.1, 0.0)]),
55+ lambda c: setattr(c.output, "record_acceleration", True),
56+ lambda c: setattr(c.listener, "position", (0.4, 0.3, 1.0))):
57+ c = copy.deepcopy(base)
58+ change(c)
59+ variants.append(cache_key(c))
60+ assert len(set(variants + [cache_key(base)])) == len(variants) + 1
61+
62+
63+def test_exciters_at_their_defaults_are_told_apart():
64+ keys = {cache_key(SimulationConfig(hits=[Hit(exciter=e)])) for e in (MalletExciter(), StickExciter(), ForceExciter())}
65+ assert len(keys) == 3
66+ assert key_fields(SimulationConfig(hits=[Hit(exciter=StickExciter())])) == {"hits": [{"exciter": {"kind": "stick"}}]}
67+
68+
69+def test_equal_keys_mean_continuable_runs():
70+ """The key and Simulator.check_resumable compare the same thing."""
71+ base = default_config("stick")
72+ cfgs = [base]
73+ for change in (lambda c: setattr(c.numerics, "duration", 1.0), lambda c: setattr(c.output, "prefix", "z"),
74+ lambda c: setattr(c.hits[0], "phi", 1.0), lambda c: setattr(c.output, "displacement_rate", 0.0)):
75+ c = copy.deepcopy(base)
76+ change(c)
77+ cfgs.append(c)
78+ for a in cfgs:
79+ for b in cfgs:
80+ assert (cache_key(a) == cache_key(b)) == (a.identity_dict() == b.identity_dict())
81+
82+
83+def test_defaults_fingerprint():
84+ text = json.dumps(SimulationConfig().to_dict(), sort_keys=True, separators=(",", ":"))
85+ assert hashlib.sha256(text.encode()).hexdigest() == DEFAULTS_FINGERPRINT, (
86+ "the default configuration changed: bump MODEL_VERSION in cache.py if results of existing "
87+ "configurations change, then update DEFAULTS_FINGERPRINT")
88+
89+
90+def test_cli_cache_key(tmp_path, capsys):
91+ path = tmp_path / "c.yaml"
92+ default_config("stick").to_yaml(path)
93+ assert main(["cache-key", str(path), "--show"]) == 0
94+ out = capsys.readouterr().out.splitlines()
95+ assert out[0] == cache_key(default_config("stick"))
96+ assert '"hits": [' in "\n".join(out)