オチツカレサマ 〜 99%のゴミを消した昼と、rclone copy の罠

オチツカレサマ 〜 99%のゴミを消した昼と、rclone copy の罠

はじめに

前夜、OCR結果が元PDFの12倍に膨れていることに気づいた。99.4%が viz_page_*.jpg、つまり機械側の自己点検資料。保存しないのが正しいという結論に、思想(五蘊)と容量計算の両方が同じ場所を指していた。

朝、それを実装する。

ただし、Claude Codeにいきなり「viz画像を消すコードを書いて」と投げない。まず仕様を書く。これが昨夜立てたルールだった。


仕様を先に書いた朝

docs/specs/2026-04-27-viz画像抑止.md を作る。

含めたもの:

これを Claude Code に貼って「実装する前に CLAUDE.md と docs/specs/ を作って」と頼む。コードを触る前に、要件と作業規約を物理ファイルとして固定する

夜の判断力で広い指示を出さない、ためのワークフローだった。

「結構描いてるじゃん」事件

ところがこの直後、私は早速ひとつやらかしている。

Claudeに「CLAUDE.md の内容は別途指示します」と言ってから渡したテンプレと、Claudeが先に作っていた草案で巨大な差分が出た。Claudeはモノレポ規約・五蘊思想・既知課題を「Claudeが迷わないための要点」というフレームで丁寧に書いていた。私はそれをそのまま全置換させかけた。

「差分大きすぎ。結構描いてるじゃん!CLAUDE.md。一旦落ち着こう。」

書いている途中で気づいて、止めた。前夜「夜は重要ファイルを触らない」と書いたばかりの教訓が、朝も同じように効いていた。判断力が落ちていなくても、テンプレ全置換は危険。差分が大きい時点で立ち止まる。

最終的に、Claudeが先に書いた CLAUDE.md のうち、私のテンプレが扱っていない部分(モノレポ規約・五蘊・既知課題)はそのまま残した。重複する部分は私のテンプレに寄せた。マージできる範囲だけマージ、できない範囲は無理にしない

CLAUDE.md には、結果として:

が並んだ。これは事故を起こさずに残った。

調査 — viz画像はOCRに使われていない

仕様には「実装の自由度」として「NDLOCR-Lite側で viz生成を抑止可能かどうかは Claude Code 側で調査して判断してよい」と書いた。

Claude Code が両エンジンのソースを読みにいく。

NDLOCR-Lite の src/ocr.py:122-137:

def inference_on_detector(args, inputname, npimage, outputpath, issaveimg=True):
    detector = get_detector(args)
    detections = detector.detect(npimage)         # ← OCR検出はここで完了
    classeslist = list(detector.classes.values())
    if issaveimg:                                  # ← 以降は描画&保存だけ
        drawimage = npimage.copy()
        pil_image = detector.draw_detections(...)
        pil_image.save(output_filepath)
    return detections, classeslist                 # ← 戻り値は detections のみ

detectionsif issaveimg:手前で確定している。viz画像は単に「検出結果を元画像に上から描画した人間用の確認画像」で、recognizerやテキスト化には1ミリも流れない

入力画像 ──┬──> detector.detect() ──> detections ──> recognizer ──> page_*.{txt,json,xml}
           │
           └──> (viz=True のみ) draw_detections() ──> viz_page_*.jpg  ← OCRには戻らない

NDLkotenOCR-Lite (src/ocr.py:50-63) も同じ構造。

そして両エンジンとも --viz のデフォルトが False だった:

parser.add_argument("--viz", type=bool, required=False, ..., default=False)

つまり、--viz を渡さなければ、エンジン側のデフォルトで viz は生成されない。「生成→削除」のワークアラウンドは要らない。生成しないですむ。

私が前夜「生成自体を抑止できるなら、それが望ましい」と書いた最良ケースが、最初から手の届く場所にあった。

余談:argparse の罠

両エンジンは type=bool を使っている。これはPython argparseの有名な落とし穴で、bool("False") == True(非空文字列はすべてTruthy)になるため、--viz False と書いてもviz は保存される

幸い私たちのラッパーは「Falseのときは --viz フラグ自体を渡さない」という書き方をしていたので、この罠は踏んでいなかった。気づかずに踏まなかった罠は、気づいたら必ずメモする

実装 — feat/viz-suppress

ブランチを切る。

git checkout -b feat/viz-suppress

変更点は最小限:

pipeline.pytry/finally を消したとき、最初に動かしたらSyntaxError: expected 'except' or 'finally' block で死んだ。try: の本体だけ消して try: 自体を残してしまっていた。try を撤去し、内側の処理をそのまま関数本体に格上げする。

例外は呼び出し元の process_engine がループで捕捉してくれるので、try を消しても挙動は変わらない。むしろ「成功時に消える、失敗時に残る」がコードの構造そのもので表現されるようになった。

1回目の検証 — 「変わってないかぁ」

feat/viz-suppress で同じPDF(日本語原学_104-203、100ページ)を --no-skip で再処理した。

5分。完走した。Vault にも Markdown が出ている。

$ rclone size garage:pdf-library/ocr-results/ndlocr/日本語原学_104-203_digidepo_1836174_0001/
Total objects: 401
Total size: 594.068 MiB (622925828 Byte)

594MB

変わっていない。

「変わってないかぁ。別のやつでやってみたいな」とSlackに書いた(実態はClaude Codeとの対話)。

真因 — rclone copy は destination を消さない

落ち着いて考えると、これは新コードのバグではなかった。

pipeline.pygarage.upload_dirrclone copy を使う。コピー元に無いファイルを destination から削除しない。rsyncの --delete 相当の挙動を持たない。

時系列でいうと:

  1. 過去の実行で、ocr-results/.../viz_page_*.jpg 100枚が Garage にアップ済み
  2. 検証前、私が purge を提案したパスは 日本語原学_104-203/digidepo接尾辞なし)→ 別フォルダなので何も消えなかった
  3. 今回のクリーンな実行は viz を生成していない(新コード正常)
  4. rclone copy は新しい page_*.txt/.json/.xml を上書きアップロードしたが、過去の viz_page_*.jpg 100枚はそのまま残った
  5. 合計サイズは「過去viz + 新page」で、元と同じ ~594MB に見える

つまり新コードは viz を上げていないけれど、過去の遺産が残ったまま。size比較では効果が見えない。

「rclone copy は destination 側を消さない」。書いてしまえば3秒で読めるが、リアルタイムで「サイズ変わってない」を見たときは一瞬うろたえた。基本仕様の確認は、検証コマンドそのものの解釈にも効く

新規PDFでの確認 — 静かに通った

過去の遺産がない PDF を1冊、新規にGarageに上げて流した。6ページ、7.208 MiB。

$ rclone lsf garage:pdf-library/ocr-results/ndlocr/<新hash>/ | grep -c viz_
0

$ rclone size garage:pdf-library/ocr-results/ndlocr/<新hash>/
Total objects: 19
Total size: 229.199 KiB (234700 Byte)
受け入れ基準 期待 実測
viz画像をGarageに上げない 0件 0
必要ファイル揃う page_*.{txt,json,xml} × 6 + text.txt = 19 19
サイズ従来比1%未満 6ページなら ~360KB未満 229.199 KiB
元PDFは触らない 7.208 MiB が残存 同名・同サイズで残存
work_dir 成功時消える 当該分なし 新規分は消えている

全部通った。

サイズで言うと、6ページで 229 KiB。viz ありだったら100ページの実績から外挿して6ページで約36MB。削減率 99.4%。前夜書いた「600MB → 3MB、177倍」の試算と、思想(保存に値しない自己点検資料)が、数値で一致した。

元PDFはどこへ?

「元ファイルは7.6 MBあるんだけどそれはどこに行ったんだろう」と聞いた。

$ rclone size garage:pdf-library/ndlocr/<新hash>.pdf
Total objects: 1
Total size: 7.208 MiB (7558145 Byte)

そのまま pdf-library/ndlocr/ に残っていた。仕様の「触らないでほしい範囲」に書いた通り。

ストレージの全体像はこう:

場所 中身 サイズ
pdf-library/ndlocr/<file>.pdf 元PDF(永久保存) 7.2 MiB
ocr-results/ndlocr/<stem>/page_*.* + text.txt OCR結果 229 KiB
ローカル /tmp/ainsoph-ocr-work/ 中間生成物 処理中だけ存在、成功時消える
Vault Inbox/Sources/<engine>_<stem>_<date>.md 人間用Markdown 数十KB

「元PDFは Garage、OCR結果は Garage、Markdown は Vault」と完全に分離されている。どこかが壊れても、元PDFから何度でも再OCRできる。

設計が思想を裏切っていない、と感じた。

夕方の追補:「完了」とは何か

viz の話が片付いて、残作業を眺めていた。

status コマンドの判定ロジック改修(Phase 2)

これを「終わってるはず」と呟いたら、コードを開いてみると確かに text.txt の存在で判定する形に直っていた(cli.py:131)。前夜の「フォルダがあるだけで 表示」のバグはもう起きない。

ただし、当初の spec には:

本来は text.txt の存在 + Vault 側の .md の存在で判定すべき

と書いていた。つまり Vault 側のチェックが残っていた。これをやるかどうかで、「完了とは何か」をもう一度考え直すことになった。

Vault は唯一の真実か?

最初の問いは「Vault と Garage、どっちを基準に判定するか」だった。

私の感覚:

Garage はただの保存庫+保管庫。変わってもいいし、増えてもいい。
生きた知識、knowledge wisdom は Obsidian Vault かな。

そう答えてから、もう少し正確な言い方が降りてきた。

潜在意識と顕在意識の対比でいうと、Garage = 潜在意識かもしれないけど、何があるかは普段意識されないと思う。意識されるなら Vault かな。

「Vault が真実」より「Vault が顕在意識」の方が、自分の感覚に合う。Garage は普段意識されない潜在のストック。Vault は能動的に開いて、編集して、引用して、統合する場。

そうすると pipeline の役割が再定義される。潜在から顕在へ素材を運ぶのが pipeline の仕事。status が見たいのは「届いたか」、すなわち 顕在意識に取り込まれたか

「完了」とは、アップしたかどうかではない。OCRが終わったかどうかでもない。Vault に取り込まれたかどうか

けれど、顕在は揺れる

ここで一度引っかかった。

Vault は人間が日々編集する場だ。frontmatter status: archived になった .mdInbox/Sources/ から Knowledge/Archive/ に動くかもしれない。複数の .mdMOC(Map of Content) に統合するかもしれない。不要だと思って削除するかもしれない。

これらは全部 Vault の健全な進化であって、エラーではない。

でも、もし statusInbox/Sources/<engine>_<title>_<date>.md の存在を位置で判定していたら、ファイルを動かした瞬間に (部分完了)表示になる。3ヶ月後にはほぼ全件 になり、本来検出したい「配達失敗」がノイズに埋もれる。

「Vault は真実」と言いつつ、「Vault は揺れる」のが現実。位置で判定すると、編集の自由を奪う

位置ではなく存在で

Claude Code と話しながら答えに辿り着いた。

Vault のどこにあるか、ではなく、Vault のどこかに残っているか

frontmatter の ocr_enginetitle で検索すれば、ファイルが移動・統合・リネームされても検出できる。ユーザーが意図的に削除したときだけ になる。それは「顕在から消した」というユーザーの意思表示として、正しいシグナル。

実装としては、vault_dir 全体を walk して frontmatter を持つ .md から (ocr_engine, title) のセットを作り、各PDFがそのセットに帰属するかを判定する。Mac の SSD なら数千ファイルでも 0.4 秒程度で走査が終わる。

def _build_vault_ocr_index(vault_dir: Path) -> set[tuple[str, str]]:
    """位置(フォルダ)ではなく存在(frontmatter)で Vault Markdown を集める。"""
    index = set()
    for md_path in vault_dir.rglob("*.md"):
        # 隠しディレクトリ(.obsidian, .trash 等)はスキップ
        # frontmatter から ocr_engine + title を拾う
        # ...
        index.add((engine, title))
    return index

判定基準は3状態:

✓ Garage の text.txt + Vault のどこかに対応する .md
◐ Garage の text.txt はあるが Vault に対応する .md なし(顕在から消えた/配達失敗)
· Garage の text.txt がない(未処理)

Vault 内のどこに動こうがリネームされようが、 のまま。ユーザーの編集権を尊重しつつ、配達状態を正確に通知する

思想と実装が一致した

これは ainsoph-pipelines のある種の中核を確定する瞬間だった。pipeline は素材の生成器ではなく、顕在意識へのデリバリーサービス。届いたかどうかは frontmatter で確認する。届かなければ で通知し、再配達したければ --no-skip、放っておきたければ無視できる。機械が勝手に再生成しないことが、人間の編集権を守る最後の線になる。

実装は feat/status-vault-check ブランチで完了、main に merge した。実機テストで全7件 表示。「失敗した3冊(204-303 / 304-329 / 4-103)も完了済み」が機械的に検証できた。Vault 全走査込みで実行 1.9秒。

判断の系譜は別の場所に置く

これだけの議論を、コードのコメントやコミットメッセージに収めるのは無理がある。設計を変える理由は、機能仕様(What)とは別の質をもっている。

そこで docs/design-log/ というディレクトリを新設した。docs/specs/ が「機能の要件」だとすれば、docs/design-log/ は「判断の系譜」。

docs/
├── specs/         ← 何を作るか(What)
└── design-log/    ← なぜそう作ったか(Why)

最初の住人は docs/design-log/2026-04-28-status判定の哲学.md。フォルダ判定 → text.txt 単独 → filename glob → frontmatter scan、という改修の系譜と、それぞれの段階で何を犠牲にし何を得たかが残っている。

3ヶ月後の自分status の判定基準を見直したくなったとき、ここに戻れば1分で議論が再生される。コードのコメントを読みに行く必要はない。

オチツカレサマ

仕様を先に書いた。CLAUDE.md は事故を起こさずに済んだ。エンジンのソースを読んで、--viz を渡さなければよいと確信した。実装してテストして、混乱して、真因に行き着いて、新規PDFで確信した。

そこから一段深いところで、「完了とは何か」「Vault は揺れるが顕在意識である」「判断の系譜を残す場所」が決まった。

ここまで、淡々と。

「ちゃんと壊れた」夜(前夜)の続きとして、「ちゃんと回った」昼が来て、その先に「ちゃんと考え直した」夕方がついた、という感覚がある。設計→実装→検証→記録の輪が、思想とコードの両方で閉じる瞬間は、たぶん私がパイプラインを作る一番の理由だ。

Phase 1 設計書に書いた「色・受・想までを担い、行・識は人間に委ねる」が、容量設計と判定基準の両方で言葉のとおりになった日だった。pipeline は「想」の結晶を「行」のテーブルに置く配達人で、置けたかどうかを 位置ではなく存在で確認する。

今日の達成

残るもの

学び

仕様は、コードを書く前のClaude Codeへの最良の贈り物

docs/specs/YYYY-MM-DD-<topic>.md を先に作ると、要件・受け入れ基準・触らない範囲が物理ファイルとして固定される。Claude Codeは「実装の自由度」の枠内で動き、ハルシネーションが入る余地が減る。夜の判断力に頼らないで済む

既存ファイルがある時は、テンプレ全置換しない

CLAUDE.md 全置換事故は前夜から朝にかけて2度起こりかけた。1度目は要件本文がまだ届く前の暫定版を書いた時、2度目は私が「テンプレで全置換して」と頼んだ時。両方ともClaudeが先に書いた中身を尊重して、差分が大きすぎる時点で立ち止まれた。差分の大きさは判断のシグナル

rclone copy は destination を消さない

これは基本仕様だが、検証コマンドの解釈にも効く。サイズが変わらなかったとき、「コードが効いていない」と早合点しそうになったが、真因は「過去の遺産が残っている」だった。新規PDFで確認するという検証戦略は、こういう時の救命具になる

判断の系譜は、コードのコメントには収まらない

status の判定基準を「位置 → 存在」へ移したのは、メタファーが「真実 → 顕在意識」へ精度を上げた結果だった。この道筋はコミットメッセージにも実装コメントにも書ききれない。docs/design-log/ という独立した場所に置いて初めて、3ヶ月後の自分に届く。設計を変える理由は、機能仕様とは別の質をもつ

人間の編集権を侵さないために、機械は勝手に再生成しない

状態(Vault に届かない / 削除された)を見たとき、システムが自動再OCRしないのは設計判断。pipeline は配達できなかったことを通知するだけで、復旧するかどうかは人間が決める。ユーザーが意識的に Vault から消したものを、pipeline が勝手に蘇らせるのは越権。これは技術的判断というより、ツールが人間の意思を尊重するための線引きだった。


オチツカレサマ。

明日も、ぼちぼち。


関連記事

参考リンク