From beaa1c4451a61968fd894e0671f8f3bf57abeccb Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Tue, 6 Oct 2026 12:03:37 +0800 Subject: [PATCH 1/2] build(deck): ournotes-deck v0.0.3 Co-Authored-By: Claude Opus 5.5 --- rust/Cargo.lock | 4 ++-- rust/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 9de4642..3146643 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -95,8 +95,8 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ournotes-sim" -version = "0.0.2" -source = "git+https://github.com/empty-sekai/ournotes-deck?rev=76e96b617842bdcc031cce98e3dfeead27ddbb85#76e96b617842bdcc031cce98e3dfeead27ddbb85" +version = "0.0.3" +source = "git+https://github.com/empty-sekai/ournotes-deck?rev=31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e#31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e" dependencies = [ "serde", "serde_json", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index bb84b9d..54ef161 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -16,7 +16,7 @@ crate-type = ["cdylib"] pyo3 = { version = "0.29", features = ["abi3-py311"] } # the deck model, the crate ournotes-sim of the ournotes-deck repository, pinned to the commit of a release; # .github/workflows/deck.yml opens a pull request when ournotes-deck publishes a newer release -ournotes-sim = { git = "https://github.com/empty-sekai/ournotes-deck", rev = "76e96b617842bdcc031cce98e3dfeead27ddbb85" } +ournotes-sim = { git = "https://github.com/empty-sekai/ournotes-deck", rev = "31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e" } serde_json = { version = "1", features = ["raw_value", "arbitrary_precision"] } [profile.release] From 0ce07d284db5591091c3c0f33aee379ac948ff2b Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Tue, 6 Oct 2026 11:37:12 +0800 Subject: [PATCH 2/2] feat(music-data): reuse cached chart statistics of unchanged model sources music-data --stats-cache DIR keeps each chart's deck statistics under the SHA-256 of the deck model's sources, the options, the master tables and the chart, and measures only the charts it lacks. The deck model's provenance names the SHA-256 of its sources (sourceSha256), and the summary counts cached and measured charts (deckStats). Co-Authored-By: Claude Opus 5.5 --- docs/commands.md | 6 ++- docs/music-data.md | 10 ++++- docs/schema/music-data.schema.json | 5 +++ rust/src/lib.rs | 4 +- src/nnnotes/cli.py | 8 +++- src/nnnotes/musicdata.py | 62 +++++++++++++++++++++++++++--- tests/test_musicdata.py | 45 +++++++++++++++++++++- 7 files changed, 125 insertions(+), 15 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index c8e9b58..1280243 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -679,7 +679,7 @@ in the printed summary and in `SITE.failures.json`, models that fail in the summ ``` nnnotes music-data (--master-files DIR | --apk-master | --decoded-master) [--full] [--no-deck] [--seeds N] [--workers N] [--no-gekisou-aptitude] [--aptitude-max-seeds N] [--aptitude-cross-seeds N] - [--no-bgm] [--jackets DIR] -o FILE + [--stats-cache DIR] [--no-bgm] [--jackets DIR] -o FILE ``` Writes one JSON file with every `MasterLiveMusic` song and its charts for one master data version: titles, readings @@ -697,7 +697,9 @@ master data tables about cards, skills, bonuses, scores and events. Single-skill default (not an optimal deck); `--no-gekisou-aptitude` omits it, `--aptitude-max-seeds N` (1024) and `--aptitude-cross-seeds N` (64) cap its sampling and cross terms. `--no-deck` skips the deck model (every chart's `deck` is null); `--seeds N` (default 8) and `--workers N` (default: every processor) set its seeds on charts with a -luck range and its threads. The master data is decoded from the files as served: `--master-files DIR` reads +luck range and its threads. `--stats-cache DIR` keeps every chart's statistics in `DIR` under the SHA-256 of what +they are a function of (the deck model's sources, these options, the master data tables and the chart) and measures +only the charts it lacks; `DIR` then holds this file's charts only. The master data is decoded from the files as served: `--master-files DIR` reads `DIR/MasterManifest.json` and the `.bin` files it lists (`master download`; the file's region is `[catalog] region`), `--apk-master` the same files inside `[paths] apk` (region `embedded`); each file is checked against the manifest's SHA-256. `--decoded-master` reads master data decoded elsewhere instead, without the master key: the `.json` diff --git a/docs/music-data.md b/docs/music-data.md index 7f30142..8b8290c 100644 --- a/docs/music-data.md +++ b/docs/music-data.md @@ -12,7 +12,7 @@ as its extension module `nnnotes._deck`. The format is `nnnotes.music-data/1`; i ``` nnnotes music-data (--master-files DIR | --apk-master | --decoded-master) [--full] [--no-deck] [--seeds N] [--workers N] [--no-gekisou-aptitude] [--aptitude-max-seeds N] [--aptitude-cross-seeds N] - [--no-bgm] [--jackets DIR] -o FILE + [--stats-cache DIR] [--no-bgm] [--jackets DIR] -o FILE ``` - `--master-files DIR`: master data files as served, `DIR/MasterManifest.json` and the `.bin` files it lists @@ -33,6 +33,11 @@ nnnotes music-data (--master-files DIR | --apk-master | --decoded-master) [--ful - `--no-gekisou-aptitude`: keep the existing deck statistics but skip single-skill aptitude measurements. `--aptitude-max-seeds N` caps their samples (default 65536), and `--aptitude-cross-seeds N` caps cross-term samples (default 64). Lower caps reduce work but may leave the standard-error target unmet. +- `--stats-cache DIR`: keep every chart's deck statistics in `DIR`, one file per chart named by the SHA-256 of what + they are a function of: the deck model's sources (`provenance.deck.sourceSha256`), `--seeds` and the aptitude + options, the master data tables of the deck input and the chart's runtime notes. A chart found there is not measured + again, so an export after a deck model release that keeps the model's sources, or after a master data update that + keeps those tables, measures only the charts that changed. `DIR` then holds this file's charts only. - `--no-bgm`: do not read the cue sheets (every `bgm.length` is null). - `--jackets DIR`: also write every song's jacket, the Texture2D `Image/Jacket/`, as `DIR/.webp` (WebP quality 88, scaled down with Lanczos to at most 320 pixels on the longer side, without alpha when opaque); a @@ -439,7 +444,8 @@ release in `rust/Cargo.toml` `pip install .` or `pip install -e .` in a checkout builds the module with the Rust toolchain. The same nnnotes version always carries the same deck model: the commit moves only through a pull request (`.github/workflows/deck.yml` opens one when ournotes-deck publishes a newer release with its WASM packages), and `provenance.deck.commit` names it in -every file. The replay and recommendation engines of `--replay-engine` and `--recommend-engine` are that release's WASM +every file. `provenance.deck.sourceSha256` is the SHA-256 of the deck model's sources (`ournotes_sim::SOURCE_SHA256`): +releases with the same value measure the same statistics. The replay and recommendation engines of `--replay-engine` and `--recommend-engine` are that release's WASM packages. ## Versions diff --git a/docs/schema/music-data.schema.json b/docs/schema/music-data.schema.json index b1aff1d..44e3cdb 100644 --- a/docs/schema/music-data.schema.json +++ b/docs/schema/music-data.schema.json @@ -169,6 +169,11 @@ "type": "string", "pattern": "^[0-9a-f]{40}$" }, + "sourceSha256": { + "type": "string", + "pattern": "^[0-9a-f]{64}$", + "description": "the SHA-256 of the deck model's sources; releases with the same value measure the same statistics" + }, "format": { "type": "string", "description": "the statistics format, ournotes-deck.chart-stats/2" diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4849505..aec368c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -95,7 +95,8 @@ fn chart_stats( } /// info() -> dict: the deck model of this module (name: the ournotes-deck repository; version, source and commit: -/// its crate ournotes-sim as built) and the formats it reads and writes. +/// its crate ournotes-sim as built; sourceSha256: the SHA-256 of that crate's sources, equal for builds that run the +/// same model) and the formats it reads and writes. #[pyfunction] fn info(py: Python<'_>) -> PyResult> { let d = PyDict::new(py); @@ -103,6 +104,7 @@ fn info(py: Python<'_>) -> PyResult> { d.set_item("version", env!("DECK_VERSION"))?; d.set_item("source", env!("DECK_SOURCE"))?; d.set_item("commit", env!("DECK_COMMIT"))?; + d.set_item("sourceSha256", ournotes_sim::SOURCE_SHA256)?; d.set_item("dataFormat", ournotes_sim::data::FORMAT)?; d.set_item("format", chartstats::FORMAT)?; Ok(d) diff --git a/src/nnnotes/cli.py b/src/nnnotes/cli.py index 14cc82b..cf4f083 100644 --- a/src/nnnotes/cli.py +++ b/src/nnnotes/cli.py @@ -37,7 +37,8 @@ [--font emoji=] [--region [--region ...] | --all-regions] nnnotes music-data --master-files | --apk-master | --decoded-master [--full] [--no-deck] - [--no-gekisou-aptitude] [--no-bgm] [--jackets DIR] -o out/music-data.json[.gz] + [--no-gekisou-aptitude] [--stats-cache DIR] [--no-bgm] [--jackets DIR] + -o out/music-data.json[.gz] nnnotes export -o out/assets [--select group: | key: | bundle: ...] [--layout original,cas] nnnotes plan [--select ...] [--json] [--check] [--emit-tasks ] nnnotes run-stage [...] @@ -678,7 +679,7 @@ def cmd_music_data(args, cfg): deck = None if args.no_deck else musicdata.Deck( seeds=args.seeds, workers=args.workers, aptitude=not args.no_gekisou_aptitude, aptitude_max_seeds=args.aptitude_max_seeds, aptitude_cross_seeds=args.aptitude_cross_seeds, - require_convergence=not args.allow_unconverged_aptitude) + require_convergence=not args.allow_unconverged_aptitude, cache=args.stats_cache) if args.apk_master: src, region = deckdata.apk_master(apk), deckdata.EMBEDDED elif args.decoded_master: # decoded elsewhere: no master key @@ -998,6 +999,9 @@ def target(m, what): help="diagnostic export only: retain unmet SE flags at the sample cap; final exports reject them") c.add_argument("--aptitude-cross-seeds", type=int, metavar="N", help="seeds of a Gekisou aptitude variant's cross terms (default: the deck model's, 64)") + c.add_argument("--stats-cache", type=Path, metavar="DIR", + help="keep the charts' deck statistics in DIR: a chart whose model sources, options, master tables " + "and chart are unchanged is not measured again; DIR then holds this export's charts only") c.add_argument("--no-bgm", action="store_true", help="do not read the BGM cue sheets (every song's bgm.length is null)") c.add_argument("--replay-dir", metavar="DIR", diff --git a/src/nnnotes/musicdata.py b/src/nnnotes/musicdata.py index b2ef42e..a32d900 100644 --- a/src/nnnotes/musicdata.py +++ b/src/nnnotes/musicdata.py @@ -306,11 +306,13 @@ class Deck: """The deck model (nnnotes._deck): chart statistics of a deck input document, on `workers` threads (None: the available parallelism), `seeds` seeds for a chart with a luck range; with `aptitude` every chart's Gekisou aptitude too, a variant on at most `aptitude_max_seeds` seeds and its cross terms on at most `aptitude_cross_seeds` (None: - the deck model's defaults).""" + the deck model's defaults). With a `cache` directory, a chart's statistics are kept there under the SHA-256 of + what they are a function of: the model's sources (its `sourceSha256`), the options above, the master tables and + the chart; a chart found there is not measured again.""" def __init__(self, seeds: int = DECK_SEEDS, workers: int | None = None, module=None, aptitude: bool = True, aptitude_max_seeds: int | None = None, aptitude_cross_seeds: int | None = None, - require_convergence: bool = True): + require_convergence: bool = True, cache: Path | None = None): if module is None: try: from . import _deck as module @@ -321,14 +323,15 @@ def __init__(self, seeds: int = DECK_SEEDS, workers: int | None = None, module=N self.aptitude, self.aptitude_max_seeds, self.aptitude_cross_seeds = \ aptitude, aptitude_max_seeds, aptitude_cross_seeds self.require_convergence = require_convergence + self.cache = None if cache is None else Path(cache) + self.counts: dict | None = None # {cached, measured} charts of the last stats() def info(self) -> dict: - """{name, version, source, commit, format} of the deck model.""" + """{name, version, source, commit, sourceSha256, format} of the deck model.""" i = self.module.info() - return {k: i[k] for k in ("name", "version", "source", "commit", "format")} + return {k: i[k] for k in ("name", "version", "source", "commit", "sourceSha256", "format")} - def stats(self, deck_input: dict) -> dict: - """The chart statistics document of a deck input document (deckdata.build), its numbers as written.""" + def _measure(self, deck_input: dict) -> dict: try: text = self.module.chart_stats(deckdata.encode(deck_input).decode("utf-8"), self.seeds, self.workers, aptitude=self.aptitude, aptitude_max_seeds=self.aptitude_max_seeds, @@ -338,6 +341,52 @@ def stats(self, deck_input: dict) -> dict: doc = json.loads(text, parse_float=deckdata._Num) if doc.get("format") != self.info()["format"]: raise MusicDataError(f"deck model: wrote {doc.get('format')!r}, expected {self.info()['format']!r}") + return doc + + def cache_keys(self, deck_input: dict) -> list[str]: + """The cache key of each chart of a deck input document, in chart order.""" + info = self.info() + base = {"sourceSha256": info["sourceSha256"], "format": info["format"], "seeds": self.seeds, + "aptitude": self.aptitude, "aptitudeMaxSeeds": self.aptitude_max_seeds, + "aptitudeCrossSeeds": self.aptitude_cross_seeds, + "master": hashlib.sha256(deckdata.encode(deck_input["master"])).hexdigest()} + return [hashlib.sha256(deckdata.encode({**base, "chart": hashlib.sha256(deckdata.encode(c)).hexdigest()})) + .hexdigest() for c in deck_input["charts"]] + + def _cached_stats(self, deck_input: dict) -> dict: + """stats() through the cache: measure the charts it lacks, add them, then keep only this document's charts.""" + from .cache import write_atomic + keys = self.cache_keys(deck_input) + found = {} + for k in keys: + path = self.cache / f"{k}.json" + if path.is_file(): + found[k] = json.loads(path.read_bytes(), parse_float=deckdata._Num) + missing = [c for c, k in zip(deck_input["charts"], keys) if k not in found] + doc = self._measure({**deck_input, "charts": missing}) + if len(doc.get("charts") or []) != len(missing): + raise MusicDataError("deck model: the charts measured differ from the charts asked for") + self.cache.mkdir(parents=True, exist_ok=True) + measured = iter(doc["charts"]) + for k in keys: + if k not in found: + found[k] = next(measured) + write_atomic(self.cache / f"{k}.json", deckdata.encode(found[k])) + for path in self.cache.glob("*.json"): + if path.stem not in found: + path.unlink() + doc["charts"] = [found[k] for k in keys] + self.counts = {"cached": len(keys) - len(missing), "measured": len(missing)} + return doc + + def stats(self, deck_input: dict) -> dict: + """The chart statistics document of a deck input document (deckdata.build), its numbers as written. With a + cache, the cache then holds exactly this document's charts.""" + if self.cache is None: + doc = self._measure(deck_input) + self.counts = {"cached": 0, "measured": len(deck_input["charts"])} + else: + doc = self._cached_stats(deck_input) if self.require_convergence and self.aptitude: unmet = [(c.get("scoreId"), v.get("shape"), v.get("bandMatch"), v.get("seeds")) for c in doc.get("charts", []) @@ -1140,6 +1189,7 @@ def export(out, src: deckdata.MasterSource, key, fetch: Callable[[str], bytes], return {"out": str(out), "format": FORMAT, "region": region, "masterSource": src.source, "masterVersion": src.version, "songs": len(doc["songs"]), "charts": len(charts), "deck": doc["provenance"]["deck"]["commit"] if deck is not None else None, + **({"deckStats": deck.counts} if deck is not None else {}), "unplayable": sum(1 for c in charts if c["deck"] and c["deck"]["unplayable"]), "full": full, "bgm": bgm is not None, "jackets": len(jackets), "bytes": len(data), "fileBytes": len(written), "sha256": hashlib.sha256(written).hexdigest(), diff --git a/tests/test_musicdata.py b/tests/test_musicdata.py index 54b4aed..db077db 100644 --- a/tests/test_musicdata.py +++ b/tests/test_musicdata.py @@ -260,6 +260,7 @@ class FakeDeck: """The interface of nnnotes._deck: statistics made from the deck input as the model reports them, with the Gekisou aptitude (SHAPES; `aptitude`) unless it is left out.""" COMMIT = "7e5d84b5998d28c21541ce3f2e0a3dfb1439f4f6" + SOURCE = "5" * 64 FORMAT = "ournotes-deck.chart-stats/2" def __init__(self, change=None, fail=None, header=None): @@ -267,7 +268,8 @@ def __init__(self, change=None, fail=None, header=None): def info(self): return {"name": "ournotes-deck", "version": "0.0.1", "source": "https://github.com/empty-sekai/ournotes-deck", - "commit": self.COMMIT, "dataFormat": deckdata.DECK_FORMAT, "format": self.FORMAT} + "commit": self.COMMIT, "sourceSha256": self.SOURCE, "dataFormat": deckdata.DECK_FORMAT, + "format": self.FORMAT} def chart_stats(self, data, seeds, workers, aptitude=True, aptitude_max_seeds=None, aptitude_cross_seeds=None): if self.fail: @@ -343,6 +345,44 @@ def export(tmp_path, rows=TABLE_ROWS, charts=CHARTS, bgm=bgm, out="music.json", deck=musicdata.Deck(module=deck, workers=3) if deck is not None else None, **kw) +def test_the_stats_cache_measures_only_charts_it_lacks(tmp_path): + cache = tmp_path / "stats" + + def run(name, deck, seeds=musicdata.DECK_SEEDS, stats_cache=cache): + (tmp_path / name).mkdir() + d = master_dir(tmp_path / name) + return musicdata.export(tmp_path / name / "music.json", deckdata.master_files(d), KEY, CHARTS.__getitem__, + bgm, **PROV, deck=musicdata.Deck(module=deck, seeds=seeds, cache=stats_cache)) + plain = run("plain", FakeDeck(), stats_cache=None) + fake = FakeDeck() + first = run("first", fake) + n = plain["charts"] + assert len(fake.inputs[0][0]["charts"]) == n and first["deckStats"] == {"cached": 0, "measured": n} + assert plain["deckStats"] == {"cached": 0, "measured": n} and len(list(cache.glob("*.json"))) == n + again = run("again", fake) + assert fake.inputs[1][0]["charts"] == [] and again["deckStats"] == {"cached": n, "measured": 0} + assert plain["sha256"] == first["sha256"] == again["sha256"] + # the key names the model's sources and the options: changing either measures every chart again, and the + # cache then holds the new statistics only + other = FakeDeck() + other.SOURCE = "6" * 64 + assert run("other", other)["deckStats"] == {"cached": 0, "measured": n} and len(list(cache.glob("*.json"))) == n + assert run("seeds", FakeDeck(), seeds=4)["deckStats"] == {"cached": 0, "measured": n} + + +def test_the_stats_cache_key_reads_the_master_tables_and_the_chart(): + deck = musicdata.Deck(module=FakeDeck()) + doc = {"master": {"MasterLiveSetting": {"columns": ["_id"], "rows": [[1]]}}, + "charts": [{"scoreId": 1, "notes": [1]}, {"scoreId": 2, "notes": [2]}]} + keys = deck.cache_keys(doc) + assert len(set(keys)) == 2 and all(re.fullmatch(r"[0-9a-f]{64}", k) for k in keys) + assert deck.cache_keys({**doc, "charts": doc["charts"][1:]}) == keys[1:] + changed = {**doc, "charts": [doc["charts"][0], {"scoreId": 2, "notes": [3]}]} + assert deck.cache_keys(changed)[0] == keys[0] and deck.cache_keys(changed)[1] != keys[1] + other = {**doc, "master": {"MasterLiveSetting": {"columns": ["_id"], "rows": [[2]]}}} + assert not set(deck.cache_keys(other)) & set(keys) + + def schema_validator(): jsonschema = pytest.importorskip("jsonschema") doc = json.loads((ROOT / "docs" / "schema" / "music-data.schema.json").read_text(encoding="utf-8")) @@ -435,7 +475,7 @@ def test_deck(tmp_path): assert [c["scoreId"] for c in deck_input["charts"]] == [10, 20, 30] # the songs' charts, not chart 40 assert deck_input["provenance"]["master"] == {"source": "api", "version": "v-test"} p = doc["provenance"] - assert p["deck"] == {k: fake.info()[k] for k in ("name", "version", "source", "commit", "format")} + assert p["deck"] == {k: fake.info()[k] for k in ("name", "version", "source", "commit", "sourceSha256", "format")} assert list(p["master"]["tables"]) == list(musicdata.tables_of(True, False)) assert doc["deck"]["model"]["power"] == 300000 assert doc["deck"]["gekisouAptitude"]["shapes"] == SHAPES @@ -630,6 +670,7 @@ def test_deck_module(): info = deck.info() assert info["name"] == "ournotes-deck" and info["format"] == "ournotes-deck.chart-stats/2" assert info["dataFormat"] == deckdata.DECK_FORMAT and re.fullmatch(r"[0-9a-f]{40}", info["commit"]) + assert re.fullmatch(r"[0-9a-f]{64}", info["sourceSha256"]) lock = (ROOT / "rust" / "Cargo.lock").read_text(encoding="utf-8") assert f"#{info['commit']}\"" in lock # the commit Cargo.lock pins with pytest.raises(ValueError, match="not a deck data file"):