シフトのたたき台を出すWebツールを作った ── そしてClaude Codeに同じ条件を投げたら、制約ソルバーを書いてきた

目次

作ったもの

シフトのたたき台を出すだけのWebツールを公開した。

シフトたたき台ジェネレーターGitHub: kwrkb/schedule-gen

入力は3つだけだ。人数、シフト数、日数。「シフトを作る」を押すと、1ヶ月分の「人 × 日」のシフト表が出る。登録も保存もない。計算はすべてブラウザ内で完結するので、入力した内容がどこかのサーバーに送られることもない。

出てきた表はExcelに貼り付けて手直しする前提で作った。完成品ではなく、あくまで叩き台である。「Excel 用にコピー」ボタンを置いてあるのはそのためだ。

出力の上には結果のサマリーを出している。8人×3シフト×30日なら「8人 × 早・中1・遅 × 30日 / 出勤 22〜23日 / 0.33秒」といった具合だ。出勤日数がどの範囲に収まったかを最初に見せるのは、たたき台としての品質を1行で判断してもらうためである。

もうひとつ、ボタンの下に小さく「もう一度押すと別の案が出ます」と書いてある。押すたびに違う解が出る。気に入らなければ押し直せばいいという設計で、これは後述する多スタートの副産物でもある。

この記事は、前半でツールの設計判断を、中盤で中のアルゴリズムを、後半で「実際にAIへ投げたらどうなったか」を書く。とにかくシフトを組む手段が知りたいだけの人は、中盤の「中で動いている考え方」は飛ばして後半から読んでほしい。

なぜこの割り切りにしたか

シフト管理のSaaSは既に何社もある。それでも多くの現場がExcelと手作業で回っているのは、機能が足りないからではなく、使い始めるまでが遠いからだと考えている。従業員を登録し、シフトパターンを定義し、店舗設定をして、制約を入れる。「今月のたたき台がほしいだけ」の人には、その手前で心が折れる。

一方で、シフト作成において人間の手直しは絶対に発生する。「あの人とあの人は組ませない」「来週は研修がある」といった事情は、人間しか知らないからだ。

ならば、ツールは完璧を目指すのをやめて、手直しの起点を最速で出すことだけに徹すればいい。ゼロから組むより、たたき台を直すほうが圧倒的に楽だ。

これがこのツールの設計の出発点になっている。

何をして、何をしないか

ユーザーは何も設定しないが、裏側では常に以下が効いている。

必ず守るもの(ハード制約)

  • 連勤は6日まで
  • 各シフトに毎日1人以上を配置する
  • 遅番の翌日に早番を入れない

できるだけ均等にするもの(ソフト制約)

  • 出勤日数(週休2日相当)
  • 遅番の回数
  • 休みが飛び石で散る
  • 同一シフトが3日以上続かない

「遅番の翌日に早番を入れない」は、勤務間インターバルの考え方をかなり粗く近似したものだ。日本では労働時間等の設定の改善に関する特別措置法第2条により、2019年4月から勤務間インターバル制度の導入が事業主の努力義務となっている(厚生労働省のパンフレット)。ただし法律上の最低時間は明記されておらず、このツールも時刻を扱っていないため、あくまで「遅番→早番という並びを避ける」という近似にとどまる点は断っておく。

意図的に入れなかったもの

項目理由
休み希望・定休日の入力入力の手間がリピート利用の壁になる。手直しで対応する領域
土日祝の考慮土日休みの公平化は年単位でしか成立しない。月次ツールの守備範囲外
データの保存・ユーザー登録入力が3項目なので保存する価値がない。導入障壁もゼロにできる
LLMの利用APIコストと精度リスクを排除。古典的アルゴリズムで足りる

細かく制約を設定したい人は、はっきり言って対象外だ。その理由は記事の後半で書く。

中で動いている考え方

ここからは、どういう理屈でシフトを組んでいるかの話になる。

先に断っておくと、これは考え方の整理であって、実装と一対一で対応しているわけではない。実際のコードは検証を経て形が変わっている(その顛末も後述する)。アルゴリズムそのものの詳細は別記事に譲り、ここでは骨格だけ書く。

交換操作は「保存則」を持つ

最初に考えたのは、素朴なやり方だった。ランダムにシフトを割り当てて、「同じ日の2人を入れ替える」操作で少しずつ改善していく。

交換を選ぶ理由は明快だ。AさんとBさんの3日目を入れ替えても、その日に早番が何人いるかは変わらない。人数バランスが勝手に崩れない。

しかしこれは破綻する。3日目に早番が誰もいない状態を考えてほしい。誰と誰を交換しようが、早番の総数はゼロのままだ。交換は構成を保存するので、最初から存在しないものは作り出せない

同じ理由で、各人の出勤日数のばらつきも交換では直らない。初期解のばらつきをそのまま最後まで引きずる。

交換操作で保存されてしまう量は、初期解の時点で正しくしておかなければ永久に直らない。

だから「列」を作って固定する

原因が初期解にあるなら、初期解を完璧にすればいい。

1日分の「列」を1つだけ作る。まず週休2日相当(2/7)になるよう休みの人数を決め、残りの勤務者を早番・中番・遅番へ順番に割り振る。8人×3シフトなら、休み2人・早番2人・中番2人・遅番2人という列になる。

これを全日にコピーする。

        1日  2日  3日  4日 ... 30日
P1      早   早   早   早  ...  早
P2      中1  中1  中1  中1 ...  中1
P3      遅   遅   遅   遅  ...  遅
P4      早   早   早   早  ...  早
...
P7      休   休   休   休  ...  休
P8      休   休   休   休  ...  休

初期状態は極端に偏っている。P1は毎日早番、P7は1ヶ月まるごと休み。シフト表としては最悪だ。

だが構造的には正しい。列に各シフトが必ず1人以上含まれているので、「各シフトに毎日1人以上」は最初から満たされている。そして交換しても列構成は不変だから、探索中に壊れようがない。全員が同じ列を共有するので、出勤日数も原理的には均等化できる。

つまり、ハード制約の一部を「探索で満たす」のではなく「構造で保証する」。探索は残りの均等化だけに集中できる。

これが一番効いた発想だった。制約を評価関数に押し込んで探索に頑張らせるより、そもそも壊れない設計にするほうが圧倒的に強い

均等性は「分散」で測る

「全員の遅番回数を均等に」をどう数値化するか。答えは分散だった。

各人のシフト構成 [休み, 早番, 中番, 遅番] の分散を取る。全員が全シフトを同じ回数担当していれば分散はゼロ、偏るほど大きくなる。分散の最小化がそのまま均等化になる

「休みが飛び石で自然に散る」も同じ発想で書ける。休みの日付の間隔を並べて、その分散を取ればいい。間隔のばらつきが小さい=規則的に散らばっている状態だ。

多目的最適化に見える問題が、分散という共通の物差しで単一のコスト関数に落ちる。ここは書いていて気持ちのいい部分だった。

収束判定は温度スケジュールとセットで

改善する交換だけを採用すると局所解にハマるので、焼きなまし法(Simulated Annealing)を使う。わざと少しだけ悪い方向にも動くことで、行き止まりから抜け出す探索手法だ。温度が高いうちは悪化も確率的に受け入れて広く探索し、徐々に下げて改善のみに絞る。

ここで罠を踏んだ。最初は単純に「8ラウンド改善がなければ終了」としたが、30人×5シフトのケースで品質が激しく劣化した。

原因は、温度がまだ高い段階でたまたま改善が止まっただけなのに「収束した」と誤判定していたことだった。高温中は悪化も受け入れるので、ベストスコアが一時的に更新されない期間は普通に発生する。

if it >= cool_iters and stale >= patience:
    break

冷却が完了して初めて、「改善が止まった」という事実に意味が出る。焼きなましの収束判定は、温度スケジュールと必ずセットで設計しなければならない。

実装して初めて分かった誤算

ここからが、記事にする価値があると思った部分だ。

「違反ゼロ」は結果論だった

Pythonのプロトタイプでは、8人×3シフトから50人×4シフトまで検証した7ケースすべてで、ハード制約違反ゼロという結果が出ていた。

だが当時から気にはなっていた。焼きなましは収束判定や時間切れで打ち切る以上、違反が残ったまま解を返す可能性が原理的にある。違反ゼロだったのは結果であって、保証ではない。

TypeScriptに移植してから、この懸念は的中した。

最もタイトな構成(5人×2シフト×30日)を100回試した。乱数の種(シード)を変えれば同じ入力でも違う解が出るので、100通りの「引き」を試したことになる。 すると単発の焼きなましは、TS 2/100、Python 1/100の確率で連勤上限違反を含む解を返した。

この差は誤差の範囲だった(Fisher検定で p≈0.24。要するに「TS版のほうが壊れやすい」とは言えない)。つまり移植のバグではなく、元のアルゴリズムがもともと持っていた性質だと分かった。

さらに、反復数を増やしても抜けられなかった。低温での探索を増やせば局所最適から脱出できるはずという想定で試したが、失敗するシードの結果は変わらなかった。

多スタートで決着させた

対処は単純だった。時間予算の範囲で何度も解き直し、最良の解を採る

既定800ms、最大12回。単発が12〜71msと十分速いので、平均12回の試行が回る。採用基準は「ハード制約違反の少なさ」を最優先し、同数ならコストの低い解を選ぶ。

これで全ケース違反0/100になった。5人ケースの出勤日数・遅番回数の幅は、全100シードで0を達成している。

副産物として、UIの「もう一度押すと別の案が出ます」が成立した。毎回シードが変わるので、同じ入力でも違う解が出る。どれを引いても違反ゼロが担保されているから、ユーザーは品質を心配せずに引き直せる。多スタートを入れていなかったら、押し直すたびに稀に壊れた表が出る挙動になっていたはずだ。

各ケース20シードでの実測値(最悪値)はこうなった。

ケース時間出勤日数の幅遅番回数の幅違反
8人×3シフト×30日0.23s1日1回0
20人×3シフト×31日0.45s1日1回0
50人×4シフト×31日0.82s1日1回0

時間は20シード中の最悪値なので、冒頭のスクリーンショットが0.33秒なのは実行環境とブラウザの違いによるものだ。

出勤日数の幅1は、この条件での理論下限だ。1日あたりの出勤人数を固定しているので、月間の延べ出勤枠も固定される。この延べ枠が人数で割り切れなければ、全員を同じ出勤日数にはできない。

8人×3シフト×30日なら、列の構成上1日6人が勤務するので延べ出勤は180日。180÷8=22.5となり、22日の人と23日の人が混在するのは避けられない。

冒頭のスクリーンショットで「出勤 22〜23日」と表示されているのは、この構造的な端数がそのまま出たものだ。完全均等にするには日ごとの出勤人数を変動させる必要があるが、実務ではシフトごとの必要人数がそもそも一様ではないため、差1は手直しの対象にならないと判断して入れていない。

アルゴリズムの正しさと、実装の正しさは別物

この一連の流れで学んだのは、紙の上で筋が通っていることと、実際に何度回しても壊れないことは違うということだ。

プロトタイプの「7ケース違反ゼロ」は嘘ではない。ただしそれは、たまたま引いたシードでの結果だった。100回回して初めて2%の失敗率が見えた。

シフトのように「1回でも破綻したら使えない」種類の出力では、平均的な品質より最悪ケースの保証のほうが重要になる。多スタートは頭のいい手法ではないが、この問題に対しては正しい解決だったと思う。

実際にAIへ投げてみた

ここまで書いておいて何だが、正直な話を書く。

投げられる環境がある人なら、このツールより Claude Code や Codex に自由記述で投げたほうが早いし、いいものが出る。 記事を書きながら実際に試したので、その結果も含めて書く。

なぜフォームでは無理なのか

このツールの入力は3項目しかない。それは設計としての強みでもあるが、裏を返せば表現できることが3項目分しかないということだ。

一方、実際のシフト作成の制約はこういう形をしている。

  • 田中さんは金曜が固定で休み
  • 佐藤さんと鈴木さんは同じ日に入れたくない
  • 新人が2人いるので、必ずベテランと組ませたい
  • 月末3日間は棚卸しで人手が要る
  • 有資格者が毎日1人は必要

これを設定項目としてUIに落とすと、入力フォームが一気に肥大化する。そして項目を増やせば増やすほど、「今月のたたき台がほしいだけ」の人は離れていく。ツールとして両立しない。

だがLLMなら、この箇条書きをそのまま投げられる。フォームの設計も、制約の型定義も要らない。日本語で書けばいい。

具体的な投げ方

実際に投げたプロンプトはこれだ。

薬局のシフト表を作ってください。

【前提】
- 12人、2026年9月(30日)
- シフトは早番(9-18)・遅番(12-21)の2種類
- 早番は毎日4人、遅番は毎日3人必要

【絶対に守ること】
- 連勤は5日まで
- 遅番の翌日に早番を入れない
- 田中は毎週金曜休み
- 佐藤は9/15-17が研修で不在
- 薬剤師(田中・佐藤・山田・伊藤)が各シフトに最低1人

【できるだけ】
- 出勤日数を全員均等に
- 遅番の回数を全員均等に
- 土日の休みが特定の人に偏らない

Markdownの表で出力し、最後に各人の出勤日数・遅番回数の集計を付けてください。
制約を満たせない場合は、どれを緩めるべきか提案してください。

ポイントは「絶対に守ること」と「できるだけ」を分けて書くことだ。これは本記事で書いたハード制約とソフト制約の分離と同じ発想で、優先順位を明示すると出力が安定する。

最後の一文も効く。制約が矛盾しているときに、無理やり表を作られるより「9/16は薬剤師が1人しか出せません」と言われたほうが助かる。

予想が外れた

これを Claude Code に投げた。予想は「それらしい表は出るが、どこかに違反が残っているだろう」だった。自分のアルゴリズムが2/100で壊れると書いた手前、LLMが一発で完璧な表を書けるとは思っていなかった。

外れた。Claude Code は表を書かなかった。制約ソルバーを書いた。

返ってきたのは、OR-Tools の CP-SAT で全制約をハード制約として定式化し、OPTIMAL 解を得て、さらに独立した検証スクリプトで再チェックした、という報告つきのシフト表だった。私が「今回の規模にはオーバースペック」と判断して避けたものを、頼んでもいないのに持ち出してきたことになる。

自分で検算した

出力をそのまま信じるのは、この記事の主張に反する。独立に検算コードを書いて確かめた。

違反ゼロだった。

  • 早番4人・遅番3人 × 30日 = 210枠を完全充足
  • 最大連勤5日(上限ちょうどが4人)
  • 遅番→翌日早番 0件
  • 田中の金曜4回、佐藤の9/15-17、各日の早番・遅番それぞれに薬剤師1名以上 — すべて充足
  • 集計表に並んだ72個の数値も、1つの誤りもなく一致

均等性も理論限界に届いていた。

項目結果理論値
出勤日数17〜18日(幅1)210÷12 = 17.5
遅番回数7〜8回(幅1)90÷12 = 7.5
土日休み3〜4日(幅1)56枠÷12 = 4.67

私のツールも差1に到達する。だが到達の仕方が違う。焼きなましはたまたま到達しただけで、それが限界かどうかは分からない。

ここは正確に書いておきたい。今回 Claude Code は均等化条件までハード制約に含めて解いているので、これは目的関数のない充足問題だ。OPTIMAL が意味するのは「その制約集合を満たす解を見つけ、探索を完了した」ことであって、「これ以上均等にできない」ことの証明ではない。

差1が限界であることを示しているのは、CP-SATではなく算術のほうだ(210÷12=17.5)。CP-SATが示したのは、その理論限界を満たす解が実在すること。焼きなましは実在するかどうかも分からないまま探すが、CP-SATは「あるなら必ず見つける」。差はそこにある。

そして最後の行が痛い。土日休みの均等化は、VISION.md で「土日休みの公平化は年単位でしか成立しない。月次ツールの守備範囲外」として切り捨てた項目だ。CP-SAT は同じ月内で差1に収めてきた。切り捨てた判断のほうが、検証不足だった。

何が起きていたのか

ここで効いているのは、LLMが賢くシフトを組んだことではない。

LLMが、自分でシフトを組むのをやめて、決定論的なソルバーに委譲したことだ。

これで減るのは、LLMが表そのものを生成するときの精度リスクだ。実際の割り当てを作っているのは CP-SAT なので、「もっともらしい表を書いたが、数えてみると人数が足りない」という言語モデル特有の失敗モードは起きにくくなる。

ただしリスクが消えるわけではない。危険は翻訳の段階に移る。

たとえばLLMが「遅番の翌日に早番は禁止」を、うっかり「同じ日に遅番と早番を両方入れない」と定式化したとする。CP-SATは何も文句を言わない。間違った問題を、完璧に解く。 しかも出てきた表は内部的に整合が取れているので、眺めただけでは気づけない。焼きなましなら制約違反として現れたはずのものが、ここでは「正しい答え」の顔をして出てくる。

減ったのではなく、移った。だからこそ、モデルとは別に検証コードを書かせることと、最終結果を独立に確かめることが必要になる。

このツールを作った動機のひとつは、VISION.md に書いたこの前提だった。

Google OR-Tools のような制約ソルバーは非エンジニアには扱えない

崩れた。正確には、エージェントが代わりに扱うようになった。

ただし敷居は消えていない。移動しただけだ

とはいえ、これで話は終わらない。

Claude Code を使えること自体が、新しい敷居になっている。 インストールし、サブスクリプションを契約し、ターミナルに慣れる必要がある。制約ソルバーを直接扱うよりはずっと易しいが、「今月のたたき台がほしいだけ」の薬局長やホール主任にとって易しいかというと、そうではない。

敷居が消えたのではなく、「制約ソルバーの敷居」が「エージェントの敷居」に置き換わった。置き換わった先のほうが低いのは確かだが、ゼロではない。

だから住み分けはこうなる。

状況向いているもの
AIを使う環境がない / とにかく形が見たいこのツール(3項目・数秒・登録不要)
Claude Code や Codex を使える自由記述で投げる(制約ソルバーまで書いてくれる)
毎月の運用に組み込みたいシフト管理SaaS
自分でソルバーを書けるOR-Tools を直接

投げられるなら投げたほうが早い。投げられないなら、3項目で数秒のたたき台がある。 正直なところ、これに尽きる。

会話で詰められることの強さ

もうひとつ、フォームでは真似できない差がある。

Claude Code の出力は、最後にこう自己申告してきた。「遅番の翌々日に早番」という並びは成立しているが、これは指定された制約に含まれていないので許容した、と。そのうえで、必要なら同じソルバーで再計算できると提案してきた。

ここが決定的だと思う。私のツールでできる反復は「もう一度押す」だけで、条件そのものは変えられない。AIに投げれば、出てきた表を見てから条件を足せる。「やっぱり遅番の翌日は休みにして」「田中と佐藤は同じ日に入れないで」と言えば、その場で組み直される。

シフト作成は一発では決まらない。出てきたものを見て初めて「これは無理だ」と気づく類の作業だ。会話で詰められることは、精度そのものより実務的な価値が大きいかもしれない。

逆に、AIルートの弱点

擁護ではなく事実として、弱いところもある。

指定しなかった制約は守られない。 「遅番の翌々日に早番」を許容したのは、私がプロンプトに書かなかったからだ。私のツールが黙って効かせている「シフトあるある」は、AIルートでは全部言語化しなければならない。何を書くべきか知らない人にとって、この差は小さくない。

入力の穴を勝手に埋める。 氏名を指定しなかった8人には仮名が入っていた。断り書きが付いていたので今回は無害だったが、こういう補完が常に無害とは限らない。

そして検算は結局こちらの仕事だ。 Claude Code は自己検証していたし、実際その結果は正しかった。それでも私はもう一度確かめた。自己申告の検証が正しいことを、その自己申告だけで信じる理由はないからだ。前半に書いた「1回動いたことは保証にならない」は、相手がLLMでも同じように効く。

それでもWebツールを伸ばすなら

AIに投げられない人のための選択肢として残す以上、このツールにもまだ伸ばし方はある。現状入れていないが、方向性としては見えている拡張を書いておく。

セルの固定

実務では「この人のこの日は動かせない」という事情が必ずある。休み希望、研修、出張、固定担当。

基本的には、固定セルを交換候補から除外する形で実装できる。交換しないセルは列構成に影響しないので、列固定方式の保証はそのまま維持される。

ただし「だけ」では済まない。表示中の解を次回探索の初期解として引き継ぐ仕組みと、固定した結果として解が存在しなくなった場合の検出が別途要る。

UIとの相性はいい。現状すでに「押し直せば別の案が出る」という反復の形になっているので、そこに「気に入らない箇所をクリックして固定してから押し直す」を足す形になる。入力フォームを一切増やさずに個別事情を反映できるので、このツールの設計思想とも矛盾しない。拡張するなら最初に入れるべきはここだと思っている。

日によって必要人数が違う場合

現状は毎日同じ人数が出勤する前提だ。列を全日にコピーする以上そうなる。しかし実務ではこれは成立しない。土日は忙しい、月初は処理が多い。

二段構えにすればいい。まず必要最低限の人数で全日を組み、そのあと忙しい日に追加する。ベース部分は全員が同じ列を回るので完全に均等になり、追加分だけが不均等の源になるが、量が少ないので均しやすい。

不均等になり得る範囲を小さく閉じ込めるのがポイントだ。人がシフトを組むときの実際の思考順序にも近い。

違反ゼロを保証する運用

多スタートで結果的に違反ゼロになっているが、構造的な保証にすることもできる。

違反フラグの合計がゼロになるまで、収束判定を無効化して回し続ける。時間はかかるが、違反の残った解を返さないことが保証される。

そして違反が残ったまま時間切れになったときは、中途半端な解を返すより「この条件では組めませんでした」と正直に返すほうが誠実だと思う。使う側にとっては、違反を含む表を渡されるより、条件を変えるよう促されたほうが助かる。

この考え方を推し進めれば、今はソフト制約の均等性をハード制約に格上げすることもできる。「割り切れるなら差ゼロ、割り切れないなら差1以内」を要求し、満たすまで回す。割り切れるかどうかは事前計算で判定できるので、条件を動的に切り替えればいい。

これは結局、Claude Code が CP-SAT でやっていたことと同じ発想だ。あちらは均等化条件までハード制約に含めて解いた。手法は違っても、目指す形は同じところに来る。

まとめ

  • 人数・シフト数・日数の3項目だけでシフトのたたき台を出すWebツールを公開した(シフトたたき台ジェネレーター
  • 中核は「列構成固定方式」。制約を探索で満たすのではなく、構造で保証する
  • 交換操作は保存則を持つので、保存される量は初期解の時点で正しくしておく必要がある
  • プロトタイプの「違反ゼロ」は結果論だった。100シード回して初めて2%の失敗率が見え、多スタートで決着した
  • 同じ条件を Claude Code に投げたら、表ではなく制約ソルバーを書いてきた。独立に検算しても違反ゼロで、均等性は算術上の理論限界に届いていた
  • 効いているのはLLMの賢さではなく、LLMが決定論的なソルバーに委譲したこと。ただし精度リスクは消えたのではなく、日本語を制約式へ翻訳する段階に移った
  • 「制約ソルバーは非エンジニアには扱えない」という前提は崩れた。ただし敷居も消えたのではなく、エージェントを使える人と使えない人の差に移動した
  • 投げられるなら投げたほうが早い。投げられないなら、3項目で数秒のたたき台がある

制約ソルバーを持ち出す前に、問題の構造を作り変えられないか検討する ── これは今も有効な発想だと思う。ただしそれは自分で実装する場合の話だった。エージェントに投げられるなら、最初からソルバーで殴ったほうが速いし正確だ。今回それを実演されてしまった。

ツールを作ってみて分かったのは、「何を作らないか」を決めるほうが難しいということだ。入力欄を1つ増やすたびに、このツールが存在する理由が薄れていく。3項目で止めた判断が正しかったかは、使われてみないと分からない。

そして自分で作ったものの立ち位置が、記事を書いている最中に一段変わった。それも含めて記録しておく。

コードはGitHubにMITライセンスで置いてある。設計判断の経緯は VISION.mdLESSONS.md に、何を試して捨てたかまで含めて残した。

参考文献・出典

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

コメント

コメントする

目次