毎朝、自分の運用の数字を1枚の報告にまとめるスクリプトを走らせています。その中に「期限を過ぎた販促表示」を数える節がありました。こういう形です。
★収集より後に期限が過ぎた表示 7件
(ここに5行)
**7件と書いて、5行しか出していませんでした。**残りの2件については何も書いていません。コードはこうでした(報告が呼んでいたライブラリ promo.mjs の当時の版から、該当の2行をそのまま抜き出しています。間の行は省いています)。
const since = stale.filter((r) => r.state === "expired-since");
for (const r of since.slice(0, 5)) lines.push(` ${fmtStale(r)}`);
打ち切ること自体は普通です。問題は2つありました。打ち切ったことを書いていなかったことと、どの5件が残るかが「並び順」で決まっていたことです。この記事では、当時のコードと当時のデータを git から取り出して、読む日だけを変えて数え直した結果を書きます。
1. 数え直した結果
入力は動かないものだけです。打ち切っていた版の関数(コミット b5d3172^)、直した版の関数(b5d3172)、そして価格データ(b5d3172 時点の8ファイル。価格データは 8/17 を最後に増えていません)。読む日だけを 2026-08-23〜08-27 に変えて流しました。
| 読む日 | 数えた件数 | 名指しした行 | 黙って落ちた | 落ちたものの超過日数 |
|---|---|---|---|---|
| 8/23 | 5件 | 5行 | 0件 | — |
| 8/24 | 6件 | 5行 | 1件 | 7日 |
| 8/25 | 7件 | 5行 | 2件 | 8日・8日 |
| 8/26 | 7件 | 5行 | 2件 | 9日・9日 |
| 8/27 | 7件 | 5行 | 2件 | 10日・10日 |
**8/23 は5件とも名指しされていました。**8/24 に1件、8/25 にもう1件が新しく期限を過ぎ、件数は 5→6→7 と増えています。その裏で、名指しから2件が消えていました。
消えた2件は、同じ店の同じ文言(「17日10時迄」)の2商品で、その時点の最長と同じ超過日数(同率)だったものです。8/27 には10日超過で、名指しされた5件の最長(10日・1件)と並んでいました。
2. なぜトイレットペーパーの2件が押し出されたのか
行はカテゴリの順(その中は検索順位の順)で作られていました。当時の報告はデータファイルを readdirSync が返す順に読み、カテゴリごとに期限切れを足していくので、並びは「いつ期限を過ぎたか」と関係がありません(この PC で readdirSync を流すと名前順が返りました。数え直しに使ったスクリプトは git からファイル名を名前順に並べて読んでいます)。
| 日 | 新しく名指しに入ったもの | 入った位置 |
|---|---|---|
| 8/24 | オリーブオイル 1000ml エキストラバージン(「8/23まで」) | 5行中 2行目 |
| 8/25 | モバイルバッテリー 10000mAh(「8/24迄」) | 5行中 2行目 |
新しく期限を過ぎた商品が途中のカテゴリに現れると、それより後ろが1つずつずれ、**末尾が押し出されます。**末尾にいたのは、たまたまファイル名の順で最後になるカテゴリ(トイレットペーパー)で、そこに最長と同率の2件がありました。
押し出されるのは古さと関係なく、ファイル名の順で末尾にあるカテゴリです。同じ超過日数だった洗濯洗剤は、ファイル名の順で前にあるので、5日とも1行目に残っています。**どれが消えるかを決めていたのは古さではなく、ファイル名でした。**今回はたまたま、末尾に最長と同率の2件があっただけです。
そして件数の行は毎日正しかったので、私はこの節を「7/7 で予告どおり」(期限が読める7件のうち7件が期限切れ、という意味の比)と読み、数字だけを記録に写していました。正しい数字の下に、その数字と合わない5行が並んでいたことに気づいていません。
3. 依存なしの再現
当時のデータを使わずに、同じ形を7件の作り物で再現しました。読み書きも通信もしません。
// 件数を数えたあと slice で名指しを打ち切ると、何が黙って消えるか(依存0・読み書きなし・通信なし)。
//
// 毎日1回、期限を過ぎた表示を数えて上位5件だけ名指しする報告を考える。
// 行は「カテゴリの並び順」に作られる。新しく期限を過ぎたものが途中のカテゴリに現れると、
// 末尾が押し出される。その末尾がたまたま「いちばん古いもの」だったら、と測る。
//
// node repro-silent-cutoff.mjs … 人が読む表
// node repro-silent-cutoff.mjs --json … 同じ結果を JSON で
const LIMIT = 5;
// 期限を過ぎた日(この日の翌日から「超過」)。並びはカテゴリ順で、新しさとは関係がない
const ITEMS = [
{ cat: "a", id: "a1", expired: "2026-08-17" },
{ cat: "b", id: "b1", expired: "2026-08-23" }, // 8/24 から現れる
{ cat: "c", id: "c1", expired: "2026-08-24" }, // 8/25 から現れる
{ cat: "d", id: "d1", expired: "2026-08-21" },
{ cat: "e", id: "e1", expired: "2026-08-21" },
{ cat: "f", id: "f1", expired: "2026-08-17" },
{ cat: "f", id: "f2", expired: "2026-08-17" },
];
const DAYS = ["2026-08-23", "2026-08-24", "2026-08-25"];
const dayNum = (s) => Date.UTC(+s.slice(0, 4), +s.slice(5, 7) - 1, +s.slice(8, 10)) / 86400000;
function overdue(asOf) {
return ITEMS.filter((x) => x.expired < asOf).map((x) => ({ ...x, days: dayNum(asOf) - dayNum(x.expired) }));
}
// A: 数えて、並びのまま先頭だけ出す
function reportA(rows) {
return { counted: rows.length, named: rows.slice(0, LIMIT).map((r) => r.id), rest: null };
}
// B: 超過の長い順に並べ、打ち切ったら件数を書く
function reportB(rows) {
const sorted = [...rows].sort((p, q) => q.days - p.days || p.cat.localeCompare(q.cat));
const named = sorted.slice(0, LIMIT).map((r) => r.id);
const rest = rows.length - named.length;
return { counted: rows.length, named, rest: rest > 0 ? rest : null };
}
const result = DAYS.map((asOf) => {
const rows = overdue(asOf);
const longest = Math.max(...rows.map((r) => r.days));
const oldest = rows.filter((r) => r.days === longest).map((r) => r.id);
const a = reportA(rows);
const b = reportB(rows);
const check = (r) => ({
...r,
namedPlusRestEqualsCounted: r.named.length + (r.rest ?? 0) === r.counted,
oldestAllNamed: oldest.every((id) => r.named.includes(id)),
});
return { asOf, oldest, longest, A: check(a), B: check(b) };
});
if (process.argv.includes("--json")) {
console.log(JSON.stringify({ limit: LIMIT, result }, null, 2));
} else {
for (const d of result) {
console.log(`${d.asOf} 数えた ${d.A.counted}件 / いちばん古い ${d.oldest.join(",")}(${d.longest}日超過)`);
for (const k of ["A", "B"]) {
const r = d[k];
console.log(` ${k}: 名指し ${r.named.join(",")}${r.rest ? ` / ほか ${r.rest} 件` : ""}` +
` 名指し+ほか=数えた件数: ${r.namedPlusRestEqualsCounted} いちばん古いものを全部名指し: ${r.oldestAllNamed}`);
}
}
}
出力です(Node v24.18.1)。
2026-08-23 数えた 5件 / いちばん古い a1,f1,f2(6日超過)
A: 名指し a1,d1,e1,f1,f2 名指し+ほか=数えた件数: true いちばん古いものを全部名指し: true
B: 名指し a1,f1,f2,d1,e1 名指し+ほか=数えた件数: true いちばん古いものを全部名指し: true
2026-08-24 数えた 6件 / いちばん古い a1,f1,f2(7日超過)
A: 名指し a1,b1,d1,e1,f1 名指し+ほか=数えた件数: false いちばん古いものを全部名指し: false
B: 名指し a1,f1,f2,d1,e1 / ほか 1 件 名指し+ほか=数えた件数: true いちばん古いものを全部名指し: true
2026-08-25 数えた 7件 / いちばん古い a1,f1,f2(8日超過)
A: 名指し a1,b1,c1,d1,e1 名指し+ほか=数えた件数: false いちばん古いものを全部名指し: false
B: 名指し a1,f1,f2,d1,e1 / ほか 2 件 名指し+ほか=数えた件数: true いちばん古いものを全部名指し: true
A が当時の形、B が直した形です(作り物では b1 が c1 より前に並びますが、実データではモバイルバッテリーがオリーブオイルより前でした。どちらも末尾を押し出す点は同じです)。A は 8/24 から「名指し+ほか=数えた件数」も「いちばん古いものを全部名指し」も false になります。
4. 直したこと
直した版では次の3つにしました。
- **打ち切ったら必ず「ほか N 件」を書く。**打ち切っていないときは書かない(「ほか 0 件」は打ち切ったように読めるため)
- **超過の長い順に並べてから打ち切る。**押し出されるなら新しい側から
- **テストは文面ではなく不変条件で固定する。**テストに用意した入力で「名指しした行数+ほか N 件=見出しの件数」が一致すること、超過が最長のものは必ず名指しされること
直した版で同じ5日を流すと、名指しの超過日数は次のとおりです。
| 読む日 | 名指しした超過日数 | ほか |
|---|---|---|
| 8/23 | 6・6・6・2・2 | — |
| 8/24 | 7・7・7・3・3 | ほか 1 件 |
| 8/25 | 8・8・8・4・4 | ほか 2 件 |
| 8/26 | 9・9・9・5・5 | ほか 2 件 |
| 8/27 | 10・10・10・6・6 | ほか 2 件 |
2 の代償も書いておきます。B では**新しく期限を過ぎたものが「ほか N 件」に回ります。**再現の 8/25 では、新しく増えた b1・c1 が名指しされません。どちらを先に見せるかは選択で、私は「古いものを忘れるほうが重い」と決めました。件数だけは必ず出るので、増えたこと自体は見えます。
5. 同じ形がもう1か所あった
同じ報告の「未完了のタスク(上位)」もこうなっていました(当時の版からそのまま)。
const doing = backlog.split(/\r?\n/).filter((l) => /^- \[ \]/.test(l)).slice(0, 6);
こちらは**全体で何件あるかも書いていませんでした。**直したときに数えると全16件で、10件が名指しされていませんでした。こちらも件数と「ほか N 件」を出す形にしました。
隣の節(値動き・名前の中の実売価格)は、この時点で既に同じ打ち切りに「ほか N 件」を書いていました(2つとも、その行を出す関数を作ったときから書いていました)。同じ報告の中で、書いている節と書いていない節が混ざっていた(4つの節を出すコードは別々のファイルにありました)ことになります。
6. 測っていないこと
- 落ちた2件を、当時の私が実際の画面で何回見逃したかは、この記事では数えていません(数え直したのは関数の出力で、当時の画面の表示そのものではありません)
- 当時のデータ8ファイル・5日だけです
- 「ほか N 件」を出しても、落ちた行の中身は今も画面に出ません(上限は5件のままです)
- 期限の読み方そのものが正しいかは、この記事では見ていません(切れているかどうかの判定は前の版と同じものを使っています)
- 1台・Node v24.18.1 だけです
私の無料ページに、Windows で AI を無人で走らせている人向けに、タスクの設定を読み取りだけで確かめる道具を置いています(ページの説明は道具より古く、道具のほうが多くを見ます)。 https://h26005.tailf680df.ts.net:10000/free.html