「営業時間内の毎正時」を 1 行で——そして半日休のデータが尽きた日に何が起きるか

Kairos

cron の「9-14」という数字は、どこから来たか

営業時間内だけ、毎正時に監視ジョブを回したい。cron で書くと、たいていこうなる:

0 9-14 * * 1-5   monitoring.sh

これで「平日の 9 時から 14 時まで毎正時」——最初は合っている。だが祝日に誤発火する。そこで monitoring.sh の先頭に祝日判定の if 文が入る。次に大納会(11:30 引け)の日に 12 時と 13 時の監視が「閉場後の市場」を叩いて警報を鳴らす。if 文がもう一つ増える。

数年後、誰かが訊く——「この 9-14 という数字、どこから来たんですか」。答えられる人はもういない。営業時間は取引所の事実なのに、それが cron の数字と、シェルスクリプトの if 文と、運用手順書の注記に分散して埋まっている。

営業時間は「実体の文化」として宣言する

Kairos では、営業時間はジョブの側ではなくカレンダー実体——取引所そのもの——の側に書く:

premise TSE {
  calendar-system: Gregorian
  tz: "Asia/Tokyo"
  source: "example"
  satSunC = everyDay |> filter(d => weekday(d) == Sat or weekday(d) == Sun)
  holidays = [2026-01-01] covering: 2026..2026
  nonWorking = satSunC | holidays
  nine  = chronos grid 1d anchor: 2026-01-01T09:00
  three = chronos grid 1d anchor: 2026-01-01T15:00
  halfDayCloses = [2026-01-06T11:30] covering: 2026..2026
  sessionOpens  = nine |> first
  sessionCloses = (three |> first |> filter(t => not coincides(halfDayCloses, day, t))) | halfDayCloses
}

読み下すと: 開場は毎日 9 時(sessionOpens)。閉場は通常 15 時、ただし半日休テーブル halfDayCloses にある日はその時刻(この例では 1/6 が 11:30 引け)。休業日は土日と祝日(nonWorking)。半日休テーブル 1 本が「どの日が半日か」と「何時に引けるか」を兼ねる——日付のリストと時刻のリストを別々に保守して食い違う、という事故の形が最初から存在しない。

そして肝心のジョブ側は、1 行になる:

hourly |> filter(t => isOpen(t))

isOpen(t) は言語が実体の宣言から一律に導出する述語で、「t は営業セッション [開場, 閉場) のどれかに入っているか」を答える。実測:

2026-01-05T09:00
2026-01-05T10:00
2026-01-05T11:00
2026-01-05T12:00
2026-01-05T13:00
2026-01-05T14:00
2026-01-06T09:00
2026-01-06T10:00
2026-01-06T11:00
2026-01-07T09:00
2026-01-07T10:00
2026-01-07T11:00
2026-01-07T12:00
2026-01-07T13:00
2026-01-07T14:00

月曜(1/5)は 9 時から 14 時まで 6 点。半日休の火曜(1/6)は 9・10・11 時の 3 点だけ—— 11:30 に引けるので、12 時の正時はもう営業外。水曜(1/7)はまた 6 点に戻る。cron の 9-14 と 2 本の if 文でやっていたことの全部が、実体の宣言と filter 一段に畳まれた。

境界にも規約がある。開場の 9:00 ちょうどは営業中(含む)、閉場の 15:00 ちょうどは営業外(含まない)——半開区間。「15 時の正時は発火するのか」という、cron の 9-14 ではそもそも問えなかった問いに、言語が決定的に答える。

▶ Playground で開く

評価範囲を 2027 年に書き換えて再評価してみてほしい。次の節の話が、そのまま起きる。

半日休のデータが尽きた日に、何が起きるか

halfDayCloses の covering は 2026 年まで。では 2027 年 1 月の「営業時間内の毎正時」を評価すると何が出るか。「2027 年の半日休はまだ知らないのだから、ぜんぶ通常の 15 時引けとみなして動く」——多くのシステムはそう作られている。そして大納会の午後に、閉まった市場を監視し続ける。

Kairos の実測:

# ⚠ 範囲外 2027-01-04..2027-01-06(TSE.halfDayCloses covering 2026-01-01..2026-12-31)
# ⚠ 範囲外 2027-01-04..2027-01-06(TSE.holidays covering 2026-01-01..2026-12-31)
# 被覆サマリ
#   TSE.halfDayCloses covering 2026-01-01..2026-12-31 残走路 -5 日
#   TSE.holidays covering 2026-01-01..2026-12-31 残走路 -5 日

発火点はゼロ。代わりに範囲外の註釈が併走する。halfDayCloses の covering が主張しているのは「2026 年の範囲では、半日休はこの列で完全」であって、2027 年については何も言っていない。すると 2027 年の各営業日が 15 時引けなのか 11:30 引けなのかは判定材料が欠けている——だから営業帯そのものが「未知」であり、黙って通常営業に延長しない

「データが無いから通常営業」と「データが無いから未知」。前者は勝手な補完で、後者は正直な答えである。そして残走路 −5 日——「半日休データの賞味期限は 5 日前に切れている。2027 年分を入れよ」という運用信号が、評価結果そのものに機械可読で載っている。祝日テーブルの腐り(第 5 弾)・残走路の監視(第 8 弾)と同じ器が、営業「時間」の粒度でもそのまま働く。

片方だけ書いたら

ちなみに、開場列 sessionOpens だけ宣言して閉場列を書き忘れると:

sessionOpens/sessionCloses は対で宣言する——実体 TSE は sessionOpens だけ
(片方だけは静的エラー・with 派生は継承込みで判定。ADR-41)

「閉場が書いてないなら 24 時間営業」とはみなさない。セッションは [開場, 閉場) の対で初めて定義できる——宣言していない営業時間を言語が勝手に補うことは、ここでもしない。

まとめ

  • 営業時間は cron の数字でもジョブの if 文でもなく、実体の文化として宣言する——開閉の対・休業日・半日休テーブルが一箇所に集まり、isOpen 一語で使える
  • 半日休テーブル 1 本が「どの日か」と「何時までか」を兼ねる——リストの食い違いという事故の形が消える
  • データが尽きた先の営業帯は「未知」——黙って通常営業に延長せず、註釈と残走路が「データを足せ」と機械可読に言ってくる
  • 境界は言語の規約(開場は含む・閉場は含まない)——「15 時は発火するのか」に決定的に答えられる

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

コメント

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