0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

毎朝の報告が「7件」と数えて5件しか名指ししていなかった。slice(0, 5) で消えていたのは、最長と同率の2件だった

0
Posted at

毎朝、自分の運用の数字を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つにしました。

  1. **打ち切ったら必ず「ほか N 件」を書く。**打ち切っていないときは書かない(「ほか 0 件」は打ち切ったように読めるため)
  2. **超過の長い順に並べてから打ち切る。**押し出されるなら新しい側から
  3. **テストは文面ではなく不変条件で固定する。**テストに用意した入力で「名指しした行数+ほか 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

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?