From 42fa244d0be6f6b9b0a2ad239bc7477e9d9d6fe5 Mon Sep 17 00:00:00 2001 From: nichinichisou0609 <128159543+nichinichisou0609@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:17:15 +0800 Subject: [PATCH 1/7] feat(music-data): use native program caching and nominal expectations --- .github/MUSIC_DATA.md | 76 +-- .github/scripts/music_data.py | 375 +++----------- .github/scripts/music_data_smoke.mjs | 46 +- .github/scripts/test_music_data.py | 302 +++++------- .github/workflows/music-data-region.yml | 22 +- docs/contracts.md | 2 +- docs/music-data.md | 339 +++++++------ docs/replay.md | 4 +- docs/schema/music-data.schema.json | 562 ++++++++++----------- rust/Cargo.lock | 2 +- rust/Cargo.toml | 8 +- rust/src/lib.rs | 102 ++-- src/nnnotes/cli.py | 16 +- src/nnnotes/musicdata.py | 617 ++++++++++++------------ tests/test_musicdata.py | 429 +++++++++------- tests/test_replaydata.py | 10 +- tests/test_statscache.py | 167 +++++++ 17 files changed, 1515 insertions(+), 1564 deletions(-) create mode 100644 tests/test_statscache.py diff --git a/.github/MUSIC_DATA.md b/.github/MUSIC_DATA.md index 89c5825..3bbf05c 100644 --- a/.github/MUSIC_DATA.md +++ b/.github/MUSIC_DATA.md @@ -98,10 +98,14 @@ extend the existing linear plain-skill UI domain or derive source rank threshold `ournotes-sim` package `rust/Cargo.lock` pins, each checked against the release's `SHA256SUMS`; nnnotes takes their web packages after checking each `build-info.json` (module, commit, file SHA-256) against its deck model; - `nnnotes music-data --decoded-master --jackets jackets --replay-dir replay --replay-engine ... --recommend-engine - ... --stats-cache $MUSIC_DATA_STATS_CACHE -o music-data.json`: the deck statistics of the last build of the - region come back from `actions/cache` (derived numbers the file publishes, no game files), and only the charts - whose deck model sources (`provenance.deck.sourceSha256`), options, master tables or chart changed are measured - again; the summary counts both. The master data as decoded (no + ... --stats-cache $MUSIC_DATA_STATS_CACHE -o music-data.json`: restores the region's native measurement + cache from `actions/cache`. It reuses compatible compiled programs, expectations and completed runs across + inputs, including unchanged skill shapes after a catalog change. The complete current master and charts + still enter the model on every build; current headers and chart documents are assembled anew. Records are + isolated by cache schema and model source SHA-256, validated on read, and saved atomically after each result. + Interrupted builds retain completed records. There is no automatic garbage collection or migration of the + former whole-chart Python cache. Summary counters are native cache hits, successful computations, writes + and invalid records, not numbers of charts. The master data as decoded (no master key), `provenance.master` the manifest's version and SHA-256 of the files as served; the charts, cue sheets and jackets from the TW catalog, downloaded afresh on every run (never `actions/cache`: nnnotes keeps a downloaded catalog for good, and the cache holds decrypted game files); @@ -169,12 +173,12 @@ Every one must pass, else nothing is published. Warnings go to the job summary a | Gate | Checks | |---|---| | (build) | nnnotes' own checks: every table, chart and cue sheet read, every chart measured, the deck statistics cross-checked against the chart facts and the master data (the command writes no file otherwise) | -| `schema` | the file against `docs/schema/music-data.schema.json` of the checkout (JSON Schema 2020-12) | +| `schema` | the file against `docs/schema/music-data.schema.json` of the checkout (JSON Schema 2020-12), requiring `nnnotes.music-data/2` and `ournotes-deck.chart-stats/3` statistics | | `provenance` | `format`; `region` `tw`; `master.source` `api`; `master.version` equal to the snapshot's and its `MasterManifest.json`'s; every table's SHA-256 the manifest's, every decoded table read the one `index.json` lists; the song tables and the deck model's present; `deck.commit` the one `rust/Cargo.lock` pins; `exporter.version` the installed nnnotes; an APK version; a catalog SHA-256 (warning: the APK is another client version than the snapshot's) | | `counts` | no fewer songs and charts than the published file (warning: ids no longer in it) | -| `deck` | deck statistics on every chart: kinds, a positive power, events and positions matching the chart, seeds unless unplayable (a warning): the one seed 0 on a chart without a luck range, else two or more different seeds, the same on every luck chart (their number is the file's, not fixed), `weights[kind][position]` numbers, every seed range's `rankBonus` = trunc(`rangeScore` x `rankBonusPercent` / 100) and its `luckPoints` an int, every check deck within its bound | -| `scenarios` | the play scenario fields: `offSeeds` exactly one entry (seed 0, score, weights, check within its bound), every range's `rankBonusPercents` five ints (the first `rankBonusPercent`), every seed's `scorePerfect`, `rangeWeights` (`[kind][position][range]`) and `rankCheck` (within its bound), every seed range's `rangeScorePerfect` (warnings, none in TW: a null `rangeWeights`, a null kind in it or in `offSeeds`' weights) | -| `aptitude` | the Gekisou skill aptitude (every shape alone on a chart). `deck.model.gekisouAptitude` a text; `deck.gekisouAptitude`: every key, `plainKind` the page's plain kind, a `host` text, the `seedRule` (a deterministic test, increasing batches, the targets, the cross seeds), `shapes` numbered 0, 1, 2, ... (source `member` or `support`, mission 1 to 4, `bandCondition` a support skill's alone and exactly when an effect has condition 5000, effect rows with every key and their condition groups, condition 5000 without targets, skills with a level and, with a band condition alone, member targets and bands). Every chart's `deck.gekisouAptitude`: null exactly when the chart is unplayable with Gekisou on, has no Gekisou range or there is no shape; else `factors` one per range (counts; no Just or Perfect notes outside a Just range; `lotteries` `[0, 0]` outside a luck range, else the mean of `deck.seeds`' `lotResults`) and `variants` one per shape of the chart's missions (or mission 4) in shape order, a band condition shape's `bandMatch` true then false: every `[mean, se]` two finite numbers with se >= 0 (every se 0 when deterministic), ranges one per range, `tail` = `score` less the ranges' `rangeScore` and `rankBonus` (allowing 0.0005 per rounded term plus 1e-6), deterministic point deltas integers and `tailPerfect` checked against the baseline Perfect range bonuses, 1 seed when deterministic else a batch of the seed rule, with `seTargetMet` true for both exported score channels, `crossSeeds` min(seeds, the rule's), `weights` one per position and `rangeWeights` per position and range where the plain kind and `deck.seeds[0].rangeWeights` are, else null, the `check` on `deck.seeds[0]`'s seed, a rank per range (1 where the ranks are not linear), a plain kind value or null per position, within its bound (fails when any variant misses the standard error target, including at the sample cap) | +| `deck` | native kinds, positive power, events and positions matching chart facts; explicit nominal `expectation` unless unplayable; replay seeds equal `[0]` without luck or the published prefix shared by luck charts; finite `[center, outward radius]` estimates, valid dimensions and deterministic counters; lottery estimates enclose zero outside luck ranges; complete expectation check decks within their bounds | +| `scenarios` | exactly one deterministic Free Live `offSeeds` entry (seed 0, scalar score/weights and bounded check); five integer rank percentages per range; explicit best/Perfect scores and `rankBonusPerfect`; rank-weight dimensions and rank checks match the supported linear domain; nulls warn about unavailable rank or Free Live weights rather than becoming zero gains | +| `aptitude` | complete model/header descriptions and exact independent nominal probability `law`; continuous shape ids, valid sources/missions, complete effect/condition structure and skill/level/band references; factors and ordered variants cover every applicable shape, with both band results where required; finite outward intervals, deterministic integer increments, per-position cross terms, both total/tail interval identities, explicit Perfect bonuses and bounded nominal checks; confirmed-rank conditions disable linear rank cross terms and require rank-1 checks | | `finite` | no NaN or infinity (warning: one inside master data rows, `songs[].master`, which the format writes as `1e999`) | | `references` | texts in every language of `languages` (names and titles not empty); unique ids; songs sorted; the songs' bands, vocal characters and tags in the file; a band or a band name; a jacket, and its file in `jackets/`; a BGM cue; score ranks; charts in difficulty order, score ids unique (warnings: a title without a `zh-Hant` text, a music category on no tab, a character of no band) | | `sourceRanks` | every exported song ID and each raw solo `requiredScore` / room `battleRequiredScore` exactly matches downloaded `MasterLiveMusic` + `MasterLiveScoreRank`; no derived `requiredPower` is treated as a source field | @@ -195,22 +199,26 @@ python -m pytest -q -p no:cacheprovider .github/scripts/test_music_data.py .gith ``` with, optionally, `MUSIC_DATA_SCHEMA` (a schema file when the checkout has none), `MUSIC_DATA_PAGE` (an -`examples/songs` directory: the smoke test), `MUSIC_DATA_SAMPLE` (a real file with the play scenario fields: its -content gates pass; one made before the ranges' `luckPoints` and the aptitude: the deck and aptitude gates stop it -on those alone) and -`MUSIC_DATA_OLD_SAMPLE` (one without the play scenario fields: the scenario gate stops it). +`examples/songs` directory for the smoke test), `MUSIC_DATA_SAMPLE` (a current `/2` file with nominal expectations) +and `MUSIC_DATA_OLD_SAMPLE` (an incompatible file that the required scenario contract rejects). + +The deck and aptitude gates reuse the exporter's interval validators. An Estimate is a finite center and +nonnegative outward radius, not a sampled mean and standard error. A radius encloses numerical error; a check's +separate scalar bound covers the formula's approximation and integer-floor error. Expected rank bonuses are +measured explicitly: truncating the expected range score is not equivalent to taking the expectation of the +truncated score. There is no sample-cap or standard-error convergence gate in the nominal format. ### Aptitude page smoke -With the aptitude API (ournotes-player PR #11, `1522c24`), the same Node smoke also checks shape/skill/band -lookups, chart variants, all five battle scenarios, Free Live exclusion, finite gains, raw standard errors, -missing cross terms and the absence of standard errors for transformed or combined figures. Removing aptitude -must not change the default chart figures: default ranking still has no card Gekisou skills. +The same Node smoke checks the page's shape/skill/band lookups, applicable chart variants, five battle scenarios, +Free Live exclusion, finite gains, raw numerical radii, missing cross terms and the absence of an assigned +uncertainty for transformed or combined figures without a supported bound. Removing aptitude must not change the +default chart figures: default ranking still has no card Gekisou skills. -Only deterministic variants are reconstructed against their individual `check` seed, using positional cards and -`masterSkillFactor` for the game's float32 conversion. Stochastic means are never used to reconstruct a check. -Older pinned page modules explicitly report `API unavailable (skipped)`; moving `MUSIC_DATA_PLAYER_REF` remains a -separate rollout decision. No browser or page build is needed. +Every nominal variant can be reconstructed against its expectation check, using positional ordinary cards and +`masterSkillFactor` for the game's float32 conversion. Replay seeds are not part of that reconstruction. The +page's API must support the current expectation format; an unavailable aptitude API fails publication. Pin a +compatible `MUSIC_DATA_PLAYER_REF` with the format upgrade. No browser or page build is needed for the Node smoke. ## Settings @@ -222,7 +230,7 @@ Repository variables: | Variable | Default | | |---|---|---| -| `MUSIC_DATA_PLAYER_REF` | none: **required** | the ournotes-player commit whose chart data page reads this file (the page with the play scenarios); a run stops before building without it | +| `MUSIC_DATA_PLAYER_REF` | `946f25c1b50ca1cbb8209ca766056a1a2f6c3aca` | the ournotes-player commit supporting nominal chart-stats/3; custom overrides must pass the same format smoke test before building | | `MUSIC_DATA_PUBLISH` | none: off | `true`: upload; anything else: every run is a dry run | | `MUSIC_DATA_PLAYER_REPOSITORY` | `empty-sekai/ournotes-player` | | | `MUSIC_DATA_REGIONS` | `hk-tw-mo jp` | regions checked independently by dispatch/schedule | @@ -232,14 +240,24 @@ Repository variables: ## Before the first run -- **nnnotes.** The workflow runs this fork's nnnotes. It needs upstream's `music-data` command with the play - scenarios (MetaSekaiLab/nnnotes `a03591e`) and `--decoded-master` (MetaSekaiLab/nnnotes#6, `12df2a6`): sync the - fork with upstream first. Until then `plan` stops naming what is missing. The deck and aptitude gates also need - the ranges' `luckPoints` and the Gekisou skill aptitude, which come with nnnotes' and ournotes-deck's Gekisou skill - changes: until the fork has them every build stops there. -- **The page.** Set `MUSIC_DATA_PLAYER_REF` to the ournotes-player commit of the chart data page that reads the play - scenario fields, once that page is merged. -- **Publishing.** Set `MUSIC_DATA_PUBLISH` to `true` last, when dry runs pass and the published format is final. +- **Native model and engines.** The native program cache is supplied by + [ournotes-deck#44](https://github.com/empty-sekai/ournotes-deck/pull/44). The exact source revision in + `rust/Cargo.toml` and `rust/Cargo.lock` must also be available as a model release with both replay and + recommendation WASM packages. Building this source pin locally is supported before that release exists; + production publication still requires matching engine commit and source identities. If the release uses a + different commit after merging, update the Cargo revision and lockfile package version and rebuild the extension. + The publisher selects `v` from that package version, so creating a new tag alone is insufficient. + Older engines, including an older release with the + same package version, must not be substituted or admitted by weakening the identity gate. +- **Consumers.** Deploy readers for `nnnotes.music-data/2` and `ournotes-deck.chart-stats/3` before publishing new + data. The default `MUSIC_DATA_PLAYER_REF` already selects the nominal-expectation consumer in + [ournotes-player#18](https://github.com/empty-sekai/ournotes-player/pull/18); an explicit repository variable + overrides that default and must pass the same smoke test. The corresponding site changes are + [moenotes#7](https://github.com/nichinichisou0609/moenotes/pull/7), stacked on the deck-goal repair in + [moenotes#6](https://github.com/nichinichisou0609/moenotes/pull/6). +- **Producer and publication.** This checkout publishes only the new nominal format and starts a fresh native + cache namespace; it does not migrate old Python cache records. Run the normal or prebuilt publication gates + with the matching engines and consumers. Set `MUSIC_DATA_PUBLISH` to `true` after the dry run passes. ## Notes diff --git a/.github/scripts/music_data.py b/.github/scripts/music_data.py index 5fe1561..889cda8 100755 --- a/.github/scripts/music_data.py +++ b/.github/scripts/music_data.py @@ -50,11 +50,11 @@ import http_compression # noqa: E402 from story_site import env, get, output, summary # noqa: E402 -FORMAT = "nnnotes.music-data/1" +FORMAT = "nnnotes.music-data/2" BUILD_FORMAT = "moenotes.music-data-build/1" # This script's own version of a build: bump it when what it builds or publishes changes, so that the next run builds # although the master data, the deck model and nnnotes are the same. -RECIPE = 7 +RECIPE = 8 FILE, FILE_BR, MARKER, JACKETS, ARCHIVE = "music-data.json", "music-data.json.br", "build.json", "jackets/", "archive/" MANIFEST = "MasterManifest.json" SOURCE_PATHS = ("src", "rust", "pyproject.toml") # nnnotes' code: the commit that last changed one of them @@ -367,7 +367,8 @@ def cmd_build(out: str) -> None: stats = r.get("deckStats") or {} summary(f"- built: {r.get('songs')} songs, {r.get('charts')} charts, {r.get('jackets')} jackets, deck " f"{short(r.get('deck'))}, {r.get('bytes')} bytes, sha256 {short(r.get('sha256'))}; chart statistics " - f"{stats.get('cached')} cached, {stats.get('measured')} measured") + f"{stats.get('hits', 0)} native cache hits, {stats.get('computed', 0)} computed, " + f"{stats.get('writes', 0)} checkpoint writes, {stats.get('invalid', 0)} invalid entries") def release_engines(out: Path) -> tuple[Path, ...]: @@ -666,10 +667,15 @@ def weights_shape(w, kinds: int, positions: int, nullable: bool) -> bool: (nullable and k is None) or (isinstance(k, list) and len(k) == positions and all(map(is_num, k))) for k in w) +def native_numbers(value): + """Read a gate value with the exporter's lossless JSON number representation.""" + from nnnotes.deckdata import _Num + return json.loads(json.dumps(value), parse_float=_Num) + + def gate_deck(doc, ctx: Context, g: Gate): - """The deck statistics. Seeds (deck.model.seeds): the one seed 0 on a chart without a luck range, else two or more - seeds, the same on every luck chart (their number is the file's); every range's rankBonus the rank 1 bonus and - its luckPoints (the range's luck points without skills).""" + """Validate nominal expectations, independent Free Live runs and replay-only seed lists.""" + from nnnotes import musicdata deck = doc.get("deck") if not isinstance(deck, dict): g.fail("deck is null: no deck statistics (made with --no-deck?)") @@ -680,7 +686,7 @@ def gate_deck(doc, ctx: Context, g: Gate): power = (deck.get("model") or {}).get("power") if not (is_num(power) and power > 0): g.fail(f"deck.model.power {power!r}") - n = unplayable = seeds = 0 + n = unplayable = 0 luck_seeds = None for song, chart in charts_of(doc): n += 1 @@ -689,58 +695,37 @@ def gate_deck(doc, ctx: Context, g: Gate): g.fail(f"{w}: no deck statistics") continue positions, events = d.get("positions"), d.get("events") or [] - if len(events) != len(chart.get("skillEventsMs") or []): - g.fail(f"{w}: {len(events)} skill events, the chart has {len(chart.get('skillEventsMs') or [])}") - if not is_int(positions) or (events and positions != max(e[0] for e in events) + 1): + if [event[1] for event in events] != (chart.get("skillEventsMs") or []): + g.fail(f"{w}: skill events do not match the chart") + if not is_int(positions) or positions < 0 or positions != max((e[0] + 1 for e in events), default=0): g.fail(f"{w}: positions {positions!r} do not match the events") continue - if d.get("unplayable"): + values = d.get("replaySeeds") + if d.get("unplayable") is not None: unplayable += 1 g.warn(f"{w}: unplayable with Gekisou on ({d['unplayable']})") - if d.get("seeds"): - g.fail(f"{w}: unplayable, but has Gekisou on seeds") - elif not d.get("seeds"): - g.fail(f"{w}: no seeds") - values = [s.get("seed") for s in d.get("seeds") or []] - if values and not luck_chart(d): - if len(values) != 1 or not is_int(values[0]) or values[0] != 0: - g.fail(f"{w}: seeds {values[:4]!r} without a luck range, expected the one seed 0") - elif values: - if not all(map(is_int, values)) or len(values) < 2 or len(set(values)) != len(values): - g.fail(f"{w}: {len(values)} seeds on a luck chart, expected two or more different int seeds") - elif luck_seeds is None: - luck_seeds = values - elif values != luck_seeds: - g.fail(f"{w}: its {len(values)} seeds are not the {len(luck_seeds)} of the first luck chart") - for seed in d.get("seeds") or []: - seeds += 1 - s = f"{w} seed {seed.get('seed')}" - if not is_int(seed.get("score")): - g.fail(f"{s}: score {seed.get('score')!r}") - if not weights_shape(seed.get("weights"), kinds, positions, nullable=False): - g.fail(f"{s}: weights are not [kind][position] numbers") - if len(seed.get("ranges") or []) != len(d.get("ranges") or []): - g.fail(f"{s}: {len(seed.get('ranges') or [])} range results for {len(d.get('ranges') or [])} ranges") - for i, (r, rr) in enumerate(zip(seed.get("ranges") or [], d.get("ranges") or [], strict=False)): - r, rr = (r if isinstance(r, dict) else {}), (rr if isinstance(rr, dict) else {}) - if not (is_int(r.get("rangeScore")) and is_int(r.get("rankBonus"))): - g.fail(f"{s} range {i}: rangeScore {r.get('rangeScore')!r}, rankBonus {r.get('rankBonus')!r}") - elif is_int(rr.get("rankBonusPercent")) and r["rankBonus"] != rank_bonus(r["rangeScore"], - rr["rankBonusPercent"]): - g.fail(f"{s} range {i}: rankBonus {r['rankBonus']} is not trunc({r['rangeScore']} * " - f"{rr['rankBonusPercent']} / 100)") - if not is_int(r.get("luckPoints")): - g.fail(f"{s} range {i}: luckPoints {'missing' if 'luckPoints' not in r else 'not an int'}") - if not within(seed.get("check")): - g.fail(f"{s}: the check deck is not within its bound") - g.note = (f"{n} charts, {kinds} kinds, {seeds} seeds, {unplayable} unplayable, " - f"{len(luck_seeds or [])} seeds per luck chart") + if values != []: + g.fail(f"{w}: unplayable, but has replay seeds") + elif not isinstance(values, list) or not values or not all(is_int(v) for v in values): + g.fail(f"{w}: replaySeeds must be a nonempty integer list") + elif not luck_chart(d): + if values != [0]: + g.fail(f"{w}: replaySeeds without a luck range must be [0]") + elif values != musicdata.published_seeds(len(values)): + g.fail(f"{w}: replaySeeds are not the published seed prefix") + elif luck_seeds is None: + luck_seeds = values + elif values != luck_seeds: + g.fail(f"{w}: replaySeeds differ between luck charts") + try: + musicdata._check_expectation(native_numbers({**d, "judgedNotes": chart["notes"]["judged"]}), kinds, w) + except (ValueError, TypeError, KeyError, IndexError) as e: + g.fail(str(e)) + g.note = f"{n} charts, {kinds} kinds, nominal expectations, {unplayable} unplayable" def gate_scenarios(doc, ctx: Context, g: Gate): - """The play scenario fields (Gekisou off, every rank, the Perfect play): offSeeds exactly one, every range's - rankBonusPercents five ints, every seed scorePerfect, rangeWeights and rankCheck, every seed range - rangeScorePerfect. A null rangeWeights, a null kind in it or in offSeeds' weights is a warning (none in TW).""" + """Validate scenario availability and rank coefficients; expectations are checked by the deck gate.""" kinds = len(((doc.get("deck") or {}).get("kinds")) or []) null_rw = null_kind = null_off = 0 for song, chart in charts_of(doc): @@ -751,11 +736,11 @@ def gate_scenarios(doc, ctx: Context, g: Gate): positions, ranges = d.get("positions"), d.get("ranges") or [] off = d.get("offSeeds") if not isinstance(off, list) or len(off) != 1: - g.fail(f"{w}: offSeeds {'missing' if off is None else f'has {len(off)} entries'}, expected exactly one") + g.fail(f"{w}: offSeeds missing or not exactly one entry") else: o = off[0] if o.get("seed") != 0 or not is_int(o.get("score")): - g.fail(f"{w}: Gekisou off seed {o.get('seed')!r} score {o.get('score')!r}") + g.fail(f"{w}: invalid Gekisou off seed or score") if not weights_shape(o.get("weights"), kinds, positions, nullable=True): g.fail(f"{w}: Gekisou off weights are not [kind][position] numbers") else: @@ -765,64 +750,41 @@ def gate_scenarios(doc, ctx: Context, g: Gate): for i, r in enumerate(ranges): p = r.get("rankBonusPercents") if not (isinstance(p, list) and len(p) == RANKS and all(map(is_int, p))): - g.fail(f"{w} range {i}: rankBonusPercents {'missing' if p is None else 'not five ints'}") + g.fail(f"{w} range {i}: rankBonusPercents are not five ints") elif p[0] != r.get("rankBonusPercent"): - g.fail(f"{w} range {i}: rankBonusPercents[0] {p[0]} is not rankBonusPercent " - f"{r.get('rankBonusPercent')}") - for seed in d.get("seeds") or []: - s = f"{w} seed {seed.get('seed')}" - absent = [k for k in ("scorePerfect", "rangeWeights", "rankCheck") if k not in seed] - if absent: - g.fail(f"{s}: no {', '.join(absent)}") - continue - if not is_int(seed["scorePerfect"]): - g.fail(f"{s}: scorePerfect {seed['scorePerfect']!r}") - for i, r in enumerate(seed.get("ranges") or []): - if not is_int(r.get("rangeScorePerfect")): - why = "missing" if "rangeScorePerfect" not in r else "not an int" - g.fail(f"{s} range {i}: rangeScorePerfect {why}") - rw = seed["rangeWeights"] - if rw is None: - null_rw += 1 - elif not (isinstance(rw, list) and len(rw) == kinds and all( - k is None or (isinstance(k, list) and len(k) == positions and all( - isinstance(x, list) and len(x) == len(ranges) and all(map(is_num, x)) for x in k)) - for k in rw)): - g.fail(f"{s}: rangeWeights are not [kind][position][range] numbers") - else: - null_kind += sum(k is None for k in rw) - rc = seed["rankCheck"] - if rc is not None: - if len(rc.get("ranks") or []) != len(ranges) or not all( - is_int(x) and 1 <= x <= RANKS for x in rc.get("ranks") or []): - g.fail(f"{s}: rankCheck ranks are not one rank per range") - elif not within(rc): - g.fail(f"{s}: the rank check deck is not within its bound") + g.fail(f"{w} range {i}: rankBonusPercents[0] is not rankBonusPercent") + expected = d.get("expectation") + if expected is None: + if d.get("unplayable") is None: + g.fail(f"{w}: no Gekisou expectation") + continue + for key in ("scorePerfect", "rangeWeights", "rankCheck"): + if key not in expected: + g.fail(f"{w}: expectation has no {key}") + rw = expected.get("rangeWeights") + if rw is None: + null_rw += 1 + elif isinstance(rw, list): + null_kind += sum(k is None for k in rw) if null_rw: - g.warn(f"{null_rw} seeds have rangeWeights null (overlapping ranges: no rank scenarios on those charts)") + g.warn(f"{null_rw} charts have rangeWeights null (no linear rank scenarios)") if null_kind: g.warn(f"{null_kind} rangeWeights kinds are null (conditions on the confirmed rank)") if null_off: g.warn(f"{null_off} Gekisou off weight kinds are null (conditions on the Gekisou state)") - g.note = "offSeeds, rankBonusPercents, scorePerfect, rangeWeights, rankCheck, rangeScorePerfect" + g.note = "offSeeds, expected Perfect scores and bonuses, rangeWeights and rank checks" -APTITUDE_KEYS = ("plainKind", "host", "seedRule", "shapes") -SEED_RULE_KEYS = ("deterministicTest", "batches", "relative", "baseline", "crossSeeds") +APTITUDE_KEYS = ("plainKind", "host", "law", "shapes") SHAPE_KEYS = ("id", "source", "mission", "bandCondition", "effects", "skills") EFFECT_INTS = ("effectType", "triggerType", "effectValue", "maxEffectValue", "effectLimitCount", "effectExecuteLimitCount") EFFECT_GROUPS = ("trigger", "condition", "release", "reset") FACTOR_INTS = ("judgedNotes", "justNotes", "perfectNotes", "tailNotes", "comboAtStart") -VARIANT_KEYS = ("shape", "bandMatch", "deterministic", "seeds", "seTargetMet", "crossSeeds", "score", "scorePerfect", - "tail", "tailPerfect", "converted", "ranges", "weights", "rangeWeights", "check") -VARIANT_PAIRS = ("score", "scorePerfect", "tail", "tailPerfect", "converted") -VARIANT_RANGE_PAIRS = ("rangeScore", "rankBonus", "rangeScorePerfect", "maxCombo", "justCount", "luckPoints") -APTITUDE_CHECK_KEYS = ("seed", "ranks", "deck", "exact", "predicted", "bound") def pair(v) -> bool: - """A [mean, standard error]: two finite numbers, the error not negative.""" + """A [center, outward radius]: two finite numbers, the radius not negative.""" return isinstance(v, list) and len(v) == 2 and is_num(v[0]) and is_num(v[1]) and v[1] >= 0 @@ -831,14 +793,7 @@ def close(a: float, b: float) -> bool: def gate_aptitude(doc, ctx: Context, g: Gate): - """The charts' Gekisou skill aptitude: deck.gekisouAptitude (its plain kind the page's, the seed rule, shapes - numbered from 0: source, mission, band condition, effect rows, skills); every chart's deck.gekisouAptitude, null - exactly when the chart is unplayable with Gekisou on, has no Gekisou range or there is no shape; else factors per - range and one variant per shape of the chart's missions (or mission 4) in shape order, a band condition shape's - bandMatch true then false: every [mean, se] two finite numbers with se >= 0 (0 when deterministic), ranges per - range, tail = score - the ranges' rangeScore and rankBonus, the seeds and the cross seeds by the seed rule, weights - and rangeWeights where the plain kind and the chart's rank weights are, the check within its bound. Every - variant must meet the seed rule's standard error target, including at the sample cap.""" + """Validate the current shape catalog and every independent nominal aptitude result.""" deck = doc.get("deck") if not isinstance(deck, dict): g.fail("deck is null: no Gekisou skill aptitude") @@ -858,15 +813,8 @@ def gate_aptitude(doc, ctx: Context, g: Gate): g.fail(f"deck.gekisouAptitude.plainKind {pk!r}, the page's plain kind is {plain!r}") if not (isinstance(head.get("host"), str) and head["host"].strip()): g.fail("deck.gekisouAptitude.host: no text") - rule = head.get("seedRule") if isinstance(head.get("seedRule"), dict) else {} - batches = rule.get("batches") - if head and not (all(k in rule for k in SEED_RULE_KEYS) and is_int(rule["deterministicTest"]) - and rule["deterministicTest"] >= 1 and isinstance(batches, list) and batches - and all(map(is_int, batches)) and batches == sorted(set(batches)) and batches[0] >= 1 - and is_num(rule["relative"]) and rule["relative"] >= 0 and is_num(rule["baseline"]) - and rule["baseline"] >= 0 and is_int(rule["crossSeeds"]) and rule["crossSeeds"] >= 1): - g.fail(f"deck.gekisouAptitude.seedRule {rule!r}"[:200]) - rule, batches = {}, None + if head.get("law") != "independent nominal lottery and skill probabilities": + g.fail(f"deck.gekisouAptitude.law {head.get('law')!r}") # the shapes shapes = head.get("shapes") if isinstance(head.get("shapes"), list) else [] @@ -948,201 +896,22 @@ def gate_aptitude(doc, ctx: Context, g: Gate): g.fail(f"{w}: skill {k!r} is not an id, a level and (with a band condition alone) member targets " f"and bands"[:240]) - # the charts - aptitudes = nulls = variants = deterministic = 0 - missed = [] + from nnnotes.musicdata import validate_aptitude_statistics + aptitudes = variants = 0 for song, chart in charts_of(doc): w, d = where(song, chart), chart.get("deck") if not isinstance(d, dict): - continue # the deck gate fails it - if "gekisouAptitude" not in d: - g.fail(f"{w}: no deck.gekisouAptitude") - continue - a, ranges, dseeds = d["gekisouAptitude"], d.get("ranges") or [], d.get("seeds") or [] - why = ("unplayable with Gekisou on" if d.get("unplayable") else "without a Gekisou range" if not ranges - else None if shapes else "without a Gekisou skill shape") - if a is None: - nulls += 1 - if why is None: - g.fail(f"{w}: deck.gekisouAptitude is null, but the chart is playable with Gekisou on") continue - if why is not None: - g.fail(f"{w}: a Gekisou skill aptitude on a chart {why}") - continue - if not (isinstance(a, dict) and isinstance(a.get("factors"), list) and isinstance(a.get("variants"), list)): - g.fail(f"{w}: deck.gekisouAptitude has no factors and variants lists") + try: + validate_aptitude_statistics(head, native_numbers({**d, "judgedNotes": chart["notes"]["judged"]}), w) + except (ValueError, TypeError, KeyError, IndexError) as e: + g.fail(str(e)) continue - aptitudes += 1 - positions = d.get("positions") - missions = {r.get("mission") for r in ranges if isinstance(r, dict)} - linear = bool(dseeds) and dseeds[0].get("rangeWeights") is not None - - # factors - if len(a["factors"]) != len(ranges): - g.fail(f"{w}: {len(a['factors'])} factors for {len(ranges)} ranges") - for j, (f, r) in enumerate(zip(a["factors"], ranges, strict=False)): - f, r = (f if isinstance(f, dict) else {}), (r if isinstance(r, dict) else {}) - bad = [k for k in FACTOR_INTS if not (is_int(f.get(k)) and f[k] >= 0)] - if not pair(f.get("lotteries")): - bad.append("lotteries") - if bad: - g.fail(f"{w} factors {j}: {', '.join(bad)} missing or not counts") - continue - if r.get("mission") != JUST_MISSION and (f["justNotes"] or f["perfectNotes"]): - g.fail(f"{w} factors {j}: Just or Perfect notes in a range without the Just mission") - if f["justNotes"] + f["perfectNotes"] > f["judgedNotes"]: - g.fail(f"{w} factors {j}: {f['justNotes']} Just and {f['perfectNotes']} Perfect notes of " - f"{f['judgedNotes']} judged") - at = [s["ranges"][j] for s in dseeds if isinstance(s.get("ranges"), list) and len(s["ranges"]) > j - and isinstance(s["ranges"][j], dict)] - lots = [sum(x["lotResults"]) for x in at - if isinstance(x.get("lotResults"), list) and all(map(is_int, x["lotResults"]))] - if r.get("mission") != LUCK_MISSION and f["lotteries"] != [0, 0]: - g.fail(f"{w} factors {j}: lotteries {f['lotteries']} in a range without the luck mission") - elif lots and len(lots) == len(dseeds) and not close(f["lotteries"][0], sum(lots) / len(lots)): - g.fail(f"{w} factors {j}: lotteries {f['lotteries'][0]}, deck.seeds' lotResults give " - f"{sum(lots) / len(lots)}") - - # the variants: which, in order - want = [(s["id"], match) for s in sorted(by_id.values(), key=lambda s: s["id"]) - if s["mission"] == 4 or s["mission"] in missions - for match in ((True, False) if s["bandCondition"] is True else (None,))] - got = [(v.get("shape"), v.get("bandMatch")) if isinstance(v, dict) else None for v in a["variants"]] - unknown = [v[0] for v in got if v is not None and not (is_int(v[0]) and v[0] in by_id)] - other = [v[0] for v in got if v is not None and is_int(v[0]) and v[0] in by_id - and by_id[v[0]]["mission"] != 4 and by_id[v[0]]["mission"] not in missions] - if unknown: - g.fail(f"{w}: variants of shapes {sorted(set(map(repr, unknown)))} not in deck.gekisouAptitude.shapes") - if other: - g.fail(f"{w}: variants of shapes {sorted(set(other))} of a mission the chart does not play") - if got != want and not unknown and not other: - lacking = [x for x in want if x not in got] - g.fail(f"{w}: variants {'lack ' + repr(lacking[:6]) if lacking else 'not in shape order, true first'}" - f" ({len(got)} for {len(want)})") - - for v in a["variants"]: - if not isinstance(v, dict): - g.fail(f"{w}: a variant {v!r}") - continue - s = f"{w} shape {v.get('shape')}" + ("" if v.get("bandMatch") is None else f" {v['bandMatch']}") - absent = [k for k in VARIANT_KEYS if k not in v] - if absent: - g.fail(f"{s}: no {', '.join(absent)}") - continue - variants += 1 - shape = by_id.get(v["shape"]) if is_int(v["shape"]) else None - det = v["deterministic"] - if not isinstance(det, bool): - g.fail(f"{s}: deterministic {det!r}") - det = False - deterministic += det - if v["bandMatch"] is not None and not isinstance(v["bandMatch"], bool): - g.fail(f"{s}: bandMatch {v['bandMatch']!r}") - elif shape is not None and (v["bandMatch"] is None) == (shape.get("bandCondition") is True): - g.fail(f"{s}: bandMatch {v['bandMatch']!r} for a shape " - f"{'with' if v['bandMatch'] is None else 'without'} a band condition") - n, cross, met = v["seeds"], v["crossSeeds"], v["seTargetMet"] - if not (is_int(n) and n >= 1 and isinstance(met, bool)): - g.fail(f"{s}: seeds {n!r}, seTargetMet {met!r}") - elif det and (n != 1 or not met): - g.fail(f"{s}: deterministic, but {n} seeds and seTargetMet {met}") - elif not det and batches and (n not in batches or (not met and n != batches[-1])): - g.fail(f"{s}: {n} seeds (seTargetMet {met}), not a batch of the seed rule {batches}") - elif not det and not met: - missed.append(f"{chart.get('scoreId')} shape {v['shape']}") - if (not is_int(cross) or cross < 1 or (is_int(n) and is_int(rule.get("crossSeeds")) - and cross != min(n, rule["crossSeeds"]))): - g.fail(f"{s}: crossSeeds {cross!r}, expected min({n}, {rule['crossSeeds']})") - - # every [mean, se] - values = {k: v[k] for k in VARIANT_PAIRS} - rs = v["ranges"] - if not isinstance(rs, list) or len(rs) != len(ranges): - g.fail(f"{s}: {len(rs) if isinstance(rs, list) else repr(rs)} range results for {len(ranges)} ranges") - rs = [] - for j, r in enumerate(rs): - for k in VARIANT_RANGE_PAIRS: - values[f"ranges[{j}].{k}"] = r.get(k) if isinstance(r, dict) else None - wt, rw = v["weights"], v["rangeWeights"] - if plain is None: - if wt is not None or rw is not None: - g.fail(f"{s}: weights or rangeWeights without a plain kind") - else: - if not (isinstance(wt, list) and len(wt) == positions): - g.fail(f"{s}: weights are not one [mean, se] per position") - else: - values.update({f"weights[{k}]": x for k, x in enumerate(wt)}) - if not linear: - if rw is not None: - g.fail(f"{s}: rangeWeights, but deck.seeds[0].rangeWeights is null") - elif not (isinstance(rw, list) and len(rw) == positions and all( - isinstance(x, list) and len(x) == len(ranges) for x in rw)): - g.fail(f"{s}: rangeWeights are not [position][range] [mean, se]") - else: - values.update({f"rangeWeights[{k}][{j}]": y for k, x in enumerate(rw) for j, y in enumerate(x)}) - bad = [k for k, x in values.items() if not pair(x)] - if bad: - g.fail(f"{s}: {', '.join(bad[:6])}{' ...' if len(bad) > 6 else ''} not [mean, se] (finite, se >= 0)") - elif det and any(x[1] for x in values.values()): - g.fail(f"{s}: deterministic, but a standard error is not 0: " - f"{', '.join(k for k, x in values.items() if x[1])[:160]}") - elif rs and all(pair(r.get(k)) for r in rs for k in ("rangeScore", "rankBonus")): - inside = sum(r["rangeScore"][0] + r["rankBonus"][0] for r in rs) - if abs(v["tail"][0] - (v["score"][0] - inside)) > ( - 0.0005 * (2 + 2 * len(rs)) + APTITUDE_SLACK): - g.fail(f"{s}: tail {v['tail'][0]} is not score {v['score'][0]} less the ranges' rangeScore and " - f"rankBonus {inside}") - - # Perfect rank-bonus deltas are not exported. Only a necessary rounding bound can be checked - # for stochastic means: each difference of two truncated bonuses is within 2 points of delta*p/100. - if not bad and len(rs) == len(ranges): - perfect_terms = [r["rangeScorePerfect"][0] * (1 + info["rankBonusPercent"] / 100) - for r, info in zip(rs, ranges, strict=True)] - rounding = 0.001 + sum(0.0005 * abs(1 + info["rankBonusPercent"] / 100) for info in ranges) - residual = v["scorePerfect"][0] - v["tailPerfect"][0] - sum(perfect_terms) - if abs(residual) > 2 * len(ranges) + rounding + APTITUDE_SLACK: - g.fail(f"{s}: tailPerfect violates the necessary Perfect rank-bonus rounding bound") - - # Deterministic point deltas are integers. Perfect rank bonuses can then be recovered exactly - # from the first baseline seed (stochastic Perfect rank-bonus deltas are not exported). - if det and not bad and rs and dseeds: - points = [x[0] for k, x in values.items() if not k.startswith(("weights", "rangeWeights"))] - if not all(float(x).is_integer() for x in points): - g.fail(f"{s}: deterministic, but a point delta is not an integer") - else: - perfect = 0 - for r, info, base in zip(rs, ranges, dseeds[0]["ranges"], strict=True): - delta, baseline = int(r["rangeScorePerfect"][0]), base["rangeScorePerfect"] - percent = info["rankBonusPercent"] - perfect += delta + rank_bonus(baseline + delta, percent) - rank_bonus(baseline, percent) - if v["tailPerfect"][0] != v["scorePerfect"][0] - perfect: - g.fail(f"{s}: tailPerfect is not scorePerfect less the Perfect range scores and bonuses") - - # the check - c = v["check"] - if not (isinstance(c, dict) and all(k in c for k in APTITUDE_CHECK_KEYS)): - g.fail(f"{s}: the check has no {', '.join(APTITUDE_CHECK_KEYS)}") - continue - if dseeds and c["seed"] != dseeds[0].get("seed"): - g.fail(f"{s}: the check's seed {c['seed']!r} is not deck.seeds[0]'s {dseeds[0].get('seed')!r}") - ranks = c["ranks"] - if not (isinstance(ranks, list) and len(ranks) == len(ranges) - and all(is_int(x) and 1 <= x <= RANKS for x in ranks)): - g.fail(f"{s}: the check's ranks {ranks!r} are not one rank per range") - elif not linear and any(x != 1 for x in ranks): - g.fail(f"{s}: the check's ranks {ranks} on a chart whose ranks are not linear (rank 1 alone)") - cd = c["deck"] - if not (isinstance(cd, list) and len(cd) == positions and all( - x is None or (plain is not None and isinstance(x, list) and len(x) == 2 and x[0] == plain - and is_num(x[1])) for x in cd)): - g.fail(f"{s}: the check deck is not a [plain kind, value] or null per position") - if not within(c): - g.fail(f"{s}: the check is not within its bound") - if missed: - g.fail(f"{len(missed)} variants missed the seed rule's standard error target: {', '.join(missed[:10])}" - + (" ..." if len(missed) > 10 else "")) - g.note = (f"{len(shapes)} shapes; {aptitudes} charts with an aptitude, {nulls} null; {variants} variants, " - f"{deterministic} deterministic; plain kind {plain}") + if d.get("gekisouAptitude") is not None: + aptitudes += 1 + variants += len(d["gekisouAptitude"]["variants"]) + g.note = f"{len(shapes)} shapes, {aptitudes} charts, {variants} independent nominal variants" + def gate_gzip(doc, ctx: Context, g: Gate, raw: bytes): diff --git a/.github/scripts/music_data_smoke.mjs b/.github/scripts/music_data_smoke.mjs index 635b456..ac5a10b 100644 --- a/.github/scripts/music_data_smoke.mjs +++ b/.github/scripts/music_data_smoke.mjs @@ -22,6 +22,7 @@ const data = JSON.parse(readFileSync(file, "utf8")); const problems = []; const problem = (m) => problems.push(m); const finite = (v) => typeof v === "number" && Number.isFinite(v); +if (data.format !== "nnnotes.music-data/2") problem(`unsupported music data format: ${data.format}`); const charts = (data.songs || []).flatMap((s) => (s.charts || []).map((c) => ({ song: s, chart: c }))); const rows = catalog.chartRows(data); @@ -35,9 +36,9 @@ const computable = (deck, scenario) => { if (!deck) return false; const has = (s) => s && Array.isArray((s.weights || [])[kind]); if (scenario && scenario.mode === "free") return (deck.offSeeds || []).length > 0 && deck.offSeeds.every(has); - if (deck.unplayable || !(deck.seeds || []).length) return false; + if (deck.unplayable || !deck.expectation) return false; const rank1 = !scenario || ((scenario.ranks || []).every((r) => r === 1) && (scenario.just ?? 1) >= 1); - return deck.seeds.every((s) => has(s) && (rank1 || Array.isArray((s.rangeWeights || [])[kind]))); + return has(deck.expectation) && (rank1 || Array.isArray((deck.expectation.rangeWeights || [])[kind])); }; const has = ranking.scenarioData(data); for (const k of ["free", "ranks", "just"]) if (!has[k]) problem(`scenarioData: no data for the ${k} scenario`); @@ -85,13 +86,14 @@ for (const [name, scenario] of SCENARIOS) { } catalog.histogram(rows, (r) => r.level); -// Aptitude is a separate view, never folded into the default song ranking. Older pinned pages may lack this API; -// report that explicitly until PLAYER_REF is deliberately moved to a page with the aptitude UI. +// Aptitude is a separate view, never folded into the default song ranking. The pinned consumer must support the +// same nominal expectation format as the artifact being published. let aptitudeFigures = 0; -const aptitudeApi = ["aptitudeShapes", "chartVariants", "aptitudeFigures", "aptitudeRate", "aptitudeSe", +const aptitudeApi = ["aptitudeShapes", "chartVariants", "aptitudeFigures", "aptitudeRate", "aptitudeSe", "aptitudeRadius", "masterSkillFactor"].every((k) => typeof ranking[k] === "function") && ["gekisouSkill", "shapeSkills", "shapeBands"].every((k) => typeof catalog[k] === "function"); const near = (a, b) => finite(a) && finite(b) && Math.abs(a - b) <= 1e-8 * Math.max(1, Math.abs(a), Math.abs(b)); +if (data.deck?.gekisouAptitude && !aptitudeApi) problem("aptitude API unavailable in the pinned page"); if (aptitudeApi && data.deck?.gekisouAptitude) { const shapes = ranking.aptitudeShapes(data); if (shapes.size !== data.deck.gekisouAptitude.shapes.length) problem("aptitudeShapes: missing shapes"); @@ -125,11 +127,16 @@ if (aptitudeApi && data.deck?.gekisouAptitude) { for (const variant of variants) { for (const [name, scenario] of SCENARIOS) { const tag = `aptitude chart ${chart.scoreId} shape ${variant.shape}, ${name}`; - const f = ranking.aptitudeFigures(variant, d.ranges, power, scenario); + const f = ranking.aptitudeFigures(variant, d.ranges, power, scenario, true); if (scenario?.mode === "free") { if (f !== null) problem(`${tag}: Free Live has aptitude`); continue; } + const rank1 = (scenario?.ranks || []).slice(0, d.ranges.length).every((r) => r === 1); + if (!rank1 && variant.rangeWeights === null) { + if (f !== null) problem(`${tag}: a nonlinear variant was extrapolated to another rank`); + continue; + } aptitudeFigures++; if (!f || !finite(f.base)) { problem(`${tag}: missing or nonfinite base`); continue; } if (f.weights !== null && (f.weights.length !== d.positions || !f.weights.every(finite))) { @@ -139,28 +146,29 @@ if (aptitudeApi && data.deck?.gekisouAptitude) { if (!near(ranking.aptitudeRate(f, zero), f.base)) problem(`${tag}: no-skill rate`); const rate = ranking.aptitudeRate(f, SKILLS); if (f.weights === null ? rate !== null : !finite(rate)) problem(`${tag}: missing cross-term handling`); - if (ranking.aptitudeSe(f, SKILLS) !== null) problem(`${tag}: SE assigned without covariance`); + if (ranking.aptitudeSe(f, zero) !== null) problem(`${tag}: nominal uncertainty mislabeled as sampling SE`); + if (ranking.aptitudeRadius(f, SKILLS) !== null) problem(`${tag}: uncertainty assigned to unbounded cross terms`); if (scenario === null) { if (!near(f.base, variant.score[0] / power) - || !near(ranking.aptitudeSe(f, zero), variant.score[1] / power)) problem(`${tag}: raw mean/SE`); - } else if (ranking.aptitudeSe(f, zero) !== null) problem(`${tag}: transformed SE is not null`); + || !near(ranking.aptitudeRadius(f, zero), variant.score[1] / power)) problem(`${tag}: raw expectation/interval`); + } else if (ranking.aptitudeRadius(f, zero) !== null) problem(`${tag}: transformed uncertainty is not null`); } - // Only deterministic deltas describe the individual check seed. Never reconstruct a stochastic check - // from sampled means. Ordinary check cards are positional, unlike the UI's random-order expectation. - if (!variant.deterministic || kind === null) continue; + // Every nominal delta can reconstruct its expectation check. Ordinary check cards are positional, + // unlike the UI's random-order expectation. Replay seeds play no part in this calculation. + if (kind === null) continue; const c = variant.check; - const seed = d.seeds.find((s) => s.seed === c.seed); const sc = { mode: "battle", ranks: c.ranks, just: 1, great: 0 }; - const base = ranking.scenarioSeed(seed, d.ranges, kind, sc); - const gain = ranking.aptitudeFigures(variant, d.ranges, power, sc); + const base = ranking.chartFigures(d, kind, power, sc); + const gain = ranking.aptitudeFigures(variant, d.ranges, power, sc, true); if (!base || !gain?.weights) continue; - const predicted = data.deck.model.checkPower * ((base.score / power) + gain.base + const predicted = data.deck.model.checkPower * (base.base + gain.base + c.deck.reduce((sum, card, k) => sum + (card ? ranking.masterSkillFactor(card[1]) * (base.weights[k] + gain.weights[k]) : 0), 0)); // Exported weight rounding adds a small reconstruction error on top of the engine's bound. const rounding = data.deck.model.checkPower * 1e-7 * (1 + d.ranges.length) * d.positions; - if (!finite(predicted) || Math.abs(predicted - c.exact) > c.bound + rounding) { - problem(`aptitude chart ${chart.scoreId} shape ${variant.shape}: deterministic check reconstruction`); + if (!finite(predicted) || !Array.isArray(c.expected) || !c.expected.every(finite) + || Math.abs(predicted - c.expected[0]) + c.expected[1] > c.bound + rounding) { + problem(`aptitude chart ${chart.scoreId} shape ${variant.shape}: nominal check reconstruction`); } } } @@ -172,4 +180,4 @@ if (problems.length) { process.exit(1); } console.log(`page smoke test: ${rows.length} charts, ${SCENARIOS.length} scenarios, ${figures} chart figures; ` - + `plain kind ${kind}, scenarios free/ranks/just; aptitude ${aptitudeApi ? aptitudeFigures + " figures" : "API unavailable (skipped)"}`); + + `plain kind ${kind}, scenarios free/ranks/just; aptitude ${aptitudeFigures} figures`); diff --git a/.github/scripts/test_music_data.py b/.github/scripts/test_music_data.py index 8dae72a..ceef47c 100644 --- a/.github/scripts/test_music_data.py +++ b/.github/scripts/test_music_data.py @@ -5,9 +5,8 @@ The JSON Schema gate uses docs/schema/music-data.schema.json (or $MUSIC_DATA_SCHEMA) when the checkout has it; the page smoke test runs when $MUSIC_DATA_PAGE names ournotes-player's examples/songs (and Node.js is installed). Real -files, when named: $MUSIC_DATA_SAMPLE (a file with the play scenario fields: the content gates pass; one made before -the ranges' luckPoints and the Gekisou skill aptitude, the deck and aptitude gates stop it on those alone) and -$MUSIC_DATA_OLD_SAMPLE (one without the play scenario fields: the scenario gate stops it). +files, when named: $MUSIC_DATA_SAMPLE (a current nnnotes.music-data/2 file: the content gates pass) and +$MUSIC_DATA_OLD_SAMPLE (a legacy sampled file: the deck gate rejects its missing nominal expectations). """ import copy import hashlib @@ -55,11 +54,9 @@ def check_deck(exact): return {"deck": [[0, 5000], None], "exact": exact, "predicted": exact + 0.25, "bound": 7.0} -LUCK_SEEDS = [11, 22] # a luck chart's seeds; else the one seed 0 +LUCK_SEEDS = __import__("nnnotes.musicdata", fromlist=["published_seeds"]).published_seeds(2) # a luck chart's seeds; else the one seed 0 # the Gekisou skill aptitude's shapes: id, source, mission, band condition SHAPES = [(0, "member", 1, False), (1, "support", 2, True), (2, "member", 3, False), (3, "support", 4, False)] -SEED_RULE = {"deterministicTest": 4, "batches": [32, 64, 128, 256, 512, 1024], "relative": 0.01, "baseline": 0.001, - "crossSeeds": 64} def effect(band=False): @@ -71,46 +68,42 @@ def effect(band=False): def aptitude_head(): - return {"plainKind": 0, "host": "one performer with a synthetic empty Gekisou skill", "seedRule": SEED_RULE, + return {"plainKind": 0, "host": "one performer with a synthetic empty Gekisou skill", "law": "independent nominal lottery and skill probabilities", "shapes": [{"id": i, "source": src, "mission": m, "bandCondition": band, "effects": [effect(band)], "skills": [{"id": 10 + i, "level": 5, "memberTargetIds": [41] if band else None, "bandIds": [1] if band else None}]} for i, src, m, band in SHAPES]} -def variant(deck, shape, match, deterministic): - """One shape's aptitude on a chart: deterministic on a chart without a luck range, else on 32 seeds.""" - se = 0 if deterministic else 12.5 - - def p(m): - return [m, se] - ranges = [{"rangeScore": p(300), "rankBonus": p(750), "rangeScorePerfect": p(300), "maxCombo": p(0), - "justCount": p(0), "luckPoints": p(2)} for _ in deck["ranges"]] - n = 1 if deterministic else 32 - linear = deck["seeds"][0]["rangeWeights"] is not None - result = {"shape": shape, "bandMatch": match, "deterministic": deterministic, "seeds": n, "seTargetMet": True, - "crossSeeds": min(n, SEED_RULE["crossSeeds"]), "score": p(1500), "scorePerfect": p(1500), - "tail": p(1500 - 1050 * len(ranges)), "tailPerfect": p(1500 - 1050 * len(ranges)), - "converted": p(0), "ranges": ranges, - "weights": [p(0.01), p(0.02)], - "rangeWeights": [[p(0.001)] * len(ranges), [p(0.002)] * len(ranges)] if linear else None, - "check": {"seed": deck["seeds"][0]["seed"], "ranks": [2] * len(ranges) if linear else [1] * len(ranges), - "deck": [[0, 7000], None], "exact": 5000, "predicted": 5000.5, "bound": 3.0}} - base = deck["seeds"][0] - score = base["score"] + 1500 - weight = base["weights"][0][0] + 0.01 +def variant(deck, shape, match): + """A complete single-shape expectation with an independently reconstructible check.""" + def p(value): + return [value, 0] + ranges = [{"rangeScore": p(300), "rankBonus": p(750), "rankBonusPerfect": p(750), + "rangeScorePerfect": p(300), "maxCombo": p(0), "justCount": p(0), "luckPoints": p(2)} + for _ in deck["ranges"]] + base = deck["expectation"] + linear = base["rangeWeights"] is not None + result = {"shape": shape, "bandMatch": match, "score": p(1500), "scorePerfect": p(1500), + "tail": p(1500 - 1050 * len(ranges)), "tailPerfect": p(1500 - 1050 * len(ranges)), + "converted": p(0), "ranges": ranges, "weights": [p(0.01), p(0.02)], + "rangeWeights": [[p(0.001)] * len(ranges), [p(0.002)] * len(ranges)] if linear else None, + "check": {"ranks": [2] * len(ranges) if linear else [1] * len(ranges), + "deck": [[0, 7000], None], "bound": 3.0}} + score = base["score"][0] + 1500 + weight = base["weights"][0][0][0] + 0.01 if linear: for r, info in zip(base["ranges"], deck["ranges"], strict=True): percent = info["rankBonusPercents"][1] - score += int(r["rangeScore"] * percent / 100) - r["rankBonus"] + 300 * (percent - 250) / 100 + score += r["rangeScore"][0] * percent / 100 - r["rankBonus"][0] + 300 * (percent - 250) / 100 weight += (190 - 250) / 100 * (0.1 + 0.001) predicted = 1000003 * (score / 300000 + 0.7 * weight) - result["check"].update(exact=round(predicted), predicted=predicted) + result["check"].update(expected=[predicted, 0], predicted=[predicted, 0]) return result def aptitude(deck, luck): missions = {r["mission"] for r in deck["ranges"]} - variants = [variant(deck, i, match, deterministic=not luck) for i, _, m, band in SHAPES + variants = [variant(deck, i, match) for i, _, m, band in SHAPES if m == 4 or m in missions for match in ((True, False) if band else (None,))] factors = [{"judgedNotes": 12, "justNotes": 0, "perfectNotes": 0, "tailNotes": 2, "comboAtStart": 5, "lotteries": [4.0, 0.0] if r["mission"] == 2 else [0, 0]} for r in deck["ranges"]] @@ -118,17 +111,22 @@ def aptitude(deck, luck): def deck_chart(luck=False): - def one(n): - return {"seed": n, "score": 120000 + n, "ranges": [{"rangeScore": 4000, "rankBonus": 10000, "maxCombo": 10, - "justCount": 0, "luckPoints": 3 if luck else 0, - "lotResults": [1, 2, 0, 1] if luck else [0, 0, 0, 0], - "rangeScorePerfect": 4000}], - "weights": [[0.5, 0.25]], "check": check_deck(2000), "scorePerfect": 120000 + n, - "rangeWeights": [[[0.1], [0.05]]], "rankCheck": dict(check_deck(1900), ranks=[3])} + def p(value): + return [value, 0] + def checked(value, ranks=None): + return {"deck": [[0, 5000], None], "ranks": ranks, "expected": p(value), + "predicted": [value + 0.25, 0], "bound": 7.0} + expected = {"score": p(120000), "scorePerfect": p(120000), + "ranges": [{"rangeScore": p(4000), "rankBonus": p(10000), "rankBonusPerfect": p(10000), + "rangeScorePerfect": p(4000), "maxCombo": 10, "justCount": 0, + "luckPoints": p(3 if luck else 0), + "lotResults": list(map(p, [1, 2, 0, 1] if luck else [0, 0, 0, 0]))}], + "weights": [[p(0.5), p(0.25)]], "rangeWeights": [[[p(0.1)], [p(0.05)]]], + "check": checked(2000), "rankCheck": checked(1900, [3])} d = {"convertedNoteCount": 20, "skip": 0.01, "events": [[0, 1000], [1, 3000]], "positions": 2, "ranges": [{"index": 0, "mission": 2 if luck else 1, "startMs": 1000, "endMs": 5000, "rankBonusPercent": 250, "rankBonusPercents": [250, 190, 160, 100, 100]}], - "justNotes": 0, "seeds": [one(n) for n in (LUCK_SEEDS if luck else [0])], + "justNotes": 0, "expectation": expected, "replaySeeds": LUCK_SEEDS if luck else [0], "offSeeds": [{"seed": 0, "score": 90000, "weights": [[0.4, 0.2]], "check": check_deck(1500)}], "unplayable": None} d["gekisouAptitude"] = aptitude(d, luck) @@ -160,17 +158,17 @@ def song(i, charts): def sample() -> dict: kind = {"id": 0, "effectType": 2000, "activationTimeSecond": 5.0, "durationMs": 5000, "skillTargetIds": [], "skillConditionGroup": 0, "skillReleaseConditionGroup": 0, "effectLimitCount": 0, - "effectExecuteLimitCount": 0, "effectExecuteLimitResetConditionGroup": 0, "rows": [1], "values": [10000]} + "effectExecuteLimitCount": 0, "effectExecuteLimitResetConditionGroup": 0, "rows": 1, "values": [10000]} return { - "format": "nnnotes.music-data/1", + "format": "nnnotes.music-data/2", "provenance": { "region": "tw", "client": {"versionName": "1.0.1", "versionCode": 25}, "catalog": {"resourceVersion": None, "sha256": "cd" * 32}, "master": {"source": "api", "version": "v-test", "tables": {t: {"sha256": BIN[t]} for t in TABLES}}, "exporter": {"name": "nnnotes", "version": "0.1.2", "chartFormat": "nnnotes.live-score/1"}, "deck": {"name": "ournotes-deck", "version": "0.0.1", - "source": "https://github.com/empty-sekai/ournotes-deck", "commit": DECK, - "format": "ournotes-deck.chart-stats/2"}}, + "source": "https://github.com/empty-sekai/ournotes-deck", "commit": DECK, "sourceSha256": "de" * 32, + "format": "ournotes-deck.chart-stats/3"}}, "languages": LANGS, "bands": [{"id": 1, "name": text("band"), "mainColor": "#3388BB", "subColor": "#FFFFFF"}], "characters": [{"id": 1, "bandId": 1, "name": text("ch"), "shortName": text("c"), "mainColor": "#77BBDD"}], @@ -234,7 +232,7 @@ def failures(report: dict) -> list: def seed(doc, song=0, chart=0): - return doc["songs"][song]["charts"][chart]["deck"]["seeds"][0] + return doc["songs"][song]["charts"][chart]["deck"]["expectation"] def apt(doc, song=0, chart=0): @@ -250,15 +248,14 @@ def shape(doc, i): def nonlinear(doc, song=0, chart=0): - """A chart whose ranks are not linear (overlapping ranges): no rangeWeights, the checks at rank 1.""" + """An overlapping-range chart has no linear response and checks rank 1.""" d = doc["songs"][song]["charts"][chart]["deck"] - for s in d["seeds"]: - s.update(rangeWeights=None, rankCheck=None) + d["expectation"].update(rangeWeights=None, rankCheck=None) for v in d["gekisouAptitude"]["variants"]: v["rangeWeights"] = None v["check"]["ranks"] = [1] * len(d["ranges"]) - predicted = 1000003 * ((d["seeds"][0]["score"] + v["score"][0]) / 300000 + 0.7 * 0.51) - v["check"].update(exact=round(predicted), predicted=predicted) + predicted = 1000003 * ((d["expectation"]["score"][0] + v["score"][0]) / 300000 + 0.7 * 0.51) + v["check"].update(expected=[predicted, 0], predicted=[predicted, 0]) # ---------------------------------------------------------------- the gates @@ -296,13 +293,13 @@ def test_page_aptitude_check_reconstruction(tmp_path): if page is None or "aptitudeFigures" not in (page / "ranking.js").read_text(encoding="utf-8"): pytest.skip("page does not yet expose the aptitude API") doc = sample() - # Stochastic check values are not reconstructible from means; changing them must not trigger reconstruction. - var(doc, 0, 0, 1)["check"].update(exact=99999999, predicted=99999999) + # Replay seeds do not change any nominal figure or its independent expectation check. + doc["songs"][0]["charts"][1]["deck"]["replaySeeds"] = [] assert gate(run(tmp_path, doc, page=page), "page")["passed"] - # Even a self-consistent exported check is independently rejected when deterministic deltas disagree. - var(doc)["check"].update(exact=99999999, predicted=99999999) + # Even a self-consistent exported check is rejected when the page reconstructs another expectation. + var(doc)["check"].update(expected=[99999999, 0], predicted=[99999999, 0]) g = gate(run(tmp_path, doc, page=page), "page") - assert any("deterministic check reconstruction" in f for f in g["failures"]), g + assert any("nominal check reconstruction" in f for f in g["failures"]), g def unplayable(doc): @@ -310,101 +307,66 @@ def unplayable(doc): @pytest.mark.parametrize("change, name, match", [ - # the play scenario fields (lambda d: d["songs"][0]["charts"][0]["deck"].pop("offSeeds"), "scenarios", "offSeeds missing"), - (lambda d: d["songs"][0]["charts"][1]["deck"]["offSeeds"].append({}), "scenarios", "offSeeds has 2 entries"), - (lambda d: d["songs"][0]["charts"][0]["deck"]["ranges"][0].pop("rankBonusPercents"), "scenarios", - "rankBonusPercents missing"), - (lambda d: d["songs"][0]["charts"][0]["deck"]["ranges"][0].update(rankBonusPercents=[250, 190, 160, 100]), - "scenarios", "rankBonusPercents not five ints"), - (lambda d: d["songs"][0]["charts"][0]["deck"]["ranges"][0].update(rankBonusPercents=[251, 190, 160, 100, 100]), - "scenarios", "is not rankBonusPercent"), + (lambda d: d["songs"][0]["charts"][1]["deck"]["offSeeds"].append({}), "scenarios", "offSeeds missing"), + (lambda d: d["songs"][0]["charts"][0]["deck"]["ranges"][0].pop("rankBonusPercents"), "scenarios", "not five ints"), + (lambda d: d["songs"][0]["charts"][0]["deck"]["ranges"][0].update(rankBonusPercents=[251, 190, 160, 100, 100]), "scenarios", "is not rankBonusPercent"), (lambda d: seed(d).pop("scorePerfect"), "scenarios", "no scorePerfect"), (lambda d: seed(d).pop("rangeWeights"), "scenarios", "no rangeWeights"), (lambda d: seed(d).pop("rankCheck"), "scenarios", "no rankCheck"), - (lambda d: seed(d)["ranges"][0].pop("rangeScorePerfect"), "scenarios", "rangeScorePerfect missing"), - (lambda d: seed(d).update(rangeWeights=[[[0.1]]]), "scenarios", "rangeWeights are not"), - (lambda d: seed(d)["rankCheck"].update(exact=99999), "scenarios", "rank check deck is not within"), - # the Gekisou skill aptitude - (lambda d: d["deck"].pop("gekisouAptitude"), "aptitude", "deck.gekisouAptitude missing"), - (lambda d: d["deck"]["model"].pop("gekisouAptitude"), "aptitude", "deck.model.gekisouAptitude: no text"), + (lambda d: seed(d)["ranges"][0].pop("rankBonusPerfect"), "deck", "outward radius"), + (lambda d: seed(d).update(rangeWeights=[[[[0.1, 0]]]]), "deck", "rangeWeights are not"), + (lambda d: seed(d)["rankCheck"].update(expected=[99999, 0]), "deck", "exceeds its bound"), + (lambda d: d["deck"].pop("gekisouAptitude"), "aptitude", "gekisouAptitude missing"), + (lambda d: d["deck"]["model"].pop("gekisouAptitude"), "aptitude", "no text"), (lambda d: d["deck"]["gekisouAptitude"].update(plainKind=1), "aptitude", "the page's plain kind is 0"), - (lambda d: d["deck"]["kinds"][0].update(durationMs=6000), "aptitude", "plainKind 0, the page's plain kind is None"), - (lambda d: d["deck"]["kinds"][0].update(durationMs=6000), "aptitude", "weights or rangeWeights without a plain"), - (lambda d: d["deck"]["gekisouAptitude"].pop("host"), "aptitude", "deck.gekisouAptitude: no host"), - (lambda d: d["deck"]["gekisouAptitude"].update(seedRule=dict(SEED_RULE, batches=[64, 32])), "aptitude", - "deck.gekisouAptitude.seedRule"), - (lambda d: shape(d, 1).update(id=5), "aptitude", "ids are not 0, 1, 2, ... in order"), - (lambda d: shape(d, 0).update(source="card"), "aptitude", "shape 0: source 'card'"), - (lambda d: shape(d, 2).update(mission=5), "aptitude", "shape 2: mission 5"), - (lambda d: shape(d, 0).update(bandCondition=True), "aptitude", "bandCondition True (a support skill's alone)"), - (lambda d: shape(d, 3).update(bandCondition=True), "aptitude", - "shape 3: bandCondition True, its effects have 0 condition 5000"), - (lambda d: shape(d, 1)["effects"][0]["condition"][0][0].update(targetIds=[41]), "aptitude", - "condition 5000 targetIds not null"), - (lambda d: shape(d, 0)["effects"][0].pop("cumulative"), "aptitude", "effect 0: cumulative missing or malformed"), - (lambda d: shape(d, 0)["effects"][0].update(effectValue=1.5), "aptitude", "effectValue missing or malformed"), - (lambda d: shape(d, 0)["effects"][0].update(reset=None), "aptitude", "reset missing or malformed"), + (lambda d: d["deck"]["gekisouAptitude"].pop("host"), "aptitude", "no host"), + (lambda d: d["deck"]["gekisouAptitude"].update(law="sampled"), "aptitude", ".law"), + (lambda d: shape(d, 1).update(id=5), "aptitude", "ids are not 0, 1, 2"), + (lambda d: shape(d, 0).update(source="card"), "aptitude", "source 'card'"), + (lambda d: shape(d, 2).update(mission=5), "aptitude", "mission 5"), + (lambda d: shape(d, 1)["effects"][0]["condition"][0][0].update(targetIds=[41]), "aptitude", "targetIds not null"), + (lambda d: shape(d, 0)["effects"][0].pop("cumulative"), "aptitude", "cumulative missing"), + (lambda d: shape(d, 0)["effects"][0].update(effectValue=1.5), "aptitude", "effectValue missing"), + (lambda d: shape(d, 0)["effects"][0].update(reset=None), "aptitude", "reset missing"), (lambda d: shape(d, 1)["skills"][0].update(memberTargetIds=None), "aptitude", "is not an id, a level"), - (lambda d: shape(d, 0)["skills"][0].update(bandIds=[1]), "aptitude", "is not an id, a level"), - (lambda d: shape(d, 0).update(skills=[]), "aptitude", "shape 0: no skills"), - (lambda d: d["songs"][0]["charts"][0]["deck"].pop("gekisouAptitude"), "aptitude", "no deck.gekisouAptitude"), - (lambda d: d["songs"][0]["charts"][0]["deck"].update(gekisouAptitude=None), "aptitude", - "deck.gekisouAptitude is null, but the chart is playable"), - (unplayable, "aptitude", "a Gekisou skill aptitude on a chart unplayable with Gekisou on"), + (lambda d: shape(d, 0).update(skills=[]), "aptitude", "no skills"), + (lambda d: d["songs"][0]["charts"][0]["deck"].update(gekisouAptitude=None), "aptitude", "none (gekisouAptitude"), + (unplayable, "aptitude", "with a chart unplayable"), (lambda d: apt(d)["factors"].pop(), "aptitude", "0 factors for 1 ranges"), - (lambda d: apt(d)["factors"][0].update(justNotes=3), "aptitude", "Just or Perfect notes in a range without"), - (lambda d: apt(d)["factors"][0].update(tailNotes=-1), "aptitude", "tailNotes missing or not counts"), - (lambda d: apt(d)["factors"][0].update(lotteries=[1, 0]), "aptitude", "in a range without the luck mission"), - (lambda d: apt(d, 0, 1)["factors"][0].update(lotteries=[3.0, 0]), "aptitude", "deck.seeds' lotResults give 4.0"), - (lambda d: apt(d)["variants"].pop(0), "aptitude", "variants lack [(0, None)]"), - (lambda d: apt(d)["variants"].reverse(), "aptitude", "not in shape order, true first"), - (lambda d: apt(d, 0, 1)["variants"].pop(1), "aptitude", "variants lack [(1, False)]"), - (lambda d: var(d).update(shape=9), "aptitude", "variants of shapes ['9'] not in deck.gekisouAptitude.shapes"), - (lambda d: var(d).update(shape=2), "aptitude", "variants of shapes [2] of a mission the chart does not play"), - (lambda d: var(d, 0, 0, 1).update(bandMatch=None), "aptitude", "bandMatch None for a shape with a band"), - (lambda d: var(d).update(bandMatch=True), "aptitude", "bandMatch True for a shape without a band"), - (lambda d: var(d).pop("tail"), "aptitude", "shape 0: no tail"), - (lambda d: var(d).update(score=[1500]), "aptitude", "score not [mean, se]"), - (lambda d: var(d).update(score=[1500, -1]), "aptitude", "score not [mean, se]"), - (lambda d: var(d).update(converted=[float("nan"), 0]), "aptitude", "converted not [mean, se]"), - (lambda d: var(d)["ranges"][0].update(luckPoints=None), "aptitude", "ranges[0].luckPoints not [mean, se]"), - (lambda d: var(d).update(score=[1500, 1]), "aptitude", "deterministic, but a standard error is not 0: score"), - (lambda d: var(d).update(seeds=2), "aptitude", "deterministic, but 2 seeds"), - (lambda d: var(d, 0, 0, 1).update(seeds=33), "aptitude", "33 seeds (seTargetMet True), not a batch"), - (lambda d: var(d, 0, 0, 1).update(seTargetMet=False), "aptitude", "32 seeds (seTargetMet False), not a batch"), - (lambda d: var(d, 0, 0, 1).update(crossSeeds=64), "aptitude", "crossSeeds 64, expected min(32, 64)"), - (lambda d: var(d)["ranges"].append(dict(var(d)["ranges"][0])), "aptitude", "2 range results for 1 ranges"), - (lambda d: var(d).update(tail=[451, 0]), "aptitude", "tail 451 is not score 1500 less the ranges' rangeScore"), - (lambda d: var(d)["weights"].append([0, 0]), "aptitude", "weights are not one [mean, se] per position"), - (lambda d: seed(d).update(rangeWeights=None, rankCheck=None), "aptitude", - "rangeWeights, but deck.seeds[0].rangeWeights is null"), - (lambda d: var(d).update(rangeWeights=[[[0, 0]]]), "aptitude", "rangeWeights are not [position][range]"), - (lambda d: (nonlinear(d), var(d)["check"].update(ranks=[2])), "aptitude", "ranks are not linear (rank 1 alone)"), - (lambda d: var(d)["check"].update(seed=5), "aptitude", "the check's seed 5 is not deck.seeds[0]'s 0"), - (lambda d: var(d, 0, 0, 1)["check"].update(seed=22), "aptitude", "the check's seed 22 is not deck.seeds[0]'s 11"), - (lambda d: var(d)["check"].update(ranks=[6]), "aptitude", "the check's ranks [6] are not one rank per range"), - (lambda d: var(d)["check"].update(deck=[[1, 7000], None]), "aptitude", "not a [plain kind, value] or null"), - (lambda d: var(d)["check"].update(exact=99999999), "aptitude", "the check is not within its bound"), - (lambda d: var(d)["check"].pop("bound"), "aptitude", "the check has no seed, ranks"), - (lambda d: var(d).update(tailPerfect=[449, 0]), "aptitude", "tailPerfect is not scorePerfect"), - (lambda d: var(d).update(converted=[0.5, 0]), "aptitude", "a point delta is not an integer"), - # the deck statistics + (lambda d: apt(d)["factors"][0].update(justNotes=3), "aptitude", "outside a Just"), + (lambda d: apt(d)["factors"][0].update(tailNotes=-1), "aptitude", "not {"), + (lambda d: apt(d, 0, 1)["factors"][0].update(lotteries=[3.0, 0]), "aptitude", "expected results"), + (lambda d: apt(d)["variants"].pop(0), "aptitude", "variants (shape, bandMatch)"), + (lambda d: apt(d)["variants"].reverse(), "aptitude", "variants (shape, bandMatch)"), + (lambda d: var(d).update(shape=9), "aptitude", "variants (shape, bandMatch)"), + (lambda d: var(d, 0, 0, 1).update(bandMatch=None), "aptitude", "variants (shape, bandMatch)"), + (lambda d: var(d).pop("tail"), "aptitude", "tail None is not"), + (lambda d: var(d).update(score=[1500]), "aptitude", "outward radius"), + (lambda d: var(d).update(score=[1500, -1]), "aptitude", "outward radius"), + (lambda d: var(d).update(converted=[float("nan"), 0]), "aptitude", "outward radius"), + (lambda d: var(d).update(converted=[0, 0.1]), "aptitude", "deterministic integer"), + (lambda d: var(d).update(tail=[451, 0]), "aptitude", "tail interval is inconsistent"), + (lambda d: var(d).update(tailPerfect=[449, 0]), "aptitude", "tailPerfect interval is inconsistent"), + (lambda d: var(d)["weights"].append([0, 0]), "aptitude", "weights are not"), + (lambda d: seed(d).update(rangeWeights=None, rankCheck=None), "aptitude", "rank-linear domain"), + (lambda d: var(d).update(rangeWeights=[[[0, 0]]]), "aptitude", "rangeWeights are not"), + (lambda d: (nonlinear(d), var(d)["check"].update(ranks=[2])), "aptitude", "check ranks"), + (lambda d: var(d)["check"].update(ranks=[6]), "aptitude", "check ranks"), + (lambda d: var(d)["check"].update(deck=[[1, 7000], None]), "aptitude", "check deck"), + (lambda d: var(d)["check"].update(expected=[99999999, 0]), "aptitude", "exceeds its bound"), + (lambda d: var(d)["check"].pop("bound"), "aptitude", "expectation check"), (lambda d: d.update(deck=None), "deck", "deck is null"), (lambda d: d["songs"][0]["charts"][0].update(deck=None), "deck", "no deck statistics"), - (lambda d: seed(d)["check"].update(exact=99999), "deck", "check deck is not within"), - (lambda d: seed(d).update(weights=[[0.5]]), "deck", "weights are not"), - (lambda d: d["songs"][0]["charts"][0]["deck"].update(seeds=[]), "deck", "no seeds"), - (unplayable, "deck", "unplayable, but has Gekisou on seeds"), - (lambda d: d["songs"][0]["charts"][0]["deck"].update(events=[[0, 1000]]), "deck", "1 skill events"), - (lambda d: seed(d).update(seed=5), "deck", "seeds [5] without a luck range, expected the one seed 0"), - (lambda d: d["songs"][0]["charts"][1]["deck"]["seeds"].pop(), "deck", "1 seeds on a luck chart"), - (lambda d: d["songs"][0]["charts"][1]["deck"]["seeds"][1].update(seed=11), "deck", "2 seeds on a luck chart"), - (lambda d: d["songs"][1]["charts"][0]["deck"]["seeds"][1].update(seed=33), "deck", - "its 2 seeds are not the 2 of the first luck chart"), - (lambda d: seed(d)["ranges"][0].update(rankBonus=9999), "deck", "rankBonus 9999 is not trunc(4000 * 250 / 100)"), - (lambda d: seed(d)["ranges"][0].pop("luckPoints"), "deck", "seed 0 range 0: luckPoints missing"), - (lambda d: seed(d, 0, 1)["ranges"][0].update(luckPoints=2.5), "deck", "seed 11 range 0: luckPoints not an int"), + (lambda d: seed(d)["check"].update(expected=[99999, 0]), "deck", "exceeds its bound"), + (lambda d: seed(d).update(weights=[[[0.5, 0]]]), "deck", "weights are not"), + (lambda d: d["songs"][0]["charts"][0]["deck"].update(replaySeeds=[]), "deck", "nonempty integer"), + (unplayable, "deck", "unplayable, but has replay seeds"), + (lambda d: d["songs"][0]["charts"][0]["deck"].update(events=[[0, 1000]]), "deck", "skill events do not match"), + (lambda d: d["songs"][0]["charts"][0]["deck"].update(replaySeeds=[5]), "deck", "must be [0]"), + (lambda d: d["songs"][0]["charts"][1]["deck"].update(replaySeeds=[11, 22]), "deck", "published seed prefix"), + (lambda d: seed(d)["ranges"][0].pop("luckPoints"), "deck", "outward radius"), + (lambda d: seed(d)["ranges"][0].update(luckPoints=[2.5, 0]), "deck", "outside a luck range"), # numbers (lambda d: seed(d)["weights"][0].__setitem__(1, float("nan")), "finite", "weights[0][1]: not finite"), (lambda d: d["songs"][1]["charts"][0]["bpm"].update(main=float("inf")), "finite", "bpm.main: not finite"), @@ -429,7 +391,7 @@ def unplayable(doc): (lambda d: d["songs"][0]["bgm"]["length"].update(durationMs=1200000, samples=48000 * 1200), "bgm", "bounds"), # provenance - (lambda d: d.update(format="nnnotes.music-data/2"), "provenance", "format"), + (lambda d: d.update(format="nnnotes.music-data/99"), "provenance", "format"), (lambda d: d["provenance"].update(region="en"), "provenance", "region 'en'"), (lambda d: d["provenance"]["master"].update(version="other"), "provenance", "the snapshot's 'v-test'"), (lambda d: d["provenance"]["master"].update(source="embedded"), "provenance", "master.source"), @@ -465,8 +427,10 @@ def test_a_jacket_file_is_missing(tmp_path): def test_warnings_do_not_fail(tmp_path): doc = sample() nonlinear(doc) # overlapping ranges - seed(doc, 0, 1)["rangeWeights"][0] = None # a kind reading the confirmed rank + seed(doc, 0, 1)["rangeWeights"][0] = None + seed(doc, 0, 1)["rankCheck"] = None # a kind reading the confirmed rank doc["songs"][1]["charts"][0]["deck"]["offSeeds"][0]["weights"][0] = None + doc["songs"][1]["charts"][0]["deck"]["offSeeds"][0]["check"]["deck"] = [None, None] doc["songs"][0]["master"]["MasterLiveMusic"]["_rate"] = float("inf") # 1e999 in master data as served doc["songs"][1]["title"]["zh-Hant"] = "" r = run(tmp_path, doc, page=page_path()) @@ -475,15 +439,12 @@ def test_warnings_do_not_fail(tmp_path): assert gate(r, "references")["warnings"] == ["songs without a zh-Hant title: 100002"] -def test_aptitude_cap_does_not_accept_unmet_target(tmp_path): +def test_uncertainty_cannot_conceal_a_failed_expectation_check(tmp_path): doc = sample() - var(doc, 0, 0, 1).update(seeds=1024, seTargetMet=False, crossSeeds=64) # the last batch, not the target + var(doc, 0, 0, 1)["check"]["expected"][1] = 100 r = run(tmp_path, doc) assert not r["passed"] - assert gate(r, "aptitude")["failures"] == [ - "1 variants missed the seed rule's standard error target: 30 shape 1"] - assert gate(r, "aptitude")["note"] == ("4 shapes; 3 charts with an aptitude, 0 null; 8 variants, " - "2 deterministic; plain kind 0") + assert any("exceeds its bound" in x for x in gate(r, "aptitude")["failures"]) def test_no_gekisou_skill_shapes(tmp_path): @@ -496,16 +457,15 @@ def test_no_gekisou_skill_shapes(tmp_path): assert r["passed"], failures(r) doc["songs"][0]["charts"][0]["deck"]["gekisouAptitude"] = kept g = gate(run(tmp_path, doc), "aptitude") - assert g["failures"] == ["chart 10 (100001 easy): a Gekisou skill aptitude on a chart without a Gekisou skill " - "shape"] + assert any("no shape" in x for x in g["failures"]) def test_an_unplayable_chart_has_no_aptitude(tmp_path): doc = sample() - doc["songs"][1]["charts"][0]["deck"].update(unplayable="more than three fevers", seeds=[], gekisouAptitude=None) + doc["songs"][1]["charts"][0]["deck"].update(unplayable="more than three fevers", replaySeeds=[], expectation=None, gekisouAptitude=None) r = run(tmp_path, doc, page=page_path()) assert r["passed"], failures(r) - assert gate(r, "aptitude")["note"].startswith("4 shapes; 2 charts with an aptitude, 1 null") + assert gate(r, "aptitude")["note"].startswith("4 shapes, 2 charts") def test_gzip_caps(tmp_path, monkeypatch): @@ -550,7 +510,7 @@ def test_report_markdown(tmp_path): doc["songs"][0]["charts"][0]["deck"].pop("offSeeds") text_ = music_data.report_markdown(run(tmp_path, doc)) assert "| scenarios | **failed** (1) |" in text_ - assert "- scenarios failure: chart 10 (100001 easy): offSeeds missing, expected exactly one" in text_ + assert "- scenarios failure: chart 10 (100001 easy): offSeeds missing or not exactly one entry" in text_ def test_deck_package_from_cargo_lock(tmp_path): @@ -573,51 +533,33 @@ def real(name): return Path(p).read_bytes() -def luck_points(raw: bytes) -> bool: - """Whether a file's deck.seeds ranges have luckPoints (a file made before them has none).""" - return any("luckPoints" in r for _, c in music_data.charts_of(json.loads(raw)) - for s in (c.get("deck") or {}).get("seeds") or [] for r in s.get("ranges") or []) - - -def before_luck_points(r: dict) -> bool: - """The deck gate stopped only on the ranges' missing luckPoints.""" - g = gate(r, "deck") - return not g["passed"] and all(f.endswith("luckPoints missing") for f in g["failures"]) - - -def test_a_real_file_without_the_scenario_fields(): +def test_a_real_legacy_file_is_rejected(): raw = real("MUSIC_DATA_OLD_SAMPLE") + assert json.loads(raw)["format"] == "nnnotes.music-data/1" r = gates(raw, Context(language="zh-Hant"), only=CONTENT) - assert [n for n, _ in failures(r)] == (["scenarios"] if luck_points(raw) else ["deck", "scenarios"]) - assert luck_points(raw) or before_luck_points(r) - g = gate(r, "scenarios") - charts = sum(len(s["charts"]) for s in json.loads(raw)["songs"]) - assert g["failureCount"] >= charts and all("offSeeds missing" in f or "rankBonusPercents missing" in f - or "no scorePerfect" in f for f in g["failures"]) + assert not gate(r, "deck")["passed"] + assert any("expectation" in f or "replaySeeds" in f for f in gate(r, "deck")["failures"]) def test_a_real_file_with_the_scenario_fields(tmp_path): raw = real("MUSIC_DATA_SAMPLE") + assert json.loads(raw)["format"] == "nnnotes.music-data/2" (tmp_path / "music-data.json").write_bytes(raw) ctx = Context(language="zh-Hant", page=page_path(), file=tmp_path / "music-data.json", published=raw) r = gates(raw, ctx, only=CONTENT + ("aptitude", "counts", "size", "gzip", "page")) - new = [n for n, ok in (("deck", luck_points(raw)), ("aptitude", "gekisouAptitude" in json.loads(raw)["deck"])) - if not ok] # the gates of fields made after the file - assert [n for n, _ in failures(r)] == new, failures(r) - assert "deck" not in new or before_luck_points(r) + assert r["passed"], failures(r) g = gate(r, "aptitude") - assert "aptitude" not in new or all(f.endswith(("gekisouAptitude missing", "gekisouAptitude: no text", - ": no deck.gekisouAptitude")) for f in g["failures"]), g - assert gate(r, "scenarios")["warningCount"] == 0 print(f"aptitude: {g['note']}; warnings: {g['warnings']}; gzip: {gate(r, 'gzip')['note']}") def test_real_aptitude_deck_sample(): """Optional real chart-stats output, wrapped without modifying its statistics or touching the source file.""" stats = json.loads(real("MUSIC_DATA_APTITUDE_DECK_SAMPLE")) + assert stats["format"] == "ournotes-deck.chart-stats/3" doc = {"deck": {k: stats[k] for k in ("model", "kinds", "gekisouAptitude")}, "songs": [{"id": c["musicId"], "charts": [{"scoreId": c["scoreId"], "difficulty": c["difficulty"], - "skillEventsMs": [e[1] for e in c["events"]], "deck": c}]} for c in stats["charts"]]} + "skillEventsMs": [e[1] for e in c["events"]], "notes": {"judged": c["judgedNotes"]}, + "deck": c}]} for c in stats["charts"]]} report = gates(json.dumps(doc).encode(), Context(), only=("deck", "aptitude", "gzip")) assert report["passed"], failures(report) diff --git a/.github/workflows/music-data-region.yml b/.github/workflows/music-data-region.yml index de0bb1d..261c13b 100644 --- a/.github/workflows/music-data-region.yml +++ b/.github/workflows/music-data-region.yml @@ -29,18 +29,18 @@ env: # moenotes-masterdata-sync: decoded master data by region (index.json lists every file with its SHA-256). MASTERDATA_BASE_URL: ${{ vars.MASTERDATA_BASE_URL || 'https://metadata.bdon.moe' }} MASTERDATA_REGION: ${{ inputs.region }} - # The ournotes-player whose chart data page reads the file: its modules smoke-test every build. No default yet: the - # page with the play scenarios is not merged; a run stops before building until MUSIC_DATA_PLAYER_REF is set. + # This consumer reads nominal chart-stats/3. Explicit overrides must support the same format; the page's modules + # smoke-test the new format before master-data download and every completed artifact before publication. PLAYER_REPOSITORY: ${{ vars.MUSIC_DATA_PLAYER_REPOSITORY || 'empty-sekai/ournotes-player' }} - PLAYER_REF: ${{ vars.MUSIC_DATA_PLAYER_REF || '' }} + PLAYER_REF: ${{ vars.MUSIC_DATA_PLAYER_REF || '946f25c1b50ca1cbb8209ca766056a1a2f6c3aca' }} PLAYFETCH_VERSION: ${{ vars.PLAYFETCH_VERSION || 'v0.92' }} APK_PACKAGE: ${{ inputs.region == 'jp' && 'com.bushiroad.sirius' || vars.STORY_APK_PACKAGE || 'com.bilibili.sirius' }} PYTHON_VERSION: "3.13" PYTHONPATH: src WORK: ${{ github.workspace }}/music-data-work - # The deck statistics of every chart of the last build, under the SHA-256 of the model sources, options, master - # tables and chart they come from (nnnotes music-data --stats-cache): derived numbers the file publishes, no game - # files. A build measures only the charts whose inputs changed. + # Completed native programs, under the model source identity and compiled semantic inputs. A change to a + # chart, kind or shape only recomputes affected programs; current metadata and checks are assembled each run. + # This contains derived numeric results, with no original game files or migration from whole-chart caches. MUSIC_DATA_STATS_CACHE: ${{ github.workspace }}/music-data-work/stats-cache # Bundle keys come from secrets; catalog versions and CDN roots come from the saved master snapshot. NNNOTES_CATALOG_REGION: ${{ inputs.region == 'jp' && 'jp' || 'tw' }} @@ -135,8 +135,8 @@ jobs: uses: actions/cache/restore@v6 with: path: ${{ env.MUSIC_DATA_STATS_CACHE }} - key: music-data-stats-${{ inputs.region }}-${{ github.run_id }}-${{ github.run_attempt }} - restore-keys: music-data-stats-${{ inputs.region }}- + key: music-data-native-v1-${{ inputs.region }}-${{ github.run_id }}-${{ github.run_attempt }} + restore-keys: music-data-native-v1-${{ inputs.region }}- - name: Build env: @@ -152,13 +152,13 @@ jobs: python .github/scripts/music_data.py build "$WORK/out" - name: Keep the chart statistics for the next build - # also after a failed build: nnnotes writes each chart's statistics as soon as they are measured, and a later - # failure (a jacket the game API cannot serve) leaves them valid + # Each completed native computation is saved atomically. Keep those checkpoints after a later + # failure so the next run only computes missing units. if: always() uses: actions/cache/save@v6 with: path: ${{ env.MUSIC_DATA_STATS_CACHE }} - key: music-data-stats-${{ inputs.region }}-${{ github.run_id }}-${{ github.run_attempt }} + key: music-data-native-v1-${{ inputs.region }}-${{ github.run_id }}-${{ github.run_attempt }} - name: Gates id: gates diff --git a/docs/contracts.md b/docs/contracts.md index 2cf4e7f..929ca1a 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -226,7 +226,7 @@ The catalog versions of a store, `nnnotes.catalogs/1` ([schema/catalogs.schema.j ([schema/catalog-diff.schema.json](schema/catalog-diff.schema.json), `nnnotes catalogs diff --json`), are written by the commands. -The music data file of `nnnotes music-data`, `nnnotes.music-data/1` +The music data file of `nnnotes music-data`, `nnnotes.music-data/2` ([schema/music-data.schema.json](schema/music-data.schema.json)), names its format in a `format` field; it is described in [music-data.md](music-data.md). diff --git a/docs/music-data.md b/docs/music-data.md index 90dc74d..da4b2bf 100644 --- a/docs/music-data.md +++ b/docs/music-data.md @@ -6,12 +6,12 @@ ranks, jacket, BGM length), the Gekisou catalog (member cards, snaps and their G chart facts (level, note counts, BPM, chart times, skill events, fevers), and per chart its **deck statistics**: what the chart contributes to the live score whatever the deck, in a solo live (Gekisou off) and in a Gekisou live at every rank, measured by the deck model [ournotes-deck](https://github.com/empty-sekai/ournotes-deck), which nnnotes carries -as its extension module `nnnotes._deck`. The format is `nnnotes.music-data/1`; its JSON Schema is +as its extension module `nnnotes._deck`. The format is `nnnotes.music-data/2`; its JSON Schema is [schema/music-data.schema.json](schema/music-data.schema.json). ``` 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] + [--workers N] [--no-gekisou-aptitude] [--stats-cache DIR] [--no-bgm] [--jackets DIR] -o FILE ``` @@ -26,18 +26,26 @@ nnnotes music-data (--master-files DIR | --apk-master | --decoded-master) [--ful - `--full`: also write the deck model's input, every chart's runtime notes and the master data tables about cards, skills, bonuses, scores and events ([the deck input](#the-deck-input---full)), for tools that run a deck model of their own. -- `--no-deck`: do not run the deck model; every chart's `deck` is null. The deck model measures every chart on a - whole-live simulation, dozens of lives per chart: a full run takes processor time in proportion to the number of - charts. `--workers N` sets the threads it uses (default: every processor), `--seeds N` the seeds measured on a chart - with a luck range (default 8). -- `--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-deck`: do not run the deck model; every chart's `deck` is null. A full statistics build evaluates many + complete lives per chart. `--workers N` sets the native worker count (default: every available processor). +- `--seeds N`: list this many replay seeds for a chart with a luck range (default 8). Expectations and aptitude + use the model's independent nominal probabilities, not a sample of these seeds. Increasing this option changes + the replay examples without changing the expected scores or their numerical enclosures. +- `--no-gekisou-aptitude`: keep the ordinary deck statistics and skip single-skill aptitude measurements. +- `--stats-cache DIR`: use the native model's persistent program and run cache. Reuse follows the complete + compiled simulation inputs, so unchanged programs and runs can survive a change to the skill catalog. New + shapes need their own measurements; changing a shape's numeric index does not by itself identify a new program. + Every invocation still passes the complete current master data and charts to the native model, which constructs + the current header and assembles the requested statistics. Python does not reuse a chart-sized JSON result or + replace the model's dependency analysis with a table allowlist. + Cache records are isolated by cache schema and model source SHA-256. Each completed result is saved through a + unique temporary file, a file sync and an atomic rename. Invalid or unreadable records are measured again; + interrupted work retains its completed records. Concurrent processes may compute the same missing record, but + publish complete records atomically. Old inputs remain available for later reuse: there is no automatic garbage + collection, and unused model-source directories can be removed to reclaim space. A record larger than 64 MiB is + not persisted. The command's `deckStats` summary reports native requests, hits, completed computations, writes, + invalid entries and written bytes; these count cache operations, not charts. Cache counters are not part of the + published music data. This cache does not import the previous Python chart cache. - `--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 @@ -67,7 +75,7 @@ the wheels on PyPI carry it. See [Building](#building). ```json { - "format": "nnnotes.music-data/1", + "format": "nnnotes.music-data/2", "provenance": {"region": ..., "client": {...}, "catalog": {...}, "master": {...}, "exporter": {...}, "deck": {...}}, "languages": ["ja", "en", "zh-Hant", "zh-Hans", "ko"], "bands": [...], "characters": [...], "tags": [...], "categories": [...], @@ -102,7 +110,7 @@ A **text** is an object with one string per language of `languages` (`{"ja": ... | `master.tables..sha256` | SHA-256 of each table's file as served, before decoding: the song tables (`MasterLiveMusic`, `MasterLiveMusicScore`, `MasterText`, `MasterBand`, `MasterCharacter`, `MasterTag`, `MasterLiveMusicCategory`, `MasterSound`, `MasterSoundCueSheet`, `MasterLiveScoreRank`), the tables of the Gekisou catalog (`MasterMemberCard`, `MasterSupportCard`, `MasterSupportCardRank`, `MasterGekisouSkill`, `MasterGekisouSkillEffect`, `MasterGekisouSupportSkill`, `MasterGekisouSupportSkillEffect`) and, when the deck model runs or with `--full`, the tables of [the deck input](#the-deck-input---full) | | `exporter.name`, `exporter.version` | `nnnotes` and its version | | `exporter.chartFormat` | the format of the chart converter the notes come from, `nnnotes.live-score/1` | -| `deck` | the deck model: `{name, version, source, commit, format}`, `ournotes-deck`, the package version of its crate ournotes-sim, the repository, the git commit nnnotes is built with and the statistics format (`ournotes-deck.chart-stats/2`); null with `--no-deck` | +| `deck` | the deck model: `{name, version, source, commit, sourceSha256, format}`, `ournotes-deck`, the package version of its crate ournotes-sim, the repository, the git commit nnnotes is built with and the statistics format (`ournotes-deck.chart-stats/3`); null with `--no-deck` | ### bands, characters, tags, categories @@ -175,157 +183,159 @@ plays, `musicLengthMs` is the length the score code uses; a listing chooses the ## Deck statistics -The deck model plays every chart of a song on its whole-live simulation in the theoretical best play, the frame -times of the game's default schedule, in two scenarios: +The native statistics format is `ournotes-deck.chart-stats/3`. It evaluates complete lives on the game's default +frame schedule in two scenarios: -- **Gekisou on** (`seeds`), as a Gekisou live (Battle Live, up to five players) plays: every judged note at its exact - time, Just inside the Just-count ranges and Perfect elsewhere, rank 1 in every Gekisou range. Other ranks follow - from the same numbers (below). -- **Gekisou off** (`offSeeds`), as a solo live (Free Live, Challenge Live) plays: every judged note Perfect at its - exact time, seed 0, no Just, luck, Gekisou combo or rank bonus. A chart with more than three fevers plays here too. +- **Gekisou on** (`expectation`): each note is judged at its exact time, Just inside a Just-count range and Perfect + elsewhere, with rank 1 in each range. Random lotteries and probabilistic skills follow independent nominal + probabilities. The numbers enclose the expectation under that stated model. +- **Gekisou off** (`offSeeds`): each note is Perfect, with seed 0 and no Just, luck, Gekisou combo or rank bonus. + This remains a deterministic measurement. A chart with more than three fevers can still play here. -In each it measures, at deck power `model.power` (300000): +### Expected values and their intervals -- `score`: the exact score without skills (with Gekisou on, the rank 1 bonuses of the Gekisou ranges included); -- for every **score-up kind** (`deck.kinds`) and every performance position `k`, `weights[kind][k]`: the exact score - a deck gains when its position-`k` member has one effect of that kind at factor 1, divided by the deck power. The - effect runs through the simulation's own updaters, conditions, frames and appliers, so the weight carries every rule - of the game (execute and finish frames, the 40 ms score frames, combo and Gekisou combo factors, Just scores, luck - rushes, the rank bonuses of the ranges it overlaps). +An **Estimate** is `[center, radius]`, denoting the interval `[center - radius, center + radius]`. Both values and +both endpoints must be finite, and the radius must be nonnegative. The radius bounds numerical uncertainty; it is +not a standard error, a confidence interval or variation between replay seeds. Point values are serialized at +millipoint precision, lottery counts at five decimal places and weights at twelve, with radii expanded outwards +so rounding keeps the enclosure. A zero radius is a point interval. -A deck whose live skills are all score-up kinds then scores, up to the floors, +The no-skill expected score is measured at `deck.model.power` (300000). For each ordinary **score-up kind** and +performance position, `expectation.weights[kind][position]` encloses the expected gain from one effect at factor +1, divided by that power. Effects run through the simulator's own conditions, frames and appliers. A deck whose +ordinary live skills are represented by these kinds has the score approximation ``` -P * (score / power + sum over positions k of factor_k * weights[kind_k][k]) +P * (score / model.power + sum_k factor_k * weights[kind_k][k]) ``` -with `P` the deck power and `factor_k` the effect's factor as the game's applier converts its value: effect type 2000 -`floor(value / 10000f * 1e5) / 1e5`, 2005 `floor(value / -10000f * 1e5) / 1e5`, 2002 and 2004 the same quotient -rounded half to even (`value / 10000f` in binary32). Each seed also plays a **check deck**, random kinds at real -master values at another power (`model.checkPower`), and the command fails when its exact score leaves the bound of -the formula. Effects of other types (cumulative score 2001 / 2003, life, judgement conversion, Gekisou and snap -skills) are not linear in the chart alone and have no weights: a deck's score with them comes from the simulation. - -A luck range draws lottery results from the play's random seed, so the Gekisou on measurements are given **per -seed**: one seed (0) when no range is a luck range, else the first `--seeds` seeds of the deck model's published seed -set. That set is a fixed sequence: candidate `k` is the low 32 bits (signed) of output `k + 1` of SplitMix64 started at -`0x6765_6B69_736F_7531` (`gekisou1`), kept when its pair of stream seeds (`|b|` and `|b ^ 0x9E3779B9|`, -2^31 as -2^31 - 1) differs from those of every seed kept before it, so a smaller set is a prefix of a larger one. The seed -set is not the game's seed law (which is unknown); a mean over it is not the game's expectation. - -### Ranks - -In a Gekisou live, range `i` takes a rank `r_i` from 1 to 5 among the room's players, and its rank bonus is -`trunc(rangeScore_i * p_i(r_i) / 100)` with `p_i(r) = ranges[i].rankBonusPercents[r - 1]`. The bonus is a fixed score -at the range's end: it changes no factor and no note score, and a later range's score holds it at both ends, so the -range scores do not depend on the ranks. At ranks `r` a seed's numbers are therefore - -``` -score_r = score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * p_i(r_i) / 100) (exact) -weights_r[kind][k] = weights[kind][k] + sum_i (p_i(r_i) - p_i(1)) / 100 * rangeWeights[kind][k][i] -``` - -(`rankBonus_i`, `rangeScore_i`: `seeds[].ranges[i]`), and a deck scores `P * (score_r / power + sum_k factor_k * -weights_r[kind_k][k])` as above; `weights_r` is within `2 * ranges / power` per unit of factor of the exact weight at -those ranks. At rank 1 everywhere these are `score` and `weights`. `rangeWeights` is null when a range's bonus can fall -inside another range's score frames (overlapping ranges), and a kind's entry is null when its conditions read the -confirmed rank (condition 7012): the ranks do not follow linearly there. `rankCheck` plays the seed's check deck at -random ranks through the simulation's explicit rank confirmations and bounds it against these formulas. - -### Just rate - -`scorePerfect` and `ranges[i].rangeScorePerfect` are the no-skill score (rank 1 bonuses included) and the range -scores of the same play with every Just judged Perfect (the Just judgement is enabled only inside the Just-count -ranges, so nothing else changes). Between a Just rate of 1 (`score`, `rangeScore`) and 0 (`scorePerfect`, -`rangeScorePerfect`) a page can interpolate; the rank bonus of the Perfect play is `trunc(rangeScorePerfect_i * -p_i(r_i) / 100)`. A chart without Just notes (`justNotes` 0) has `scorePerfect == score`. +where `P` is its power. Arithmetic on estimates must carry their radii. The approximation also has a separate +integer-floor error, checked by a complete nominal evaluation at `model.checkPower`; a numerical radius does not +replace that error bound. The factor follows the game's value conversion: effect type 2000 uses +`floor(value / 10000f * 1e5) / 1e5`, type 2005 uses `floor(value / -10000f * 1e5) / 1e5`, and types 2002 and 2004 +round the binary32 quotient `value / 10000f` half to even. Other effect types, including cumulative scoring, life +and judgement conversion, have no ordinary score-up weights. + +`replaySeeds` is separate from these expectations. A playable chart without a luck range lists `[0]`; otherwise +it lists the first `--seeds` values of the published sequence. Candidate `k` is the low 32 bits, interpreted as a +signed integer, of output `k + 1` of SplitMix64 started at `0x6765_6B69_736F_7531` (`gekisou1`). A candidate is kept +when its effective stream-seed pair (`|b|`, `|b ^ 0x9E3779B9|`, with -2^31 treated as 2^31 - 1) differs from all +previous pairs. A shorter list is a prefix of a longer list. These values select concrete replay examples; the +exporter never averages them to obtain `expectation` or `gekisouAptitude`. + +### Ranks and Just judgements + +A range at rank `r` awards `trunc(rangeScore * p(r) / 100)`, where +`p(r) = ranges[i].rankBonusPercents[r - 1]`. `rankBonus` is the expected result of that integer operation at rank 1. +In general, **the expectation of a truncated score is not the truncation of the expected score**. Readers must +keep the separately measured `rankBonus` and `rankBonusPerfect`; neither is reconstructed by truncating an +expectation's center. + +When rank changes do not alter range scoring, the other-rank approximation replaces the rank-1 bonus by +`E[rangeScore] * p(r) / 100` and adjusts an ordinary weight by +`sum_i (p_i(r_i) - p_i(1)) / 100 * rangeWeights[kind][position][i]`. The native rank check includes the resulting +integer-truncation slack in its bound. `rangeWeights` is null when overlapping ranges break that independence; +a kind's row is null when its conditions read a confirmed rank (condition 7012). These nulls mark a limit of the +rank formula, not a zero contribution. Such scenarios require full simulation. + +`scorePerfect` and `rangeScorePerfect` use the same nominal model with every Just judgement replaced by Perfect. +`rankBonusPerfect` is measured on that play too. A chart with `justNotes == 0` has matching best and Perfect +expectations. Interpolating between these endpoints is a consumer's approximation, not an additional measured +Just-rate curve. ### deck | Field | Content | |---|---| -| `model` | the deck model's description of the measurement: `engine`, `play`, `score` (the formula), `power`, `checkPower`, `unitValue` (the effect value of factor 1, 10000), `seeds`, `ranks` (the rank formulas), `perfect` (the Perfect play), `off` (the Gekisou off scenario) | -| `kinds[]` | the score-up kinds of the master data: `MasterLiveSkillEffect` rows of type 2000, 2002, 2004 or 2005 without a cumulative condition, grouped by what shapes their score. `id` (the index in `weights`), `effectType`, `activationTimeSecond`, `durationMs` (`ceil(activationTimeSecond * 1000f)`), `skillTargetIds`, `skillConditionGroup`, `skillReleaseConditionGroup`, `effectLimitCount`, `effectExecuteLimitCount`, `effectExecuteLimitResetConditionGroup`; `rows` (master rows of the kind) and `values` (their distinct `_effectValue`s, ascending) | +| `model` | the native model's description: engine, play, score formula, measurement and check powers, effect unit value, nominal expectation law, rank approximation, Perfect play, Free Live and single-shape aptitude | +| `kinds[]` | ordinary score-up kinds from `MasterLiveSkillEffect` types 2000, 2002, 2004 or 2005 without a cumulative condition. Each has a continuous zero-based `id`, effect type, activation time, duration, targets, condition/release/reset groups, effect limits, source `rows` and distinct sorted `values` | +| `gekisouAptitude` | the independently validated single-shape catalog below, or null when aptitude is disabled | -The kind of a card's live skill is found by matching its `MasterLiveSkillEffect` row (at the skill level) on these -fields; `values` lists what the master data uses. +A kind's duration is `ceil(activationTimeSecond * 1000f)` milliseconds. Match a card's ordinary live-skill effect at +its actual level to the kind's structural fields. Kind ids and shape ids are indices in the current document; +they are not stable identifiers across master-data releases. ### charts[].deck | Field | Content | |---|---| -| `convertedNoteCount` | the note count the score formula divides by (converted notes) | -| `skip` | score per unit of deck power of a skipped live (every note Great, combo 0, no skills) | -| `events` | `[[position, timeMs], ...]`: the skill events in chart order with the performance position each fires | -| `positions` | the performance positions the events fire (the largest position + 1): the length of every `weights[kind]` | -| `ranges[]` | the Gekisou ranges: `index`, `mission` (1 combo, 2 luck, 3 Just count), `startMs`, `endMs`, `rankBonusPercents` (the rank bonus percentages of ranks 1..5 of the song's mission pattern, `MasterLiveGekisouRankingScoreBonus`), `rankBonusPercent` (the rank 1 percentage, `rankBonusPercents[0]`) | -| `justNotes` | notes judged Just on the Gekisou on play | -| `seeds[]` | Gekisou on, per seed: `seed`; `score` (points at `model.power`, rank 1 bonuses included); `ranges[]` (`rangeScore`: the points gained inside the range, `rankBonus`: its rank 1 bonus in points, `maxCombo`, `justCount`, `luckPoints`: the luck points gained (`TotalBonusPoint`; a combo, Just count or luck range ranks the room by `maxCombo`, `justCount` or `luckPoints`), `lotResults`: lottery results Miss, Hit, Super Hit, Critical, `rangeScorePerfect`: `rangeScore` on the Perfect play); `weights[kind][position]` (points per unit of deck power and of factor); `check` (`deck`: `[kind, value]` or null per position, `exact`, `predicted`, `bound`: points at `model.checkPower`); `scorePerfect` (`score` on the Perfect play); `rangeWeights[kind][position][range]` (range points per unit of deck power and of factor, or null; a kind null); `rankCheck` (`ranks`: 1..5 per range, `exact`, `predicted`, `bound`; null without ranges or range weights) | -| `offSeeds[]` | Gekisou off, one seed: `seed` (0), `score`, `weights[kind][position]` (a kind null when its conditions read the Gekisou state, which a solo live does not have) and `check`, as in `seeds[]` | -| `unplayable` | null, or why the game cannot play the chart with Gekisou (more than three fevers: the game fails when the fourth starts); `seeds` is then empty, `offSeeds` is not | - -The deck model's chart facts are checked against the file's: the song, difficulty, level, judged note count, last -note time, music length, Gekisou missions, skill event times and fever ranges must agree, and are not repeated in -`deck`. Its numbers are checked against the master data and themselves: every range's `rankBonusPercents` are the -`MasterLiveGekisouRankingScoreBonus` rows of the song's mission pattern (0 without a row), every `rankBonus` is -`trunc(rangeScore * rankBonusPercent / 100)`, a chart without Just notes has the same scores on the Perfect play, -every array has its shape (`[kind][position]`, `[kind][position][range]`, one range result per range with its luck -points, one Gekisou off seed), the seeds are the chart's seed set (none on a chart unplayable with Gekisou, seed 0 -without a luck range, else the first `--seeds` seeds of the published seed set, which nnnotes computes itself), and -every check and rank check is within its bound. +| `convertedNoteCount` | the converted note count used by the score formula | +| `skip` | points per unit of power for a skipped live: every note Great, combo 0, no skills | +| `events` | `[[position, timeMs], ...]` in chart order | +| `positions` | largest event position plus one; the size of each position dimension | +| `ranges[]` | `index`, `mission` (1 combo, 2 luck, 3 Just count), `startMs`, `endMs`, `rankBonusPercents` for ranks 1–5, and the rank-1 `rankBonusPercent` | +| `justNotes` | notes judged Just on the best Gekisou play | +| `expectation` | the expected Gekisou statistics below, or null when unplayable | +| `replaySeeds` | concrete replay seeds, empty when unplayable | +| `offSeeds` | exactly one deterministic Free Live result: seed 0, integer `score`, scalar `weights[kind][position]`, and `check` with `deck`, integer `exact`, scalar `predicted` and `bound`; a weight row is null when the kind reads unavailable Gekisou state | +| `unplayable` | null, or why Gekisou cannot play this chart; a fourth fever is unsupported by the game. Free Live statistics remain present | +| `gekisouAptitude` | per-range factors and single-shape increments below, or null | + +The expectation object contains: + +| Field | Content | +|---|---| +| `score`, `scorePerfect` | Estimate of no-skill points, including rank-1 bonuses, on the best and Perfect plays | +| `ranges[]` | `rangeScore`, `rankBonus`, `rankBonusPerfect`, `rangeScorePerfect` and `luckPoints` as Estimates; deterministic integer `maxCombo` and `justCount`; four `lotResults` Estimates in Miss, Hit, Super Hit, Critical order | +| `weights` | `[kind][position]` Estimates of points per unit of deck power and effect factor | +| `rangeWeights` | `[kind][position][range]` Estimates for rank adjustment, or null; individual kind rows may be null | +| `check` | `deck`: `[kind, value]` or null at each position; `ranks: null`; `expected` and `predicted` Estimates in points at `model.checkPower`; scalar nonnegative `bound` | +| `rankCheck` | the same check deck evaluated at ranks 1–5 per range; the same Estimate fields and scalar bound, or null when no rank check applies | + +The exporter checks native chart identities and times against the chart facts, and checks each range's rank +percentages against `MasterLiveGekisouRankingScoreBonus`. It validates array dimensions, finite estimates, +nonnegative radii, deterministic counters, replay seeds, unavailable rank domains, best/Perfect agreement without +Just notes, and all check bounds. Checks compare the most distant endpoints; the serialized check allows only +the outward millipoint rounding expansion of its two intervals in addition to the native bound. Lottery counts +and luck points outside a luck range must enclose zero. ### Gekisou skill aptitude -`deck.gekisouAptitude` describes the shapes measured, and each chart's `deck.gekisouAptitude` describes how its -score changes with **one** such shape equipped. It does not select a deck, and increments measured separately must -not be added to estimate several skills together. The existing `deck.seeds` still measures Gekisou **without card -Gekisou skills**; a solo/free live uses `offSeeds`, without Gekisou. These are model results, not a guarantee that -they reproduce the game. - -The file header has `plainKind` (the ordinary, unconditional, whole-team five-second score-up kind used for cross -terms, or null), `host` (how a support skill's paired member is measured), `seedRule` and `shapes`. A shape has a -continuous zero-based `id`, `source` (`member` or `support`), `mission` (1 combo, 2 luck, 3 Just, 4 all), -`bandCondition`, normalized `effects`, and `skills`. Each skill is `{id, level, memberTargetIds, bandIds}`; join its -id to `gekisouCatalog.skills` or `supportSkills` for its name. Members use their skill's highest level; snaps use -the support skill level at their highest rank. Effect rows preserve the master order and expose their effect, -trigger, duration, value, limits, targets, four condition groups and cumulative condition (the exact fields are in -the JSON Schema). Equivalent parameters share a shape. Support condition 5000's target is normalized away; -`memberTargetIds` and `bandIds` retain each skill's targets and bands for matching, or are null without that condition. - -Each chart has `{factors, variants}`, or null when aptitude is disabled, the chart is unplayable with Gekisou, there -are no ranges, or the master has no shapes. `factors`, in range order, has `judgedNotes`, `justNotes`, `perfectNotes`, -`tailNotes`, `comboAtStart`, and `lotteries`: the mean and standard error of the number of lotteries without a skill, -on `deck.seeds` (zero outside luck ranges). - -Variants are in shape-id order, only for the chart's missions or mission 4. A band-conditioned shape appears twice, -`bandMatch: true` then `false`; otherwise `bandMatch` is null. Each variant has: +`deck.gekisouAptitude` describes the shapes measured, and each chart's `deck.gekisouAptitude` describes the expected +increment with **one** such shape equipped. Ordinary `expectation` has no card Gekisou skills. Single-shape gains +cannot be added to estimate several interacting skills; complete formations still require simulation. + +The header has `plainKind`, `host`, `law` and `shapes`. `plainKind` is the ordinary, unconditional, whole-team +five-second score-up kind used for cross terms, or null. `host` describes the paired member used for a support +skill. `law` is exactly `independent nominal lottery and skill probabilities`. + +Each shape has a continuous zero-based `id`, `source` (`member` or `support`), `mission` (1 combo, 2 luck, 3 Just, +4 all), `bandCondition`, normalized `effects` and `skills`. Each skill reference is +`{id, level, memberTargetIds, bandIds}` and joins to the Gekisou catalog for its text. Members use their skill's +highest level; snaps use their support skills' levels at the highest rank. Effects preserve master order and +expose type, trigger, duration, value, limits, targets, four condition groups and cumulative condition, as specified +by the Schema. Equivalent parameters share a shape. Support condition 5000's target is normalized away; +`memberTargetIds` and `bandIds` retain the targets and bands for matching, or are null without that condition. + +A chart's aptitude is `{factors, variants}`, or null when disabled, unplayable, without ranges, or without shapes. +`factors` has one object per range: deterministic `judgedNotes`, `justNotes`, `perfectNotes`, `tailNotes` and +`comboAtStart`, plus an Estimate of baseline `lotteries`. That interval must agree with the sum of the baseline +range's four expected lottery-result counts. The factors of overlapping ranges may refer to the same notes. + +Variants appear in shape-id order for the chart's missions and mission 4. A band-conditioned shape has a +`bandMatch: true` variant followed by `false`; other shapes use null. | Field | Content | |---|---| -| `shape`, `bandMatch` | shape and the measured band condition | -| `deterministic`, `seeds`, `seTargetMet`, `crossSeeds` | whether random dependencies were excluded and four initial plays agreed, number of measured seeds, whether the standard-error target was met, number used for cross terms | -| `score`, `scorePerfect`, `tail`, `tailPerfect`, `converted` | score increments, the increments after the ranges, and the conversion increment; each `[mean, standard error]` | -| `ranges` | one object per range: `rangeScore`, `rankBonus`, `rangeScorePerfect`, `maxCombo`, `justCount`, `luckPoints`, each `[mean, standard error]` | -| `weights` | cross terms `[position][mean, standard error]`, null without a plain kind | -| `rangeWeights` | cross terms `[position][range][mean, standard error]`, null without a plain kind or a linear rank model | -| `check` | first seed's check: `seed`, `ranks`, `deck` (plain kind/value or null per slot), `exact`, `predicted`, `bound` | - -Point increments are measured at `deck.model.power`, using the same seed for the equipped and unequipped plays. -`score.mean = tail.mean + sum(rangeScore.mean + rankBonus.mean)`; **standard errors cannot be added this way**. -Point means and errors are rounded to 0.001. With R ranges this equality allows `(2+2R)*0.0005 + 1e-8` points of -independent rounding error. Weights, lottery statistics and the check's prediction and bound keep binary64 precision. -A deterministic result uses one seed and zero standard errors. Charts with luck ranges, effects 11000–11005, -or probability condition 4011 in any condition group always use random sampling, even if the first observations -agree; four matching observations alone do not establish determinism. Others expand through `seedRule.batches` (normally -32, 64, 128, 256, 512, 1024), stopping when the unrounded score error is at most the larger of `relative` (0.01) times -the absolute mean increment and `baseline` (0.001) times the no-skill mean on the same seeds. A result at the cap may -have `seTargetMet: false`. Cross terms use the first `min(seeds, seedRule.crossSeeds)` seeds (normally capped at 64). -The sampled increment is an estimate, not an exact expectation; consult its error and the check bound. - -The exporter checks the shape table against independently read master data, its skill/level coverage and bands, -the variants' references, mission and band coverage, finite `[mean, se]` pairs with nonnegative errors, deterministic -zero errors, array lengths, the tail identity and each check's bound. New exports always include the aptitude keys; -the Schema still accepts older `/1` files without them. +| `shape`, `bandMatch` | the current shape index and measured band condition | +| `score`, `scorePerfect` | Estimate of the total point increment on the best and Perfect plays | +| `tail`, `tailPerfect` | Estimate of the increment outside the range scores and their bonuses | +| `converted` | deterministic integer converted-note increment as `[value, 0]` | +| `ranges[]` | Estimates of `rangeScore`, `rankBonus`, `rankBonusPerfect`, `rangeScorePerfect` and `luckPoints`; deterministic integer `maxCombo` and `justCount` increments as `[value, 0]` | +| `weights` | `[position]` Estimates of cross terms with the plain kind, or null without it | +| `rangeWeights` | `[position][range]` Estimates of cross terms, or null without a plain kind or when the chart or shape cannot use linear rank adjustment | +| `check` | plain-kind `deck`, explicit `ranks`, expected/predicted point Estimates and scalar bound; a nonlinear-rank shape is checked at rank 1 in every range | + +Point increments are measured at `deck.model.power`. Their intervals must be consistent with +`score = tail + sum(rangeScore + rankBonus)` and +`scorePerfect = tailPerfect + sum(rangeScorePerfect + rankBonusPerfect)`. These are interval identities, not +identities between rounded centers. A shape that reads confirmed rank in any condition group or cumulative +condition has no linear rank cross terms, even when the chart's ordinary range weights are available. + +The exporter independently checks the shape catalog against the complete master data, including skill and level +coverage, normalized effects, member targets and bands. It then checks variant order and coverage, factor counts, +interval identities, dimensions, deterministic integer increments and expectation bounds. Aptitude has no +sample-count, standard-error target or convergence-cap fields in this format. ## The deck input (`--full`) @@ -437,31 +447,44 @@ The deck model reads the same content under the format name `nnnotes.deck-data/1 ## Building -The deck model is the Rust crate ournotes-sim of the ournotes-deck repository, pinned to the commit of an ournotes-deck -release in `rust/Cargo.toml` +The deck model is the Rust crate ournotes-sim of the ournotes-deck repository, pinned to an exact source commit +in `rust/Cargo.toml` (and `rust/Cargo.lock`) and built into the extension module `nnnotes._deck` with [maturin](https://www.maturin.rs/) (PyO3, the stable ABI of Python 3.11 and later: one wheel per platform). The release workflow builds the wheels; `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. `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. +releases with the same value measure the same statistics. Production replay bundles require an ournotes-deck +release of that same revision: the replay and recommendation engines of `--replay-engine` and `--recommend-engine` +are its WASM packages, with matching commit and source identities. A source pin can be built and tested before +those packages are released, but older release engines cannot be substituted. If the release uses a different +commit after merging, update the Cargo revision and lockfile package version to that release and rebuild the +extension before publication. The publisher selects `v` from that lockfile package version; creating +a new tag alone does not change the selected engines. ## Versions -`format` names the major version. Within `nnnotes.music-data/1`, fields and tables are only added, never renamed, -removed or given another meaning, and readers ignore keys they do not know. A reader rejects a file with a major -version it does not know. A change that breaks readers is a new major version, `nnnotes.music-data/2`. The deck -statistics follow the deck model's format (`provenance.deck.format`): a new major version of it is a new major -version of this file. - -`nnnotes.music-data/1` replaces the `nnnotes.songs/1` file of `nnnotes songs` (its fields are the songs, charts and -their facts here) and the `nnnotes.deck-data/1` file of `nnnotes deck-data` (its content is the deck input of -`--full`). - - -Final aptitude exports require the standard-error target for both `score` and `scorePerfect`, each against its own paired no-skill baseline. Sampling extends the same published seed prefix through geometric batches, stopping as soon as both targets agree with the unchanged max(1% of increment, 0.1% of baseline) rule. The 65,536-seed cap is a failure guard, not a requirement to run every seed. An unmet cap aborts a normal export before writing artifacts. `--allow-unconverged-aptitude` is an explicit diagnostic option; it retains real SE values and unmet flags. +`format` names the major version. Within `nnnotes.music-data/2`, fields and tables are only added, never renamed, +removed or given another meaning, and readers ignore keys they do not know. Readers must reject an unknown major +version. This version carries `ournotes-deck.chart-stats/3` in `provenance.deck.format` and requires that exact +statistics format from the extension module. + +`nnnotes.music-data/2` replaces the per-seed Gekisou statistics and sampled aptitude of `/1` with nominal +expectations and outward numerical intervals. `seeds` becomes `expectation` plus separate `replaySeeds`; the +aptitude `seedRule` becomes `law`, sampling flags disappear, checks use interval `expected`/`predicted` values, and +Perfect-play range bonuses are explicit. These are changes of meaning as well as shape: an older reader must not +interpret a radius as a standard error. Free Live `offSeeds`, chart facts and the optional `nnnotes.deck-data/1` +input retain their existing semantics. The old aptitude sampling-cap CLI options are no longer accepted. + +The persistent statistics cache also starts a new format. The native cache uses model-source namespaces and +fine-grained program/run identities; no old Python chart-cache entry is migrated or trusted. Removing a cache +changes the amount of work, not the music data bytes. + +Deploy readers supporting `/2` and nominal expectations before publishing the new format. The reference page +is pinned in `.github/workflows/music-data-region.yml`; any configured override must support the same format. +The native release packages, Python producer and deployed consumers must move together as described in +[the publication workflow](../.github/MUSIC_DATA.md#before-the-first-run). `--replay-dir OUT/replay --replay-engine PKG [--recommend-engine PKG]` writes normalized runtime inputs, the pinned model's WASM release packages (the replay engine and, optionally, the recommendation engine) and same-snapshot Snap diff --git a/docs/replay.md b/docs/replay.md index d03d17c..fc56546 100644 --- a/docs/replay.md +++ b/docs/replay.md @@ -18,14 +18,14 @@ The music-data pointer is: "deckData": {"format":"nnnotes.deck-data/1","url":"deck-data.json","sha256":"","bytes":0}, "charts": [{"scoreId":10000303,"url":"charts/10000303.json","sha256":"","bytes":0,"musicLengthMs":120557,"noteCount":0,"assetSha256":""}], "engine": { - "model": {"name":"ournotes-deck","version":"","source":"","commit":"","format":"ournotes-deck.chart-stats/2"}, + "model": {"name":"ournotes-deck","version":"","source":"","commit":"","sourceSha256":"","format":"ournotes-deck.chart-stats/3"}, "requestFormat":"ournotes.replay/1","class":"ReplaySession","methods":["describeChart","template","run"], "js":{"url":"engine/ournotes_replay_wasm.js","sha256":"","bytes":0}, "wasm":{"url":"engine/ournotes_replay_wasm_bg.wasm","sha256":"","bytes":0}, "build":{"url":"engine/build-info.json","sha256":"","bytes":0} }, "recommendEngine": { - "model": {"name":"ournotes-deck","version":"","source":"","commit":"","format":"ournotes-deck.chart-stats/2"}, + "model": {"name":"ournotes-deck","version":"","source":"","commit":"","sourceSha256":"","format":"ournotes-deck.chart-stats/3"}, "js":{"url":"recommend/ournotes_recommend_wasm.js","sha256":"","bytes":0}, "wasm":{"url":"recommend/ournotes_recommend_wasm_bg.wasm","sha256":"","bytes":0}, "build":{"url":"recommend/build-info.json","sha256":"","bytes":0} diff --git a/docs/schema/music-data.schema.json b/docs/schema/music-data.schema.json index 44e3cdb..148c345 100644 --- a/docs/schema/music-data.schema.json +++ b/docs/schema/music-data.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:nnnotes:schema:music-data", - "title": "nnnotes.music-data/1", + "title": "nnnotes.music-data/2", "description": "Every live song and chart of one master data version: song metadata in every language, chart facts and the deck model's chart statistics, with --full also the deck model's input (every chart's runtime notes and a subset of the master data tables as columns and rows). Written by `nnnotes music-data`; described in docs/music-data.md. Within the arrays of a deck input chart's `notes` (and of its `fevers`) the i-th elements belong to the same note (range): the arrays have equal lengths, which a schema cannot state. Readers ignore keys they do not know.", "type": "object", "required": [ @@ -17,7 +17,7 @@ ], "properties": { "format": { - "const": "nnnotes.music-data/1" + "const": "nnnotes.music-data/2" }, "provenance": { "type": "object", @@ -152,6 +152,7 @@ "version", "source", "commit", + "sourceSha256", "format" ], "properties": { @@ -175,8 +176,8 @@ "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" + "const": "ournotes-deck.chart-stats/3", + "description": "The native nominal-expectation statistics format." } } } @@ -424,7 +425,8 @@ ], "required": [ "model", - "kinds" + "kinds", + "gekisouAptitude" ], "description": "the deck model's measurement and score-up kinds; null with --no-deck", "properties": { @@ -691,8 +693,11 @@ "positions", "ranges", "justNotes", - "seeds", - "unplayable" + "unplayable", + "expectation", + "replaySeeds", + "offSeeds", + "gekisouAptitude" ], "properties": { "convertedNoteCount": { @@ -731,7 +736,8 @@ "mission", "startMs", "endMs", - "rankBonusPercent" + "rankBonusPercent", + "rankBonusPercents" ], "properties": { "index": { @@ -763,166 +769,8 @@ } }, "justNotes": { - "type": "integer" - }, - "seeds": { - "type": "array", - "items": { - "type": "object", - "required": [ - "seed", - "score", - "ranges", - "weights", - "check" - ], - "properties": { - "seed": { - "type": "integer" - }, - "score": { - "type": "integer", - "description": "the exact no-skill score at model.power" - }, - "ranges": { - "type": "array", - "items": { - "type": "object", - "required": [ - "rangeScore", - "rankBonus", - "maxCombo", - "justCount", - "lotResults" - ], - "properties": { - "rangeScore": { - "type": "integer" - }, - "rankBonus": { - "type": "integer" - }, - "maxCombo": { - "type": "integer" - }, - "justCount": { - "type": "integer" - }, - "luckPoints": { - "type": "integer", - "description": "the luck points gained in the range (TotalBonusPoint), a luck range's rank figure" - }, - "lotResults": { - "type": "array", - "items": { - "type": "integer" - }, - "minItems": 4, - "maxItems": 4 - }, - "rangeScorePerfect": { - "type": "integer", - "description": "the range score on the Perfect play (every Just judged Perfect)" - } - } - } - }, - "weights": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - }, - "description": "weights[kind][position]: score per unit of deck power and of factor" - }, - "check": { - "type": "object", - "required": [ - "deck", - "exact", - "predicted", - "bound" - ], - "properties": { - "deck": { - "type": "array", - "items": { - "type": [ - "array", - "null" - ] - } - }, - "exact": { - "type": "integer" - }, - "predicted": { - "type": "number" - }, - "bound": { - "type": "number" - } - } - }, - "scorePerfect": { - "type": "integer", - "description": "the exact no-skill score at model.power on the Perfect play (every Just judged Perfect), its rank 1 bonuses included" - }, - "rangeWeights": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "array", - "null" - ], - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - "description": "rangeWeights[kind][position][range]: range score per unit of deck power and of factor; null when the ranks do not follow linearly on the chart, a kind null when its conditions read the confirmed rank" - }, - "rankCheck": { - "type": [ - "object", - "null" - ], - "required": [ - "ranks", - "exact", - "predicted", - "bound" - ], - "description": "the check deck played at random ranks; null without ranges or range weights", - "properties": { - "ranks": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 5 - } - }, - "exact": { - "type": "integer" - }, - "predicted": { - "type": "number" - }, - "bound": { - "type": "number" - } - } - } - } - } + "type": "integer", + "minimum": 0 }, "offSeeds": { "type": "array", @@ -937,7 +785,7 @@ ], "properties": { "seed": { - "type": "integer" + "const": 0 }, "score": { "type": "integer", @@ -986,17 +834,39 @@ } } } - } + }, + "minItems": 1, + "maxItems": 1 }, "unplayable": { "type": [ "string", "null" ], - "description": "why the game cannot play the chart with Gekisou (seeds is then empty; offSeeds is not)" + "description": "Why the game cannot play the chart with Gekisou; expectation is then null and replaySeeds empty, while offSeeds remains available." }, "gekisouAptitude": { "$ref": "#/$defs/chartGekisouAptitude" + }, + "expectation": { + "anyOf": [ + { + "$ref": "#/$defs/expectedStats" + }, + { + "type": "null" + } + ], + "description": "Expected Gekisou scores under independent nominal lottery and skill probabilities; null when unplayable." + }, + "replaySeeds": { + "type": "array", + "items": { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + "description": "Whole-live replay examples. Changing this prefix does not change expectations." } } }, @@ -1372,21 +1242,6 @@ } } }, - "aptitudeMeanSE": { - "type": "array", - "prefixItems": [ - { - "type": "number" - }, - { - "type": "number", - "minimum": 0 - } - ], - "items": false, - "minItems": 2, - "maxItems": 2 - }, "aptitudeCondition": { "type": "object", "required": [ @@ -1637,7 +1492,7 @@ "required": [ "plainKind", "host", - "seedRule", + "law", "shapes" ], "properties": { @@ -1656,47 +1511,14 @@ "type": "string", "minLength": 1 }, - "seedRule": { - "type": "object", - "required": [ - "deterministicTest", - "batches", - "relative", - "baseline", - "crossSeeds" - ], - "properties": { - "deterministicTest": { - "type": "integer", - "minimum": 1 - }, - "batches": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1 - }, - "minItems": 1 - }, - "relative": { - "type": "number", - "minimum": 0 - }, - "baseline": { - "type": "number", - "minimum": 0 - }, - "crossSeeds": { - "type": "integer", - "minimum": 1 - } - } - }, "shapes": { "type": "array", "items": { "$ref": "#/$defs/aptitudeShape" } + }, + "law": { + "const": "independent nominal lottery and skill probabilities" } } }, @@ -1710,10 +1532,6 @@ "required": [ "shape", "bandMatch", - "deterministic", - "seeds", - "seTargetMet", - "crossSeeds", "score", "scorePerfect", "tail", @@ -1739,34 +1557,20 @@ } ] }, - "deterministic": { - "type": "boolean" - }, - "seeds": { - "type": "integer", - "minimum": 1 - }, - "seTargetMet": { - "type": "boolean" - }, - "crossSeeds": { - "type": "integer", - "minimum": 1 - }, "score": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "scorePerfect": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "tail": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "tailPerfect": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "converted": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "ranges": { "type": "array", @@ -1775,6 +1579,7 @@ "required": [ "rangeScore", "rankBonus", + "rankBonusPerfect", "rangeScorePerfect", "maxCombo", "justCount", @@ -1782,22 +1587,25 @@ ], "properties": { "rangeScore": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "rankBonus": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "rangeScorePerfect": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "maxCombo": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "justCount": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "luckPoints": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" + }, + "rankBonusPerfect": { + "$ref": "#/$defs/estimate" } } } @@ -1807,7 +1615,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" } }, { @@ -1822,7 +1630,7 @@ "items": { "type": "array", "items": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" } } }, @@ -1832,63 +1640,7 @@ ] }, "check": { - "type": "object", - "required": [ - "seed", - "ranks", - "deck", - "exact", - "predicted", - "bound" - ], - "properties": { - "seed": { - "type": "integer" - }, - "ranks": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 5 - } - }, - "deck": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "array", - "prefixItems": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "integer" - } - ], - "items": false, - "minItems": 2, - "maxItems": 2 - }, - { - "type": "null" - } - ] - } - }, - "exact": { - "type": "integer" - }, - "predicted": { - "type": "number" - }, - "bound": { - "type": "number", - "minimum": 0 - } - } + "$ref": "#/$defs/expectationCheck" } } }, @@ -1935,7 +1687,7 @@ "minimum": 0 }, "lotteries": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" } } } @@ -1952,6 +1704,192 @@ "type": "null" } ] + }, + "estimate": { + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number", + "minimum": 0 + } + ], + "items": false, + "minItems": 2, + "maxItems": 2, + "description": "[center, outward interval half-width]; the radius is a numerical enclosure, not a standard error." + }, + "expectationCheck": { + "type": "object", + "required": [ + "deck", + "ranks", + "expected", + "predicted", + "bound" + ], + "properties": { + "deck": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "integer" + } + ], + "items": false, + "minItems": 2, + "maxItems": 2 + }, + { + "type": "null" + } + ] + } + }, + "ranks": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer", + "minimum": 1, + "maximum": 5 + } + }, + "expected": { + "$ref": "#/$defs/estimate" + }, + "predicted": { + "$ref": "#/$defs/estimate" + }, + "bound": { + "type": "number", + "minimum": 0 + } + } + }, + "expectedRange": { + "type": "object", + "required": [ + "rangeScore", + "rankBonus", + "rankBonusPerfect", + "rangeScorePerfect", + "maxCombo", + "justCount", + "luckPoints", + "lotResults" + ], + "properties": { + "rangeScore": { + "$ref": "#/$defs/estimate" + }, + "rankBonus": { + "$ref": "#/$defs/estimate" + }, + "rankBonusPerfect": { + "$ref": "#/$defs/estimate" + }, + "rangeScorePerfect": { + "$ref": "#/$defs/estimate" + }, + "maxCombo": { + "type": "integer", + "minimum": 0 + }, + "justCount": { + "type": "integer", + "minimum": 0 + }, + "luckPoints": { + "$ref": "#/$defs/estimate" + }, + "lotResults": { + "type": "array", + "items": { + "$ref": "#/$defs/estimate" + }, + "minItems": 4, + "maxItems": 4 + } + } + }, + "expectedStats": { + "type": "object", + "required": [ + "score", + "scorePerfect", + "ranges", + "weights", + "rangeWeights", + "check", + "rankCheck" + ], + "properties": { + "score": { + "$ref": "#/$defs/estimate" + }, + "scorePerfect": { + "$ref": "#/$defs/estimate" + }, + "ranges": { + "type": "array", + "items": { + "$ref": "#/$defs/expectedRange" + } + }, + "weights": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/estimate" + } + } + }, + "rangeWeights": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "array", + "null" + ], + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/estimate" + } + } + } + }, + "check": { + "$ref": "#/$defs/expectationCheck" + }, + "rankCheck": { + "anyOf": [ + { + "$ref": "#/$defs/expectationCheck" + }, + { + "type": "null" + } + ] + } + } } } } diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3146643..5bc5fcc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -96,7 +96,7 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ournotes-sim" version = "0.0.3" -source = "git+https://github.com/empty-sekai/ournotes-deck?rev=31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e#31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e" +source = "git+https://github.com/empty-sekai/ournotes-deck?rev=df86cc758e57da662582f9167752ce7ac68013e5#df86cc758e57da662582f9167752ce7ac68013e5" dependencies = [ "serde", "serde_json", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 54ef161..f55c58e 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -3,7 +3,7 @@ name = "nnnotes-deck" # the nnnotes version (src/nnnotes/__init__.py); the wheel takes its version from here version = "0.1.4" edition = "2024" -rust-version = "1.85" +rust-version = "1.88" description = "nnnotes._deck: the ournotes-sim chart statistics as a Python extension module" license = "MIT" publish = false @@ -14,9 +14,9 @@ crate-type = ["cdylib"] [dependencies] 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 = "31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e" } +# The model source is pinned exactly. Publishing additionally requires replay/recommendation release engines +# from that same revision; .github/workflows/deck.yml tracks newer model releases. +ournotes-sim = { git = "https://github.com/empty-sekai/ournotes-deck", rev = "df86cc758e57da662582f9167752ce7ac68013e5" } serde_json = { version = "1", features = ["raw_value", "arbitrary_precision"] } [profile.release] diff --git a/rust/src/lib.rs b/rust/src/lib.rs index aec368c..71c7a6a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,10 +1,10 @@ -//! `nnnotes._deck`: the chart statistics of the deck model ournotes-sim (`ournotes-deck.chart-stats/2`) on a deck +//! `nnnotes._deck`: the chart statistics of the deck model ournotes-sim (`ournotes-deck.chart-stats/3`) on a deck //! data document held in memory, the charts measured in parallel. use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; -use ournotes_sim::chartstats::{self, AptitudeOptions, ChartStats, GEKISOU_SEEDS, Options}; +use ournotes_sim::chartstats::{self, ChartStats, ChartStatsCache, Options, REPLAY_SEEDS}; use ournotes_sim::data::DeckData; use ournotes_sim::error::Error; use pyo3::exceptions::PyValueError; @@ -16,7 +16,12 @@ fn value_error(e: impl std::fmt::Display) -> PyErr { } /// The statistics of every chart, in chart order, on `workers` threads. -fn measure(data: &DeckData, options: &Options, workers: usize) -> Result, Error> { +fn measure( + data: &DeckData, + options: &Options, + workers: usize, + cache: Option<&ChartStatsCache>, +) -> Result, Error> { let kinds = chartstats::kinds(&data.master); let n = data.charts.len(); let next = AtomicUsize::new(0); @@ -31,7 +36,11 @@ fn measure(data: &DeckData, options: &Options, workers: usize) -> Result chartstats::chart_stats_with_cache(&data.master, c, &kinds, options, cache), + None => chartstats::chart_stats_with(&data.master, c, &kinds, options), + } + .map_err(|e| match e { Error::Domain(m) => Error::Domain(format!("chart {}: {m}", c.score_id)), e => e, }); @@ -53,45 +62,69 @@ fn measure(data: &DeckData, options: &Options, workers: usize) -> Result str +fn document( + data: &str, + seeds: Option, + workers: Option, + aptitude: bool, + cache: Option<&ChartStatsCache>, +) -> PyResult { + let options = Options { replay_seeds: seeds.unwrap_or(REPLAY_SEEDS), aptitude }; + let workers = workers.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get())); + let mut data = DeckData::from_json(data).map_err(value_error)?; + let charts = std::mem::take(&mut data.charts); + let mut doc = chartstats::document_with(&data, &options).map_err(value_error)?; + data.charts = charts; + let stats = measure(&data, &options, workers, cache).map_err(value_error)?; + doc["charts"] = serde_json::to_value(stats).map_err(value_error)?; + serde_json::to_string(&doc).map_err(value_error) +} + +/// chart_stats(data, seeds=None, workers=None, aptitude=True) -> str /// -/// The `ournotes-deck.chart-stats/2` document (JSON text) of a deck data document (`nnnotes.deck-data/1` JSON text), -/// as `ournotes-deck chart-stats` writes it. `seeds`: the size of the seed set of charts with a luck range (default -/// 8); `workers`: threads measuring charts (default: the available parallelism). Raises ValueError for data the -/// deck model cannot read or a chart whose check deck fails. `aptitude`: measure single Gekisou skill shapes -/// (default true); `aptitude_max_seeds` and `aptitude_cross_seeds`: sample caps (defaults 65536 and 64). +/// The `ournotes-deck.chart-stats/3` document (JSON text) of a deck data document (`nnnotes.deck-data/1` JSON text). +/// `seeds` controls the replay seed list (default 8); expectations use independent nominal probabilities. +/// `workers` controls chart parallelism. `aptitude` includes single Gekisou skill shapes (default true). +/// Raises ValueError for invalid data or a failed native expectation check. #[pyfunction] -#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true, aptitude_max_seeds=None, aptitude_cross_seeds=None))] +#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true))] fn chart_stats( py: Python<'_>, data: &str, seeds: Option, workers: Option, aptitude: bool, - aptitude_max_seeds: Option, - aptitude_cross_seeds: Option, ) -> PyResult { - let seeds = seeds.unwrap_or(GEKISOU_SEEDS); - let defaults = AptitudeOptions::default(); - let options = Options { - seeds, - aptitude: aptitude.then_some(AptitudeOptions { - max_seeds: aptitude_max_seeds.unwrap_or(defaults.max_seeds), - cross_seeds: aptitude_cross_seeds.unwrap_or(defaults.cross_seeds), - }), - }; - let workers = workers.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get())); - py.detach(|| { - let mut data = DeckData::from_json(data).map_err(value_error)?; - let charts = std::mem::take(&mut data.charts); - // the document of no chart: the format, source, model and kinds, exactly as the command writes them - let mut doc = chartstats::document_with(&data, &options).map_err(value_error)?; - data.charts = charts; - let stats = measure(&data, &options, workers).map_err(value_error)?; - doc["charts"] = serde_json::to_value(stats).map_err(value_error)?; - serde_json::to_string(&doc).map_err(value_error) - }) + py.detach(|| document(data, seeds, workers, aptitude, None)) +} + +/// chart_stats_cached(data, seeds=None, workers=None, aptitude=True, *, cache_dir) -> (str, dict) +/// +/// Persist exact native computation units and return operation counters separately from the document. +/// Completed units survive a later failure; every call validates the complete input and rebuilds its header. +#[pyfunction] +#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true, *, cache_dir))] +fn chart_stats_cached<'py>( + py: Python<'py>, + data: &str, + seeds: Option, + workers: Option, + aptitude: bool, + cache_dir: &str, +) -> PyResult<(String, Bound<'py, PyDict>)> { + let (text, stats) = py.detach(|| { + let cache = ChartStatsCache::new(cache_dir).map_err(value_error)?; + let text = document(data, seeds, workers, aptitude, Some(&cache))?; + Ok::<_, PyErr>((text, cache.snapshot())) + })?; + let counts = PyDict::new(py); + counts.set_item("requests", stats.requests)?; + counts.set_item("hits", stats.hits)?; + counts.set_item("computed", stats.computed)?; + counts.set_item("writes", stats.writes)?; + counts.set_item("invalid", stats.invalid)?; + counts.set_item("bytes", stats.bytes)?; + Ok((text, counts)) } /// info() -> dict: the deck model of this module (name: the ournotes-deck repository; version, source and commit: @@ -113,6 +146,7 @@ fn info(py: Python<'_>) -> PyResult> { #[pymodule] fn _deck(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(chart_stats, m)?)?; + m.add_function(wrap_pyfunction!(chart_stats_cached, m)?)?; m.add_function(wrap_pyfunction!(info, m)?)?; Ok(()) } diff --git a/src/nnnotes/cli.py b/src/nnnotes/cli.py index cf4f083..7fcc6c3 100644 --- a/src/nnnotes/cli.py +++ b/src/nnnotes/cli.py @@ -678,8 +678,7 @@ def cmd_music_data(args, cfg): try: 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, cache=args.stats_cache) + 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 @@ -987,21 +986,14 @@ def target(m, what): c.add_argument("--no-deck", action="store_true", help="do not run the deck model (every chart's deck is null)") c.add_argument("--seeds", type=int, default=8, metavar="N", - help="seeds measured on a chart with a luck range (default 8)") + help="replay seeds for a chart with a luck range (default 8); expectations do not use sampling") c.add_argument("--workers", type=int, metavar="N", help="threads measuring charts (default: every processor)") c.add_argument("--no-gekisou-aptitude", action="store_true", help="leave out the charts' Gekisou aptitude (every gekisouAptitude is null)") - c.add_argument("--aptitude-max-seeds", type=int, metavar="N", - help="seeds of a Gekisou aptitude variant at most (default: the deck model's, 65536); " - "sampling stops earlier when both score targets converge") - c.add_argument("--allow-unconverged-aptitude", action="store_true", - 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") + help="reuse native simulation programs and runs in DIR across builds; " + "completed cache entries survive interrupted builds") 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 9c8be53..0867155 100644 --- a/src/nnnotes/musicdata.py +++ b/src/nnnotes/musicdata.py @@ -1,5 +1,5 @@ """Music data: one JSON file with every live song and chart of one master data version (format -`nnnotes.music-data/1`, docs/music-data.md): titles and credits in every language, bands, vocal characters, category, +`nnnotes.music-data/2`, docs/music-data.md): titles and credits in every language, bands, vocal characters, category, tags, release time, score ranks, the live BGM's length, the Gekisou catalog (member cards, snaps and their Gekisou skills), per difficulty the chart facts (level, note counts, BPM, chart times, skill events, fever ranges) and the chart's deck statistics, what the chart contributes to the live score whatever the deck, with Gekisou on (a Gekisou @@ -26,14 +26,16 @@ import io import json import math +import sys from collections import Counter +from decimal import Decimal, localcontext from pathlib import Path from typing import Callable from . import deckdata from .languages import LANGUAGES -FORMAT = "nnnotes.music-data/1" +FORMAT = "nnnotes.music-data/2" DIFFICULTIES = ("easy", "normal", "hard", "expert") MUSIC_LENGTH_TAIL_MS = 1000 # the live's music length: the last note time + 1000 ms (LiveScore skip path) @@ -299,110 +301,83 @@ def skill(r: dict, max_level: dict) -> dict: # ---------------------------------------------------------------- the deck model -DECK_SEEDS = 8 # seed set size of a chart with a luck range (ournotes-deck's default) +DECK_SEEDS = 8 # replay seeds of a chart with a luck range +DECK_STATS_FORMAT = "ournotes-deck.chart-stats/3" 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). 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.""" + """The native chart expectation model. `seeds` selects replay examples; it does not sample expectations. + With `cache`, the native model persists reusable simulation programs and runs. Python passes the complete + current input on every call and publishes the native cache counters separately from the music data. + """ 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, cache: Path | None = None): + cache: Path | None = None): + native_max = sys.maxsize * 2 + 1 + if not _int(seeds) or not 1 <= seeds <= native_max: + raise MusicDataError("--seeds must be a positive integer supported by the native model") + if workers is not None and (not _int(workers) or not 0 <= workers <= native_max): + raise MusicDataError("--workers must be a nonnegative integer supported by the native model (0: automatic)") if module is None: try: from . import _deck as module except ImportError: raise MusicDataError("the deck model (nnnotes._deck) is not built into this installation: install " "nnnotes from a wheel or build it (maturin), or pass --no-deck") from None - self.module, self.seeds, self.workers = module, seeds, workers - self.aptitude, self.aptitude_max_seeds, self.aptitude_cross_seeds = \ - aptitude, aptitude_max_seeds, aptitude_cross_seeds - self.require_convergence = require_convergence + self.module, self.seeds, self.workers = module, seeds, None if workers == 0 else workers + self.aptitude = aptitude self.cache = None if cache is None else Path(cache) - self.counts: dict | None = None # {cached, measured} charts of the last stats() + self.counts: dict | None = None # native counters, or None when no cache was requested def info(self) -> dict: - """{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", "sourceSha256", "format")} + """The identity and statistics format of the compiled model.""" + info = self.module.info() + return {k: info[k] for k in ("name", "version", "source", "commit", "sourceSha256", "format")} - def _measure(self, deck_input: dict) -> dict: + def stats(self, deck_input: dict) -> dict: + """The current chart-statistics document, preserving every number exactly as the model writes it.""" + info = self.info() + if info["format"] != DECK_STATS_FORMAT: + raise MusicDataError(f"deck model: format {info['format']!r} is not supported; rebuild nnnotes with " + f"{DECK_STATS_FORMAT}") + data = deckdata.encode(deck_input).decode("utf-8") 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, - aptitude_cross_seeds=self.aptitude_cross_seeds) + if self.cache is None: + text = self.module.chart_stats(data, self.seeds, self.workers, aptitude=self.aptitude) + counts = None + else: + measure = getattr(self.module, "chart_stats_cached", None) + if not callable(measure): + raise MusicDataError("deck model: native statistics caching is not available; rebuild nnnotes " + "with the current deck model") + text, counts = measure(data, self.seeds, self.workers, aptitude=self.aptitude, + cache_dir=str(self.cache)) + if not isinstance(counts, dict): + raise MusicDataError("deck model: native cache counters are not an object") + except MusicDataError: + raise except ValueError as e: raise MusicDataError(f"deck model: {e}") from None - 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): + try: + doc = json.loads(text, parse_float=deckdata._Num) + except (TypeError, ValueError): + raise MusicDataError("deck model: the statistics are not a JSON document") from None + if not isinstance(doc, dict) or doc.get("format") != DECK_STATS_FORMAT: + wrote = doc.get("format") if isinstance(doc, dict) else type(doc).__name__ + raise MusicDataError(f"deck model: wrote {wrote!r}, expected {DECK_STATS_FORMAT!r}") + charts = doc.get("charts") + asked = [chart["scoreId"] for chart in deck_input["charts"]] + if (not isinstance(charts, list) or any(not isinstance(chart, dict) or not _int(chart.get("scoreId")) + for chart in charts) + or [chart["scoreId"] for chart in charts] != asked): 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", []) - for v in (c.get("gekisouAptitude") or {}).get("variants", []) - if v.get("seTargetMet") is not True] - if unmet: - chart, shape, band, seeds = unmet[0] - raise MusicDataError(f"deck model: {len(unmet)} aptitude variants did not meet BOTH score SE targets; " - f"chart {chart}, shape {shape}, bandMatch {band}, seeds {seeds}; " - "increase --aptitude-max-seeds; no final artifact written") + self.counts = counts return doc # the keys of a chart's statistics carried by `deck` (the others are checked against the chart facts) -DECK_CHART_KEYS = ("convertedNoteCount", "skip", "events", "positions", "ranges", "justNotes", "seeds", "offSeeds", - "unplayable", "gekisouAptitude") +DECK_CHART_KEYS = ("convertedNoteCount", "skip", "events", "positions", "ranges", "justNotes", "expectation", + "replaySeeds", "offSeeds", "unplayable", "gekisouAptitude") RANKS = 5 # the ranks of a Gekisou range (a Gekisou live has up to five players) GEKISOU_RANGES = 3 # the Gekisou ranges of a live (its first three fevers) @@ -430,12 +405,6 @@ def rank_bonus_percents(rows: list[dict], missions) -> list[list]: return out -def _trunc_percent(score: int, percent: int) -> int: - """trunc(score * percent / 100), the rank bonus of a range score.""" - p = score * percent - return p // 100 if p >= 0 else -(-p // 100) - - def _int(v) -> bool: return isinstance(v, int) and not isinstance(v, bool) @@ -443,7 +412,7 @@ def _int(v) -> bool: def _numbers(v, n: int) -> bool: """Whether v is a list of n numbers (as the deck model writes them).""" return isinstance(v, list) and len(v) == n and all( - isinstance(x, (int, deckdata._Num)) and not isinstance(x, bool) for x in v) + _number(x) for x in v) def _check_bound(c, what: str, where: str) -> None: @@ -458,12 +427,6 @@ def _check_bound(c, what: str, where: str) -> None: f"{c['bound']}") -def _check_rank_check(rc: dict, n: int, s: str, where: str) -> None: - if len(rc["ranks"]) != n or not all(1 <= r <= RANKS for r in rc["ranks"]): - raise MusicDataError(f"{where}: {s}: rank check ranks {rc['ranks']!r}") - _check_bound(rc, f"{s}: the check deck at ranks {rc['ranks']!r}", where) - - # ournotes-sim's published seed set (live::seeds): candidate k is the low 32 bits (a signed integer) of output k + 1 # of SplitMix64 started at SEED_ORIGIN (the ASCII bytes of `gekisou1`); a candidate is kept when its pair of effective # stream seeds (|b| and |b ^ 0x9E3779B9|, -2^31 taken as 2^31 - 1) differs from that of every seed kept before it @@ -497,60 +460,133 @@ def eff(x: int) -> int: def chart_seeds(stats: dict, seeds: int) -> list[int]: - """The seeds of a chart's Gekisou on statistics: none when it is unplayable with Gekisou, seed 0 when no range is - a luck range (the play draws nothing), else the first `seeds` published seeds.""" + """Replay examples: none when unplayable, seed 0 without luck, otherwise the published prefix.""" if stats.get("unplayable") is not None: return [] return published_seeds(seeds) if any(r["mission"] == MISSION_LUCK for r in stats["ranges"]) else [0] -def _check_seed_shapes(stats: dict, kinds: int, where: str) -> None: - """The array shapes and the checks of a chart's statistics: `weights[kind][position]` (a kind null only with - Gekisou off), `rangeWeights[kind][position][range]` (null, or a kind null), one Gekisou off seed, the seeds' - ranges one per range with their luck points, and every check (and rank check) within its bound.""" - positions, n = stats["positions"], len(stats["ranges"]) - for seed in stats["seeds"]: - s = f"seed {seed['seed']}" - w = seed.get("weights") - if not (isinstance(w, list) and len(w) == kinds and all(_numbers(x, positions) for x in w)): - raise MusicDataError(f"{where}: {s}: weights are not [kind][position]") - if len(seed["ranges"]) != n: - raise MusicDataError(f"{where}: {s}: {len(seed['ranges'])} range results for {n} ranges") - for i, r in enumerate(seed["ranges"]): - if not _int(r.get("luckPoints")): - raise MusicDataError(f"{where}: {s} range {i}: no luck points (luckPoints)") - rw = seed.get("rangeWeights") - if rw is not None and not (isinstance(rw, list) and len(rw) == kinds and all( - k is None or (isinstance(k, list) and len(k) == positions and all(_numbers(x, n) for x in k)) - for k in rw)): - raise MusicDataError(f"{where}: {s}: rangeWeights are not [kind][position][range]") - _check_bound(seed["check"], f"{s}: the check deck", where) - rc = seed.get("rankCheck") - if rc is not None: - _check_rank_check(rc, n, s, where) +def _check_deck_slots(deck, positions: int, allowed: set[int], where: str) -> None: + if not (isinstance(deck, list) and len(deck) == positions and all( + slot is None or (isinstance(slot, list) and len(slot) == 2 and _int(slot[0]) and slot[0] in allowed + and _int(slot[1])) for slot in deck)): + raise MusicDataError(f"{where}: check deck is not [kind, value] or null per position") + + +def _check_expectation_check(check, positions: int, allowed: set[int], ranges: int, where: str, *, + ranked: bool = False, linear: bool = True) -> None: + if not (isinstance(check, dict) and all(key in check for key in CHECK_KEYS) + and _estimate(check["expected"]) and _estimate(check["predicted"]) + and _number(check["bound"]) and float(check["bound"]) >= 0): + raise MusicDataError(f"{where}: invalid expectation check (expected, predicted and bound)") + _check_deck_slots(check["deck"], positions, allowed, where) + ranks = check["ranks"] + if ranked: + if not (isinstance(ranks, list) and len(ranks) == ranges + and all(_int(rank) and 1 <= rank <= RANKS for rank in ranks) + and (linear or all(rank == 1 for rank in ranks))): + raise MusicDataError(f"{where}: check ranks {ranks!r}") + elif ranks is not None: + raise MusicDataError(f"{where}: an unranked expectation check has ranks {ranks!r}") + with localcontext() as context: + context.prec = 800 # exact arithmetic for the finite binary64 decimal tokens + expected, predicted = check["expected"], check["predicted"] + error = abs(Decimal(expected[0]) - Decimal(predicted[0])) + Decimal(expected[1]) + Decimal(predicted[1]) + # Each of the two serialized score intervals rounds its center to millipoints and expands its radius + # outwards. This can add at most four millipoints to the pre-serialization check's endpoint distance. + if error > Decimal(check["bound"]) + Decimal("0.00400001"): + raise MusicDataError(f"{where}: expectation check exceeds its bound {check['bound']}: {error}") + + +def _check_expectation(stats: dict, kinds: int, where: str) -> None: + """Validate /3 expectations and the independent deterministic Free Live measurements.""" + positions, infos = stats.get("positions"), stats.get("ranges") + if not _int(positions) or positions < 0: + raise MusicDataError(f"{where}: invalid number of performance positions") + if not (isinstance(infos, list) and all(isinstance(row, dict) and _int(row.get("mission")) for row in infos)): + raise MusicDataError(f"{where}: invalid Gekisou range descriptions") + if not (all(_int(stats.get(key)) and stats[key] >= 0 for key in ("judgedNotes", "justNotes")) + and stats["justNotes"] <= stats["judgedNotes"]): + raise MusicDataError(f"{where}: invalid chart judgement counts") + n = len(infos) off = stats.get("offSeeds") - if not isinstance(off, list) or len(off) != 1: + if not (isinstance(off, list) and len(off) == 1 and isinstance(off[0], dict) + and _int(off[0].get("seed")) and off[0]["seed"] == 0 and _int(off[0].get("score"))): raise MusicDataError(f"{where}: the deck model gives no Gekisou off statistics (offSeeds)") - for seed in off: - w = seed.get("weights") - if not (isinstance(w, list) and len(w) == kinds and all(x is None or _numbers(x, positions) for x in w)): - raise MusicDataError(f"{where}: Gekisou off: weights are not [kind][position]") - _check_bound(seed["check"], "Gekisou off: the check deck", where) + weights = off[0].get("weights") + if not (isinstance(weights, list) and len(weights) == kinds + and all(row is None or _numbers(row, positions) for row in weights)): + raise MusicDataError(f"{where}: Gekisou off: weights are not [kind][position]") + _check_bound(off[0].get("check"), "Gekisou off: the check deck", where) + _check_deck_slots(off[0]["check"].get("deck"), positions, + {i for i, row in enumerate(weights) if row is not None}, f"{where}: Gekisou off") + if "expectation" not in stats: + raise MusicDataError(f"{where}: no Gekisou expectation field") + expected = stats["expectation"] + if stats.get("unplayable") is not None: + if expected is not None: + raise MusicDataError(f"{where}: an unplayable Gekisou chart has an expectation") + return + if not isinstance(expected, dict): + raise MusicDataError(f"{where}: no Gekisou expectation") + if any(key not in expected for key in ("rangeWeights", "rankCheck")): + raise MusicDataError(f"{where}: no expectation rangeWeights or rankCheck field") + if not all(_estimate(expected.get(key)) for key in ("score", "scorePerfect")): + raise MusicDataError(f"{where}: expected scores are not [center, outward radius]") + ranges = expected.get("ranges") + if not (isinstance(ranges, list) and len(ranges) == n and all(isinstance(row, dict) for row in ranges)): + raise MusicDataError(f"{where}: expectation ranges do not match the chart's ranges") + for i, (row, info) in enumerate(zip(ranges, stats["ranges"])): + at = f"{where}: expectation range {i}" + if not all(_estimate(row.get(key)) for key in EXPECTED_RANGE_ESTIMATES): + raise MusicDataError(f"{at}: a range score or luck points is not [center, outward radius]") + if not all(_int(row.get(key)) and row[key] >= 0 for key in ("maxCombo", "justCount")): + raise MusicDataError(f"{at}: invalid judgement counters") + lots = row.get("lotResults") + if not (isinstance(lots, list) and len(lots) == 4 and all(_estimate(value) for value in lots)): + raise MusicDataError(f"{at}: lottery results are not four [center, outward radius] estimates") + if info["mission"] != MISSION_LUCK and any(not _consistent(value, [[0, 0]]) for value in [row["luckPoints"], *lots]): + raise MusicDataError(f"{at}: lottery results or luck points outside a luck range") + if stats["justNotes"] == 0 and any(not _consistent(row[a], [row[b]]) for a, b in ( + ("rangeScore", "rangeScorePerfect"), ("rankBonus", "rankBonusPerfect"))): + raise MusicDataError(f"{at}: a chart without Just notes differs on its Perfect play") + if stats["justNotes"] == 0 and not _consistent(expected["score"], [expected["scorePerfect"]]): + raise MusicDataError(f"{where}: a chart without Just notes differs on its Perfect play") + weights, rw = expected.get("weights"), expected.get("rangeWeights") + if not (isinstance(weights, list) and len(weights) == kinds + and all(isinstance(row, list) and len(row) == positions and all(_estimate(v) for v in row) + for row in weights)): + raise MusicDataError(f"{where}: expectation weights are not [kind][position] of estimates") + if rw is not None and not (isinstance(rw, list) and len(rw) == kinds and all( + kind is None or (isinstance(kind, list) and len(kind) == positions and all( + isinstance(row, list) and len(row) == n and all(_estimate(value) for value in row) for row in kind)) + for kind in rw)): + raise MusicDataError(f"{where}: expectation rangeWeights are not [kind][position][range] of estimates") + _check_expectation_check(expected.get("check"), positions, set(range(kinds)), n, f"{where}: expectation") + check, rank_check = expected["check"], expected.get("rankCheck") + can_rank = rw is not None and n > 0 and all(slot is None or rw[slot[0]] is not None for slot in check["deck"]) + if can_rank != (rank_check is not None): + raise MusicDataError(f"{where}: rankCheck does not match the range-weight domain") + if rank_check is not None: + _check_expectation_check(rank_check, positions, set(range(kinds)), n, + f"{where}: rank expectation", ranked=True) + if rank_check["deck"] != check["deck"]: + raise MusicDataError(f"{where}: rankCheck does not use the expectation's check deck") # ---------------------------------------------------------------- the Gekisou aptitude PLAIN_EFFECT_TYPE, PLAIN_MS = 2000, 5000 # the plain kind: score up on the whole deck for 5 s, nothing else CONDITION_MEMBER_TARGET = 5000 # the skill condition on the member a snap's support skill is paired with -APTITUDE_KEYS = ("plainKind", "host", "seedRule", "shapes") -SEED_RULE_KEYS = ("deterministicTest", "batches", "relative", "baseline", "crossSeeds") +APTITUDE_KEYS = ("plainKind", "host", "law", "shapes") +EXPECTATION_LAW = "independent nominal lottery and skill probabilities" SHAPE_KEYS = ("id", "source", "mission", "bandCondition", "effects", "skills") FACTOR_COUNTS = ("judgedNotes", "justNotes", "perfectNotes", "tailNotes", "comboAtStart") -VARIANT_MEANS = ("score", "scorePerfect", "tail", "tailPerfect", "converted") -RANGE_MEANS = ("rangeScore", "rankBonus", "rangeScorePerfect", "maxCombo", "justCount", "luckPoints") -CHECK_KEYS = ("seed", "ranks", "deck", "exact", "predicted", "bound") +VARIANT_ESTIMATES = ("score", "scorePerfect", "tail", "tailPerfect", "converted") +EXPECTED_RANGE_ESTIMATES = ("rangeScore", "rankBonus", "rankBonusPerfect", "rangeScorePerfect", "luckPoints") +RANGE_ESTIMATES = (*EXPECTED_RANGE_ESTIMATES, "maxCombo", "justCount") +CHECK_KEYS = ("ranks", "deck", "expected", "predicted", "bound") CONDITION_GROUPS = (("trigger", "_skillTriggerConditionGroup"), ("condition", "_skillConditionGroup"), ("release", "_skillReleaseConditionGroup"), ("reset", "_effectExecuteLimitResetConditionGroup")) -MEAN_TOLERANCE = 1e-9 # relative: a mean against its recomputation (binary64 sums) def plain_kind(kinds: list[dict]) -> int | None: @@ -576,20 +612,20 @@ def _number(v) -> bool: return False -def _mean_se(v) -> bool: - """Whether v is `[mean, standard error]`: two finite numbers, the error not negative.""" - return isinstance(v, list) and len(v) == 2 and all(_number(x) for x in v) and float(v[1]) >= 0 - +def _estimate(value) -> bool: + """A finite [center, outward interval half-width], including its two finite endpoints.""" + return (isinstance(value, list) and len(value) == 2 and all(_number(v) for v in value) + and float(value[1]) >= 0 and math.isfinite(float(value[0]) - float(value[1])) + and math.isfinite(float(value[0]) + float(value[1]))) -def _same(a: float, b: float, scale: float = 1.0) -> bool: - return abs(a - b) <= MEAN_TOLERANCE * max(1.0, abs(scale), abs(a), abs(b)) - -def mean_se(xs: list[int]) -> tuple[float, float]: - """The mean of xs and its standard error (the sample standard deviation / sqrt(n); 0 for one value).""" - n = len(xs) - m = sum(xs) / n - return m, (math.sqrt(sum((x - m) ** 2 for x in xs) / (n - 1) / n) if n > 1 else 0.0) +def _consistent(value, terms) -> bool: + """Whether an estimate and a sum of estimates have a common value; radii are bounds, never sample errors.""" + with localcontext() as context: + context.prec = 800 + center = sum((Decimal(term[0]) for term in terms), Decimal(0)) + radius = sum((Decimal(term[1]) for term in terms), Decimal(0)) + return abs(Decimal(value[0]) - center) <= Decimal(value[1]) + radius def _f32(v) -> float: @@ -703,7 +739,7 @@ def _effects_equal(got, want) -> bool: def check_header(self, info, kinds: list[dict], model: dict) -> dict | None: """`deck.gekisouAptitude` after checking it (None when the deck model leaves the aptitude out): the plain kind - of `kinds`, a description of the host (and `model.gekisouAptitude`), the seed rule, and the shape table: + of `kinds`, a description of the host (and `model.gekisouAptitude`), the expectation law and shape table: ids 0..n-1, each shape's skills in the catalog (a member card's skill at its highest level, a snap's support skill at its highest rank level) with the shape's mission, their effect rows as the master data has them, their member targets and bands, and every skill and level of the catalog in exactly one shape.""" @@ -720,15 +756,8 @@ def check_header(self, info, kinds: list[dict], model: dict) -> dict | None: raise MusicDataError(f"{where}: the support skills' host is not described (host)") if not (isinstance(model.get("gekisouAptitude"), str) and model["gekisouAptitude"]): raise MusicDataError("deck model: the model does not describe the aptitude (model.gekisouAptitude)") - rule = info["seedRule"] - if not (isinstance(rule, dict) and all(k in rule for k in SEED_RULE_KEYS) - and _int(rule["deterministicTest"]) and rule["deterministicTest"] >= 1 - and isinstance(rule["batches"], list) and rule["batches"] and all(_int(b) for b in rule["batches"]) - and rule["batches"][0] >= 2 and all(a < b for a, b in zip(rule["batches"], rule["batches"][1:])) - and _number(rule["relative"]) and float(rule["relative"]) >= 0 - and _number(rule["baseline"]) and float(rule["baseline"]) >= 0 - and _int(rule["crossSeeds"]) and rule["crossSeeds"] >= 1): - raise MusicDataError(f"{where}: seed rule {rule!r}") + if info["law"] != EXPECTATION_LAW: + raise MusicDataError(f"{where}: unsupported expectation law {info['law']!r}") shapes = info["shapes"] if not isinstance(shapes, list) or [s.get("id") if isinstance(s, dict) else s for s in shapes] != list( range(len(shapes))): @@ -779,161 +808,121 @@ def check_header(self, info, kinds: list[dict], model: dict) -> dict | None: self.header, self.shapes = info, shapes return {k: info[k] for k in APTITUDE_KEYS} - # ------------------------------------------------ a chart's aptitude def chart(self, where: str, stats: dict, plain: int | None) -> dict | None: - """A chart's `gekisouAptitude` after checking it: null exactly when the aptitude is left out, the master data - has no shape, the chart is unplayable with Gekisou or has no Gekisou range; else a factor per range and a - variant per shape of the chart's missions (both band match results of a shape with a band condition), each - with its means shaped and consistent (the tail, a deterministic variant's rank bonuses) and its check within - its bound.""" - apt = stats.get("gekisouAptitude") - if self.header is None or not self.shapes or stats.get("unplayable") is not None or not stats["ranges"]: - if apt is not None: - why = ("the aptitude left out" if self.header is None else "no shape" if not self.shapes - else "a chart unplayable with Gekisou" if stats.get("unplayable") is not None - else "a chart without Gekisou ranges") - raise MusicDataError(f"{where}: the deck model gives a Gekisou aptitude with {why}") - return None - where = f"{where}: Gekisou aptitude" - if not (isinstance(apt, dict) and isinstance(apt.get("factors"), list) and isinstance(apt.get("variants"), - list)): - raise MusicDataError(f"{where}: none (gekisouAptitude {{factors, variants}})") - ranges, base = stats["ranges"], stats["seeds"] - self._factors(where, apt["factors"], stats) - missions = {r["mission"] for r in ranges} - want = [(s["id"], b) for s in self.shapes if s["mission"] == MISSION_ALL or s["mission"] in missions - for b in ((True, False) if s["bandCondition"] else (None,))] - if any(not isinstance(v, dict) or not _int(v.get("shape")) - or v.get("bandMatch") is not None and not isinstance(v.get("bandMatch"), bool) - or "bandMatch" not in v for v in apt["variants"]): - raise MusicDataError(f"{where}: invalid shape or bandMatch") - got = [(v.get("shape"), v.get("bandMatch")) if isinstance(v, dict) else v for v in apt["variants"]] - if got != want: - raise MusicDataError(f"{where}: variants (shape, bandMatch) {got!r}, the shapes of the chart's missions " - f"{sorted(missions)} give {want!r}") - for v in apt["variants"]: - self._variant(f"{where}: shape {v['shape']} band match {v['bandMatch']!r}", v, stats, base, plain) - return {"factors": apt["factors"], "variants": apt["variants"]} - - def _factors(self, where: str, factors: list, stats: dict) -> None: - ranges, base = stats["ranges"], stats["seeds"] - if len(factors) != len(ranges): - raise MusicDataError(f"{where}: {len(factors)} factors for {len(ranges)} ranges") - just = 0 - for j, (f, r) in enumerate(zip(factors, ranges)): - at = f"{where}: range {j} factors" - if not (isinstance(f, dict) and all(_int(f.get(k)) and f[k] >= 0 for k in FACTOR_COUNTS) - and _mean_se(f.get("lotteries"))): - raise MusicDataError(f"{at}: not {{{', '.join(FACTOR_COUNTS)}, lotteries}}: {f!r}") - if r["mission"] != MISSION_JUST and (f["justNotes"], f["perfectNotes"]) != (0, 0): - raise MusicDataError(f"{at}: {f['justNotes']} Just and {f['perfectNotes']} Perfect notes outside a " - f"Just count range") - if f["justNotes"] + f["perfectNotes"] > f["judgedNotes"] or f["comboAtStart"] > stats["judgedNotes"]: - raise MusicDataError(f"{at}: note counts {f!r} beyond the range's or the chart's") - just += f["justNotes"] - m, se = mean_se([sum(s["ranges"][j]["lotResults"]) for s in base]) if base else (0.0, 0.0) - if r["mission"] != MISSION_LUCK: - m, se = 0.0, 0.0 - if not (_same(float(f["lotteries"][0]), m) and _same(float(f["lotteries"][1]), se, m)): - raise MusicDataError(f"{at}: lotteries {f['lotteries']}, the chart's seeds give [{m!r}, {se!r}]") - if just > stats["justNotes"]: - raise MusicDataError(f"{where}: {just} Just notes in the ranges, {stats['justNotes']} on the play") - - def _variant(self, at: str, v: dict, stats: dict, base: list, plain: int | None) -> None: - rule, n, positions = self.header["seedRule"], len(stats["ranges"]), stats["positions"] - if not (isinstance(v.get("deterministic"), bool) and isinstance(v.get("seTargetMet"), bool) - and _int(v.get("seeds")) and _int(v.get("crossSeeds"))): - raise MusicDataError(f"{at}: deterministic, seeds, seTargetMet, crossSeeds " - f"{[v.get(k) for k in ('deterministic', 'seeds', 'seTargetMet', 'crossSeeds')]!r}") - det = v["deterministic"] - effects = self.shapes[v["shape"]]["effects"] - random = any(r["mission"] == MISSION_LUCK for r in stats["ranges"]) or any( - 11000 <= e["effectType"] <= 11005 or any( - c["type"] == 4011 for key, _ in CONDITION_GROUPS for group in e[key] for c in group) - for e in effects) - if det and random: - raise MusicDataError(f"{at}: a deterministic variant with a random dependency") - if det and (v["seeds"], v["seTargetMet"]) != (1, True): - raise MusicDataError(f"{at}: deterministic on {v['seeds']} seeds (seTargetMet {v['seTargetMet']})") - if not det and (v["seeds"] not in rule["batches"] - or not v["seTargetMet"] and v["seeds"] != rule["batches"][-1]): - raise MusicDataError(f"{at}: {v['seeds']} seeds (seTargetMet {v['seTargetMet']}), not a batch of the seed " - f"rule {rule['batches']}") - if v["crossSeeds"] != min(v["seeds"], rule["crossSeeds"]): - raise MusicDataError(f"{at}: {v['crossSeeds']} cross seeds, not min({v['seeds']}, {rule['crossSeeds']})") - pairs = [(k, v.get(k)) for k in VARIANT_MEANS] - rs = v.get("ranges") - if not (isinstance(rs, list) and len(rs) == n and all(isinstance(r, dict) for r in rs)): - raise MusicDataError(f"{at}: {len(rs) if isinstance(rs, list) else rs!r} range results for {n} ranges") - pairs += [(f"ranges[{j}].{k}", r.get(k)) for j, r in enumerate(rs) for k in RANGE_MEANS] - w, rw = v.get("weights"), v.get("rangeWeights") - linear = bool(base) and base[0].get("rangeWeights") is not None - if plain is None: - if w is not None or rw is not None: - raise MusicDataError(f"{at}: weights without a plain kind") - else: - if not (isinstance(w, list) and len(w) == positions): - raise MusicDataError(f"{at}: weights are not [position] of [mean, se]") - pairs += [(f"weights[{k}]", x) for k, x in enumerate(w)] - if (rw is not None) != linear: - raise MusicDataError(f"{at}: rangeWeights {'given' if rw is not None else 'null'}, the chart's seeds " - f"have {'them' if linear else 'none'}") - if rw is not None: - if not (isinstance(rw, list) and len(rw) == positions and all(isinstance(x, list) and len(x) == n - for x in rw)): - raise MusicDataError(f"{at}: rangeWeights are not [position][range] of [mean, se]") - pairs += [(f"rangeWeights[{k}][{j}]", y) for k, x in enumerate(rw) for j, y in enumerate(x)] - for name, x in pairs: - if not _mean_se(x): - raise MusicDataError(f"{at}: {name} {x!r} is not [mean, standard error]") - if det and float(x[1]) != 0: - raise MusicDataError(f"{at}: {name} {x!r}: a deterministic variant with a standard error") - m = {k: float(v[k][0]) for k in VARIANT_MEANS} - terms = [float(r[k][0]) for r in rs for k in ("rangeScore", "rankBonus")] - # Every serialized point mean is independently rounded to 0.001 (score, tail and two terms per range). - if abs(m["tail"] - (m["score"] - sum(terms))) > (len(terms) + 2) * 0.0005 + 1e-8: - raise MusicDataError(f"{at}: tail {v['tail'][0]} is not score {v['score'][0]} - {sum(terms)!r} (the " - f"ranges' scores and rank bonuses)") - if det: - ints = [float(x[0]) for name, x in pairs if not name.startswith(("weights", "rangeWeights"))] - if not all(x.is_integer() for x in ints): - raise MusicDataError(f"{at}: a deterministic variant with a fraction of a point") - perfect = 0 # the Perfect play's range scores and rank bonuses - for j, (r, info, r0) in enumerate(zip(rs, stats["ranges"], base[0]["ranges"])): - p = info["rankBonusPercent"] - want = _trunc_percent(r0["rangeScore"] + int(float(r["rangeScore"][0])), p) - r0["rankBonus"] - if int(float(r["rankBonus"][0])) != want: - raise MusicDataError(f"{at}: range {j}: rank bonus {r['rankBonus'][0]} is not trunc((" - f"{r0['rangeScore']} + {r['rangeScore'][0]}) * {p} / 100) - " - f"{r0['rankBonus']} = {want}") - d, r0p = int(float(r["rangeScorePerfect"][0])), r0["rangeScorePerfect"] - perfect += d + _trunc_percent(r0p + d, p) - _trunc_percent(r0p, p) - if m["tailPerfect"] != m["scorePerfect"] - perfect: - raise MusicDataError(f"{at}: tail on the Perfect play {v['tailPerfect'][0]} is not scorePerfect " - f"{v['scorePerfect'][0]} - {perfect} (the ranges' Perfect play scores and rank " - f"bonuses)") - c = v.get("check") - if not (isinstance(c, dict) and all(k in c for k in CHECK_KEYS)): - raise MusicDataError(f"{at}: no check {{{', '.join(CHECK_KEYS)}}}") - first = base[0]["seed"] if det else published_seeds(1)[0] - if not _int(c["seed"]) or c["seed"] != first: - raise MusicDataError(f"{at}: the check plays seed {c['seed']!r}, not the variant's first {first}") - if not (isinstance(c["ranks"], list) and len(c["ranks"]) == n and all(_int(r) and 1 <= r <= RANKS - for r in c["ranks"]) - and (linear or all(r == 1 for r in c["ranks"]))): - raise MusicDataError(f"{at}: check ranks {c['ranks']!r}") - if not (isinstance(c["deck"], list) and len(c["deck"]) == positions and all( - x is None or (plain is not None and isinstance(x, list) and len(x) == 2 and x[0] == plain - and _int(x[1])) for x in c["deck"])): - raise MusicDataError(f"{at}: check deck {c['deck']!r} is not [plain kind ({plain}), value] or null per " - f"position") - _check_bound(c, "the check deck", at) + return validate_aptitude_statistics(self.header, stats, where) + + +def validate_aptitude_statistics(header: dict | None, stats: dict, where: str) -> dict | None: + """A chart's `gekisouAptitude` after checking it: null exactly when the aptitude is left out, the master data + has no shape, the chart is unplayable with Gekisou or has no Gekisou range; else a factor per range and a + variant per shape of the chart's missions (both band match results of a shape with a band condition), each + with finite outward estimates, consistent tail identities and its expectation check within + its bound.""" + shapes = header["shapes"] if header is not None else [] + plain = header["plainKind"] if header is not None else None + if "gekisouAptitude" not in stats: + raise MusicDataError(f"{where}: no gekisouAptitude field") + apt = stats["gekisouAptitude"] + if header is None or not shapes or stats.get("unplayable") is not None or not stats["ranges"]: + if apt is not None: + why = ("the aptitude left out" if header is None else "no shape" if not shapes + else "a chart unplayable with Gekisou" if stats.get("unplayable") is not None + else "a chart without Gekisou ranges") + raise MusicDataError(f"{where}: the deck model gives a Gekisou aptitude with {why}") + return None + where = f"{where}: Gekisou aptitude" + if not (isinstance(apt, dict) and isinstance(apt.get("factors"), list) and isinstance(apt.get("variants"), + list)): + raise MusicDataError(f"{where}: none (gekisouAptitude {{factors, variants}})") + ranges, base = stats["ranges"], stats["expectation"] + _validate_aptitude_factors(where, apt["factors"], stats) + missions = {r["mission"] for r in ranges} + want = [(s["id"], b) for s in shapes if s["mission"] == MISSION_ALL or s["mission"] in missions + for b in ((True, False) if s["bandCondition"] else (None,))] + if any(not isinstance(v, dict) or not _int(v.get("shape")) + or v.get("bandMatch") is not None and not isinstance(v.get("bandMatch"), bool) + or "bandMatch" not in v for v in apt["variants"]): + raise MusicDataError(f"{where}: invalid shape or bandMatch") + got = [(v.get("shape"), v.get("bandMatch")) if isinstance(v, dict) else v for v in apt["variants"]] + if got != want: + raise MusicDataError(f"{where}: variants (shape, bandMatch) {got!r}, the shapes of the chart's missions " + f"{sorted(missions)} give {want!r}") + for v in apt["variants"]: + _validate_aptitude_variant(f"{where}: shape {v['shape']} band match {v['bandMatch']!r}", v, stats, base, plain, shapes) + return {"factors": apt["factors"], "variants": apt["variants"]} + + +def _validate_aptitude_factors(where: str, factors: list, stats: dict) -> None: + ranges, base = stats["ranges"], stats["expectation"] + if len(factors) != len(ranges): + raise MusicDataError(f"{where}: {len(factors)} factors for {len(ranges)} ranges") + for j, (f, r) in enumerate(zip(factors, ranges)): + at = f"{where}: range {j} factors" + if not (isinstance(f, dict) and all(_int(f.get(k)) and f[k] >= 0 for k in FACTOR_COUNTS) + and _estimate(f.get("lotteries"))): + raise MusicDataError(f"{at}: not {{{', '.join(FACTOR_COUNTS)}, lotteries}}: {f!r}") + if r["mission"] != MISSION_JUST and (f["justNotes"], f["perfectNotes"]) != (0, 0): + raise MusicDataError(f"{at}: {f['justNotes']} Just and {f['perfectNotes']} Perfect notes outside a " + f"Just count range") + if f["justNotes"] + f["perfectNotes"] > f["judgedNotes"] or f["comboAtStart"] > stats["judgedNotes"]: + raise MusicDataError(f"{at}: note counts {f!r} beyond the range's or the chart's") + lotteries = base["ranges"][j]["lotResults"] + if not _consistent(f["lotteries"], lotteries): + raise MusicDataError(f"{at}: lotteries {f['lotteries']} do not enclose the sum of expected results") + if f["justNotes"] > stats["justNotes"]: + raise MusicDataError(f"{at}: more Just notes than the chart's play") + + +def _validate_aptitude_variant(at: str, value: dict, stats: dict, base: dict, plain: int | None, shapes: list) -> None: + n, positions = len(stats["ranges"]), stats["positions"] + effects = shapes[value["shape"]]["effects"] + reads_rank = any( + any(condition["type"] == 7012 for key, _ in CONDITION_GROUPS for group in effect[key] + for condition in group) or (effect["cumulative"] or {}).get("type") == 7012 for effect in effects) + linear = base.get("rangeWeights") is not None and not reads_rank + pairs = [(key, value.get(key)) for key in VARIANT_ESTIMATES] + ranges = value.get("ranges") + if not (isinstance(ranges, list) and len(ranges) == n and all(isinstance(row, dict) for row in ranges)): + raise MusicDataError(f"{at}: range results do not match the chart's {n} ranges") + pairs += [(f"ranges[{i}].{key}", row.get(key)) for i, row in enumerate(ranges) for key in RANGE_ESTIMATES] + if "weights" not in value or "rangeWeights" not in value: + raise MusicDataError(f"{at}: no weights or rangeWeights field") + weights, rw = value["weights"], value["rangeWeights"] + if plain is None: + if weights is not None or rw is not None: + raise MusicDataError(f"{at}: weights without a plain kind") + else: + if not (isinstance(weights, list) and len(weights) == positions): + raise MusicDataError(f"{at}: weights are not [position] of estimates") + pairs += [(f"weights[{i}]", weight) for i, weight in enumerate(weights)] + if (rw is not None) != linear: + raise MusicDataError(f"{at}: rangeWeights do not match the chart and shape's rank-linear domain") + if rw is not None: + if not (isinstance(rw, list) and len(rw) == positions + and all(isinstance(row, list) and len(row) == n for row in rw)): + raise MusicDataError(f"{at}: rangeWeights are not [position][range] of estimates") + pairs += [(f"rangeWeights[{i}][{j}]", estimate) for i, row in enumerate(rw) + for j, estimate in enumerate(row)] + for name, estimate in pairs: + if not _estimate(estimate): + raise MusicDataError(f"{at}: {name} {estimate!r} is not [center, outward radius]") + for name, estimate in [("converted", value["converted"])] + [ + (f"ranges[{i}].{key}", row[key]) for i, row in enumerate(ranges) for key in ("maxCombo", "justCount")]: + if float(estimate[1]) != 0 or not float(estimate[0]).is_integer(): + raise MusicDataError(f"{at}: {name} is not a deterministic integer counter") + for score, tail, range_score, bonus in (("score", "tail", "rangeScore", "rankBonus"), + ("scorePerfect", "tailPerfect", "rangeScorePerfect", "rankBonusPerfect")): + terms = [value[tail]] + [row[key] for row in ranges for key in (range_score, bonus)] + if not _consistent(value[score], terms): + raise MusicDataError(f"{at}: {tail} interval is inconsistent with {score} and the range increments") + _check_expectation_check(value.get("check"), positions, set() if plain is None else {plain}, n, + f"{at}: expectation", ranked=True, linear=linear) def chart_deck(song: dict, chart: dict, stats: dict, kinds: int, percents: list[list], seeds: int, aptitude: Aptitude, plain: int | None) -> dict: """A chart's `deck` from its statistics, after checking them against the song, the chart facts, the song's rank - bonus percentages (`percents`: rank_bonus_percents), the seed set (chart_seeds: `seeds` seeds with a luck range) + bonus percentages (`percents`: rank_bonus_percents), and the replay seed set (`seeds` with a luck range) and its Gekisou aptitude (Aptitude.chart, `plain` the plain kind), `kinds` the number of score-up kinds.""" where = f"chart {chart['scoreId']} ({song['id']} {chart['difficulty']})" checks = ( @@ -958,20 +947,10 @@ def chart_deck(song: dict, chart: dict, stats: dict, kinds: int, percents: list[ raise MusicDataError(f"{where}: range {i}: the deck model's rank bonus percentages {got!r} " f"({r['rankBonusPercent']!r}) differ from MasterLiveGekisouRankingScoreBonus {want!r}") want = chart_seeds(stats, seeds) - if [s["seed"] for s in stats["seeds"]] != want: - raise MusicDataError(f"{where}: seeds {[s['seed'] for s in stats['seeds']]} are not the chart's seed set " - f"{want}") - _check_seed_shapes(stats, kinds, where) - for seed in stats["seeds"]: - for i, (r, info) in enumerate(zip(seed["ranges"], ranges)): - bonus = _trunc_percent(r["rangeScore"], info["rankBonusPercent"]) - if r["rankBonus"] != bonus: - raise MusicDataError(f"{where}: seed {seed['seed']} range {i}: rank bonus {r['rankBonus']} is not " - f"trunc({r['rangeScore']} * {info['rankBonusPercent']} / 100) = {bonus}") - if stats["justNotes"] == 0 and (seed.get("scorePerfect") != seed["score"] or any( - r.get("rangeScorePerfect") != r["rangeScore"] for r in seed["ranges"])): - raise MusicDataError(f"{where}: seed {seed['seed']}: a chart without Just notes scores otherwise on the " - f"Perfect play ({seed.get('scorePerfect')} for {seed['score']})") + replay = stats.get("replaySeeds") + if not isinstance(replay, list) or not all(_int(seed) for seed in replay) or replay != want: + raise MusicDataError(f"{where}: replay seeds {replay!r} are not the chart's seed set {want}") + _check_expectation(stats, kinds, where) return dict({k: stats.get(k) for k in DECK_CHART_KEYS}, gekisouAptitude=aptitude.chart(where, stats, plain)) diff --git a/tests/test_musicdata.py b/tests/test_musicdata.py index db077db..2b4b81f 100644 --- a/tests/test_musicdata.py +++ b/tests/test_musicdata.py @@ -7,6 +7,7 @@ import json import re import sys +from copy import deepcopy from pathlib import Path import pytest @@ -205,63 +206,50 @@ def shape_skill(i, level, targets=None, bands=None): {"id": 4, "source": "support", "mission": 1, "bandCondition": False, "effects": [effect_row(5)], "skills": [shape_skill(32, 5)]}, ] -SEED_RULE = {"deterministicTest": 4, "batches": [32, 64, 128, 256, 512, 1024], "relative": 0.01, "baseline": 0.001, - "crossSeeds": 64} -NONDETERMINISTIC = {2, 3} # the shapes the stand-in measures on 32 seeds - - -def mean_se(xs): - m = sum(xs) / len(xs) - return [m, (sum((x - m) ** 2 for x in xs) / (len(xs) - 1) / len(xs)) ** 0.5 if len(xs) > 1 else 0.0] - - -def aptitude_of(s, positions): - """The stand-in's Gekisou aptitude of a chart's statistics `s`: per range 5 judged notes (Perfect in a Just count - range), per variant +10 * (j + 1) in range j and 7 after the ranges; a nondeterministic shape's means a quarter - point off with a standard error of 0.5.""" - ranges, base = s["ranges"], s["seeds"][0] - missions = {r["mission"] for r in ranges} - factors = [{"judgedNotes": 5, "justNotes": 0, "perfectNotes": 5 if r["mission"] == 3 else 0, "tailNotes": 1, - "comboAtStart": 0, - "lotteries": mean_se([sum(x["ranges"][j]["lotResults"]) for x in s["seeds"]]) if r["mission"] == 2 - else [0, 0]} for j, r in enumerate(ranges)] +def expectation_check(positions, ranks=None): + return {"deck": ([[0, 5000]] + [None] * (positions - 1)) if positions else [], "ranks": ranks, + "expected": [2000, 0.001], "predicted": [2000.25, 0.001], "bound": 7.0} + + +def aptitude_of(stats, positions): + """Independently authored interval fixtures, not native numerical results or sampling estimates.""" + ranges, base = stats["ranges"], stats["expectation"] + missions = {row["mission"] for row in ranges} + factors = [{"judgedNotes": 5, "justNotes": 0, "perfectNotes": 5 if row["mission"] == 3 else 0, + "tailNotes": 1, "comboAtStart": 0, + "lotteries": [sum(value[0] for value in base["ranges"][j]["lotResults"]), + sum(value[1] for value in base["ranges"][j]["lotResults"])]} + for j, row in enumerate(ranges)] variants = [] for shape in SHAPES: if shape["mission"] != 4 and shape["mission"] not in missions: continue for band in ((True, False) if shape["bandCondition"] else (None,)): - det = shape["id"] not in NONDETERMINISTIC and 2 not in missions - off, se = (0, 0) if det else (0.25, 0.5) - rs, inside, inside_p = [], 0, 0 - for j, (r, r0) in enumerate(zip(ranges, base["ranges"])): - d = 10 * (j + 1) - rb = trunc_percent(r0["rangeScore"] + d, r["rankBonusPercent"]) - r0["rankBonus"] - rbp = trunc_percent(r0["rangeScorePerfect"] + d, r["rankBonusPercent"]) - \ - trunc_percent(r0["rangeScorePerfect"], r["rankBonusPercent"]) - rs.append({"rangeScore": [d + off, se], "rankBonus": [rb + off, se], "rangeScorePerfect": [d + off, se], - "maxCombo": [0, 0], "justCount": [1 + off, se], "luckPoints": [off, se]}) - inside += d + rb + 2 * off - inside_p += d + rbp - seeds = 1 if det else 32 - variants.append({ - "shape": shape["id"], "bandMatch": band, "deterministic": det, "seeds": seeds, "seTargetMet": True, - "crossSeeds": min(seeds, SEED_RULE["crossSeeds"]), - "score": [inside + 7, se], "scorePerfect": [inside_p + 7 + off, se], "tail": [7, se], - "tailPerfect": [7, se], "converted": [0, 0], "ranges": rs, - "weights": [[0.01, se / 100]] * positions, - "rangeWeights": [[[0.001, 0]] * len(ranges)] * positions if base["rangeWeights"] is not None else None, - "check": {"seed": base["seed"] if det else musicdata.published_seeds(1)[0], "ranks": [2 if base["rangeWeights"] is not None else 1] * len(ranges), - "deck": [[0, 5000]] + [None] * (positions - 1), "exact": 2000, "predicted": 2000.25, - "bound": 7.0}}) + rs, inside, inside_perfect = [], 0, 0 + for j, (row, baseline) in enumerate(zip(ranges, base["ranges"])): + gain = 10 * (j + 1) + bonus = trunc_percent(baseline["rangeScore"][0] + gain, row["rankBonusPercent"]) - baseline["rankBonus"][0] + perfect_bonus = trunc_percent(baseline["rangeScorePerfect"][0] + gain, row["rankBonusPercent"]) - baseline["rankBonusPerfect"][0] + rs.append({"rangeScore": [gain, 0], "rankBonus": [bonus, 0], "rankBonusPerfect": [perfect_bonus, 0], + "rangeScorePerfect": [gain, 0], "maxCombo": [0, 0], "justCount": [1, 0], + "luckPoints": [0, 0]}) + inside += gain + bonus + inside_perfect += gain + perfect_bonus + linear = base["rangeWeights"] is not None + variants.append({"shape": shape["id"], "bandMatch": band, + "score": [inside + 7, 0], "scorePerfect": [inside_perfect + 7, 0], + "tail": [7, 0], "tailPerfect": [7, 0], "converted": [0, 0], "ranges": rs, + "weights": [[0.01, 1e-12]] * positions, + "rangeWeights": [[[0.001, 1e-12]] * len(ranges)] * positions if linear else None, + "check": expectation_check(positions, [2 if linear else 1] * len(ranges))}) return {"factors": factors, "variants": variants} 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.""" + """The /3 extension interface with synthetic expectations and independently constructed outward intervals.""" COMMIT = "7e5d84b5998d28c21541ce3f2e0a3dfb1439f4f6" SOURCE = "5" * 64 - FORMAT = "ournotes-deck.chart-stats/2" + FORMAT = "ournotes-deck.chart-stats/3" def __init__(self, change=None, fail=None, header=None): self.change, self.fail, self.header, self.inputs, self.options = change, fail, header, [], [] @@ -271,68 +259,75 @@ def info(self): "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): + def chart_stats(self, data, seeds, workers, aptitude=True): if self.fail: raise ValueError(self.fail) + if seeds < 1: + raise ValueError("an empty seed set") doc = json.loads(data) self.inputs.append((doc, seeds, workers)) - self.options.append((aptitude, aptitude_max_seeds, aptitude_cross_seeds)) + self.options.append(aptitude) assert doc["format"] == deckdata.DECK_FORMAT and list(doc["master"]) == [t for t, _ in deckdata.TABLES] - m = doc["master"] - with_aptitude = aptitude + master = doc["master"] def table(name): - return [dict(zip(m[name]["columns"], r)) for r in m[name]["rows"]] - levels = {r["_id"]: r["_musicScoreLevel"] for r in table("MasterLiveMusicScore")} - bonus = {(r["_missionPattern"], r["_count"], r["_rank"]): r["_scoreBonusPercent"] - for r in table("MasterLiveGekisouRankingScoreBonus")} + return [dict(zip(master[name]["columns"], row)) for row in master[name]["rows"]] + levels = {row["_id"]: row["_musicScoreLevel"] for row in table("MasterLiveMusicScore")} + bonuses = {(row["_missionPattern"], row["_count"], row["_rank"]): row["_scoreBonusPercent"] + for row in table("MasterLiveGekisouRankingScoreBonus")} songs = {} - for r in table("MasterLiveMusic"): - for d in musicdata.DIFFICULTIES: - if r[f"_{d}ID"]: - songs[r[f"_{d}ID"]] = (r["_id"], d, [r["_gekisouMission1"], r["_gekisouMission2"], - r["_gekisouMission3"]]) + for row in table("MasterLiveMusic"): + for difficulty in musicdata.DIFFICULTIES: + if row[f"_{difficulty}ID"]: + songs[row[f"_{difficulty}ID"]] = (row["_id"], difficulty, + [row["_gekisouMission1"], row["_gekisouMission2"], row["_gekisouMission3"]]) charts = [] - for c in doc["charts"]: - music, d, missions = songs[c["scoreId"]] - last = max(c["notes"]["timeMs"]) - fevers = list(zip(c["fevers"]["startMs"], c["fevers"]["endMs"]))[:3] - positions = min(len(c["skillEvents"]["timeMs"]), 5) + for chart in doc["charts"]: + music, difficulty, missions = songs[chart["scoreId"]] + last = max(chart["notes"]["timeMs"]) + fevers = list(zip(chart["fevers"]["startMs"], chart["fevers"]["endMs"]))[:3] + positions = min(len(chart["skillEvents"]["timeMs"]), 5) pattern = musicdata.mission_pattern(missions) - percents = [[bonus.get((pattern, i + 1, k), 0) for k in range(1, 6)] for i in range(len(fevers))] - check = {"deck": [[0, 5000]], "exact": 2000, "predicted": 2000.25, "bound": 7.0} + percents = [[bonuses.get((pattern, i + 1, rank), 0) for rank in range(1, 6)] for i in range(len(fevers))] luck = musicdata.MISSION_LUCK in missions[:len(fevers)] - - def seed_stats(i, seed): - return {"seed": seed, "score": 1234, - "ranges": [{"rangeScore": 101 * (j + 1), "rankBonus": trunc_percent(101 * (j + 1), p[0]), - "maxCombo": 1, "justCount": 0, "luckPoints": 0, - "lotResults": [i % 3, 1, 0, 0] if luck else [0, 0, 0, 0], - "rangeScorePerfect": 101 * (j + 1)} for j, p in enumerate(percents)], - "weights": [["W"] * positions], "check": dict(check), "scorePerfect": 1234, - "rangeWeights": [[["W"] * len(fevers)] * positions], - "rankCheck": {"ranks": [2] * len(fevers), "exact": 2001, "predicted": 2000.5, - "bound": 7.0} if fevers else None} - s = {"scoreId": c["scoreId"], "musicId": music, "difficulty": d, "level": levels[c["scoreId"]], - "judgedNotes": sum(score.is_judgement_note(op) for op in c["notes"]["op"]), - "convertedNoteCount": len(c["notes"]["id"]), "lastNoteMs": last, "musicLengthMs": last + 1000, - "skip": 1.5, "events": [[i % 5, t] for i, t in enumerate(c["skillEvents"]["timeMs"])], - "positions": positions, "missions": missions, - "ranges": [{"index": i, "mission": missions[i], "startMs": a, "endMs": b, - "rankBonusPercent": percents[i][0], "rankBonusPercents": percents[i]} - for i, (a, b) in enumerate(fevers)], - "justNotes": 0, - "seeds": [seed_stats(i, x) for i, x in enumerate(musicdata.published_seeds(seeds) if luck else [0])], - "offSeeds": [{"seed": 0, "score": 1000, "weights": [["W"] * positions], "check": dict(check)}]} - s["gekisouAptitude"] = aptitude_of(s, positions) if with_aptitude and fevers else None + expected_ranges = [{"rangeScore": [101 * (j + 1), 0], + "rankBonus": [trunc_percent(101 * (j + 1), percent[0]), 0], + "rankBonusPerfect": [trunc_percent(101 * (j + 1), percent[0]), 0], + "rangeScorePerfect": [101 * (j + 1), 0], "maxCombo": 1, "justCount": 0, + "luckPoints": [10, 0] if missions[j] == 2 else [0, 0], + "lotResults": [[1, 0], [1, 0], [0, 0], [0, 0]] if missions[j] == 2 else [[0, 0]] * 4} + for j, percent in enumerate(percents)] + expected = {"score": [1234, 0], "scorePerfect": [1234, 0], "ranges": expected_ranges, + "weights": [[["W", 0]] * positions], + "rangeWeights": [[[["W", 0]] * len(fevers)] * positions], + "check": expectation_check(positions), + "rankCheck": expectation_check(positions, [2] * len(fevers)) if fevers else None} + off_check = {"deck": ([[0, 5000]] + [None] * (positions - 1)) if positions else [], + "exact": 2000, "predicted": 2000.25, "bound": 7.0} + stats = {"scoreId": chart["scoreId"], "musicId": music, "difficulty": difficulty, + "level": levels[chart["scoreId"]], + "judgedNotes": sum(score.is_judgement_note(op) for op in chart["notes"]["op"]), + "convertedNoteCount": len(chart["notes"]["id"]), "lastNoteMs": last, "musicLengthMs": last + 1000, + "skip": 1.5, "events": [[i % 5, time] for i, time in enumerate(chart["skillEvents"]["timeMs"])], + "positions": positions, "missions": missions, + "ranges": [{"index": i, "mission": missions[i], "startMs": a, "endMs": b, + "rankBonusPercent": percents[i][0], "rankBonusPercents": percents[i]} + for i, (a, b) in enumerate(fevers)], + "justNotes": 0, "expectation": expected, + "replaySeeds": musicdata.published_seeds(seeds) if luck else [0], + "offSeeds": [{"seed": 0, "score": 1000, "weights": [["W"] * positions], "check": off_check}]} + stats["gekisouAptitude"] = aptitude_of(stats, positions) if aptitude and fevers else None if self.change: - self.change(s) - charts.append(s) - out = {"format": self.FORMAT, "source": {}, - "model": {"power": 300000, "gekisouAptitude": "each Gekisou (support) skill shape alone"}, + self.change(stats) + charts.append(stats) + out = {"format": self.FORMAT, "source": doc["provenance"], + "model": {"power": 300000, "checkPower": 1000003, "unitValue": 10000, + "expectation": "independent nominal probabilities with outward intervals", + "gekisouAptitude": "each Gekisou (support) skill shape alone"}, "gekisouAptitude": {"plainKind": 0, "host": "a synthetic member without a Gekisou skill", - "seedRule": SEED_RULE, "shapes": SHAPES} if aptitude else None, - "kinds": [{"id": 0, "effectType": 2000, "activationTimeSecond": 7.5}], "charts": charts} + "law": musicdata.EXPECTATION_LAW, "shapes": SHAPES} if aptitude else None, + "kinds": [{"id": 0, "effectType": 2000, "activationTimeSecond": 5.0, "durationMs": 5000}], + "charts": charts} out = json.loads(json.dumps(out)) if self.header: self.header(out) @@ -345,44 +340,6 @@ 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")) @@ -400,7 +357,7 @@ def test_document(tmp_path): doc = json.loads(raw) assert list(doc) == ["format", "provenance", "languages", "bands", "characters", "tags", "categories", "gekisouCatalog", "deck", "songs"] - assert doc["format"] == "nnnotes.music-data/1" and doc["languages"] == ["ja", "en", "zh-Hant", "zh-Hans", "ko"] + assert doc["format"] == "nnnotes.music-data/2" and doc["languages"] == ["ja", "en", "zh-Hant", "zh-Hans", "ko"] p = doc["provenance"] assert list(p) == ["region", "client", "catalog", "master", "exporter", "deck"] and p["deck"] is None # without the deck model: the song and the Gekisou catalog tables only @@ -485,9 +442,10 @@ def test_deck(tmp_path): assert c["deck"]["unplayable"] is None and c["deck"]["skip"] == 1.5 # the ranks: the song's mission pattern (3) rows of every range assert [r["rankBonusPercents"] for r in c["deck"]["ranges"]] == [[35, 34, 33, 32, 31], [40, 38, 36, 34, 32]] - seed = c["deck"]["seeds"][0] - assert seed["rankCheck"]["ranks"] == [2, 2] and seed["scorePerfect"] == seed["score"] - assert [r["rankBonus"] for r in seed["ranges"]] == [35, 80] and [r["luckPoints"] for r in seed["ranges"]] == [0, 0] + expected = c["deck"]["expectation"] + assert expected["rankCheck"]["ranks"] == [2, 2] and expected["scorePerfect"] == expected["score"] + assert [r["rankBonus"] for r in expected["ranges"]] == [[35, 0], [80, 0]] + assert [r["luckPoints"] for r in expected["ranges"]] == [[0, 0], [0, 0]] assert c["deck"]["offSeeds"][0]["score"] == 1000 # the deck model's numbers as it writes them: a binary64 value is not narrowed to binary32 assert b'"weights":[[' + WEIGHT.encode() + b',' + WEIGHT.encode() + b']]' in raw and b'"predicted":2000.25' in raw @@ -508,22 +466,22 @@ def test_deck(tmp_path): (lambda s: s.update(difficulty="hard"), "difficulty 'hard' differs"), (lambda s: s["ranges"] and s["ranges"][0]["rankBonusPercents"].__setitem__(2, 99), "range 0: .*percentages"), (lambda s: s["ranges"] and s["ranges"][0].update(rankBonusPercent=1), "range 0: .*percentages"), - (lambda s: s["ranges"] and s["seeds"][0]["ranges"][0].update(rankBonus=-1), "rank bonus -1 is not trunc"), - (lambda s: s["seeds"][0]["ranges"].pop(), "range results for"), + (lambda s: s["ranges"] and s["expectation"]["ranges"][0].update(rankBonus=-1), "range score or luck points"), + (lambda s: s["expectation"]["ranges"].pop(), "expectation ranges do not match"), (lambda s: s.pop("offSeeds"), "no Gekisou off statistics"), (lambda s: s["offSeeds"].append(s["offSeeds"][0]), "no Gekisou off statistics"), (lambda s: s["offSeeds"][0]["weights"].append(None), "Gekisou off: weights"), (lambda s: s["offSeeds"][0]["check"].update(exact=0), "Gekisou off: the check deck scores 0"), - (lambda s: s["seeds"][0].update(scorePerfect=1), "otherwise on the Perfect play"), - (lambda s: s["seeds"][0]["weights"][0].pop(), "weights are not"), - (lambda s: s["seeds"][0]["weights"].append(None), "weights are not"), - (lambda s: s["seeds"][0].update(rangeWeights=[[]]), "rangeWeights are not"), - (lambda s: s["seeds"][0]["check"].update(bound=0.1), "the check deck scores 2000"), - (lambda s: s["ranges"] and s["seeds"][0]["rankCheck"].update(exact=0), "at ranks"), - (lambda s: s["ranges"] and s["seeds"][0]["rankCheck"].update(ranks=[6, 1]), "rank check ranks"), - (lambda s: s["seeds"][0]["ranges"][0].pop("luckPoints"), "chart 10 .*seed 0 range 0: no luck points"), - (lambda s: s["seeds"][0].update(seed=3), r"chart 10 .*seeds \[3\] are not the chart's seed set \[0\]"), - (lambda s: s["seeds"].append(dict(s["seeds"][0], seed=5)), r"chart 10 .*seeds \[0, 5\] are not the chart's seed"), + (lambda s: s["expectation"].update(scorePerfect=[1, 0]), "without Just notes differs"), + (lambda s: s["expectation"]["weights"][0].pop(), "weights are not"), + (lambda s: s["expectation"]["weights"].append(None), "weights are not"), + (lambda s: s["expectation"].update(rangeWeights=[[]]), "rangeWeights are not"), + (lambda s: s["expectation"]["check"].update(bound=0.1), "expectation check exceeds"), + (lambda s: s["ranges"] and s["expectation"]["rankCheck"].update(expected=[0, 0]), "expectation check exceeds"), + (lambda s: s["ranges"] and s["expectation"]["rankCheck"].update(ranks=[6, 1]), "check ranks"), + (lambda s: s["expectation"]["ranges"][0].pop("luckPoints"), "expectation range 0: a range score or luck points"), + (lambda s: s.update(replaySeeds=[3]), r"chart 10 .*seeds \[3\] are not the chart's seed set \[0\]"), + (lambda s: s["replaySeeds"].append(5), r"chart 10 .*seeds \[0, 5\] are not the chart's seed"), ]) def test_deck_checks(tmp_path, change, match): with pytest.raises(musicdata.MusicDataError, match=match): @@ -531,12 +489,84 @@ def test_deck_checks(tmp_path, change, match): assert not (tmp_path / "music.json").exists() +@pytest.fixture +def expectation_fixture(): + """One complete /3 chart from the controlled interface, decoded without narrowing native numbers.""" + tables = {name: all_rows(name) for name, _ in deckdata.TABLES} + charts = deckdata.charts([tables["MasterLiveMusicScore"][1]], CHARTS.__getitem__) + source = deckdata.build(tables, charts, {}) + document = FakeDeck().chart_stats(deckdata.encode(source).decode("utf-8"), 8, 1) + return json.loads(document, parse_float=deckdata._Num)["charts"][0] + + +@pytest.mark.parametrize("estimate", [ + 100, [100], [100, 0, 0], [True, 0], [100, False], [100, -1], + [deckdata._Num("NaN"), 0], [100, deckdata._Num("Infinity")], + [deckdata._Num("1e308"), deckdata._Num("1e308")], +]) +def test_expectation_rejects_invalid_enclosures(expectation_fixture, estimate): + expectation_fixture["expectation"]["score"] = estimate + with pytest.raises(musicdata.MusicDataError, match="outward radius"): + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + + +@pytest.mark.parametrize("change, message", [ + (lambda c: c.update(justNotes=True), "judgement counts"), + (lambda c: c.update(justNotes=c["judgedNotes"] + 1), "judgement counts"), + (lambda c: c.pop("expectation"), "expectation field"), + (lambda c: c["expectation"].pop("rangeWeights"), "rangeWeights or rankCheck field"), + (lambda c: c["expectation"].pop("rankCheck"), "rangeWeights or rankCheck field"), + (lambda c: c["expectation"]["ranges"][0].pop("rankBonusPerfect"), "range score"), + (lambda c: c["expectation"]["ranges"][0].update(maxCombo=True), "judgement counters"), + (lambda c: c["expectation"]["ranges"][0].update(lotResults=[[1, 0]] * 4), "outside a luck range"), + (lambda c: c["expectation"]["check"].update(ranks=[]), "unranked expectation check"), + (lambda c: c["expectation"]["rankCheck"].update(deck=[None, None]), "check deck"), +]) +def test_expectation_requires_the_new_contract(expectation_fixture, change, message): + change(expectation_fixture) + with pytest.raises(musicdata.MusicDataError, match=message): + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + + +def test_expected_bonus_is_not_truncation_of_the_expected_score(expectation_fixture): + # An equally likely score of 99 or 100 has E[score] = 99.5 and E[trunc(score * .35)] = 34.5. + # Truncating the mean would incorrectly produce 34. Native /3 explicitly measures both bonuses. + row = expectation_fixture["expectation"]["ranges"][0] + row.update(rangeScore=[deckdata._Num("99.5"), 0], rangeScorePerfect=[deckdata._Num("99.5"), 0], + rankBonus=[deckdata._Num("34.5"), 0], rankBonusPerfect=[deckdata._Num("34.5"), 0]) + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + assert trunc_percent(float(row["rangeScore"][0]), 35) != float(row["rankBonus"][0]) + + +def test_expectation_check_bounds_both_interval_endpoints(expectation_fixture): + check = expectation_fixture["expectation"]["check"] + check.update(expected=[1000, 1], predicted=[1000, 1], bound=1) + # Identical centers alone cannot certify a bound smaller than the farthest endpoint separation. + with pytest.raises(musicdata.MusicDataError, match="expectation check exceeds"): + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + check["bound"] = 2 + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + + +def test_unplayable_keeps_only_free_live_statistics(expectation_fixture): + original = deepcopy(expectation_fixture["offSeeds"]) + expectation_fixture.update(unplayable="synthetic unsupported fourth fever", expectation=None, + replaySeeds=[], gekisouAptitude=None) + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + assert musicdata.validate_aptitude_statistics(None, expectation_fixture, "synthetic") is None + assert expectation_fixture["offSeeds"] == original + expectation_fixture.pop("expectation") + with pytest.raises(musicdata.MusicDataError, match="expectation field"): + musicdata._check_expectation(expectation_fixture, 1, "synthetic") + + def test_deck_without_range_weights(tmp_path): """A chart without range weights, a kind without them and a kind without Gekisou off weights are carried.""" def change(s): - s["seeds"][0]["rangeWeights"] = None if s["scoreId"] == 10 else [None] - s["seeds"][0]["rankCheck"] = None + s["expectation"]["rangeWeights"] = None if s["scoreId"] == 10 else [None] + s["expectation"]["rankCheck"] = None s["offSeeds"][0]["weights"] = [None] + s["offSeeds"][0]["check"]["deck"] = [None] * s["positions"] if s["scoreId"] == 10: for v in s["gekisouAptitude"]["variants"]: v["rangeWeights"] = None @@ -544,7 +574,7 @@ def change(s): export(tmp_path, deck=FakeDeck(change)) doc = json.loads((tmp_path / "music.json").read_bytes()) decks = {c["scoreId"]: c["deck"] for s in doc["songs"] for c in s["charts"]} - assert decks[10]["seeds"][0]["rangeWeights"] is None and decks[20]["seeds"][0]["rangeWeights"] == [None] + assert decks[10]["expectation"]["rangeWeights"] is None and decks[20]["expectation"]["rangeWeights"] == [None] assert decks[30]["offSeeds"][0]["weights"] == [None] schema_validator().validate(doc) @@ -636,11 +666,11 @@ def test_luck_chart(tmp_path): export(tmp_path, rows=rows, deck=FakeDeck()) doc = json.loads((tmp_path / "music.json").read_bytes()) deck = doc["songs"][0]["charts"][0]["deck"] - assert [s["seed"] for s in deck["seeds"]] == musicdata.published_seeds(8) + assert deck["replaySeeds"] == musicdata.published_seeds(8) schema_validator().validate(doc) (tmp_path / "m").rename(tmp_path / "m1") with pytest.raises(musicdata.MusicDataError, match=r"chart 10 .*seeds .* are not the chart's seed set"): - export(tmp_path, rows=rows, deck=FakeDeck(on(10, lambda s: s["seeds"].pop()))) + export(tmp_path, rows=rows, deck=FakeDeck(on(10, lambda s: s["replaySeeds"].pop()))) def test_rank_bonus_percents(): @@ -668,7 +698,7 @@ def test_deck_errors(tmp_path, monkeypatch): def test_deck_module(): deck = pytest.importorskip("nnnotes._deck") info = deck.info() - assert info["name"] == "ournotes-deck" and info["format"] == "ournotes-deck.chart-stats/2" + assert info["name"] == "ournotes-deck" and info["format"] == "ournotes-deck.chart-stats/3" 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") @@ -847,15 +877,17 @@ def test_command_decoded_master(tmp_path, capsys, monkeypatch): @pytest.fixture def aptitude_fixture(): """A header and a two-range chart, independently authored above, through JSON as the extension returns it.""" - tables = {t: TABLE_ROWS.get(t, rows_of(t)) for t in musicdata.tables_of(True, False)} + tables = deepcopy({t: TABLE_ROWS.get(t, rows_of(t)) for t in musicdata.tables_of(True, False)}) a = musicdata.Aptitude(tables, musicdata.gekisou_catalog(tables, musicdata.Texts(tables['MasterText']))) - header = json.loads(json.dumps({'plainKind': 0, 'host': 'synthetic host', 'seedRule': SEED_RULE, 'shapes': SHAPES}), + header = json.loads(json.dumps({'plainKind': 0, 'host': 'synthetic host', 'law': musicdata.EXPECTATION_LAW, 'shapes': SHAPES}), parse_float=deckdata._Num) chart = {'positions': 2, 'judgedNotes': 20, 'justNotes': 0, 'ranges': [ {'mission': 1, 'rankBonusPercent': 35}, {'mission': 3, 'rankBonusPercent': 40}], - 'seeds': [{'seed': 0, 'rangeWeights': [], 'ranges': [ - {'rangeScore': 101, 'rangeScorePerfect': 101, 'rankBonus': 35, 'lotResults': [0, 0, 0, 0]}, - {'rangeScore': 202, 'rangeScorePerfect': 202, 'rankBonus': 80, 'lotResults': [0, 0, 0, 0]}]}]} + 'expectation': {'rangeWeights': [], 'ranges': [ + {'rangeScore': [101, 0], 'rangeScorePerfect': [101, 0], 'rankBonus': [35, 0], + 'rankBonusPerfect': [35, 0], 'lotResults': [[0, 0]] * 4}, + {'rangeScore': [202, 0], 'rangeScorePerfect': [202, 0], 'rankBonus': [80, 0], + 'rankBonusPerfect': [80, 0], 'lotResults': [[0, 0]] * 4}]}} chart['gekisouAptitude'] = aptitude_of(chart, 2) chart = json.loads(json.dumps(chart), parse_float=deckdata._Num) return a, header, chart @@ -891,9 +923,9 @@ def test_aptitude_fixture(aptitude_fixture): lambda h: h['shapes'].pop(), lambda h: h.update(plainKind=1), lambda h: h.update(host=''), - lambda h: h['seedRule'].update(batches=[32, 16]), - lambda h: h['seedRule'].update(crossSeeds=0), - lambda h: h['seedRule'].update(relative=deckdata._Num('NaN')), + lambda h: h.update(law='sampled means'), + lambda h: h.update(law=None), + lambda h: h.pop('law'), ]) def test_aptitude_header_rejects(aptitude_fixture, change): a, h, _ = aptitude_fixture @@ -916,16 +948,16 @@ def test_aptitude_header_rejects(aptitude_fixture, change): lambda c: c['gekisouAptitude']['variants'][0].update(score=[deckdata._Num('NaN'), 0]), lambda c: c['gekisouAptitude']['variants'][0].update(score=[10**400, 0]), lambda c: c['gekisouAptitude']['variants'][0].update(score=[1, -1]), - lambda c: c['gekisouAptitude']['variants'][0].update(tail=[7, 1]), + lambda c: c['gekisouAptitude']['variants'][0].update(tail=[7, deckdata._Num('Infinity')]), lambda c: c['gekisouAptitude']['variants'][0].update(tail=[8, 0]), lambda c: c['gekisouAptitude']['variants'][0]['ranges'].pop(), - lambda c: c['gekisouAptitude']['variants'][0].update(seeds=2), - lambda c: c['gekisouAptitude']['variants'][0].update(crossSeeds=2), - lambda c: c['gekisouAptitude']['variants'][2].update(seeds=31), - lambda c: c['gekisouAptitude']['variants'][2].update(seTargetMet=False), + lambda c: c['gekisouAptitude']['variants'][0].update(converted=[deckdata._Num('0.5'), 0]), + lambda c: c['gekisouAptitude']['variants'][0]['ranges'][0].pop('rankBonusPerfect'), + lambda c: c['gekisouAptitude']['variants'][2].update(tailPerfect=[9, 0]), + lambda c: c['gekisouAptitude']['variants'][2]['weights'].__setitem__(0, [0, -1]), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(bound=-1), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(predicted=deckdata._Num('NaN')), - lambda c: c['gekisouAptitude']['variants'][0]['check'].update(exact=0), + lambda c: c['gekisouAptitude']['variants'][0]['check'].update(expected=[0, 0]), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(ranks=[0, 2]), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(deck=[[9, 100], None]), ]) @@ -941,13 +973,62 @@ def test_aptitude_rounding(aptitude_fixture): a, h, c = aptitude_fixture aptitude_header(a, h) v = c['gekisouAptitude']['variants'][2] - v['tail'][0] = deckdata._Num('7.002') - a.chart('synthetic', c, 0) # independently rounded terms, two ranges: 0.00300001 absolute tolerance - v['tail'][0] = deckdata._Num('7.004') + v['tail'] = [deckdata._Num('7.002'), deckdata._Num('0.002')] + a.chart('synthetic', c, 0) # outward radius, not a standard error or an implicit rounding tolerance + v['tail'][1] = deckdata._Num('0.001') with pytest.raises(musicdata.MusicDataError, match='tail'): a.chart('synthetic', c, 0) +def test_aptitude_perfect_tail_uses_the_measured_perfect_bonus(aptitude_fixture): + a, h, c = aptitude_fixture + header = aptitude_header(a, h) + v = c['gekisouAptitude']['variants'][0] + c['justNotes'] = 1 + v['ranges'][0]['rankBonusPerfect'][0] = deckdata._Num('4.25') + v['scorePerfect'][0] = deckdata._Num('49.25') + assert v['ranges'][0]['rankBonus'][0] == 3 + # The Perfect and best channels have different independently measured bonuses. + # Their separate interval identities must both hold, without a seed or standard-error field. + assert musicdata.validate_aptitude_statistics(header, c, 'synthetic') == a.chart('synthetic', c, 0) + v['ranges'][0]['rankBonusPerfect'][0] = v['ranges'][0]['rankBonus'][0] + with pytest.raises(musicdata.MusicDataError, match='tailPerfect'): + musicdata.validate_aptitude_statistics(header, c, 'synthetic') + + +@pytest.mark.parametrize('group', ['trigger', 'condition', 'release', 'reset', 'cumulative']) +def test_confirmed_rank_shape_has_no_linear_rank_cross_terms(aptitude_fixture, group): + a, h, c = aptitude_fixture + source = a.rows[('member', 1, 5)][0] + effect = h['shapes'][0]['effects'][0] + if group == 'cumulative': + source['_skillCumulativeConditionID'] = 77 + a.cumulative[77] = {'_skillCumulativeConditionType': 7012, '_conditionValues': [1], + '_conditionTargetIDs': [], '_maxCumulativeCount': 1} + effect[group] = {'type': 7012, 'values': [1], 'targetIds': [], 'maxCumulativeCount': 1} + else: + column = dict(musicdata.CONDITION_GROUPS)[group] + source[column] = 77 + a.sets[77] = [{'_id': 77, '_conditionIds': [77]}] + a.conditions[77] = {'_conditionType': 7012, '_conditionValues': [1], + '_conditionTargetIDs': [], '_isPositive': True} + effect[group] = [[{'type': 7012, 'values': [1], 'targetIds': [], 'positive': True}]] + header = aptitude_header(a, h) # Still checked against independent master rows. + v = c['gekisouAptitude']['variants'][0] + old_weights = v['rangeWeights'] + v['rangeWeights'] = None + v['check']['ranks'] = [1, 1] + assert c['expectation']['rangeWeights'] is not None + assert musicdata.validate_aptitude_statistics(header, c, 'synthetic') == c['gekisouAptitude'] + v['check']['ranks'][0] = 2 + with pytest.raises(musicdata.MusicDataError, match='check ranks'): + a.chart('synthetic', c, 0) + v['check']['ranks'][0] = 1 + v['rangeWeights'] = old_weights + with pytest.raises(musicdata.MusicDataError, match='rank-linear domain'): + musicdata.validate_aptitude_statistics(header, c, 'synthetic') + + def test_aptitude_no_plain(aptitude_fixture): a, h, c = aptitude_fixture h['plainKind'] = None @@ -962,9 +1043,9 @@ def test_aptitude_disabled(tmp_path): fake = FakeDeck() d = master_dir(tmp_path) musicdata.export(tmp_path / 'music.json', deckdata.master_files(d), KEY, CHARTS.__getitem__, bgm, **PROV, - deck=musicdata.Deck(module=fake, aptitude=False, aptitude_max_seeds=32, aptitude_cross_seeds=8)) + deck=musicdata.Deck(module=fake, aptitude=False)) doc = json.loads((tmp_path / 'music.json').read_bytes()) - assert fake.options == [(False, 32, 8)] + assert fake.options == [False] assert doc['deck']['gekisouAptitude'] is None assert all(c['deck']['gekisouAptitude'] is None for s in doc['songs'] for c in s['charts']) schema_validator().validate(doc) diff --git a/tests/test_replaydata.py b/tests/test_replaydata.py index aa95373..2465e66 100644 --- a/tests/test_replaydata.py +++ b/tests/test_replaydata.py @@ -212,11 +212,11 @@ def test_recommend_engine_rejects_another_build(tmp_path, change, message): assert not (tmp_path / "music.json").exists() and not (tmp_path / "replay").exists() -def test_final_export_rejects_unmet_sampling_instead_of_publishing_flag(tmp_path): - def unmet(chart): +def test_final_export_rejects_an_invalid_expectation_before_publishing(tmp_path): + def invalid(chart): apt = chart.get("gekisouAptitude") if apt and apt["variants"]: - apt["variants"][0]["seTargetMet"] = False - with pytest.raises(musicdata.MusicDataError, match="did not meet BOTH score SE targets"): - export(tmp_path, deck=FakeDeck(change=unmet)) + apt["variants"][0]["check"]["expected"] = [0, 0] + with pytest.raises(musicdata.MusicDataError, match="expectation check exceeds"): + export(tmp_path, deck=FakeDeck(change=invalid)) assert not (tmp_path / "music.json").exists() diff --git a/tests/test_statscache.py b/tests/test_statscache.py new file mode 100644 index 0000000..572b063 --- /dev/null +++ b/tests/test_statscache.py @@ -0,0 +1,167 @@ +"""Python/native cache boundary tests, using controlled /3 documents rather than a cache implementation. + +The Rust tests exercise program/run reuse, persistence and concurrency. These tests establish that Python passes +complete inputs and options to that implementation, preserves its reported counters and publishes identical data. +""" +from copy import deepcopy +import gzip +import json +from pathlib import Path + +import pytest + +from nnnotes import deckdata, musicdata +from test_musicdata import CHARTS, KEY, PROV, FakeDeck, all_rows, bgm, master_dir, run + + +@pytest.fixture +def deck_input(): + tables = {name: all_rows(name) for name, _ in deckdata.TABLES} + scores = [row for row in tables["MasterLiveMusicScore"] if row["_id"] != 40] + return deckdata.build(tables, deckdata.charts(scores, CHARTS.__getitem__), {"region": "xx", "tables": {}}) + + +class CacheBridge(FakeDeck): + """An instrumented native interface, not a simulation of native cache hits or performance.""" + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.cache_calls = [] + self.counters = {"requests": 29, "hits": 19, "computed": 10, "writes": 10, "invalid": 2, "bytes": 5000} + + def chart_stats_cached(self, data, seeds, workers, aptitude=True, cache_dir=None): + self.cache_calls.append((json.loads(data), seeds, workers, aptitude, cache_dir)) + return self.chart_stats(data, seeds, workers, aptitude=aptitude), dict(self.counters) + + +def test_cached_calls_pass_the_whole_current_input_and_report_native_counters(tmp_path, deck_input): + module = CacheBridge() + directory = tmp_path / "native cache" + directory.mkdir() + retained = directory / "old-entry.json" + retained.write_bytes(b"native-owned entry") + before = deckdata.encode(deck_input) + deck = musicdata.Deck(module=module, seeds=4, workers=3, aptitude=False, cache=directory) + result = deck.stats(deck_input) + assert module.cache_calls == [(json.loads(before), 4, 3, False, str(directory))] + assert deck.counts == module.counters + assert result["format"] == "ournotes-deck.chart-stats/3" + assert deckdata.encode(deck_input) == before and retained.read_bytes() == b"native-owned entry" + assert list(directory.iterdir()) == [retained] # Python neither replaces nor garbage-collects native records. + current = deepcopy(deck_input) + current["provenance"]["revision"] = "next" + current["master"]["MasterEvent"] = {"columns": ["_id"], "rows": [[99]]} + result = deck.stats(current) + assert module.cache_calls[-1][0] == json.loads(deckdata.encode(current)) + assert result["source"] == current["provenance"] + + +def test_uncached_calls_do_not_invent_native_cache_counters(deck_input): + module = CacheBridge() + deck = musicdata.Deck(module=module, seeds=2, workers=1) + deck.stats(deck_input) + assert module.cache_calls == [] and len(module.inputs) == 1 + assert module.inputs[0][1:] == (2, 1) and module.options == [True] + assert deck.counts is None + + +@pytest.mark.parametrize("options, flag", [ + ({"seeds": -1}, "--seeds"), ({"seeds": 0}, "--seeds"), ({"seeds": True}, "--seeds"), + ({"seeds": 1 << 100}, "--seeds"), ({"workers": -1}, "--workers"), + ({"workers": False}, "--workers"), ({"workers": 1 << 100}, "--workers"), +]) +def test_invalid_options_are_user_errors_before_native_or_export_work(tmp_path, capsys, options, flag): + with pytest.raises(musicdata.MusicDataError, match=flag): + musicdata.Deck(**options) + value = next(iter(options.values())) + if not isinstance(value, bool): + output = tmp_path / "music.json" + code, out, error = run(["music-data", "--decoded-master", "-o", str(output), flag, str(value)], capsys) + assert code == 1 and not out and flag in error and "Traceback" not in error + assert not output.exists() + + +def test_zero_workers_selects_native_automatic_parallelism(deck_input): + module = CacheBridge() + deck = musicdata.Deck(module=module, seeds=1, workers=0) + deck.stats(deck_input) + assert module.inputs[0][1:] == (1, None) + + +def test_requested_cache_requires_the_native_interface(tmp_path, deck_input): + module = FakeDeck() + with pytest.raises(musicdata.MusicDataError, match="native statistics caching is not available"): + musicdata.Deck(module=module, cache=tmp_path / "stats").stats(deck_input) + assert module.inputs == [] + + +def test_sampling_format_is_rejected_before_calling_the_model(deck_input): + module = FakeDeck() + module.FORMAT = "ournotes-deck.chart-stats/2" + with pytest.raises(musicdata.MusicDataError, match="format .*chart-stats/2.* is not supported"): + musicdata.Deck(module=module).stats(deck_input) + assert module.inputs == [] + + +@pytest.mark.parametrize("change", [ + lambda doc: doc.update(format="ournotes-deck.chart-stats/2"), + lambda doc: doc["charts"].reverse(), + lambda doc: doc["charts"].append(doc["charts"][0]), + lambda doc: doc["charts"][0].update(scoreId=True), +]) +def test_model_output_format_and_chart_identity_are_checked(deck_input, change): + with pytest.raises(musicdata.MusicDataError): + musicdata.Deck(module=FakeDeck(header=change)).stats(deck_input) + + +@pytest.mark.parametrize("response", ["{", "[]", "null", "{}"]) +def test_malformed_native_documents_raise_a_domain_error(deck_input, response): + class BadDocument(FakeDeck): + def chart_stats(self, *args, **kwargs): + return response + with pytest.raises(musicdata.MusicDataError, match="deck model:"): + musicdata.Deck(module=BadDocument()).stats(deck_input) + + +def test_cache_interface_failure_does_not_replace_an_existing_export(tmp_path): + source = master_dir(tmp_path) + out = tmp_path / "music.json" + out.write_bytes(b"previous published artifact\n") + with pytest.raises(musicdata.MusicDataError, match="interrupted native run"): + musicdata.export(out, deckdata.master_files(source), KEY, CHARTS.__getitem__, bgm, **PROV, + deck=musicdata.Deck(module=CacheBridge(fail="interrupted native run"), cache=tmp_path / "stats")) + assert out.read_bytes() == b"previous published artifact\n" + + +@pytest.mark.parametrize("compressed", [False, True]) +def test_native_cache_transport_does_not_change_exported_bytes(tmp_path, compressed): + source = master_dir(tmp_path) + sources = {path.name: path.read_bytes() for path in source.iterdir()} + outputs, summaries = [], [] + for name, cache in (("uncached", None), ("cached", tmp_path / "stats")): + out = tmp_path / (name + (".json.gz" if compressed else ".json")) + summaries.append(musicdata.export(out, deckdata.master_files(source), KEY, CHARTS.__getitem__, bgm, **PROV, + full=True, deck=musicdata.Deck(module=CacheBridge(), cache=cache, workers=2))) + outputs.append(out.read_bytes()) + assert outputs[0] == outputs[1] and summaries[0]["sha256"] == summaries[1]["sha256"] + assert summaries[0]["deckStats"] is None + assert summaries[1]["deckStats"] == CacheBridge().counters + doc = json.loads(gzip.decompress(outputs[0]) if compressed else outputs[0]) + assert doc["format"] == "nnnotes.music-data/2" + assert doc["provenance"]["deck"]["format"] == "ournotes-deck.chart-stats/3" + assert "deckStats" not in doc + assert {path.name: path.read_bytes() for path in source.iterdir()} == sources + + +@pytest.mark.parametrize("option", ["--aptitude-max-seeds", "--aptitude-cross-seeds", "--allow-unconverged-aptitude"]) +def test_removed_sampling_options_are_not_silently_accepted(capsys, option): + argv = ["music-data", "--decoded-master", "-o", "out.json", option] + argv += [] if option == "--allow-unconverged-aptitude" else ["32"] + code, _, error = run(argv, capsys) + assert code == 2 and "unrecognized arguments" in error + + +def test_the_native_cached_entry_point_rejects_bad_input(tmp_path): + native = pytest.importorskip("nnnotes._deck") + with pytest.raises(ValueError, match="not a deck data file"): + native.chart_stats_cached('{"format":"invalid"}', cache_dir=str(tmp_path / "stats")) + assert not list(Path(tmp_path).glob("stats/**/*.json")) From 46cf165d28a4d3dfb44e232c7831a47acb3ba184 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Thu, 8 Oct 2026 12:07:57 +0800 Subject: [PATCH 2/7] ci(release): verify wheels and reuse quality checks --- .github/scripts/smoke_wheel.py | 48 ++++++++++++++++++++++++++++++++++ .github/workflows/ci.yml | 3 ++- .github/workflows/release.yml | 8 +++++- 3 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 .github/scripts/smoke_wheel.py diff --git a/.github/scripts/smoke_wheel.py b/.github/scripts/smoke_wheel.py new file mode 100644 index 0000000..2befd5a --- /dev/null +++ b/.github/scripts/smoke_wheel.py @@ -0,0 +1,48 @@ +"""Install one wheel in a fresh environment and exercise its CLI and extension.""" +from pathlib import Path +import os +import subprocess +import sys +import tempfile +import venv + + +SMOKE = """ +import json +from pathlib import Path +import sys + +import nnnotes +from nnnotes import _deck, deckdata + +assert Path(nnnotes.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()) +assert Path(_deck.__file__).resolve().is_relative_to(Path(sys.prefix).resolve()) +info = _deck.info() +data = { + "format": info["dataFormat"], + "master": {name: {"columns": [], "rows": []} for name, _ in deckdata.TABLES}, + "charts": [], +} +stats = json.loads(_deck.chart_stats(json.dumps(data), workers=1, aptitude=False)) +assert stats["format"] == info["format"] +assert stats["charts"] == [] +print(f"nnnotes {nnnotes.__version__}: {info['name']} {info['version']}") +""" + + +def main(directory: str) -> None: + wheel, = Path(directory).resolve().glob("*.whl") + with tempfile.TemporaryDirectory(prefix="nnnotes-wheel-") as directory: + root = Path(directory) + environment = root / "venv" + venv.EnvBuilder(with_pip=True).create(environment) + scripts = environment / ("Scripts" if os.name == "nt" else "bin") + python = scripts / ("python.exe" if os.name == "nt" else "python") + subprocess.run([python, "-I", "-m", "pip", "install", str(wheel)], cwd=root, check=True) + subprocess.run([python, "-I", "-c", SMOKE], cwd=root, check=True) + subprocess.run([scripts / ("nnnotes.exe" if os.name == "nt" else "nnnotes"), "--help"], + cwd=root, check=True) + + +if __name__ == "__main__": + main(sys.argv[1]) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61ab385..280ff7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,12 +6,13 @@ on: pull_request: # the Deck model workflow dispatches CI on the pull request it opens workflow_dispatch: + workflow_call: permissions: contents: read concurrency: - group: ci-${{ github.ref }} + group: ci-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ebb881a..7ba8ba0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,6 +27,10 @@ jobs: exit 1 fi + quality: + needs: check + uses: ./.github/workflows/ci.yml + # one abi3 wheel (CPython 3.11 and later) per platform with the extension module nnnotes._deck, and the sdist wheels: needs: check @@ -50,6 +54,8 @@ jobs: target: ${{ matrix.target }} manylinux: ${{ matrix.manylinux || 'auto' }} args: --release --locked --out dist + - name: Test the installed wheel + run: python .github/scripts/smoke_wheel.py dist - uses: actions/upload-artifact@v7 with: name: wheel-${{ matrix.os }}-${{ matrix.target }} @@ -72,7 +78,7 @@ jobs: if-no-files-found: error build: - needs: [wheels, sdist] + needs: [quality, wheels, sdist] runs-on: ubuntu-latest steps: - uses: actions/download-artifact@v8 From 6139e40c189f653e53180f39a3137a1327085e7d Mon Sep 17 00:00:00 2001 From: nichinichisou0609 <128159543+nichinichisou0609@users.noreply.github.com> Date: Sat, 10 Oct 2026 04:11:17 +0000 Subject: [PATCH 3/7] build(deck): ournotes-deck v0.0.4 --- 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 3146643..5192313 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -95,8 +95,8 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ournotes-sim" -version = "0.0.3" -source = "git+https://github.com/empty-sekai/ournotes-deck?rev=31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e#31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e" +version = "0.0.4" +source = "git+https://github.com/empty-sekai/ournotes-deck?rev=efbf286ad60f98e50c906605e76a1ad67c18ea4b#efbf286ad60f98e50c906605e76a1ad67c18ea4b" dependencies = [ "serde", "serde_json", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 54ef161..b3ee408 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 = "31d74487c4fd9e5dc3c1d4f34be8dee8f1a75b0e" } +ournotes-sim = { git = "https://github.com/empty-sekai/ournotes-deck", rev = "efbf286ad60f98e50c906605e76a1ad67c18ea4b" } serde_json = { version = "1", features = ["raw_value", "arbitrary_precision"] } [profile.release] From 303b8503621180a016c41c574a41864ac67f7b1f Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 10 Oct 2026 12:16:33 +0800 Subject: [PATCH 4/7] feat(music-data): measure chart statistics with nominal lottery and skill probabilities Chart statistics follow the ournotes-deck chart-stats/3 document: Gekisou statistics use independent nominal lottery and skill probabilities, and charts with a luck range take a replay seed count. The music-data document, its schema, the command help and the replay data tests follow that format. Co-Authored-By: Claude Sonnet 5.5 --- docs/commands.md | 9 +- docs/contracts.md | 2 +- docs/music-data.md | 248 +++++--------- docs/replay.md | 4 +- docs/schema/music-data.schema.json | 533 ++++++++++++----------------- rust/src/lib.rs | 27 +- src/nnnotes/cli.py | 12 +- src/nnnotes/musicdata.py | 299 +++++++--------- tests/test_musicdata.py | 163 +++++---- tests/test_replaydata.py | 6 +- 10 files changed, 539 insertions(+), 764 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 1280243..fd22e1b 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -678,7 +678,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] + [--workers N] [--no-gekisou-aptitude] [--stats-cache DIR] [--no-bgm] [--jackets DIR] -o FILE ``` @@ -694,10 +694,9 @@ rank and the Perfect play need) and off (a solo live), measured by the deck mode `nnnotes._deck`) on its whole-live simulation and checked against the chart facts and the master data. `--full` also writes the deck model's input: every `MasterLiveMusicScore` row's chart as the client builds it at runtime (notes, skill events, fever ranges) and the master data tables about cards, skills, bonuses, scores and events. Single-skill Gekisou aptitude is included by -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. `--stats-cache DIR` keeps every chart's statistics in `DIR` under the SHA-256 of what +default as independent nominal expectations; `--no-gekisou-aptitude` omits it. +`--no-deck` leaves every chart's `deck` null. `--seeds N` (default 8) gives replay seeds for luck charts; +`--workers N` sets measurement threads (default: every processor). `--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`), diff --git a/docs/contracts.md b/docs/contracts.md index 2cf4e7f..929ca1a 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -226,7 +226,7 @@ The catalog versions of a store, `nnnotes.catalogs/1` ([schema/catalogs.schema.j ([schema/catalog-diff.schema.json](schema/catalog-diff.schema.json), `nnnotes catalogs diff --json`), are written by the commands. -The music data file of `nnnotes music-data`, `nnnotes.music-data/1` +The music data file of `nnnotes music-data`, `nnnotes.music-data/2` ([schema/music-data.schema.json](schema/music-data.schema.json)), names its format in a `format` field; it is described in [music-data.md](music-data.md). diff --git a/docs/music-data.md b/docs/music-data.md index 8b8290c..dd6ad75 100644 --- a/docs/music-data.md +++ b/docs/music-data.md @@ -6,12 +6,12 @@ ranks, jacket, BGM length), the Gekisou catalog (member cards, snaps and their G chart facts (level, note counts, BPM, chart times, skill events, fevers), and per chart its **deck statistics**: what the chart contributes to the live score whatever the deck, in a solo live (Gekisou off) and in a Gekisou live at every rank, measured by the deck model [ournotes-deck](https://github.com/empty-sekai/ournotes-deck), which nnnotes carries -as its extension module `nnnotes._deck`. The format is `nnnotes.music-data/1`; its JSON Schema is +as its extension module `nnnotes._deck`. The format is `nnnotes.music-data/2`; its JSON Schema is [schema/music-data.schema.json](schema/music-data.schema.json). ``` 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] + [--workers N] [--no-gekisou-aptitude] [--stats-cache DIR] [--no-bgm] [--jackets DIR] -o FILE ``` @@ -26,13 +26,10 @@ nnnotes music-data (--master-files DIR | --apk-master | --decoded-master) [--ful - `--full`: also write the deck model's input, every chart's runtime notes and the master data tables about cards, skills, bonuses, scores and events ([the deck input](#the-deck-input---full)), for tools that run a deck model of their own. -- `--no-deck`: do not run the deck model; every chart's `deck` is null. The deck model measures every chart on a - whole-live simulation, dozens of lives per chart: a full run takes processor time in proportion to the number of - charts. `--workers N` sets the threads it uses (default: every processor), `--seeds N` the seeds measured on a chart - with a luck range (default 8). -- `--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. +- `--no-deck`: leave every chart's `deck` null. `--workers N` sets the measurement threads + (default: every processor). `--seeds N` sets the replay seed count for a chart with a luck range (default 8); + nominal expectation measurements are independent of this count. +- `--no-gekisou-aptitude`: omit single-skill aptitude measurements while retaining baseline statistics. - `--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 @@ -67,7 +64,7 @@ the wheels on PyPI carry it. See [Building](#building). ```json { - "format": "nnnotes.music-data/1", + "format": "nnnotes.music-data/2", "provenance": {"region": ..., "client": {...}, "catalog": {...}, "master": {...}, "exporter": {...}, "deck": {...}}, "languages": ["ja", "en", "zh-Hant", "zh-Hans", "ko"], "bands": [...], "characters": [...], "tags": [...], "categories": [...], @@ -102,7 +99,7 @@ A **text** is an object with one string per language of `languages` (`{"ja": ... | `master.tables.
.sha256` | SHA-256 of each table's file as served, before decoding: the song tables (`MasterLiveMusic`, `MasterLiveMusicScore`, `MasterText`, `MasterBand`, `MasterCharacter`, `MasterTag`, `MasterLiveMusicCategory`, `MasterSound`, `MasterSoundCueSheet`, `MasterLiveScoreRank`), the tables of the Gekisou catalog (`MasterMemberCard`, `MasterSupportCard`, `MasterSupportCardRank`, `MasterGekisouSkill`, `MasterGekisouSkillEffect`, `MasterGekisouSupportSkill`, `MasterGekisouSupportSkillEffect`) and, when the deck model runs or with `--full`, the tables of [the deck input](#the-deck-input---full) | | `exporter.name`, `exporter.version` | `nnnotes` and its version | | `exporter.chartFormat` | the format of the chart converter the notes come from, `nnnotes.live-score/1` | -| `deck` | the deck model: `{name, version, source, commit, format}`, `ournotes-deck`, the package version of its crate ournotes-sim, the repository, the git commit nnnotes is built with and the statistics format (`ournotes-deck.chart-stats/2`); null with `--no-deck` | +| `deck` | the deck model: `{name, version, source, commit, format}`, `ournotes-deck`, the package version of its crate ournotes-sim, the repository, the git commit nnnotes is built with and the statistics format (`ournotes-deck.chart-stats/3`); null with `--no-deck` | ### bands, characters, tags, categories @@ -175,157 +172,100 @@ plays, `musicLengthMs` is the length the score code uses; a listing chooses the ## Deck statistics -The deck model plays every chart of a song on its whole-live simulation in the theoretical best play, the frame -times of the game's default schedule, in two scenarios: +The deck model reports `ournotes-deck.chart-stats/3`. Gekisou measurements use independent nominal +lottery and skill probabilities. An interval is `[center, half-width]`, enclosing the expectation under +that law with outward probability arithmetic and score rounding bounds. Deterministic judgement counters +are exact integers. The law describes the model; agreement with a particular game's seed distribution +is a separate question. -- **Gekisou on** (`seeds`), as a Gekisou live (Battle Live, up to five players) plays: every judged note at its exact - time, Just inside the Just-count ranges and Perfect elsewhere, rank 1 in every Gekisou range. Other ranks follow - from the same numbers (below). -- **Gekisou off** (`offSeeds`), as a solo live (Free Live, Challenge Live) plays: every judged note Perfect at its - exact time, seed 0, no Just, luck, Gekisou combo or rank bonus. A chart with more than three fevers plays here too. +Serialized point centers use millipoints, consumed result counts use five fractional digits, and weights +use twelve. Half-widths round outward at the same precision and include the center's rounding. -In each it measures, at deck power `model.power` (300000): - -- `score`: the exact score without skills (with Gekisou on, the rank 1 bonuses of the Gekisou ranges included); -- for every **score-up kind** (`deck.kinds`) and every performance position `k`, `weights[kind][k]`: the exact score - a deck gains when its position-`k` member has one effect of that kind at factor 1, divided by the deck power. The - effect runs through the simulation's own updaters, conditions, frames and appliers, so the weight carries every rule - of the game (execute and finish frames, the 40 ms score frames, combo and Gekisou combo factors, Just scores, luck - rushes, the rank bonuses of the ranges it overlaps). - -A deck whose live skills are all score-up kinds then scores, up to the floors, - -``` -P * (score / power + sum over positions k of factor_k * weights[kind_k][k]) -``` - -with `P` the deck power and `factor_k` the effect's factor as the game's applier converts its value: effect type 2000 -`floor(value / 10000f * 1e5) / 1e5`, 2005 `floor(value / -10000f * 1e5) / 1e5`, 2002 and 2004 the same quotient -rounded half to even (`value / 10000f` in binary32). Each seed also plays a **check deck**, random kinds at real -master values at another power (`model.checkPower`), and the command fails when its exact score leaves the bound of -the formula. Effects of other types (cumulative score 2001 / 2003, life, judgement conversion, Gekisou and snap -skills) are not linear in the chart alone and have no weights: a deck's score with them comes from the simulation. - -A luck range draws lottery results from the play's random seed, so the Gekisou on measurements are given **per -seed**: one seed (0) when no range is a luck range, else the first `--seeds` seeds of the deck model's published seed -set. That set is a fixed sequence: candidate `k` is the low 32 bits (signed) of output `k + 1` of SplitMix64 started at -`0x6765_6B69_736F_7531` (`gekisou1`), kept when its pair of stream seeds (`|b|` and `|b ^ 0x9E3779B9|`, -2^31 as -2^31 - 1) differs from those of every seed kept before it, so a smaller set is a prefix of a larger one. The seed -set is not the game's seed law (which is unknown); a mean over it is not the game's expectation. - -### Ranks - -In a Gekisou live, range `i` takes a rank `r_i` from 1 to 5 among the room's players, and its rank bonus is -`trunc(rangeScore_i * p_i(r_i) / 100)` with `p_i(r) = ranges[i].rankBonusPercents[r - 1]`. The bonus is a fixed score -at the range's end: it changes no factor and no note score, and a later range's score holds it at both ends, so the -range scores do not depend on the ranks. At ranks `r` a seed's numbers are therefore - -``` -score_r = score - sum_i rankBonus_i + sum_i trunc(rangeScore_i * p_i(r_i) / 100) (exact) -weights_r[kind][k] = weights[kind][k] + sum_i (p_i(r_i) - p_i(1)) / 100 * rangeWeights[kind][k][i] -``` - -(`rankBonus_i`, `rangeScore_i`: `seeds[].ranges[i]`), and a deck scores `P * (score_r / power + sum_k factor_k * -weights_r[kind_k][k])` as above; `weights_r` is within `2 * ranges / power` per unit of factor of the exact weight at -those ranks. At rank 1 everywhere these are `score` and `weights`. `rangeWeights` is null when a range's bonus can fall -inside another range's score frames (overlapping ranges), and a kind's entry is null when its conditions read the -confirmed rank (condition 7012): the ranks do not follow linearly there. `rankCheck` plays the seed's check deck at -random ranks through the simulation's explicit rank confirmations and bounds it against these formulas. - -### Just rate - -`scorePerfect` and `ranges[i].rangeScorePerfect` are the no-skill score (rank 1 bonuses included) and the range -scores of the same play with every Just judged Perfect (the Just judgement is enabled only inside the Just-count -ranges, so nothing else changes). Between a Just rate of 1 (`score`, `rangeScore`) and 0 (`scorePerfect`, -`rangeScorePerfect`) a page can interpolate; the rank bonus of the Perfect play is `trunc(rangeScorePerfect_i * -p_i(r_i) / 100)`. A chart without Just notes (`justNotes` 0) has `scorePerfect == score`. +The best play judges each note at its exact time, Just in Just-count ranges and Perfect elsewhere. +The Perfect play supplies the other endpoint, with every Just judged Perfect. Both include rank 1 bonuses. +Free Live has its own deterministic Perfect run, without Gekisou, and remains available on charts with +more than three fever ranges. ### deck -| Field | Content | -|---|---| -| `model` | the deck model's description of the measurement: `engine`, `play`, `score` (the formula), `power`, `checkPower`, `unitValue` (the effect value of factor 1, 10000), `seeds`, `ranks` (the rank formulas), `perfect` (the Perfect play), `off` (the Gekisou off scenario) | -| `kinds[]` | the score-up kinds of the master data: `MasterLiveSkillEffect` rows of type 2000, 2002, 2004 or 2005 without a cumulative condition, grouped by what shapes their score. `id` (the index in `weights`), `effectType`, `activationTimeSecond`, `durationMs` (`ceil(activationTimeSecond * 1000f)`), `skillTargetIds`, `skillConditionGroup`, `skillReleaseConditionGroup`, `effectLimitCount`, `effectExecuteLimitCount`, `effectExecuteLimitResetConditionGroup`; `rows` (master rows of the kind) and `values` (their distinct `_effectValue`s, ascending) | - -The kind of a card's live skill is found by matching its `MasterLiveSkillEffect` row (at the skill level) on these -fields; `values` lists what the master data uses. +`model` describes the play, probability law, measurement `power`, `checkPower`, `unitValue` and score formulas. +`kinds` groups ordinary score-up rows of type 2000, 2002, 2004 or 2005 by duration, targets, conditions, +release and execution limits. A weight is the expected increment per unit of deck power and effect factor. +Type 2000 uses `value / 10000`; 2002 adds its intrinsic half factor; 2004 and 2005 use the effect's +half-to-even rounded activation factor. A card's kind is identified by its effect row's parameters. ### charts[].deck | Field | Content | |---|---| -| `convertedNoteCount` | the note count the score formula divides by (converted notes) | -| `skip` | score per unit of deck power of a skipped live (every note Great, combo 0, no skills) | -| `events` | `[[position, timeMs], ...]`: the skill events in chart order with the performance position each fires | -| `positions` | the performance positions the events fire (the largest position + 1): the length of every `weights[kind]` | -| `ranges[]` | the Gekisou ranges: `index`, `mission` (1 combo, 2 luck, 3 Just count), `startMs`, `endMs`, `rankBonusPercents` (the rank bonus percentages of ranks 1..5 of the song's mission pattern, `MasterLiveGekisouRankingScoreBonus`), `rankBonusPercent` (the rank 1 percentage, `rankBonusPercents[0]`) | -| `justNotes` | notes judged Just on the Gekisou on play | -| `seeds[]` | Gekisou on, per seed: `seed`; `score` (points at `model.power`, rank 1 bonuses included); `ranges[]` (`rangeScore`: the points gained inside the range, `rankBonus`: its rank 1 bonus in points, `maxCombo`, `justCount`, `luckPoints`: the luck points gained (`TotalBonusPoint`; a combo, Just count or luck range ranks the room by `maxCombo`, `justCount` or `luckPoints`), `lotResults`: lottery results Miss, Hit, Super Hit, Critical, `rangeScorePerfect`: `rangeScore` on the Perfect play); `weights[kind][position]` (points per unit of deck power and of factor); `check` (`deck`: `[kind, value]` or null per position, `exact`, `predicted`, `bound`: points at `model.checkPower`); `scorePerfect` (`score` on the Perfect play); `rangeWeights[kind][position][range]` (range points per unit of deck power and of factor, or null; a kind null); `rankCheck` (`ranks`: 1..5 per range, `exact`, `predicted`, `bound`; null without ranges or range weights) | -| `offSeeds[]` | Gekisou off, one seed: `seed` (0), `score`, `weights[kind][position]` (a kind null when its conditions read the Gekisou state, which a solo live does not have) and `check`, as in `seeds[]` | -| `unplayable` | null, or why the game cannot play the chart with Gekisou (more than three fevers: the game fails when the fourth starts); `seeds` is then empty, `offSeeds` is not | - -The deck model's chart facts are checked against the file's: the song, difficulty, level, judged note count, last -note time, music length, Gekisou missions, skill event times and fever ranges must agree, and are not repeated in -`deck`. Its numbers are checked against the master data and themselves: every range's `rankBonusPercents` are the -`MasterLiveGekisouRankingScoreBonus` rows of the song's mission pattern (0 without a row), every `rankBonus` is -`trunc(rangeScore * rankBonusPercent / 100)`, a chart without Just notes has the same scores on the Perfect play, -every array has its shape (`[kind][position]`, `[kind][position][range]`, one range result per range with its luck -points, one Gekisou off seed), the seeds are the chart's seed set (none on a chart unplayable with Gekisou, seed 0 -without a luck range, else the first `--seeds` seeds of the published seed set, which nnnotes computes itself), and -every check and rank check is within its bound. +| `convertedNoteCount`, `skip` | the score divisor and skipped-live score per unit of power | +| `events`, `positions` | `[position, timeMs]` skill events and the number of measured performance positions | +| `ranges` | index, mission, start/end times and `rankBonusPercents` for ranks 1..5 | +| `justNotes` | exact Just count of the best play | +| `expectation` | `score`, `scorePerfect`, `ranges`, `weights`, `rangeWeights`, `check`, `rankCheck`; null when Gekisou is unplayable | +| `replaySeeds` | seeds for per-play replay: `[0]` without a luck range, otherwise the first `--seeds` published seeds; empty when Gekisou is unplayable | +| `offSeeds` | one deterministic Free Live run: `seed: 0`, integer `score`, numeric `weights` and `{deck, exact, predicted, bound}` check | +| `unplayable` | null or the reason Gekisou cannot play the chart | + +`expectation.score` and `scorePerfect` are point intervals at `model.power`. Each expected range has +`rangeScore`, `rangeScorePerfect`, `rankBonus`, `rankBonusPerfect` and `luckPoints` intervals, integer +`maxCombo` and `justCount`, and four `lotResults` intervals: expected consumed Miss, Hit, Super Hit and +Critical counts. `weights[kind][position]` and `rangeWeights[kind][position][range]` hold intervals in +the weight unit. A range-weight kind is null when its conditions read confirmed rank; the whole field +is null when overlapping ranges prevent a linear rank transformation. + +A check has `{deck, ranks, expected, predicted, bound}`. Its deck contains `[kind, value]` or null per +position, its score intervals use `checkPower`, and the greatest separation of their endpoints must be +within `bound`. The baseline check uses `ranks: null`; `rankCheck` supplies explicit fixed rank arrivals +and is null when no linear rank check is available. The exporter checks chart facts, dimensions, finite +intervals, counters, replay seeds and every check before writing the file. + +### Ranks and accuracy + +For disjoint ranges whose effects do not read confirmed rank, an arbitrary-rank expectation uses + +``` +score_r = score - sum(rankBonus_i) + sum(E[trunc(rangeScore_i * p_i(r_i) / 100)]) +weight_r = weight + sum((p_i(r_i) - p_i(1)) / 100 * rangeWeight_i) +``` + +The expected truncation can be enclosed by scaling the range-score expectation and adding a rounding +allowance. Truncating the center alone does not compute an expected rank bonus. At rank 1 the measured +bonus interval is available directly. `rankBonusPerfect` supplies the Perfect endpoint's bonus. +Just-rate interpolation and Great-share scaling are accuracy approximations; the summary does not +describe arbitrary misses or combo breaks. Per-note replay runs an explicit play with its own seed. ### Gekisou skill aptitude -`deck.gekisouAptitude` describes the shapes measured, and each chart's `deck.gekisouAptitude` describes how its -score changes with **one** such shape equipped. It does not select a deck, and increments measured separately must -not be added to estimate several skills together. The existing `deck.seeds` still measures Gekisou **without card -Gekisou skills**; a solo/free live uses `offSeeds`, without Gekisou. These are model results, not a guarantee that -they reproduce the game. - -The file header has `plainKind` (the ordinary, unconditional, whole-team five-second score-up kind used for cross -terms, or null), `host` (how a support skill's paired member is measured), `seedRule` and `shapes`. A shape has a -continuous zero-based `id`, `source` (`member` or `support`), `mission` (1 combo, 2 luck, 3 Just, 4 all), -`bandCondition`, normalized `effects`, and `skills`. Each skill is `{id, level, memberTargetIds, bandIds}`; join its -id to `gekisouCatalog.skills` or `supportSkills` for its name. Members use their skill's highest level; snaps use -the support skill level at their highest rank. Effect rows preserve the master order and expose their effect, -trigger, duration, value, limits, targets, four condition groups and cumulative condition (the exact fields are in -the JSON Schema). Equivalent parameters share a shape. Support condition 5000's target is normalized away; -`memberTargetIds` and `bandIds` retain each skill's targets and bands for matching, or are null without that condition. - -Each chart has `{factors, variants}`, or null when aptitude is disabled, the chart is unplayable with Gekisou, there -are no ranges, or the master has no shapes. `factors`, in range order, has `judgedNotes`, `justNotes`, `perfectNotes`, -`tailNotes`, `comboAtStart`, and `lotteries`: the mean and standard error of the number of lotteries without a skill, -on `deck.seeds` (zero outside luck ranges). - -Variants are in shape-id order, only for the chart's missions or mission 4. A band-conditioned shape appears twice, -`bandMatch: true` then `false`; otherwise `bandMatch` is null. Each variant has: +The file's `deck.gekisouAptitude` has `plainKind`, `host`, `law` and `shapes`. Each shape groups equivalent +score-relevant parameters and retains its source, mission, band condition, normalized effect rows and +skill/level references. Member skills use their highest level; support skills use the highest Snap rank's +level. Support target differences share a shape while each skill retains `memberTargetIds` and `bandIds`. +A support skill is measured with an empty member skill of the same mission. + +Each chart has `{factors, variants}` or null when there are no applicable ranges/shapes or aptitude is +disabled. Factors give judged, Just, Perfect and tail note counts, starting combo and the expected number +of consumed lotteries (`lotteries`, an interval). Each applicable shape has one variant, or matching and +nonmatching band variants. A variant gives: | Field | Content | |---|---| -| `shape`, `bandMatch` | shape and the measured band condition | -| `deterministic`, `seeds`, `seTargetMet`, `crossSeeds` | whether random dependencies were excluded and four initial plays agreed, number of measured seeds, whether the standard-error target was met, number used for cross terms | -| `score`, `scorePerfect`, `tail`, `tailPerfect`, `converted` | score increments, the increments after the ranges, and the conversion increment; each `[mean, standard error]` | -| `ranges` | one object per range: `rangeScore`, `rankBonus`, `rangeScorePerfect`, `maxCombo`, `justCount`, `luckPoints`, each `[mean, standard error]` | -| `weights` | cross terms `[position][mean, standard error]`, null without a plain kind | -| `rangeWeights` | cross terms `[position][range][mean, standard error]`, null without a plain kind or a linear rank model | -| `check` | first seed's check: `seed`, `ranks`, `deck` (plain kind/value or null per slot), `exact`, `predicted`, `bound` | - -Point increments are measured at `deck.model.power`, using the same seed for the equipped and unequipped plays. -`score.mean = tail.mean + sum(rangeScore.mean + rankBonus.mean)`; **standard errors cannot be added this way**. -Point means and errors are rounded to 0.001. With R ranges this equality allows `(2+2R)*0.0005 + 1e-8` points of -independent rounding error. Weights, lottery statistics and the check's prediction and bound keep binary64 precision. -A deterministic result uses one seed and zero standard errors. Charts with luck ranges, effects 11000–11005, -or probability condition 4011 in any condition group always use random sampling, even if the first observations -agree; four matching observations alone do not establish determinism. Others expand through `seedRule.batches` (normally -32, 64, 128, 256, 512, 1024), stopping when the unrounded score error is at most the larger of `relative` (0.01) times -the absolute mean increment and `baseline` (0.001) times the no-skill mean on the same seeds. A result at the cap may -have `seTargetMet: false`. Cross terms use the first `min(seeds, seedRule.crossSeeds)` seeds (normally capped at 64). -The sampled increment is an estimate, not an exact expectation; consult its error and the check bound. - -The exporter checks the shape table against independently read master data, its skill/level coverage and bands, -the variants' references, mission and band coverage, finite `[mean, se]` pairs with nonnegative errors, deterministic -zero errors, array lengths, the tail identity and each check's bound. New exports always include the aptitude keys; -the Schema still accepts older `/1` files without them. +| `score`, `scorePerfect`, `tail`, `tailPerfect`, `converted` | expected gains with the skill minus without it; `[center, half-width]` | +| `ranges` | `rangeScore`, `rankBonus`, `rangeScorePerfect`, `rankBonusPerfect`, `maxCombo`, `justCount`, `luckPoints` gain intervals | +| `weights` | the plain kind's weight changes, `[position][interval]`; null without that kind | +| `rangeWeights` | its range-weight changes, `[position][range][interval]`; null outside the linear domain | +| `check` | the full expected score and linear prediction for a random plain-skill deck at explicit ranks | + +The score and tail centers satisfy `tail = score - sum(rangeScore + rankBonus)`, and the Perfect endpoint +uses the corresponding Perfect fields. Interval radii propagate by outward arithmetic. Conversion, +combo and Just counters have zero radius after proving their independence from lotteries. + +Each variant measures one skill alone. Several gains cannot be added to estimate combined skills: +combo caps, lottery gauges, Rush and Just-triggered support effects interact. Plain-skill cross weights +are measured on the best play. Below 100% Just, a page can interpolate the no-ordinary-skill endpoints; +it cannot derive complete cross weights at that accuracy from this summary. A full play uses replay. +The exporter verifies shape coverage, normalized effect rows, targets, missions, variant ordering, +intervals, tail identities and expectation checks against the supplied master data. ## The deck input (`--full`) @@ -450,17 +390,9 @@ packages. ## Versions -`format` names the major version. Within `nnnotes.music-data/1`, fields and tables are only added, never renamed, -removed or given another meaning, and readers ignore keys they do not know. A reader rejects a file with a major -version it does not know. A change that breaks readers is a new major version, `nnnotes.music-data/2`. The deck -statistics follow the deck model's format (`provenance.deck.format`): a new major version of it is a new major -version of this file. - -`nnnotes.music-data/1` replaces the `nnnotes.songs/1` file of `nnnotes songs` (its fields are the songs, charts and -their facts here) and the `nnnotes.deck-data/1` file of `nnnotes deck-data` (its content is the deck input of -`--full`). - - -Final aptitude exports require the standard-error target for both `score` and `scorePerfect`, each against its own paired no-skill baseline. Sampling extends the same published seed prefix through geometric batches, stopping as soon as both targets agree with the unchanged max(1% of increment, 0.1% of baseline) rule. The 65,536-seed cap is a failure guard, not a requirement to run every seed. An unmet cap aborts a normal export before writing artifacts. `--allow-unconverged-aptitude` is an explicit diagnostic option; it retains real SE values and unmet flags. +`format` names the major version. `nnnotes.music-data/2` carries deck statistics +`ournotes-deck.chart-stats/3`. Additive fields and tables retain their existing meanings within a major +version; readers ignore unknown keys and reject unsupported major versions. Deck model provenance names +the exact statistics format and source digest. The optional runtime input keeps `nnnotes.deck-data/1`. `--replay-dir OUT/replay --replay-engine PKG [--recommend-engine PKG]` writes normalized runtime inputs and the pinned model's WASM release packages (the replay engine and, optionally, the recommendation engine) as described in [replay.md](replay.md). The music data stays compact and carries the SHA-bound `replay.manifestUrl` pointer. No original chart/master blobs or native binary are included in this artifact bundle. diff --git a/docs/replay.md b/docs/replay.md index 7a4c747..d785863 100644 --- a/docs/replay.md +++ b/docs/replay.md @@ -16,14 +16,14 @@ The music-data pointer is: "deckData": {"format":"nnnotes.deck-data/1","url":"deck-data.json","sha256":"","bytes":0}, "charts": [{"scoreId":10000303,"url":"charts/10000303.json","sha256":"","bytes":0,"musicLengthMs":120557,"noteCount":0,"assetSha256":""}], "engine": { - "model": {"name":"ournotes-deck","version":"","source":"","commit":"","format":"ournotes-deck.chart-stats/2"}, + "model": {"name":"ournotes-deck","version":"","source":"","commit":"","format":"ournotes-deck.chart-stats/3"}, "requestFormat":"ournotes.replay/1","class":"ReplaySession","methods":["describeChart","template","run"], "js":{"url":"engine/ournotes_replay_wasm.js","sha256":"","bytes":0}, "wasm":{"url":"engine/ournotes_replay_wasm_bg.wasm","sha256":"","bytes":0}, "build":{"url":"engine/build-info.json","sha256":"","bytes":0} }, "recommendEngine": { - "model": {"name":"ournotes-deck","version":"","source":"","commit":"","format":"ournotes-deck.chart-stats/2"}, + "model": {"name":"ournotes-deck","version":"","source":"","commit":"","format":"ournotes-deck.chart-stats/3"}, "js":{"url":"recommend/ournotes_recommend_wasm.js","sha256":"","bytes":0}, "wasm":{"url":"recommend/ournotes_recommend_wasm_bg.wasm","sha256":"","bytes":0}, "build":{"url":"recommend/build-info.json","sha256":"","bytes":0} diff --git a/docs/schema/music-data.schema.json b/docs/schema/music-data.schema.json index 44e3cdb..9ffdec6 100644 --- a/docs/schema/music-data.schema.json +++ b/docs/schema/music-data.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "urn:nnnotes:schema:music-data", - "title": "nnnotes.music-data/1", + "title": "nnnotes.music-data/2", "description": "Every live song and chart of one master data version: song metadata in every language, chart facts and the deck model's chart statistics, with --full also the deck model's input (every chart's runtime notes and a subset of the master data tables as columns and rows). Written by `nnnotes music-data`; described in docs/music-data.md. Within the arrays of a deck input chart's `notes` (and of its `fevers`) the i-th elements belong to the same note (range): the arrays have equal lengths, which a schema cannot state. Readers ignore keys they do not know.", "type": "object", "required": [ @@ -17,7 +17,7 @@ ], "properties": { "format": { - "const": "nnnotes.music-data/1" + "const": "nnnotes.music-data/2" }, "provenance": { "type": "object", @@ -176,7 +176,7 @@ }, "format": { "type": "string", - "description": "the statistics format, ournotes-deck.chart-stats/2" + "description": "the statistics format, ournotes-deck.chart-stats/3" } } } @@ -691,8 +691,9 @@ "positions", "ranges", "justNotes", - "seeds", - "unplayable" + "unplayable", + "expectation", + "replaySeeds" ], "properties": { "convertedNoteCount": { @@ -765,165 +766,6 @@ "justNotes": { "type": "integer" }, - "seeds": { - "type": "array", - "items": { - "type": "object", - "required": [ - "seed", - "score", - "ranges", - "weights", - "check" - ], - "properties": { - "seed": { - "type": "integer" - }, - "score": { - "type": "integer", - "description": "the exact no-skill score at model.power" - }, - "ranges": { - "type": "array", - "items": { - "type": "object", - "required": [ - "rangeScore", - "rankBonus", - "maxCombo", - "justCount", - "lotResults" - ], - "properties": { - "rangeScore": { - "type": "integer" - }, - "rankBonus": { - "type": "integer" - }, - "maxCombo": { - "type": "integer" - }, - "justCount": { - "type": "integer" - }, - "luckPoints": { - "type": "integer", - "description": "the luck points gained in the range (TotalBonusPoint), a luck range's rank figure" - }, - "lotResults": { - "type": "array", - "items": { - "type": "integer" - }, - "minItems": 4, - "maxItems": 4 - }, - "rangeScorePerfect": { - "type": "integer", - "description": "the range score on the Perfect play (every Just judged Perfect)" - } - } - } - }, - "weights": { - "type": "array", - "items": { - "type": "array", - "items": { - "type": "number" - } - }, - "description": "weights[kind][position]: score per unit of deck power and of factor" - }, - "check": { - "type": "object", - "required": [ - "deck", - "exact", - "predicted", - "bound" - ], - "properties": { - "deck": { - "type": "array", - "items": { - "type": [ - "array", - "null" - ] - } - }, - "exact": { - "type": "integer" - }, - "predicted": { - "type": "number" - }, - "bound": { - "type": "number" - } - } - }, - "scorePerfect": { - "type": "integer", - "description": "the exact no-skill score at model.power on the Perfect play (every Just judged Perfect), its rank 1 bonuses included" - }, - "rangeWeights": { - "type": [ - "array", - "null" - ], - "items": { - "type": [ - "array", - "null" - ], - "items": { - "type": "array", - "items": { - "type": "number" - } - } - }, - "description": "rangeWeights[kind][position][range]: range score per unit of deck power and of factor; null when the ranks do not follow linearly on the chart, a kind null when its conditions read the confirmed rank" - }, - "rankCheck": { - "type": [ - "object", - "null" - ], - "required": [ - "ranks", - "exact", - "predicted", - "bound" - ], - "description": "the check deck played at random ranks; null without ranges or range weights", - "properties": { - "ranks": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 5 - } - }, - "exact": { - "type": "integer" - }, - "predicted": { - "type": "number" - }, - "bound": { - "type": "number" - } - } - } - } - } - }, "offSeeds": { "type": "array", "description": "the measurements with Gekisou off (a solo live), one seed", @@ -993,10 +835,27 @@ "string", "null" ], - "description": "why the game cannot play the chart with Gekisou (seeds is then empty; offSeeds is not)" + "description": "why Gekisou cannot play the chart; expectation is null and replaySeeds is empty" }, "gekisouAptitude": { "$ref": "#/$defs/chartGekisouAptitude" + }, + "expectation": { + "anyOf": [ + { + "$ref": "#/$defs/chartExpectation" + }, + { + "type": "null" + } + ] + }, + "replaySeeds": { + "type": "array", + "items": { + "type": "integer" + }, + "description": "Seeds for deterministic replays." } } }, @@ -1372,21 +1231,6 @@ } } }, - "aptitudeMeanSE": { - "type": "array", - "prefixItems": [ - { - "type": "number" - }, - { - "type": "number", - "minimum": 0 - } - ], - "items": false, - "minItems": 2, - "maxItems": 2 - }, "aptitudeCondition": { "type": "object", "required": [ @@ -1637,7 +1481,7 @@ "required": [ "plainKind", "host", - "seedRule", + "law", "shapes" ], "properties": { @@ -1656,47 +1500,16 @@ "type": "string", "minLength": 1 }, - "seedRule": { - "type": "object", - "required": [ - "deterministicTest", - "batches", - "relative", - "baseline", - "crossSeeds" - ], - "properties": { - "deterministicTest": { - "type": "integer", - "minimum": 1 - }, - "batches": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1 - }, - "minItems": 1 - }, - "relative": { - "type": "number", - "minimum": 0 - }, - "baseline": { - "type": "number", - "minimum": 0 - }, - "crossSeeds": { - "type": "integer", - "minimum": 1 - } - } - }, "shapes": { "type": "array", "items": { "$ref": "#/$defs/aptitudeShape" } + }, + "law": { + "type": "string", + "minLength": 1, + "description": "The independent nominal lottery and skill probability law." } } }, @@ -1710,10 +1523,6 @@ "required": [ "shape", "bandMatch", - "deterministic", - "seeds", - "seTargetMet", - "crossSeeds", "score", "scorePerfect", "tail", @@ -1739,34 +1548,20 @@ } ] }, - "deterministic": { - "type": "boolean" - }, - "seeds": { - "type": "integer", - "minimum": 1 - }, - "seTargetMet": { - "type": "boolean" - }, - "crossSeeds": { - "type": "integer", - "minimum": 1 - }, "score": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "scorePerfect": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "tail": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "tailPerfect": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "converted": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "ranges": { "type": "array", @@ -1778,26 +1573,30 @@ "rangeScorePerfect", "maxCombo", "justCount", - "luckPoints" + "luckPoints", + "rankBonusPerfect" ], "properties": { "rangeScore": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "rankBonus": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "rangeScorePerfect": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "maxCombo": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "justCount": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" }, "luckPoints": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" + }, + "rankBonusPerfect": { + "$ref": "#/$defs/estimate" } } } @@ -1807,7 +1606,7 @@ { "type": "array", "items": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" } }, { @@ -1822,7 +1621,7 @@ "items": { "type": "array", "items": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" } } }, @@ -1832,63 +1631,7 @@ ] }, "check": { - "type": "object", - "required": [ - "seed", - "ranks", - "deck", - "exact", - "predicted", - "bound" - ], - "properties": { - "seed": { - "type": "integer" - }, - "ranks": { - "type": "array", - "items": { - "type": "integer", - "minimum": 1, - "maximum": 5 - } - }, - "deck": { - "type": "array", - "items": { - "anyOf": [ - { - "type": "array", - "prefixItems": [ - { - "type": "integer", - "minimum": 0 - }, - { - "type": "integer" - } - ], - "items": false, - "minItems": 2, - "maxItems": 2 - }, - { - "type": "null" - } - ] - } - }, - "exact": { - "type": "integer" - }, - "predicted": { - "type": "number" - }, - "bound": { - "type": "number", - "minimum": 0 - } - } + "$ref": "#/$defs/expectationCheck" } } }, @@ -1935,7 +1678,7 @@ "minimum": 0 }, "lotteries": { - "$ref": "#/$defs/aptitudeMeanSE" + "$ref": "#/$defs/estimate" } } } @@ -1952,6 +1695,182 @@ "type": "null" } ] + }, + "estimate": { + "type": "array", + "minItems": 2, + "maxItems": 2, + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number", + "minimum": 0 + } + ], + "items": false, + "description": "The center and outward half-width of a nominal expectation interval." + }, + "expectationCheck": { + "type": "object", + "required": [ + "deck", + "ranks", + "expected", + "predicted", + "bound" + ], + "properties": { + "deck": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "integer" + }, + "minItems": 2, + "maxItems": 2 + }, + { + "type": "null" + } + ] + } + }, + "ranks": { + "type": [ + "array", + "null" + ], + "items": { + "type": "integer", + "minimum": 1, + "maximum": 5 + } + }, + "expected": { + "$ref": "#/$defs/estimate" + }, + "predicted": { + "$ref": "#/$defs/estimate" + }, + "bound": { + "type": "number", + "minimum": 0 + } + }, + "description": "A full nominal expectation and its linear prediction; the worst endpoint separation is within bound." + }, + "chartExpectation": { + "type": "object", + "required": [ + "score", + "scorePerfect", + "ranges", + "weights", + "rangeWeights", + "check", + "rankCheck" + ], + "properties": { + "score": { + "$ref": "#/$defs/estimate" + }, + "scorePerfect": { + "$ref": "#/$defs/estimate" + }, + "ranges": { + "type": "array", + "items": { + "type": "object", + "required": [ + "rangeScore", + "rankBonus", + "rankBonusPerfect", + "rangeScorePerfect", + "luckPoints", + "maxCombo", + "justCount", + "lotResults" + ], + "properties": { + "rangeScore": { + "$ref": "#/$defs/estimate" + }, + "rankBonus": { + "$ref": "#/$defs/estimate" + }, + "rankBonusPerfect": { + "$ref": "#/$defs/estimate" + }, + "rangeScorePerfect": { + "$ref": "#/$defs/estimate" + }, + "luckPoints": { + "$ref": "#/$defs/estimate" + }, + "maxCombo": { + "type": "integer" + }, + "justCount": { + "type": "integer" + }, + "lotResults": { + "type": "array", + "items": { + "$ref": "#/$defs/estimate" + }, + "minItems": 4, + "maxItems": 4, + "description": "Expected consumed result counts: Miss, Hit, Super Hit, Critical." + } + } + } + }, + "weights": { + "type": "array", + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/estimate" + } + } + }, + "rangeWeights": { + "type": [ + "array", + "null" + ], + "items": { + "type": [ + "array", + "null" + ], + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/estimate" + } + } + } + }, + "check": { + "$ref": "#/$defs/expectationCheck" + }, + "rankCheck": { + "anyOf": [ + { + "$ref": "#/$defs/expectationCheck" + }, + { + "type": "null" + } + ] + } + } } } } diff --git a/rust/src/lib.rs b/rust/src/lib.rs index aec368c..6405055 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,10 +1,10 @@ -//! `nnnotes._deck`: the chart statistics of the deck model ournotes-sim (`ournotes-deck.chart-stats/2`) on a deck +//! `nnnotes._deck`: the chart statistics of the deck model ournotes-sim (`ournotes-deck.chart-stats/3`) on a deck //! data document held in memory, the charts measured in parallel. use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; -use ournotes_sim::chartstats::{self, AptitudeOptions, ChartStats, GEKISOU_SEEDS, Options}; +use ournotes_sim::chartstats::{self, ChartStats, REPLAY_SEEDS, Options}; use ournotes_sim::data::DeckData; use ournotes_sim::error::Error; use pyo3::exceptions::PyValueError; @@ -53,34 +53,23 @@ fn measure(data: &DeckData, options: &Options, workers: usize) -> Result str +/// chart_stats(data, seeds=None, workers=None, aptitude=True) -> str /// -/// The `ournotes-deck.chart-stats/2` document (JSON text) of a deck data document (`nnnotes.deck-data/1` JSON text), -/// as `ournotes-deck chart-stats` writes it. `seeds`: the size of the seed set of charts with a luck range (default +/// The `ournotes-deck.chart-stats/3` document (JSON text) of a deck data document (`nnnotes.deck-data/1` JSON text), +/// as `ournotes-deck chart-stats` writes it. `seeds`: the replay seed count of charts with a luck range (default /// 8); `workers`: threads measuring charts (default: the available parallelism). Raises ValueError for data the /// deck model cannot read or a chart whose check deck fails. `aptitude`: measure single Gekisou skill shapes -/// (default true); `aptitude_max_seeds` and `aptitude_cross_seeds`: sample caps (defaults 65536 and 64). +/// (default true). Gekisou statistics use independent nominal lottery and skill probabilities. #[pyfunction] -#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true, aptitude_max_seeds=None, aptitude_cross_seeds=None))] +#[pyo3(signature = (data, seeds=None, workers=None, aptitude=true))] fn chart_stats( py: Python<'_>, data: &str, seeds: Option, workers: Option, aptitude: bool, - aptitude_max_seeds: Option, - aptitude_cross_seeds: Option, ) -> PyResult { - let seeds = seeds.unwrap_or(GEKISOU_SEEDS); - let defaults = AptitudeOptions::default(); - let options = Options { - seeds, - aptitude: aptitude.then_some(AptitudeOptions { - max_seeds: aptitude_max_seeds.unwrap_or(defaults.max_seeds), - cross_seeds: aptitude_cross_seeds.unwrap_or(defaults.cross_seeds), - }), - }; + let options = Options { replay_seeds: seeds.unwrap_or(REPLAY_SEEDS), aptitude }; let workers = workers.unwrap_or_else(|| std::thread::available_parallelism().map_or(1, |n| n.get())); py.detach(|| { let mut data = DeckData::from_json(data).map_err(value_error)?; diff --git a/src/nnnotes/cli.py b/src/nnnotes/cli.py index cf4f083..b812280 100644 --- a/src/nnnotes/cli.py +++ b/src/nnnotes/cli.py @@ -678,8 +678,7 @@ def cmd_music_data(args, cfg): try: 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, cache=args.stats_cache) + 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 @@ -987,18 +986,11 @@ def target(m, what): c.add_argument("--no-deck", action="store_true", help="do not run the deck model (every chart's deck is null)") c.add_argument("--seeds", type=int, default=8, metavar="N", - help="seeds measured on a chart with a luck range (default 8)") + help="replay seeds of a chart with a luck range (default 8); statistics use nominal expectations") c.add_argument("--workers", type=int, metavar="N", help="threads measuring charts (default: every processor)") c.add_argument("--no-gekisou-aptitude", action="store_true", help="leave out the charts' Gekisou aptitude (every gekisouAptitude is null)") - c.add_argument("--aptitude-max-seeds", type=int, metavar="N", - help="seeds of a Gekisou aptitude variant at most (default: the deck model's, 65536); " - "sampling stops earlier when both score targets converge") - c.add_argument("--allow-unconverged-aptitude", action="store_true", - 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") diff --git a/src/nnnotes/musicdata.py b/src/nnnotes/musicdata.py index a32d900..58a7e81 100644 --- a/src/nnnotes/musicdata.py +++ b/src/nnnotes/musicdata.py @@ -1,5 +1,5 @@ """Music data: one JSON file with every live song and chart of one master data version (format -`nnnotes.music-data/1`, docs/music-data.md): titles and credits in every language, bands, vocal characters, category, +`nnnotes.music-data/2`, docs/music-data.md): titles and credits in every language, bands, vocal characters, category, tags, release time, score ranks, the live BGM's length, the Gekisou catalog (member cards, snaps and their Gekisou skills), per difficulty the chart facts (level, note counts, BPM, chart times, skill events, fever ranges) and the chart's deck statistics, what the chart contributes to the live score whatever the deck, with Gekisou on (a Gekisou @@ -33,7 +33,7 @@ from . import deckdata from .languages import LANGUAGES -FORMAT = "nnnotes.music-data/1" +FORMAT = "nnnotes.music-data/2" DIFFICULTIES = ("easy", "normal", "hard", "expert") MUSIC_LENGTH_TAIL_MS = 1000 # the live's music length: the last note time + 1000 ms (LiveScore skip path) @@ -299,20 +299,19 @@ def skill(r: dict, max_level: dict) -> dict: # ---------------------------------------------------------------- the deck model -DECK_SEEDS = 8 # seed set size of a chart with a luck range (ournotes-deck's default) +DECK_SEEDS = 8 # replay seed count of a chart with a luck range 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). With a `cache` directory, a chart's statistics are kept there under the SHA-256 of + available parallelism), with independent nominal lottery and skill probabilities. `seeds` gives the number of + replay seeds for a chart with a luck range; `aptitude` includes every chart's Gekisou aptitude and cross terms. + 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, cache: Path | None = None): + cache: Path | None = None): if module is None: try: from . import _deck as module @@ -320,9 +319,7 @@ def __init__(self, seeds: int = DECK_SEEDS, workers: int | None = None, module=N raise MusicDataError("the deck model (nnnotes._deck) is not built into this installation: install " "nnnotes from a wheel or build it (maturin), or pass --no-deck") from None self.module, self.seeds, self.workers = module, seeds, workers - self.aptitude, self.aptitude_max_seeds, self.aptitude_cross_seeds = \ - aptitude, aptitude_max_seeds, aptitude_cross_seeds - self.require_convergence = require_convergence + self.aptitude = aptitude self.cache = None if cache is None else Path(cache) self.counts: dict | None = None # {cached, measured} charts of the last stats() @@ -334,8 +331,7 @@ def info(self) -> dict: 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, - aptitude_cross_seeds=self.aptitude_cross_seeds) + aptitude=self.aptitude) except ValueError as e: raise MusicDataError(f"deck model: {e}") from None doc = json.loads(text, parse_float=deckdata._Num) @@ -347,8 +343,7 @@ 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, + "aptitude": self.aptitude, "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"]] @@ -387,21 +382,12 @@ def stats(self, deck_input: dict) -> dict: 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", []) - for v in (c.get("gekisouAptitude") or {}).get("variants", []) - if v.get("seTargetMet") is not True] - if unmet: - chart, shape, band, seeds = unmet[0] - raise MusicDataError(f"deck model: {len(unmet)} aptitude variants did not meet BOTH score SE targets; " - f"chart {chart}, shape {shape}, bandMatch {band}, seeds {seeds}; " - "increase --aptitude-max-seeds; no final artifact written") return doc # the keys of a chart's statistics carried by `deck` (the others are checked against the chart facts) -DECK_CHART_KEYS = ("convertedNoteCount", "skip", "events", "positions", "ranges", "justNotes", "seeds", "offSeeds", +DECK_CHART_KEYS = ("convertedNoteCount", "skip", "events", "positions", "ranges", "justNotes", "expectation", + "replaySeeds", "offSeeds", "unplayable", "gekisouAptitude") RANKS = 5 # the ranks of a Gekisou range (a Gekisou live has up to five players) GEKISOU_RANGES = 3 # the Gekisou ranges of a live (its first three fevers) @@ -458,12 +444,6 @@ def _check_bound(c, what: str, where: str) -> None: f"{c['bound']}") -def _check_rank_check(rc: dict, n: int, s: str, where: str) -> None: - if len(rc["ranks"]) != n or not all(1 <= r <= RANKS for r in rc["ranks"]): - raise MusicDataError(f"{where}: {s}: rank check ranks {rc['ranks']!r}") - _check_bound(rc, f"{s}: the check deck at ranks {rc['ranks']!r}", where) - - # ournotes-sim's published seed set (live::seeds): candidate k is the low 32 bits (a signed integer) of output k + 1 # of SplitMix64 started at SEED_ORIGIN (the ASCII bytes of `gekisou1`); a candidate is kept when its pair of effective # stream seeds (|b| and |b ^ 0x9E3779B9|, -2^31 taken as 2^31 - 1) differs from that of every seed kept before it @@ -497,37 +477,74 @@ def eff(x: int) -> int: def chart_seeds(stats: dict, seeds: int) -> list[int]: - """The seeds of a chart's Gekisou on statistics: none when it is unplayable with Gekisou, seed 0 when no range is - a luck range (the play draws nothing), else the first `seeds` published seeds.""" + """The chart's replay seeds: none when unplayable with Gekisou, 0 without a luck range, otherwise the first + `seeds` published seeds. Nominal expectation measurements are independent of this list.""" if stats.get("unplayable") is not None: return [] return published_seeds(seeds) if any(r["mission"] == MISSION_LUCK for r in stats["ranges"]) else [0] -def _check_seed_shapes(stats: dict, kinds: int, where: str) -> None: - """The array shapes and the checks of a chart's statistics: `weights[kind][position]` (a kind null only with - Gekisou off), `rangeWeights[kind][position][range]` (null, or a kind null), one Gekisou off seed, the seeds' - ranges one per range with their luck points, and every check (and rank check) within its bound.""" +def _check_interval_bound(c, what: str, where: str) -> None: + """The largest separation of the expected and predicted intervals is within the stated bound.""" + if not isinstance(c, dict) or any(k not in c for k in CHECK_KEYS): + raise MusicDataError(f"{where}: {what}: no expectation check") + if not (_estimate(c["expected"]) and _estimate(c["predicted"]) and _number(c["bound"]) + and float(c["bound"]) >= 0): + raise MusicDataError(f"{where}: {what}: invalid expected, predicted or bound") + error = abs(float(c["expected"][0]) - float(c["predicted"][0])) + \ + float(c["expected"][1]) + float(c["predicted"][1]) + if error > float(c["bound"]) and not _same(error, float(c["bound"])): + raise MusicDataError(f"{where}: {what}: expected {c['expected']}, predicted {c['predicted']} " + f"beyond the bound {c['bound']}") + + +def _check_expectation_shapes(stats: dict, kinds: int, where: str) -> None: + """Expected intervals and their array dimensions, deterministic counters and validation decks.""" positions, n = stats["positions"], len(stats["ranges"]) - for seed in stats["seeds"]: - s = f"seed {seed['seed']}" - w = seed.get("weights") - if not (isinstance(w, list) and len(w) == kinds and all(_numbers(x, positions) for x in w)): - raise MusicDataError(f"{where}: {s}: weights are not [kind][position]") - if len(seed["ranges"]) != n: - raise MusicDataError(f"{where}: {s}: {len(seed['ranges'])} range results for {n} ranges") - for i, r in enumerate(seed["ranges"]): - if not _int(r.get("luckPoints")): - raise MusicDataError(f"{where}: {s} range {i}: no luck points (luckPoints)") - rw = seed.get("rangeWeights") + expected = stats.get("expectation") + if stats.get("unplayable") is not None: + if expected is not None: + raise MusicDataError(f"{where}: an expectation on an unplayable Gekisou chart") + else: + if not isinstance(expected, dict): + raise MusicDataError(f"{where}: no nominal expectation") + if not all(_estimate(expected.get(k)) for k in ("score", "scorePerfect")): + raise MusicDataError(f"{where}: expectation scores are not [center, half-width]") + w = expected.get("weights") + if not (isinstance(w, list) and len(w) == kinds and all(isinstance(x, list) and len(x) == positions + and all(_estimate(y) for y in x) for x in w)): + raise MusicDataError(f"{where}: expectation weights are not [kind][position] intervals") + rs = expected.get("ranges") + if not isinstance(rs, list) or len(rs) != n: + raise MusicDataError(f"{where}: expectation range results differ from the {n} ranges") + for i, r in enumerate(rs): + if not (isinstance(r, dict) and all(_estimate(r.get(k)) for k in + ("rangeScore", "rangeScorePerfect", "rankBonus", "rankBonusPerfect", "luckPoints")) + and _int(r.get("maxCombo")) and _int(r.get("justCount")) + and isinstance(r.get("lotResults"), list) and len(r["lotResults"]) == 4 + and all(_estimate(x) for x in r["lotResults"])): + raise MusicDataError(f"{where}: expectation range {i}: invalid scores, counters or lottery results") + rw = expected.get("rangeWeights") if rw is not None and not (isinstance(rw, list) and len(rw) == kinds and all( - k is None or (isinstance(k, list) and len(k) == positions and all(_numbers(x, n) for x in k)) - for k in rw)): - raise MusicDataError(f"{where}: {s}: rangeWeights are not [kind][position][range]") - _check_bound(seed["check"], f"{s}: the check deck", where) - rc = seed.get("rankCheck") - if rc is not None: - _check_rank_check(rc, n, s, where) + k is None or (isinstance(k, list) and len(k) == positions and all(isinstance(x, list) and len(x) == n + and all(_estimate(y) for y in x) + for x in k)) for k in rw)): + raise MusicDataError(f"{where}: expectation rangeWeights are not [kind][position][range] intervals") + for name, c in (("the check deck", expected.get("check")), ("rank check", expected.get("rankCheck"))): + if c is None and name == "rank check": + continue + _check_interval_bound(c, name, where) + if not (isinstance(c["deck"], list) and len(c["deck"]) == positions and all( + x is None or isinstance(x, list) and len(x) == 2 and _int(x[0]) and 0 <= x[0] < kinds + and _int(x[1]) for x in c["deck"])): + raise MusicDataError(f"{where}: {name}: invalid check deck") + ranks = c["ranks"] + if name == "rank check": + if not (isinstance(ranks, list) and len(ranks) == n + and all(_int(r) and 1 <= r <= RANKS for r in ranks)): + raise MusicDataError(f"{where}: rank check ranks {ranks!r}") + elif ranks is not None: + raise MusicDataError(f"{where}: the baseline check deck has fixed rank arrivals") off = stats.get("offSeeds") if not isinstance(off, list) or len(off) != 1: raise MusicDataError(f"{where}: the deck model gives no Gekisou off statistics (offSeeds)") @@ -537,17 +554,15 @@ def _check_seed_shapes(stats: dict, kinds: int, where: str) -> None: raise MusicDataError(f"{where}: Gekisou off: weights are not [kind][position]") _check_bound(seed["check"], "Gekisou off: the check deck", where) - # ---------------------------------------------------------------- the Gekisou aptitude PLAIN_EFFECT_TYPE, PLAIN_MS = 2000, 5000 # the plain kind: score up on the whole deck for 5 s, nothing else CONDITION_MEMBER_TARGET = 5000 # the skill condition on the member a snap's support skill is paired with -APTITUDE_KEYS = ("plainKind", "host", "seedRule", "shapes") -SEED_RULE_KEYS = ("deterministicTest", "batches", "relative", "baseline", "crossSeeds") +APTITUDE_KEYS = ("plainKind", "host", "law", "shapes") SHAPE_KEYS = ("id", "source", "mission", "bandCondition", "effects", "skills") FACTOR_COUNTS = ("judgedNotes", "justNotes", "perfectNotes", "tailNotes", "comboAtStart") VARIANT_MEANS = ("score", "scorePerfect", "tail", "tailPerfect", "converted") -RANGE_MEANS = ("rangeScore", "rankBonus", "rangeScorePerfect", "maxCombo", "justCount", "luckPoints") -CHECK_KEYS = ("seed", "ranks", "deck", "exact", "predicted", "bound") +RANGE_MEANS = ("rangeScore", "rankBonus", "rankBonusPerfect", "rangeScorePerfect", "maxCombo", "justCount", "luckPoints") +CHECK_KEYS = ("ranks", "deck", "expected", "predicted", "bound") CONDITION_GROUPS = (("trigger", "_skillTriggerConditionGroup"), ("condition", "_skillConditionGroup"), ("release", "_skillReleaseConditionGroup"), ("reset", "_effectExecuteLimitResetConditionGroup")) MEAN_TOLERANCE = 1e-9 # relative: a mean against its recomputation (binary64 sums) @@ -576,8 +591,8 @@ def _number(v) -> bool: return False -def _mean_se(v) -> bool: - """Whether v is `[mean, standard error]`: two finite numbers, the error not negative.""" +def _estimate(v) -> bool: + """Whether v is `[center, half-width]`: two finite numbers, the radius not negative.""" return isinstance(v, list) and len(v) == 2 and all(_number(x) for x in v) and float(v[1]) >= 0 @@ -585,13 +600,6 @@ def _same(a: float, b: float, scale: float = 1.0) -> bool: return abs(a - b) <= MEAN_TOLERANCE * max(1.0, abs(scale), abs(a), abs(b)) -def mean_se(xs: list[int]) -> tuple[float, float]: - """The mean of xs and its standard error (the sample standard deviation / sqrt(n); 0 for one value).""" - n = len(xs) - m = sum(xs) / n - return m, (math.sqrt(sum((x - m) ** 2 for x in xs) / (n - 1) / n) if n > 1 else 0.0) - - def _f32(v) -> float: import numpy as np return float(np.float32(float(v))) @@ -720,15 +728,8 @@ def check_header(self, info, kinds: list[dict], model: dict) -> dict | None: raise MusicDataError(f"{where}: the support skills' host is not described (host)") if not (isinstance(model.get("gekisouAptitude"), str) and model["gekisouAptitude"]): raise MusicDataError("deck model: the model does not describe the aptitude (model.gekisouAptitude)") - rule = info["seedRule"] - if not (isinstance(rule, dict) and all(k in rule for k in SEED_RULE_KEYS) - and _int(rule["deterministicTest"]) and rule["deterministicTest"] >= 1 - and isinstance(rule["batches"], list) and rule["batches"] and all(_int(b) for b in rule["batches"]) - and rule["batches"][0] >= 2 and all(a < b for a, b in zip(rule["batches"], rule["batches"][1:])) - and _number(rule["relative"]) and float(rule["relative"]) >= 0 - and _number(rule["baseline"]) and float(rule["baseline"]) >= 0 - and _int(rule["crossSeeds"]) and rule["crossSeeds"] >= 1): - raise MusicDataError(f"{where}: seed rule {rule!r}") + if not isinstance(info["law"], str) or not info["law"]: + raise MusicDataError(f"{where}: the nominal probability law is not described (law)") shapes = info["shapes"] if not isinstance(shapes, list) or [s.get("id") if isinstance(s, dict) else s for s in shapes] != list( range(len(shapes))): @@ -784,7 +785,7 @@ def chart(self, where: str, stats: dict, plain: int | None) -> dict | None: """A chart's `gekisouAptitude` after checking it: null exactly when the aptitude is left out, the master data has no shape, the chart is unplayable with Gekisou or has no Gekisou range; else a factor per range and a variant per shape of the chart's missions (both band match results of a shape with a band condition), each - with its means shaped and consistent (the tail, a deterministic variant's rank bonuses) and its check within + with its intervals shaped and consistent (the best and Perfect tails) and its check within its bound.""" apt = stats.get("gekisouAptitude") if self.header is None or not self.shapes or stats.get("unplayable") is not None or not stats["ranges"]: @@ -798,7 +799,7 @@ def chart(self, where: str, stats: dict, plain: int | None) -> dict | None: if not (isinstance(apt, dict) and isinstance(apt.get("factors"), list) and isinstance(apt.get("variants"), list)): raise MusicDataError(f"{where}: none (gekisouAptitude {{factors, variants}})") - ranges, base = stats["ranges"], stats["seeds"] + ranges, base = stats["ranges"], stats["expectation"] self._factors(where, apt["factors"], stats) missions = {r["mission"] for r in ranges} want = [(s["id"], b) for s in self.shapes if s["mission"] == MISSION_ALL or s["mission"] in missions @@ -816,14 +817,14 @@ def chart(self, where: str, stats: dict, plain: int | None) -> dict | None: return {"factors": apt["factors"], "variants": apt["variants"]} def _factors(self, where: str, factors: list, stats: dict) -> None: - ranges, base = stats["ranges"], stats["seeds"] + ranges, base = stats["ranges"], stats["expectation"] if len(factors) != len(ranges): raise MusicDataError(f"{where}: {len(factors)} factors for {len(ranges)} ranges") just = 0 for j, (f, r) in enumerate(zip(factors, ranges)): at = f"{where}: range {j} factors" if not (isinstance(f, dict) and all(_int(f.get(k)) and f[k] >= 0 for k in FACTOR_COUNTS) - and _mean_se(f.get("lotteries"))): + and _estimate(f.get("lotteries"))): raise MusicDataError(f"{at}: not {{{', '.join(FACTOR_COUNTS)}, lotteries}}: {f!r}") if r["mission"] != MISSION_JUST and (f["justNotes"], f["perfectNotes"]) != (0, 0): raise MusicDataError(f"{at}: {f['justNotes']} Just and {f['perfectNotes']} Perfect notes outside a " @@ -831,109 +832,66 @@ def _factors(self, where: str, factors: list, stats: dict) -> None: if f["justNotes"] + f["perfectNotes"] > f["judgedNotes"] or f["comboAtStart"] > stats["judgedNotes"]: raise MusicDataError(f"{at}: note counts {f!r} beyond the range's or the chart's") just += f["justNotes"] - m, se = mean_se([sum(s["ranges"][j]["lotResults"]) for s in base]) if base else (0.0, 0.0) - if r["mission"] != MISSION_LUCK: - m, se = 0.0, 0.0 - if not (_same(float(f["lotteries"][0]), m) and _same(float(f["lotteries"][1]), se, m)): - raise MusicDataError(f"{at}: lotteries {f['lotteries']}, the chart's seeds give [{m!r}, {se!r}]") + estimates = base["ranges"][j]["lotResults"] + center = sum(float(x[0]) for x in estimates) if r["mission"] == MISSION_LUCK else 0.0 + radius = sum(float(x[1]) for x in estimates) if r["mission"] == MISSION_LUCK else 0.0 + if abs(float(f["lotteries"][0]) - center) > float(f["lotteries"][1]) + radius + MEAN_TOLERANCE: + raise MusicDataError(f"{at}: lotteries {f['lotteries']}, expected counts give [{center}, {radius}]") if just > stats["justNotes"]: raise MusicDataError(f"{where}: {just} Just notes in the ranges, {stats['justNotes']} on the play") - def _variant(self, at: str, v: dict, stats: dict, base: list, plain: int | None) -> None: - rule, n, positions = self.header["seedRule"], len(stats["ranges"]), stats["positions"] - if not (isinstance(v.get("deterministic"), bool) and isinstance(v.get("seTargetMet"), bool) - and _int(v.get("seeds")) and _int(v.get("crossSeeds"))): - raise MusicDataError(f"{at}: deterministic, seeds, seTargetMet, crossSeeds " - f"{[v.get(k) for k in ('deterministic', 'seeds', 'seTargetMet', 'crossSeeds')]!r}") - det = v["deterministic"] - effects = self.shapes[v["shape"]]["effects"] - random = any(r["mission"] == MISSION_LUCK for r in stats["ranges"]) or any( - 11000 <= e["effectType"] <= 11005 or any( - c["type"] == 4011 for key, _ in CONDITION_GROUPS for group in e[key] for c in group) - for e in effects) - if det and random: - raise MusicDataError(f"{at}: a deterministic variant with a random dependency") - if det and (v["seeds"], v["seTargetMet"]) != (1, True): - raise MusicDataError(f"{at}: deterministic on {v['seeds']} seeds (seTargetMet {v['seTargetMet']})") - if not det and (v["seeds"] not in rule["batches"] - or not v["seTargetMet"] and v["seeds"] != rule["batches"][-1]): - raise MusicDataError(f"{at}: {v['seeds']} seeds (seTargetMet {v['seTargetMet']}), not a batch of the seed " - f"rule {rule['batches']}") - if v["crossSeeds"] != min(v["seeds"], rule["crossSeeds"]): - raise MusicDataError(f"{at}: {v['crossSeeds']} cross seeds, not min({v['seeds']}, {rule['crossSeeds']})") + def _variant(self, at: str, v: dict, stats: dict, base: dict, plain: int | None) -> None: + n, positions = len(stats["ranges"]), stats["positions"] pairs = [(k, v.get(k)) for k in VARIANT_MEANS] rs = v.get("ranges") if not (isinstance(rs, list) and len(rs) == n and all(isinstance(r, dict) for r in rs)): - raise MusicDataError(f"{at}: {len(rs) if isinstance(rs, list) else rs!r} range results for {n} ranges") + raise MusicDataError(f"{at}: invalid range results for {n} ranges") pairs += [(f"ranges[{j}].{k}", r.get(k)) for j, r in enumerate(rs) for k in RANGE_MEANS] w, rw = v.get("weights"), v.get("rangeWeights") - linear = bool(base) and base[0].get("rangeWeights") is not None + linear = base.get("rangeWeights") is not None if plain is None: if w is not None or rw is not None: raise MusicDataError(f"{at}: weights without a plain kind") else: - if not (isinstance(w, list) and len(w) == positions): - raise MusicDataError(f"{at}: weights are not [position] of [mean, se]") + if not isinstance(w, list) or len(w) != positions: + raise MusicDataError(f"{at}: weights are not [position] intervals") pairs += [(f"weights[{k}]", x) for k, x in enumerate(w)] if (rw is not None) != linear: - raise MusicDataError(f"{at}: rangeWeights {'given' if rw is not None else 'null'}, the chart's seeds " - f"have {'them' if linear else 'none'}") + raise MusicDataError(f"{at}: rangeWeights differ from the baseline's range weight domain") if rw is not None: - if not (isinstance(rw, list) and len(rw) == positions and all(isinstance(x, list) and len(x) == n - for x in rw)): - raise MusicDataError(f"{at}: rangeWeights are not [position][range] of [mean, se]") + if not (isinstance(rw, list) and len(rw) == positions + and all(isinstance(x, list) and len(x) == n for x in rw)): + raise MusicDataError(f"{at}: rangeWeights are not [position][range] intervals") pairs += [(f"rangeWeights[{k}][{j}]", y) for k, x in enumerate(rw) for j, y in enumerate(x)] for name, x in pairs: - if not _mean_se(x): - raise MusicDataError(f"{at}: {name} {x!r} is not [mean, standard error]") - if det and float(x[1]) != 0: - raise MusicDataError(f"{at}: {name} {x!r}: a deterministic variant with a standard error") - m = {k: float(v[k][0]) for k in VARIANT_MEANS} - terms = [float(r[k][0]) for r in rs for k in ("rangeScore", "rankBonus")] - # Every serialized point mean is independently rounded to 0.001 (score, tail and two terms per range). - if abs(m["tail"] - (m["score"] - sum(terms))) > (len(terms) + 2) * 0.0005 + 1e-8: - raise MusicDataError(f"{at}: tail {v['tail'][0]} is not score {v['score'][0]} - {sum(terms)!r} (the " - f"ranges' scores and rank bonuses)") - if det: - ints = [float(x[0]) for name, x in pairs if not name.startswith(("weights", "rangeWeights"))] - if not all(x.is_integer() for x in ints): - raise MusicDataError(f"{at}: a deterministic variant with a fraction of a point") - perfect = 0 # the Perfect play's range scores and rank bonuses - for j, (r, info, r0) in enumerate(zip(rs, stats["ranges"], base[0]["ranges"])): - p = info["rankBonusPercent"] - want = _trunc_percent(r0["rangeScore"] + int(float(r["rangeScore"][0])), p) - r0["rankBonus"] - if int(float(r["rankBonus"][0])) != want: - raise MusicDataError(f"{at}: range {j}: rank bonus {r['rankBonus'][0]} is not trunc((" - f"{r0['rangeScore']} + {r['rangeScore'][0]}) * {p} / 100) - " - f"{r0['rankBonus']} = {want}") - d, r0p = int(float(r["rangeScorePerfect"][0])), r0["rangeScorePerfect"] - perfect += d + _trunc_percent(r0p + d, p) - _trunc_percent(r0p, p) - if m["tailPerfect"] != m["scorePerfect"] - perfect: - raise MusicDataError(f"{at}: tail on the Perfect play {v['tailPerfect'][0]} is not scorePerfect " - f"{v['scorePerfect'][0]} - {perfect} (the ranges' Perfect play scores and rank " - f"bonuses)") + if not _estimate(x): + raise MusicDataError(f"{at}: {name} {x!r} is not [center, half-width]") + if name == "converted" or name.endswith(("maxCombo", "justCount")): + if float(x[1]) != 0 or not float(x[0]).is_integer(): + raise MusicDataError(f"{at}: {name} must be an exact integer counter") + for score, tail, range_score, rank_bonus in ( + ("score", "tail", "rangeScore", "rankBonus"), + ("scorePerfect", "tailPerfect", "rangeScorePerfect", "rankBonusPerfect")): + inside = sum(float(r[k][0]) for r in rs for k in (range_score, rank_bonus)) + # Each point center is serialized to millipoints; every field encloses its own rounding. + tolerance = (2 * n + 2) * 0.0005 + MEAN_TOLERANCE * max(1, abs(float(v[score][0]))) + if abs(float(v[tail][0]) - (float(v[score][0]) - inside)) > tolerance: + raise MusicDataError(f"{at}: {tail} is not {score} minus the ranges' scores and rank bonuses") c = v.get("check") - if not (isinstance(c, dict) and all(k in c for k in CHECK_KEYS)): - raise MusicDataError(f"{at}: no check {{{', '.join(CHECK_KEYS)}}}") - first = base[0]["seed"] if det else published_seeds(1)[0] - if not _int(c["seed"]) or c["seed"] != first: - raise MusicDataError(f"{at}: the check plays seed {c['seed']!r}, not the variant's first {first}") - if not (isinstance(c["ranks"], list) and len(c["ranks"]) == n and all(_int(r) and 1 <= r <= RANKS - for r in c["ranks"]) + _check_interval_bound(c, "the check deck", at) + if not (isinstance(c["ranks"], list) and len(c["ranks"]) == n + and all(_int(r) and 1 <= r <= RANKS for r in c["ranks"]) and (linear or all(r == 1 for r in c["ranks"]))): raise MusicDataError(f"{at}: check ranks {c['ranks']!r}") if not (isinstance(c["deck"], list) and len(c["deck"]) == positions and all( x is None or (plain is not None and isinstance(x, list) and len(x) == 2 and x[0] == plain and _int(x[1])) for x in c["deck"])): - raise MusicDataError(f"{at}: check deck {c['deck']!r} is not [plain kind ({plain}), value] or null per " - f"position") - _check_bound(c, "the check deck", at) - + raise MusicDataError(f"{at}: check deck {c['deck']!r} is not [plain kind ({plain}), value] or null per position") def chart_deck(song: dict, chart: dict, stats: dict, kinds: int, percents: list[list], seeds: int, aptitude: Aptitude, plain: int | None) -> dict: """A chart's `deck` from its statistics, after checking them against the song, the chart facts, the song's rank - bonus percentages (`percents`: rank_bonus_percents), the seed set (chart_seeds: `seeds` seeds with a luck range) + bonus percentages (`percents`: rank_bonus_percents), the replay seed set (chart_seeds) and its Gekisou aptitude (Aptitude.chart, `plain` the plain kind), `kinds` the number of score-up kinds.""" where = f"chart {chart['scoreId']} ({song['id']} {chart['difficulty']})" checks = ( @@ -958,20 +916,15 @@ def chart_deck(song: dict, chart: dict, stats: dict, kinds: int, percents: list[ raise MusicDataError(f"{where}: range {i}: the deck model's rank bonus percentages {got!r} " f"({r['rankBonusPercent']!r}) differ from MasterLiveGekisouRankingScoreBonus {want!r}") want = chart_seeds(stats, seeds) - if [s["seed"] for s in stats["seeds"]] != want: - raise MusicDataError(f"{where}: seeds {[s['seed'] for s in stats['seeds']]} are not the chart's seed set " - f"{want}") - _check_seed_shapes(stats, kinds, where) - for seed in stats["seeds"]: - for i, (r, info) in enumerate(zip(seed["ranges"], ranges)): - bonus = _trunc_percent(r["rangeScore"], info["rankBonusPercent"]) - if r["rankBonus"] != bonus: - raise MusicDataError(f"{where}: seed {seed['seed']} range {i}: rank bonus {r['rankBonus']} is not " - f"trunc({r['rangeScore']} * {info['rankBonusPercent']} / 100) = {bonus}") - if stats["justNotes"] == 0 and (seed.get("scorePerfect") != seed["score"] or any( - r.get("rangeScorePerfect") != r["rangeScore"] for r in seed["ranges"])): - raise MusicDataError(f"{where}: seed {seed['seed']}: a chart without Just notes scores otherwise on the " - f"Perfect play ({seed.get('scorePerfect')} for {seed['score']})") + if stats.get("replaySeeds") != want: + raise MusicDataError(f"{where}: replay seeds {stats.get('replaySeeds')!r} are not the chart's seed set {want}") + _check_expectation_shapes(stats, kinds, where) + expected = stats.get("expectation") + if expected is not None and stats["justNotes"] == 0: + pairs = [(expected["score"], expected["scorePerfect"])] + [ + (r["rangeScore"], r["rangeScorePerfect"]) for r in expected["ranges"]] + if any(abs(float(a[0]) - float(b[0])) > float(a[1]) + float(b[1]) + MEAN_TOLERANCE for a, b in pairs): + raise MusicDataError(f"{where}: a chart without Just notes scores otherwise on the Perfect play") return dict({k: stats.get(k) for k in DECK_CHART_KEYS}, gekisouAptitude=aptitude.chart(where, stats, plain)) diff --git a/tests/test_musicdata.py b/tests/test_musicdata.py index db077db..4fb3333 100644 --- a/tests/test_musicdata.py +++ b/tests/test_musicdata.py @@ -205,54 +205,59 @@ def shape_skill(i, level, targets=None, bands=None): {"id": 4, "source": "support", "mission": 1, "bandCondition": False, "effects": [effect_row(5)], "skills": [shape_skill(32, 5)]}, ] -SEED_RULE = {"deterministicTest": 4, "batches": [32, 64, 128, 256, 512, 1024], "relative": 0.01, "baseline": 0.001, - "crossSeeds": 64} -NONDETERMINISTIC = {2, 3} # the shapes the stand-in measures on 32 seeds - - -def mean_se(xs): - m = sum(xs) / len(xs) - return [m, (sum((x - m) ** 2 for x in xs) / (len(xs) - 1) / len(xs)) ** 0.5 if len(xs) > 1 else 0.0] +NOMINAL_LAW = "independent nominal lottery and skill probabilities" + + +def expected_stats(positions, percents, missions): + ranges = [] + for j, p in enumerate(percents): + points = 101 * (j + 1) + luck = missions[j] == musicdata.MISSION_LUCK + ranges.append({"rangeScore": [points, 0], "rangeScorePerfect": [points, 0], + "rankBonus": [trunc_percent(points, p[0]), 0], + "rankBonusPerfect": [trunc_percent(points, p[0]), 0], + "maxCombo": 1, "justCount": 0, "luckPoints": [11, 0.35] if luck else [0, 0], + "lotResults": [[2, 0.01], [1, 0.01], [0.5, 0.01], [0.1, 0.01]] if luck else [[0, 0]] * 4}) + check = {"deck": [[0, 5000]] + [None] * (positions - 1), "ranks": None, + "expected": [2000, 0.25], "predicted": [2000.25, 0.1], "bound": 7} + return {"score": [1234, 0.5], "scorePerfect": [1234, 0.5], "ranges": ranges, + "weights": [[["W", 0]] * positions], "rangeWeights": [[[["W", 0]] * len(ranges)] * positions], + "check": check, "rankCheck": dict(check, ranks=[2] * len(ranges)) if ranges else None} def aptitude_of(s, positions): - """The stand-in's Gekisou aptitude of a chart's statistics `s`: per range 5 judged notes (Perfect in a Just count - range), per variant +10 * (j + 1) in range j and 7 after the ranges; a nondeterministic shape's means a quarter - point off with a standard error of 0.5.""" - ranges, base = s["ranges"], s["seeds"][0] + """Synthetic nominal intervals: known range gains and an exact seven-point tail.""" + ranges, base = s["ranges"], s["expectation"] missions = {r["mission"] for r in ranges} factors = [{"judgedNotes": 5, "justNotes": 0, "perfectNotes": 5 if r["mission"] == 3 else 0, "tailNotes": 1, "comboAtStart": 0, - "lotteries": mean_se([sum(x["ranges"][j]["lotResults"]) for x in s["seeds"]]) if r["mission"] == 2 - else [0, 0]} for j, r in enumerate(ranges)] + "lotteries": [sum(x[i] for x in base["ranges"][j]["lotResults"]) for i in range(2)]} + for j, r in enumerate(ranges)] variants = [] for shape in SHAPES: if shape["mission"] != 4 and shape["mission"] not in missions: continue for band in ((True, False) if shape["bandCondition"] else (None,)): - det = shape["id"] not in NONDETERMINISTIC and 2 not in missions - off, se = (0, 0) if det else (0.25, 0.5) + offset, radius = (0.25, 0.5) if shape["id"] in (2, 3) or 2 in missions else (0, 0) rs, inside, inside_p = [], 0, 0 for j, (r, r0) in enumerate(zip(ranges, base["ranges"])): d = 10 * (j + 1) - rb = trunc_percent(r0["rangeScore"] + d, r["rankBonusPercent"]) - r0["rankBonus"] - rbp = trunc_percent(r0["rangeScorePerfect"] + d, r["rankBonusPercent"]) - \ - trunc_percent(r0["rangeScorePerfect"], r["rankBonusPercent"]) - rs.append({"rangeScore": [d + off, se], "rankBonus": [rb + off, se], "rangeScorePerfect": [d + off, se], - "maxCombo": [0, 0], "justCount": [1 + off, se], "luckPoints": [off, se]}) - inside += d + rb + 2 * off - inside_p += d + rbp - seeds = 1 if det else 32 + rb = trunc_percent(r0["rangeScore"][0] + d, r["rankBonusPercent"]) - r0["rankBonus"][0] + rbp = trunc_percent(r0["rangeScorePerfect"][0] + d, r["rankBonusPercent"]) - r0["rankBonusPerfect"][0] + rs.append({"rangeScore": [d + offset, radius], "rankBonus": [rb + offset, radius], + "rangeScorePerfect": [d + offset, radius], "rankBonusPerfect": [rbp + offset, radius], + "maxCombo": [0, 0], "justCount": [1, 0], "luckPoints": [offset, radius]}) + inside += d + rb + 2 * offset + inside_p += d + rbp + 2 * offset variants.append({ - "shape": shape["id"], "bandMatch": band, "deterministic": det, "seeds": seeds, "seTargetMet": True, - "crossSeeds": min(seeds, SEED_RULE["crossSeeds"]), - "score": [inside + 7, se], "scorePerfect": [inside_p + 7 + off, se], "tail": [7, se], - "tailPerfect": [7, se], "converted": [0, 0], "ranges": rs, - "weights": [[0.01, se / 100]] * positions, + "shape": shape["id"], "bandMatch": band, + "score": [inside + 7, radius], "scorePerfect": [inside_p + 7, radius], "tail": [7, radius], + "tailPerfect": [7, radius], "converted": [0, 0], "ranges": rs, + "weights": [[0.01, radius / 100]] * positions, "rangeWeights": [[[0.001, 0]] * len(ranges)] * positions if base["rangeWeights"] is not None else None, - "check": {"seed": base["seed"] if det else musicdata.published_seeds(1)[0], "ranks": [2 if base["rangeWeights"] is not None else 1] * len(ranges), - "deck": [[0, 5000]] + [None] * (positions - 1), "exact": 2000, "predicted": 2000.25, - "bound": 7.0}}) + "check": {"ranks": [2 if base["rangeWeights"] is not None else 1] * len(ranges), + "deck": [[0, 5000]] + [None] * (positions - 1), "expected": [2000, 0.25], + "predicted": [2000.25, 0.1], "bound": 7}}) return {"factors": factors, "variants": variants} @@ -261,7 +266,7 @@ class FakeDeck: aptitude (SHAPES; `aptitude`) unless it is left out.""" COMMIT = "7e5d84b5998d28c21541ce3f2e0a3dfb1439f4f6" SOURCE = "5" * 64 - FORMAT = "ournotes-deck.chart-stats/2" + FORMAT = "ournotes-deck.chart-stats/3" def __init__(self, change=None, fail=None, header=None): self.change, self.fail, self.header, self.inputs, self.options = change, fail, header, [], [] @@ -271,12 +276,12 @@ def info(self): "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): + def chart_stats(self, data, seeds, workers, aptitude=True): if self.fail: raise ValueError(self.fail) doc = json.loads(data) self.inputs.append((doc, seeds, workers)) - self.options.append((aptitude, aptitude_max_seeds, aptitude_cross_seeds)) + self.options.append(aptitude) assert doc["format"] == deckdata.DECK_FORMAT and list(doc["master"]) == [t for t, _ in deckdata.TABLES] m = doc["master"] with_aptitude = aptitude @@ -303,16 +308,6 @@ def table(name): check = {"deck": [[0, 5000]], "exact": 2000, "predicted": 2000.25, "bound": 7.0} luck = musicdata.MISSION_LUCK in missions[:len(fevers)] - def seed_stats(i, seed): - return {"seed": seed, "score": 1234, - "ranges": [{"rangeScore": 101 * (j + 1), "rankBonus": trunc_percent(101 * (j + 1), p[0]), - "maxCombo": 1, "justCount": 0, "luckPoints": 0, - "lotResults": [i % 3, 1, 0, 0] if luck else [0, 0, 0, 0], - "rangeScorePerfect": 101 * (j + 1)} for j, p in enumerate(percents)], - "weights": [["W"] * positions], "check": dict(check), "scorePerfect": 1234, - "rangeWeights": [[["W"] * len(fevers)] * positions], - "rankCheck": {"ranks": [2] * len(fevers), "exact": 2001, "predicted": 2000.5, - "bound": 7.0} if fevers else None} s = {"scoreId": c["scoreId"], "musicId": music, "difficulty": d, "level": levels[c["scoreId"]], "judgedNotes": sum(score.is_judgement_note(op) for op in c["notes"]["op"]), "convertedNoteCount": len(c["notes"]["id"]), "lastNoteMs": last, "musicLengthMs": last + 1000, @@ -322,7 +317,8 @@ def seed_stats(i, seed): "rankBonusPercent": percents[i][0], "rankBonusPercents": percents[i]} for i, (a, b) in enumerate(fevers)], "justNotes": 0, - "seeds": [seed_stats(i, x) for i, x in enumerate(musicdata.published_seeds(seeds) if luck else [0])], + "expectation": expected_stats(positions, percents, missions), + "replaySeeds": musicdata.published_seeds(seeds) if luck else [0], "offSeeds": [{"seed": 0, "score": 1000, "weights": [["W"] * positions], "check": dict(check)}]} s["gekisouAptitude"] = aptitude_of(s, positions) if with_aptitude and fevers else None if self.change: @@ -331,7 +327,7 @@ def seed_stats(i, seed): out = {"format": self.FORMAT, "source": {}, "model": {"power": 300000, "gekisouAptitude": "each Gekisou (support) skill shape alone"}, "gekisouAptitude": {"plainKind": 0, "host": "a synthetic member without a Gekisou skill", - "seedRule": SEED_RULE, "shapes": SHAPES} if aptitude else None, + "law": NOMINAL_LAW, "shapes": SHAPES} if aptitude else None, "kinds": [{"id": 0, "effectType": 2000, "activationTimeSecond": 7.5}], "charts": charts} out = json.loads(json.dumps(out)) if self.header: @@ -400,7 +396,7 @@ def test_document(tmp_path): doc = json.loads(raw) assert list(doc) == ["format", "provenance", "languages", "bands", "characters", "tags", "categories", "gekisouCatalog", "deck", "songs"] - assert doc["format"] == "nnnotes.music-data/1" and doc["languages"] == ["ja", "en", "zh-Hant", "zh-Hans", "ko"] + assert doc["format"] == "nnnotes.music-data/2" and doc["languages"] == ["ja", "en", "zh-Hant", "zh-Hans", "ko"] p = doc["provenance"] assert list(p) == ["region", "client", "catalog", "master", "exporter", "deck"] and p["deck"] is None # without the deck model: the song and the Gekisou catalog tables only @@ -485,9 +481,10 @@ def test_deck(tmp_path): assert c["deck"]["unplayable"] is None and c["deck"]["skip"] == 1.5 # the ranks: the song's mission pattern (3) rows of every range assert [r["rankBonusPercents"] for r in c["deck"]["ranges"]] == [[35, 34, 33, 32, 31], [40, 38, 36, 34, 32]] - seed = c["deck"]["seeds"][0] - assert seed["rankCheck"]["ranks"] == [2, 2] and seed["scorePerfect"] == seed["score"] - assert [r["rankBonus"] for r in seed["ranges"]] == [35, 80] and [r["luckPoints"] for r in seed["ranges"]] == [0, 0] + expected = c["deck"]["expectation"] + assert expected["rankCheck"]["ranks"] == [2, 2] and expected["scorePerfect"] == expected["score"] + assert [r["rankBonus"] for r in expected["ranges"]] == [[35, 0], [80, 0]] + assert [r["luckPoints"] for r in expected["ranges"]] == [[0, 0], [0, 0]] assert c["deck"]["offSeeds"][0]["score"] == 1000 # the deck model's numbers as it writes them: a binary64 value is not narrowed to binary32 assert b'"weights":[[' + WEIGHT.encode() + b',' + WEIGHT.encode() + b']]' in raw and b'"predicted":2000.25' in raw @@ -508,22 +505,22 @@ def test_deck(tmp_path): (lambda s: s.update(difficulty="hard"), "difficulty 'hard' differs"), (lambda s: s["ranges"] and s["ranges"][0]["rankBonusPercents"].__setitem__(2, 99), "range 0: .*percentages"), (lambda s: s["ranges"] and s["ranges"][0].update(rankBonusPercent=1), "range 0: .*percentages"), - (lambda s: s["ranges"] and s["seeds"][0]["ranges"][0].update(rankBonus=-1), "rank bonus -1 is not trunc"), - (lambda s: s["seeds"][0]["ranges"].pop(), "range results for"), + (lambda s: s["ranges"] and s["expectation"]["ranges"][0].update(rankBonus=[1, -1]), "invalid scores"), + (lambda s: s["expectation"]["ranges"].pop(), "range results differ"), (lambda s: s.pop("offSeeds"), "no Gekisou off statistics"), (lambda s: s["offSeeds"].append(s["offSeeds"][0]), "no Gekisou off statistics"), (lambda s: s["offSeeds"][0]["weights"].append(None), "Gekisou off: weights"), (lambda s: s["offSeeds"][0]["check"].update(exact=0), "Gekisou off: the check deck scores 0"), - (lambda s: s["seeds"][0].update(scorePerfect=1), "otherwise on the Perfect play"), - (lambda s: s["seeds"][0]["weights"][0].pop(), "weights are not"), - (lambda s: s["seeds"][0]["weights"].append(None), "weights are not"), - (lambda s: s["seeds"][0].update(rangeWeights=[[]]), "rangeWeights are not"), - (lambda s: s["seeds"][0]["check"].update(bound=0.1), "the check deck scores 2000"), - (lambda s: s["ranges"] and s["seeds"][0]["rankCheck"].update(exact=0), "at ranks"), - (lambda s: s["ranges"] and s["seeds"][0]["rankCheck"].update(ranks=[6, 1]), "rank check ranks"), - (lambda s: s["seeds"][0]["ranges"][0].pop("luckPoints"), "chart 10 .*seed 0 range 0: no luck points"), - (lambda s: s["seeds"][0].update(seed=3), r"chart 10 .*seeds \[3\] are not the chart's seed set \[0\]"), - (lambda s: s["seeds"].append(dict(s["seeds"][0], seed=5)), r"chart 10 .*seeds \[0, 5\] are not the chart's seed"), + (lambda s: s["expectation"].update(scorePerfect=[1, 0]), "otherwise on the Perfect play"), + (lambda s: s["expectation"]["weights"][0].pop(), "weights are not"), + (lambda s: s["expectation"]["weights"].append(None), "weights are not"), + (lambda s: s["expectation"].update(rangeWeights=[[]]), "rangeWeights are not"), + (lambda s: s["expectation"]["check"].update(bound=0.1), "the check deck: expected"), + (lambda s: s["ranges"] and s["expectation"]["rankCheck"].update(expected=[0, 0]), "rank check: expected"), + (lambda s: s["ranges"] and s["expectation"]["rankCheck"].update(ranks=[6, 1]), "rank check ranks"), + (lambda s: s["expectation"]["ranges"][0].pop("luckPoints"), "chart 10 .*range 0: invalid scores"), + (lambda s: s.update(replaySeeds=[3]), r"chart 10 .*replay seeds \[3\] are not the chart's seed set \[0\]"), + (lambda s: s["replaySeeds"].append(5), r"chart 10 .*replay seeds \[0, 5\] are not the chart's seed"), ]) def test_deck_checks(tmp_path, change, match): with pytest.raises(musicdata.MusicDataError, match=match): @@ -534,8 +531,8 @@ def test_deck_checks(tmp_path, change, match): def test_deck_without_range_weights(tmp_path): """A chart without range weights, a kind without them and a kind without Gekisou off weights are carried.""" def change(s): - s["seeds"][0]["rangeWeights"] = None if s["scoreId"] == 10 else [None] - s["seeds"][0]["rankCheck"] = None + s["expectation"]["rangeWeights"] = None if s["scoreId"] == 10 else [None] + s["expectation"]["rankCheck"] = None s["offSeeds"][0]["weights"] = [None] if s["scoreId"] == 10: for v in s["gekisouAptitude"]["variants"]: @@ -544,7 +541,7 @@ def change(s): export(tmp_path, deck=FakeDeck(change)) doc = json.loads((tmp_path / "music.json").read_bytes()) decks = {c["scoreId"]: c["deck"] for s in doc["songs"] for c in s["charts"]} - assert decks[10]["seeds"][0]["rangeWeights"] is None and decks[20]["seeds"][0]["rangeWeights"] == [None] + assert decks[10]["expectation"]["rangeWeights"] is None and decks[20]["expectation"]["rangeWeights"] == [None] assert decks[30]["offSeeds"][0]["weights"] == [None] schema_validator().validate(doc) @@ -636,11 +633,11 @@ def test_luck_chart(tmp_path): export(tmp_path, rows=rows, deck=FakeDeck()) doc = json.loads((tmp_path / "music.json").read_bytes()) deck = doc["songs"][0]["charts"][0]["deck"] - assert [s["seed"] for s in deck["seeds"]] == musicdata.published_seeds(8) + assert deck["replaySeeds"] == musicdata.published_seeds(8) schema_validator().validate(doc) (tmp_path / "m").rename(tmp_path / "m1") with pytest.raises(musicdata.MusicDataError, match=r"chart 10 .*seeds .* are not the chart's seed set"): - export(tmp_path, rows=rows, deck=FakeDeck(on(10, lambda s: s["seeds"].pop()))) + export(tmp_path, rows=rows, deck=FakeDeck(on(10, lambda s: s["replaySeeds"].pop()))) def test_rank_bonus_percents(): @@ -668,7 +665,7 @@ def test_deck_errors(tmp_path, monkeypatch): def test_deck_module(): deck = pytest.importorskip("nnnotes._deck") info = deck.info() - assert info["name"] == "ournotes-deck" and info["format"] == "ournotes-deck.chart-stats/2" + assert info["name"] == "ournotes-deck" and info["format"] == "ournotes-deck.chart-stats/3" 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") @@ -849,13 +846,11 @@ def aptitude_fixture(): """A header and a two-range chart, independently authored above, through JSON as the extension returns it.""" tables = {t: TABLE_ROWS.get(t, rows_of(t)) for t in musicdata.tables_of(True, False)} a = musicdata.Aptitude(tables, musicdata.gekisou_catalog(tables, musicdata.Texts(tables['MasterText']))) - header = json.loads(json.dumps({'plainKind': 0, 'host': 'synthetic host', 'seedRule': SEED_RULE, 'shapes': SHAPES}), + header = json.loads(json.dumps({'plainKind': 0, 'host': 'synthetic host', 'law': NOMINAL_LAW, 'shapes': SHAPES}), parse_float=deckdata._Num) chart = {'positions': 2, 'judgedNotes': 20, 'justNotes': 0, 'ranges': [ {'mission': 1, 'rankBonusPercent': 35}, {'mission': 3, 'rankBonusPercent': 40}], - 'seeds': [{'seed': 0, 'rangeWeights': [], 'ranges': [ - {'rangeScore': 101, 'rangeScorePerfect': 101, 'rankBonus': 35, 'lotResults': [0, 0, 0, 0]}, - {'rangeScore': 202, 'rangeScorePerfect': 202, 'rankBonus': 80, 'lotResults': [0, 0, 0, 0]}]}]} + 'expectation': expected_stats(2, [[35], [40]], [1, 3])} chart['gekisouAptitude'] = aptitude_of(chart, 2) chart = json.loads(json.dumps(chart), parse_float=deckdata._Num) return a, header, chart @@ -891,9 +886,8 @@ def test_aptitude_fixture(aptitude_fixture): lambda h: h['shapes'].pop(), lambda h: h.update(plainKind=1), lambda h: h.update(host=''), - lambda h: h['seedRule'].update(batches=[32, 16]), - lambda h: h['seedRule'].update(crossSeeds=0), - lambda h: h['seedRule'].update(relative=deckdata._Num('NaN')), + lambda h: h.update(law=''), + lambda h: h.update(law=None), ]) def test_aptitude_header_rejects(aptitude_fixture, change): a, h, _ = aptitude_fixture @@ -916,16 +910,13 @@ def test_aptitude_header_rejects(aptitude_fixture, change): lambda c: c['gekisouAptitude']['variants'][0].update(score=[deckdata._Num('NaN'), 0]), lambda c: c['gekisouAptitude']['variants'][0].update(score=[10**400, 0]), lambda c: c['gekisouAptitude']['variants'][0].update(score=[1, -1]), - lambda c: c['gekisouAptitude']['variants'][0].update(tail=[7, 1]), + lambda c: c['gekisouAptitude']['variants'][0].update(converted=[0.25, 0]), lambda c: c['gekisouAptitude']['variants'][0].update(tail=[8, 0]), lambda c: c['gekisouAptitude']['variants'][0]['ranges'].pop(), - lambda c: c['gekisouAptitude']['variants'][0].update(seeds=2), - lambda c: c['gekisouAptitude']['variants'][0].update(crossSeeds=2), - lambda c: c['gekisouAptitude']['variants'][2].update(seeds=31), - lambda c: c['gekisouAptitude']['variants'][2].update(seTargetMet=False), + lambda c: c['gekisouAptitude']['variants'][0]['ranges'][0].update(justCount=[1, 0.1]), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(bound=-1), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(predicted=deckdata._Num('NaN')), - lambda c: c['gekisouAptitude']['variants'][0]['check'].update(exact=0), + lambda c: c['gekisouAptitude']['variants'][0]['check'].update(expected=[0, 0]), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(ranks=[0, 2]), lambda c: c['gekisouAptitude']['variants'][0]['check'].update(deck=[[9, 100], None]), ]) @@ -941,8 +932,8 @@ def test_aptitude_rounding(aptitude_fixture): a, h, c = aptitude_fixture aptitude_header(a, h) v = c['gekisouAptitude']['variants'][2] - v['tail'][0] = deckdata._Num('7.002') - a.chart('synthetic', c, 0) # independently rounded terms, two ranges: 0.00300001 absolute tolerance + v['tail'][0] = deckdata._Num('7.000000001') + a.chart('synthetic', c, 0) # binary64 sum tolerance v['tail'][0] = deckdata._Num('7.004') with pytest.raises(musicdata.MusicDataError, match='tail'): a.chart('synthetic', c, 0) @@ -962,9 +953,9 @@ def test_aptitude_disabled(tmp_path): fake = FakeDeck() d = master_dir(tmp_path) musicdata.export(tmp_path / 'music.json', deckdata.master_files(d), KEY, CHARTS.__getitem__, bgm, **PROV, - deck=musicdata.Deck(module=fake, aptitude=False, aptitude_max_seeds=32, aptitude_cross_seeds=8)) + deck=musicdata.Deck(module=fake, aptitude=False)) doc = json.loads((tmp_path / 'music.json').read_bytes()) - assert fake.options == [(False, 32, 8)] + assert fake.options == [False] assert doc['deck']['gekisouAptitude'] is None assert all(c['deck']['gekisouAptitude'] is None for s in doc['songs'] for c in s['charts']) schema_validator().validate(doc) diff --git a/tests/test_replaydata.py b/tests/test_replaydata.py index dd1b02d..e4a1b37 100644 --- a/tests/test_replaydata.py +++ b/tests/test_replaydata.py @@ -159,11 +159,11 @@ def test_recommend_engine_rejects_another_build(tmp_path, change, message): assert not (tmp_path / "music.json").exists() and not (tmp_path / "replay").exists() -def test_final_export_rejects_unmet_sampling_instead_of_publishing_flag(tmp_path): +def test_final_export_checks_the_complete_expectation_interval(tmp_path): def unmet(chart): apt = chart.get("gekisouAptitude") if apt and apt["variants"]: - apt["variants"][0]["seTargetMet"] = False - with pytest.raises(musicdata.MusicDataError, match="did not meet BOTH score SE targets"): + apt["variants"][0]["check"]["expected"] = [2000, 100] + with pytest.raises(musicdata.MusicDataError, match="beyond the bound"): export(tmp_path, deck=FakeDeck(change=unmet)) assert not (tmp_path / "music.json").exists() From 12672e891ca1c06df6d838681325edc9694fe45e Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 10 Oct 2026 12:19:30 +0800 Subject: [PATCH 5/7] style(rust): order chartstats imports Co-Authored-By: Claude Sonnet 5.5 --- rust/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 6405055..793b441 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; -use ournotes_sim::chartstats::{self, ChartStats, REPLAY_SEEDS, Options}; +use ournotes_sim::chartstats::{self, ChartStats, Options, REPLAY_SEEDS}; use ournotes_sim::data::DeckData; use ournotes_sim::error::Error; use pyo3::exceptions::PyValueError; From 7f2c950d4c6008a91be2cbe32f409dc5cddbff82 Mon Sep 17 00:00:00 2001 From: nichinichisou0609 <128159543+nichinichisou0609@users.noreply.github.com> Date: Sat, 10 Oct 2026 05:06:44 +0000 Subject: [PATCH 6/7] build(deck): ournotes-deck v0.0.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 5192313..fbbc12d 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -95,8 +95,8 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "ournotes-sim" -version = "0.0.4" -source = "git+https://github.com/empty-sekai/ournotes-deck?rev=efbf286ad60f98e50c906605e76a1ad67c18ea4b#efbf286ad60f98e50c906605e76a1ad67c18ea4b" +version = "0.0.5" +source = "git+https://github.com/empty-sekai/ournotes-deck?rev=67835811e06f3d528adb90ae64b012a0eb5f8966#67835811e06f3d528adb90ae64b012a0eb5f8966" dependencies = [ "serde", "serde_json", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b3ee408..0a9080a 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 = "efbf286ad60f98e50c906605e76a1ad67c18ea4b" } +ournotes-sim = { git = "https://github.com/empty-sekai/ournotes-deck", rev = "67835811e06f3d528adb90ae64b012a0eb5f8966" } serde_json = { version = "1", features = ["raw_value", "arbitrary_precision"] } [profile.release] From b18312329575f58251916d4472cab86ea4ddb1b9 Mon Sep 17 00:00:00 2001 From: nichinichisou Date: Sat, 10 Oct 2026 13:21:47 +0800 Subject: [PATCH 7/7] ci(music-data): pin the page consumer on the player main commit The chart data page that supports nominal chart-stats/3 is on the main branch of ournotes-player; the music data page smoke gate uses that commit by default. Co-Authored-By: Claude Sonnet 5.5 --- .github/MUSIC_DATA.md | 2 +- .github/workflows/music-data-region.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/MUSIC_DATA.md b/.github/MUSIC_DATA.md index 3bbf05c..365c5a3 100644 --- a/.github/MUSIC_DATA.md +++ b/.github/MUSIC_DATA.md @@ -230,7 +230,7 @@ Repository variables: | Variable | Default | | |---|---|---| -| `MUSIC_DATA_PLAYER_REF` | `946f25c1b50ca1cbb8209ca766056a1a2f6c3aca` | the ournotes-player commit supporting nominal chart-stats/3; custom overrides must pass the same format smoke test before building | +| `MUSIC_DATA_PLAYER_REF` | `44d31754f8b2ba0fe414eaf49586d375ecb3c0d0` | the ournotes-player commit supporting nominal chart-stats/3; custom overrides must pass the same format smoke test before building | | `MUSIC_DATA_PUBLISH` | none: off | `true`: upload; anything else: every run is a dry run | | `MUSIC_DATA_PLAYER_REPOSITORY` | `empty-sekai/ournotes-player` | | | `MUSIC_DATA_REGIONS` | `hk-tw-mo jp` | regions checked independently by dispatch/schedule | diff --git a/.github/workflows/music-data-region.yml b/.github/workflows/music-data-region.yml index 261c13b..e065bac 100644 --- a/.github/workflows/music-data-region.yml +++ b/.github/workflows/music-data-region.yml @@ -32,7 +32,7 @@ env: # This consumer reads nominal chart-stats/3. Explicit overrides must support the same format; the page's modules # smoke-test the new format before master-data download and every completed artifact before publication. PLAYER_REPOSITORY: ${{ vars.MUSIC_DATA_PLAYER_REPOSITORY || 'empty-sekai/ournotes-player' }} - PLAYER_REF: ${{ vars.MUSIC_DATA_PLAYER_REF || '946f25c1b50ca1cbb8209ca766056a1a2f6c3aca' }} + PLAYER_REF: ${{ vars.MUSIC_DATA_PLAYER_REF || '44d31754f8b2ba0fe414eaf49586d375ecb3c0d0' }} PLAYFETCH_VERSION: ${{ vars.PLAYFETCH_VERSION || 'v0.92' }} APK_PACKAGE: ${{ inputs.region == 'jp' && 'com.bushiroad.sirius' || vars.STORY_APK_PACKAGE || 'com.bilibili.sirius' }} PYTHON_VERSION: "3.13"