暦は、会社に一枚ではない
前回、式の前に premise(前提の束)を宣言する話を書いた。tz・週の始まり・暦法——式の意味を決める文脈を、サーバーの設定ではなく定義の一部にする、という話である。
今回はその続き。実際の組織に入ると、すぐ気づくことがある。前提の束は、一つでは足りない。本社と大阪支社では休業日が一日違う。工場は土日ではなく 4 勤 4 休で回っている。経理は月次ではなく 4-4-5 の会計カレンダーで締めている。同じ社内に、暦が何枚もある。
汎用のスケジューラでこれを支えようとすると、たいてい「システム全体で一つのカレンダー設定」に突き当たる。Kairos の答えは単純で、premise を複数書き、差分は差分として書く。
本社と支社——差分は 1 行
大阪支社は、本社の営業日から創立記念日(9/25)だけが余分に休みだとする。支社の営業日をゼロから書き直す必要はない。本社の営業日列から 1 日引くだけである:
premise JP { calendar-system: Gregorian; tz: "Asia/Tokyo"; wkst: Mon }
@JP
national = [2026-09-21, 2026-09-22, 2026-09-23] covering: 2026..2026
satSun = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
hqBiz = everyDay \ (satSun | national)
osakaBiz = hqBiz \ [2026-09-25] covering: 2026..2026
(祝日は 9 月分の抜粋。\ は差集合——第 6 弾の祝日カスケードで使った、列から列を引く演算子である。)
ここに「給料日は 25 日、休日なら前営業日」という同じ式を、軸だけ替えて評価する:
payday = everyDay |> within(month) |> nth(25) payday |> roll(Preceding, on: hqBiz) # 本社 payday |> roll(Preceding, on: osakaBiz) # 大阪支社
2026 年 9 月の実測:
本社: 2026-09-25 大阪: 2026-09-24
9/25 は金曜日。本社ではそのまま給料日、大阪では創立記念日なので前営業日の木曜に前倒し——式は一字も変わっていないのに、暦が違うから答えが違う。「支社だけ 1 日ずれる」を給与システムのif 文に埋めるのではなく、暦の定義の差分として書けたことになる。
with——暦法そのものを派生させる
休業日の差分なら列の引き算で足りる。だが「工場は 4 勤 4 休」「経理は 4-4-5」になると、話は週や月の形そのものに及ぶ。そのための構文が with——既存の暦法を土台に、語彙を足したり上書きしたりした新しい暦法を作る:
premise Rota = Gregorian with {
duty = day cycle [On, On, On, On, Off, Off, Off, Off] anchor: 2026-01-05
}
読み下すと「グレゴリオ暦に、8 日周期のラベル duty を足した暦法」。cycle は巡回ラベルの生成子で、実はこの言語では曜日そのものが 7 日周期の cycle として定義されている(月曜は月曜、を千年先まで言えるのはこの律動のおかげである)。anchor: が位相の錨。勤務日は:
everyDay |> filter(d => duty(d) == On)
2026-01-05 2026-01-06 2026-01-07 2026-01-08 2026-01-13 2026-01-14 2026-01-15 2026-01-16 2026-01-21 2026-01-22 2026-01-23 2026-01-24
4 日出て 4 日休む。この列は曜日と噛み合わない(週は 7 日、周期は 8 日)——cron やカレンダーアプリの繰り返し設定が苦手にしてきた形だが、曜日が cycle なら、8 日周期は曜日の隣に並ぶただのもう一つの律動である。特別扱いは何も要らない。
4-4-5——会計の暦を 5 行で
仕上げに、経理の 4-4-5 を書く。四半期を 4 週+4 週+5 週に割る会計カレンダーで、小売業や製造業で広く使われている。「月」が存在しない暦なので、月ベースの繰り返し語彙は全滅し、会計 SaaS 各社が専用機能として個別実装している領域である。
標準同梱の ISO 週暦(月曜始まりの週・その年最初の木曜を含む週が W01)を土台に派生させる:
premise R445 = ISOWeek with {
periodStart = isoWeekStart |> filter(d =>
((isoWeekNo(d) - 1) mod 13 == 0 or (isoWeekNo(d) - 1) mod 13 == 4 or (isoWeekNo(d) - 1) mod 13 == 8)
and isoWeekNo(d) <= 48)
period = day |> segmentBy(periodStart, edges: clip, empties: error)
}
「期の頭は、週番号 1, 5, 9, 14, …, 48 の月曜」——4-4-5 の律動を週番号の剰余で言い、segmentBy でその月曜を境に期の窓を張る。各期の初日を実測すると:
2025-12-29 2026-01-26 2026-02-23 2026-03-30 2026-04-27 2026-05-25 2026-06-29 2026-07-27 2026-08-24 2026-09-28 2026-10-26 2026-11-23 2027-01-04 2027-02-01
4 週(12/29→1/26)・4 週(→2/23)・5 週(→3/30)——4-4-5 が刻まれている。そしてここに、この暦のいちばん厄介な問題が黙って解けている。364 日(52 週)の暦は数年に一度 53 週の年に当たり、余った 1 週をどこかの期に足す必要がある(繰り上げ週)。2026 年のISO 年はちょうど 53 週——上の実測で最終期が 11/23 から翌年 1/3 まで 6 週間に伸び、翌年は 1/4(W01 の月曜)から正常に再開している。繰り上げの規則は一行も書いていない。「期の頭は W48 まで」という定義の形から、余り週が最終期に吸収されることが帰結として出るのである。
まとめ
- 暦は会社に一枚ではない——本社と支社・工場・経理は、それぞれの前提の束で動いている
- 差分は差分として書く: 支社の営業日は本社の列から 1 日引くだけ・同じ給料日の式が 暦の違いだけで別の日を返す(実測 9/25 と 9/24)
withは暦法の派生——8 日周期の勤務ラベル(4 勤 4 休)も、週ベースの会計暦(4-4-5)も、 グレゴリオ暦や ISO 週暦を土台にした差分の宣言で立つ- 定義の形が良いと、規則を書かなくても正しさが出る——4-4-5 の 53 週目の繰り上げは、 期の頭の定義から帰結として転がり出た
次回は少し引いた場所から。この連載で「cron では書けない」「RRULE では書けない」と何度も書いてきたが、では世の中で実際に「書けない」と言われてきたスケジュールを集めたら、どれだけ書けるのか——挫折の目録と、その実測を書く予定である。
Kairos は設計中のスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント