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:
Wanjohi
2026-09-03 18:52:47 +03:00
parent 3dba825f17
commit 1bfdfcf3cf
3 changed files with 283 additions and 59 deletions
+37 -15
View File
@@ -232,7 +232,18 @@ pub fn summary_line(f_: &Full) -> String {
steam::size_band(steam.bytes_on_disk)
));
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 {
put("titles", steam.titles.to_string());
put("gib", format!("{:.0}", steam::gib(steam.bytes_on_disk)));
if steam.launch_samples > 0 {
put(
"hours",
steam
.launch_hours
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(","),
);
put("n", steam.launch_samples.to_string());
let csv = |h: &[u32; 24]| h.iter().map(u32::to_string).collect::<Vec<_>>().join(",");
if steam.titles_sampled > 0 {
// `hours` and `n` keep their names and their meaning so the corpus
// stays continuous, but neither ever meant launches. What is new is
// everything needed to tell a stale histogram from a live one:
// the recent subset, how far back the full one reaches, and which
// of the two `peak` came from.
put("hours", csv(&steam.last_played_hours));
put("n", steam.titles_sampled.to_string());
put("profiles", steam.profiles.to_string());
if steam.launches_uninstalled > 0 {
put("ngone", steam.launches_uninstalled.to_string());
if steam.recent_samples > 0 {
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 {
put("peak", format!("{a}-{b}"));
if let Some(src) = steam.peak_source {
put("peaksrc", src.to_string());
}
}
if !steam.largest.is_empty() {
// 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 {
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");
}
}