4 勤 4 休——週にも月にも整列しないシフトを、8 個のラベルで書く

Kairos

1. 8 日周期は、週にも月にも揃わない

工場・病院・コールセンターで見る「4 勤 4 休」は、4 日働いて 4 日休む 8 日周期である。1 月 5 日(月)に始まるサイクルを追うと、次のサイクルの初日は 1 月 13 日(火)、その次は21 日(水)、29 日(木)、2 月 6 日(金)——曜日が毎回ずれる。週(7 日)と 8 日は揃わず、月(28〜31 日)とも揃わない。

cron は「分 時 日 月 曜日」の 5 つの枠しか持たないので、これは書けない。iCalendar の RRULE もFREQ=DAILY;INTERVAL=8 で「8 日ごとの 1 日」は書けるが、「4 日続けて」は書けない。シフト管理のサービスはどうしているかというと、DDDD---- のような独自のパターン文字列で持ち、外に出すときは日付を展開し切った .ics を吐く(「書けないスケジュール」の博物誌、カタログ 26)。展開した日付には、もう「8 日周期」という構造は残っていない。

2. 曜日と同じ仕組みで書く

Kairos で曜日は、日に「Mon, Tue, …, Sun」の 7 個のラベルを繰り返し貼ったものとして定義されている(標準 premise の weekday)。ラベルは日を切り分ける窓ではなく、日と並列に走る目盛りで、周期の長さは 7 でなくてもよい。4 勤 4 休は、8 個のラベルを貼れば書ける:

premise Rota = Gregorian with {
  tz: "Asia/Tokyo"
  dutyA = day cycle [On, On, On, On, Off, Off, Off, Off] anchor: 2026-01-05
}
premise JPR { calendar-system: Rota; tz: "Asia/Tokyo"; wkst: Mon }

@JPR
everyDay |> filter(d => dutyA(d) == On)

初めて出る記法を順に。

  • premise Rota = Gregorian with { … } は、標準のグレゴリオ暦を継承してラベルを 1 つ足した 暦法(本社の暦と、支社の暦で書いた「前文の派生」)。
  • day cycle [On, On, On, On, Off, Off, Off, Off] が 8 日周期の反復ラベル。リストは巡回の順を 与えるだけで、先頭に「開始」の意味はない。
  • anchor: 2026-01-05 が位相。「anchor の日が先頭ラベル(On)」と決めることで、どの日が どのラベルかが確定する。
  • dutyA(d) は「日 d のラベル」を返す。weekday(d) と同じ読み方である。

1 月 5 日から 4 週間ぶん出してみる:

$ kairos list --from 2026-01-05 --to 2026-02-02 rota.kairos
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
2026-01-29  2026-01-30  2026-01-31  2026-02-01

4 日出て 4 日休む列が、月をまたいでそのまま続く(1 月 29 日〜2 月 1 日)。年をまたいでも同じ:

$ kairos list --from 2026-12-20 --to 2027-01-20 rota.kairos
2026-12-23  2026-12-24  2026-12-25  2026-12-26
2026-12-31  2027-01-01  2027-01-02  2027-01-03
2027-01-08  2027-01-09  2027-01-10  2027-01-11
2027-01-16  2027-01-17  2027-01-18  2027-01-19

12 月 31 日から 1 月 3 日が勤務のサイクルになっている。式は年のことを何も知らない。2026 年の勤務日数は 181 日(365 日は 8 日周期 45 回と端数 5 日なので、位相によって 180〜184 日になる)。

3. B 班は anchor を 4 日ずらすだけ

A 班が休む 4 日に B 班が出る、という組み方は、同じラベルの列で anchor だけ 4 日後にする:

premise Rota = Gregorian with {
  tz: "Asia/Tokyo"
  dutyA = day cycle [On, On, On, On, Off, Off, Off, Off] anchor: 2026-01-05
  dutyB = day cycle [On, On, On, On, Off, Off, Off, Off] anchor: 2026-01-09
}
$ kairos list --from 2026-01-05 --to 2026-02-02 rotaB.kairos
2026-01-09  2026-01-10  2026-01-11  2026-01-12
2026-01-17  2026-01-18  2026-01-19  2026-01-20
2026-01-25  2026-01-26  2026-01-27  2026-01-28

A 班の休みにぴったり嵌まる。確かめるなら、両班が同時に出る日を積 & で数える:

(everyDay |> filter(d => dutyA(d) == On)) & (everyDay |> filter(d => dutyB(d) == On))

2026 年を通して 0 点。逆に和 | を取れば毎日になる。

ここで一つ、落とし穴を書いておく。B 班を作るとき、リストの方を [Off, Off, Off, Off, On, On, On, On]と書き換えて anchor を同じにしても、結果は同じになる。しかし「位相を持つのは anchor」と決めておく方が事故が少ない。リストを回転させた上に anchor も別の日にすると、ラベルが一斉に回って、どの日が On かが分からなくなる。班を増やすときは、リストは同じまま、anchor だけを実際の初日にする。anchor には照合済みの実日(このサイクルは確かにこの日に始まった)を書く。

4. サイクルの初日と、出勤時刻

「各サイクルの初日に申し送りを出す」なら、勤務日の列を 4 個ごとに間引く(前回の stride):

everyDay |> filter(d => dutyA(d) == On) |> stride(4, from: 2026-01-05)
$ kairos list --from 2026-01-01 --to 2026-03-01 rota-first.kairos
2026-01-05
2026-01-13
2026-01-21
2026-01-29
2026-02-06
2026-02-14
2026-02-22

月・火・水・木・金・土・日と、初日の曜日が 1 つずつ進んでいく。8 日周期を 7 日の週に載せると、こうなる。

出勤時刻を貼るなら at:

everyDay |> filter(d => dutyA(d) == On) |> at(T08:00)
2026-01-05T08:00
2026-01-06T08:00
2026-01-07T08:00
2026-01-08T08:00
2026-01-13T08:00

5. 落とし穴——ラベルと窓、律動とデータ

  • cycle は律動(無限に繰り返す規則)である。「今年のシフト表は人事が決めた不規則な並び」の ように有限のデータなら、cycle ではなくテーブルの labels: で日付とラベルを 1 対 1 に持つ (調休の回で表を写したのと同じ器)。規則で書けるものと、データで しか持てないものは、Kairos では別の書き方になる。
  • ラベルは窓ではない。within(week) のように「サイクルで区切る」ことはできず、filter で ラベルを読む。区切りが要るなら、初日の列(§4)を境界にする。
  • wkst(週の開始)は cycle と無関係。週が月曜始まりでも日曜始まりでも、8 日周期は動かない。

6. 射程——式が出すのは「誰かが出る日」まで

  • 式が出すのは「A 班が出る日」の列であって、誰が A 班かは出さない。人の割当・交代・有給の 差し引きは、シフト管理の実装系の仕事である。言語は集合の定義まで。
  • 夜勤の「日をまたぐ勤務」は、ここでは扱っていない。at(T20:00) で始まりの点は貼れるが、 終わりの時刻や日跨ぎの扱いは営業時間(isOpen)の領分で、別の回にする。
  • DuPont 方式(28 日周期・昼夜で時刻が違う)も同じ型で書ける——28 個のラベルの cycle に、 ラベルごとの時刻を at で貼って | で束ねる。

7. 動かす

月末最終営業日の自動実行まで通すの型 1 がそのまま使える:

0 7 * * *  cd /srv/shift && ./run-if-today.sh rota-first.kairos ./send-handover.sh

まとめ

  • 4 勤 4 休は 8 日周期。週にも月にも揃わず、初日の曜日が毎回ずれる。cron・RRULE では書けない
  • Kairos では曜日と同じ仕組み——day cycle [On×4, Off×4] anchor: 実日——で書く。月も年もまたぐ
  • 位相は anchor が持つ。B 班はリストを回さず anchor を 4 日ずらす。両班の交差は 0 日、和は毎日
  • サイクル初日は stride(4, from:)、出勤時刻は at
  • 律動は cycle、有限のデータは labels:。誰が出るかは実装系の仕事

▶ Playground で定義を実行する——2026 年 1〜2 月の A 班の勤務日が出る。コメントにある B 班・両班の交差(空)・サイクル初日・出勤 8 時の行と入れ替えて試せる。anchor: を 1 日動かすと全部がずれる。ブラウザ内で完結し、何も送信しない。


Kairos はスケジュール定義言語です。仕様と参照実装はGitHub で公開しています。

コメント

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