Featured image of post 深夜の注文が締め切り前なのに断られる。3回直したのは、どれも見当違いの場所だった

深夜の注文が締め切り前なのに断られる。3回直したのは、どれも見当違いの場所だった

深夜に入った翌朝便の注文だけが締め切り前なのに断られ、3回直しても直らない。観察と解釈を分け、予測を先に書いてから実験する科学的デバッグ(Scientific Debugging)で、Go の time.Truncate の落とし穴まで仮説ごとのテストで辿ります。

連載 コードサルベージャー (第1話 / 全1話)

第1幕: 泥と目盛り

三日で三回直した。三回とも、出して、苦情が来て、戻した。深夜に入った翌朝便の注文が、締め切りの前なのに「締め切りを過ぎました」で断られる。直したはずの不具合の苦情が、いま四件目として届いた。

港から乾ドックへ下りる坂の途中で、上着のポケットが二度震えた。両手に弁当の箱を三段ずつ重ねて提げていたから、すぐには見られなかった。道の手すりに片方の箱を預け、空いた手でスマートフォンを出す。問い合わせ窓口から回ってきた転送が一件。その下に、東京にいる上司から Slack が一行。

――例の翌朝便、どんな感じ?

責める調子ではない。だからよけいに堪えた。返事は打たずにポケットへ戻した。

風が出ていた。十一月の半ばに入って、港の風は急に北から吹くようになる。坂の下から、錆と潮の混じった匂いが上がってくる。高圧洗浄機の唸りと、何かを硬い物で削る音。乾ドック――船を水の外に出して底まで直せるように、海水を抜いた船渠――の底で、誰かが貝殻を削っている。

今日の僕は、祖母の代わりに弁当を届けに来ただけだ。食堂「とみや」は、この引き揚げ会社の作業員の昼の弁当を毎日届けている。いつもは祖母の富が坂を下りてくるのだが、今朝は腰が痛いと言って、帳場の奥の椅子から立たなかった。

二年前、祖母が腰を悪くしたのをきっかけに、僕は風待浦へ戻ってきた。都市部のスーパーが運営しているネットスーパーの、注文の受付と配達枠のバックエンドを、食堂の奥の席からリモートで書いている。チームは東京にいて、港町から入っているのは僕一人だ。

弁当を届けて、戻って、仕事をする。それだけのはずだった。

ドックの底へ下りる鉄の階段は、泥で滑った。底には、船を載せる台――盤木という、木と鋼でできた枕のような台――が一列に並び、その上に、錆と泥の塊のような船体が載っている。

盤木の脇で、作業着の人が船体に巻尺を当てていた。上半身を脱いで腰で結んだ作業つなぎ、首にタオル。束ねた黒髪。両手から肘の上まで泥で汚れていて、小指だけが乾いている。片方の手で巻尺の端を船底の下の端に押さえ、もう片方の手で巻尺の本体を上へ伸ばしていた。

食堂に弁当を取りに来る人だ、と分かった。言葉を交わしたことはほとんどない。伝票の受領欄の署名で、汐見という名字だけ知っている。

汐見さんは、僕が提げている風呂敷の包みに目をやって、小さく頷いた。

「とみやさんの、お孫さんですね」

「あ、はい。今日、祖母が来られなくて」

「すみません。受け取る前に、一つお願いしてもいいですか」

汐見さんは巻尺のほうへ顎を少し動かした。

「手が塞がっていて。目盛りを読んでもらえますか」

急いでいる、とは言い出せなかった。弁当の箱を盤木の端に置いて、汐見さんの手元に顔を寄せる。巻尺は船底の下の端から上へ伸びていて、汐見さんの指が押さえているのは、船体の色が変わる所だった。下は茶色い塗膜と泥。上は、白っぽい貝殻がびっしりと重なっている。

「……八十二センチ、です」

「八十二」

汐見さんはそれを繰り返すと、胸ポケットから防水の野帳(屋外の作業で使う、硬い表紙の記録帳)を出した。泥の付いていない小指でページを押さえて書き込む。それから巻尺を巻き取り、二メートルほど船首のほうへ移って、同じように当てる。

「ここもお願いします」

「七十九です」

汐見さんがまた二メートルほど移る。

「……七十八、かな。七十八です」

三か所。読んだ数字を汐見さんが野帳に書く。測っているのは、貝殻が付いている所と付いていない所の境目の高さらしかった。何のためかは分からない。ポケットの中のスマートフォンが、また一度震えた。

三か所目を読み終えると、汐見さんは野帳を閉じずに、船体の反対側へ回っていった。

置いていかれた形になって、僕は初めて船を見上げた。

鋼の船体は茶色く、ところどころ白い貝殻の層が厚く盛り上がっている。甲板の縁から泥が垂れたまま乾いていて、船首の近くに、名前を書いた板らしいものがある。泥に埋もれて、文字は一つも読めない。

二十年前、僕は九歳だった。あの日の翌朝、港の岸壁に大人たちが集まっていたのを覚えている。何が起きたのか、そのときはよく分からなかった。分かったのは、もっと後になって、食堂の常連の話からだ。

この船の話になると、常連たちは昔から決まって同じことを言う。

「警報を受けても、帆を降ろさなかった船長がいたって。港の人はみんなそう言ってますよね」

反対側から戻ってきた汐見さんに、話の接ぎ穂のつもりで言った。汐見さんは相槌を打たなかった。巻尺を持ち直して、船体の向こう側を指した。

「向こうも、読んでもらえますか」

聞こえなかったのかもしれない、と思った。

汐見さんが国の調査官だったらしい、という噂も、食堂で聞いたことがある。確かめたことはない。

反対側へ回る途中で、作業員が二人、洗浄の段取りを話していた。船首の名前の板は、いちばん最後に洗うらしい。泥ごと削ると、字が一緒に剥がれるかもしれないから、と。

こちら側の三か所も、僕が目盛りを読んだ。百十四、百十八、百二十。さっきより、四十センチ近く高い。

汐見さんは野帳を開いたまま、僕にも見える角度で持った。

ページには、表が引いてあった。列の見出しは、仮説、予測、やったこと、見たこと、結論。仮説の欄にはこう書いてある。

――見た目の濃い側の舷(船の左右の側面のこと)が下。

予測の欄には、その舷の境目が高く、反対の舷の境目が低い、とあった。

僕は、さっき汐見さんが巻尺を当てる前に、野帳にペンを走らせていたのを思い出した。この二行は、測る前に書かれていた。見た目が濃く汚れて見えたのは、最初に測ったほうの舷だった。そちらが下だったなら、そちらの境目が高く出るはずだった。

読んだ数字は、逆だった。

汐見さんは、消しゴムを使わなかった。仮説の行に横から一本、まっすぐな線を引いた。その横に小さく「棄却」と書いた。その下の行に、新しい仮説を書いた。反対の舷が下。

それから、泥の手のまま、洗浄の作業員のほうへ顔を向けた。

「洗う順番を入れ替えます。見た目の濃いほうから先に」

作業員が手を上げて応えた。

「外れたのに、消さないんですか」

僕は思わず聞いていた。

「消しておいたほうが、見やすくないですか」

汐見さんは、野帳の線を引いた行を、泥の付いていない小指の先で示した。

「消さずに、線を引く。同じ所を、もう一度測らないためです」

それだけだった。

そのときの僕には、帳面の付け方の話にしか聞こえなかった。

汐見さんはそこでやっと手を作業つなぎの腰の布で拭い、盤木の端に置いた弁当の箱を受け取った。数を確かめる目が、ふと僕のポケットに向いた。さっきから何度も震えているのに、気づかれていたらしい。

「何か、急ぎのお仕事ですか」

それで、言わなくていいことまで言ってしまった。

「ネットスーパーの、翌朝便の受付を作っているんです」

注文した日の翌日の午前に届く便で、締め切りは注文した日の夜10時。その便の注文が、締め切りの前なのに断られる。そういう苦情が三日前から来ている、と僕は続けた。

汐見さんは、弁当の箱を抱えたまま小さく頷いた。

一回目は、締め切りの比較が一秒ずれているのだと思った。10時ちょうどの扱いを変えた。二回目は、締め切りの時刻を読んで持っておいた値が古いまま残っているような気がして、毎回読み直すようにした。三回目は、設定の締め切りの時刻が別の値になっているのではないかと思って、設定の読み込みを直した。

どれも、直したあとにちゃんと注文を通して確かめた。通った。だから出した。それでも次の苦情が来て、戻して、次を直した。

早口になっているのが自分でも分かった。汐見さんは遮らずに、最後まで聞いていた。

「確かめたのは、何時でしたか」

「夜の9時台です。9時半とか、9時50分とか。一回目は9時59分」

汐見さんは少し間を置いた。

「その時刻は、直す前でも通りましたか」

スマートフォンを持った手が、下がった。

「……通ります」

声に出してから、自分の言ったことの意味が分かった。9時台の注文は、直す前から普通に受け付けられていた。

三回とも、直す前でも通る時刻で確かめていた。通るに決まっている所を通して、直ったと思っていた。三回とも、何も確かめていなかった。

汐見さんは答えを言わなかった。

「昼休みに、もう一度来られますか。事務所の机が空いています」

食堂には戻らなかった。いつも食堂の隅で仕事をするから、鞄にノートパソコンが入っている。

第2幕: 分ける

ドックの事務所は、プレハブの二階だった。石油ストーブが焚かれていて、窓の外に船が見える。作業員たちは長机で弁当を開けていた。汐見さんは作業つなぎの上半身を腰で結んだまま、机の端で弁当を食べ始めた。

僕はノートパソコンを開いて、汐見さんのほうへ画面を向け直した。受付の処理は僕が書いたものだ。翌朝便の受付の関数は、注文を受けた時刻を受け取り、受け付けたら配達日を返し、締め切りを過ぎていたら断る。現在の時刻は、受付のハンドラが日本の時刻に直してから渡している。テストのしやすさを考えて、関数の中で時計を読まないようにしてある。

order.go の中身は、こうなっている。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// Accept は注文を受け付け、配達日(JST の 0 時)を返す。
// now の 22:00:00 より前なら翌日の配達日、以降なら ErrPastDeadline。
func Accept(now time.Time) (time.Time, error) {
	today := now.Truncate(24 * time.Hour) // 「今日の0時」のつもり
	y, m, d := today.Date()
	deadline := time.Date(y, m, d, DeadlineHour, 0, 0, 0, now.Location())
	if !now.Before(deadline) {
		return time.Time{}, ErrPastDeadline
	}
	return time.Date(y, m, d+1, 0, 0, 0, 0, now.Location()), nil
}

DeadlineHour は 22。今日の0時を作り、その日の22時を締め切りにして、締め切りより前なら翌日を配達日として返す。わざわざテストが全部緑になっている画面まで開いて見せた。夜9時半の注文は受け付ける、夜10時半の注文は断る。ちゃんと通っている。

汐見さんは画面を読んだ。スクロールが速かった。関数を一つ読み終えるたびに指が止まり、また次へ進む。コードを読める人の読み方だった。

「苦情の文面を、全部見せてもらえますか」

四件を並べた。汐見さんは自分の野帳を出さずに、事務所の棚から裏紙を一枚取って、縦に一本線を引いた。左と右に分かれた紙と、ボールペンを僕に渡す。

「左に、苦情の文面を、そのまま写してください。一字も変えずに」

写しながら、気づいた。

――昨日の夜に頼もうとしたら、締め切りを過ぎましたと出ました。 ――夜中に注文したら断られました。 ――寝る前に翌朝の分を頼もうとしたのですが。 ――朝いちばんに頼もうとしたら、もう締め切りだと。

どれにも、時刻が書いていない。

僕はこれを、ずっと締め切りの10時前後の話だと読んでいた。夜、と書いてあれば10時のあたり。だから一回目に、10時ちょうどの扱いを疑った。

「でも、夜は夜ですよね」

半分笑って言った。

「何時ですか」

汐見さんはそれだけ言った。

答えられなかった。客は「夜」と書いた。10時という数字は、どこにもない。客の言った「夜」を、僕が自分の頭の中で10時に置き換えていた。置き換えたのは僕だった。

注文のログを引いた。苦情の四件の注文が、実際に何時に入ったのか。三日間、一度も見ていなかった。

01:32。00:47。02:15。05:40。

「……なんで、今まで見なかったんだ」

声が大きくなった。長机の作業員が一人、こちらを見た。汐見さんは何も言わなかった。裏紙を、僕のほうへ少し滑らせた。右の列が空いている。

右の列に、見たこととして、ログの四つの時刻と、テストが通っている事実を書き足した。

紙の左右を見比べて、一つずつ分けた。

見たことは、苦情が四件あったこと。注文の時刻は、四件とも夜中の0時台から朝の5時台の間だったこと。夜9時半と夜10時半で書いたテストは、全部通ること。

思っていたことは、締め切りの境界のずれ。設定の値の間違い。古い値が残っていること。二回目は、何を疑ったのかが自分でも言えない。

まだ分からないことは、なぜ早朝の注文だけが断られるのか。

もう一つ、気づいた。僕はこの三日、苦情に返事を書くたびに「直りました」と書いていた。けれど、それは右の列のどこにも入らない。直った、というのは見たことではなく、僕の判断だった。

すぐにコードを開きたくなった。夜中だ。日付が変わったあとに何かある。

手が、キーボードの上で止まった。巻尺を当てる前に、汐見さんがペンを動かしていたのを思い出したからだ。測る前に、書いていた。

僕は裏紙を裏返して、汐見さんの野帳と同じ五つの列の表を引いた。やったことは実験、見たことは観察と、仕事で使う言葉に書き換えた。仮説、予測、実験、観察、結論。自分の三回の修正を、そこへ写してみる。

一回目。仮説は、締め切りの比較が一秒ずれている。予測は書ける。9時59分59秒か10時ちょうどの、どちらかで答えが仕様と違うはずだ。書きながら、気づいた。それなら、夜中の1時半の注文は関係ない。1時半は締め切りから三時間半も離れている。この仮説は、01:32 を説明できない。

三回目。仮説は、設定の締め切りの時刻が10時になっていない。予測も書ける。断られ始める時刻が、10時以外のどこかにあるはずだ。これも、日付が変わったあとの1時半を説明できない。日付が変われば、新しい日の締め切りまで、まだ二十時間あまりある。

二回目。仮説の欄に、何を書けばいいか分からなかった。値を捨てて読み直すようにした。何を疑って、そうしたのか。古い値が残っている気がした。どの値が、いつ、何のせいで古くなるのか。言えない。だから予測の欄が書けない。

ペンが止まった。

汐見さんが、二回目の行の空いた予測の欄を、指で一度だけ叩いた。何も言わなかった。

僕は、ログの時刻から、新しい候補を二つ、表の下に書き足した。

一つめは、「今日」の計算が、早朝だけ前の日になっている。

二つめは、早朝に走る在庫の更新の間だけ、受付が止まっている。うちの会社では、毎晩、夜中から朝にかけて、店舗の在庫を一括で更新している。その間、受付が何かに引っかかっていてもおかしくない。

どちらも、01:32 を説明できる。

番号を振った。境界のずれを H1、設定の時刻を H2、「今日」の計算を H3、在庫の更新を H4。二回目の修正は予測が書けないので、番号を振らなかった。だから三回目の修正が H2 になる。

H3 と H4 の予測の欄の前で、ペンがまた止まった。

汐見さんが、弁当の蓋を閉めた。

「少し、外に出ましょうか」

第3幕: 予測を先に書く

ドックの縁は、風が強かった。下では作業員が高圧洗浄機を構えて、さっき汐見さんが入れ替えた順番どおりに、見た目の濃い側の舷を洗っている。水が当たった所から、泥と貝殻が剥がれて、茶色い水になって流れていく。

「さっき、予測を書いたら二つ落ちました」

僕は言った。

「でも、全部測ってから、それに合う説明を探しても、同じように落とせませんか。そのほうが速い気がするんです」

汐見さんは、船体のほうを見たまま答えた。

「さっきの境目を、先に測ったとします。高い舷と低い舷が出ます。どちらが下だったか、それから決めれば、決めた仮説はいつも数字に合います」

「合うなら、いいじゃないですか」

「どんな数字が出ても合います。逆の数字が出ても、逆の仮説を書けば合います」

汐見さんは、そこで少し間を置いた。

「先に書いた予測だけが、外れることができます。外れた仮説だけが、確実に候補から落ちます」

僕はすぐには何も返せなかった。洗浄機の水しぶきの向こうで、さっき下だと書かれて、線を引かれた側の舷が、少しずつ鋼の色に戻っていくのを見ていた。

「これ、何ていう調べ方なんですか」

ようやくそう聞くと、汐見さんは調べ方の名前と、本の名前だけを言った。科学的デバッグ。Andreas Zeller の『Why Programs Fail』の6章。

あとで調べた。

科学的デバッグ(Scientific Debugging)は、失敗を観察し、その原因の仮説を立て、仮説から予測を立て、実験で予測を確かめ、仮説がそれ以上絞れなくなるまで繰り返す調べ方だ。Zeller は6章で、デバッグに当てはめた手順を次の五段で書いている。

  1. 失敗を観察する(Observe a failure)
  2. 観察と矛盾しない、失敗の原因の仮説を立てる(Invent a hypothesis as to the failure cause that is consistent with the observations)
  3. 仮説を使って予測する(Use the hypothesis to make predictions)
  4. 実験とさらなる観察で仮説を確かめる(Test the hypothesis by experiments and further observations)。予測どおりなら仮説を絞り込み、予測どおりでなければ別の仮説を立てる
  5. 仮説がそれ以上絞れなくなるまで、3と4を繰り返す(Repeat steps 3 and 4 until the hypothesis can no longer be refined)

同じ章の 6.5 は、記録の付け方を扱っている。書き残すのは、問題の報告、原因の仮説、仮説の予測、予測を確かめるための実験、実験で観察した結果、結果から出した結論。Zeller はこれを logbook と呼ぶ。汐見さんの野帳の列は、これだった。

確証バイアス、という言葉も、そのとき知った。自分の仮説を、否定するより確かめようとする傾向のことだ。

ソフトウェアの開発者とテストの担当者を対象にした研究がある(Calikli, Arslan, Bener「Confirmation Bias in Software Development and Testing」PPIG 2010)。大手の通信会社、小さな会社三社、大学院生の合わせて88人を調べて、確証バイアスの強さと、コードの欠陥の多さに直接の相関があったと報告している。経験の長さは、偏りを減らしていなかった。偏りを減らしていたのは、論理的に推論する力と、仮説を検定する技能のほうだった。

慣れれば避けられるものではなかった、ということだ。

事務所に戻る前に、ドックの縁で、自分の言葉で言い直してみた。

「つまり、予測が当たったら、その仮説が正しいって分かるってことですよね」

汐見さんは、半分だけ頷いた。

「当たったら、その仮説が残った、ということです。ほかの仮説も同じ予測をしていたら、当たっても何も分かりません」

「……外れたら」

「外れたら、その仮説が候補から落ちます」

僕はもう一度、言い直した。

「当たるかどうかより、外れたらどの仮説が落ちるかで、実験を選ぶ」

「それで」

汐見さんはそれだけ言った。

汐見さんは、そのままドックの底へ下りていった。

僕は事務所に戻って、裏紙の表の前に座った。いま生きている候補は、H3 と H4。どちらも、01:32 を説明できる。

どこで確かめればいいのか。夜中から朝まで、全部の時刻で試せばいいのではないか、と書きかけて、さっきの言い直しに戻った。外れたら、どれが落ちるか。

H4 が正しいなら、注文を断っているのは在庫の更新のほうで、受付の関数ではない。受付の関数だけを取り出して、時刻を渡して直接呼べば、夜中の0時47分でも受け付けられるはずだ。在庫の更新は、その呼び出しのどこにも関わらない。

H3 が正しいなら、関数だけを呼んでも、0時47分は断られる。

同じ日の夜9時半は、どちらの仮説でも受け付けられる。比べるための目印になる。

だから、関数に0時47分と夜9時半を直接渡せば、一度で H3 と H4 が分かれる。

全部を試す必要はなかった。仮説ごとに答えの違う一点だけでいい。

ここまでを並べると、役割が二つに分かれていた。候補を作るのは観察だ。ログの時刻を見たから、H3 と H4 が出てきた。候補を落とすのは予測だ。予測を先に書いてあるから、外れた候補は迷わず落とせる。落とした候補は記録に残るので、二度と調べに戻らない。候補は、増えることはあっても、同じものが戻ってくることはない。だから、行ったり来たりせずに前へ進む。

テストを書いた。仮説一つにつき、テストを一本。どのテストも、予測そのものではなく、仕様どおりに正しく動くことを確かめる形にした。仕様の形で書いておけば、原因が見つかって直したあと、そのまま再発を見張るテストとして残せる。テストの冒頭には、裏紙の表と同じ五つの列を、t.Log(テストの中から出力に一行を書き出す関数)で書いた。

書きながら、このテストの読み方を自分に言い聞かせた。どのテストも、仕様どおりに動くことを確かめる。だから H1 と H2 が緑なら、予測した誤りが起きなかった、つまり仮説は外れた、ということになる。H3 が赤なら、予測した誤りが起きた、つまり仮説が残った、ということになる。注文が通る(受け付けられる)ことと、テストが通る(PASS)ことを混ぜないように、テストのほうは緑と赤で呼ぶことにした。

hypotheses_test.go は、こうなった。まず、記録を書くための関数と、時刻を作る道具。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// logbook は、この仮説を調べたときの記録(Zeller の5列)を t.Log に書く。
// 観察と結論は、実験した時点で書いたもの。テストの合否はこの記録とは別に、
// 仕様どおりに動くかどうかだけを確かめる。
func logbook(t *testing.T, hypothesis, prediction, experiment, observation, conclusion string) {
	t.Helper()
	t.Log("仮説: " + hypothesis)
	t.Log("予測: " + prediction)
	t.Log("実験: " + experiment)
	t.Log("観察: " + observation)
	t.Log("結論: " + conclusion)
}

// at は 2026-11-16 の JST の時刻。
func at(h, m, s int) time.Time { return time.Date(2026, 11, 16, h, m, s, 0, jst) }

jst は time.FixedZone("JST", 9*60*60)、日本の時刻(協定世界時より9時間進んでいる)を表す。時刻はすべてテストの中で決めて渡すので、いつ流しても同じ結果になる。accepted と rejected は、その時刻の注文が受け付けられて配達日が翌日になること、締め切りを過ぎたとして断られることを確かめる小さな関数だ。

そして、仮説ごとのテスト。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func TestH1_締め切りの境界(t *testing.T) {
	logbook(t,
		"締め切りの比較が1秒ずれている",
		"21:59:59 か 22:00:00 のどちらかで、仕様と違う答えになる",
		"21:59:59 と 22:00:00 を Accept に渡す",
		"21:59:59 は受け付け、22:00:00 は断った。仕様どおり",
		"棄却",
	)
	accepted(t, at(21, 59, 59))
	rejected(t, at(22, 0, 0))
}

func TestH2_設定の締め切り時刻(t *testing.T) {
	logbook(t,
		"設定の締め切り時刻が22時でない",
		"断られ始める時刻が22時以外にある",
		"21:00 と 21:30 と 22:30 を Accept に渡す",
		"21:00 と 21:30 は受け付け、22:30 は断った。22時で切り替わっている",
		"棄却",
	)
	accepted(t, at(21, 0, 0))
	accepted(t, at(21, 30, 0))
	rejected(t, at(22, 30, 0))
}

func TestH3_今日の日付の計算(t *testing.T) {
	logbook(t,
		"「今日」の計算が、早朝だけ前の日になっている(対立仮説 H4: 早朝の在庫更新の間だけ受付が止まる)",
		"H3 なら、Accept だけを呼んでも 00:47 は断られ、21:30 は受け付けられる。H4 なら、Accept だけを呼べば 00:47 も受け付けられる",
		"2026-11-16 00:47 と 2026-11-16 21:30 を Accept に渡す(配達日はどちらも 11-17 が正しい)",
		"Accept だけを呼んでも 00:47 は断られた。21:30 は受け付けられた",
		"H3 は候補として残る。H4 は棄却",
	)
	accepted(t, at(0, 47, 0))
	accepted(t, at(21, 30, 0))
}

書いた順番は、表と同じだった。仮説、予測、実験の三行を書いてから一度流し、結果を見て、観察と結論の二行を書き足した。H4 は H3 と同じ実験で分かれるので、テストを別に立てず、H3 の記録の行に書いた。二回目の修正、値を毎回読み直すようにしたものは、テストにしなかった。予測の欄が書けない仮説は、実験にかけようがない。

一つ、自分に問うた。H1 と H2 は、裏紙の上で 01:32 を説明できないと分かった時点で、もう外れている。それでもテストにするのはなぜか。

外れたことを頭で覚えておくだけだと、次に似た苦情が来たとき、僕はまた境界から疑い直す。三日前の僕がそうだったように。テストにしておけば、境界と締め切りの時刻はもう調べた所だと、誰が見ても分かる。Zeller は 6.4 で、実験とその結果を記録せずに推理するのは、頭の中だけでマスターマインドをやるようなものだ、と書いている。相手が隠した色の並びを、手がかりを積み重ねて当てる推理ゲームだ。だから、H1 と H2 のテストは消さずに残すことにした。

仮説のテストを足しても、Accept の中身は一行も変えていない。足したのはテストのファイルだけだ。製品としての振る舞いは、何も変わっていない。

流した。-v(詳しい出力を出す指定)を付けたのは、t.Log の行が、テストが緑のときにはこれを付けないと表示されないからだ。-run 'TestH' で、名前が TestH で始まるテストだけを選んで流す。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
=== RUN   TestH1_締め切りの境界
    hypotheses_test.go:48: 仮説: 締め切りの比較が1秒ずれている
    hypotheses_test.go:48: 予測: 21:59:59 か 22:00:00 のどちらかで、仕様と違う答えになる
    hypotheses_test.go:48: 実験: 21:59:59 と 22:00:00 を Accept に渡す
    hypotheses_test.go:48: 観察: 21:59:59 は受け付け、22:00:00 は断った。仕様どおり
    hypotheses_test.go:48: 結論: 棄却
--- PASS: TestH1_締め切りの境界 (0.00s)
=== RUN   TestH2_設定の締め切り時刻
    hypotheses_test.go:60: 仮説: 設定の締め切り時刻が22時でない
    hypotheses_test.go:60: 予測: 断られ始める時刻が22時以外にある
    hypotheses_test.go:60: 実験: 21:00 と 21:30 と 22:30 を Accept に渡す
    hypotheses_test.go:60: 観察: 21:00 と 21:30 は受け付け、22:30 は断った。22時で切り替わっている
    hypotheses_test.go:60: 結論: 棄却
--- PASS: TestH2_設定の締め切り時刻 (0.00s)
=== RUN   TestH3_今日の日付の計算
    hypotheses_test.go:73: 仮説: 「今日」の計算が、早朝だけ前の日になっている(対立仮説 H4: 早朝の在庫更新の間だけ受付が止まる)
    hypotheses_test.go:73: 予測: H3 なら、Accept だけを呼んでも 00:47 は断られ、21:30 は受け付けられる。H4 なら、Accept だけを呼べば 00:47 も受け付けられる
    hypotheses_test.go:73: 実験: 2026-11-16 00:47 と 2026-11-16 21:30 を Accept に渡す(配達日はどちらも 11-17 が正しい)
    hypotheses_test.go:73: 観察: Accept だけを呼んでも 00:47 は断られた。21:30 は受け付けられた
    hypotheses_test.go:73: 結論: H3 は候補として残る。H4 は棄却
    hypotheses_test.go:80: 00:47:00 は受け付けるはず: err = 締め切りを過ぎました
--- FAIL: TestH3_今日の日付の計算 (0.00s)
FAIL
FAIL	salvager/ep01/stage-act3/expressorder	0.078s
FAIL

H1 と H2 が緑。H3 が赤。H3 の赤の一行が、関数だけを呼んでも0時47分が断られたことを示している。在庫の更新がなくても、症状は出る。H4 の予測は「原因が在庫の更新だけなら」という形で書いたものだから、外れたのはその形の H4 だ。在庫の更新がまったく関わっていないとまでは、この一回では言えない。それでも、受付の関数の中に、症状を一人で説明できる原因がある。H3 が残った。

第4幕: 九時の境目

Accept を、もう一度頭から読んだ。

早朝だけ「今日」が前の日になるのなら、怪しいのは日付を作っている行しかない。today := now.Truncate(24 * time.Hour)。今日の0時を作るつもりで書いた行だ。

Truncate は、時刻を指定した長さの倍数に切り捨てる関数だ。日の頭に揃えるときに、これまで何度も、何の疑いもなく書いてきた。前の職場ではサーバーも時刻も UTC で扱っていて、それで困ったことは一度もなかった。だからこそ、仕様を読み直した。Go の標準ライブラリの説明(pkg.go.dev の time パッケージ)には、こう書いてある。

Truncate returns the result of rounding t down to a multiple of d (since the zero time).

Truncate operates on the time as an absolute duration since the zero time; it does not operate on the presentation form of the time.

Truncate は、ゼロ時刻からの絶対的な経過時間に対して切り捨てる。表示上の時刻、つまり日本の時刻で見た何時何分に対してではない。そして、Go のゼロ時刻は「1年1月1日 0時0分0秒 UTC」、協定世界時(UTC)の時刻だ。

そこから、仮説を絞った。24時間ごとの切れ目は、UTC の0時に来る。日本の時刻は UTC より9時間進んでいるから、UTC の0時は、日本の時刻では朝の9時だ。Truncate は時刻が持っている場所(日本の時刻)を付けたまま、値だけを UTC の区切りに丸める。だから、表示は日本の時刻のまま、朝9時に揃う。

紙に、値を一つずつ追ってみた。

11月16日の0時47分(日本の時刻)は、UTC では11月15日の15時47分だ。24時間の倍数に切り捨てると、UTC の11月15日0時。日本の時刻で言えば11月15日の朝9時になる。today.Date() はこれを11月15日と読む。締め切りは11月15日の夜10時になり、0時47分はそれより後だから、断られる。

同じ日の夜9時半は、UTC では11月16日の12時30分だ。切り捨てると UTC の11月16日0時、日本の時刻で11月16日の朝9時。日付は11月16日のままで、締め切りは11月16日の夜10時。夜9時半はその前だから、受け付けられる。

紙の上で追った二つの時刻を、横に並べて書き直した。

0時47分と夜9時半の2本の時刻が同じ日付の計算を通る流れ。0時47分はTruncateで前日11月15日の朝9時に揃い、締め切りが前日の夜10時になって断られる。夜9時半は11月16日の朝9時に揃い、締め切りの前なので受け付けられる

二本の筋が分かれる時刻帯を、僕はこの三日間、一度もテストで通っていなかった。

昼や夜の時刻で書いたテストが全部緑だったのは、このためだった。朝9時から夜12時の手前までは、切り捨てても日付が変わらない。日付が一日ずれるのは、0時から朝9時の手前までだけだ。Truncate で日を切っても正しいのは、時刻を UTC で扱っているときだけになる。日本の時刻の値に使うと、日の区切りが9時間ずれる。

予測を先に書いた。それなら、日本の時刻で日が変わるのは朝の9時になる。8時59分59秒の注文は前の日として扱われて断られ、9時0分0秒の注文は受け付けられるはずだ。「今日」がずれる理由がほかにあるなら、9時ちょうどで分かれる理由はない。

H3 のテストに、サブテスト(t.Run で一つのテストの中に名前を付けて立てる、小さなテスト)を一つ足した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
	t.Run("切り替わりは9時", func(t *testing.T) {
		logbook(t,
			"UTC の 0 時で日を切っている",
			"JST と UTC の差は9時間なので、受付の切り替わりは 08:59:59 と 09:00:00 の間にある",
			"08:59:59 と 09:00:00 を Accept に渡す",
			"08:59:59 は断られ、09:00:00 は受け付けられた",
			"採用。UTC の 0 時で日を切っている",
		)
		accepted(t, at(8, 59, 59))
		accepted(t, at(9, 0, 0))
	})

H3 だけを流した(-run 'TestH3')。

1
2
3
4
5
6
7
8
9
=== RUN   TestH3_今日の日付の計算/切り替わりは9時
    hypotheses_test.go:84: 仮説: UTC の 0 時で日を切っている
    hypotheses_test.go:84: 予測: JST と UTC の差は9時間なので、受付の切り替わりは 08:59:59 と 09:00:00 の間にある
    hypotheses_test.go:84: 実験: 08:59:59 と 09:00:00 を Accept に渡す
    hypotheses_test.go:84: 観察: 08:59:59 は断られ、09:00:00 は受け付けられた
    hypotheses_test.go:84: 結論: 採用。UTC の 0 時で日を切っている
    hypotheses_test.go:91: 08:59:59 は受け付けるはず: err = 締め切りを過ぎました
--- FAIL: TestH3_今日の日付の計算 (0.00s)
    --- FAIL: TestH3_今日の日付の計算/切り替わりは9時 (0.00s)

8時59分59秒が断られた。9時0分0秒の行は、何も出ていない。受け付けられた、ということだ。予測が当たった。H3 は残り、UTC の0時で日を切っている、というところまで絞り込まれた。

裏紙の表を、最初の行から順に目で辿り直した。

推論の流れ。苦情の時刻という観察から仮説H3とH4が生まれ、H1とH2は1時32分の注文を説明できず棄却。Acceptだけに0時47分を渡す一つの実験でH4を棄却し、朝9時の切り替わりを予測して確かめ、UTCの0時で日を切っているところまで絞り込む

このコードは、コンパイルが通る。型も合っている。Truncate が返すのも、Date が返すのも、正しい型の値だ。間違っていたのは型ではなく、値だった。日付が一日ずれた時刻という、正しい型の、間違った値。Go の型の検査では捕まらない種類の誤りだった。

直した。Truncate の行を消して、now の日付を、now が持っている場所(日本の時刻)でそのまま読む。

1
2
3
func Accept(now time.Time) (time.Time, error) {
	y, m, d := now.Date() // now の Location(JST)で暦の日付を読む
	deadline := time.Date(y, m, d, DeadlineHour, 0, 0, 0, now.Location())

now.Date() は、now を、それが持っている場所の時刻で見たときの年・月・日を返す。time.Date は、指定した場所での暦の日付と時刻から時刻を組み立てる。だから日本の時刻の0時47分なら、日本の暦の11月16日として読まれる。変えたのは、この一か所だけだ。

その代わり、日付の基準は now が持っている場所で決まるようになった。今の受付のハンドラは日本の時刻にしてから渡しているが、今後別の呼び出し元が UTC のまま渡せば、今度は UTC の暦で日付が読まれる。呼び出し元がその約束を守る仕組みまでは、今日は入れていない。

最初と同じ -run 'TestH' で、仮説のテストを全部流した。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
=== RUN   TestH1_締め切りの境界
    hypotheses_test.go:48: 仮説: 締め切りの比較が1秒ずれている
    hypotheses_test.go:48: 予測: 21:59:59 か 22:00:00 のどちらかで、仕様と違う答えになる
    hypotheses_test.go:48: 実験: 21:59:59 と 22:00:00 を Accept に渡す
    hypotheses_test.go:48: 観察: 21:59:59 は受け付け、22:00:00 は断った。仕様どおり
    hypotheses_test.go:48: 結論: 棄却
--- PASS: TestH1_締め切りの境界 (0.00s)
=== RUN   TestH2_設定の締め切り時刻
    hypotheses_test.go:60: 仮説: 設定の締め切り時刻が22時でない
    hypotheses_test.go:60: 予測: 断られ始める時刻が22時以外にある
    hypotheses_test.go:60: 実験: 21:00 と 21:30 と 22:30 を Accept に渡す
    hypotheses_test.go:60: 観察: 21:00 と 21:30 は受け付け、22:30 は断った。22時で切り替わっている
    hypotheses_test.go:60: 結論: 棄却
--- PASS: TestH2_設定の締め切り時刻 (0.00s)
=== RUN   TestH3_今日の日付の計算
    hypotheses_test.go:73: 仮説: 「今日」の計算が、早朝だけ前の日になっている(対立仮説 H4: 早朝の在庫更新の間だけ受付が止まる)
    hypotheses_test.go:73: 予測: H3 なら、Accept だけを呼んでも 00:47 は断られ、21:30 は受け付けられる。H4 なら、Accept だけを呼べば 00:47 も受け付けられる
    hypotheses_test.go:73: 実験: 2026-11-16 00:47 と 2026-11-16 21:30 を Accept に渡す(配達日はどちらも 11-17 が正しい)
    hypotheses_test.go:73: 観察: Accept だけを呼んでも 00:47 は断られた。21:30 は受け付けられた
    hypotheses_test.go:73: 結論: H3 は候補として残る。H4 は棄却
=== RUN   TestH3_今日の日付の計算/切り替わりは9時
    hypotheses_test.go:84: 仮説: UTC の 0 時で日を切っている
    hypotheses_test.go:84: 予測: JST と UTC の差は9時間なので、受付の切り替わりは 08:59:59 と 09:00:00 の間にある
    hypotheses_test.go:84: 実験: 08:59:59 と 09:00:00 を Accept に渡す
    hypotheses_test.go:84: 観察: 08:59:59 は断られ、09:00:00 は受け付けられた
    hypotheses_test.go:84: 結論: 採用。UTC の 0 時で日を切っている
--- PASS: TestH3_今日の日付の計算 (0.00s)
    --- PASS: TestH3_今日の日付の計算/切り替わりは9時 (0.00s)
PASS
ok  	salvager/ep01/after/expressorder	0.081s

全部、緑だった。H3 のテストが、症状を再現するテストになった。掲載したテストでは、代表例の0時47分が、直した関数で受け付けられた。在庫更新だけが原因という H4 は、この関数単体の実験で棄却できた。日付計算まで原因を絞れたのは、次の9時境界の実験による。記録の行は、直す前と同じことを言っている。調べたときに何を考え、何を見たか。それは直したあとも変わらない。

この緑は、一回目から三回目の「通った」とは違う。今度は、直す前に同じテストが赤だったのを、この目で見ている。直す前に赤で、直したあとに緑だから、直ったと言える。

もう一つ、確かめた。直したことで、ほかの時刻の答えが変わっていないか。比べるために、直す前の関数を before パッケージとして、直したあとの関数を after パッケージとして、両方残しておいた。その二つに同じ時刻を渡して答えを比べるテストを書いた。このテストのファイルでは、日付と時刻の両方を受け取る別の at を使っている。朝9時から夜12時の手前までの注文は、このバグを踏まない時刻の範囲だ。この範囲では、配達日と、断るかどうかの答えが、直す前とまったく同じになるはずだ。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// 9:00:00〜23:59:59(バグを踏まない時刻)では、戻り値が一致する。
func TestEquivalence_九時から二十四時は同じ答え(t *testing.T) {
	for _, day := range days {
		for _, c := range []clock{{9, 0, 0}, {9, 30, 0}, {12, 0, 0}, {21, 59, 59}, {22, 0, 0}, {22, 30, 0}, {23, 59, 59}} {
			now := at(day, c)
			bg, be := before.Accept(now)
			ag, ae := after.Accept(now)
			if (be == nil) != (ae == nil) ||
				errors.Is(be, before.ErrPastDeadline) != errors.Is(ae, after.ErrPastDeadline) ||
				!bg.Equal(ag) {
				t.Errorf("%v: before = (%v, %v), after = (%v, %v)", now, bg, be, ag, ae)
			}
		}
	}
}

days は、11月16日、11月30日、12月31日の三日。月末と年末を入れたのは、配達日が翌月や翌年に繰り上がる所でも答えが同じかを見るためだ。夜中の0時から朝9時の手前までは、別のテストで、直す前は断り、直したあとは翌日の配達日を返すことを確かめた。両方とも緑。比べたのは関数の戻り値である配達日と、断ったときに返すエラーだけで、t.Log の記録や、処理にかかった時間は比べていない。

声は出なかった。画面を汐見さんのほうへ向けかけて、汐見さんが船に戻っていたことを思い出して、やめた。

事務所を出ると、洗浄は甲板まで進んでいた。

ドックの縁から見下ろすと、甲板の一区画だけ、泥が落ちて鋼の地肌が出ていた。午前中、茶色い塊にしか見えなかった船の上に、平らな板の面がある。高圧洗浄機の水が当たるたびに、その面が少しずつ広がっていく。

泥の落ちた所に、四角い台座が並んでいた。鋼の板を溶接した台座が、等間隔に六つ。甲板の上に、一列に。

汐見さんが、作業員から丸めた紙を受け取って、広げた。紙は大きく、船を上から見た線が引いてある。設計図の写しだ。汐見さんは紙と甲板を見比べた。一度、紙に目を落とし、台座を見る。もう一度、紙に目を落とし、また台座を見る。

何も言わなかった。紙を丸めて作業員に返すと、胸ポケットから野帳を出して、何か書いた。

僕も、何も聞かなかった。

汐見さんがドックの底から上がってきたところで、直ったことを報告した。

「何を確かめて、何は確かめていませんか」

汐見さんはそう聞いた。報告に、そのまま問いが返ってきた。汐見さんは野帳の今日のページを開き、ペンを持った。

僕は、自分で答えた。汐見さんは、僕の言うことを短く区切りながら書き取っていった。確かめたのは、翌朝便の受付で、早朝の注文が前の日として扱われていたこと。直したあとは、夜中の0時から朝9時の手前までの注文も翌朝便として受け付けられること。朝9時から夜12時の手前までの答えは、直す前と変わっていないこと。

確かめていないのは、ほかの処理で同じ Truncate を使っている所。まだ探していない。別の呼び出し元が日本の時刻以外で渡したときの扱いも、まだ決めていない。日本以外のタイムゾーンも試していない。夏時間がある地域も。time.Date は、夏時間の切り替わりで時刻が飛んだり重なったりするとき、どちらの時刻を返すかを保証しない。日本には夏時間がないので今回は当たらないが、ほかの地域にそのまま持っていけるとは言えない。

「毎回、これをやるんですか」

聞いてみた。

「いつもこんなに書いていたら、仕事にならない気がして」

「一回目の思いつきで直らなかったら、です」

汐見さんはそう言った。

Zeller も同じ章の 6.6 で、そう書いている。手早い調べ方で10分たっても見つからなければ、科学的な方法に切り替えて、問題を記録に書き出せ、と。単純な問題には、この手順は要らない。僕は、10分で切り替えるところを三日かけていた。

「今日のそのページ、写させてもらえませんか」

汐見さんは少し考えるような間を置いてから、野帳を開いて僕に渡した。書き写す紙は持っていなかったので、鞄に入っていた食堂の伝票の束の裏を使った。

汐見さんの字は小さく、詰まっていた。上半分は午前中の船体の数字で、どれも、何センチ、何か所、と単位まで書いてある。下半分は、さっき僕が答えたことを汐見さんの言葉で書いたものと、台座の一行だった。舷の名前を書いた行は、何と読むのか分からない字が混じっていて、写し飛ばした。

写し終わるまで、汐見さんは隣で、作業員に次の洗浄の段取りを伝えていた。野帳を返すと、汐見さんはそれを受け取って胸ポケットに戻し、ドックの底へ下りていった。

夜、食堂の隅の席で、四件目の苦情に返事を書いた。

祖母は帳場で今日の伝票を数えている。最後の客が帰ったあとの、店じまいの音。僕は、客の言葉ではなく時刻で書いた。お客様のご注文は、午前5時40分に受け付けられるはずのものでした。こちらの不具合で、前日の注文として扱われていました。

送る前に、もう一度だけテストを流した。

--- PASS: TestH3_今日の日付の計算。

緑の行の上に、直す前と同じ記録が並んでいる。仮説、予測、実験、観察、結論。H1 と H2 の結論は、棄却のまま残っていた。


野帳の写し

  • 見たこと: 苦情4件。注文の時刻は 01:32、00:47、02:15、05:40。9時から24時の注文は、直す前から正しく受け付け・断りができています。直す前、08:59:59 は断られ、09:00:00 は受け付けられました
  • 思ったこと: 締め切りの境界が1秒ずれている(棄却)。設定の締め切り時刻が22時でない(棄却)。古い値が残っている(予測が書けず、仮説になりませんでした)。早朝の在庫更新の間だけ受付が止まる(棄却)。「今日」の計算が早朝だけ前の日になる(採用。UTC の0時で日を切っている、まで絞りました)
  • 調べ方: 科学的デバッグ(Scientific Debugging)。失敗を観察し、仮説を立て、予測を先に書き、実験で確かめ、絞れなくなるまで繰り返します
  • 固定したもの: 再現テスト TestH3_今日の日付の計算(直す前は赤、直したあとは緑)。棄却した2本のテストも残しました
  • 変えていないもの: 9時から24時の注文で、配達日と断りの答え(戻り値)は直す前と同じです。比べていないものは、テストの記録と処理の時間です
  • まだ見ていない所: ほかの処理の Truncate。別の呼び出し元が日本の時刻以外で渡したときの扱い。日本以外のタイムゾーン。夏時間のある地域
  • 船について: 甲板の上、泥を落とした一区画に、設計図にない溶接の台座が並んでいます。6基、等間隔
comments powered by Disqus
システム開発・AIワークフローのご相談は Meetsource へ
Hugo で構築されています。
テーマ Stack は Jimmy によって設計されています。