mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
fix(nesdoctor): "launch records" were never launches
Steam keeps one LastPlayed per title, so the hour-of-day histogram holds one sample per *title* — at the hour it was last closed, over the whole life of the library. It was labelled and reported as a launch histogram, and the module header claimed a library of eighty games is "eighty samples of what hour this person launches a game at — a real distribution". It is not. The bias has a direction, and everything pushes the same way: a title played once years ago weighs exactly as much as a daily driver, a daily driver contributes one sample ever, and an afternoon spent installing and trying a dozen games stamps a dozen titles with that afternoon's hour. So the metric over-weights trying and under-weights playing. On the production host it reads n=331 with 329 titles no longer installed; on this dev machine, 28 titles spanning 570 days. Corrected rather than disclaimed, because the direction is knowable: - Fields say what they hold — last_played_hours, titles_sampled, and no "launch" anywhere. The display says "when you last played each game — 28 titles, local time, reaching back 19 months". - A second histogram over titles played in the last 30 days, which is one sample per title still in use, and the peak window prefers it when it has the samples to claim a shape. - Which histogram the peak came from is stated in the output and on the wire (peaksrc=30d|all), and the summary line carries the sample count the window was actually computed from, so a narrow peak drawn from nine titles cannot borrow the authority of three hundred. - New keys hours30, n30, nspan, playh. hours/n/peak keep their names and meaning so the corpus stays continuous; submissions without peaksrc are whole-library by construction. Playtime is read and reported but deliberately not used as a weight: it is a lifetime total against a single timestamp, so weighting by it would multiply one arbitrary hour by five hundred. Five tests, including the wrapping midnight window, which is the case a non-wrapping scan gets wrong and exactly the evening peak 0017 is about.
This commit is contained in:
@@ -485,23 +485,54 @@ fn print_steam(s: &steam::SteamReport) {
|
|||||||
);
|
);
|
||||||
println!(" \x1b[2mthem added together, which would be a histogram of nobody.\x1b[0m");
|
println!(" \x1b[2mthem added together, which would be a histogram of nobody.\x1b[0m");
|
||||||
}
|
}
|
||||||
if s.launch_samples > 0 {
|
if s.titles_sampled > 0 {
|
||||||
|
// Steam stores one timestamp per title, so this says "titles", not
|
||||||
|
// "launches". It was labelled launches until 2026-09-03 and the label
|
||||||
|
// was doing real damage: it made a library's history look like a week's
|
||||||
|
// behaviour.
|
||||||
println!(
|
println!(
|
||||||
"\n When you launch games — {} launch records{}, local time:",
|
"\n When you last played each game — {} titles, local time{}{}:",
|
||||||
s.launch_samples,
|
s.titles_sampled,
|
||||||
if s.launches_uninstalled > 0 {
|
match s.sample_span_days {
|
||||||
|
Some(d) if d >= 60 => format!(", reaching back {} months", d / 30),
|
||||||
|
Some(d) => format!(", reaching back {d} days"),
|
||||||
|
None => String::new(),
|
||||||
|
},
|
||||||
|
if s.sampled_uninstalled > 0 {
|
||||||
format!(
|
format!(
|
||||||
", {} of them games you no longer have installed",
|
"; {} of them you no longer have installed",
|
||||||
s.launches_uninstalled
|
s.sampled_uninstalled
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
String::new()
|
String::new()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
println!(" {}", steam::sparkline(&s.launch_hours));
|
println!(" {}", steam::sparkline(&s.last_played_hours));
|
||||||
println!(" \x1b[2m0h 6h 12h 18h 23h\x1b[0m");
|
println!(" \x1b[2m0h 6h 12h 18h 23h\x1b[0m");
|
||||||
|
if s.recent_samples > 0 {
|
||||||
|
println!(
|
||||||
|
"\n \x1b[2mAnd the {} still played in the last 30 days, which is the\x1b[0m",
|
||||||
|
s.recent_samples
|
||||||
|
);
|
||||||
|
println!(" \x1b[2mone that describes now rather than the whole library:\x1b[0m");
|
||||||
|
println!(" {}", steam::sparkline(&s.recent_hours));
|
||||||
|
}
|
||||||
if let Some((a, b)) = s.peak_window {
|
if let Some((a, b)) = s.peak_window {
|
||||||
println!(" Half of your launches fall between \x1b[1m{a:02}:00 and {b:02}:59\x1b[0m.");
|
let scope = if s.peak_source == Some("30d") {
|
||||||
|
"the games you still play"
|
||||||
|
} else {
|
||||||
|
"your library"
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" Half of {scope} was last played between \x1b[1m{a:02}:00 and {b:02}:59\x1b[0m."
|
||||||
|
);
|
||||||
|
if s.peak_source != Some("30d") {
|
||||||
|
println!(
|
||||||
|
" \x1b[2mFewer than {} titles in the last 30 days, so that is the whole\x1b[0m",
|
||||||
|
steam::MIN_SAMPLES
|
||||||
|
);
|
||||||
|
println!(" \x1b[2mlibrary's history, not a picture of this month.\x1b[0m");
|
||||||
|
}
|
||||||
let width = if b >= a { b - a + 1 } else { 24 - a + b + 1 };
|
let width = if b >= a { b - a + 1 } else { 24 - a + b + 1 };
|
||||||
if width <= 6 {
|
if width <= 6 {
|
||||||
println!(
|
println!(
|
||||||
|
|||||||
@@ -232,7 +232,18 @@ pub fn summary_line(f_: &Full) -> String {
|
|||||||
steam::size_band(steam.bytes_on_disk)
|
steam::size_band(steam.bytes_on_disk)
|
||||||
));
|
));
|
||||||
if let Some((s, e)) = steam.peak_window {
|
if let Some((s, e)) = steam.peak_window {
|
||||||
f.push(format!("plays={s:02}-{e:02}h n={}", steam.launch_samples));
|
// The sample count shown is the one the window was actually
|
||||||
|
// computed from, so a narrow peak drawn from nine titles cannot
|
||||||
|
// borrow the authority of three hundred.
|
||||||
|
let n = if steam.peak_source == Some("30d") {
|
||||||
|
steam.recent_samples
|
||||||
|
} else {
|
||||||
|
steam.titles_sampled
|
||||||
|
};
|
||||||
|
f.push(format!(
|
||||||
|
"plays={s:02}-{e:02}h n={n}/{}",
|
||||||
|
steam.peak_source.unwrap_or("?")
|
||||||
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,24 +539,35 @@ pub fn submit_url(base: &str, f_: &Full) -> String {
|
|||||||
if steam.found {
|
if steam.found {
|
||||||
put("titles", steam.titles.to_string());
|
put("titles", steam.titles.to_string());
|
||||||
put("gib", format!("{:.0}", steam::gib(steam.bytes_on_disk)));
|
put("gib", format!("{:.0}", steam::gib(steam.bytes_on_disk)));
|
||||||
if steam.launch_samples > 0 {
|
let csv = |h: &[u32; 24]| h.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
|
||||||
put(
|
if steam.titles_sampled > 0 {
|
||||||
"hours",
|
// `hours` and `n` keep their names and their meaning so the corpus
|
||||||
steam
|
// stays continuous, but neither ever meant launches. What is new is
|
||||||
.launch_hours
|
// everything needed to tell a stale histogram from a live one:
|
||||||
.iter()
|
// the recent subset, how far back the full one reaches, and which
|
||||||
.map(u32::to_string)
|
// of the two `peak` came from.
|
||||||
.collect::<Vec<_>>()
|
put("hours", csv(&steam.last_played_hours));
|
||||||
.join(","),
|
put("n", steam.titles_sampled.to_string());
|
||||||
);
|
|
||||||
put("n", steam.launch_samples.to_string());
|
|
||||||
put("profiles", steam.profiles.to_string());
|
put("profiles", steam.profiles.to_string());
|
||||||
if steam.launches_uninstalled > 0 {
|
if steam.recent_samples > 0 {
|
||||||
put("ngone", steam.launches_uninstalled.to_string());
|
put("hours30", csv(&steam.recent_hours));
|
||||||
|
put("n30", steam.recent_samples.to_string());
|
||||||
|
}
|
||||||
|
if let Some(d) = steam.sample_span_days {
|
||||||
|
put("nspan", d.to_string());
|
||||||
|
}
|
||||||
|
if steam.playtime_minutes > 0 {
|
||||||
|
put("playh", format!("{}", steam.playtime_minutes / 60));
|
||||||
|
}
|
||||||
|
if steam.sampled_uninstalled > 0 {
|
||||||
|
put("ngone", steam.sampled_uninstalled.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some((a, b)) = steam.peak_window {
|
if let Some((a, b)) = steam.peak_window {
|
||||||
put("peak", format!("{a}-{b}"));
|
put("peak", format!("{a}-{b}"));
|
||||||
|
if let Some(src) = steam.peak_source {
|
||||||
|
put("peaksrc", src.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !steam.largest.is_empty() {
|
if !steam.largest.is_empty() {
|
||||||
// Whether the title distribution has a head decides whether a depot
|
// Whether the title distribution has a head decides whether a depot
|
||||||
@@ -645,7 +667,7 @@ pub fn submit_contents(steam: &SteamReport, answers: &Answers) -> Vec<&'static s
|
|||||||
];
|
];
|
||||||
if steam.found && steam.titles > 0 {
|
if steam.found && steam.titles > 0 {
|
||||||
v.push("how many games are installed, their total size, and your five largest");
|
v.push("how many games are installed, their total size, and your five largest");
|
||||||
if steam.launch_samples > 0 {
|
if steam.titles_sampled > 0 {
|
||||||
v.push("the hour-of-day histogram above — hours, never dates");
|
v.push("the hour-of-day histogram above — hours, never dates");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,38 @@
|
|||||||
//! | *how big is a library?* | the sum of those sizes, which is the content store's cost |
|
//! | *how big is a library?* | the sum of those sizes, which is the content store's cost |
|
||||||
//! | *when do you play?* | `localconfig.vdf` — `LastPlayed` per title, as an hour-of-day histogram |
|
//! | *when do you play?* | `localconfig.vdf` — `LastPlayed` per title, as an hour-of-day histogram |
|
||||||
//!
|
//!
|
||||||
//! The third is the interesting one. Steam keeps one `LastPlayed` timestamp per
|
//! # The third one is biased, and this is the shape of the bias
|
||||||
//! title, so a library of eighty games is **eighty samples of what hour this
|
//!
|
||||||
//! person launches a game at** — a real distribution, taken without asking, and
|
//! This module used to claim that a library of eighty games is *"eighty samples
|
||||||
//! the thing a demand trough is made of. It is biased toward whatever
|
//! of what hour this person launches a game at — a real distribution"*. **It is
|
||||||
//! they played most recently and it is not a schedule; it is a sample, and it is
|
//! not.** Steam keeps one `LastPlayed` per title, so what the histogram holds is
|
||||||
//! reported as one.
|
//! one sample per *title*, at the hour that title was last closed, over the
|
||||||
|
//! whole life of the library. Three things follow, and all three push the same
|
||||||
|
//! way:
|
||||||
|
//!
|
||||||
|
//! - **A title played once, years ago, weighs exactly as much as a daily
|
||||||
|
//! driver.** Measured on one dev machine: eight titles, eight samples,
|
||||||
|
//! spanning back to 2025-09-04.
|
||||||
|
//! - **A daily driver contributes one sample, ever**, at whatever hour it
|
||||||
|
//! happened to be closed last. Five hundred hours of play, one point.
|
||||||
|
//! - **So the metric over-weights trying and under-weights playing.** An
|
||||||
|
//! afternoon spent installing and sampling a dozen games stamps a dozen
|
||||||
|
//! titles with that afternoon's hour; every one of them outvotes the game
|
||||||
|
//! somebody actually plays every evening. On the production host this reads
|
||||||
|
//! `n=331` with **329 titles no longer installed** — a histogram that is
|
||||||
|
//! mostly the archaeology of a library, presented as current behaviour.
|
||||||
|
//!
|
||||||
|
//! The direction is knowable, so it is corrected rather than disclaimed. Two
|
||||||
|
//! histograms are reported: the whole library, as before, and **titles played
|
||||||
|
//! within [`RECENT_DAYS`]**, which is one sample per title still in use and is
|
||||||
|
//! the one the peak window prefers when it has the samples for it. Which one
|
||||||
|
//! the peak came from is stated in the output and on the wire (`peaksrc=`),
|
||||||
|
//! because a corpus that cannot tell them apart is a corpus that will mix them.
|
||||||
|
//!
|
||||||
|
//! What no local file supports is weighting a sample by how much a title is
|
||||||
|
//! played: `Playtime` is a lifetime total against a single timestamp, so
|
||||||
|
//! weighting by it would multiply one arbitrary hour by five hundred. It is
|
||||||
|
//! read and reported, and it is not used as a weight.
|
||||||
//!
|
//!
|
||||||
//! # This is somebody's private library
|
//! # This is somebody's private library
|
||||||
//!
|
//!
|
||||||
@@ -27,6 +53,7 @@
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
@@ -41,21 +68,40 @@ pub struct SteamReport {
|
|||||||
/// The five largest, by size on disk. Included because library *shape* is
|
/// The five largest, by size on disk. Included because library *shape* is
|
||||||
/// what decides whether a depot cache has a head to cache.
|
/// what decides whether a depot cache has a head to cache.
|
||||||
pub largest: Vec<(String, u64)>,
|
pub largest: Vec<(String, u64)>,
|
||||||
/// Count of `LastPlayed` timestamps falling in each local hour, 0–23.
|
/// Count of `LastPlayed` timestamps falling in each local hour, 0–23, one
|
||||||
pub launch_hours: [u32; 24],
|
/// per title across the whole library. See the module header: this is a
|
||||||
pub launch_samples: usize,
|
/// per-*title* sample, not a per-launch one.
|
||||||
|
pub last_played_hours: [u32; 24],
|
||||||
|
/// Titles with a usable `LastPlayed`. **Not a launch count** — it was
|
||||||
|
/// reported as one until 2026-09-03 and it never was.
|
||||||
|
pub titles_sampled: usize,
|
||||||
|
/// The same histogram restricted to titles played within [`RECENT_DAYS`].
|
||||||
|
/// One sample per title still in use, which is the closest this data gets
|
||||||
|
/// to current behaviour.
|
||||||
|
pub recent_hours: [u32; 24],
|
||||||
|
pub recent_samples: usize,
|
||||||
|
/// Lifetime `Playtime`, in minutes, summed over sampled titles. Reported
|
||||||
|
/// for scale; deliberately *not* used to weight the histogram.
|
||||||
|
pub playtime_minutes: u64,
|
||||||
|
/// How far back the whole-library histogram reaches, in days. A histogram
|
||||||
|
/// spanning two years is a different object from one spanning a month.
|
||||||
|
pub sample_span_days: Option<u32>,
|
||||||
/// How many Steam user profiles were found on this machine. More than one
|
/// How many Steam user profiles were found on this machine. More than one
|
||||||
/// means more than one person may use it, and the histogram is taken from
|
/// means more than one person may use it, and the histogram is taken from
|
||||||
/// the busiest profile rather than summed across strangers.
|
/// the busiest profile rather than summed across strangers.
|
||||||
pub profiles: usize,
|
pub profiles: usize,
|
||||||
/// Launch records whose title is not installed any more. Kept in the
|
/// Sampled titles that are not installed any more. Kept in the whole-library
|
||||||
/// histogram on purpose — a game someone uninstalled is still a real
|
/// histogram on purpose — someone did play them once — and reported so `n`
|
||||||
/// record of when they play — and reported so `n` cannot be mistaken for
|
/// cannot be mistaken for the installed-title count. When this dwarfs
|
||||||
/// the installed-title count.
|
/// `titles`, the whole-library histogram is mostly history.
|
||||||
pub launches_uninstalled: usize,
|
pub sampled_uninstalled: usize,
|
||||||
/// Hours covering half of all launches, contiguous and wrapping — the
|
/// Hours covering half the samples, contiguous and wrapping — the "evening
|
||||||
/// "evening peak" if there is one.
|
/// peak" if there is one. Taken from [`Self::recent_hours`] when that has
|
||||||
|
/// enough samples to claim a shape, and from the whole library otherwise.
|
||||||
pub peak_window: Option<(u32, u32)>,
|
pub peak_window: Option<(u32, u32)>,
|
||||||
|
/// Which histogram [`Self::peak_window`] came from: `"30d"` or `"all"`.
|
||||||
|
/// Never infer it from the sample counts; read it here.
|
||||||
|
pub peak_source: Option<&'static str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Proton builds, runtimes and redistributables are installed like games and
|
/// Proton builds, runtimes and redistributables are installed like games and
|
||||||
@@ -212,7 +258,15 @@ pub fn read() -> SteamReport {
|
|||||||
// **Profiles are not one person.** A shared machine has several, and
|
// **Profiles are not one person.** A shared machine has several, and
|
||||||
// summing them produces a histogram of nobody. The busiest profile is used
|
// summing them produces a histogram of nobody. The busiest profile is used
|
||||||
// and the count is reported, so a two-profile machine is visible as one.
|
// and the count is reported, so a two-profile machine is visible as one.
|
||||||
let mut per_profile: Vec<([u32; 24], usize, usize)> = Vec::new();
|
let mut per_profile: Vec<ProfileSample> = Vec::new();
|
||||||
|
|
||||||
|
// Wall clock, once. If it is unavailable the recent subset stays empty and
|
||||||
|
// the peak falls back to the whole library, labelled `all` — degraded, and
|
||||||
|
// never silently mislabelled.
|
||||||
|
let now = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.ok()
|
||||||
|
.map(|d| d.as_secs());
|
||||||
for root in &roots {
|
for root in &roots {
|
||||||
let Ok(users) = fs::read_dir(root.join("userdata")) else {
|
let Ok(users) = fs::read_dir(root.join("userdata")) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -230,9 +284,7 @@ pub fn read() -> SteamReport {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut hours = [0u32; 24];
|
let mut p = ProfileSample::default();
|
||||||
let mut n = 0usize;
|
|
||||||
let mut gone = 0usize;
|
|
||||||
for (appid, app) in apps {
|
for (appid, app) in apps {
|
||||||
let Some(ts) = app.get(&["LastPlayed"]).and_then(vdf::Value::as_u64) else {
|
let Some(ts) = app.get(&["LastPlayed"]).and_then(vdf::Value::as_u64) else {
|
||||||
continue;
|
continue;
|
||||||
@@ -244,33 +296,92 @@ pub fn read() -> SteamReport {
|
|||||||
// A tool Steam launched, not a person playing.
|
// A tool Steam launched, not a person playing.
|
||||||
Some((_, true)) => continue,
|
Some((_, true)) => continue,
|
||||||
Some((_, false)) => {}
|
Some((_, false)) => {}
|
||||||
// Not installed now. A real launch by a real person, and
|
// Not installed now. Someone did play it once, so it stays
|
||||||
// counted — but counted separately so `n` is never read as
|
// in the whole-library histogram — but it is counted
|
||||||
// the installed-title count.
|
// separately, because a large count here is the signal that
|
||||||
None => gone += 1,
|
// that histogram is history rather than behaviour.
|
||||||
|
None => p.gone += 1,
|
||||||
}
|
}
|
||||||
if let Some(h) = local_hour(ts) {
|
let Some(h) = local_hour(ts) else { continue };
|
||||||
hours[h as usize] += 1;
|
p.hours[h as usize] += 1;
|
||||||
n += 1;
|
p.n += 1;
|
||||||
|
p.playtime += app
|
||||||
|
.get(&["Playtime"])
|
||||||
|
.and_then(vdf::Value::as_u64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
p.oldest = Some(p.oldest.map_or(ts, |o: u64| o.min(ts)));
|
||||||
|
// Recent means "still in use". Age is taken from the timestamp
|
||||||
|
// itself rather than from `Playtime2wks`, which is absent on
|
||||||
|
// most entries and so would silently shrink the subset.
|
||||||
|
if now.is_some_and(|now| now.saturating_sub(ts) <= RECENT_DAYS * 86_400) {
|
||||||
|
p.recent_hours[h as usize] += 1;
|
||||||
|
p.recent_n += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if n > 0 {
|
if p.n > 0 {
|
||||||
per_profile.push((hours, n, gone));
|
per_profile.push(p);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
r.profiles = per_profile.len();
|
r.profiles = per_profile.len();
|
||||||
if let Some((hours, n, gone)) = per_profile.into_iter().max_by_key(|(_, n, _)| *n) {
|
if let Some(p) = per_profile.into_iter().max_by_key(|p| p.n) {
|
||||||
r.launch_hours = hours;
|
r.last_played_hours = p.hours;
|
||||||
r.launch_samples = n;
|
r.titles_sampled = p.n;
|
||||||
r.launches_uninstalled = gone;
|
r.sampled_uninstalled = p.gone;
|
||||||
|
r.recent_hours = p.recent_hours;
|
||||||
|
r.recent_samples = p.recent_n;
|
||||||
|
r.playtime_minutes = p.playtime;
|
||||||
|
r.sample_span_days = match (now, p.oldest) {
|
||||||
|
(Some(now), Some(oldest)) => Some((now.saturating_sub(oldest) / 86_400) as u32),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
r.peak_window = peak_window(&r.launch_hours, r.launch_samples);
|
(r.peak_window, r.peak_source) = choose_peak(
|
||||||
|
(&r.recent_hours, r.recent_samples),
|
||||||
|
(&r.last_played_hours, r.titles_sampled),
|
||||||
|
);
|
||||||
r
|
r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefer the subset that describes current behaviour, and say which was used.
|
||||||
|
///
|
||||||
|
/// Falling back to the whole library is better than reporting nothing, but the
|
||||||
|
/// two answer different questions — *when does this person play* against *when
|
||||||
|
/// was this library touched, ever* — so a window must never arrive unlabelled.
|
||||||
|
fn choose_peak(
|
||||||
|
recent: (&[u32; 24], usize),
|
||||||
|
all: (&[u32; 24], usize),
|
||||||
|
) -> (Option<(u32, u32)>, Option<&'static str>) {
|
||||||
|
if let Some(w) = peak_window(recent.0, recent.1) {
|
||||||
|
return (Some(w), Some("30d"));
|
||||||
|
}
|
||||||
|
match peak_window(all.0, all.1) {
|
||||||
|
Some(w) => (Some(w), Some("all")),
|
||||||
|
None => (None, None),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How recent a `LastPlayed` has to be for its title to count as still in use.
|
||||||
|
///
|
||||||
|
/// Thirty days rather than Steam's own two-week `Playtime2wks`: two weeks is
|
||||||
|
/// below [`MIN_SAMPLES`] for most libraries, and a subset too small to claim a
|
||||||
|
/// shape is a subset that silently falls back to the biased histogram.
|
||||||
|
const RECENT_DAYS: u64 = 30;
|
||||||
|
|
||||||
|
/// One profile's tally, before the busiest is chosen.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct ProfileSample {
|
||||||
|
hours: [u32; 24],
|
||||||
|
n: usize,
|
||||||
|
gone: usize,
|
||||||
|
recent_hours: [u32; 24],
|
||||||
|
recent_n: usize,
|
||||||
|
playtime: u64,
|
||||||
|
oldest: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Hour of day, in the machine's local time, for a Unix timestamp.
|
/// Hour of day, in the machine's local time, for a Unix timestamp.
|
||||||
///
|
///
|
||||||
/// Done with the offset the OS reports rather than a timezone crate: we need
|
/// Done with the offset the OS reports rather than a timezone crate: we need
|
||||||
@@ -301,11 +412,14 @@ fn utc_offset_seconds() -> Option<i64> {
|
|||||||
Some(sign * (h * 3600 + m * 60))
|
Some(sign * (h * 3600 + m * 60))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fewer samples than this cannot claim a shape, so no peak is reported.
|
||||||
|
pub const MIN_SAMPLES: usize = 8;
|
||||||
|
|
||||||
/// The shortest contiguous, wrapping run of hours holding at least half the
|
/// The shortest contiguous, wrapping run of hours holding at least half the
|
||||||
/// launches. That is the honest form of "when do you play": if it is four hours
|
/// samples. That is the honest form of "when do you play": if it is four hours
|
||||||
/// wide there is an evening peak, and if it takes fourteen there is not.
|
/// wide there is an evening peak, and if it takes fourteen there is not.
|
||||||
fn peak_window(hours: &[u32; 24], total: usize) -> Option<(u32, u32)> {
|
fn peak_window(hours: &[u32; 24], total: usize) -> Option<(u32, u32)> {
|
||||||
if total < 8 {
|
if total < MIN_SAMPLES {
|
||||||
return None; // too few samples to claim a shape
|
return None; // too few samples to claim a shape
|
||||||
}
|
}
|
||||||
let half = (total as f64 / 2.0).ceil() as u32;
|
let half = (total as f64 / 2.0).ceil() as u32;
|
||||||
@@ -366,3 +480,60 @@ pub fn size_band(bytes: u64) -> &'static str {
|
|||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub fn debug_map(_m: &BTreeMap<String, vdf::Value>) {}
|
pub fn debug_map(_m: &BTreeMap<String, vdf::Value>) {}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn hist(pairs: &[(usize, u32)]) -> ([u32; 24], usize) {
|
||||||
|
let mut h = [0u32; 24];
|
||||||
|
for &(hour, n) in pairs {
|
||||||
|
h[hour] += n;
|
||||||
|
}
|
||||||
|
(h, h.iter().sum::<u32>() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_recent_subset_wins_when_it_can_claim_a_shape() {
|
||||||
|
// 8 samples, half is 4, and no single hour holds 4 — so the window is
|
||||||
|
// genuinely two hours wide and not an artefact of one tall bar.
|
||||||
|
let (recent, rn) = hist(&[(22, 3), (23, 3), (2, 2)]);
|
||||||
|
let (all, an) = hist(&[(14, 200), (22, 3), (23, 3), (2, 2)]);
|
||||||
|
let (w, src) = choose_peak((&recent, rn), (&all, an));
|
||||||
|
assert_eq!(src, Some("30d"));
|
||||||
|
assert_eq!(w, Some((22, 23)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_thin_recent_subset_falls_back_and_says_so() {
|
||||||
|
// Three titles this month cannot outvote a library; the point is that
|
||||||
|
// the fallback is *labelled*, not that it is avoided.
|
||||||
|
let (recent, rn) = hist(&[(22, 3)]);
|
||||||
|
let (all, an) = hist(&[(14, 40)]);
|
||||||
|
let (w, src) = choose_peak((&recent, rn), (&all, an));
|
||||||
|
assert_eq!(src, Some("all"));
|
||||||
|
assert_eq!(w, Some((14, 14)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_samples_means_no_window_and_no_source() {
|
||||||
|
let empty = [0u32; 24];
|
||||||
|
assert_eq!(choose_peak((&empty, 0), (&empty, 0)), (None, None));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_peak_is_never_claimed_from_too_few_samples() {
|
||||||
|
let (h, n) = hist(&[(20, (MIN_SAMPLES - 1) as u32)]);
|
||||||
|
assert_eq!(peak_window(&h, n), None);
|
||||||
|
let (h, n) = hist(&[(20, MIN_SAMPLES as u32)]);
|
||||||
|
assert_eq!(peak_window(&h, n), Some((20, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_window_is_the_shortest_wrapping_run_holding_half() {
|
||||||
|
// Straddling midnight is the case a non-wrapping scan gets wrong, and
|
||||||
|
// it is exactly the evening peak 0017 is about.
|
||||||
|
let (h, n) = hist(&[(23, 6), (0, 6), (12, 1), (13, 1)]);
|
||||||
|
assert_eq!(peak_window(&h, n), Some((23, 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user