旬払いを「10 日ごと」で書いてはいけない——月は等幅ではない

Kairos

1. 「旬」と「10 日ごと」は違う

上旬・中旬・下旬。旬払いの給料、旬報、旬ごとの締め。初日は毎月 1 日・11 日・21 日で、cron でも0 0 1,11,21 * * と書ける(曜日が絡まないので、「月 2 回」の回の OR の罠も出ない)。

問題は、これを「10 日ごと」と言い換えたときに起きる。上旬と中旬は 10 日ずつだが、下旬は 8〜11 日ある。旬は月に従う区切りで、等幅ではない。この違いが、式にそのまま出る。

2. 旬の初日は、月の中の日番号で書く

premise JP {
  calendar-system: Gregorian
  tz: "Asia/Tokyo"
  wkst: Mon
}

@JP
everyDay |> filter(d => dayNo(d) == 1 or dayNo(d) == 11 or dayNo(d) == 21)
  • dayNo(d) は「日 d が月の何日か」(1〜31)。標準の前文に入っている値関数で、within(month) |> nth(n) が 「月の第 n 日を取り出す」なら、dayNo は「点が第何日かを読む」。向きが逆なだけで、同じものを指している。
  • or で 3 つ並べて 1 行。「月の中の 1 日・11 日・21 日」と、要件を言った順に読める。
$ kairos list --from 2026-01-01 --to 2026-04-01 jun.kairos
2026-01-01
2026-01-11
2026-01-21
2026-02-01
2026-02-11
2026-02-21
2026-03-01
2026-03-11
2026-03-21

2026 年で 36 点。同じ列は (everyDay |> within(month) |> nth(1)) | (everyDay |> within(month) |> nth(11)) | (everyDay |> within(month) |> nth(21))でも出る。「毎月 30 日払い」の回で nth(30) は 2 月に空振りしたが、21 日はどの月にもあるので、こちらは空振りしない。

3. 「10 日ごと」に窓を敷くと、2 月 1 日が消える

等幅の窓を敷く語が grid である。10 日幅の窓を 1 月 1 日から敷いて、各窓の初日を取ってみる:

premise Dekad = Gregorian with { decade = chronos grid 10d anchor: 2026-01-01 }
premise JPD { calendar-system: Dekad; tz: "Asia/Tokyo"; wkst: Mon }

@JPD
everyDay |> within(decade) |> first
$ kairos list --from 2026-01-21 --to 2026-04-15 dekad.kairos
2026-01-21
2026-01-31
2026-02-10
2026-02-20
2026-03-02
2026-03-12
2026-03-22
2026-04-01
2026-04-11

2 月 1 日が無い。代わりに 1 月 31 日がある。1 月は 31 日あるので、1 月 21 日から 10 日の窓の次は 1 月 31 日から始まる。2 月の窓頭は旬の初日より 1 日早く、2 月は 28 日しかないので 3 月の窓頭は 1 日遅く(3 月 2 日・12 日・22 日)、3 月が 31 日なので 4 月 1 日に戻る。ずれは一方向に溜まらず、月の長さに合わせて行ったり来たりする。5 月はそろい、6 月はまた 5 月 31 日から始まる。

図 1: 2026 年 1 月から 6 月を月ごとに 1 行の帯に並べ、1 マスを 1 日とする。帯の長さは 31・28・31・30・31・30 日と月ごとに違う。金の点は旬の初日(毎月 1・11・21 日)、赤の輪は 10 日ごとの grid の窓頭。1 月は 1・11・21・31 日、2 月は 10・20 日、3 月は 2・12・22 日、4 月は 1・11・21 日、5 月は 1・11・21・31 日、6 月は 10・20・30 日に窓頭があり、2 月は 1 日早く、3 月は 1 日遅く、4 月と 5 月はそろい、6 月はまた 1 日早い。(2026-10-25 図を追加)
図 1: 2026 年 1 月から 6 月を月ごとに 1 行の帯に並べ、1 マスを 1 日とする。帯の長さは 31・28・31・30・31・30 日と月ごとに違う。金の点は旬の初日(毎月 1・11・21 日)、赤の輪は 10 日ごとの grid の窓頭。1 月は 1・11・21・31 日、2 月は 10・20 日、3 月は 2・12・22 日、4 月は 1・11・21 日、5 月は 1・11・21・31 日、6 月は 10・20・30 日に窓頭があり、2 月は 1 日早く、3 月は 1 日遅く、4 月と 5 月はそろい、6 月はまた 1 日早い。(2026-10-25 図を追加)

「月は 30 日」と決めて窓を敷いても同じことが起きる。chronos grid 30d anchor: 2026-01-01 の窓頭は1 月 1 日・1 月 31 日・3 月 2 日・4 月 1 日・5 月 1 日・5 月 31 日・6 月 30 日。等幅の窓は月の境を知らない。暦の月を grid で作ってはいけない、というのは Kairos の仕様の「落とし穴」に書いてある最初の 1 行で、月に従う区切りは within(month) か dayNo で書く。

4. 旬の最終日と、休みのとき

旬の最終日は 10 日・20 日・月末。下旬だけ長さが違うのが旬の本質で、月末は last で取る:

(everyDay |> filter(d => dayNo(d) == 10 or dayNo(d) == 20)) | (everyDay |> within(month) |> last)
$ kairos list --from 2026-01-01 --to 2026-04-01 jun-last.kairos
2026-01-10
2026-01-20
2026-01-31
2026-02-10
2026-02-20
2026-02-28
2026-03-10
2026-03-20
2026-03-31

初日が土日や祝日なら前営業日に払う、という規則は、祝日表と営業日の列を前文に置いて(締め切りまであと何営業日の§2 と同じ器)、roll を 1 回足す:

premise JP {
  calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon
  national = [2026-01-01, 2026-01-12, 2026-02-11, 2026-02-23, 2026-03-20, 2026-04-29, 2026-05-03..2026-05-06,
              2026-07-20, 2026-08-11, 2026-09-21..2026-09-23, 2026-10-12, 2026-11-03, 2026-11-23] covering: 2026..2026
  satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
  bizDay = everyDay \ (satSun | national)
}

@JP
everyDay |> filter(d => dayNo(d) == 1 or dayNo(d) == 11 or dayNo(d) == 21) |> roll(Preceding, on: bizDay)
$ kairos list --from 2026-01-01 --to 2026-05-01 jun-biz.kairos
2026-01-09
2026-01-21
2026-01-30
2026-02-10
2026-02-20
2026-02-27
2026-03-11
2026-03-19
2026-04-01
2026-04-10
2026-04-21
# ⚠ 範囲外 2026-01-01..2026-01-02(JP.national covering 2026-01-01..2026-12-31)
# 被覆サマリ
#   JP.national covering 2026-01-01..2026-12-31 残走路 245 日

1 月 11 日(日)は 9 日(金)へ、2 月 1 日(日)は 1 月 30 日へ、2 月 11 日(建国記念の日)は 10 日へ、3 月 21 日(土)は20 日が春分の日なので 19 日(木)へ。1 月 1 日は 12 月 31 日へ動いて範囲の外に出る——末尾の「範囲外」の 1 行がそれを言っている(「月 2 回」の回の §3 と同じ註釈。範囲を 12 月から取れば 12 月 31 日が出る)。

5. 落とし穴

  • 本当に「10 日ごと」でよい仕事もある。10 日おきの点検、10 日ごとの集計バッチ。それなら grid 10d か stride(10)が正しく、2 月 1 日は出なくてよい。要件が「旬」なのか「10 日ごと」なのかを 言葉で確かめる。式はその違いを隠さない。
  • 月末を dayNo(d) == 31 で書かない。4 月は 30 日で終わる。月末は within(month) |> last。
  • 締めと支払いが別の旬にまたがる(20 日締め・翌月 10 日払い)なら、締め日の列に shift を重ねるのではなく、 支払日を dayNo(d) == 10 で直接書く。前回の「読んだ順に書く」と同じ。
  • 祝日表の期限。被覆サマリの「残走路 245 日」は、表が 12 月末で切れていることを言っている (祝日表は黙って腐る)。

6. 動かす

月末最終営業日の自動実行まで通すの型 1 で、毎朝 1 回:

0 7 * * *  cd /srv/pay && ./run-if-today.sh jun-biz.kairos ./pay.sh

まとめ

  • 旬は月に従う区切りで、等幅ではない(下旬は 8〜11 日)。「10 日ごと」と言い換えた瞬間に壊れる
  • 旬の初日は dayNo(d) == 1 or dayNo(d) == 11 or dayNo(d) == 21 の 1 行。within(month) |> nth(n) の和でも同じ
  • 等幅の grid 10d は 1 月 31 日から窓が始まり、2 月 1 日が消える。ずれは月の長さで行ったり来たりする。grid 30d で月を作るのも同じ誤り
  • 最終日は 10 日・20 日・月末(last)。休みなら roll(Preceding, on: bizDay)
  • 本当に「10 日ごと」なら grid/stride が正しい。要件の言葉を確かめる

▶ Playground で定義を実行する——2026 年 1〜6 月で、旬の初日 18 点と、10 日ごとの窓頭 19 点が並んで出る。2 月 1 日が片方にしか無い。コメントにある nth の和・旬の最終日の行と入れ替えて試せる。ブラウザ内で完結し、何も送信しない。


Kairos はスケジュール定義言語です——決めたら、いつでも。仕様と参照実装はGitHub で公開しています。

コメント

タイトルとURLをコピーしました