CHANGELOGの差分を検知して要約するスクリプトを書いた。実データで検証したら、
45行中4行が新規なのに「既存」判定されて消えていた。しかも悪いことに、
条件が揃うと要約せずにリリースを丸ごと黙って取りこぼす。
原因は、行差分を「集合に含まれるか」で取っていたことだった。
最初の実装(一見自然だが壊れている)
def get_added_lines_v1(old: str, new: str) -> list[str]:
old_lines = set(old.splitlines())
return [line for line in new.splitlines() if line not in old_lines]
「前回に無かった行が新規」という考え方は自然に見える。だが CHANGELOG には
- Fixed a crash on startup
- Fixed a crash on startup
- Added support for custom themes
のような定型文が別のバージョンでも繰り返し出る。集合は「その文字列が
かつてどこかにあったか」しか見ないので、今回新しく追加された行でも、
過去のどこかに同じ文言があれば「既存」に分類されてしまう。
実測では45行中4行がこれで消えた。実害はもっと大きい。
さらに悪いケース:差分が空になり、リリースごと消える
全部が定型文の組み合わせだけのリリースだと、新規行が0件になる。
added = get_added_lines_v1(old, new)
if not added:
save_state(new) # 「新規行なし」として状態だけ進める
return # 要約せず、何も出力せず、静かに終わる
要約されないだけでなく、記録にも残らない。 次に見るときには「あの日は
何も更新が無かった」ように見える。実際には更新があったのに。
直し方:位置を見て差分を取る
difflib.SequenceMatcher は文字列の中身ではなく列としての並びを見て
差分を取るので、同じ行が複数箇所にあっても正しく扱える。
import difflib
def get_added_lines(old: str, new: str) -> str:
"""位置を見た行差分。新規に挿入された行だけを順に返す。
集合の所属で判定すると、文言が他所に既出の行は本当に新規でも落ちる。
autojunk は明示的に切る(挙動の詳細は後述)。
"""
old_lines = old.splitlines()
new_lines = new.splitlines()
sm = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
added: list[str] = []
for tag, _i1, _i2, j1, j2 in sm.get_opcodes():
if tag in ("insert", "replace"):
added.extend(new_lines[j1:j2])
return "\n".join(added)
autojunk は明示的に False にした(ただし確認できたのはここまで)
SequenceMatcher の autojunk は既定で True。公式ドキュメントによれば、
要素数が200を超え、かつある要素が全体の1%超を占めるとき、その要素を
「ノイズ(popular)」として一致探索の対象から外すヒューリスティックで、
長い文字列どうしを比較するときの性能劣化を防ぐためのものだ。
CHANGELOGやログは同じ行(特に空行)が繰り返し出るので、この条件に当たりうる。
実装では念のため autojunk=False を明示して切った。
sm = difflib.SequenceMatcher(None, old_lines, new_lines, autojunk=False)
正直に書くと、手元の再現コードでは autojunk の有無で get_opcodes() の
結果が変わるケースを作れなかった。 挿入・追記程度の単純な差分では、
ノイズ扱いされた要素があっても最終的な一致区間は変わらないようだ。
それでも明示的に切っている。理由は2つ。**①この最適化はそもそも性能のための
ものであって、CHANGELOGの差分検知くらいの規模(後述するが数千行で0.002秒)
では要らない。**性能のために入れた仕組みを、性能が要らない場面まで
持ち込む理由がない。②autojunk=True のときに何が「ノイズ」扱いされるかは
入力依存で、こちらで制御できない。 差分検知のように「取りこぼしが無いこと」
を保証したい処理では、制御できないヒューリスティックは切っておくという
判断のほうが、動くことを祈るより筋が通っている。
ここは実証できていない主張を書かないための注記だ。 「境界がずれる」と
言い切れるだけの再現ケースが作れなかったので、そう書かなかった。
速度は言い訳にならない
「集合のほうが速いから」と思うかもしれないが、測ってみると誤差だった。
import time
text_a = "\n".join(f"line {i}" for i in range(6219)) # 実データ相当(609KB)
text_b = text_a + "\nnew line"
start = time.perf_counter()
get_added_lines(text_a, text_b)
print(f"{time.perf_counter() - start:.4f}s")
手元では 0.002秒前後だった(3回実測して0.0016〜0.0021秒)。CHANGELOG や
ログの差分検知程度の規模なら、速度を理由に集合に妥協する必要はない。
テストで固定する
一度直しても、また誰かが「集合のほうがシンプルだから」と書き換えるかもしれない。
再現する条件をそのままテストにする。
def test_定型行が繰り返されても新規行は消えない():
old = "line A\nline B\n"
new = "line A\nline B\nline A\n" # 既存と同じ文言の行が新規に増えた
assert get_added_lines(old, new) == "line A"
def test_全部が既出でも差分ゼロで沈黙しない():
"""このケースで added が空になると、呼び出し側が
『新規行なし』として状態だけ進め、リリースを丸ごと取りこぼす。"""
old = "line A\nline B\n"
new = "line B\nline A\n" # 中身は同じでも順序が変わった
assert get_added_lines(old, new) != ""
どちらも、実際に踏んだ事故(新規行の消失・リリースの丸ごと取りこぼし)を
そのまま再現している。
まとめ
-
「前と比べて無かったものが新規」を集合で判定すると、繰り返し出る定型行で
誤判定する。 行差分は位置(列としての並び)を見る -
difflib.SequenceMatcherのautojunkは性能のためのヒューリスティックで、
制御できない条件で頻出要素をノイズ扱いする。性能が要らない規模なら切る。
ただし今回の再現では挙動の違いを実証できなかった —— 検証できていない
効果を「効いている」と書かないほうが、話としては地味でも正直だ -
差分が空になったときの挙動を疑う。 「新規なし」で状態だけ進めると、
本当は更新があったリリースを記録にも残さず丸ごと取りこぼす - 数千行・数百KB程度なら、集合をやめても速度は問題にならない。測ってから言う
note でも書いています
このシリーズ以外に、AIエージェントの運用から広がった政治・経済・メディア構造の話を
note で書いています。