Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -177,7 +177,8 @@ ournotes-deck live --data deck-data.json --roster box.json --score SCORE_ID --e
谱面统计:

```sh
ournotes-deck chart-stats --data deck-data.json [--seeds 8] [--charts ID,...] [--jobs N] -o chart-stats.json
ournotes-deck chart-stats --data deck-data.json [--seeds 8] [--charts ID,...] [--jobs N] \
[--stats-cache DIR] -o chart-stats.json
```

谱面统计格式为 `ournotes-deck.chart-stats/3`。激走开启时给出各次抽签、技能判定按名义概率独立的期望,
Expand All @@ -193,6 +194,11 @@ ournotes-deck chart-stats --data deck-data.json [--seeds 8] [--charts ID,...] [-
`--seeds N` 指定幸运谱面的回放种子数量,默认 8,与期望统计无关;没有幸运区间时回放种子为 `[0]`。
`--no-gekisou-aptitude` 关闭单技能适性,保留基线统计。

`--stats-cache DIR` 持久保存各个已编译测量程序及抽签曲线的原始结果,分别复用基线、普通技能位置、
激走变体与交叉项。每项完成后原子保存,可在中断后继续;输入的元数据、kind / shape 目录和校验卡组每次重新生成。
缓存按模型源码和格式隔离,输出与无缓存测量一致;命中与计算计数写到 stderr。
存储格式、依赖边界与 Rust API 见 [谱面统计缓存](docs/chart-stats-cache.md)。

每张谱面的 `expectation` 包含无技能 `score`、`scorePerfect`、区间结果、普通加分技能的 `weights[kind][position]`、
`rangeWeights[kind][position][range]`,以及随机卡组的 `check` / 固定名次的 `rankCheck`。
区间分数、名次奖励、幸运点数和四种抽签结果次数为期望区间;最大连击和 Just 数在证明与抽签独立后用整局模拟精确取得。
Expand Down
27 changes: 21 additions & 6 deletions crates/ournotes-search/src/bin/ournotes-deck.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const USAGE: &str = "usage:
ournotes-deck live --data FILE --roster FILE --score ID --expectation finite --seed-law FILE [--play FILE]
[--gekisou] [common options]
ournotes-deck chart-stats --data FILE [--seeds N] [--no-gekisou-aptitude]
[--charts ID[,ID...]] [--jobs N] [-o FILE]
[--charts ID[,ID...]] [--jobs N] [--stats-cache DIR] [-o FILE]
recommend consumes the JSON recommendation request and writes the unified recommendation result.
Use ournotes-deck recommend --help for its input and progress options.
--data is a deck data file (nnnotes.deck-data/1). live ranks by the expected score of the whole-live simulation
Expand All @@ -44,6 +44,7 @@ Estimates are [center, outward interval half-width] under independent nominal lo
--seeds N controls replay seeds for charts with a luck range (default 8); statistics use nominal expectations.
--no-gekisou-aptitude leaves both aptitude fields null and includes the baseline measurements.
--charts keeps the listed score ids in file order; --jobs N measures N charts at once (default 1).
--stats-cache DIR reuses completed measurement programs and lottery propagations; counters go to stderr.
-o FILE writes JSON to a file; without it JSON goes to stdout.
common options: -k N (default 10), --leader ID, --include ID[,ID...], --exclude ID[,ID...],
--exclude-snaps ID[,ID...], --no-snaps, --time-limit-ms N";
Expand All @@ -57,7 +58,7 @@ fn read(path: &str) -> Result<String, String> {
}

fn chart_stats(args: &[String]) -> Result<Option<serde_json::Value>, String> {
let (mut data, mut seeds, mut out) = (None, None, None);
let (mut data, mut seeds, mut out, mut cache) = (None, None, None, None);
let mut options = ournotes_sim::chartstats::Options::default();
let (mut only, mut jobs): (Option<Vec<i64>>, usize) = (None, 1);
let mut i = 0;
Expand All @@ -73,6 +74,7 @@ fn chart_stats(args: &[String]) -> Result<Option<serde_json::Value>, String> {
"--no-gekisou-aptitude" => options.aptitude = false,
"--charts" => only = Some(ids(&val()?)?),
"--jobs" => jobs = val()?.trim().parse::<usize>().map_err(|_| "bad --jobs".to_string())?.max(1),
"--stats-cache" => cache = Some(val()?),
"-o" | "--out" => out = Some(val()?),
"-h" | "--help" => return Err(USAGE.into()),
other => return Err(format!("unknown option {other}\n{USAGE}")),
Expand All @@ -86,11 +88,20 @@ fn chart_stats(args: &[String]) -> Result<Option<serde_json::Value>, String> {
if let Some(n) = seeds {
options.replay_seeds = n;
}
let cache = cache.map(ournotes_sim::chartstats::ChartStatsCache::new).transpose().map_err(|e| e.to_string())?;
let doc = if jobs <= 1 {
ournotes_sim::chartstats::document_with(&data, &options).map_err(|e| e.to_string())?
match cache.as_ref() {
Some(cache) => ournotes_sim::chartstats::document_with_cache(&data, &options, cache),
None => ournotes_sim::chartstats::document_with(&data, &options),
}
.map_err(|e| e.to_string())
} else {
chart_stats_parallel(data, &options, jobs)?
chart_stats_parallel(data, &options, jobs, cache.as_ref())
};
if let Some(cache) = cache {
eprintln!("chart cache {}", serde_json::to_string(&cache.snapshot()).expect("cache counters"));
}
let doc = doc?;
match out {
Some(path) => {
let mut text = serde_json::to_string(&doc).expect("json");
Expand All @@ -107,6 +118,7 @@ fn chart_stats_parallel(
mut data: DeckData,
options: &ournotes_sim::chartstats::Options,
jobs: usize,
cache: Option<&ournotes_sim::chartstats::ChartStatsCache>,
) -> Result<serde_json::Value, String> {
use ournotes_sim::chartstats;
let charts = std::mem::take(&mut data.charts);
Expand All @@ -122,8 +134,11 @@ fn chart_stats_parallel(
let i = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let Some(c) = charts.get(i) else { break };
let t = std::time::Instant::now();
let r = chartstats::chart_stats_with(&data.master, c, &kinds, options)
.map_err(|e| format!("chart {}: {e}", c.score_id));
let r = match cache {
Some(cache) => chartstats::chart_stats_with_cache(&data.master, c, &kinds, options, cache),
None => chartstats::chart_stats_with(&data.master, c, &kinds, options),
}
.map_err(|e| format!("chart {}: {e}", c.score_id));
eprintln!(
"chart {} {:.1} s{}",
c.score_id,
Expand Down
3 changes: 3 additions & 0 deletions crates/ournotes-search/tests/chartstats.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
#[path = "../../ournotes-sim/tests/common/mod.rs"]
mod common;

#[path = "chartstats/program_cache.rs"]
mod program_cache;

use ournotes_sim::chartstats::{self, POWER};
use ournotes_sim::data::{DeckData, FORMAT};
use ournotes_sim::live::full::{self, GekisouSetup, LiveNote, LiveParams, Performer};
Expand Down
170 changes: 170 additions & 0 deletions crates/ournotes-search/tests/chartstats/program_cache.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//! Native cache regressions: current documents agree byte for byte with independent measurements while
//! unchanged compiled programs remain reusable when the public kind and shape catalogs grow.

use super::*;

use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use chartstats::{ChartStatsCache, ChartStatsCacheStats, Options};

static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);

struct Directory(PathBuf);

impl Directory {
fn new() -> Self {
let time = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
Self(std::env::temp_dir().join(format!(
"ournotes-chart-cache-integration-{}-{time}-{}",
std::process::id(),
NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed)
)))
}
}

impl Drop for Directory {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}

fn cached(data: &DeckData, path: &Path) -> (Value, ChartStatsCacheStats) {
// A new instance for every build proves that reuse comes from committed records on disk.
let cache = ChartStatsCache::new(path).unwrap();
let doc = chartstats::document_with_cache(data, &Options::default(), &cache).unwrap();
(doc, cache.snapshot())
}

fn assert_document_bytes(actual: &Value, expected: &Value) {
assert_eq!(serde_json::to_vec(actual).unwrap(), serde_json::to_vec(expected).unwrap());
}

fn incremental(before: &DeckData, after: &DeckData) -> (Value, Value, ChartStatsCacheStats) {
let directory = Directory::new();
let (old, _) = cached(before, &directory.0);
let (current, reused) = cached(after, &directory.0);
let independent = chartstats::document_with(after, &Options::default()).unwrap();
assert_document_bytes(&current, &independent);
let cold_directory = Directory::new();
let (cold_doc, cold) = cached(after, &cold_directory.0);
assert_document_bytes(&current, &cold_doc);
assert!(reused.hits > 0, "unchanged programs must be reused: {reused:?}");
assert!(reused.computed > 0, "changed programs must be measured: {reused:?}");
assert!(reused.computed < cold.computed, "incremental {reused:?}, cold {cold:?}");
assert_eq!(reused.invalid, 0);
(old, current, reused)
}

#[test]
fn metadata_and_growth_changes_reuse_all_programs_with_current_headers() {
let before = aptitude_data(&APT_FEVERS);
let directory = Directory::new();
let (old, first) = cached(&before, &directory.0);
assert!(first.computed > 0);
let (warm, second) = cached(&before, &directory.0);
assert_document_bytes(&warm, &old);
assert_eq!(second.computed, 0);
assert_eq!(second.writes, 0);

let mut after = before.clone();
after.provenance["master"]["version"] = json!("v2");
after.provenance["exporter"]["version"] = json!("1");
after.sha256 = Some("1".repeat(64));
for row in &mut after.master.member_card_levels {
row.performance_rate += 17;
row.exp += 1;
}
for row in &mut after.master.support_card_levels {
row.visual_rate += 19;
}
let (current, reused) = cached(&after, &directory.0);
assert_eq!(reused.computed, 0, "{reused:?}");
assert_eq!(reused.writes, 0);
assert_eq!(reused.invalid, 0);
assert!(reused.hits > 0);
assert_eq!(current["source"]["master"]["version"], "v2");
assert_eq!(current["source"]["exporter"]["version"], "1");
assert_eq!(current["charts"], old["charts"]);
assert_document_bytes(&current, &chartstats::document_with(&after, &Options::default()).unwrap());
}

#[test]
fn adding_an_ordinary_kind_preserves_previous_programs() {
let before = aptitude_data(&APT_FEVERS);
let mut after = before.clone();
let mut row = after.master.live_skill_effects[0].clone();
row.id = after.master.live_skill_effects.iter().map(|r| r.id).max().unwrap() + 100;
row.live_skill_id = 90_001;
row.activation_time_second += 0.125;
after.master.live_skill_effects.push(row);
after.master.reindex().unwrap();
assert_eq!(chartstats::kinds(&after.master).len(), chartstats::kinds(&before.master).len() + 1);

let (old, current, _) = incremental(&before, &after);
let old_weights = old["charts"][0]["expectation"]["weights"].as_array().unwrap();
let current_weights = current["charts"][0]["expectation"]["weights"].as_array().unwrap();
assert_eq!(&current_weights[..old_weights.len()], old_weights);
assert_eq!(current["gekisouAptitude"], old["gekisouAptitude"]);
}

#[test]
fn changing_values_reuses_unit_weights_and_regenerates_checks() {
let before = aptitude_data(&APT_FEVERS);
let mut after = before.clone();
for row in &mut after.master.live_skill_effects {
row.effect_value += 137;
}
let (old, current, _) = incremental(&before, &after);
assert_eq!(chartstats::kinds(&before.master).len(), chartstats::kinds(&after.master).len());
assert_ne!(current["kinds"], old["kinds"]);
assert_eq!(current["charts"][0]["expectation"]["weights"], old["charts"][0]["expectation"]["weights"]);
assert_ne!(current["charts"][0]["expectation"]["check"], old["charts"][0]["expectation"]["check"]);
}

#[test]
fn adding_a_member_shape_reuses_support_measurements_after_their_indices_move() {
let before = aptitude_data(&APT_FEVERS);
let old_shapes = chartstats::shapes(&before.master);
let old_support = shape_for(&old_shapes, "support", 1);
let mut after = before.clone();
let mut skill = after.master.gekisou_skills.iter().find(|r| r.id == 1).unwrap().clone();
skill.id = 90_001;
after.master.gekisou_skills.push(skill);
let mut effect =
after.master.gekisou_skill_effects.iter().find(|r| r.skill_id == 1 && r.level == 3).unwrap().clone();
effect.id = after.master.gekisou_skill_effects.iter().map(|r| r.id).max().unwrap() + 100;
effect.skill_id = 90_001;
effect.effect_value += 2;
after.master.gekisou_skill_effects.push(effect);
// Another card still exposes the original skill of this duplicate card.
after.master.member_cards.last_mut().unwrap().gekisou_skill_id = 90_001;
after.master.reindex().unwrap();
let current_shapes = chartstats::shapes(&after.master);
let current_support = shape_for(&current_shapes, "support", 1);
assert_eq!(current_shapes.len(), old_shapes.len() + 1);
assert_eq!(current_support, old_support + 1);

let (old, current, _) = incremental(&before, &after);
let variants = |doc: &Value, id: usize| {
doc["charts"][0]["gekisouAptitude"]["variants"]
.as_array()
.unwrap()
.iter()
.filter(|v| v["shape"] == id)
.cloned()
.collect::<Vec<_>>()
};
let old_variants = variants(&old, old_support);
let current_variants = variants(&current, current_support);
assert_eq!(old_variants.len(), 2);
assert_eq!(current_variants.len(), 2);
assert!(old_variants.iter().zip(&current_variants).any(|(a, b)| a["check"] != b["check"]));
for (mut a, mut b) in old_variants.into_iter().zip(current_variants) {
for key in ["shape", "check"] {
a.as_object_mut().unwrap().remove(key);
b.as_object_mut().unwrap().remove(key);
}
assert_document_bytes(&a, &b);
}
}
Loading
Loading