---
title: "ロボット研究の記録とデータを索引でつないだ ― 次は学習の由来を自動で残す"
slug: robot-research-data-index-and-provenance
date: 2026-11-01
day: 420
meta_description: "Pixels to Motors の記事416本と外付け SSD の実験データを索引でつなぎ、記事の番号からその回のデータ・学習済みモデル・データセットを1行でたどれるようにした。対応は575件で、根拠は記事本文に書かれたパス。"
tags: [データ管理, 索引, 再現性, provenance, ロボットアーム, フィジカルAI, Codex, Claude, AI活用]
ai_consult: true
series: "ひとり研究所の裏側"
---

**Pixels to Motors の記事416本（ロボットアームの実機、配膳ロボットのシミュレーション、画像認識、考察など）と、外付け SSD に残してきた実験データを1つの索引でつなぎ、記事の番号から「その回のデータ・学習済みモデル・データセット」を、表を1行引くだけでたどれるようにしました。** 対応は575件（記事67本）で、どれも記事の本文に書かれたパスを根拠にしています。

これで良くなることは3つあります。

- 探す手間：これまでは、記事を開き、フォルダ名と日付から当たりを付けて、フォルダを1つずつ開いて確かめていました。今は、416本ぶんの対応を約20秒で数え直して作り、記事の番号から引けます。作り直しは毎週自動で行います（前にかかっていた時間は測っていないので、どれだけ減ったかは、次に記事を書くときに確かめます）
- 比べる確かさ：ロボットの学習（お手本の動きを AI に覚えさせること）では、同じデータで別の AI を試したり、データを組み替えて学習し直したりします。どのデータを使ったかを推し量らずに選べるので、成功率が上がったときに、データのおかげなのか、コードを直したおかげなのかを言い分けられるようになります
- これからの記録：調べる途中で、学習済みのモデル（学習の結果できあがった AI の重み）5つすべてに、「どのデータ・どのコードで学習したか」の記録がないことが分かりました。次の学習からは、コードの版・使ったデータ・借りた GPU と費用が自動で残るようにし、学習のスクリプトへの組み込みまで済ませました（本物の学習ではまだ試していません）

索引は、フォルダ名に付いた番号だけで結ぶ方法では作っていません。研究室の7回目（`lab/Log07`）と、画像認識の7回目（`vision/captured/Log07`）のように、別のシリーズで同じ番号が使われていて、番号だけで結ぶと取り違えるからです。作る前の設計を別の AI（OpenAI の [Codex](https://openai.com/codex/)）に「この設計は間違っている」という前提で調べさせたところ、番号だけで結ぶ方式は、12本の試しで1本しか結べませんでした。前提にしていた「3か所を見渡す入口がない」も誤りで、置き場所の決まり（MASTER という運用の文書）がすでに3か所を横断していました。指摘を受けて設計を変えてから作っています。

データは3か所に分かれています。記事の下書きは iCloud、実機の記録や学習用のデータは外付けの SSD（KIOXIA）、コードは Mac の中です。どれも消さずに残してきましたが、「この記事の結果は、どのデータから出たのか」をまとめて引ける表はありませんでした。

やったことの全体は次のとおりです。

| やったこと | なぜ | 結果 |
|---|---|---|
| データの置き場所3か所と、すでにある索引・台帳を調べた | 何が足りないかを決める | 一覧は部分的にあったが、記事からデータへたどる表がなかった |
| 作る前の設計を、Codex に「間違っている前提」で検証させた | 同じ書き手の確認は同じ盲点を持つ | 判定は「設計を変えてから着手」。名前の番号だけでは12件中1件しか結べなかった |
| 番号は付け替えず、番号の種類と書き方を決めた | 意味の違う番号が少なくとも7種類あった | DAY と LOG は同じ記事の通し番号、`Log{NN}` はシリーズ名と組で読む、と決めた |
| 記事本文に書かれたパスだけから、記事とデータの対応表を作った | 推測で結ぶと、自信ありげに取り違える | 確定575件（記事67本）、名前だけの候補29件は別の表に分けた |
| 索引を週1回、自動で作り直す予定を入れた | 手で書いた一覧は、書いた日から古くなる | 毎週月曜に、公開記事と KIOXIA を数え直して作り直す |
| これから作る学習済みモデルとデータに、由来を自動で残す決まりを作った | 学習済みモデル5つに、学習の記録がなかった | コードの版・使ったデータの指紋・GPU と費用を残す。[Claude Code](https://www.anthropic.com/claude-code) が学習のスクリプトに組み込み、残った課題も直した |


## 作業ログ ― データの置き場所3か所と、すでにある索引を調べた

### データは iCloud・KIOXIA・r2r2r-scripts の3か所にある

Pixels to Motors のデータは、種類ごとに置き場所が決まっています。決まりは MASTER（運用の上位の文書）の「保存先テーブル」に書いてあり、データの種類を言えば場所が1つに決まる形です。

| 置き場所 | 中身 |
|---|---|
| iCloud の `PixelstoMotors/` | 記事の作業ファイル（下書き・図解・SNS の文）、調査メモ、公開サイトのアーカイブ、Claude Code への依頼箱 |
| KIOXIA（外付け SSD）の `r2r2r-data/` | 実機の試行の記録と台帳、学習用データセット、学習済みモデル（checkpoint）、録画、学習・推論の記録（`lab/`）、画像認識の測定 |
| Mac の `~/r2r2r-scripts/` | 実機と学習のコード（git で管理） |

最初に、それぞれの直下と、すでにある索引らしいファイル（README・index・ledger・MANIFEST など）を読みました。読むだけで、何も変えていません。

### すでにあった索引は、部分ごとにそろっていた

| 対象 | すでにある索引 | 見たときの状態 |
|---|---|---|
| 公開記事・4コマ・図解・動画 | `site_archive/README.md` と `index/00〜09` | 2026-10-12 に Studio から持ち出した時点の写し |
| 調査メモ | `research/README.md` | 10/30 まで更新されていた |
| 実機の試行 | `runs/pick_place_ledger_v3.csv`（1試行1行の台帳）と `runs/README.md` | 台帳は生きているが、README の説明は古い |
| データセット | 各フォルダの `index.json` と `README.md` | どの試行を使ったか・使わなかった理由まで書かれていた |

足りないと考えたのは3つでした。KIOXIA の `README.md` が 2026-06-13 のままで、その後に増えたフォルダが載っていないこと。3か所をまとめて引ける入口がないこと。記事の番号からデータへたどる対応表がないこと。この3つを前提に、「索引を自動で作り直すスクリプトと、入口の1ページを足す」という設計を書きました。このうち2つ目と3つ目の前提は、次の検証で崩れます。


## 作業ログ ― Codex に、設計が間違っている前提で検証させた

### Codex は、OpenAI のコードを書いて確かめる AI

[Codex](https://openai.com/codex/) は、OpenAI が出している、コードを読み書きしてコマンドを実行できる AI です。このプロジェクトでは、Claude（設計・記事）と Claude Code（実装）が作ったものを、外から確かめる役に使っています。

| この記事で使う言葉 | 意味 |
|---|---|
| 敵対的検証 | 「これは間違っている」という前提で、その証拠を探させる確かめ方。作った側の説明を信用せず、自分で数えて比べる |
| 依頼書 | Codex に渡す指示のファイル。何を疑うか・何をしてはいけないか・どう報告するかを書く |

依頼書では、元のデータを一切変えないこと、書いてよいのは検証用のフォルダ（`_index_review/`）だけであること、KIOXIA の直下にある鍵のファイルは開かないことを決めました。そのうえで、前提が正しいか（A）、記事とデータの対応表が作れるか（B）、作っても害がないか（C）、もっと良い方法はないか（D）、データの質を上げる仕組みは妥当か（E）を、証拠つきで判定させました。依頼書と報告の全文は、この記事の最後に載せています。

### Codex の判定は「設計を変えてから着手」だった

Codex は KIOXIA の約6万3千項目を深さ4まで一覧にし、台帳とデータセットの中身を読み、12本の記事を実際に照らし合わせました。主な指摘は次のとおりです。

| 項目 | 前提 | Codex の結果 |
|---|---|---|
| 3か所を見渡す入口がない | ないと考えていた | 誤り。 MASTER の保存先テーブルがすでに3か所を横断していた。足りないのは「現役の資産を引く一覧」だけ |
| 記事からデータへは名前で推し量るしかない | そう考えていた | 誤り。 試した12本すべてで、記事本文に KIOXIA のパスがはっきり書かれていた |
| KIOXIA の README が古い | 6フォルダ対13フォルダ | 古いのは正しいが、数が違った。実際は12フォルダで、本文にないのは5つ |
| 見落とした索引 | — | 校正の採用・不採用の台帳、Unity の台帳、学習前のデータ点検 `check_dataset.py`、動くコードの一覧 `WORKING_ASSETS.md` など |
| 番号だけで結ぶ方式 | フォルダ名と日付で結べる | 12件中、正しく結べたのは1件だけ。残り11件は結べなかった |

### 名前の番号だけで結ぶと、別シリーズのデータを取り違える

いちばん重かったのは、番号の問題でした。記録とデータには、意味の違う番号が少なくとも7種類ありました。記事の通し番号（DAY・LOG）、シリーズの中の回の番号（`Log{NN}`）、日付、試行の番号（`run_0001` など）、学習の回の名前、データの版（`_v3` など）、学習の段数（checkpoint の `3999` など）です。

Codex が挙げた反例は次のとおりです。

- シリーズ名を落として番号だけで結ぶと、画像認識の7回目（記事 LOG309）が、研究室の7回目のデータ `lab/Log07` に結びつく。両方とも実在するので、「フォルダがあるか」を確かめても防げない
- `LOG352` を「シリーズの352回目」と読むと、本当は研究室の9回目だったことを失う
- 記事 LOG361 の本文は、その回のデータ（`lab/Log12`）と、前の回の評価（`lab/Log11/offline_eval`）の両方を参照している。「本文に出てきた Log は全部その回の出力」とも言えない
- 画像認識の `vision/measurements/Log12` は、README に「番号は仮」と書かれていた

【図解】LOG420_link_by_text.html

**番号や日付が近いことは、対応の証拠にならない。** 対応表は、記事の本文に書かれた参照を根拠にし、名前だけで結びつくものは「候補」として分けることにしました。


## 作業ログ ― 番号は付け替えず、種類と書き方を決めた

### DAY と LOG は同じ記事の通し番号だった

番号を1つにまとめ直すことも考えましたが、やめました。公開記事の URL・台帳・フォルダ名に番号が焼き付いているので、付け替えると元のデータを変えることになります。番号が何種類もあること自体は自然で、それぞれ数えているものが違います。困っていたのは、同じ見た目の番号を別の意味で使っていることでした。

確かめると、記事の番号は、毎日1本出していたころは DAY、1日に何本も出すようになってからは LOG と名前を変えただけで、続きの番号でした（DAY342 の次が LOG343）。そこで、台帳とデータの決まりの文書 `data_manifest_rules.md` に、11節「番号の種類と書き方」を足しました。

- 既存のフォルダ名・ファイル名・台帳の値は付け替えない
- 文章やこれから出す記録には、番号を種類つきで書く（`記事LOG352`、`研究室Log09` など）
- 番号どうしの対応は、番号に詰め込まず、索引の対応表に置く
- 仮の番号は「仮」として載せ、確定したら確定の行を1行足す

- ねらい：番号の読み違いで、別シリーズのデータを結びつける事故をなくす
- 変わったこと：番号を種類と組で書く決まりと、対応を置く場所（索引の対応表）ができた
- 見込める効果：これからの記録は、名前の推測なしで記事とデータを結べる（見込み。週1回の作り直しで、候補の件数が増えないかで確かめる）

### 索引の置き場所を、先に MASTER に書いた

MASTER には「場所を増やすときは、先に保存先テーブルに書いてから置く」という決まりがあります。索引の置き場所 `_index/` を、保存先テーブルに1行足してから作りました。


## 作業ログ ― 記事本文に書かれたパスだけから、対応表を作った

### 記事の番号とシリーズは、公開記事から取る

MASTER では「シリーズの正は公開記事」と決めています。作業ファイルのタイトルと公開時のタイトルが違うことがあるからです。公開サイト pixelstomotors.com は GitHub のリポジトリ `PixelstoMotors/p2m-site` から作られているので、そこを読むだけのスクリプト `export_published.py` を書きました。Claude が書いた記事（`src/content/records/` の Markdown）と、Studio から移した記事の一覧（`src/data/cards.json`）を合わせ、記事番号・シリーズ・タイトルの `Log#N`（シリーズ内の番号）を1行ずつ書き出します。結果は416本、番号の重複は0件でした。

### 索引を作るスクリプト build_index.py

索引を作るのは `build_index.py` です。書くのは `_index/` の中だけで、元のデータは読むだけにしました。

| 作るもの | 中身 |
|---|---|
| `INDEX.md` | 「知りたいこと → 開く場所」の表（25項目・3か所横断・それぞれの更新日）、対応表の件数、古くなった説明の一覧 |
| `data/relations.csv` | 記事の作業ファイルの本文に書かれた KIOXIA のパスのうち、実在するもの（確定）。根拠のファイル名と行番号つき |
| `data/candidates.csv` | フォルダ名の番号だけで結びつくもの（候補）。確定とは分ける |
| `data/build_log.json` | いつ・どこを見たか・見られなかったもの |

Codex の指摘をもとに、次の守りを入れました。

- KIOXIA がつながっていないときは、対応表を「0件」で上書きせず前回のまま残し、`INDEX.md` に「未接続」と書く
- 書き込みは一時ファイルに書いてから差し替える（途中で止まっても、壊れたファイルを残さない）
- KIOXIA で読むのは `r2r2r-data` の中だけで、`archive/` は見ない。鍵のファイルは対象の外
- 本文のパスに `..` が入っているものは扱わない

最初に回したとき、`teach_poses_LOG{NNN}` のような「書き方の見本」まで「参照先なし」に数えていました。パスの直後に `{` が続くものは見本として飛ばすよう直しました。

### 結果は確定575件・記事67本

Mac 上で回した結果です（20秒ほど）。

| 種類 | 件数 |
|---|---:|
| 確定（本文にあり、KIOXIA に実在する） | 575件（記事67本） |
| 参照先なし（本文にあるが、KIOXIA に見つからない） | 29件 |
| 候補（フォルダ名の番号だけで結びつく） | 29件 |

シリーズごとに見ると、データと結びついた記事の割合に大きな差がありました。

| シリーズ | データと結びついた記事 | 公開記事の本数 |
|---|---:|---:|
| R2R2R | 20 | 185 |
| 導入シミュレーション | 20 | 27 |
| フィジカルAI研究室 | 13 | 18 |
| 画像認識・物体検出 | 9 | 12 |
| ひとり研究所の裏側 | 5 | 43 |
| 考察記事 | 0 | 72 |
| HRI | 0 | 57 |
| UV-C農業ロボット | 0 | 2 |

R2R2R の記事の多くは、本文に KIOXIA のパスを書いていません。データを KIOXIA に置く決まりができたのは 2026-06-15 で、それより前の記事はデータの場所を別の書き方で残しています。考察記事と HRI は、もともと測定データを持たない記事です。

Codex が手で照らし合わせた12本は、すべて同じデータに結びつきました。反例に挙がっていた LOG309（画像認識の7回目）も、研究室の `lab/Log07` ではなく `vision/captured/Log07` に結びついています。

- ねらい：記事から、その回のデータ・学習済みモデル・データセットへ、推測なしでたどれるようにする
- 変わったこと：575件の対応が、根拠のファイルと行つきで1つの表になった
- 見込める効果：同じデータで別の AI を試す・学習し直すときに、どのデータを使ったかを探す手間がなくなる（見込み）

### 古くなった説明が4つ見つかった

索引を作るときに、説明の文書と実物を見比べる欄も作りました。

- KIOXIA の `README.md`（2026-06-13）に、`diag/`・`lab/`・`service_sim/`・`videos/`・`vision/` が載っていない
- `runs/README.md`（2026-07-15）の台帳の説明に対し、台帳は今750行・82列
- `site_archive/` は 2026-10-12 時点の写し（公開記事は今 LOG416 まで）
- `WORKING_ASSETS.md` は 2026-06-11 のまま

これらの説明は書き換えていません。索引の側で「古い」と表示し、現行の一覧へ案内します。

### 週1回、自動で作り直す

手で書いた一覧は、書いた日から古くなります。KIOXIA の README がまさにそうでした。そこで、毎週月曜の17:42に、公開記事の一覧を取り直し、Mac で KIOXIA を数え直して索引を作り直す予定を入れました。KIOXIA がつながっていない日は、そのことを報告に書いて終わります。


## 作業ログ ― 元のデータを変えずに、データの質を上げる

### 過去の記録は直さず、上と先に手を入れる

LOG に残した記録を書き換え始めると、終わりがありません。データの質は、元のデータを変えずに上げることにしました。ここでいう質は、中身の数値の良し悪しではなく、由来がわかる・対になるものがそろっている・使ってよいかがわかる・一貫している、の4つです。考えた方法と、Codex の判定は次のとおりです。

| 方法 | 中身 | Codex の判定 |
|---|---|---|
| 1. 注記の台帳 | 「このデータをどう扱うか」だけを書いた台帳を別に置く | 変える。 データセットの `index.json`（used・reason）や校正の README に、同じ役目がすでにある。既存の判定を覆す例外だけに絞る |
| 2. 由来を出力のときに付ける | これから作るデータに、コードの版・使った入力などを自動で書く | 採る。 ただし新しいファイルを増やさず、既存の記録の形を広げる |
| 3. 自動の点検 | 台帳とフォルダの食い違いなどを見つけて知らせる（直さない） | 採る。 軽い点検と重い点検（動画を全部開くなど）を分ける |
| 4. 選んで新しい版を作る | 学習用のデータを加工するときは、元を直さず新しい版を作る | 採る。 8つの派生データセットすべてに、元と処理の説明がすでにあった |

Codex は方法3を一部試していました。実機の台帳（750行）と試行のフォルダ（849個）を突き合わせると、台帳にだけあるものが34件、フォルダにだけあるものが133件ありました。ただし試しの実行や中断が混ざっているので、これを「欠けたデータ」とは言えません。今の差を基準として残し、これから新しく出た差だけを知らせる形にします。

### 学習済みモデル5つに、学習の記録がなかった

方法2を最初にやる理由は、点検の数字にあります。学習済みモデル（checkpoint）の5つの段のすべてで、学習の記録を入れる欄（`_CHECKPOINT_METADATA` の `custom_metadata`）が空でした。データに付けている記録（`meta.json` など）は254件ありましたが、コードの版を持つものは0件でした。

このプロジェクトでは「再現可能な環境の数」を唯一の主指標にしています。その条件の1つが「依存関係とバージョンが記載されていること」です。今の学習済みモデルは、この条件を満たせていません。

### 由来（provenance）を残す決まりを作った

provenance は「由来・出どころ」の意味で、ここでは「このデータやモデルが、どのコード・どの入力・どの設定で、どう終わったか」の記録を指します。`data_manifest_rules.md` に12節を足しました。

| 残すもの | 中身 |
|---|---|
| コードの版 | r2r2r-scripts のコミット番号、まだコミットしていない変更があったか |
| 外部のコードの版 | [openpi](https://github.com/Physical-Intelligence/openpi)（学習に使っている Physical Intelligence のコード）のコミット番号 |
| 入力 | 使ったデータセットやモデルの名前と、その指紋 |
| 設定 | 実行したコマンドと主な設定値 |
| GPU と費用 | クラウドで借りた GPU の型番・時間・費用 |
| 番号 | シリーズ名とシリーズ内の番号（11節） |
| 終わり方 | 「処理が最後まで動いたか」と「タスクが成功したか」を分けて書く |

指紋は、データの中身が同じかを見分ける短い文字列です。数GBの重みや動画を毎回読むと遅くなるので、ファイル名とサイズの一覧と、小さな JSON の中身から作ります。まだコミットしていない変更があっても学習は止めず、「変更あり」と記録します。研究を止めないことを優先しました。台帳の列は増やさず、各回の記録の JSON に `provenance` という欄を1つ足す形です。学習済みモデルだけは中に書ける JSON がないので、`train_provenance.json` を1つ置きます。

- ねらい：学習済みモデルを、何で学習したかが後から確定できる形で残す
- 変わったこと：これからの学習で、コード・データ・設定・費用が自動で記録される決まりになった（実装は下の節）
- 見込める効果：corr_v3 で成功率が上がった、のように、改善の理由をデータで言えるようになる。学習1回あたりの費用も出せる（見込み。最初の学習のあとで確かめる）

### 実装は Claude Code に、学習の部分から依頼した

実装は、実機と学習のコードを担当する Claude Code（Anthropic のコードを書く AI）に、Mac の依頼箱を通して頼みました。入れる順番は、効く順に3段階です。

1. 学習と学習済みモデル（`lab_train.sh`）。クラウドの GPU だけで動くので、実機を動かす危険がない
2. 学習用のデータセットを作るスクリプト
3. 実機の試行の記録。関節の制御には触れないが、実機の流れの中なので、Codex のレビューと試し運転を通してから使う

依頼の全文は、この記事の最後に載せています。

### 学習のスクリプトに由来の記録が入った

依頼は 2026-11-01 の 10:54 に終わりました（作業は45分）。Claude Code が作ったのは、共通の部品 `provenance.py` と、学習を1行で回すスクリプト `lab_train.sh` への組み込みです。

| 項目 | 結果 |
|---|---|
| 記録を置く場所 | `lab/Log{NN}/train/train_provenance.json`（成功・失敗・途中で止めた、のどれでも置く）、新しく置いた学習済みモデルのフォルダ、Hugging Face に上げたモデル |
| 記録する中身 | コードのコミットと未コミットの変更、openpi のコミット、入力3つ（学習データ・続きから学習する重み・設定のスクリプト）の指紋、できたモデルの指紋、設定、GPU と費用、シリーズと番号、終わり方 |
| 守り | 鍵は伏せる。すでにあるファイルは上書きしない。由来の処理は300秒で打ち切り、失敗しても学習の後片付けは変えない。KIOXIA が外れたら、由来を書かずに後片付けを終える |
| テスト | `provenance.py` 30件（1件はこの Mac の事情で飛ばし）・`lab_train.sh` 177件・`lab_start.sh` 128件、すべて通過 |
| Codex のレビュー | 5回。直すべき指摘は 5件 → 3件 → 1件 → 1件 → 0件 |
| コミット | 1902f37（本体）・cadd53d（作業の記録） |

本物の GPU は借りず、偽物の実行（借りたふりをして手順だけを通す仕組み）で確かめました。すでにある学習済みモデルには書き足していません。

偽物の実行で出た記録（全文は記事の最後）を見ると、課題が1つ見えています。`code.dirty`（未コミットの変更がある）が、毎回 true になります。Pod へ丸ごと送る `pi05/` フォルダに、git に入っていない古い控えのファイルが1つあり、それも数えているためです。数えること自体は正しい扱いですが、このままだと「変更あり」が毎回付き、本当にコードを変えた回と見分けにくくなります。

ほかに、決めることが4つ残りました。

- Finder でフォルダを開くだけで作られる `.DS_Store` も指紋に数えているので、開いただけで指紋が変わる。数えない形（files-v2）に変えるか
- できたモデルの指紋は、由来のファイルを置く前に測っている。置いたあとに測り直すと合わない
- シリーズ内の番号を `Log18` と書くか、数字だけにするか
- 書いている途中で強制終了されたときに残る、書きかけの記録をどう読むか

### 残った5つの課題を決めて、直した

残った4つと `code.dirty` の件は、どれも記録の読み違いにつながるので、同じ日に決めて Claude Code に直してもらいました（2026-11-01 の 12:39〜13:30）。決まりの文書（`data_manifest_rules.md` の12節）を先に直し、それに合わせてコードを直す順番です。

| 残った課題 | 決めたこと | 直した結果 |
|---|---|---|
| `.DS_Store` で指紋が変わる | 指紋の作り方を `files-v2` にし、`.DS_Store` を数えない | `files-v2` にした。前の `files-v1` で書いた記録と比べるために、前の作り方も残した |
| 由来のファイルを置いたあとに測り直すと、できたモデルの指紋が合わない | 由来のファイルそのもの（`*_provenance.json`）も数えない | 測り直しても合うことを、テストと偽物の実行で確かめた |
| シリーズ内の番号の書き方 | `Log18` の形（数字だけにしない） | 今のまま |
| 書きかけの記録をどう読むか | 「由来なし（読めない）」として警告し、処理は止めない | 由来を読む関数を足した。結果は「あり・なし・読めない」の3つ |
| `code.dirty` が毎回 true になる | git に入っていない控えのファイル（名前に `.bak`）は別に記録する | `untracked_backups` に分けて残し、偽物の実行で `dirty=false` になった |

テストは `provenance.py` 42件（1件はこの Mac の事情で飛ばし）・`lab_train.sh` 190件・`lab_start.sh` 136件と、pi05 の各テストがすべて通りました。Codex のレビューは2回で、2回目の直すべき指摘は0件です。コミットは cb288f6（本体）と 79bc11b（偽物の実行の記録と作業の記録）です。ただし `lab_train.sh` のテストは、8回流したうち1回だけ NG が1件出ました。どの項目かは記録が残っておらず、時間に左右されるテストの揺れかもしれません。

- ねらい：記録の「変更あり」や指紋が、実際の変化と関係なく変わらないようにする
- 変わったこと：控えのファイルや Finder の印では、指紋も `dirty` も変わらなくなった。由来を置いたあとに測り直しても指紋が合う
- 見込める効果：「指紋が違う＝中身が違う」「dirty＝コードを変えた」と、そのまま読める（偽物の実行で確かめた。本物の学習では、次に回したときに確かめる）

直す途中で、新しく決めることが3つ出ました。学習用のデータセットの記録に由来を埋め込むときの指紋の測り方、同じ回を取り直したときの2つ目の由来のファイルの扱い、控えとみなすファイルの範囲です。どれも、データセットを作るスクリプト（2段目）と実機の試行（3段目）に由来を入れるときに関わるので、その段階を始めるときに決めます。

最初の本物の学習で `train_provenance.json` に何が残るかは、フィジカルAI研究室の記事で確かめます。


## 作業ログ ― 使ったコード・依頼・記録（全文）

鍵・トークンは、どのファイルにも入っていない。Codex の検証の元データ（inventory.json などの一覧・数え直しの JSON）は大きいので載せず、iCloud の `PixelstoMotors/_index_review/` に残している。

### export_published.py（公開記事の一覧を書き出す）

```python
#!/usr/bin/env python3
"""公開記事の一覧を書き出す（索引の入力）。

公開記事が記事番号・シリーズの正（MASTER「シリーズの正は公開記事」）。
p2m-site の2か所を読む：
  - src/content/records/*.md … Claude が書いた記事（frontmatter の day・series・title・slug・date）
  - src/data/cards.json       … Studio から移した記事の一覧
同じ slug は records を優先する（サイトの allCards() と同じ決まり）。

使い方: python3 export_published.py --p2m-site <p2m-site のパス> --out <published_articles.csv>
p2m-site は読むだけ。
"""
import argparse, csv, json, os, re, tempfile
from pathlib import Path

LOG_IN_TITLE = re.compile(r'Log\s*#\s*(\d+)', re.I)


def frontmatter(text):
    m = re.match(r'^---\n(.*?)\n---\n', text, re.S)
    if not m:
        return {}
    out = {}
    for line in m.group(1).splitlines():
        mm = re.match(r'^([A-Za-z_]+):\s*(.*)$', line)
        if mm:
            v = mm.group(2).strip()
            if len(v) >= 2 and v[0] == v[-1] and v[0] in '"\'':
                v = v[1:-1]
            out[mm.group(1)] = v
    return out


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--p2m-site', required=True)
    ap.add_argument('--out', required=True)
    a = ap.parse_args()
    site = Path(a.p2m_site)
    rows, seen = [], set()
    for p in sorted((site / 'src/content/records').glob('*.md')):
        fm = frontmatter(p.read_text(encoding='utf-8'))
        if not fm.get('day'):
            continue
        m = LOG_IN_TITLE.search(fm.get('title', ''))
        rows.append({'article_no': int(fm['day']), 'series': fm.get('series', ''),
                     'series_log': m.group(1) if m else '', 'title': fm.get('title', ''),
                     'slug': fm.get('slug', ''), 'date': fm.get('date', '')[:10],
                     'source': f'p2m-site/src/content/records/{p.name}'})
        seen.add(fm.get('slug'))
    for c in json.loads((site / 'src/data/cards.json').read_text(encoding='utf-8')):
        if c.get('slug') in seen:
            continue
        m = re.match(r'^LOG(\d+)$', c.get('day', ''))
        if not m:
            continue
        t = LOG_IN_TITLE.search(c.get('title', ''))
        rows.append({'article_no': int(m.group(1)), 'series': c.get('series', ''),
                     'series_log': t.group(1) if t else '', 'title': c.get('title', ''),
                     'slug': c.get('slug', ''), 'date': (c.get('published') or '')[:10],
                     'source': 'p2m-site/src/data/cards.json'})
    rows.sort(key=lambda r: (r['article_no'], r['date']))
    out = Path(a.out)
    out.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=out.parent, prefix='.tmp_', suffix='.csv')
    with os.fdopen(fd, 'w', encoding='utf-8', newline='') as f:
        w = csv.DictWriter(f, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)
    os.replace(tmp, out)
    print(f'{len(rows)} articles -> {out}')


if __name__ == '__main__':
    main()
```

### build_index.py（索引を作り直す）

```python
#!/usr/bin/env python3
"""Pixels to Motors データの索引を作り直す。

置き場所（MASTER 保存先テーブル）: iCloud PixelstoMotors/_index/
番号の決まり: data_manifest_rules.md 11節

書き出すもの（_index/ の中だけ。元データは読むだけ）:
  INDEX.md                 入口（知りたいこと → 開く場所）・関係の要約・古くなった説明の一覧
  data/relations.csv       記事 ↔ KIOXIA のデータ（記事の作業ファイル本文に書かれた参照だけ。確定）
  data/candidates.csv      名前の番号だけで結びつく候補（確定ではない）
  data/build_log.json      実行の記録（いつ・どこを見たか・見られなかったもの）

入力: data/published_articles.csv（公開記事の一覧。tools/export_published.py で p2m-site から書き出す）

守ること:
  - 書き込みは _index/ の中だけ。一時ファイルから os.replace で差し替える
  - KIOXIA がつながっていなければ、関係表と候補は前回のものを残し、INDEX.md に「未接続」と書く
  - KIOXIA で見るのは r2r2r-data の中だけ。archive/ は見ない。ファイルの中身は README の文字と台帳の行数・列数だけ読む
  - パスに .. を含む参照は扱わない

使い方（Mac）: python3 build_index.py
  ほかの場所で回すときは --icloud / --kioxia / --scripts でパスを渡す
"""
import argparse, csv, datetime as dt, json, os, re, tempfile
from collections import defaultdict
from pathlib import Path

HOME = Path.home()
DEF_ICLOUD = HOME / 'Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors'
DEF_KIOXIA = Path('/Volumes/KIOXIA/r2r2r-data')
DEF_SCRIPTS = HOME / 'r2r2r-scripts'

# 表示用のパス（Mac での書き方）
SHOW = {'I': '~/…/PixelstoMotors', 'K': '/Volumes/KIOXIA/r2r2r-data', 'C': '~/r2r2r-scripts'}

# KIOXIA の上の階層（保存先テーブルにあるもの）。archive は参照の対象にしない
K_TOPS = ['runs', 'lab', 'datasets', 'checkpoints', 'recordings', 'vision', 'mecharm_calibration',
          'diag', 'service_sim', 'videos', 'fk_tables']
REF_RE = re.compile(r'(?<![A-Za-z0-9_])(?:/Volumes/KIOXIA/r2r2r-data/|r2r2r-data/)?((?:' + '|'.join(K_TOPS) +
                    r')/[A-Za-z0-9_\-./]+)')
ART_FILE_RE = re.compile(r'^(LOG|DAY)(\d+)(?:_|\.|$)')
NAME_NO_RE = re.compile(r'(?:LOG|DAY)(\d{2,3})(?!\d)')
LOGDIR_RE = re.compile(r'^Log(\d+)$')
# シリーズ内の番号でフォルダを切っている場所 → 公開記事のシリーズ名
SERIES_DIRS = {'lab': 'フィジカルAI研究室', 'vision/generated': '画像認識・物体検出',
               'vision/captured': '画像認識・物体検出', 'vision/measurements': '画像認識・物体検出'}


try:
    from zoneinfo import ZoneInfo
    JST = ZoneInfo('Asia/Tokyo')
except Exception:  # zoneinfo がない環境
    JST = dt.timezone(dt.timedelta(hours=9))


def now():
    return dt.datetime.now(JST)


def mdate(p):
    try:
        return dt.datetime.fromtimestamp(p.stat().st_mtime, JST).strftime('%Y-%m-%d')
    except OSError:
        return ''


def write_atomic(path, text):
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, prefix='.tmp_')
    with os.fdopen(fd, 'w', encoding='utf-8', newline='') as f:
        f.write(text)
    os.replace(tmp, path)


def write_csv(path, rows, fields):
    import io
    buf = io.StringIO()
    w = csv.DictWriter(buf, fieldnames=fields)
    w.writeheader()
    w.writerows(rows)
    write_atomic(path, buf.getvalue())


def load_published(path):
    arts = {}
    with open(path, encoding='utf-8') as f:
        for r in csv.DictReader(f):
            arts[int(r['article_no'])] = r
    return arts


def article_files(icloud):
    """記事の作業ファイル（LOG{N}*.md / DAY{N}*.md）を記事番号ごとに集める。"""
    root = icloud / 'デジタルツイン学習R2R2R'
    out = defaultdict(list)
    for dirpath, dirnames, files in os.walk(root):
        dirnames[:] = [d for d in dirnames if not d.startswith('.') and not d.endswith('_images')]
        for fn in files:
            m = ART_FILE_RE.match(fn)
            if m and fn.endswith('.md'):
                out[int(m.group(2))].append(Path(dirpath) / fn)
    return out


def resolve_ref(kioxia, ref):
    """本文の参照を KIOXIA の実在するフォルダ/ファイルに当てる。上から3段まで、在る所までを返す。"""
    ref = ref.rstrip('./-_')
    parts = [p for p in ref.split('/') if p]
    if not parts or '..' in parts or parts[0] not in K_TOPS:
        return None, False
    for n in range(min(3, len(parts)), 1, -1):
        if (kioxia / Path(*parts[:n])).exists():
            return '/'.join(parts[:n]), True
    return '/'.join(parts[:3]), False


def build_relations(icloud, kioxia, files_by_no, arts):
    rel = {}
    for no, files in sorted(files_by_no.items()):
        for fp in files:
            try:
                lines = fp.read_text(encoding='utf-8', errors='replace').splitlines()
            except OSError:
                continue
            for i, line in enumerate(lines, 1):
                for m in REF_RE.finditer(line):
                    if line[m.end():m.end() + 1] == '{':  # teach_poses_LOG{NNN} のような書き方の見本
                        continue
                    target, ok = resolve_ref(kioxia, m.group(1))
                    if not target:
                        continue
                    key = (no, target)
                    if key in rel:
                        continue
                    a = arts.get(no, {})
                    rel[key] = {
                        'article_no': f'LOG{no}', 'series': a.get('series', ''),
                        'series_log': a.get('series_log', ''), 'published': 'yes' if a else 'no',
                        'target': f'r2r2r-data/{target}', 'exists': 'yes' if ok else 'no',
                        'status': '確定' if ok else '参照先なし',
                        'relation': '本文で参照',
                        'evidence': f'{fp.relative_to(icloud)}:{i}',
                    }
    return sorted(rel.values(), key=lambda r: (int(r['article_no'][3:]), r['target']))


def build_candidates(kioxia, arts, confirmed):
    """名前の番号だけで結びつく候補。確定の関係にすでにあるものは除く。"""
    done = {(r['article_no'], r['target']) for r in confirmed}
    by_series_log = defaultdict(list)
    for no, a in arts.items():
        if a.get('series_log'):
            by_series_log[(a['series'], int(a['series_log']))].append(no)
    out = []

    def add(no, target, why):
        k = (f'LOG{no}' if no else '', f'r2r2r-data/{target}')
        if k in done:
            return
        done.add(k)
        a = arts.get(no, {}) if no else {}
        out.append({'article_no': k[0], 'series': a.get('series', ''), 'series_log': a.get('series_log', ''),
                    'published': 'yes' if a else ('no' if no else ''), 'target': k[1], 'status': '候補', 'why': why})

    # 1) シリーズ内の番号のフォルダ（lab/Log10 など）× 公開記事のシリーズとLog番号
    for sub, series in SERIES_DIRS.items():
        d = kioxia / sub
        if not d.is_dir():
            continue
        for e in sorted(d.iterdir()):
            m = LOGDIR_RE.match(e.name)
            if not (m and e.is_dir()):
                continue
            nos = by_series_log.get((series, int(m.group(1))), [])
            if len(nos) == 1:
                add(nos[0], f'{sub}/{e.name}', f'シリーズ「{series}」の Log#{int(m.group(1))} と同じ番号')
            else:
                add(None, f'{sub}/{e.name}', f'シリーズ「{series}」の Log#{int(m.group(1))} の公開記事が{"ない" if not nos else "複数ある"}')
    # 2) フォルダ名に記事番号（LOG352・DAY281 など）を含むもの。2段目まで見る
    for top in K_TOPS:
        d = kioxia / top
        if not d.is_dir():
            continue
        for e in sorted(d.iterdir()):
            if e.name.startswith(('.', '._')):
                continue
            m = NAME_NO_RE.search(e.name)
            if m and e.is_dir():
                add(int(m.group(1)), f'{top}/{e.name}', 'フォルダ名の記事番号')
    return sorted(out, key=lambda r: (int(r['article_no'][3:] or 0), r['target']))


def ledger_shape(p):
    try:
        with open(p, encoding='utf-8', newline='') as f:
            rows = list(csv.reader(f))
        return len(rows) - 1, len(rows[0]) if rows else 0
    except (OSError, UnicodeDecodeError):
        return None


def freshness(icloud, kioxia, scripts, k_ok, arts):
    notes = []
    if k_ok:
        readme = kioxia / 'README.md'
        if readme.exists():
            text = readme.read_text(encoding='utf-8', errors='replace')
            dirs = sorted(e.name for e in kioxia.iterdir() if e.is_dir() and not e.name.startswith('.'))
            missing = [d for d in dirs if f'{d}/' not in text]
            if missing:
                notes.append(f'`{SHOW["K"]}/README.md`（更新 {mdate(readme)}）に載っていないフォルダ: ' +
                             '、'.join(f'`{d}/`' for d in missing))
        rr = kioxia / 'runs/README.md'
        sh = ledger_shape(kioxia / 'runs/pick_place_ledger_v3.csv')
        if rr.exists() and sh:
            notes.append(f'`{SHOW["K"]}/runs/README.md`（更新 {mdate(rr)}）の台帳の説明と見比べる: '
                         f'`pick_place_ledger_v3.csv` は今 {sh[0]} 行・{sh[1]} 列（列の正は data_manifest_rules.md）')
    pub_max = max(arts) if arts else 0
    sa = icloud / 'site_archive/README.md'
    if sa.exists():
        notes.append(f'`site_archive/` は {mdate(sa)} 時点の写し（公開記事は今 LOG{pub_max} まで）。現行の記事一覧には `_index/data/published_articles.csv` を使う')
    wa = scripts / 'WORKING_ASSETS.md'
    if wa.exists():
        notes.append(f'`{SHOW["C"]}/WORKING_ASSETS.md` の更新は {mdate(wa)}。それ以降のコードは載っていない')
    return notes


ENTRY = [
    # (知りたいこと, 根, 相対パス, 補足)
    ('置き場所の決まり（種類 → 場所）', 'I', 'マスターMD/MASTER.md', '正は MASTER。台帳の列・番号の決まりは Project の data_manifest_rules.md'),
    ('公開記事の一覧（記事番号・シリーズ・Log番号）', 'I', '_index/data/published_articles.csv', '公開記事（p2m-site）から書き出したもの。シリーズと番号の正'),
    ('記事 ↔ データの関係', 'I', '_index/data/relations.csv', '記事の作業ファイルに書かれた参照だけ。名前だけの候補は candidates.csv'),
    ('記事の作業ファイル', 'I', 'デジタルツイン学習R2R2R', 'シリーズ名のフォルダ。単体記事は 記事/'),
    ('2026-10-12 までの公開サイト一式', 'I', 'site_archive/README.md', '取得した時点の写し'),
    ('調査メモ・論文', 'I', 'research/README.md', ''),
    ('ロボット動画の観察台帳', 'I', 'research/robot_video_ledger.md', ''),
    ('課題地図', 'I', 'physical-ai-todo/README.md', ''),
    ('Claude Code への依頼と報告', 'I', '依頼箱/README.md', ''),
    ('SNS の分析データ', 'I', 'sns_analytics', ''),
    ('データの置き方（KIOXIA）', 'K', 'README.md', '古い。下の「古くなった説明」を見る'),
    ('実機の通し試行（PICK→PLACE）', 'K', 'runs/pick_place_ledger_v3.csv', '1試行1行の台帳。試行ごとの中身は runs/run_pick_place/'),
    ('受け渡しの通し試行', 'K', 'runs/handover_ledger_v1.csv', '試行ごとの中身は runs/run_handover/'),
    ('実機の単体実験', 'K', 'runs/experiments', '実験ごとの索引 CSV がある場合あり'),
    ('学習用データセット', 'K', 'datasets', '各フォルダの index.json（使った試行・used・reason）と README（元と処理）'),
    ('学習済みモデル', 'K', 'checkpoints', 'モデルの中に学習の記録はない。由来は lab/Log{NN}/README.md と関係表で引く'),
    ('学習・推論の記録（フィジカルAI研究室）', 'K', 'lab', 'Log{NN} はシリーズ内の番号'),
    ('画像認識シリーズの画像と測定', 'K', 'vision', 'Log{NN} はシリーズ内の番号（研究室の Log とは別）'),
    ('校正の採用・不採用', 'K', 'mecharm_calibration/README.md', '各フォルダの README・_LEDGER.md・_INDEX'),
    ('録画', 'K', 'recordings', ''),
    ('サービスロボット導入シミュレーション（Unity）', 'K', 'service_sim/unity/unity_ledger_v1.csv', '1回1行の台帳'),
    ('解説動画', 'K', 'videos', ''),
    ('動くコードの一覧', 'C', 'WORKING_ASSETS.md', '古い。下の「古くなった説明」を見る'),
    ('学習前のデータセット点検', 'C', 'pi05/check_dataset.py', '読むだけの点検'),
    ('この索引の検証報告（Codex）', 'I', '_index_review/codex_report_20261101.md', ''),
]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--icloud', default=str(DEF_ICLOUD))
    ap.add_argument('--kioxia', default=str(DEF_KIOXIA))
    ap.add_argument('--scripts', default=str(DEF_SCRIPTS))
    a = ap.parse_args()
    icloud, kioxia, scripts = Path(a.icloud), Path(a.kioxia), Path(a.scripts)
    out = icloud / '_index'
    data = out / 'data'
    started = now()
    log = {'started': started.isoformat(timespec='seconds'), 'roots': {}, 'errors': []}

    if not icloud.is_dir():
        raise SystemExit(f'iCloud のフォルダが見つからない: {icloud}')
    k_ok = (kioxia / 'runs').is_dir()
    roots = {'I': icloud, 'K': kioxia, 'C': scripts}
    log['roots'] = {k: ('ok' if (v.is_dir() if k != 'K' else k_ok) else 'unavailable') for k, v in roots.items()}

    pub = data / 'published_articles.csv'
    arts = load_published(pub) if pub.exists() else {}
    if not arts:
        log['errors'].append('published_articles.csv がない（tools/export_published.py で書き出す）')

    files_by_no = article_files(icloud)
    rel_rows = cand_rows = None
    if k_ok:
        rel_rows = build_relations(icloud, kioxia, files_by_no, arts)
        cand_rows = build_candidates(kioxia, arts, rel_rows)
        write_csv(data / 'relations.csv', rel_rows,
                  ['article_no', 'series', 'series_log', 'published', 'target', 'exists', 'status', 'relation', 'evidence'])
        write_csv(data / 'candidates.csv', cand_rows,
                  ['article_no', 'series', 'series_log', 'published', 'target', 'status', 'why'])
    else:
        log['errors'].append('KIOXIA 未接続：relations.csv と candidates.csv は前回のまま')

    # ---- INDEX.md ----
    L = []
    L.append('# Pixels to Motors データの索引')
    L.append('')
    L.append(f'作り直した日時: {started.strftime("%Y-%m-%d %H:%M")} JST（`_index/tools/build_index.py`。手で書き換えない）')
    L.append('')
    st = {'ok': 'つながっている', 'unavailable': '**未接続（この回は見ていない）**'}
    L.append('見た場所: ' + ' ／ '.join(f'{SHOW[k]} … {st[v]}' for k, v in log['roots'].items()))
    L.append('')
    L.append('番号の読み方: 記事番号は DAY と LOG で同じ通し番号。`Log{NN}` はシリーズ内の番号で、シリーズ名と組で読む（data_manifest_rules.md 11節）。')
    L.append('')
    L.append('## 知りたいこと → 開く場所')
    L.append('')
    L.append('| 知りたいこと | 開く場所 | 更新 | 補足 |')
    L.append('|---|---|---|---|')
    for what, r, rel, note in ENTRY:
        p = roots[r] / rel
        if r == 'K' and not k_ok:
            upd = '未接続'
        elif p.exists():
            upd = mdate(p)
        else:
            upd = '**見つからない**'
        if r == 'I':
            link = rel[len('_index/'):] if rel.startswith('_index/') else f'../{rel}'
            shown = f'[{rel}]({link.replace(" ", "%20")})'
        else:
            shown = f'`{SHOW[r]}/{rel}`'
        L.append(f'| {what} | {shown} | {upd} | {note} |')
    L.append('')
    L.append('## 記事 ↔ データの関係')
    L.append('')
    if rel_rows is None:
        L.append('KIOXIA が未接続のため、この回は作り直していない。`data/relations.csv` は前回のもの。')
    else:
        ok = [r for r in rel_rows if r['status'] == '確定']
        n_art = len({r['article_no'] for r in ok})
        L.append(f'- [data/relations.csv](data/relations.csv)：**確定 {len(ok)} 件（記事 {n_art} 本）**。記事の作業ファイルの本文に書かれた KIOXIA のパスで、実在するもの。`evidence` に根拠のファイルと行')
        L.append(f'- 同じファイルの「参照先なし」{sum(1 for r in rel_rows if r["status"] != "確定")} 件：本文にあるが KIOXIA に見つからないパス（移した・書き間違い・例として書いた、のどれかは見ていない）')
        L.append(f'- [data/candidates.csv](data/candidates.csv)：候補 {len(cand_rows)} 件。フォルダ名の番号だけで結びつくもの。**確定ではない**')
        L.append('- 関係の種類（作った・使った・比べた）はまだ分けていない。すべて「本文で参照」')
        L.append('')
        by_series = defaultdict(set)
        for r in ok:
            by_series[r['series'] or '（公開記事にない番号）'].add(r['article_no'])
        L.append('| シリーズ | データと結びついた記事 | 公開記事の本数 |')
        L.append('|---|---:|---:|')
        cnt = defaultdict(int)
        for x in arts.values():
            cnt[x['series']] += 1
        for s in sorted(set(cnt) | set(by_series), key=lambda s: -len(by_series.get(s, ()))):
            L.append(f'| {s} | {len(by_series.get(s, ()))} | {cnt.get(s, 0)} |')
    L.append('')
    L.append('## 古くなった説明')
    L.append('')
    notes = freshness(icloud, kioxia, scripts, k_ok, arts)
    L.extend(f'- {n}' for n in notes) if notes else L.append('- なし')
    L.append('')
    L.append('## 作り直し方（週1回）')
    L.append('')
    L.append('1. 公開記事の一覧を新しくする（p2m-site の最新を読める場所で）: `python3 tools/export_published.py --p2m-site <p2m-site> --out data/published_articles.csv`')
    L.append('2. Mac で KIOXIA をつないで: `python3 tools/build_index.py`')
    L.append('')
    L.append('元データ（iCloud・KIOXIA・r2r2r-scripts）は読むだけ。書くのは `_index/` の中だけ。KIOXIA で読むのは r2r2r-data の中（archive/ を除く）の名前と、README の文字・台帳の行数と列数だけ。')
    log['finished'] = now().isoformat(timespec='seconds')
    write_atomic(out / 'INDEX.md', '\n'.join(L) + '\n')
    write_atomic(data / 'build_log.json', json.dumps(log, ensure_ascii=False, indent=2) + '\n')
    print(json.dumps(log, ensure_ascii=False))


if __name__ == '__main__':
    main()
```

### INDEX.md（build_index.py が書き出した入口のページ・2026-11-01 08:19 の回）

```markdown
# Pixels to Motors データの索引

作り直した日時: 2026-11-01 08:19 JST（`_index/tools/build_index.py`。手で書き換えない）

見た場所: ~/…/PixelstoMotors … つながっている ／ /Volumes/KIOXIA/r2r2r-data … つながっている ／ ~/r2r2r-scripts … つながっている

番号の読み方: 記事番号は DAY と LOG で同じ通し番号。`Log{NN}` はシリーズ内の番号で、シリーズ名と組で読む（data_manifest_rules.md 11節）。

## 知りたいこと → 開く場所

| 知りたいこと | 開く場所 | 更新 | 補足 |
|---|---|---|---|
| 置き場所の決まり（種類 → 場所） | [マスターMD/MASTER.md](../マスターMD/MASTER.md) | 2026-10-26 | 正は MASTER。台帳の列・番号の決まりは Project の data_manifest_rules.md |
| 公開記事の一覧（記事番号・シリーズ・Log番号） | [_index/data/published_articles.csv](data/published_articles.csv) | 2026-11-01 | 公開記事（p2m-site）から書き出したもの。シリーズと番号の正 |
| 記事 ↔ データの関係 | [_index/data/relations.csv](data/relations.csv) | 2026-11-01 | 記事の作業ファイルに書かれた参照だけ。名前だけの候補は candidates.csv |
| 記事の作業ファイル | [デジタルツイン学習R2R2R](../デジタルツイン学習R2R2R) | 2026-11-01 | シリーズ名のフォルダ。単体記事は 記事/ |
| 2026-10-12 までの公開サイト一式 | [site_archive/README.md](../site_archive/README.md) | 2026-10-12 | 取得した時点の写し |
| 調査メモ・論文 | [research/README.md](../research/README.md) | 2026-10-30 |  |
| ロボット動画の観察台帳 | [research/robot_video_ledger.md](../research/robot_video_ledger.md) | 2026-10-30 |  |
| 課題地図 | [physical-ai-todo/README.md](../physical-ai-todo/README.md) | 2026-06-13 |  |
| Claude Code への依頼と報告 | [依頼箱/README.md](../依頼箱/README.md) | 2026-10-29 |  |
| SNS の分析データ | [sns_analytics](../sns_analytics) | 2026-10-29 |  |
| データの置き方（KIOXIA） | `/Volumes/KIOXIA/r2r2r-data/README.md` | 2026-06-13 | 古い。下の「古くなった説明」を見る |
| 実機の通し試行（PICK→PLACE） | `/Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger_v3.csv` | 2026-10-22 | 1試行1行の台帳。試行ごとの中身は runs/run_pick_place/ |
| 受け渡しの通し試行 | `/Volumes/KIOXIA/r2r2r-data/runs/handover_ledger_v1.csv` | 2026-07-20 | 試行ごとの中身は runs/run_handover/ |
| 実機の単体実験 | `/Volumes/KIOXIA/r2r2r-data/runs/experiments` | 2026-10-19 | 実験ごとの索引 CSV がある場合あり |
| 学習用データセット | `/Volumes/KIOXIA/r2r2r-data/datasets` | 2026-10-31 | 各フォルダの index.json（使った試行・used・reason）と README（元と処理） |
| 学習済みモデル | `/Volumes/KIOXIA/r2r2r-data/checkpoints` | 2026-11-01 | モデルの中に学習の記録はない。由来は lab/Log{NN}/README.md と関係表で引く |
| 学習・推論の記録（フィジカルAI研究室） | `/Volumes/KIOXIA/r2r2r-data/lab` | 2026-10-30 | Log{NN} はシリーズ内の番号 |
| 画像認識シリーズの画像と測定 | `/Volumes/KIOXIA/r2r2r-data/vision` | 2026-07-28 | Log{NN} はシリーズ内の番号（研究室の Log とは別） |
| 校正の採用・不採用 | `/Volumes/KIOXIA/r2r2r-data/mecharm_calibration/README.md` | 2026-06-13 | 各フォルダの README・_LEDGER.md・_INDEX |
| 録画 | `/Volumes/KIOXIA/r2r2r-data/recordings` | 2026-10-29 |  |
| サービスロボット導入シミュレーション（Unity） | `/Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv` | 2026-11-01 | 1回1行の台帳 |
| 解説動画 | `/Volumes/KIOXIA/r2r2r-data/videos` | 2026-10-31 |  |
| 動くコードの一覧 | `~/r2r2r-scripts/WORKING_ASSETS.md` | 2026-06-11 | 古い。下の「古くなった説明」を見る |
| 学習前のデータセット点検 | `~/r2r2r-scripts/pi05/check_dataset.py` | 2026-10-31 | 読むだけの点検 |
| この索引の検証報告（Codex） | [_index_review/codex_report_20261101.md](../_index_review/codex_report_20261101.md) | 2026-11-01 |  |

## 記事 ↔ データの関係

- [data/relations.csv](data/relations.csv)：**確定 575 件（記事 67 本）**。記事の作業ファイルの本文に書かれた KIOXIA のパスで、実在するもの。`evidence` に根拠のファイルと行
- 同じファイルの「参照先なし」29 件：本文にあるが KIOXIA に見つからないパス（移した・書き間違い・例として書いた、のどれかは見ていない）
- [data/candidates.csv](data/candidates.csv)：候補 29 件。フォルダ名の番号だけで結びつくもの。**確定ではない**
- 関係の種類（作った・使った・比べた）はまだ分けていない。すべて「本文で参照」

| シリーズ | データと結びついた記事 | 公開記事の本数 |
|---|---:|---:|
| 導入シミュレーション | 20 | 27 |
| R2R2R | 20 | 185 |
| フィジカルAI研究室 | 13 | 18 |
| 画像認識・物体検出 | 9 | 12 |
| ひとり研究所の裏側 | 5 | 43 |
| UV-C農業ロボット | 0 | 2 |
| HRI | 0 | 57 |
| 考察記事 | 0 | 72 |

## 古くなった説明

- `/Volumes/KIOXIA/r2r2r-data/README.md`（更新 2026-06-13）に載っていないフォルダ: `diag/`、`lab/`、`service_sim/`、`videos/`、`vision/`
- `/Volumes/KIOXIA/r2r2r-data/runs/README.md`（更新 2026-07-15）の台帳の説明と見比べる: `pick_place_ledger_v3.csv` は今 750 行・82 列（列の正は data_manifest_rules.md）
- `site_archive/` は 2026-10-12 時点の写し（公開記事は今 LOG416 まで）。現行の記事一覧には `_index/data/published_articles.csv` を使う
- `~/r2r2r-scripts/WORKING_ASSETS.md` の更新は 2026-06-11。それ以降のコードは載っていない

## 作り直し方（週1回）

1. 公開記事の一覧を新しくする（p2m-site の最新を読める場所で）: `python3 tools/export_published.py --p2m-site <p2m-site> --out data/published_articles.csv`
2. Mac で KIOXIA をつないで: `python3 tools/build_index.py`

元データ（iCloud・KIOXIA・r2r2r-scripts）は読むだけ。書くのは `_index/` の中だけ。KIOXIA で読むのは r2r2r-data の中（archive/ を除く）の名前と、README の文字・台帳の行数と列数だけ。
```

### build_log.json（同じ回の実行の記録）

```json
{
  "started": "2026-11-01T08:19:54+09:00",
  "roots": {
    "I": "ok",
    "K": "ok",
    "C": "ok"
  },
  "errors": [],
  "finished": "2026-11-01T08:20:20+09:00"
}
```

### data_manifest_rules.md の11節・12節（このときに足した決まり）

```markdown
### 11. 番号の種類と書き方（2026-11-01）

記録とデータには、意味の違う番号が何種類もある。見た目が同じ番号を別の意味で使うと、記事とデータを取り違える（例：`lab/Log07` は研究室の7回目、`vision/captured/Log07` は画像認識の7回目で、別物）。番号は付け替えず、種類ごとの意味と書き方を決める。

#### 11-1. 番号の種類

| 種類 | 形 | 意味 |
|---|---|---|
| 記事番号 | `DAY{NNN}` → `LOG{NNN}` | 記事の通し番号。初期は DAY、記事を毎日出さなくなってから LOG に名前を変えた。DAY と LOG は同じ通し番号（LOG は DAY の続き） |
| シリーズ内の番号 | `Log{NN}` | そのシリーズの何回目か。シリーズ名と組でなければ意味を持たない |
| 試行の番号 | `run_stem` の全体（例 `run_0001_20261019_191257`） | 1回の試行。`run_0001` だけでは別の日の試行と重なるので、日付まで含めた全体で1回を指す |
| 学習・推論の回 | `pi05_{YYYYMMDD}_{HHMMSS}` など | 1回の学習・推論の実行 |
| データの版 | `_v1`・`_v2`・`_trim2`・`_corr_v3` など | そのデータセット・モデルの版。記事番号ではない |
| 学習の段数 | checkpoint の step（`1499`・`3999` など） | 学習の何段目のモデルか。記事番号ではない |

記事番号とシリーズ内の番号の正は、公開記事（pixelstomotors.com）とする（MASTER「シリーズの正は公開記事」に従う）。

#### 11-2. 書き方の決まり

- 既存のフォルダ名・ファイル名・台帳の値は付け替えない（記録の改変を始めないため）
- 文章・README・これから出力する meta には、番号を種類つきで書く。例：`記事LOG352`、`研究室Log09`、`画像認識Log07`、`run_0001_20261019_191257`
- これから出力する meta（各回の JSON・`recording_meta.json` など）には、シリーズ名とシリーズ内の番号を組で入れる。分かっていれば記事番号も入れる。記事が後から決まる場合は空のままでよい（対応は11-3で足す）
- 番号どうしの対応（記事番号 ↔ シリーズ内の番号 ↔ 試行・学習の回・データ・checkpoint）は、番号に詰め込まず、索引の関係表に置く（置き場所は MASTER の保存先テーブルに従う）

#### 11-3. 関係表への載せ方

- 載せる根拠は、記事本文・README・meta に書かれた明示の参照を優先する。名前や日付が近いだけのものは「候補」とし、確定と分ける
- 1つの記事が複数のデータを使い、1つのデータが複数の記事で使われることがある（多対多）。関係には種類（作った・使った・比べた など）を付ける
- 仮の番号（例：README に「番号は仮」と書かれたフォルダ）は「仮」として載せ、確定したら確定の行を1行足す（既存の行は書き換えない）

---

### 12. 出力に付ける由来（provenance）（2026-11-01）

データ・モデルが「どのコード・どの入力・どの設定で、どう終わったか」を、作った時点で機械が読める形で残す。主指標の「再現可能」の条件2（依存関係とバージョンが記載されている）を、手書きの README に頼らず満たすため。

#### 12-1. 対象

- **これから作る出力だけ**。既存のデータ・JSON・checkpoint には書き足さない（過去の由来は索引の関係表と README でたどる）
- 段階的に入れる：①学習と checkpoint → ②データセットを作るスクリプト → ③実機の試行（`run_pick_place.py`・recorder など）。③は関節の制御に触れないが、実機の流れの中なので codex のレビューと dry-run を通してから使う

#### 12-2. 置き方

- 既存の出力 JSON（各回の JSON・`recording_meta.json`・データセットの `index.json`・lab の記録など）に、キー `provenance` を1つ足して中に入れる。由来のためだけの JSON を別に増やさない
- 例外：checkpoint は中に書ける JSON がない（`_CHECKPOINT_METADATA` は openpi の形式）。新しく置く checkpoint の各ステップのフォルダ（`checkpoints/<モデル名>/<ステップ>/`）に `train_provenance.json` を1つ置き、同じものをその回の `lab/Log{NN}/` にも写す。Hugging Face に上げるときも同じファイルを入れる
- **台帳の列は増やさない**（8-A の手順を踏まずに済ませるため）。台帳から各回の JSON へは、今までどおり `run_stem` でたどる

#### 12-3. 項目（`schema_version` = `provenance/1`）

| 項目 | 中身 |
|---|---|
| `schema_version` | `provenance/1`。項目を変えるときは版を上げる |
| `created_at` | 作った時刻（ISO8601・タイムゾーンつき） |
| `producer` | スクリプトのパス（r2r2r-scripts からの相対）・実行したコマンドの引数（秘密の値は除く）・実行した機械（Mac / Pod など） |
| `code` | `repo`（r2r2r-scripts）・`commit`・`dirty`（未コミットの変更があるか）・`dirty_files`（変更のあるファイル名）・`diff_sha256`（変更の中身の指紋） |
| `external_code` | 使った外部のコードごとに `name`・`commit`・`dirty`（例 openpi） |
| `inputs` | 入力ごとに `kind`（dataset / checkpoint / run / recording / config）・`id`（フォルダ名やモデル名）・`path`（r2r2r-data からの相対）・`fingerprint` |
| `config` | 設定の名前と主な値（学習なら設定名・ステップ数・初期の重み・学習率など） |
| `compute` | 学習・推論でクラウドを借りたとき：借り先・GPU の型番・始めと終わりの時刻・時間・費用（MASTER「買う前に借りて測る」の記録） |
| `numbering` | `series`・`series_log`・`article_no`（11節。分からなければ空） |
| `status` | `process`（completed / interrupted / failed：処理が最後まで動いたか）と `task`（success / failure / unknown / not_applicable：タスクが成功したか）を分けて書く |

#### 12-4. 指紋（fingerprint）の作り方

- `files-v1:` ＋ sha256。中身は、フォルダ内のファイルを相対パスの順に並べた「相対パス・バイト数」の一覧と、1MB 未満の JSON（`index.json` など）の中身
- 大きなファイル（画像・動画・重み）は中身を読まない。数GBのものを毎回読むと出力が遅くなるため
- `._` で始まるファイル（exFAT の印）・`.cache`・一時フォルダ（`.incoming_*`）は数えない

#### 12-5. 守ること

- 未コミットの変更があっても止めない。`dirty: true` と変更のファイル名・指紋を残して進める（研究を止めないため）
- 鍵・トークン・パスワードは書かない。コマンドの引数に秘密が入る場合は値を伏せる
- 由来を書く処理が失敗しても、本来の出力（学習・データ・試行）は止めない。そのときは `provenance` に `error` を書く
- 書き方は共通の部品（r2r2r-scripts の `provenance.py`）にまとめ、各スクリプトはそれを呼ぶ
```

### codex_plan_verification_request.md（Codex への検証の依頼書）

```markdown
# 検証依頼（敵対的検証）：Pixels to Motors データ索引と、データの質を上げる仕組みの「作る前の設計」

作成：Claude（2026-11-01）　依頼者：エイイチ
対象：まだ作っていない索引の **設計案**（「設計案」節）、**データの質を上げる仕組みの設計案**（「追加の設計案」節）と、その前提にした Claude の調査結果

---

## あなたの役割

この設計案は Claude が書いた。同じ書き手の確認は同じ盲点を持つ。
あなたは **「この設計案は前提が間違っているか、作っても役に立たないか、データを壊す危険がある」という前提で、その証拠を探す** 役割を担う。

- 「たぶん大丈夫」は書かない。OK と判定する項目には、**実行したコマンド・数えた件数・比べた値** を証拠として添える。
- 下の「Claude の調査結果」を **信用しない**。実フォルダを自分で数え、自分で開いて確かめる。
- 「もっと良い方法がある」と思ったら、遠慮なく対案を出す。設計案を守る必要はない。

---

## 守ること（禁止事項）

- 下の対象フォルダは **すべて読むだけ**。変更・移動・削除・リネームをしない。
- 書いてよいのは `PixelstoMotors/_index_review/` の中だけ（報告と一時ファイル）。
- git 操作（commit・push・clean・checkout 等）をしない。
- `/Volumes/KIOXIA` 直下の `*_api_key.txt`・`*_token.txt`・`*_key.txt`・`keys/` は **開かない**（存在の確認だけ可）。
- KIOXIA は外付け SSD で `find` が遅い。全体を何度も走査せず、深さを絞るか1回の結果をファイルに保存して使い回す。

---

## 対象フォルダ（Mac 上のパス）

| 呼び名 | パス | 中身 |
|---|---|---|
| iCloud | `~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/` | 記事の作業ファイル・調査メモ・サイトアーカイブ・依頼箱など |
| KIOXIA | `/Volumes/KIOXIA/r2r2r-data/` | 実機ログ・台帳・データセット・checkpoint・録画・lab・vision・動画 |
| コード | `~/r2r2r-scripts/` | 実機・学習のコード（git） |
| その他 | `~/pixelstomotors/`（cad のみ）、GitHub `PixelstoMotors/p2m-site`（公開サイト） | |

置き場所のルールの正：`iCloud/マスターMD/MASTER.md` の「保存先テーブル」と、KIOXIA の台帳ルール `data_manifest_rules.md`（Claude の Project 側にあり、手元にない場合はスキップ可）。

---

## Claude の調査結果（設計の前提。すべて疑って確かめること）

1. **既にある索引**
   - 公開記事・マンガ・図解・動画：`iCloud/site_archive/README.md` と `site_archive/index/00〜09`。2026-10-12 時点で止まっている。
   - 調査メモ：`iCloud/research/README.md`（10/30 まで更新）。
   - 実機の台帳：`KIOXIA/runs/README.md`・`runs/pick_place_ledger_v3.csv`・`runs/run_pick_place/index.csv`・`runs/handover_ledger_v1.csv`。
   - データセット：`KIOXIA/datasets/*/index.json`・`README.md`（フォルダごと）。
   - 依頼箱：`iCloud/依頼箱/README.md`。
2. **KIOXIA の `r2r2r-data/README.md` が古い**（最終更新 2026-06-13）。書かれているフォルダは datasets / recordings / runs / checkpoints / fk_tables / archive の6つだが、実際には lab / vision / videos / service_sim / diag / mecharm_calibration なども含めて13ある。
3. **3か所（iCloud・KIOXIA・コード）を横断して「何がどこにあるか」を引ける入口がない。**
4. **記事の Log 番号からデータ（runs・lab・datasets・checkpoints・recordings）へたどる対応表がない。** フォルダ名（例 `teach_poses_LOG352`・`lab/Log17`・`*_DAY281`）から推測するしかない。

## 設計案

- 新しく足すのは2つだけ：
  - `iCloud/INDEX.md`（索引本体・自動生成）
  - 生成スクリプト1本（置き場所は未定。候補は `~/r2r2r-scripts/tools/` か `iCloud/_index/`）
- 既存ファイルは読むだけ。書き換え・移動はしない。
- `INDEX.md` の中身：
  - **入口**：「知りたいこと → 開く場所」の表（3か所横断。既存の索引へリンクする）
  - **Log 対応表**：シリーズ／Log ↔ 記事の作業フォルダ ↔ KIOXIA のデータ。フォルダ名と日付から自動で結びつけ、結びつかないものは「未対応」として一覧に出す
  - **鮮度チェック**：中身と食い違っている README を一覧にする（例：KIOXIA の README に載っていないフォルダ）
- 更新：週1回スクリプトを実行して作り直す。数字は焼き付けず、毎回実測する。

---

## 確かめてほしいこと

### A. 前提は正しいか
A1. 調査結果 1〜4 はそれぞれ正しいか（証拠つき）。
A2. Claude が見落とした **既存の索引・台帳・一覧** はないか（`README*`・`*index*`・`*ledger*`・`*MANIFEST*`・`*一覧*`・`*台帳*` などを3か所で探す）。見落としがあれば、設計案の「足りないところ」は実在するのか。

### B. Log 対応表は作れるのか（いちばん重要）
B1. 番号の体系が何種類あるか数える（例：`DAY{NNN}`・`LOG{NNN}`・シリーズ内の `Log{NN}`・日付 `YYYYMMDD`・`run_{NNNN}`）。互いの対応は機械的に決まるか、決まらないか。
B2. 実際に **10件以上サンプル** を取り、記事の作業フォルダ ↔ KIOXIA のデータを手で結びつけてみる。フォルダ名・日付だけで正しく結びついた件数／誤って結びついた件数／結びつかなかった件数を数える。
B3. 自動の結びつけが **間違った対応を自信ありげに出す** パターンはあるか（同じ番号が別の意味で使われている等）。

### C. 作っても役に立つか・害はないか
C1. 自動生成した INDEX.md が古くなる・誤るパターン（KIOXIA 未接続で実行、iCloud の同期待ちのファイル、途中の書き込み中のフォルダ等）。
C2. iCloud 直下に生成ファイルを置くことの問題（同期の衝突・`INDEX 2.md` のような複製）。
C3. 走査のコスト（KIOXIA の videos/ や archive/ のように重いフォルダ）。週1回で現実的か。
C4. 秘密情報が索引に載る危険（KIOXIA 直下のキー類、`.env`、トークンなど）。除外の仕組みが要るか。
C5. 索引が MASTER の「保存先テーブル」と二重管理になり、食い違う危険はないか。どちらを正にすべきか。

### D. 対案
D1. 「索引ファイルを作る」より良い方法があれば出す（例：既存の各 README を最新化するだけで足りる／MASTER の保存先テーブルに「何があるか」の列を足す／各データフォルダに Log 番号のメタデータを置く規則を作る、など）。
D2. 設計案と対案のどちらを勧めるか、理由つきで。

---

## 追加の設計案：データの質を上げる仕組み（元データは変えない）

前提：**既存のデータ・LOG は一切変えない**（改変を始めるとキリがないため）。質は「元データの上と、これから取るデータ」で上げる。
ここでいう質は、中身の数値の良し悪しではなく、①由来がわかる ②抜けがない（対になるものがそろう）③使ってよいかがわかる ④一貫している、の4つ。

- **方法1：注記の台帳** — 元データの横に「このデータをどう扱うか」だけを書いた台帳を1本置く（正誤表の考え方）。列の案：対象パス／判定（学習に使う・参考のみ・使わない）／理由／根拠（LOG番号・記事DAY）／記入日。行は足すだけで、判定が変わったら新しい行を足す（書き換え・削除しない）。
- **方法2：これから取るデータに、取るときに情報を付ける** — 実機・学習のスクリプトが出力時に `meta.json` を横に自動で置く。中身の案：Log番号・コードの版（git のコミット番号）・使ったデータセット／checkpoint 名・機体・カメラ・主な設定値・成功／失敗／中断。規則（MASTER の保存先テーブルか data_manifest_rules.md）を先に書き、実装は Claude Code（r2r2r-scripts）。
- **方法3：自動の点検** — 索引の生成スクリプトに点検を持たせる。**見つけて報告するだけで直さない**。対象の案：台帳に行がない実行ログ／実行ログがない台帳の行、開けない・空・途中で切れたファイル（壊れた mp4・行数が合わない CSV）、中身が同じファイルの二重保存、`meta.json` のないこれからのデータ。
- **方法4：使うデータは選んで新しい版として作る** — 既に行っている形（`teach_v1` → `trim2` → `corr_v3`）を正式な規則にし、新しい版には「元はどれで何をしたか」を必ず書く。

勧める順番（Claude の案）：方法2 → 方法1 → 方法3 → 方法4。索引・注記の台帳・点検は1つの設計にまとめる想定。

### E. 確かめてほしいこと（データの質）

E1. 既存の仕組みとの重複：注記の台帳（方法1）は、既存の台帳（`pick_place_ledger_v3.csv` の verdict・reason 列、`handover_ledger_v1.csv`、datasets の README・index.json、MASTER・data_manifest_rules）と役割が重ならないか。重なるなら既存側で足りるのでは。新しい台帳を作ることが「1種類 = 1ルート」（MASTER）に反しないか。
E2. 方法2の現状：既に出力時にメタ情報を書いているスクリプト・データはあるか（例：`recording_meta.json`、runs の JSON、lab/Log の記録、datasets の index.json）。あるなら何が欠けていて、何が既に満たされているか。git のコミット番号が残っているデータはあるか。実在の例を3件以上挙げる。
E3. 方法3の点検を **実際に一部やってみる**：(a) `runs/run_pick_place/` の run と `pick_place_ledger_v3.csv` の行の突き合わせ（片方にしかないものの件数）、(b) datasets・checkpoints のうち元データや由来の記載がないものの件数。重い走査は避け、件数と例を報告する。
E4. 方法4：既存の派生データセット（trim・corr 系）に「元はどれで何をしたか」が実際に書かれているか。書かれていないものはどれか。
E5. 方法1〜4の優先順位は妥当か。元データを変えないという前提の下で、もっと効く方法はないか（対案があれば D と同じく理由つきで）。

---

## 報告の形

`_index_review/codex_report_YYYYMMDD.md` に書く。

各項目（A1〜E5）について：
- ✅ **OK** — 証拠（コマンド・件数・比べた値）
- ⚠️ **要注意** — 何が懸念か・どう直すか
- ❌ **NG** — 前提の誤り・明確な問題（具体的なパスと修正案）

最後にサマリ：
- この設計案のまま作り始めてよいか
- 作る前に変えるべき点（優先順）
- データの質の仕組み（方法1〜4）について、採る・変える・採らないの判定と理由
```

### codex_report_20261101.md（Codex の検証の報告）

````markdown
# Pixels to Motors 索引・データ品質設計の独立検証

検証日：2026-11-01（JST）／検証者：Codex
対象：`codex_plan_verification_request.md` の設計案と調査前提。

**判定：この設計案のまま実装を始めるのは NG。既存索引をつなぐ入口は有用だが、名前・日付による対応の確定、新しい判定台帳、点検の一体化は設計変更が必要。**

重大な発見は、①既に MASTER が3拠点の保存先を横断している、②記事本文には実データへの明示参照がある、③品質管理・派生履歴・点検の既存実装がある、④run と台帳には実際の差分があるが差分＝欠損ではない、の4点。

## 検証方法・範囲・再現資料

以下の略称を使う。

- I：`/Users/shimazueiichi/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors`
- K：`/Volumes/KIOXIA/r2r2r-data`
- C：`/Users/shimazueiichi/r2r2r-scripts`
- R：`I/_index_review`

元データへの書き込み、移動、削除、リネーム、git コマンド、実機・学習スクリプトの実行は行っていない。書き込みは R のみ。KIOXIA 直下のキー類は**件数だけ確認し、内容は開いていない**。archive 配下のファイル本文も読んでいない。

初回に Python `os.walk` でルートから深さ4のディレクトリまで走査し、その各階層のファイル名を保存。`.git`、`node_modules`、`.venv`、`venv`、`__pycache__`、`_index_review` は除外。以降の一覧検索は `inventory.json` を再利用した。全階層の網羅調査ではない。必要な checkpoint の assets だけ追加で深さを絞って確認した。

| 対象 | 保存した項目数（ファイル＋ディレクトリ、隠しファイル等を含む） | 初回走査時間 |
|---|---:|---:|
| I | 14,502 | 0.562秒 |
| K | 63,411 | 6.605秒 |
| C | 2,330 | 0.086秒 |
| `~/pixelstomotors/cad` | 10 | 0.003秒 |

時間は今回の接続状態・キャッシュでの実測であり、cold scan やファイル本文検証の所要時間ではない。

主要な再集計コマンド（`R` は上記絶対パスに展開）：

```sh
python3 "$R/verify_evidence.py"
```

同スクリプトは保存済み一覧と実台帳・dataset index を読む。実行結果は `verification_metrics.json`。その他の証拠は `sample_links.json`、`article_evidence.json`、`run_reconciliation_all.json`、`metadata_audit.json`、`checkpoint_audit.json`。`run_reconciliation.json` は `run_数字` だけに狭めた予備集計であり、最終件数には使わない。

コードの既存実装の検索には次を実行し、該当箇所を `sed -n` で読んだ。

```sh
rg -n 'git_commit|git_sha|rev-parse|recording_meta|meta.json|code_version|commit_hash' \
  "$C/pi05" "$C/dynamic_tracking_limits/place/place_modules/recorder.py" \
  "$C/lab_train.sh" "$C/lab_start.sh" "$C/capture_top_frames_log07.py" \
  -g '*.py' -g '*.sh' -g '!*.bak*'
```

公開サイトの正本リポジトリ [PixelstoMotors/p2m-site](https://github.com/PixelstoMotors/p2m-site) は取得を試みたが取得失敗。現在の公開記事・GitHub の内容は未検証であり、ローカルアーカイブを公開正本の代用とは判定していない。`data_manifest_rules.md` はローカルに**2026-10-11 のアーカイブ写し**があり読めたが、現行正本とは扱わない。

## A. 前提

### A1. 調査結果1〜4 — ❌ NG（一部は正しいが、欠如の断定と数が不正確）

**1：既存索引の実在は確認。** `Path.read_text()`／保存一覧で次を独立確認した。

- `I/site_archive/README.md`：作成2026-10-12。`index/00`〜`09` は10ファイル存在。README に DAY1〜332、取得日2026-10-12 と明記。「古い」は現行サイト索引として使う場合の問題であり、過去時点のアーカイブとしては正常。
- `I/research/README.md`：本文中の日付の最大値を正規表現で抽出して **2026-10-30**。ファイル更新日時だけで判定していない。
- `K/runs/README.md`、pick-place 台帳、handover 台帳、集約 index を実読。pick-place は **750データ行・82列**、handover は **8データ行・44列**、`runs/run_pick_place/index.csv` は **1データ行・49列**。集約 index を全試行の代用にすると大きく欠ける。
- datasets は直下 **10フォルダすべてに index.json**、README は **8/10**。「各フォルダに README もある」なら誤り。scripted_v1、teach_v1 にはない。
- `I/依頼箱/README.md` は存在し、箱の運用を説明する。個別データを列挙する索引とは性質が違う。

**2：古いが、6対13という説明は誤り。** `K/README.md` は最終更新2026-06-13。冒頭のツリーは6フォルダだが、末尾には既に `mecharm_calibration/` の節があり、本文全体では7種類を説明している。実際の直下ディレクトリは **12個**：datasets、recordings、runs、checkpoints、fk_tables、archive、mecharm_calibration、diag、vision、lab、videos、service_sim。本文にもないのは **5個**（diag、vision、lab、videos、service_sim）。冒頭ツリーとの差は6個。

古さはフォルダ数だけではない。K/README は runs を学習 run と説明し、recordings に「不要なら消す」とある。MASTER の現在の実機ログの置き方・測定データのコピーのみ規則と整合しない。新 INDEX だけを加えても既存 README の誤誘導は残る。

**3：「3か所横断の入口がない」は反証された。** `I/マスターMD/MASTER.md` の「データ保管」「保存先テーブル」は C・K・I と公開正本を既に横断している。`site_archive/index/07_作業ファイルとコード.md` にもコードと KIOXIA 写しへの導線がある。実際に本文の文字列を数えると KIOXIA 3出現、r2r2r-scripts 182出現、lab/ 7出現。これは参照の数であり独立資産数ではない。**現行の具体的資産を検索する一枚の一覧が弱い**、という限定した課題なら残る。

**4：「名前から推測するしかない」は反証された。** B2 の12件すべてで記事本文が K の対象パスを明示している。全体を統一した機械可読対応表は調査範囲で確認できなかったが、既存の明示参照を捨てて推測から作り直す必要はない。

### A2. 見落とし索引 — ❌ NG（重要な既存資産を落としている）

保存一覧から basename を小文字化し、README／index／ledger／manifest／一覧／台帳を検索し、次の代表例を実読した。README 以外の一覧として `WORKING_ASSETS.md` も確認した。

| 見落とし | 実在パス | 役割・含意 |
|---|---|---|
| 動くコードの一覧 | `C/WORKING_ASSETS.md` | DAY230・2026-06-11。コード、役割、実機実績、データ、依存の入口。古さを表示してリンクすべき |
| 校正の採用／不採用 | `K/mecharm_calibration/README.md`、`j0_sweep_adopted/_INDEX_source.md`、`j0_sweep_classified/*/_LEDGER.md` | 元データ・phase・完走・detect・採用を説明。新しい注記台帳と重複 |
| 姿勢索引 | `K/mecharm_calibration/pick_z_postures_DAY243/_INDEX.md`、`pick_z_postures_index.csv` | 系統別の既存索引（名称・存在を確認） |
| Unity の横断台帳 | `K/service_sim/unity/unity_ledger_v1.csv` と README | 条件・結果・設計書への導線。索引対象を実機だけに限定できない |
| 録画一覧 | `K/service_sim/unity/night/20261030_rec/rec_index.csv`、`20261031_rec/rec_index.csv` | 条件と録画の対応（存在確認） |
| 実験の個別索引 | `K/runs/experiments/grasp_lift_test/grasp_lift_index.csv` 等 | 一般 run だけを調べると取りこぼす（存在確認） |
| 動画の利用根拠 | `I/research/robot_video_ledger.md`、動画作業フォルダの「よその映像の台帳.md」 | 学習採用とは別の、公開素材の扱いの台帳（存在確認） |
| アーカイブの索引と生成器 | `I/site_archive/data/pages.csv`、`work_files/_MANIFEST.md`、`tools/gen_index.py`、`tools/gen_files_index.py` | 既存索引の再利用対象。アーカイブ写しと現役を混ぜない |
| 学習前点検 | `C/pi05/check_dataset.py` | 画像枚数、shape、停止、関節範囲、action 等の既存 read-only 点検。方法3の設計前に再利用すべき |

README だけの最新化では、記事とデータを結ぶ機械可読の関係は揃わない。一方、「入口も品質管理もゼロから必要」という問題設定は成立しない。

## B. Log 対応（最重要）

### B1. 番号体系 — ❌ NG（単一番号に正規化して結べない）

少なくとも **7つの意味の異なる識別体系**がある：①全記事通番（DAY／大文字LOG）、②シリーズ内 Log、③日付時刻、④run の試行連番、⑤pi05 の run ID、⑥dataset の版、⑦checkpoint の step。さらに phase、pose、trial の番号を含む。DAY と LOG は全記事通番に見えるが、全範囲で同一名前空間だという仕様は未確認。

保存一覧の各 basename を正規表現で数えた値（複製、退避、AppleDouble 等も含む**字面の数**。資産数や体系数ではない）：

| パターン | 該当項目 | 異なるトークン数 |
|---|---:|---:|
| `DAY\d+` | 2,585 | 347 |
| `LOG\d+` | 1,058 | 76 |
| `Log\d+` | 34 | 14 |
| 8桁日付 `20\d{6}`（数字境界あり） | 11,566 | 98 |
| `run_\d{4}` | 4,903 | 12 |
| `pi05_YYYYMMDD_HHMMSS` | 209 | 109 |

意味の反証：LOG352 の記事は研究室 Log#9、LOG355 は研究室 Log#10、DAY317 は画像認識 Log#10。`lab/Log10` と `vision/measurements/Log10` は別物。`run_0001` は多数の日付で繰り返すため、全 run_stem を主キーにする必要がある。checkpoint の 3999 は記事番号ではない。

修正案：`article_global_id`、`series_id`、`series_log_no`、`run_id`、`dataset_id`、`checkpoint_id + step` を分離する。関係は一対一にせず、作成・使用・派生・評価などの多対多の辺として持つ。

### B2. 12件の実照合 — ⚠️ 要注意（明示参照は利用できるが、名前だけでは不足）

**手順**：現役の作業記事を読み、本文が明記するパスを取り出し、K のフォルダが存在し、下表のファイルがあることを確認。タイトルのシリーズ内 Log も照合した。主張の数値や全ログの再現性まで真と判定したものではない。対象に偏りのある意図的サンプルで、全資産の精度推定には使わない。

記事の共通親は `I/デジタルツイン学習R2R2R/`。最初の9件は「フィジカルAI研究室を作る」、最後の3件は「画像認識と物体検出を学び直す」。

| 作業記事 | シリーズ内番号 | 本文から確認した K の対応先 | 対象側の実在例 |
|---|---:|---|---|
| LOG345_article.md | 6 | `lab/Log06` | `20261017_pi05_execute_finger_on_block.mp4`、serve.log |
| LOG348_article.md | 7 | `lab/Log07` | train_resume.log、serve_4000.log |
| LOG352_article.md | 9 | `mecharm_calibration/teach_poses_LOG352` | measure_20261019_131420.json、README.md |
| LOG355_article.md | 10 | `lab/Log10` | README.md、pod/、logs/ |
| LOG358_article.md | 11 | `lab/Log11` | serve.log、pod_trim2_logs/ |
| LOG361_article.md | 12 | `lab/Log12` | serve_evening.log、LOG361_v3_success_181602_red.mp4 |
| LOG363_article.md | 13 | `lab/Log13` | serve.log |
| LOG401_article.md | 14 | `lab/Log14` | terminal_1155-1215.txt 等 |
| LOG407_article.md | 16 | `lab/Log16` | terminal_1836-1915.txt、serve.log |
| DAY299_vision_article.md | 2 | `vision/measurements/Log02` | region_stats.csv、run_meta_captured.json |
| DAY309_vision_article.md | 7 | `vision/captured/Log07` | flat_0_20260802_072613/meta.json |
| DAY310_vision_article.md | 8 | `vision/captured/Log08` | flat_0_20260807_082025/ 等 |

**件数**：本文の明示参照＋対象実在は **正しい対応12／誤対応0／未対応0**（この判定範囲内）。

名前だけの対照実験は、記事ファイルの DAY／LOG の整数と、上表のデータフォルダ名の Log／LOG の整数が一致するときだけ結び、異なる場合は棄権する保守的ルールを明示して実施した。**正しい1（LOG352）／誤り0／未対応11**。これは提案スクリプトの精度ではなく、命名だけで自明には決まらないことの実証。親フォルダ配下の全ファイル名まで探索するなど、別の規則なら結果は変わる。

日付も一意にならない。LOG345 と LOG348 の記事 frontmatter は両方 **2026-10-17**。保存一覧で同日を basename に持つ K の非 archive 項目は recordings 1、runs 20、lab 4。LOG355 の日付2026-10-20では lab の basename 日付一致は0、videos は21。一致した日付の全件を採用すれば関連のない資産を混ぜ、1件を任意採用すれば誤対応を作る。mtime はコピー・同期で変わるので、この不足を埋める確定根拠にできない。

**設計変更**：本文・既存 manifest の明示参照を第一候補にする。本文の参照でも「この回で作った」「過去データを使った」「コード例」の区別が必要。名前・日付のみは「候補」で止め、根拠パスと行、確認者・確認日を残す。「未対応」と「曖昧で複数候補」と「対応対象なし」を分ける。

### B3. 自信のある誤対応 — ❌ NG（現物の反例あり）

- シリーズを落とすと、画像認識 Log#7（DAY309）を研究室 `lab/Log07`（LOG348）へ結ぶ。両方が実在するため、存在チェックでも防げない。
- LOG352 をシリーズ内352と解釈すると、本来の Log#9を失う。大文字小文字を無条件に潰す処理は危険。
- LOG361 の記事本文は **この回の lab/Log12** と **前回の lab/Log11/offline_eval** の両方を参照する。「出現した Log は全部この回の出力」も誤り。
- `K/vision/measurements/Log12/README.md` は番号が**仮**で、記事化時に確認すると明記。フォルダの存在は公開シリーズ番号の確定を意味しない。
- 同じデータが複数の記事で使用され、同日の異なる実験が複数ある。番号・日付の近さに点数を付けても、対応確定の証拠にはならない。

受入条件に、上記反例を「確定対応にしない」ことを加える。

## C. 有用性と害

### C1. 古さ・不完全観測 — ❌ NG（状態設計がない）

実物に `K/checkpoints/pi05_mecharm_trim_corr_lora_v1/.incoming_1499_20261101_012353_54379/` があり、同時に正式な `1499/` もある。中身を重みとして開かず名称を確認した。全フォルダを完成済みとして載せる設計は既に反例を持つ。

K 未接続は「0件」とせず unavailable と記録。既存の正常な索引を空の結果で置換しない。`Path.exists()` だけではマウントの代用にならない。実際、trim2 README にも `is_mount()` に直した履歴がある。

iCloud は今回 `.icloud` という名前の項目は一覧上0件だったが、これで全ファイルのローカル実体を保証できない。未取得・読取失敗・走査上限・書込中を別状態で表示する。検証済み／候補／未観測を混ぜない。実行開始・終了時刻、対象 root、到達深度、エラー数を出力に残す。書込中 CSV は終了後再試行し、前後のサイズ・mtime が変化したものを安定済み扱いしない。これら障害注入テストは今回未実施。

### C2. iCloud 直下への生成 — ⚠️ 要注意

`I/site_archive/content/record/DAY327_pi05-libero-benchmark-first-run 2/` が実在し、`iterdir()` の結果は **空**。無印側には article.md がある。他にも「 2」のディレクトリ名が多数あるが、原因を同期衝突だとは断定できない。空の別名フォルダを別記事として数える危険は実在する。

単一端末・単一 writer に限定し、同一ディレクトリの一時ファイルから rename で公開し、同時実行を防ぐ。atomic rename は同期競合そのものの解決ではない。「INDEX 2.md」等を自動で正本に昇格させない。入口の場所は MASTER に先に登録し、既存ファイルを上書きしない初期作成手順も決める。

### C3. コスト — ⚠️ 要注意（軽量走査は可能、全面内容検査は未評価）

K の深さ制限付き一覧取得 **63,411項目／6.605秒** は週1回の入口更新には現実的。ただし videos の decode、archive の再帰全ハッシュ、checkpoint の全読込は測っておらず、この数字で実行可能と保証できない。

入口生成は許可した階層のディレクトリ名・小さな manifest に限定。archive は原則走査対象外、videos は上位名と既存台帳を利用し、media の内容検査・重複ハッシュは別コマンドで対象を明示する。変更のない巨大ファイルを毎週再読しない。低速・切断時に時間上限で止め、未検証と報告する。

### C4. 秘密情報 — ❌ NG（除外規則なしでは不可）

`/Volumes/KIOXIA` 直下で、指定のキー名パターンまたは keys に当たる項目は **7件**。存在と件数のみを確認し、名前の列挙や本文の読取はしていない。提案の K root は r2r2r-data なので直下のキーは外側だが、探索 root を親へ拡張すると事故になる。

root と読取ファイル種別の allowlist を先に決める。`.env*`、key/token/credential、keys、`.git`、設定の秘密値、秘密鍵、URL の認証情報・query を除外。symlink を追わず、解決後のパスが許可 root 内か確認する。ファイル名自体に秘密がある場合もあるため、除外対象は名前も出さない。小さい JSON だから全文を INDEX に転載する、という実装は不可。明示したフィールドのみ抜き出す。

### C5. MASTER との二重管理 — ❌ NG（正の境界を明示する必要）

MASTER は「場所を増やす前に保存先テーブルへ追記」「台帳の中身は data_manifest_rules を唯一の正」と明記。新 INDEX の場所・品質台帳の保存先が未定のまま実装するのはこれと整合しない。

正を、**配置規則＝MASTER、台帳の列・状態＝現行 data_manifest_rules、観測した資産一覧＝生成結果**に分ける。INDEX は規則を独自に書き直さず、規則のリンク・参照版と実測結果を表示する。現行の data_manifest_rules が取得できなければ「未確認」とし、2026-10-11 のアーカイブ写しを現在の正として配布しない。

## D. 対案

### D1. 対案 — ⚠️ 要注意（小さな入口＋関係データ＋既存点検の再利用）

推奨する最小構成：

1. MASTER から辿れる短い入口を1つ作り、現役の README・台帳・既存の点検コマンドにリンク。アーカイブには取得時点を表示する。既存の「作業ファイルとコード」生成器の対象範囲も調べ、流用可能な部分を使う。
2. 記事と実験・dataset・checkpoint の**明示的な関係**だけを、承認した1か所に少量の機械可読レコードとして持つ。シリーズ ID、全記事 ID、関係種別、対象 root＋相対パス、証拠、確認状態を持つ。元データの横へ一律に注記ファイルをばらまかない。
3. 既存メタ情報の欠ける列を、これからの出力で補う。run/episode ID を記事がなくても発行する。記事が後から付く場合は関係だけを後で足す。
4. 入口の生成と品質点検は実行単位を分ける。共有の読取層はよいが、重い検査が失敗して入口まで消える構成にしない。

README 更新だけの案は検索関係を補えず、各データへの手動注記案は保守場所を増やす。新 INDEX 一枚に全てを埋め込む案は、根拠と運用の境界が曖昧になる。

### D2. 推奨 — ⚠️ 要注意（修正案を推奨）

上の対案を推奨。12件すべてに既存の参照があり、10 dataset に index があるため、既存証拠をつなぐ費用が推測を検証し続ける費用より小さい。INDEX.md 自体は表示として採用してよいが、自動推定を正本にしない。「新規2ファイルだけ」という制限は、手動確認した関係・注記履歴を保存する段階では成立しない。生成物と保存すべき手入力を別に設計する必要がある。

## E. データ品質

### E1. 注記台帳の重複 — ❌ NG（現案の用途が広すぎる）

実データに次が既にある。

- pick-place 台帳の pick/place verdict・reason、handover 台帳の outcome・source・abort 情報。
- 各 dataset の index の `used`、`reason`。corr_v1〜v3 はそれぞれ5件。teach_v1 は候補20件中 used=true が19件。
- `K/mecharm_calibration/teach_poses_LOG352/README.md` は `measure_20261019_131420.json` を使わないことと、`measure_20261019_152546.json` が外れ値である理由を明記。これは方法1の機能を既に実施している。
- 校正の `_LEDGER.md`／`_INDEX_source.md` には採用・不採用と根拠がある。

ただし VLM の把持 yes は「学習に使ってよい」と同義ではない。用途・期間・データセット版を伴う利用判断、後から判明した例外の履歴には補足の余地がある。

新しい台帳は、**既存判定を複写せず、既存を覆す例外・用途制限だけ**に限定する。`対象ID＋用途＋判定＋理由＋根拠＋event_id＋supersedes＋記入者＋適用時点` を持つ追記履歴が必要。「最後の記入日が勝つ」だけでは同時追記・同日変更・取消を処理できない。対象パスだけではコピー先や改名後の同一性も不明。

「1種類=1ルート」は別種の例外イベントを一つの正規 root に置くことまで禁止しない。ただし現役台帳の複製を別 root に作るなら違反・二重管理になる。規則を先に追加し、生成時の解決規則と学習側での適用方法も決める。台帳を書くだけでは不適切データの学習利用は止まらない。

### E2. 出力メタ情報の現状 — ⚠️ 要注意（新設ではなく不足分の補完）

以下を実読した。少なくとも3件という条件に対し、5種類の具体例を確認。

| 実在例 | 既にある情報 | 足りない／保証できない情報 |
|---|---|---|
| `K/runs/run_pick_place/smoke_day287_01/recording/recording_meta.json` | run_stem、開始終了、228 captured/written、fps、画像寸法、status=ok | コード版、機体・カメラの恒久ID、記事ID |
| `K/runs/run_pick_place/run_0001_20261019_191257/run_0001_20261019_191257.json` | script、source、measure、teach、constants、結果、abort、judgments 等のキー | git commit はこの構造から自動的には保証されない |
| `K/datasets/mecharm_redblock_box_teach_v1/raw_episodes/run_0001_20261019_191257/meta.json` | 205 steps、fps=10、state_names、action の意味、human_verdict | 変換コード版、元ファイル指紋、dataset 全体の生成レシピ |
| `K/vision/captured/Log07/flat_0_20260802_072613/meta.json` | 撮影日時、カメラ解決番号・backend、1920×1080、5フレーム、対象寸法 | カメラ番号は恒久IDではない。code commit・公開記事との確定関係 |
| `K/lab/Log10/README.md` | dataset、checkpoint、設定、GPU、学習条件、**openpi: 215abfb** | C 側のローカル変更・addon の厳密な版は特定しきれない |

git のコミット番号が残るデータは**ある**（lab/Log10 の openpi 版）。全データにないという断定は誤り。ただし、保存一覧にある非 archive の `meta.json`／`recording_meta.json` **254ファイル**を JSON parse して commit／git_sha／revision／code_version というキーを再帰検索すると、該当 **0件**、parse error **0件**（`metadata_audit.json`）。他のJSON・ログ・README全体にコミットがない、という意味ではない。

`C/pi05/build_demo_episodes.py` は既に meta を出力し、`recorder.py` は `recording_meta.json.tmp` からの atomic write を持つ。`policy_recorder.py` には別形式の top_meta.json がある。共通の meta.json をもう1枚必須にする前に、既存形式へ schema_version、code repo＋commit＋dirty状態、入力ID／版、単位・座標系・実行状態を追加する方針にする。成功は「処理が完了」と「タスク達成」を分離する。abort では meta が残らない場合も recorder のコメントに明記されており、「metaなし＝古いデータ」だけではない。

### E3. 一部点検の実行結果 — ⚠️ 要注意（差分は実在、原因の一括断定不可）

#### (a) run と pick-place 台帳

`csv.DictReader` で run_stem を集合化し、保存一覧の `runs/run_pick_place/` **直下の全ディレクトリ名**と完全一致で比較。`run_数字` だけでなく pick_then_place_auto と smoke も含めた。

| 比較 | 件数 |
|---|---:|
| 台帳データ行／unique run_stem | 750／750 |
| 直下ディレクトリ | 849 |
| 両方にある | 716 |
| 台帳だけ | **34** |
| ディレクトリだけ | **133** |
| 重複 run_stem | 0 |
| CSV行幅 | ヘッダを含め751行すべて82列 |

台帳だけの例：`run_0042_test`、`run_0001_20260618_142436`、`pick_then_place_auto_20260621_192329`。ディレクトリだけの例：`run_0001_20260711_183351`、`run_0001_20261015_102101`、`smoke_day287_01`。

**34件すべてを「実行ログが消えた」としない。** 比較は指定 root 直下との関係。台帳だけ34件のうち、run_dir が非空かつその記載先が存在する行は **1件**。別の階層や旧形式の可能性がある。ディレクトリだけ133件には **smoke_ 6件**が含まれる。また **25件**は今回の深さ範囲で通常ファイルを観測できなかった（完全に空との断定ではない）。試験・dry-run・中断・旧形式を分類した上で、追記義務違反を別途判断する。

一般的な `run_数字` だけを対象にすると 783フォルダ、共通664、台帳だけ86、フォルダだけ119になり、採用すべき最終結果と変わる。この実例からも、点検対象の定義を先に固定する必要がある。最終の全差分は `run_reconciliation_all.json` に保存。

#### (b) dataset と checkpoint の由来

「記載がない」の基準を二つに分けた。名前だけの想像を由来の証拠とはしない。

- **手掛かり基準**：元 run ID・元 dataset ID・元パスのいずれかが index/meta/README/assets にあるか。
- **再現基準**：入力の厳密な版、変換／学習コマンドと設定、実行コード版が対象と結び付いているか。これはより厳しい。

**dataset：10フォルダ。index.json がないもの0、READMEがないもの2。** README がない scripted_v1 と teach_v1 も index の run ID と per-episode meta を持つので、「由来の手掛かりゼロ」とは数えない。各1件で元 `runs/run_pick_place/<run_stem>` が実在することも確認。残り8件は README に元データ／生成コマンドがある。したがって今回の手掛かり基準では **由来記載なし0/10**、一方で **dataset 直下の明示的な元パス・生成レシピ文書がない2/10**。全 episode の入力指紋・コード版まで満たす件数は未検証。

**checkpoint：4モデル root／正式な数値 step は5。**

| モデル root | step | assets 配下の norm_stats の dataset ID |
|---|---|---|
| pi05_mecharm_lora_v1 | 1999、4000 | local/mecharm_redblock_box_v1 |
| pi05_mecharm_teach_lora_v1 | 3999 | local/mecharm_redblock_box_teach_v1 |
| pi05_mecharm_trim_lora_v1 | 3999 | local/mecharm_redblock_box_teach_v1_trim2 |
| pi05_mecharm_trim_corr_lora_v1 | 1499 | local/mecharm_redblock_box_teach_v1_trim2_corr_v3 |

手掛かり基準の由来記載なしは **0/4モデル（0/5 step）**。ただし **1/4モデル（2/5 step）** の `mecharm_redblock_box_v1` は現在の datasets 直下名と一致せず、別名の対応規則が要る。norm_stats のIDは学習入力そのものの証明ではない。5 step すべての `_CHECKPOINT_METADATA.custom_metadata` は `{}` であり、checkpoint 内だけで学習 run・入力版・コード版を結ぶ provenance が揃うと判断できたものは **0/5**。外側の lab/Log10 README のような記録を関係表に取り込むべきである。

上の0件と不足件数は矛盾しない。「何も書かれていない」と「再現可能な来歴が揃わない」を混同しないために両方示した。mp4の全面decode、CSVと録画の全行対応、全ファイルhashは実施していない。

### E4. trim・corr の派生来歴 — ✅ OK（人が読む由来は既にある）／⚠️ 厳密な版固定は不足

全 **8派生フォルダ**の README と index を実読した。

| 派生 | 記載された元と処理 |
|---|---|
| teach_v1_trim | teach_v1、途中の停止を詰めるコマンドと理由 |
| teach_v1_trim2 | teach_v1、前版trim、レビュー6件の修正、t_orig、グリッパー保護等 |
| corr_v1 | runs/pi05_policy の成功 run、model/correct 採用、rewind等の除外 |
| corr_v2 | 同上＋ `--gap-fill speed20` |
| corr_v3 | 同上＋ `--gap-fill speed20 --trim-hold` |
| teach_v1_trim2_corr_v1 | trim2＋corr_v1、mergeコマンド、19＋5＝24本、4,186コマ |
| teach_v1_trim2_corr_v2 | trim2＋corr_v2、24本、3,876コマ |
| teach_v1_trim2_corr_v3 | trim2＋corr_v3、24本、3,443コマ |

この8件で「元はどれで何をしたか」の**説明がないものは0件**。merge の index は sources と episode ごとの from_dataset を持つ。`source` が空の episode でも from_dataset があるので、単一キーの空欄だけで来歴欠如に数える点検は誤り。

方法4は未着手ではない。ただし `--run <19本を列挙>` は実行コマンドの完全保存ではなく、元ファイル指紋・処理コード版・全入力の固定が不足する。存在する説明と、完全な再現保証は区別する。

### E5. 優先順位 — ❌ NG（現状を踏まえて組み替える）

「2→1→3→4」では、既存点検を再実装し、既にある派生来歴を後回しにする。推奨順：

1. **規則と既存資産の対応を確定**：MASTER と現行 manifest の正を確認し、対象の単位・ID・完了状態・例外判断の意味を決める。保存先未定のまま増やさない。
2. **方法3の軽量部分を先行**：今回の台帳差分と既存 index/meta の読み取り、既存 check_dataset の再利用。全履歴の是正を始めず、既知差分はベースラインとして残し、新規差分を識別する。
3. **方法2と方法4を一緒に補強**：新規出力にコード版・入力版・schema・終了状態を付け、変換／学習ごとに来歴を残す。記事番号未定でも記録可能にする。使用・学習の直前に必要条件を検証する。
4. **方法1は必要な例外だけ**：校正READMEやdataset indexにない、用途を伴う後日判断に限定。例外が増える実例を確認してから1本の正本を設計する。

品質判断は四つを別々にする：処理の正常終了、タスクの成功、構造の整合、特定用途への利用可否。たとえば成功のみの抽出は模倣学習には適しても、失敗検知の評価データを欠落させる。未判定を自動で「使う」に昇格させない。

## 最終サマリ

### このまま着手してよいか

**不可。** 入口の試作に進むなら、既存索引へのリンクと観測状態の表示までに限定する。推測の確定対応、汎用の採否台帳、重い検査の週次一括実行は、その前に設計を修正する。

### 作る前に変える点（優先順）

1. **番号と関係の仕様**：全記事通番とシリーズ内番号を分離し、明示参照を優先。候補と確定を分ける。関係種別と多対多を許容する。
2. **正本と保存先**：MASTER を配置規則の正、現行 data_manifest_rules を列・状態の正とする。新しい保存先を登録し、既存台帳の複製を増やさない。
3. **未観測と完成の仕様**：未接続、iCloud読取失敗、書込中、incoming、仮Logを欠損・完成済みと混同しない。
4. **走査・公開の安全性**：root/field allowlist、秘密情報除外、symlink制限、単一writer、atomic出力。失敗時に正常版を空へ置換しない。
5. **既存実装の再利用**：check_dataset、dataset index/meta、校正台帳、Unity台帳、アーカイブ生成器を先に調べる。入口生成と重い検査の実行を分ける。
6. **新規データの来歴固定**：コードcommitだけでなくdirty状態、設定、入力ID/版、単位・座標系、変換レシピを持つ。checkpointと学習runを明示接続する。

### 方法1〜4の採否

| 方法 | 判定 | 理由 |
|---|---|---|
| 1 注記台帳 | **変える** | 既存 verdict/used/校正注記と重複する。用途別の後日例外・取消履歴に限定し、実際の利用時に適用する |
| 2 取得時メタ情報 | **採る・既存形式を拡張** | 254件の既存metaがあり新設一律化は不要。欠けるコード版・入力版・終了状態を補う |
| 3 自動点検 | **採る・軽重を分離** | 差分は実測で存在。既存点検を利用し、差分を即欠損扱いしない。全動画decode/全hashは通常更新から分離 |
| 4 選別した新しい版 | **採る・既存運用を正式化** | 8派生すべてに元と処理の説明がある。完全な入力・コード版固定を追加し、元データ不変を維持 |

未解決：現行GitHubと現行 data_manifest_rules の内容、すべてのepisodeの来歴解決、差分167件の個別原因、媒体の全内容整合、障害時の生成器挙動。これらを検証済みとはしていない。本検証で元データの修正や索引の本実装は行っていない。
````

### verify_evidence.py（Codex が件数を数え直すために書いたスクリプト）

```python
"""Read-only checks against cached bounded inventory. Writes only review evidence JSON."""
from pathlib import Path
import json,csv,re,collections
REVIEW=Path(__file__).resolve().parent
I=REVIEW.parent
K=Path('/Volumes/KIOXIA/r2r2r-data')
entries=json.loads((REVIEW/'inventory.json').read_text())['entries']
rows=list(csv.DictReader((K/'runs/pick_place_ledger_v3.csv').open(newline='')))
D={Path(e['path']).name for e in entries if e['root']=='kioxia' and e['type']=='dir' and str(Path(e['path']).parent)=='runs/run_pick_place'}
S={r['run_stem'] for r in rows}
result={'run':{'rows':len(rows),'columns':len(rows[0]),'unique_stems':len(S),'directories':len(D),'both':len(S&D),'ledger_only_count':len(S-D),'directory_only_count':len(D-S),'ledger_only':sorted(S-D),'directory_only':sorted(D-S)},'datasets':[],'number_patterns':{},'date_candidates':{}}
for ds in sorted((K/'datasets').iterdir()):
 if not ds.is_dir(): continue
 idx=json.loads((ds/'index.json').read_text())
 ep=idx.get('episodes',idx)
 metas=[e['path'] for e in entries if e['root']=='kioxia' and e['path'].startswith('datasets/'+ds.name+'/raw_episodes/') and e['path'].endswith('/meta.json') and not Path(e['path']).name.startswith('._')]
 result['datasets'].append({'name':ds.name,'readme':(ds/'README.md').exists(),'index_entries':len(ep),'meta_files':len(metas),'source_datasets':list(idx.get('sources',{}))})
for pat in [r'DAY\d+',r'LOG\d+',r'Log\d+',r'(?<!\d)20\d{6}(?!\d)',r'run_\d{4}',r'pi05_\d{8}_\d{6}']:
 hits=[e for e in entries if re.search(pat,Path(e['path']).name)]
 result['number_patterns'][pat]={'entries':len(hits),'distinct_tokens':len({v for e in hits for v in re.findall(pat,Path(e['path']).name)})}
for day in ['20261017','20261019','20261020','20261021','20261022','20261024','20261029','20261030']:
 result['date_candidates'][day]=dict(collections.Counter(e['path'].split('/')[0] for e in entries if e['root']=='kioxia' and not e['path'].startswith('archive/') and day in Path(e['path']).name and not Path(e['path']).name.startswith('._')))
samples=json.loads((REVIEW/'sample_links.json').read_text())
result['sample_test']={'sample_count':len(samples),'explicit_reference_and_exists':sum(s['target_exists'] and s['explicit_reference_in_article'] for s in samples),'strict_number_correct':sum(s['strict_name_match'] for s in samples),'strict_number_unmatched':sum(not s['strict_name_match'] for s in samples),'strict_number_wrong':0}
(REVIEW/'verification_metrics.json').write_text(json.dumps(result,ensure_ascii=False,indent=2))
print(json.dumps({'run':{k:v for k,v in result['run'].items() if not isinstance(v,list)},'sample_test':result['sample_test'],'datasets':result['datasets']},ensure_ascii=False,indent=2))
```

### 20261101_54_学習とcheckpointに由来を記録する.md（Claude Code への依頼）

```markdown
---
作業フォルダ: /Users/shimazueiichi/r2r2r-scripts
上限時間: 150
---
# 依頼：学習とcheckpointに「由来（provenance）」を自動で記録する（2026-11-01・段階①）

## 最初に確かめること

依頼52（lab_start.sh のモデル選択・lab_train.sh の3つの修正）が終わっていること。`~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/依頼箱/済み/` に 20261101_52 があり、その変更がコミットされていることを `git log` で確かめる。終わっていなければ何もせずに止めて、報告の「決めてほしいこと」に書く（同じ lab_train.sh を触るため）。

## 目的

学習済みモデル（checkpoint）に、どのデータ・どのコード・どの設定で学習したかの記録がない（既存の checkpoint 5件はすべて `_CHECKPOINT_METADATA` の custom_metadata が空）。これからの学習で、作った時点に機械が読める形で残す。決まりの正は Project の data_manifest_rules.md 12節。要点を下に写す。

## 作るもの

### 1. 共通の部品 `provenance.py`（r2r2r-scripts 直下か pi05/ のどちらか。置き場所は判断して報告）

- Python から呼べる関数と、bash から呼べる CLI の両方
- 出す JSON（`schema_version: "provenance/1"`）の項目：
  - `created_at`（ISO8601・タイムゾーンつき）
  - `producer`：スクリプトの相対パス・コマンドの引数（秘密は伏せる）・実行した機械
  - `code`：`repo`=r2r2r-scripts・`commit`・`dirty`・`dirty_files`・`diff_sha256`
  - `external_code`：`[{name, commit, dirty}]`（openpi など）
  - `inputs`：`[{kind, id, path, fingerprint}]`（kind = dataset / checkpoint / run / recording / config。path は r2r2r-data からの相対）
  - `config`：設定名と主な値
  - `compute`：借り先・GPU の型番・始めと終わりの時刻・時間・費用
  - `numbering`：`series`・`series_log`・`article_no`（分からなければ空）
  - `status`：`process`（completed / interrupted / failed）と `task`（success / failure / unknown / not_applicable）を分ける
  - 失敗したときは `error`
- 指紋 `fingerprint`：`files-v1:` ＋ sha256。フォルダ内のファイルを相対パスの順に並べた「相対パス\tバイト数」の一覧と、1MB 未満の JSON の中身から作る。画像・動画・重みの中身は読まない。`._` で始まるファイル・`.cache`・`.incoming_*` は数えない
- 鍵・トークン（HF・RunPod など）を JSON に入れない。引数に秘密が入る場合は値を伏せる
- 由来を書く処理が失敗しても、呼んだ側（学習）は止めない

### 2. lab_train.sh に組み込む

- 学習が終わり、KIOXIA の `checkpoints/<CKPT_NAME>/<ステップ>/` に置けたら、そのフォルダに `train_provenance.json` を新しく置く（既にあれば上書きしない）。同じものをその回の `lab/Log{NN}/train/` にも置く
- 入力：学習データ（設定のデータ名と、KIOXIA の `datasets/<名前>` の指紋）・初期の重み（CKPT_IN に当たる KIOXIA の checkpoint の指紋）
- 外部のコード：Pod の openpi のコミットと変更の有無（ssh は依頼52で付けた時間の上限を使う）
- compute：GPU_USED・Pod の始めと終わり・時間・COST_PER_HR と費用（今の [COST] の行と同じ値）
- numbering：series = フィジカルAI研究室、series_log = その回の lab/Log の番号
- 学習が途中で止まった・失敗したときも、`lab/Log{NN}/train/` に status つきで残す（checkpoint がなければ checkpoint 側には置かない）
- Hugging Face のモデルにも同じファイルを入れられるなら入れる（入れられなければ理由を報告。失敗しても止めない）

## 決まり

- 既存の checkpoint・データセット・runs には書き足さない（これからの出力だけ）。今ある `pi05_mecharm_trim_corr_lora_v1/1499` にも足さない
- 測定データは上書き・移動・削除しない。KIOXIA に書く前にマウントを確かめる
- 台帳の列は増やさない
- 本物の Pod は借りない（費用がかかる）。確かめは lab_train_fakes.sh などの偽物で行う
- 関節を動かすコードは触らない

## 確かめ

- 既存のテストが全部通ること（lab_train・lab_start・pi05 の各テスト）。provenance.py のテストと、lab_train に組み込んだ部分のテスト（成功・途中で止まる・KIOXIA 未接続・dirty のとき）を足す
- 出した JSON の例を1つ報告に貼る（偽物の実行で出たもの）
- codex のレビューを通す（docs/ に依頼文と結果を残す）
- git は個別ファイルを指定して add する

## 報告に書くこと

- 変えたファイル、コミット、テストの件数
- provenance.py の置き場所と、②データセット・③実機の試行に使い回すときの呼び方（1〜2行）
- 決めてほしいこと
```

### 週1回の作り直しの予定の指示文

```text
（予定の名前：データの索引の週1回の作り直し／毎週月曜 17:42（日本時間）／Mac と KIOXIA を使う）

あなたは Pixels to Motors のデータの索引（iCloud の PixelstoMotors/_index/）を、週1回作り直す係です。短く、次の手順だけをやって終える。ほかの作業はしない。返事は日本語。

決まりの正: MASTER の保存先テーブル「データの索引」の行、Project の data_manifest_rules.md 11節。元データ（iCloud・KIOXIA・r2r2r-scripts）は読むだけ。書いてよいのは _index/ の中だけ。

1. 公開記事の一覧を新しくする
   - GitHub の PixelstoMotors/p2m-site を clone する（git clone --depth 1 https://github.com/PixelstoMotors/p2m-site）。できなければ add_repo（read）で足してからやり直す。このリポジトリには書き込まない。リポジトリのコードは実行しない。
   - device_stage_files で Mac の次のファイルを取り込む:
     ~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/_index/tools/export_published.py
   - 取り込んだスクリプトを実行する: python3 -I <取り込んだ export_published.py> --p2m-site <clone したフォルダ> --out /mnt/user-data/outputs/_index/data/published_articles.csv
   - device_commit_files（force: true）で、その CSV を Mac の ~/Library/Mobile Documents/com~apple~CloudDocs/デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/_index/data/published_articles.csv に書き戻す。書き戻したあと device_list_dir でサイズが同じになったことを確かめる。
   - clone か書き出しに失敗したら、この段は飛ばして前回の一覧のまま 2 に進み、報告にそう書く。

2. Mac の shell（device_bash）で索引を作り直す:
   cd "$HOME/mnt/PixelstoMotors/_index" && timeout 170 python3 tools/build_index.py --icloud "$HOME/mnt/PixelstoMotors" --kioxia "$HOME/mnt/KIOXIA/r2r2r-data" --scripts "$HOME/mnt/r2r2r-scripts"
   （$HOME/mnt/ の下のフォルダ名が違うときは ls $HOME/mnt/ で確かめて合わせる。KIOXIA がつながっていなくても実行してよい。スクリプトが関係表を前回のまま残し、INDEX.md に「未接続」と書く）

3. 最後に、次を3〜5行で報告する:
   - 公開記事の数（published_articles.csv の行数）と、一番新しい記事番号
   - INDEX.md の「記事 ↔ データの関係」の確定・参照先なし・候補の件数
   - INDEX.md の「古くなった説明」の行の数と、新しく増えたもの（前回の報告と比べられなければ省く）
   - KIOXIA が未接続だった・Mac につながらなかった、などうまくいかなかったこと

Mac につながらないときは、何も書かずに「Mac につながらなかった」と1行書いて終える。_index/ の外のファイルを作ったり変えたりしない。
```

### 20261101_54 の結果（Claude Code の報告）

```markdown
# 結果：20261101_54_学習とcheckpointに由来を記録する

- 状態：完了
- 始めた時刻：2026-11-01 10:09
- 終わった時刻：2026-11-01 10:54（45分）
- 作業フォルダ：/Users/shimazueiichi/r2r2r-scripts
- この続きを Claude Code で話すとき：作業フォルダで `claude --resume b0ff554c-0fbd-46ab-b3dd-4aafa20c4e17`

## Claude Code の報告

**やったこと**
- 依頼52が「済み」にあり、コミット eb491c5 に入っているのを確かめてから始めました。
- 共通の部品 `provenance.py` を作りました。指紋（files-v1）を作る、コードの変更の有無を見る、始める時点に測る、由来を作る、書く、の5つで、bash からも Python からも使えます。
  - 鍵は伏せます。置く先にもうファイルがあれば上書きせず、フォルダも作りません。
- lab_train.sh に組み込みました。
  - 0. で、始める時点のコードと、入力3つ（学習データ・続きから学習する 3999・add_trim_config.py）の指紋を測ります。
  - 3f のあと、Pod の openpi のコミットと変更を読みます。
  - 終わったら `lab/LogNN/train/train_provenance.json` に置きます。成功・失敗・止めた・借りられなかった・--stop のどれでも置きます。
  - 今回 KIOXIA に新しく置いた checkpoint と、今回上げた Hugging Face のリポジトリにも同じものを置きます。
  - 由来の処理は全体を300秒で打ち切ります。失敗しても、学習の片付け・終了コード・通知は変えません。
- 置き場所: `provenance.py` は r2r2r-scripts 直下にしました。②③は直下からも pi05/ からも呼ぶためです。
- ②③での呼び方:
  - Python: `import provenance; start = provenance.prepare({"inputs":[...]}, data_root=...)` で始める前に測り、終わったら `provenance.write(provenance.build({**start, "status":{...}, ...}, data_root=...), "<出力>/dataset_provenance.json")`
  - bash: `python3 provenance.py write --data-root /Volumes/KIOXIA/r2r2r-data --out <置く先> < spec.json`
- Hugging Face にも入れられました。比べるときに由来のファイルを数えないよう、`hf_upload_ckpt.py` と KIOXIA 側の比べを直しました。そのままだと、次の回や取り直しで「別の中身がある」として止まるためです。前からある同じ中身のリポジトリ（same）には足しません。
- codex のレビューを5回通しました。必須は R1 5件 → R2 3件 → R3 1件 → R4 1件 → R5 0件で、依頼文と結果は `docs/` にあります。
- 既存の checkpoint・データセット・runs（`pi05_mecharm_trim_corr_lora_v1/1499` を含む）には書き足していません。本物の Pod は借りていません。

**結果**
- test_provenance.py 30件（OK、1件 skip。この Mac では名前の字形の違うファイルが同じ名前とみなされるため）
- test_lab_train.sh 177件（OK、NG 0）
- test_lab_start.sh 128件（OK、NG 0）
- pi05 の各テストも全部 OK（test_hf_upload_ckpt.py は「Hugging Face に由来があっても同じ中身とみなす」を足して10件）
- 足した確かめ: 成功・途中で止まる・上限・Ctrl-C/TERM・--stop・KIOXIA が外れる・dirty のときとそうでないとき、など。KIOXIA が外れたときは、由来を書かずに最後まで片付けて通知します。
- 偽物の実行で出た JSON の例（`docs/example_train_provenance_dryrun_20261101.json`。長い部分は省いています）:

{"schema_version":"provenance/1","created_at":"2026-11-01T10:53:43+09:00",
 "producer":{"script":"lab_train.sh","argv":["--log","Log18","--dry-run"],"machine":"shimadzueiichinoMacBook-Pro.local","dry_run":true},
 "code":{"repo":"r2r2r-scripts","commit":"eb491c5a…","dirty":true,"dirty_files":["lab_lib.sh","lab_train.sh","pi05/build_demo_episodes.py.bak_pre_LOG352_20261019",…],"diff_sha256":"478ee7eb…","measured_at":"2026-11-01T10:53:41+09:00","paths":["lab_train.sh","lab_lib.sh","provenance.py","pi05"]},
 "external_code":[{"name":"openpi","commit":"215abfb1…","dirty":true,"dirty_files":["src/openpi/training/config.py",…],"expected_commit":"215abfb"}],
 "inputs":[{"kind":"dataset","id":"mecharm_redblock_box_teach_v1_trim2_corr_v3","path":"datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3","fingerprint":"files-v1:1c95b870…"},
  {"kind":"checkpoint","id":"pi05_mecharm_trim_lora_v1/3999","path":"checkpoints/pi05_mecharm_trim_lora_v1/3999","fingerprint":"files-v1:262027fc…","hf_repo":"PixelstoMotors/pi05-mecharm-trim-lora-v1"},
  {"kind":"config","id":"add_trim_config.py","path":"lab/Log11/pod/add_trim_config.py","fingerprint":"files-v1:c30584c2…","sha256":"807fd6eb…"}],
 "outputs":[{"kind":"checkpoint","id":"pi05_mecharm_trim_corr_lora_v1/1499","path":"checkpoints/pi05_mecharm_trim_corr_lora_v1/1499","fingerprint":"files-v1:8071f264…","hf_repo":"PixelstoMotors/pi05-mecharm-trim-corr-lora-v1"}],
 "config":{"name":"pi05_mecharm_trim_corr_lora","exp_name":"trim_corr_v3","num_train_steps":1500,"init_params":"/workspace/ckpt/3999/params","limit_hours":5},
 "compute":{"provider":"runpod","gpu":"NVIDIA A40","started_at":"…","ended_at":"…","duration_sec":2,"cost_per_hr_usd":0.44,"cost_usd":0,"cost_line":"借りていた時間 0 分 2 秒 … 約 0.00 ドル (約 0 円. 1ドル=158円)"},
 "numbering":{"series":"フィジカルAI研究室","series_log":"Log18","article_no":""},
 "status":{"process":"completed","task":"not_applicable"}}

- 今のままだと code.dirty は毎回 true になります。本物の学習でも、pi05/ にある git に入っていない `build_demo_episodes.py.bak_pre_LOG352_20261019` が数えられるためです。pi05/ は丸ごと Pod へ送るので、数えるのが正しい扱いです。

**変えたファイル**
- 新しく作ったもの: provenance.py・test_provenance.py
- 直したもの: lab_train.sh・lab_lib.sh（Mac から Hugging Face へファイルを1つ上げる関数を足した）・lab_train_fakes.sh・test_lab_train.sh・pi05/openpi_addon/hf_upload_ckpt.py・pi05/test_hf_upload_ckpt.py
- 記録: docs/review_request_20261101_train_provenance*.md（5つ）・docs/review_result_20261101_train_provenance_codex*.md（5つ）・docs/example_train_provenance_dryrun_20261101.json・logs/20261101_*.md
- コミット: 1902f37（本体）・cadd53d（作業の記録）。どちらも個別にファイルを指定して add しました。

**決めてほしいこと**
1. 12節の写し: data_manifest_rules.md の12節は手元のファイル（r2r2r-content/methodology/）にありませんでした。依頼文に写された要点で作ったので、Project の12節と食い違いがないか見てください。
2. .DS_Store: 依頼どおり指紋に数えています。そのため、Finder でフォルダを開くだけで指紋が変わります。数えないことにするなら、指紋を files-v2 にするのがよいと思います（codex も同じ意見）。
3. 出力（checkpoint）の指紋: 由来のファイルを置く前に測っています。置いたあとに同じフォルダを測り直すと合いません。由来のファイルを数えないことにするかどうか。
4. series_log: 今は "Log18" の形で入れています。数字だけ（18）にするかどうか。
5. 書きかけの JSON: 書いている途中で強制終了されたときだけ、書きかけの JSON が残ることがあります。②③で読む側が、読めない由来をどう扱うか（飛ばす・失敗として扱う、など）。
```

### コミット 1902f37 のコードの変更（provenance.py・lab_train.sh ほか・git show）

```diff
commit 1902f37eee0f970e0a3b2f1f805b3cbc61b9a046
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sun Nov 1 10:53:54 2026 +0900

    学習とチェックポイントに由来 (provenance/1) を残す provenance.py を足し, lab_train.sh に組み込む. 始める前にコードと入力 (学習データ・3999・add_trim_config.py) の指紋 files-v1 を測り, 3f のあと Pod の openpi のコミットと変更を読み, 終わり方 (completed / interrupted / failed) にかかわらず lab/<Log>/train/train_provenance.json に置く. 今回 KIOXIA に新しく置いたチェックポイントと, 今回上げた Hugging Face のリポジトリにも同じものを置く. 鍵は伏せ, 書けなくても学習の片付けと終了コードは変えない (全体 300 秒で打ち切る). hf_upload_ckpt.py と KIOXIA の比べでは train_provenance.json を数えない (依頼 54)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/lab_lib.sh b/lab_lib.sh
index 68e781d..09695c0 100644
--- a/lab_lib.sh
+++ b/lab_lib.sh
@@ -517,6 +517,17 @@ lab_hf_download_mac() {     # lab_hf_download_mac リポジトリ 置く場所 
     return $rc
 }
 
+lab_hf_upload_file_mac() {  # lab_hf_upload_file_mac リポジトリ ファイル リポジトリの中の名前 コミットの文 → Mac の hf で1つ上げる (依頼 54)
+    # リポジトリは作らない (hf_upload_ckpt.py が非公開で作ったものにだけ足す). 鍵は HF_TOKEN_PATH でファイルの場所だけを渡す
+    local log rc
+    log=$(mktemp)
+    ( unset HF_TOKEN; export HF_TOKEN_PATH="$HF_TOKEN_FILE" HF_HUB_DISABLE_TELEMETRY=1 HF_HUB_DISABLE_PROGRESS_BARS=1
+      lab_timeout 120 hf upload "$1" "$2" "$3" --repo-type model --private --commit-message "$4" ) > "$log" 2>&1
+    rc=$?
+    tail -2 "$log"; rm -f "$log"
+    return $rc
+}
+
 lab_notify() {              # lab_notify 題 文 終了コード → Mac の通知 (0 なら Glass, それ以外は Basso の音)
     local sound=Basso
     [ "$3" = 0 ] && sound=Glass
diff --git a/lab_train.sh b/lab_train.sh
index b64d8ee..e3b7e72 100755
--- a/lab_train.sh
+++ b/lab_train.sh
@@ -25,6 +25,8 @@
 #    上げられなければ, Pod から KIOXIA へ scp で取ってから消す
 # 8. 落ちた・止まった・上限を超えた・Ctrl-C: ログを持ち帰ってから消す
 # 9. Mac に通知を出す. まとめは lab/<Log>/train/result_<時刻>.txt, ターミナルの出力は lab_train_<時刻>.txt
+#    由来 (どのデータ・コード・設定で学習したか. provenance.py) を lab/<Log>/train/train_provenance.json に置き,
+#    KIOXIA に新しく置けたチェックポイントと Hugging Face にも同じものを置く (依頼 54. 落ちた・止めたときも lab/ 側には置く)
 #
 # 鍵 (Runpod・Hugging Face) は KIOXIA の keys/ から読み, 画面・記録には出さない (lab_lib.sh).
 # Pod 側の見張りには Mac の鍵を送らない.
@@ -35,6 +37,7 @@ set -o pipefail
 # 中身は全部 main の中に置き, 最後の1行で呼ぶ (依頼 47 と同じ. 動いている途中でこのファイルが書き換わっても壊れない)
 main() {
 HERE="$(cd "$(dirname "$0")" && pwd)"
+ORIG_ARGS=("$@")            # 由来に残す (依頼 54)
 # shellcheck source=lab_lib.sh
 . "$HERE/lab_lib.sh"
 
@@ -53,7 +56,7 @@ while [ $# -gt 0 ]; do
         --stall-min) STALL_MIN="${2:-}"; shift 2 ;;
         --stop) MODE=stop; shift ;;
         --dry-run) DRY_RUN=1; shift ;;
-        -h|--help) sed -n '2,29p' "$0"; exit 0 ;;
+        -h|--help) sed -n '2,31p' "$0"; exit 0 ;;
         *) echo "[ERROR] 知らない引数: $1  (./lab_train.sh --help)" >&2; exit 2 ;;
     esac
 done
@@ -87,6 +90,7 @@ HF_REPO_IN=PixelstoMotors/pi05-mecharm-trim-lora-v1           # 続きから学
 HF_REPO_OUT=PixelstoMotors/pi05-mecharm-trim-corr-lora-v1     # 上げる先 (非公開). 名前は checkpoints/ のフォルダ名に合わせる
 CKPT_NAME=pi05_mecharm_trim_corr_lora_v1                      # KIOXIA の checkpoints/<これ>/<ステップ>/
 CKPT_IN=/workspace/ckpt/3999
+CKPT_IN_KIOXIA="$CKPT_ROOT/pi05_mecharm_trim_lora_v1/3999"   # HF_REPO_IN と同じもの (由来の指紋を取る. 依頼 54)
 POD_EXP_DIR=/workspace/openpi/checkpoints/$CONFIG/$EXP
 TRAIN_LOG=/workspace/logs/train_$EXP.log
 GPU_LOG=/workspace/logs/gpu_train.csv
@@ -132,11 +136,147 @@ summary() {
 }
 hm() { local x=$1; [ "$x" -lt 0 ] && x=0; printf '%d:%02d' $((x / 3600)) $((x % 3600 / 60)); }
 
+# ---- 由来 (依頼 54. provenance.py. 書けなくても学習の片付けは止めない) ----
+PROV_PROCESS=""; PROV_ERROR=""; PROV_CKPT_DIR=""; PROV_HF=0; PROV_DONE=0
+OPENPI_JSON='[]'            # 3f のあとに Pod の openpi のコミットと変更の有無を入れる
+iso_of() { [ -n "${1:-}" ] && [ "$1" != 0 ] && date -r "$1" '+%Y-%m-%dT%H:%M:%S%z' | sed -E 's/([0-9]{2})$/:\1/'; }
+prov_inputs() {             # 入力 (学習データ・続きから学習する 3999・設定を足すスクリプト) を spec の inputs の形で出す
+    local init_ck=""
+    case "$INIT" in "$CKPT_IN"/*) init_ck="$CKPT_IN_KIOXIA" ;; esac
+    jq -n --arg data "$DATA_NAME" --arg data_dir "$DATA_ROOT/datasets/$DATA_NAME" \
+        --arg init "$INIT" --arg init_ck "$init_ck" --arg init_id "${CKPT_IN_KIOXIA#"$CKPT_ROOT"/}" --arg repo_in "$HF_REPO_IN" \
+        --arg add_trim "$ADD_TRIM" \
+        '{inputs: ([{kind: "dataset", id: $data, path: $data_dir}]
+                  + [{kind: "checkpoint", id: (if $init_ck == "" then (if $init == "" then null else $init end) else $init_id end),
+                      path: (if $init_ck == "" then null else $init_ck end),
+                      hf_repo: (if $init_ck == "" then null else $repo_in end), pod_path: (if $init == "" then null else $init end)}]
+                  + [{kind: "config", id: "add_trim_config.py", path: $add_trim}])}'
+}
+PROV_CODE_ARGS=(--repo-dir "${LAB_PROV_REPO:-$HERE}" --code-path lab_train.sh --code-path lab_lib.sh --code-path provenance.py --code-path pi05)
+prov_prepare() {            # 始める前にコードと入力の指紋を測って PROV_START に持つ (終わったときに測ると, 途中で変えたものになる)
+    local out
+    out=$(prov_inputs | lab_timeout 120 python3 "$HERE/provenance.py" prepare --data-root "$DATA_ROOT" "${PROV_CODE_ARGS[@]}" 2>/dev/null) \
+        && jq -e '.code and .inputs' <<<"$out" >/dev/null 2>&1 && PROV_START=$(jq -c . <<<"$out") && return 0
+    echo "[WARN  ] 始める時点のコードと入力の指紋を測れなかった (由来は終わったときに測る)"
+    PROV_START=""
+    return 1
+}
+
+prov_spec() {               # 由来の spec (lab_train.sh が知っていること) を JSON で出す
+    local t_end rented start
+    t_end=$(jq -r '.terminated_epoch // empty' "$STATE" 2>/dev/null)
+    rented=$(jq -r '.rented_sec // empty' "$STATE" 2>/dev/null)
+    start=${PROV_START:-}
+    if [ -z "$start" ] && [ $MODE = stop ]; then
+        # --stop で始めたときの記録がない (前の形の記録など): 止めた今のものを測らず「分からない」とする (codex R2)
+        start=$(prov_inputs | jq -c '{code: {repo: "r2r2r-scripts", commit: null, dirty: null, error: "始めたときの記録がない (--stop)"},
+                 inputs: [.inputs[] | . + {fingerprint: null, fingerprint_error: "始めたときの記録がない (--stop)", measured_at: null}]}') || return 1
+    elif [ -z "$start" ]; then
+        start=$(prov_inputs) || return 1        # 始める前に測れなかった: ここで測る (measured_at が終わったときの時刻になる)
+    fi
+    jq -n --argjson start "$start" --arg script lab_train.sh --argjson argv "$(printf '%s\0' ${ORIG_ARGS[@]+"${ORIG_ARGS[@]}"} | jq -Rs 'split("\u0000")[:-1]')" \
+        --arg proc "$PROV_PROCESS" --arg err "$PROV_ERROR" --arg log "$LOG" --arg dry "$DRY_RUN" \
+        --arg data "$DATA_NAME" --arg init "$INIT" --argjson openpi "$OPENPI_JSON" \
+        --arg config "$CONFIG" --arg exp "$EXP" --arg steps "$STEPS" --arg limit "$LIMIT_HOURS" \
+        --arg repo_out "$HF_REPO_OUT" --arg ckpt_name "$CKPT_NAME" --arg step "${CKPT_STEP:-}" --arg ck_dir "$PROV_CKPT_DIR" \
+        --arg hf "${HF_MSG:-}" --arg kx "${KIOXIA_MSG:-}" --arg prov_hf "$PROV_HF" \
+        --arg pod "$POD_ID" --arg gpu "$GPU_USED" --arg cph "$COST_PER_HR" --arg yen "$YEN_PER_USD" \
+        --arg t0 "$(iso_of "$POD_T0")" --arg t1 "$(iso_of "$t_end")" --arg tt "$(iso_of "$TRAIN_T0")" --arg rented "$rented" \
+        --arg cost_line "${COST_LINE:-}" \
+        'def num: if . == "" then null else tonumber end; def str: if . == "" then null else . end;
+        {producer: {script: $script, argv: $argv, dry_run: ($dry == "1")},
+         external_code: $openpi,
+         inputs: $start.inputs,
+         code: ($start.code // null),
+         outputs: (if $ck_dir == "" then [] else [{kind: "checkpoint", id: ($ckpt_name + "/" + $step), path: $ck_dir,
+                   hf_repo: (if $prov_hf == "1" then $repo_out else null end),
+                   fingerprint_note: "train_provenance.json を置く前に測った"}] end),
+         config: {name: $config, exp_name: $exp, num_train_steps: ($steps | num), init_params: ($init | str), data_name: $data,
+                  limit_hours: ($limit | num), checkpoint_step: ($step | str)},
+         compute: {provider: "runpod", pod_id: ($pod | str), gpu: ($gpu | str), started_at: ($t0 | str), train_started_at: ($tt | str),
+                   ended_at: ($t1 | str), duration_sec: ($rented | num), cost_per_hr_usd: ($cph | num),
+                   cost_usd: (if $rented == "" then null else (($rented | tonumber) / 3600 * ($cph | tonumber) * 100 | round / 100) end),
+                   yen_per_usd: ($yen | num), cost_line: ($cost_line | str)},
+         numbering: {series: "フィジカルAI研究室", series_log: $log, article_no: ""},
+         status: {process: $proc, task: "not_applicable"},
+         error: ($err | str),
+         notes: {hugging_face: ($hf | str), kioxia: ($kx | str)}}'
+}
+PROV_TO=${LAB_PROV_TO:-300} # 由来を書く全体 (spec・書く・Hugging Face) の時間の上限 (通知を遅らせすぎない. codex R1・R2)
+kill_group() {              # kill_group pgid → そのプロセスグループ (由来を書く子と, その子孫全部) に TERM, 2 秒たって残っていれば KILL
+    # グループごと送るので, たどったあとに生まれた子孫も取りこぼさない. 番号の使い回しは, グループの全員が終わったあとにしか起きない
+    # (KILL の前にまだ誰か残っているかを見る) (codex R3)
+    kill -TERM -- "-$1" 2>/dev/null
+    local i
+    for i in 1 2 3 4; do pgrep -g "$1" >/dev/null 2>&1 || return 0; sleep 0.5; done
+    kill -KILL -- "-$1" 2>/dev/null
+    return 0
+}
+prov_write() {              # lab/<Log>/train/ (と, 今回新しく置いたチェックポイント・Hugging Face) に由来を置く. RESULT_LINES に1行足す
+    local f pid t0 line
+    [ $PROV_DONE = 1 ] && return 0
+    PROV_DONE=1
+    echo; echo "=== 由来 (train_provenance.json) を書く"
+    [ -n "$PROV_PROCESS" ] || PROV_PROCESS=failed
+    f=$(mktemp)
+    # 自分のプロセスグループで流す (set -m). 時間切れのときにグループごと止める. 中の python と hf にはそれぞれ 120 秒の上限がある
+    set -m
+    ( trap - INT TERM HUP; prov_write_body ) > "$f" 2>&1 &
+    pid=$!
+    set +m
+    t0=$SECONDS
+    while kill -0 "$pid" 2>/dev/null && [ $((SECONDS - t0)) -lt "$PROV_TO" ]; do sleep 1; done
+    if kill -0 "$pid" 2>/dev/null; then
+        kill_group "$pid"; wait "$pid" 2>/dev/null
+        echo "PROVLINE 由来: $PROV_TO 秒で書き終わらなかったので打ち切った (書けたものだけ残る)" >> "$f"
+    else
+        wait "$pid" 2>/dev/null
+    fi
+    grep -v '^PROVLINE ' "$f"
+    line=$(sed -n 's/^PROVLINE //p' "$f" | tail -1); rm -f "$f"
+    [ -n "$line" ] || line="由来: 書く途中で終わった"
+    echo "[PROV  ] $line"
+    RESULT_LINES=$(printf '%s\n%s' "${RESULT_LINES:-}" "$line")
+}
+prov_write_body() {         # 子のシェルで流す. 画面に出すものと, 最後に「PROVLINE <まとめの1行>」を出す
+    local spec out lab_f ck_f="" outs line
+    if [ ! -d "$DATA_ROOT" ] || [ ! -d "$LOGDIR" ]; then
+        echo "PROVLINE 由来: KIOXIA がマウントされていない ($DATA_ROOT). 書かなかった"; return 1
+    fi
+    lab_f=$(dest_of train_provenance.json)
+    outs=(--out "$lab_f")
+    if [ -n "$PROV_CKPT_DIR" ] && [ -d "$PROV_CKPT_DIR" ]; then
+        ck_f="$PROV_CKPT_DIR/train_provenance.json"
+        outs+=(--out "$ck_f")
+    fi
+    spec=$(prov_spec) || spec=""
+    if [ -z "$spec" ]; then
+        echo "PROVLINE 由来: spec を作れなかった (jq). 書かなかった"; return 1
+    fi
+    out=$(printf '%s' "$spec" | lab_timeout 120 python3 "$HERE/provenance.py" write --data-root "$DATA_ROOT" "${outs[@]}" "${PROV_CODE_ARGS[@]}" \
+              --secret-file "$RUNPOD_KEY_FILE" --secret-file "$HF_TOKEN_FILE" 2>&1)
+    echo "$out" | sed 's/^/[PROV  ] /'
+    line="由来: $([ -f "$lab_f" ] && echo "$lab_f" || echo "lab 側に書けなかった")"
+    if [ -n "$ck_f" ]; then
+        if grep -qxF -- "$ck_f" <<<"$out"; then line="$line ・ $ck_f"; else line="$line ・ チェックポイントには置けなかった"; fi
+    fi
+    if [ "$PROV_HF" = 1 ] && [ -f "$lab_f" ]; then
+        # Hugging Face (今回 hf_upload_ckpt.py が上げたリポジトリ) にも同じものを足す. だめでも止めない
+        if lab_hf_upload_file_mac "$HF_REPO_OUT" "$lab_f" train_provenance.json "lab_train.sh $LOG: train_provenance.json" | sed 's/^/[PROV  ] hf: /'; then
+            line="$line ・ Hugging Face ($HF_REPO_OUT) にも置いた"
+        else
+            line="$line ・ Hugging Face には置けなかった"
+        fi
+    fi
+    echo "PROVLINE $line"
+}
+
 # ---- 通知とまとめ ----
 RESULT="$LOGDIR/result_$(date +%Y%m%d_%H%M%S).txt"
 finish() {                  # finish 終了コード 通知の文 (まとめを書き, 通知を出して終わる)
     local rc="$1" msg="$2"
     FINISHED=1
+    prov_write
     summary
     {
         echo "lab_train.sh $LOG  $(date '+%Y-%m-%d %H:%M:%S')"
@@ -151,6 +291,7 @@ finish() {                  # finish 終了コード 通知の文 (まとめを
 }
 early_fail() {              # Pod を作る前に止まる (借りていないので消すものはない)
     echo; echo "[STOP  ] 「${CUR_STAGE}」で止まりました: $*"
+    PROV_PROCESS=failed; PROV_ERROR="「${CUR_STAGE}」: $*"
     RESULT_LINES="Pod は借りていません"
     finish 1 "失敗: $* (Pod は借りていません)"
 }
@@ -270,6 +411,9 @@ wrap_fail() {               # wrap_fail 種類 わけ → ログを持ち帰っ
     local kind="$1" why="$2" logs
     WRAPPING=1; trap '' INT TERM HUP     # ここからは Ctrl-C でも止めない (Pod を消すところまで行く. 二重には入らない)
     echo; echo "[STOP  ] 「${CUR_STAGE}」で止めます: $why"
+    case "$kind" in signal|stop|timeout) PROV_PROCESS=interrupted ;; *) PROV_PROCESS=failed ;; esac      # 由来 (依頼 54)
+    [ "${TRAIN_DONE:-0}" = 1 ] && PROV_PROCESS=completed      # 学習は最後まで終わっていて, そのあとの片付けで止まった
+    PROV_ERROR="$kind: 「${CUR_STAGE}」: $why"
     if [ -z "$POD_ID" ]; then RESULT_LINES="Pod は借りていません"; finish 1 "失敗: $why (Pod は借りていません)"; fi
     # 学習を止めてからログを持ち帰る (消す前に書き終わったログを取る. 止められなくても続ける)
     if [ "$kind" != crash ] && [ -n "$TRAIN_T0" ]; then
@@ -295,7 +439,8 @@ POD_LIST=""                  # Pod 側の最後のチェックポイントの一
 compare_with_pod() {        # compare_with_pod フォルダ → Pod 側の一覧と名前・バイト数が全部同じなら 0
     local mine d rc
     mine=$(mktemp)
-    if ! lab_list_files "$1" > "$mine" || [ ! -s "$POD_LIST" ]; then
+    # 由来 (train_provenance.json) は Mac があとで足すので数えない (依頼 54)
+    if ! { lab_list_files "$1" | { grep -v $'^train_provenance\.json\t' || true; }; } > "$mine" || [ ! -s "$POD_LIST" ]; then
         echo "[KIOXIA] 一覧を作れない (KIOXIA 側 $1 か Pod 側 $POD_LIST). 合ったとはしない"; rm -f "$mine"; return 1
     fi
     d=$(diff "$mine" "$POD_LIST"); rc=$?; rm -f "$mine"
@@ -325,6 +470,7 @@ place_ckpt() {              # place_ckpt hf|scp → KIOXIA の checkpoints/<CKPT
             || { KIOXIA_MSG="Pod から scp で取れなかった (途中のものは $tmp)"; return 1; }
     fi
     rm -rf "${tmp:?}/.cache" "${tmp:?}/.gitattributes"     # hf download の印 (自分で作った一時フォルダの中だけ)
+    rm -f "${tmp:?}/train_provenance.json"                 # Hugging Face にあった前の回の由来は持ってこない (今回のものをあとで置く. codex R1)
     if ! compare_with_pod "$tmp"; then KIOXIA_MSG="取ったものが Pod 側の一覧と合わない (そのまま $tmp に置いた)"; return 1; fi
     # mkdir は「なければ作る」を1回で行う (あれば失敗). 作れたら中身を移す (確かめと移すの間に別の実行が作っても上書きしない)
     if ! mkdir "$dest" 2>/dev/null; then KIOXIA_MSG="$dest が途中でできた. 上書きしない (取ったものは $tmp)"; return 1; fi
@@ -336,6 +482,7 @@ place_ckpt() {              # place_ckpt hf|scp → KIOXIA の checkpoints/<CKPT
     compare_with_pod "$dest" || { KIOXIA_MSG="$dest に移したあとの一覧が Pod 側と合わない"; return 1; }
     n=$(wc -l < "$POD_LIST" | tr -d ' '); bytes=$(awk -F'\t' '{s += $2} END {printf "%d", s}' "$POD_LIST")
     KIOXIA_MSG="$dest に置いた ($n ファイル $bytes バイト. Pod 側と名前・バイト数が全部合った)"
+    PROV_CKPT_DIR="$dest"       # 今回新しく置いたものにだけ由来を置く (前からあるものには書き足さない. 依頼 54)
 }
 
 rm_incoming() {             # rm_incoming 一時フォルダ → ._* (exFAT が付ける) しか残っていなければ, それとフォルダを消す. ほかのものがあれば何もせず 1
@@ -358,6 +505,7 @@ extend_watchdog() {         # Pod 側の見張りの時刻を, 今から WRAP_EX
 wrap_success() {            # 学習が最後まで終わった: 上げる → 持ち帰る → 消す → KIOXIA へ
     local hf_ok=0 hf_line out k_ok=0 k_how="" logs msg rc
     WRAPPING=1; trap '' INT TERM HUP
+    TRAIN_DONE=1                # このあとの片付けで止まっても, 由来は completed (codex R1)
     CKPT_STEP=$S_CKPT
     POD_CKPT="$POD_EXP_DIR/$CKPT_STEP"
     echo; echo "[TRAIN ] 学習が終わりました (最後のチェックポイント $CKPT_STEP, loss ${S_LOSS})"
@@ -412,7 +560,9 @@ wrap_success() {            # 学習が最後まで終わった: 上げる → 
         [ $hf_ok = 0 ] && msg="$msg Hugging Face に上げられなかった ($HF_MSG)."
         if [ $k_ok = 1 ]; then msg="$msg KIOXIA には置いた ($k_how)."; else msg="$msg KIOXIA に置けなかった ($KIOXIA_MSG)."; fi
         if [ $gone = 0 ]; then msg="$msg Pod は消しました."; else msg="$msg $(pod_left_msg)"; fi
+        PROV_ERROR="学習のあとの片付け: $msg"
     fi
+    PROV_PROCESS=completed; [ $hf_ok = 1 ] && [ "${hf_line#HFUP ok }" != "$hf_line" ] && PROV_HF=1     # Hugging Face へは今回上げたときだけ足す
     finish "$rc" "$msg"
 }
 
@@ -424,6 +574,10 @@ if [ $MODE = stop ]; then
     COST_PER_HR=$(jq -r '.costPerHr // 0' "$STATE"); GPU_USED=$(jq -r '.gpu // ""' "$STATE")
     TRAIN_T0=$(jq -r '.train_started_epoch // ""' "$STATE")
     EXP=$(jq -r --arg e "$EXP" '.exp // $e' "$STATE")
+    # 由来 (依頼 54. codex R1): 始めたときの設定・コード・入力を記録から戻す. 記録にないものは「分からない」(空) にする
+    STEPS=$(jq -r '.steps // ""' "$STATE"); INIT=$(jq -r '.init // ""' "$STATE"); LIMIT_HOURS=$(jq -r '.limit_hours // ""' "$STATE")
+    PROV_START=$(jq -c '.prov_start // empty' "$STATE")
+    OPENPI_JSON=$(jq -c '.openpi // [{name: "openpi", commit: null, dirty: null, error: "記録にない (--stop)"}]' "$STATE") || OPENPI_JSON='[]'
     POD_EXP_DIR=/workspace/openpi/checkpoints/$CONFIG/$EXP; TRAIN_LOG=/workspace/logs/train_$EXP.log
     [ -n "$POD_ID" ] || { echo "[ERROR] $STATE に Pod の id がない"; exit 1; }
     echo "[STOP  ] $LOG の学習の Pod: id $POD_ID  GPU=$GPU_USED  作った時刻 $(jq -r '.created_at // "?"' "$STATE")"
@@ -444,6 +598,7 @@ if [ -e "$CKPT_ROOT/$CKPT_NAME/$LAST_STEP" ]; then
     echo "[WARN  ] $CKPT_ROOT/$CKPT_NAME/$LAST_STEP がもうあります. 学習のあと上書きはせず, 中身が同じかだけを見ます"
 fi
 echo "[DATA  ] tar $(stat -f '%z' "$TAR" 2>/dev/null || wc -c < "$TAR") バイト  ($TAR)"
+prov_prepare && echo "[PROV  ] 始める時点のコード ($(jq -r '.code.commit // "?" | .[0:7]' <<<"$PROV_START")$(jq -r 'if .code.dirty then "・変更あり" else "" end' <<<"$PROV_START")) と入力 $(jq '.inputs | length' <<<"$PROV_START") つの指紋を測った"
 lab_keep_awake
 stage_done
 
@@ -506,9 +661,11 @@ done
 POD_T0=$(date +%s)
 jq -n --arg id "$POD_ID" --arg gpu "$GPU_USED" --arg log "$LOG" --argjson cost "$COST_PER_HR" --argjson t0 "$POD_T0" \
       --arg exp "$EXP" --argjson steps "$STEPS" --arg config "$CONFIG" --argjson lim "$LIMIT_SEC" \
+      --arg init "$INIT" --arg lh "$LIMIT_HOURS" --arg ps "${PROV_START:-}" \
       '{id:$id, kind:"train", gpu:$gpu, log:$log, costPerHr:$cost, created_epoch:$t0,
         created_at:($t0|strflocaltime("%Y-%m-%d %H:%M:%S")), config:$config, exp:$exp, steps:$steps,
-        limit_sec:$lim, limit_at:(($t0 + $lim)|strflocaltime("%Y-%m-%d %H:%M:%S"))}' > "$STATE"
+        limit_sec:$lim, limit_at:(($t0 + $lim)|strflocaltime("%Y-%m-%d %H:%M:%S")),
+        init:$init, limit_hours:$lh, prov_start:(if $ps == "" then null else ($ps | fromjson) end)}' > "$STATE"
 echo "[POD   ] 作った: id $POD_ID  GPU=$GPU_USED  1時間 $COST_PER_HR ドル  (記録 $STATE)"
 echo "[POD   ] 上限: $(date -r $((POD_T0 + LIMIT_SEC)) '+%m-%d %H:%M') ($LIMIT_HOURS 時間) を過ぎたら, ログを持ち帰って消します"
 stage_done
@@ -559,6 +716,19 @@ LAB_TO=$LAB_TO_LONG pssh "$(lab_cmd_uv_sync)" || wrap_fail prep "uv sync に失
 stage_done
 stage "3f. addon・設定・assets"
 pssh "$(lab_cmd_addon)" || wrap_fail prep "addon・設定・assets に失敗"
+# 由来 (依頼 54): 学習に使う Pod の openpi のコミットと変更の有無 (addon・設定を入れたあと). 取れなくても続ける
+# git status が最後まで通ったときだけ OPENPI_END を出す. ssh の終了コードと両方を見る (途中で切れたものを「変更なし」にしない. codex R1)
+out=$(LAB_TO=$LAB_TO_SHORT pssh ": lt=openpi_rev; cd /workspace/openpi && c=\$(git rev-parse HEAD) && s=\$(git status --porcelain=v1 --untracked-files=all) && echo \"OPENPI \$c\" && printf '%s\\n' \"\$s\" | sed -n 's/^\\(..\\) /OPF \\1 /p' && echo OPENPI_END"); orc=$?
+if oc=$(sed -n 's/^OPENPI \([0-9a-f]\{40\}\)$/\1/p' <<<"$out" | tail -1) && [ -n "$oc" ] && [ $orc = 0 ] && grep -qx OPENPI_END <<<"$out"; then
+    OPENPI_JSON=$(grep '^OPF ' <<<"$out" | head -200 | cut -c8- | jq -Rn --arg c "$oc" --arg want "$LAB_OPENPI_COMMIT" \
+        '[inputs] as $f | [{name: "openpi", commit: $c, dirty: ($f | length > 0), dirty_files: $f, expected_commit: $want}]') \
+        || OPENPI_JSON='[]'
+    echo "[PROV  ] openpi $oc ($(jq -r '.[0].dirty_files | length' <<<"$OPENPI_JSON") ファイル変更・addon と設定を入れたあと)"
+else
+    OPENPI_JSON=$(jq -n --arg want "$LAB_OPENPI_COMMIT" '[{name: "openpi", commit: null, dirty: null, expected_commit: $want, error: "Pod から読めなかった"}]')
+    echo "[WARN  ] Pod の openpi のコミットを読めなかった (由来には「読めなかった」と残す)"
+fi
+save_state '. + {openpi:$o}' --argjson o "$OPENPI_JSON"       # --stop で戻す (codex R2)
 stage_done
 
 # ---- 4. データを送り, 学習の準備 ----
diff --git a/lab_train_fakes.sh b/lab_train_fakes.sh
index 68f2e7a..1e06610 100644
--- a/lab_train_fakes.sh
+++ b/lab_train_fakes.sh
@@ -22,6 +22,12 @@
 #   list_empty      Pod 側のチェックポイントの一覧が取れない
 #   list_partial    Pod 側の一覧の途中で find が失敗する (一部の行と終了コード 1)
 #   late_start      学習を始めるのに手間取り, 見守りに入ったときにはもう上限を過ぎている (学習は終わっている)
+#   kioxia_gone     Hugging Face から取ろうとしたところで KIOXIA が外れる (由来は書けない. 依頼 54)
+#   hf_prov_fail    由来 (train_provenance.json) だけ Hugging Face に上げられない (依頼 54)
+#   openpi_unknown  Pod の openpi のコミットが読めない (依頼 54)
+#   hf_same         Hugging Face にもう同じ中身がある (上げ直さない. 由来も足さない. 前の回の由来がある. 依頼 54)
+#   hf_prov_hang    由来を Hugging Face へ足すのが返らない (LAB_PROV_TO を小さくして使う. codex R2)
+#   openpi_partial  Pod の openpi のコミットの行だけ出て ssh が切れる (codex R1)
 
 LAB_DRY_ROOT=${LAB_DRY_ROOT:-$(mktemp -d /tmp/lab_train_dry.XXXXXX)}
 LAB_FAKE_SCENARIO=${LAB_FAKE_SCENARIO:-success}
@@ -47,6 +53,8 @@ mkdir -p "$LAB_DRY_ROOT/keys" "$LAB_DRY_ROOT/fake" "$DATA_ROOT/lab/Log11/pod" \
 [ -f "$DATA_ROOT/lab/Log11/pod/add_trim_config.py" ] || echo "# dry" > "$DATA_ROOT/lab/Log11/pod/add_trim_config.py"
 TARF="$DATA_ROOT/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3/mecharm_redblock_box_teach_v1_trim2_corr_v3_raw_episodes.tar"
 [ -f "$TARF" ] || echo "dry tar" > "$TARF"
+FAKE_INIT="$DATA_ROOT/checkpoints/pi05_mecharm_trim_lora_v1/3999"      # 続きから学習する 3999 (由来の指紋を取る. 依頼 54)
+[ -e "$FAKE_INIT" ] || { mkdir -p "$FAKE_INIT/params"; echo '{"dry": 3999}' > "$FAKE_INIT/_CHECKPOINT_METADATA"; head -c 100 /dev/zero > "$FAKE_INIT/params/x"; }
 touch "$LAB_DRY_ROOT/calls.log" "$LAB_DRY_ROOT/ssh.log" "$LAB_DRY_ROOT/scp.log"
 
 CREATE_WAIT=0; SSH_WAIT=0; LOGIN_WAIT=0; GONE_WAIT=0; FETCH_WAIT=0
@@ -131,8 +139,14 @@ pod_ssh() {
             fi ;;
         *"hf_upload_ckpt.py"*)
             if [ "$LAB_FAKE_SCENARIO" = hf_fail ]; then echo "HFUP error HfHubHTTPError: 401 Client Error"; return 6; fi
+            [ "$LAB_FAKE_SCENARIO" = hf_same ] && { echo "HFUP same PixelstoMotors/pi05-mecharm-trim-corr-lora-v1 にはもう同じ中身がある (5 ファイル). 上げ直さない"; return 0; }
             echo "HFUP ok PixelstoMotors/pi05-mecharm-trim-corr-lora-v1 に 5 ファイルを上げ, 名前・バイト数が全部合うのを確かめた" ;;
         *"pod_prep_trim_corr.sh"*) echo "== 5. 準備できました (ドライラン)" ;;
+        *": lt=openpi_rev"*)
+            [ "$LAB_FAKE_SCENARIO" = openpi_unknown ] && { echo "fatal: not a git repository"; return 128; }
+            echo "OPENPI 215abfb1234567890abcdef1234567890abcdef0"
+            [ "$LAB_FAKE_SCENARIO" = openpi_partial ] && return 255         # コミットの行だけ出て ssh が切れた
+            printf 'OPF  M src/openpi/training/config.py\nOPF ?? src/openpi/policies/mecharm_policy.py\nOPENPI_END\n' ;;
         *"hf auth whoami"*) echo "user: PixelstoMotors (ドライラン)" ;;
     esac
     return 0
@@ -153,7 +167,11 @@ pod_scp() {
 lab_have_hf_cli() { return 0; }
 lab_hf_download_mac() {
     echo "hf download $1 → $2" >> "$LAB_DRY_ROOT/calls.log"
+    if [ "$LAB_FAKE_SCENARIO" = kioxia_gone ]; then     # KIOXIA が外れた (DATA_ROOT ごと見えなくなる)
+        command mv "$DATA_ROOT" "$LAB_DRY_ROOT/data_unmounted"; return 1
+    fi
     fake_make_ckpt "$2" "$([ "$LAB_FAKE_SCENARIO" = dl_mismatch ] && echo shift)"
+    [ "$LAB_FAKE_SCENARIO" = hf_same ] && echo '{"schema_version": "provenance/1", "old": true}' > "$2/train_provenance.json"     # 前の回が足した由来
     mkdir -p "$2/.cache/huggingface/download"; echo x > "$2/.cache/huggingface/download/x.metadata"; echo "*" > "$2/.gitattributes"
     echo x > "$2/._params"; echo x > "$2/.__CHECKPOINT_METADATA"; echo x > "$2/.DS_Store"     # KIOXIA (exFAT) が付けるもの
     echo x > "$2/._.cache"      # 本体 (.cache) を消したあとに残る ._ (依頼 52)
@@ -174,5 +192,13 @@ mv() {                      # KIOXIA (exFAT) の mv のまね (依頼 52): ._<
     done
     return $rc
 }
+lab_hf_upload_file_mac() {
+    echo "hf upload $1 $(basename "$2") $3" >> "$LAB_DRY_ROOT/calls.log"
+    [ "$LAB_FAKE_SCENARIO" = hf_prov_fail ] && { echo "HfHubHTTPError: 503 Server Error"; return 1; }
+    if [ "$LAB_FAKE_SCENARIO" = hf_prov_hang ]; then     # 返らない. 孫を次々に作り, TERM を受けても続ける (由来の時間の上限で切る)
+        trap '' TERM; while :; do sleep 61 & sleep 0.1; done
+    fi
+    mkdir -p "$LAB_DRY_ROOT/fake/hf"; cp "$2" "$LAB_DRY_ROOT/fake/hf/$3"; echo "https://huggingface.co/$1/blob/main/$3"
+}
 lab_notify() { echo "[NOTIFY] $1: $2" | tee -a "$LAB_DRY_ROOT/notify.log"; }
 lab_keep_awake() { echo "[MAC   ] (ドライラン) caffeinate は使わない"; }
diff --git a/pi05/openpi_addon/hf_upload_ckpt.py b/pi05/openpi_addon/hf_upload_ckpt.py
index e3c5d56..1939df0 100644
--- a/pi05/openpi_addon/hf_upload_ckpt.py
+++ b/pi05/openpi_addon/hf_upload_ckpt.py
@@ -20,7 +20,7 @@ import os
 import sys
 
 SKIP_TOP = ("train_state",)
-SKIP_REMOTE = (".gitattributes",)
+SKIP_REMOTE = (".gitattributes", "train_provenance.json")     # 由来は Mac の lab_train.sh があとで足す (依頼 54)
 
 
 def local_files(root):
@@ -41,7 +41,7 @@ def local_files(root):
 
 
 def remote_files(api, repo):
-    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes は数えない)"""
+    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes・train_provenance.json は数えない)"""
     out = []
     for x in api.list_repo_tree(repo, recursive=True, repo_type="model"):
         size = getattr(x, "size", None)
diff --git a/pi05/test_hf_upload_ckpt.py b/pi05/test_hf_upload_ckpt.py
index 015d9f3..83acae8 100644
--- a/pi05/test_hf_upload_ckpt.py
+++ b/pi05/test_hf_upload_ckpt.py
@@ -103,6 +103,12 @@ class T(unittest.TestCase):
         self.assertEqual((rc, last.split()[1]), (0, "same"))
         self.assertFalse([c for c in api.calls if c[0] == "upload_folder"])
 
+    def test_same_content_with_provenance_skips(self):
+        # Mac があとで足した由来 (依頼 54) は数えない. 同じ中身なら上げ直さない
+        api = FakeApi(files={**dict(hu.local_files(self.d)), "train_provenance.json": 3000})
+        rc, last = run(api, self.d)
+        self.assertEqual((rc, last.split()[1]), (0, "same"))
+
     def test_other_content_stops(self):
         api = FakeApi(files={"params/old": 5})
         rc, last = run(api, self.d)
diff --git a/provenance.py b/provenance.py
new file mode 100644
index 0000000..ac5162e
--- /dev/null
+++ b/provenance.py
@@ -0,0 +1,379 @@
+#!/usr/bin/env python3
+"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54).
+
+決まりの正は data_manifest_rules.md 12節. 出す JSON は schema_version "provenance/1".
+
+bash から:
+  python3 provenance.py fingerprint <フォルダかファイル>            指紋 (files-v1:<sha256>) を1行で出す
+  python3 provenance.py prepare --data-root <r2r2r-data> [--code-path ...] < spec.json
+      始める時点のコード (code) と入力の指紋 (inputs) を JSON で出す. 終わったときに write の spec に入れて渡す
+  python3 provenance.py write --data-root <r2r2r-data> --out <置く先> [--out <もう1つ>] \\
+          [--code-path <リポジトリの中のパス> ...] [--secret-file <鍵のファイル> ...] < spec.json
+      spec.json (呼ぶ側が知っていることだけ. 下の build() の spec と同じ形) から由来を作って書き, 置いた先を出す.
+      置く先がもうあれば上書きしない (その先は飛ばし, 終了コード 3). 置く先のフォルダは作らない.
+      失敗しても呼んだ側を止めないよう, 呼ぶ側は「|| echo 書けなかった」で受ける (終了コード 0 = 全部置いた)
+
+Python から:
+  import provenance
+  rec = provenance.build({"producer": {"script": "make_dataset.py", "argv": sys.argv[1:]},
+                          "inputs": [{"kind": "recording", "id": "...", "path": "/Volumes/KIOXIA/r2r2r-data/recordings/..."}],
+                          "status": {"process": "completed", "task": "not_applicable"}},
+                         data_root="/Volumes/KIOXIA/r2r2r-data")
+  provenance.write(rec, "<出力フォルダ>/dataset_provenance.json")      # あれば FileExistsError
+
+指紋 files-v1 (fingerprint()):
+  フォルダの中のファイルを相対パス (NFC・/ 区切り) の UTF-8 のバイト順に並べ,
+    1) 全部のファイルの「相対パス\\tバイト数\\n」
+    2) そのうち名前が .json で終わり 1MB 未満のファイルについて「\\0相対パス\\0」+ 中身
+  をこの順に sha256 に入れる. 画像・動画・重みの中身は読まない.
+  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと).
+  リンクはたどらない (リンク自身の大きさ). ファイル1つを渡したときは, その名前1つの一覧として同じに作る.
+
+鍵・トークンは書かない: 引数の --token / --key / --secret / --password などの値と, hf_・rpa_ などで始まる鍵らしい文字は
+「<redacted>」に置き換え, --secret-file で渡した鍵のファイルの中身が記録のどこかに出れば同じく置き換える.
+"""
+import argparse
+import datetime
+import hashlib
+import json
+import os
+import platform
+import re
+import socket
+import subprocess
+import sys
+import unicodedata
+
+SCHEMA = "provenance/1"
+REPO_NAME = "r2r2r-scripts"
+REPO_DIR = os.path.dirname(os.path.abspath(__file__))
+FP_PREFIX = "files-v1:"
+JSON_MAX = 1 << 20          # これ未満の .json は中身も指紋に入れる
+REDACTED = "<redacted>"
+INPUT_KINDS = ("dataset", "checkpoint", "run", "recording", "config")
+PROCESS = ("completed", "interrupted", "failed")
+TASK = ("success", "failure", "unknown", "not_applicable")
+DIRTY_FILES_MAX = 200
+
+SECRET_FLAG = re.compile(r"(token|secret|passw(or)?d|api[-_]?key|(^|[-_])key$|credential|(^|[-_])auth($|[-_]))", re.I)
+SECRET_VALUE = re.compile(r"\b(hf_[A-Za-z0-9]{10,}|rpa_[A-Za-z0-9]{10,}|sk-[A-Za-z0-9_-]{10,}|gh[pousr]_[A-Za-z0-9]{10,}"
+                          r"|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16})\b")
+
+
+# ---- 指紋 ----
+def _skip(name):
+    return name.startswith("._") or name == ".cache" or name.startswith(".incoming_")
+
+
+def _nfc(s):
+    return unicodedata.normalize("NFC", s)
+
+
+def list_files(path):
+    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)"""
+    path = os.path.abspath(path)
+    if not os.path.lexists(path):
+        raise FileNotFoundError(path)
+    if not os.path.isdir(path) or os.path.islink(path):
+        return [(_nfc(os.path.basename(path)), os.lstat(path).st_size, path)]
+    out = []
+
+    def err(e):
+        raise e
+
+    for d, dirs, files in os.walk(path, onerror=err, followlinks=False):
+        dirs[:] = [x for x in dirs if not _skip(x)]
+        # リンクのフォルダは os.walk が dirs に入れるがたどらない. ファイルとして数える
+        for x in [x for x in dirs if os.path.islink(os.path.join(d, x))]:
+            dirs.remove(x)
+            files.append(x)
+        rel = os.path.relpath(d, path)
+        for f in files:
+            if _skip(f):
+                continue
+            full = os.path.join(d, f)
+            p = f if rel == "." else os.path.join(rel, f)
+            out.append((_nfc(p.replace(os.sep, "/")), os.lstat(full).st_size, full))
+    out.sort(key=lambda x: x[0].encode())
+    for a, b in zip(out, out[1:]):
+        if a[0] == b[0]:        # NFC にそろえると同じ名前になる別のファイル (並びが決まらない) は指紋にしない
+            raise ValueError(f"NFC で同じ名前になるファイルが2つある: {a[0]}")
+    return out
+
+
+def fingerprint(path):
+    files = list_files(path)
+    h = hashlib.sha256()
+    for p, n, _ in files:
+        h.update(f"{p}\t{n}\n".encode())
+    for p, n, full in files:
+        if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
+            with open(full, "rb") as fh:
+                h.update(b"\0" + p.encode() + b"\0" + fh.read())
+    return FP_PREFIX + h.hexdigest()
+
+
+# ---- コード ----
+def _git(args, cwd):
+    r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, timeout=60)
+    if r.returncode != 0:
+        raise RuntimeError(f"git {' '.join(args[:2])}: {r.stderr.decode(errors='replace').strip()[:200]}")
+    return r.stdout
+
+
+def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
+    """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
+    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "diff_sha256": None, "measured_at": now_iso()}
+    if paths:
+        info["paths"] = list(paths)
+    try:
+        info["commit"] = _git(["rev-parse", "HEAD"], repo_dir).decode().strip()
+        sel = ["--", *paths] if paths else []
+        st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
+        entries = [e for e in st.decode(errors="replace").split("\0") if e]
+        files, untracked, i = [], [], 0
+        while i < len(entries):
+            e = entries[i]
+            code, f = e[:2], e[3:]
+            if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
+                i += 1
+            files.append(f)
+            if code == "??":
+                untracked.append(f)
+            i += 1
+        files = sorted(set(files))
+        info["dirty"] = bool(files)
+        info["dirty_files"] = files[:DIRTY_FILES_MAX]
+        if len(files) > DIRTY_FILES_MAX:
+            info["dirty_files_total"] = len(files)
+        h = hashlib.sha256(_git(["diff", "HEAD", "--binary", *sel], repo_dir))
+        for f in sorted(untracked):
+            full = os.path.join(repo_dir, f)
+            h.update(b"\0untracked\0" + f.encode() + b"\0")
+            if os.path.isfile(full) and not os.path.islink(full):
+                with open(full, "rb") as fh:
+                    h.update(hashlib.sha256(fh.read()).digest())
+        info["diff_sha256"] = h.hexdigest() if files else None
+    except Exception as e:      # git がない・リポジトリでないなど. 分からないことを残す
+        info["error"] = f"{type(e).__name__}: {e}"
+    return info
+
+
+# ---- 鍵を伏せる ----
+def redact_argv(argv):
+    out, hide_next = [], False
+    for a in argv:
+        a = str(a)
+        if hide_next:
+            out.append(REDACTED); hide_next = False; continue
+        if a.startswith("-"):
+            flag, eq, _ = a.partition("=")
+            if SECRET_FLAG.search(flag.lstrip("-")):
+                if eq:
+                    out.append(f"{flag}={REDACTED}")
+                else:
+                    out.append(a); hide_next = True
+                continue
+        out.append(SECRET_VALUE.sub(REDACTED, a))
+    return out
+
+
+def _scrub(x, secrets):
+    if isinstance(x, dict):
+        return {_scrub(k, secrets): _scrub(v, secrets) for k, v in x.items()}
+    if isinstance(x, list):
+        return [_scrub(v, secrets) for v in x]
+    if isinstance(x, str):
+        for s in secrets:
+            x = x.replace(s, REDACTED)
+        return SECRET_VALUE.sub(REDACTED, x)
+    return x
+
+
+def read_secrets(files):
+    out = []
+    for f in files or []:
+        try:
+            with open(f, encoding="utf-8", errors="replace") as fh:
+                s = fh.read().strip()
+            if len(s) >= 8:
+                out.append(s)
+        except OSError:
+            pass
+    return out
+
+
+# ---- 由来を作る ----
+def now_iso():
+    return datetime.datetime.now().astimezone().isoformat(timespec="seconds")
+
+
+def rel_to(path, root):
+    if not path:
+        return None
+    if root:
+        a, r = os.path.abspath(path), os.path.abspath(root)
+        if a == r or a.startswith(r.rstrip(os.sep) + os.sep):
+            return _nfc(os.path.relpath(a, r).replace(os.sep, "/"))
+    return _nfc(path)
+
+
+def _item(x, data_root):
+    """{kind, id, path(絶対でも可), fingerprint?} → path を r2r2r-data からの相対にし, 指紋がなければ測る"""
+    it = {"kind": x.get("kind"), "id": x.get("id"), "path": None, "fingerprint": x.get("fingerprint")}
+    p = x.get("path")
+    if p:
+        full = p if os.path.isabs(p) or not data_root else os.path.join(data_root, p)
+        it["path"] = rel_to(full, data_root)
+        if it["kind"] == "config" and "measured_at" not in x and os.path.isfile(full) and os.path.getsize(full) < JSON_MAX:
+            try:                                # 設定のファイルは小さいので中身の sha256 も残す (指紋は名前と大きさだけ)
+                with open(full, "rb") as fh:
+                    it["sha256"] = hashlib.sha256(fh.read()).hexdigest()
+            except OSError as e:
+                it["sha256_error"] = type(e).__name__
+        if not it["fingerprint"] and "measured_at" not in x:      # prepare() で測ったもの (読めなかったものも) は測り直さない
+            try:
+                it["fingerprint"] = fingerprint(full)
+            except Exception as e:
+                it["fingerprint"] = None
+                it["fingerprint_error"] = type(e).__name__ + ("" if isinstance(e, FileNotFoundError) else f": {e}")
+    for k, v in x.items():
+        if k not in it:
+            it[k] = v
+    return it
+
+
+def build(spec, data_root=None, repo_dir=REPO_DIR, code_paths=None, secrets=()):
+    """spec (呼ぶ側が知っていること) から provenance/1 の dict を作る. 項目がなければ空 (None・[]・{}) で置く
+
+    spec の形 (どれも省いてよい):
+      producer:      {script: リポジトリからの相対パス, argv: [...], machine?: ...}
+      code:          prepare() で前もって測ったもの (なければ今測る)
+      external_code: [{name, commit, dirty, ...}]
+      inputs:        [{kind, id, path (絶対でも r2r2r-data からの相対でも), fingerprint?}]
+      outputs:       inputs と同じ形 (作ったもの)
+      config:        {name, ...主な値}
+      compute:       {provider, gpu, started_at, ended_at, duration_sec, cost_per_hr_usd, cost_usd, ...}
+      numbering:     {series, series_log, article_no}
+      status:        {process: completed|interrupted|failed, task: success|failure|unknown|not_applicable}
+      error:         失敗のわけ (文字列)
+      notes:         ほかに残したいこと
+    """
+    prod = dict(spec.get("producer") or {})
+    script = prod.get("script")
+    if script and os.path.isabs(script):
+        script = rel_to(script, repo_dir)
+    producer = {"script": script, "argv": redact_argv(prod.get("argv") or []),
+                "machine": prod.get("machine") or socket.gethostname(),
+                "platform": f"{platform.system()} {platform.release()}", "python": platform.python_version()}
+    for k, v in prod.items():
+        producer.setdefault(k, v)
+    st = dict(spec.get("status") or {})
+    if st.get("process") not in PROCESS:
+        st["process"] = "failed" if spec.get("error") else None
+    if st.get("task") not in TASK:
+        st["task"] = "unknown"
+    num = spec.get("numbering") or {}
+    rec = {
+        "schema_version": SCHEMA,
+        "created_at": now_iso(),
+        "producer": producer,
+        "code": spec.get("code") or code_info(repo_dir, code_paths),
+        "external_code": list(spec.get("external_code") or []),
+        "inputs": [_item(x, data_root) for x in spec.get("inputs") or []],
+        "outputs": [_item(x, data_root) for x in spec.get("outputs") or []],
+        "config": spec.get("config") or {},
+        "compute": spec.get("compute") or {},
+        "numbering": {"series": num.get("series") or "", "series_log": num.get("series_log") or "",
+                      "article_no": num.get("article_no") or ""},
+        "status": st,
+    }
+    if spec.get("error"):
+        rec["error"] = str(spec["error"])
+    if spec.get("notes"):
+        rec["notes"] = spec["notes"]
+    return _scrub(rec, list(secrets))
+
+
+def prepare(spec, data_root=None, repo_dir=REPO_DIR, code_paths=None):
+    """始める時点のコードと入力の指紋を測る (終わったときではなく, 使う前の姿を残すため). 戻りを build の spec の code・inputs に渡す"""
+    return {"code": code_info(repo_dir, code_paths),
+            "inputs": [dict(_item(x, data_root), measured_at=now_iso()) for x in spec.get("inputs") or []]}
+
+
+def write(rec, path):
+    """path に書く. もうあれば FileExistsError (上書きしない). 置く先のフォルダは作らない
+
+    O_EXCL で作る (あれば失敗する. exFAT の KIOXIA でも同じに働く. 確かめてから rename する形は, その間に
+    別の実行が作ったものを上書きしうるので使わない. codex R4). 中身は数 KB. 書き終わるまで write をくり返し, 書けなければ消す.
+    一時ファイルは作らない (止められたときに残って, そのフォルダの指紋に入るのを避ける).
+    書く途中で KILL されたときだけ書きかけが残りうる (読む側は JSON として読めるかを確かめる)"""
+    path = os.fspath(path)
+    data = (json.dumps(rec, ensure_ascii=False, indent=2) + "\n").encode()
+    fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o644)
+    try:
+        view = memoryview(data)
+        while view:
+            n = os.write(fd, view)
+            view = view[n:]
+        os.fsync(fd)
+    except BaseException:
+        os.close(fd)
+        try:
+            os.unlink(path)
+        except OSError:
+            pass
+        raise
+    os.close(fd)
+    return path
+
+
+# ---- CLI ----
+def main(argv=None):
+    ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
+    sub = ap.add_subparsers(dest="cmd", required=True)
+    f = sub.add_parser("fingerprint")
+    f.add_argument("path")
+    pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
+    pr.add_argument("--data-root", default=None)
+    pr.add_argument("--repo-dir", default=REPO_DIR)
+    pr.add_argument("--code-path", action="append", default=None)
+    w = sub.add_parser("write")
+    w.add_argument("--data-root", default=None, help="r2r2r-data (inputs・outputs の path をここからの相対にする)")
+    w.add_argument("--out", action="append", required=True, help="置く先 (いくつでも. あれば飛ばす)")
+    w.add_argument("--code-path", action="append", default=None, help="変更の有無を見るリポジトリの中のパス (なければ全部)")
+    w.add_argument("--repo-dir", default=REPO_DIR, help="r2r2r-scripts (既定 このファイルのあるところ)")
+    w.add_argument("--secret-file", action="append", default=[], help="中身を記録に出さない鍵のファイル")
+    w.add_argument("--spec", default="-", help="spec の JSON (既定 標準入力)")
+    a = ap.parse_args(argv)
+    try:
+        if a.cmd == "fingerprint":
+            print(fingerprint(a.path))
+            return 0
+        if a.cmd == "prepare":
+            print(json.dumps(prepare(json.loads(sys.stdin.read() or "{}"), a.data_root, a.repo_dir, a.code_path), ensure_ascii=False))
+            return 0
+        if a.data_root and not os.path.isdir(a.data_root):
+            print(f"[PROV  ] {a.data_root} がない (マウントされていない?). 書かない", file=sys.stderr)
+            return 1
+        raw = sys.stdin.read() if a.spec == "-" else open(a.spec, encoding="utf-8").read()
+        rec = build(json.loads(raw), a.data_root, repo_dir=a.repo_dir, code_paths=a.code_path, secrets=read_secrets(a.secret_file))
+    except Exception as e:
+        print(f"[PROV  ] 由来を作れない: {type(e).__name__}: {e}", file=sys.stderr)
+        return 1
+    rc = 0
+    for out in a.out:
+        try:
+            write(rec, out)
+            print(out)
+        except FileExistsError:
+            print(f"[PROV  ] {out} はもうある. 上書きしない", file=sys.stderr)
+            rc = rc or 3
+        except Exception as e:
+            print(f"[PROV  ] {out} に書けない: {type(e).__name__}: {e}", file=sys.stderr)
+            rc = 1
+    return rc
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/test_lab_train.sh b/test_lab_train.sh
index b081aed..ad7d9c8 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -114,6 +114,7 @@ T=$(mktemp -d); mkdir -p "$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1/149
 CK="$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1"
 check "前もって知らせる" 'has out.txt "がもうあります. 学習のあと上書きはせず"'
 check "もとの中身はそのまま・終了コード 1・一時フォルダも作らない" '[ "$(cat "$CK/1499/keep.txt")" = old ] && [ $RC = 1 ] && [ $(n_call "hf download") = 0 ] && has notify.log "上書きしなかった"'
+check "もとからある 1499 には由来を書き足さない・lab 側には置く (依頼 54)" '[ ! -e "$CK/1499/train_provenance.json" ] && [ "$(jq -r ".outputs | length" "$T/data/lab/LogT/train/train_provenance.json")" = 0 ]'
 rm -rf "$T"
 
 echo "== 8b. 前の回の .incoming_* (._ だけ残ったもの) は触らない (依頼 52)"
@@ -184,6 +185,7 @@ check "終了コード 1・POST しない・--stop の案内" '[ $RC = 1 ] && [
 (cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run --stop < /dev/null > "$T/out.txt" 2>&1); RC=$?
 check "--stop: 学習を止め, ログを持ち帰って DELETE" '[ $(n_call DELETE) = 1 ] && has ssh.log ": lt=kill_train" && [ -f "$TD/train_trim_corr_v3.log" ] && has out.txt "--stop で止めた"'
 check "--stop の出力は lab_train_*_stop.txt に残る" 'ls "$TD"/lab_train_*_stop.txt >/dev/null 2>&1'
+check "--stop: 由来は2つ目を別の名前で置き (前のものは上書きしない), status interrupted (依頼 54)" '[ "$(jq -r .status.process "$TD/train_provenance.json")" = failed ] && [ "$(jq -r .status.process "$(ls "$TD"/train_provenance_*.json | head -1)")" = interrupted ]'
 (cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run < /dev/null > "$T/out2.txt" 2>&1)
 check "消したあとは前の記録を名前を変えて残し, 新しく作る" '[ $(n_call "POST /pods") = 1 ] && ls "$TD"/lab_pod_*.json >/dev/null 2>&1'
 rm -rf "$T"
@@ -197,6 +199,7 @@ pk=$(pgrep -f "lab_train.sh --log LogT --dry-run --limit-hours 1" | head -1)
 kill -TERM "$pk" 2>/dev/null       # 裏で動かした bash は INT を受けない (始まる前から無視) ので TERM で確かめる. INT も同じ on_signal
 wait $bg; TD="$T/data/lab/LogT/train"
 check "止められたら, ログを持ち帰って DELETE・通知" '[ $(n_call DELETE) = 1 ] && [ -f "$TD/train_trim_corr_v3.log" ] && grep -q "TERM (kill) で止めた" "$T/notify.log"'
+check "止められたら, 由来は status interrupted (依頼 54)" '[ "$(jq -r .status.process "$TD/train_provenance.json")" = interrupted ] && [[ "$(jq -r .error "$TD/train_provenance.json")" == signal:*"TERM"* ]]'
 rm -rf "$T"
 
 echo "== 14. 引数"
@@ -363,7 +366,96 @@ echo "== 16. lab_start.sh が lab_lib.sh の準備のコマンドで同じもの
 check "3c は lab_start.sh の時と同じ中身" '[ "$(lab_cmd_ckpt PixelstoMotors/pi05-mecharm-trim-lora-v1 /workspace/ckpt/3999)" = "rm -f /workspace/ckpt/3999/.lab_download_ok && hf download PixelstoMotors/pi05-mecharm-trim-lora-v1 --local-dir /workspace/ckpt/3999 && touch /workspace/ckpt/3999/.lab_download_ok && du -sh /workspace/ckpt/3999" ]'
 check "3e は \$HOME を Pod 側で読む" '[[ "$(lab_cmd_uv_sync)" == *"source \$HOME/.local/bin/env"* ]]'
 
-rm -rf "$OUT1"
+echo "== 17. 由来 (train_provenance.json. 依頼 54)"
+T="$OUT1"; TD="$T/data/lab/LogT/train"; CK="$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1"
+P="$TD/train_provenance.json"
+pj() { jq -r "$1" "$P"; }
+check "成功: lab 側とチェックポイント側に同じものを置く" '[ -f "$P" ] && cmp -s "$P" "$CK/1499/train_provenance.json"'
+check "成功: provenance/1・ISO8601 (タイムゾーンつき)・lab_train.sh と引数" '[ "$(pj .schema_version)" = provenance/1 ] && [[ "$(pj .created_at)" =~ ^20[0-9-]{8}T[0-9:]{8}[+-][0-9]{2}:[0-9]{2}$ ]] && [ "$(pj .producer.script)" = lab_train.sh ] && [ "$(pj ".producer.argv | join(\" \")")" = "--log LogT --dry-run" ] && [ -n "$(pj .producer.machine)" ]'
+check "成功: 入力とコードは始める時点に測る・記録にも残す (codex R1)" '[ "$(jq -c .prov_start.code "$TD/lab_pod.json")" = "$(jq -c .code "$P")" ] && [ "$(pj ".inputs[0].measured_at <= .compute.started_at")" = true ] && [[ "$(pj ".inputs[2].sha256")" =~ ^[0-9a-f]{64}$ ]]'
+check "成功: code (r2r2r-scripts のコミット・dirty・見たパス)" '[ "$(pj .code.repo)" = r2r2r-scripts ] && [ "$(pj .code.commit)" = "$(git -C "$HERE" rev-parse HEAD)" ] && [[ "$(pj .code.dirty)" =~ ^(true|false)$ ]] && [ "$(pj ".code.paths | join(\",\")")" = "lab_train.sh,lab_lib.sh,provenance.py,pi05" ]'
+check "成功: Pod の openpi のコミットと変更" '[ "$(pj ".external_code[0].name")" = openpi ] && [ "$(pj ".external_code[0].commit")" = 215abfb1234567890abcdef1234567890abcdef0 ] && [ "$(pj ".external_code[0].dirty")" = true ] && [ "$(pj ".external_code[0].dirty_files[0]")" = src/openpi/training/config.py ]'
+check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v1:* ]]'
+check "成功: 設定・compute (GPU・始めと終わり・時間・費用は [COST] と同じ)・numbering・status" '[ "$(pj .config.name)" = pi05_mecharm_trim_corr_lora ] && [ "$(pj .config.num_train_steps)" = 1500 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.cost_per_hr_usd)" = 0.44 ] && [ "$(pj .compute.duration_sec)" = "$(jq .rented_sec "$TD/lab_pod.json")" ] && [ -n "$(pj ".compute.started_at // empty")" ] && [ -n "$(pj ".compute.ended_at // empty")" ] && grep -qF "[COST  ] $(pj .compute.cost_line)" "$T/out.txt" && [ "$(pj .numbering.series)" = フィジカルAI研究室 ] && [ "$(pj .numbering.series_log)" = LogT ] && [ "$(pj ".status | [.process,.task] | join(\" \")")" = "completed not_applicable" ] && [ "$(pj ".error // empty")" = "" ]'
+check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v1:* ]]'
+# Pod の openpi を読むコマンドを, この Mac の bash で一時の git リポジトリに向けて流す (codex R1)
+oc=$(grep -F ": lt=openpi_rev" "$T/ssh.log" | head -1); G=$(mktemp -d)
+(cd "$G" && git init -q && git config user.email t@example.com && git config user.name t && echo a > f && git add f && git commit -qm i && echo b >> f && echo n > new)
+oout=$(bash -c "${oc//\/workspace\/openpi/$G}"); orc=$?
+check "Pod の openpi を読むコマンド: コミット・変更 (M と ??)・終わりの印" '[ $orc = 0 ] && [ "$(sed -n 1p <<<"$oout")" = "OPENPI $(git -C "$G" rev-parse HEAD)" ] && grep -qx "OPF  M f" <<<"$oout" && grep -qx "OPF ?? new" <<<"$oout" && [ "$(tail -1 <<<"$oout")" = OPENPI_END ]'
+rm -rf "$G/.git"; oout=$(bash -c "${oc//\/workspace\/openpi/$G}" 2>/dev/null); orc=$?
+check "Pod の openpi を読むコマンド: git でなければ終わりの印を出さない" '[ $orc != 0 ] && ! grep -qx OPENPI_END <<<"$oout"'
+rm -rf "$G"
+check "成功: Hugging Face にも同じものを足す (KIOXIA へ取ったあと)" 'cmp -s "$P" "$T/fake/hf/train_provenance.json" && [ $(line_of calls.log "hf download") -lt $(line_of calls.log "hf upload") ] && has calls.log "hf upload PixelstoMotors/pi05-mecharm-trim-corr-lora-v1 train_provenance.json train_provenance.json"'
+check "成功: 鍵を由来に書かない (KIOXIA の checkpoints 側も)" '! grep -rqE "DRY-RUNPOD-SECRET-KEY|hf_DRYSECRETTOKEN|10\.9\.8\.7|40123" "$P" "$CK/1499/train_provenance.json"'
+check "成功: 由来を置いてもチェックポイントの一覧の比べには入らない" 'has out.txt "Pod 側と名前・バイト数が全部合った" && grep -q "Hugging Face (PixelstoMotors/pi05-mecharm-trim-corr-lora-v1) にも置いた" "$TD"/result_*.txt'
+
+run crash
+P="$TD/train_provenance.json"
+check "落ちた: lab 側に status failed と error・チェックポイント側には置かない・Hugging Face にも上げない" '[ $RC = 1 ] && [ "$(pj .status.process)" = failed ] && [[ "$(pj .error)" == crash:*"終了コード 1"* ]] && [ "$(pj ".outputs | length")" = 0 ] && [ ! -e "$CK" ] && [ $(n_call "hf upload") = 0 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.duration_sec)" != null ]'
+rm -rf "$T"
+run timeout --limit-hours 0.001
+check "上限で止めた: status interrupted" '[ "$(jq -r .status.process "$TD/train_provenance.json")" = interrupted ] && [[ "$(jq -r .error "$TD/train_provenance.json")" == timeout:* ]]'
+rm -rf "$T"
+run all_busy
+check "借りられなかった: status failed・Pod の id なし・終了コードは変わらない (1)" '[ $RC = 1 ] && [ "$(jq -r .status.process "$TD/train_provenance.json")" = failed ] && [ "$(jq -r .compute.pod_id "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+run hf_same
+check "Hugging Face にもとからあった (same): 由来は Hugging Face に足さない・KIOXIA には置く" '[ $RC = 0 ] && [ $(n_call "hf upload") = 0 ] && [ -f "$CK/1499/train_provenance.json" ] && [ "$(jq -r ".outputs[0].hf_repo" "$CK/1499/train_provenance.json")" = null ]'
+check "same: Hugging Face にあった前の回の由来は持ってこず, 今回のものを置く (codex R1)" 'cmp -s "$TD/train_provenance.json" "$CK/1499/train_provenance.json" && [ "$(jq -r ".old // false" "$CK/1499/train_provenance.json")" = false ]'
+rm -rf "$T"
+run hf_prov_fail
+check "由来だけ Hugging Face に上げられない: 学習は成功のまま (0)・まとめに出す" '[ $RC = 0 ] && has notify.log "成功: 学習が終わりました" && grep -q "Hugging Face には置けなかった" "$TD"/result_*.txt && [ -f "$CK/1499/train_provenance.json" ]'
+rm -rf "$T"
+# 外にも見張り: 打ち切りが働かず返らなくても, 90 秒でこの回を止めてテストを先へ進める (codex R4)
+( sleep 90; pkill -f "lab_train.sh --log LogT --dry-run" ) & ow=$!
+t0=$SECONDS; LAB_PROV_TO=5 run hf_prov_hang; tp=$((SECONDS - t0))
+kill $ow 2>/dev/null; wait $ow 2>/dev/null
+check "由来が時間の上限で終わらない (孫を次々に作り TERM を無視): 打ち切って通知へ進む・学習は成功のまま・孫も残さない (codex R2・R3)" '[ $RC = 0 ] && [ $tp -lt 40 ] && grep -q "秒で書き終わらなかったので打ち切った" "$TD"/result_*.txt && [ -f "$TD/train_provenance.json" ] && [ $(wc -l < "$T/notify.log") = 1 ] && ! pgrep -f "^sleep 61\$" >/dev/null'
+rm -rf "$T"
+run openpi_partial
+check "openpi のコミットの行だけで ssh が切れた: 「変更なし」にしない (codex R1)" '[ $RC = 0 ] && [ "$(jq -r ".external_code[0].commit" "$TD/train_provenance.json")" = null ] && [ "$(jq -r ".external_code[0].dirty" "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+run list_empty
+check "学習が終わったあとの一覧で止まった: status は completed・error に一覧 (codex R1)" '[ $RC = 1 ] && [ "$(jq -r .status.process "$TD/train_provenance.json")" = completed ] && [[ "$(jq -r .error "$TD/train_provenance.json")" == list:* ]]'
+rm -rf "$T"
+# --stop: 始めたときの設定・コード・入力を記録から戻す (codex R1)
+T=$(mktemp -d); mkdir -p "$T/data/lab/LogT/train" "$T/fake"; TD="$T/data/lab/LogT/train"
+jq -n '{id:"dryrunpod0001", kind:"train", gpu:"NVIDIA A40", costPerHr:0.44, created_epoch:1793449178, created_at:"2026-10-31 21:19:38", exp:"trim_corr_v3", steps:100, init:"/workspace/ckpt/other/params", limit_hours:"2", train_started_epoch:1793449300,
+       prov_start:{code:{repo:"r2r2r-scripts", commit:"abc", dirty:false, measured_at:"2026-10-31T21:19:00+09:00"}, inputs:[{kind:"dataset", id:"d", path:"datasets/d", fingerprint:"files-v1:00"}]}}' > "$TD/lab_pod.json"
+echo "NVIDIA A40" > "$T/fake/created"
+(cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run --stop < /dev/null > "$T/out.txt" 2>&1)
+check "--stop: steps・init・上限・始めたときのコードと入力は記録の値 (既定値ではない)" '[ "$(jq -r .config.num_train_steps "$TD/train_provenance.json")" = 100 ] && [ "$(jq -r .config.init_params "$TD/train_provenance.json")" = /workspace/ckpt/other/params ] && [ "$(jq -r .config.limit_hours "$TD/train_provenance.json")" = 2 ] && [ "$(jq -r .code.commit "$TD/train_provenance.json")" = abc ] && [ "$(jq -r ".inputs[0].fingerprint" "$TD/train_provenance.json")" = files-v1:00 ]'
+check "--stop: 記録の openpi が無い (前の形の記録) → 「記録にない」" '[ "$(jq -r ".external_code[0].error" "$TD/train_provenance.json")" = "記録にない (--stop)" ]'
+# 前の形の記録 (prov_start なし) で --stop: 止めた今のコード・入力を測らず「分からない」 (codex R2)
+jq 'del(.prov_start)' "$TD/lab_pod.json" > "$TD/x.json" && mv "$TD/x.json" "$TD/lab_pod.json"; rm -f "$T/fake/deleted"
+(cd "$HERE" && LAB_DRY_ROOT="$T" ./lab_train.sh --log LogT --dry-run --stop < /dev/null > "$T/out.txt" 2>&1)
+P2=$(ls -t "$TD"/train_provenance_*.json | head -1)
+check "--stop・始めたときの記録なし: code.commit・入力の指紋は null と理由 (今のものを測らない)" '[ "$(jq -r .code.commit "$P2")" = null ] && [ "$(jq -r .code.error "$P2")" = "始めたときの記録がない (--stop)" ] && [ "$(jq -r "[.inputs[].fingerprint] | unique | .[0]" "$P2")" = null ] && [ "$(jq -r ".inputs[0].fingerprint_error" "$P2")" = "始めたときの記録がない (--stop)" ]'
+rm -rf "$T"
+run success
+check "openpi の記録は lab_pod.json にも残す (--stop で戻すため)" '[ "$(jq -r ".openpi[0].commit" "$TD/lab_pod.json")" = 215abfb1234567890abcdef1234567890abcdef0 ]'
+rm -rf "$T"
+run openpi_unknown
+check "openpi のコミットが読めない: 学習は続ける・由来に「読めなかった」" '[ $RC = 0 ] && [ "$(jq -r ".external_code[0].commit" "$TD/train_provenance.json")" = null ] && [ "$(jq -r ".external_code[0].error" "$TD/train_provenance.json")" = "Pod から読めなかった" ]'
+rm -rf "$T"
+run kioxia_gone
+check "KIOXIA が外れた: 由来は書かない (フォルダも作り直さない)・最後まで片付けて通知" '[ $RC = 1 ] && [ ! -e "$T/data" ] && ! find "$T/data_unmounted" -name "train_provenance*.json" | grep -q . && has out.txt "由来: KIOXIA がマウントされていない" && [ $(n_call DELETE) = 1 ] && [ $(wc -l < "$T/notify.log") = 1 ]'
+rm -rf "$T"
+# dirty: 一時の git リポジトリに lab_train.sh などを入れ, 変える前・変えたあとで code を見る
+G=$(mktemp -d); (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t \
+    && cp "$HERE/lab_train.sh" "$HERE/lab_lib.sh" "$HERE/provenance.py" . && mkdir pi05 && echo x > pi05/a.py && echo y > other.txt \
+    && git add lab_train.sh lab_lib.sh provenance.py pi05/a.py other.txt && git commit -qm init)
+LAB_PROV_REPO="$G" run success
+check "dirty でない: code.dirty=false・dirty_files なし" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r .code.commit "$TD/train_provenance.json")" = "$(git -C "$G" rev-parse HEAD)" ]'
+rm -rf "$T"
+echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
+LAB_PROV_REPO="$G" run success
+check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
+rm -rf "$T" "$G"
+OUT1=""
+
+rm -rf "${OUT1:-}"
 echo
 echo "OK $OK / NG $NG"
 [ $NG = 0 ]
diff --git a/test_provenance.py b/test_provenance.py
new file mode 100644
index 0000000..bf1313a
--- /dev/null
+++ b/test_provenance.py
@@ -0,0 +1,339 @@
+#!/usr/bin/env python3
+"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54) の確かめ.
+
+  python3 test_provenance.py -v
+
+一時フォルダと一時の git リポジトリだけを使う (KIOXIA・本物のリポジトリには書かない).
+"""
+import hashlib
+import io
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unicodedata
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import provenance as pv  # noqa: E402
+
+
+def put(root, rel, data=b"x"):
+    p = Path(root) / rel
+    p.parent.mkdir(parents=True, exist_ok=True)
+    p.write_bytes(data if isinstance(data, bytes) else data.encode())
+    return p
+
+
+def git(cwd, *args):
+    subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
+
+
+class Fingerprint(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.d = Path(self.tmp.name) / "ds"
+        put(self.d, "index.json", '{"n": 1}')
+        put(self.d, "raw/ep1/frame.jpg", b"\xff" * 50)
+        put(self.d, "raw/ep1/meta.json", '{"a": 1}')
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def test_format_and_same_twice(self):
+        a = pv.fingerprint(self.d)
+        self.assertRegex(a, r"^files-v1:[0-9a-f]{64}$")
+        self.assertEqual(a, pv.fingerprint(self.d))
+
+    def test_definition(self):
+        # 決めた作り方そのもの (相対パス\tバイト数 の一覧 → 1MB 未満の .json の中身)
+        h = hashlib.sha256()
+        for p, n in [("index.json", 8), ("raw/ep1/frame.jpg", 50), ("raw/ep1/meta.json", 8)]:
+            h.update(f"{p}\t{n}\n".encode())
+        h.update(b"\0index.json\0" + b'{"n": 1}')
+        h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
+        self.assertEqual(pv.fingerprint(self.d), "files-v1:" + h.hexdigest())
+
+    def test_skips_dotunderscore_cache_incoming(self):
+        a = pv.fingerprint(self.d)
+        put(self.d, "._index.json", "junk")
+        put(self.d, "raw/._ep1", "junk")
+        put(self.d, ".cache/huggingface/x.json", "{}")
+        put(self.d, ".incoming_1499_x/params/a", "zz")
+        self.assertEqual(a, pv.fingerprint(self.d))
+
+    def test_image_content_not_read_but_size_counts(self):
+        a = pv.fingerprint(self.d)
+        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 50)      # 同じ大きさで中身だけ違う
+        self.assertEqual(a, pv.fingerprint(self.d))
+        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 51)
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_json_content_counts(self):
+        a = pv.fingerprint(self.d)
+        put(self.d, "raw/ep1/meta.json", '{"a": 2}')         # 同じ大きさで中身が違う
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_big_json_content_not_read(self):
+        put(self.d, "big.json", b"a" * pv.JSON_MAX)
+        a = pv.fingerprint(self.d)
+        put(self.d, "big.json", b"b" * pv.JSON_MAX)
+        self.assertEqual(a, pv.fingerprint(self.d))
+
+    def test_new_file_counts(self):
+        a = pv.fingerprint(self.d)
+        put(self.d, "raw/ep2/frame.jpg", b"")
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_nfd_names_same_as_nfc(self):
+        e = Path(self.tmp.name)
+        put(e / "a", unicodedata.normalize("NFC", "データ.txt"), "1")
+        put(e / "b", unicodedata.normalize("NFD", "データ.txt"), "1")
+        self.assertEqual(pv.fingerprint(e / "a"), pv.fingerprint(e / "b"))
+
+    def test_nfc_collision_raises(self):
+        e = Path(self.tmp.name) / "c"
+        put(e, unicodedata.normalize("NFC", "が.txt"), "1")
+        try:
+            put(e, unicodedata.normalize("NFD", "が.txt"), "2")
+        except OSError:
+            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
+        if len(os.listdir(e)) < 2:
+            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
+        with self.assertRaises(ValueError):
+            pv.fingerprint(e)
+
+    def test_symlink_not_followed(self):
+        e = Path(self.tmp.name) / "l"
+        put(e, "real/a.json", '{"x": 1}')
+        os.symlink(self.d, e / "link")
+        names = [p for p, _, _ in pv.list_files(e)]
+        self.assertEqual(names, ["link", "real/a.json"])
+
+    def test_single_file_and_missing(self):
+        f = put(self.tmp.name, "one.json", "{}")
+        h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
+        self.assertEqual(pv.fingerprint(f), "files-v1:" + h)
+        with self.assertRaises(FileNotFoundError):
+            pv.fingerprint(Path(self.tmp.name) / "nothing")
+
+    def test_unreadable_dir_raises(self):
+        locked = self.d / "locked"
+        locked.mkdir()
+        locked.chmod(0)
+        try:
+            with self.assertRaises(OSError):
+                pv.fingerprint(self.d)
+        finally:
+            locked.chmod(0o755)
+
+
+class Redact(unittest.TestCase):
+    def test_argv(self):
+        argv = ["--log", "Log17", "--hf-token", "abc123", "--api_key=xyz", "--key", "k", "--steps", "1500",
+                "hf_ABCDEFGHIJKLMNOP", "--author", "me"]
+        self.assertEqual(pv.redact_argv(argv), ["--log", "Log17", "--hf-token", "<redacted>", "--api_key=<redacted>",
+                                                "--key", "<redacted>", "--steps", "1500", "<redacted>", "--author", "me"])
+
+    def test_secret_in_dict_key(self):
+        rec = pv.build({"notes": {"hf_ABCDEFGHIJKLMNOPQ": 1}}, repo_dir=tempfile.gettempdir())
+        self.assertNotIn("hf_ABCDEFGHIJKLMNOPQ", json.dumps(rec))
+
+    def test_secret_file_values_removed_everywhere(self):
+        with tempfile.TemporaryDirectory() as t:
+            kf = put(t, "runpod.txt", "rpa_SECRETSECRETSECRET\n")
+            rec = pv.build({"producer": {"script": "x.py", "argv": ["plain"]},
+                            "notes": {"log": "header Bearer rpa_SECRETSECRETSECRET end", "other": "MYSECRET99"},
+                            "status": {"process": "completed", "task": "not_applicable"}},
+                           repo_dir=t, secrets=pv.read_secrets([kf, put(t, "k2", "MYSECRET99")]))
+            s = json.dumps(rec)
+            self.assertNotIn("SECRETSECRET", s)
+            self.assertNotIn("MYSECRET99", s)
+
+
+class Code(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.r = Path(self.tmp.name)
+        git(self.r, "init", "-q")
+        git(self.r, "config", "user.email", "t@example.com")
+        git(self.r, "config", "user.name", "t")
+        put(self.r, "a.sh", "echo a\n")
+        put(self.r, "sub/b.py", "print(1)\n")
+        put(self.r, "other.txt", "o\n")
+        git(self.r, "add", "a.sh", "sub/b.py", "other.txt")
+        git(self.r, "commit", "-qm", "init")
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def test_clean(self):
+        c = pv.code_info(self.r)
+        self.assertRegex(c["commit"], r"^[0-9a-f]{40}$")
+        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
+
+    def test_dirty_modified_and_untracked(self):
+        put(self.r, "a.sh", "echo A\n")
+        put(self.r, "sub/new.py", "x\n")
+        c = pv.code_info(self.r)
+        self.assertTrue(c["dirty"])
+        self.assertEqual(c["dirty_files"], ["a.sh", "sub/new.py"])
+        self.assertRegex(c["diff_sha256"], r"^[0-9a-f]{64}$")
+        d1 = c["diff_sha256"]
+        put(self.r, "sub/new.py", "y\n")                     # まだ git にないファイルの中身も差分に入る
+        self.assertNotEqual(d1, pv.code_info(self.r)["diff_sha256"])
+
+    def test_staged_and_renamed(self):
+        git(self.r, "mv", "other.txt", "renamed.txt")
+        put(self.r, "a.sh", "echo staged\n")
+        git(self.r, "add", "a.sh")
+        c = pv.code_info(self.r)
+        self.assertEqual(c["dirty_files"], ["a.sh", "renamed.txt"])
+
+    def test_paths_limit(self):
+        put(self.r, "other.txt", "changed\n")
+        c = pv.code_info(self.r, paths=["a.sh", "sub"])
+        self.assertEqual((c["dirty"], c["paths"]), (False, ["a.sh", "sub"]))
+        put(self.r, "sub/b.py", "print(2)\n")
+        self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])
+
+    def test_not_a_repo(self):
+        with tempfile.TemporaryDirectory() as t:
+            c = pv.code_info(t)
+        self.assertIsNone(c["commit"])
+        self.assertIn("error", c)
+
+
+class BuildWrite(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.root = Path(self.tmp.name) / "r2r2r-data"
+        put(self.root, "datasets/ds1/index.json", "{}")
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def spec(self, **kw):
+        s = {"producer": {"script": "lab_train.sh", "argv": ["--log", "Log9"]},
+             "inputs": [{"kind": "dataset", "id": "ds1", "path": str(self.root / "datasets/ds1")},
+                        {"kind": "checkpoint", "id": "c", "path": "checkpoints/none"}],
+             "numbering": {"series": "フィジカルAI研究室", "series_log": "Log9"},
+             "status": {"process": "completed", "task": "not_applicable"}}
+        s.update(kw)
+        return s
+
+    def test_build_fields(self):
+        rec = pv.build(self.spec(), data_root=str(self.root), repo_dir=self.tmp.name)
+        for k in ("schema_version", "created_at", "producer", "code", "external_code", "inputs", "config",
+                  "compute", "numbering", "status"):
+            self.assertIn(k, rec)
+        self.assertEqual(rec["schema_version"], "provenance/1")
+        self.assertRegex(rec["created_at"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d$")
+        self.assertEqual(rec["code"]["repo"], "r2r2r-scripts")
+        self.assertEqual(rec["inputs"][0]["path"], "datasets/ds1")              # r2r2r-data からの相対
+        self.assertEqual(rec["inputs"][0]["fingerprint"], pv.fingerprint(self.root / "datasets/ds1"))
+        self.assertEqual((rec["inputs"][1]["fingerprint"], rec["inputs"][1]["fingerprint_error"]), (None, "FileNotFoundError"))
+        self.assertEqual(rec["numbering"], {"series": "フィジカルAI研究室", "series_log": "Log9", "article_no": ""})
+        self.assertNotIn("error", rec)
+
+    def test_prepare_snapshot_kept(self):
+        # 始めたときに読めなかった入力は, 終わったときに読めても測り直さない (codex R2)
+        spec = {"inputs": [{"kind": "checkpoint", "id": "late", "path": "checkpoints/late"},
+                           {"kind": "dataset", "id": "ds1", "path": "datasets/ds1"}]}
+        start = pv.prepare(spec, data_root=str(self.root), repo_dir=self.tmp.name)
+        self.assertIsNone(start["inputs"][0]["fingerprint"])
+        put(self.root, "checkpoints/late/x", "1")
+        put(self.root, "datasets/ds1/new.json", "{}")
+        rec = pv.build({**start, "status": {"process": "completed"}}, data_root=str(self.root), repo_dir=self.tmp.name)
+        a, b = rec["inputs"]
+        self.assertEqual((a["fingerprint"], a["fingerprint_error"], a["measured_at"]),
+                         (None, "FileNotFoundError", start["inputs"][0]["measured_at"]))
+        self.assertEqual(b["fingerprint"], start["inputs"][1]["fingerprint"])          # 始めたときの指紋のまま
+        self.assertNotEqual(b["fingerprint"], pv.fingerprint(self.root / "datasets/ds1"))
+        self.assertEqual(rec["code"], start["code"])
+
+    def test_status_defaults(self):
+        rec = pv.build({"error": "落ちた"}, repo_dir=self.tmp.name)
+        self.assertEqual(rec["status"], {"process": "failed", "task": "unknown"})
+        self.assertEqual(rec["error"], "落ちた")
+
+    def test_write_no_overwrite_no_mkdir(self):
+        f = self.root / "p.json"
+        pv.write({"a": 1}, f)
+        with self.assertRaises(FileExistsError):
+            pv.write({"a": 2}, f)
+        self.assertEqual(json.loads(f.read_text()), {"a": 1})
+        with self.assertRaises(FileNotFoundError):
+            pv.write({"a": 1}, self.root / "nodir" / "p.json")
+        self.assertFalse((self.root / "nodir").exists())
+        self.assertEqual(sorted(x.name for x in self.root.iterdir()), ["datasets", "p.json"])     # 一時ファイルを残さない
+
+    def test_write_race_never_overwrites(self):
+        # 別の実行が同じ名前を先に作ったら, 書かずに FileExistsError (中身はそのまま. codex R4)
+        f = self.root / "r.json"
+        orig = os.open
+
+        def racing_open(p, flags, *a):
+            if os.fspath(p) == str(f) and not f.exists():
+                f.write_text("other")           # 確かめたあと・作る直前に別の実行が置いた
+            return orig(p, flags, *a)
+        os.open = racing_open
+        try:
+            with self.assertRaises(FileExistsError):
+                pv.write({"a": 1}, f)
+        finally:
+            os.open = orig
+        self.assertEqual(f.read_text(), "other")
+
+    def test_write_failure_removes_partial(self):
+        f = self.root / "w.json"
+        orig = os.write
+        os.write = lambda fd, b: (_ for _ in ()).throw(OSError(28, "No space left on device"))
+        try:
+            with self.assertRaises(OSError):
+                pv.write({"a": 1}, f)
+        finally:
+            os.write = orig
+        self.assertFalse(f.exists())
+
+    def cli(self, argv, stdin):
+        out, err = io.StringIO(), io.StringIO()
+        old = sys.stdin
+        sys.stdin = io.StringIO(stdin)
+        try:
+            with redirect_stdout(out), redirect_stderr(err):
+                rc = pv.main(argv)
+        finally:
+            sys.stdin = old
+        return rc, out.getvalue(), err.getvalue()
+
+    def test_cli_write(self):
+        a, b = self.root / "a.json", self.root / "b.json"
+        b.write_text("old")
+        rc, out, err = self.cli(["write", "--data-root", str(self.root), "--repo-dir", self.tmp.name,
+                                 "--out", str(a), "--out", str(b)], json.dumps(self.spec()))
+        self.assertEqual(rc, 3)                                  # b はもとからある → 上書きしない
+        self.assertEqual(b.read_text(), "old")
+        self.assertEqual(json.loads(a.read_text())["schema_version"], "provenance/1")
+        self.assertIn(str(a), out)
+
+    def test_cli_unmounted(self):
+        rc, out, err = self.cli(["write", "--data-root", str(self.root / "gone"), "--out", str(self.root / "x.json")], "{}")
+        self.assertEqual(rc, 1)
+        self.assertFalse((self.root / "x.json").exists())
+
+    def test_cli_bad_spec(self):
+        rc, out, err = self.cli(["write", "--out", str(self.root / "x.json")], "{not json")
+        self.assertEqual(rc, 1)
+        self.assertIn("由来を作れない", err)
+
+    def test_cli_fingerprint(self):
+        rc, out, err = self.cli(["fingerprint", str(self.root / "datasets/ds1")], "")
+        self.assertEqual((rc, out.strip()), (0, pv.fingerprint(self.root / "datasets/ds1")))
+
+
+if __name__ == "__main__":
+    unittest.main()
```

### example_train_provenance_dryrun_20261101.json（偽物の実行で出た由来の記録）

```json
{
  "schema_version": "provenance/1",
  "created_at": "2026-11-01T10:53:43+09:00",
  "producer": {
    "script": "lab_train.sh",
    "argv": [
      "--log",
      "Log18",
      "--dry-run"
    ],
    "machine": "shimadzueiichinoMacBook-Pro.local",
    "platform": "Darwin 25.6.0",
    "python": "3.12.12",
    "dry_run": true
  },
  "code": {
    "repo": "r2r2r-scripts",
    "commit": "eb491c5a393d07fb9ae0a8bd463249371defb8fa",
    "dirty": true,
    "dirty_files": [
      "lab_lib.sh",
      "lab_train.sh",
      "pi05/build_demo_episodes.py.bak_pre_LOG352_20261019",
      "pi05/openpi_addon/hf_upload_ckpt.py",
      "pi05/test_hf_upload_ckpt.py",
      "provenance.py"
    ],
    "diff_sha256": "478ee7eb87bdbf01f433a39da88c4455b1c1394b24b002c572a4e8fbebf98984",
    "measured_at": "2026-11-01T10:53:41+09:00",
    "paths": [
      "lab_train.sh",
      "lab_lib.sh",
      "provenance.py",
      "pi05"
    ]
  },
  "external_code": [
    {
      "name": "openpi",
      "commit": "215abfb1234567890abcdef1234567890abcdef0",
      "dirty": true,
      "dirty_files": [
        "src/openpi/training/config.py",
        "src/openpi/policies/mecharm_policy.py"
      ],
      "expected_commit": "215abfb"
    }
  ],
  "inputs": [
    {
      "kind": "dataset",
      "id": "mecharm_redblock_box_teach_v1_trim2_corr_v3",
      "path": "datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3",
      "fingerprint": "files-v1:1c95b8708332ee7feae2149dbf568f952ecd777c4b692a75fa22dfb28e77de4f",
      "measured_at": "2026-11-01T10:53:41+09:00"
    },
    {
      "kind": "checkpoint",
      "id": "pi05_mecharm_trim_lora_v1/3999",
      "path": "checkpoints/pi05_mecharm_trim_lora_v1/3999",
      "fingerprint": "files-v1:262027fc8884bd5f617beb31a7a9dd3b976fde0d29b2bc67f80255658baf3ebd",
      "hf_repo": "PixelstoMotors/pi05-mecharm-trim-lora-v1",
      "pod_path": "/workspace/ckpt/3999/params",
      "measured_at": "2026-11-01T10:53:41+09:00"
    },
    {
      "kind": "config",
      "id": "add_trim_config.py",
      "path": "lab/Log11/pod/add_trim_config.py",
      "fingerprint": "files-v1:c30584c29dcad622e4e52198e916030969d63493af07e7422f1da598d1328149",
      "sha256": "807fd6eb050b197b8eb583dd51561ee7ee92727f7827143997c30edde88fc8ce",
      "measured_at": "2026-11-01T10:53:41+09:00"
    }
  ],
  "outputs": [
    {
      "kind": "checkpoint",
      "id": "pi05_mecharm_trim_corr_lora_v1/1499",
      "path": "checkpoints/pi05_mecharm_trim_corr_lora_v1/1499",
      "fingerprint": "files-v1:8071f2649c71456b44faa6ad4e50cc6902cfd41399be61e2065c4ecfa2064d90",
      "hf_repo": "PixelstoMotors/pi05-mecharm-trim-corr-lora-v1",
      "fingerprint_note": "train_provenance.json を置く前に測った"
    }
  ],
  "config": {
    "name": "pi05_mecharm_trim_corr_lora",
    "exp_name": "trim_corr_v3",
    "num_train_steps": 1500,
    "init_params": "/workspace/ckpt/3999/params",
    "data_name": "mecharm_redblock_box_teach_v1_trim2_corr_v3",
    "limit_hours": 5,
    "checkpoint_step": "1499"
  },
  "compute": {
    "provider": "runpod",
    "pod_id": "dryrunpod0001",
    "gpu": "NVIDIA A40",
    "started_at": "2026-11-01T10:53:41+09:00",
    "train_started_at": "2026-11-01T10:53:41+09:00",
    "ended_at": "2026-11-01T10:53:43+09:00",
    "duration_sec": 2,
    "cost_per_hr_usd": 0.44,
    "cost_usd": 0,
    "yen_per_usd": 158,
    "cost_line": "借りていた時間 0 分 2 秒 (0.00 時間) × 1時間 0.44 ドル = 約 0.00 ドル (約 0 円. 1ドル=158円)"
  },
  "numbering": {
    "series": "フィジカルAI研究室",
    "series_log": "Log18",
    "article_no": ""
  },
  "status": {
    "process": "completed",
    "task": "not_applicable"
  },
  "notes": {
    "hugging_face": "ok PixelstoMotors/pi05-mecharm-trim-corr-lora-v1 に 5 ファイルを上げ, 名前・バイト数が全部合うのを確かめた",
    "kioxia": "<DRY_ROOT>/data/checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 に置いた (5 ファイル 1084292 バイト. Pod 側と名前・バイト数が全部合った)"
  }
}
```

### review_request_20261101_train_provenance.md（Codex のレビュー1回目の依頼）

```markdown
# コードレビュー依頼：学習と checkpoint に由来（provenance）を自動で記録する（2026-11-01 依頼 54・段階①）

前提: 学習済みの checkpoint に、どのデータ・どのコード・どの設定で学習したかの記録がない（既存 5 件はどれも `_CHECKPOINT_METADATA` の custom_metadata が空）。これからの lab_train.sh の学習で、作った時点に機械が読める形で残す。共通の部品 provenance.py は、このあと ②データセットを作るとき・③実機の試行でも使い回す。
過去のレビュー: docs/review_result_20261031_lab_train_codex*.md・docs/review_result_20261101_model_select_train_fixes_codex*.md

対象（`git diff HEAD -- lab_lib.sh lab_train.sh lab_train_fakes.sh test_lab_train.sh pi05/openpi_addon/hf_upload_ckpt.py pi05/test_hf_upload_ckpt.py` と、新しいファイル `provenance.py`・`test_provenance.py`。ほかのファイルの未コミット差分は対象外）

## 1. provenance.py（r2r2r-scripts 直下）
- `fingerprint(path)` → `files-v1:<sha256>`。ファイルを相対パス（NFC・/ 区切り）の UTF-8 バイト順に並べ、①全部の「相対パス\tバイト数\n」②名前が .json で 1MB 未満のものの「\0相対パス\0」＋中身、の順に sha256。画像・動画・重みの中身は読まない。`._*`・`.cache`・`.incoming_*` は数えない。リンクはたどらない
- `code_info(repo, paths)`: commit・dirty・dirty_files（git にないファイルも数える）・diff_sha256（`git diff HEAD --binary` と、git にないファイルの名前と中身の sha256）。paths を渡すとそのパスの中だけを見る
- `build(spec, data_root, ...)`: schema_version provenance/1・created_at（ISO8601・タイムゾーンつき）・producer（スクリプト・引数・機械）・code・external_code・inputs/outputs（path は r2r2r-data からの相対・指紋を測る）・config・compute・numbering・status（process と task を分ける）・error
- 鍵: 引数の --token / --key / --api-key などの値・hf_ / rpa_ などで始まる鍵らしい文字を `<redacted>`。--secret-file の中身が記録のどこかに出れば置き換える
- `write(rec, path)`: O_EXCL で書く（あれば上書きしない）。フォルダは作らない
- CLI: `fingerprint <path>` / `write --data-root --out ... [--code-path ...] [--secret-file ...] [--repo-dir] < spec.json`（終了コード 0 全部置いた / 3 もとからあって飛ばした / 1 失敗）

## 2. lab_train.sh
- 3f のあと、Pod の openpi の `git rev-parse HEAD` と `git status --porcelain`（addon・設定を入れたあと）を LAB_TO_SHORT の ssh で読む。読めなくても続け、由来に「読めなかった」と残す
- finish（成功・失敗・止めた・借りられなかった・--stop のどれでも通る）の頭で prov_write: lab/<Log>/train/train_provenance.json（あれば dest_of で別名）に置く。今回 KIOXIA に新しく置けたチェックポイント（place_ckpt が最後まで行ったときの PROV_CKPT_DIR）にも置く。もとからあった 1499 には置かない
- Hugging Face: 7b が `HFUP ok`（今回上げた）のときだけ、KIOXIA へ取ったあとで Mac の `hf upload` で train_provenance.json を足す（`HFUP same` のときは足さない）。hf_upload_ckpt.py の比べと lab_train.sh の compare_with_pod では train_provenance.json を数えない（次の回・取り直しで「別の中身」にならないように）
- status: completed（学習が最後まで）/ interrupted（signal・--stop・上限）/ failed（ほか）。task は not_applicable。片付けの一部が失敗したときは completed のまま error に書く
- compute: GPU_USED・Pod の id・作った時刻・学習を始めた時刻・消した時刻・rented_sec・COST_PER_HR・cost_usd・[COST] の行そのもの
- 由来を書く処理は lab_timeout 300 で包み、失敗しても終了コード・通知は変えない。KIOXIA がない（LOGDIR がない）ときは書かない（フォルダを作り直さない）

## 3. テスト
- test_provenance.py 23 件（指紋の作り方そのもの・数えないもの・JSON の中身・NFD・読めないフォルダ・鍵を伏せる・git の clean/dirty/paths・上書きしない・フォルダを作らない・CLI）
- test_lab_train.sh 17 節ほか（成功・落ちた・上限・借りられなかった・same・由来だけ上げられない・openpi が読めない・KIOXIA が外れた・dirty/clean・--stop・TERM・もとからある 1499）。全部で OK 166 / NG 0
- test_hf_upload_ckpt.py に「リポジトリに train_provenance.json があっても same」を足した

## 見てほしい点
1. 由来を書く処理が、学習の片付け（Pod を消す・KIOXIA に置く・通知）を止める・遅らせる・終了コードを変える道がないか（set -u・pipefail・trap の中・cleanup の中の wrap_fail）
2. 鍵（Runpod・Hugging Face）・Pod の IP とポートが由来・Hugging Face に入る道がないか
3. KIOXIA の既存のもの（checkpoints・datasets・runs）に書き足す・上書きする道がないか。KIOXIA が外れたときにフォルダを作り直さないか
4. 指紋の決め方が、②データセット・③実機の試行で使い回すときに困る点（.DS_Store を数える・出力の指紋を由来を置く前に測ることなど）
5. Hugging Face に train_provenance.json を足すことで、lab_start.sh（--model corr の hf download）・hf_upload_ckpt.py・compare_with_pod が壊れないか
6. git の dirty の見方（--code-path で lab_train.sh・lab_lib.sh・provenance.py・pi05 に絞る）が妥当か

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。
```

### review_result_20261101_train_provenance_codex.md（Codex のレビュー1回目の結果）

```markdown
# codex レビュー結果 R1：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance.md（codex exec -s read-only）

**判定：必須5件、推奨4件です。** Pod削除より前に由来処理が入る経路は見当たりませんが、通知の遅延と、由来を誤って記録する経路があります。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| 由来処理全体の時間制限 | **必須** | [lab_train.sh:201](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:201) の300秒制限はPythonだけです。その後のHFアップロードには別途600秒あり、通知は合計約900秒＋終了待機分、遅れる可能性があります。`prov_spec`も制限外です。**spec生成・書込み・HF追加を含む全体**を300秒以内にし、失敗結果だけ親へ返す構成にしてください。 |
| `HFUP same`で過去の由来を取り込む | **必須** | [lab_train.sh:412](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:412) のダウンロードは、HFにある過去の`train_provenance.json`も取得します。比較からは除外されるため配置は成功し、今回の由来は`O_EXCL`で書けません。結果、**lab側は今回、checkpoint側は過去の由来**になります。ダウンロード対象から除外するか、今回作った一時フォルダ内で除去してください。既存1499には触れません。現行fakeはこのファイルを再現していません。 |
| 学習完了後の一覧取得失敗 | **必須** | [lab_train.sh:465](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:465) から`wrap_fail list`へ進むと、学習は完了済みでも`status.process=failed`になります。完了を検出した時点で記録し、その後の一覧取得・保存・削除の失敗では`completed`を維持して`error`へ追記してください。 |
| `--stop`時の学習設定 | **必須** | [lab_train.sh:516](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:516) ではGPU・時刻・EXPは復元しますが、STEPS・INIT・上限などは停止コマンドの既定値です。例えば`--steps 100`で開始し、`--stop`すると由来には1500と残ります。開始時の設定をSTATEへ保存して復元し、復元できない項目は不明として記録してください。 |
| openpiの部分取得をclean扱いする | **必須** | [lab_train.sh:658](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:658) はSSH終了コードを確認せず、コミット行だけあれば成功扱いします。コミット取得後に切断、または`git status`が失敗すると`dirty=false`になり得ます。リモート側でstatusの成功を確認し、最後の完了マーカーとSSH終了コードの両方を確認してください。不完全なら`dirty=null`とエラーを残します。 |
| コード・入力を測定する時点 | **推奨** | [lab_train.sh:201](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:201) では終了時のローカルコード・入力を測ります。学習中に編集すると、実際に送ったコードとは異なる由来になります。コードは実行開始／転送時、入力は利用前に測定して保持し、終了時は結果と費用を加える構成を推奨します。 |
| `.DS_Store`と由来自身の指紋 | **推奨** | [provenance.py:62](/Users/shimazueiichi/r2r2r-scripts/provenance.py:62) は仕様どおり`.DS_Store`を数えます。Finder操作だけでも指紋が変わり、由来配置後の再測定も保存済み指紋と一致しません。②③へ広げる前に、除外規則と「由来配置前を測る」検証手順を固定してください。規則を変更するなら指紋のバージョンも更新するのが適切です。 |
| 秘密情報の辞書キー | **推奨** | [provenance.py:175](/Users/shimazueiichi/r2r2r-scripts/provenance.py:175) の`_scrub`は値だけを処理し、辞書キーは伏せません。人工文字列によるメモリ内確認でも残りました。現在のlab用specではキーは固定ですが、共通部品の「記録のどこか」という契約に合わせ、キーも対象にしてください。 |
| 指紋・gitの境界テスト | **推奨** | NFC正規化後に同名になる別ファイル、リンク、タブ／改行を含む名前、gitのrename・staged変更が未検証です。特にNFC同名が共存する環境ではソート順が一意にならないため、衝突を検出してエラーにすることを推奨します。 |
| 終了コード・trap・cleanup | 据え置き | `set -e`はなく、通常の由来処理失敗は`finish`の保存済み`rc`を変更しません。`wrap_success`・`wrap_fail`ともPod削除を試みた後に`finish`へ進みます。追加部分に明白な未初期化変数参照は見当たりません。ただし時間制限は上記修正が必要です。 |
| 既存KIOXIA・未マウント時 | 据え置き | `PROV_CKPT_DIR`は新規配置と最終照合の成功後だけ設定され、既存1499には設定されません。`write`は`O_EXCL`、フォルダ作成なしです。由来処理自身がdatasets・runsへ書く経路も見当たりません。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のlab用specにIP・ポートを直接渡す箇所はありません。SSH由来の文字列は既存の`lab_mask`を通り、鍵は引数の伏字・既知プレフィックス・secret-file内容の置換対象です。任意のIPを除去する機能ではないため、将来の呼出側でも明示的に渡さない設計を維持してください。 |
| HF比較・`lab_start.sh`・dirty対象 | 据え置き | リモート比較と`compare_with_pod`のルート由来ファイル除外は整合しています。`lab_start.sh --model corr`の取得先にJSONが増えても、確認した起動経路に破綻は見当たりません。dirty対象の4パスも妥当です。ただし外部の`add_trim_config.py`は指紋が名前・サイズだけなので、内容SHA256の追加には価値があります。 |

追加テストでは、特に「過去の由来入りHFからのsame取得」「学習完了後の一覧取得失敗」「既定値以外で開始してからの`--stop`」「コミット行だけ返ってSSH失敗」を押さえてください。

シェル4ファイルとPython4ファイルの構文確認は成功しました。テスト本体は書込み・プロセス操作を伴うため実行しておらず、**OK 166／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須 1（全体の時間）: provenance.py write を 120 秒、Hugging Face へ足すのを 120 秒（LAB_HF_UP_TO）に。prov_spec は jq と date だけ。合わせて 240 秒まで
- 必須 2（same で前の回の由来）: place_ckpt で hf download のあと、自分の一時フォルダの train_provenance.json を消してから比べ・移す。偽の hf download（hf_same）に前の回の由来を置き、今回のものが置かれるのを確かめる
- 必須 3（学習が終わったあとの一覧で止まる）: wrap_success の頭で TRAIN_DONE=1. wrap_fail は TRAIN_DONE=1 なら completed のまま error に書く（list_empty で確かめ）
- 必須 4（--stop の設定）: lab_pod.json に init・limit_hours・prov_start を残し、--stop で steps・init・limit_hours・始めたときのコードと入力を戻す。記録にないものは空（null）
- 必須 5（openpi の途中で切れる）: Pod 側で git status まで通ったときだけ OPENPI_END を出し、ssh の終了コード 0 と両方そろったときだけ使う。そろわなければ commit・dirty は null。コマンドを Mac の bash で一時の git リポジトリに流して確かめる
- 推奨（測る時点）: 0. で provenance.py prepare によりコードと入力の指紋を測って持ち、終わったときはそれを使う（測れなければ終わったときに測る）
- 推奨（辞書のキー）: _scrub でキーも伏せる
- 推奨（境界）: NFC で同じ名前になる2つは ValueError（この Mac では同じ名前とみなされるので skip）、リンクはたどらない、git の rename・staged のテストを足す。config の入力（add_trim_config.py）には中身の sha256 も残す
- 据え置き（.DS_Store・由来自身の指紋）: 決まり（12節）の数えないものを変えることになるので、エイイチに決めてもらう
```

### review_request_20261101_train_provenance_r2.md（Codex のレビュー2回目の依頼）

```markdown
# コードレビュー依頼：学習と checkpoint に由来（provenance）を自動で記録する（2026-11-01 依頼 54・段階①）R2

前提: 学習済みの checkpoint に、どのデータ・どのコード・どの設定で学習したかの記録がない（既存 5 件はどれも `_CHECKPOINT_METADATA` の custom_metadata が空）。これからの lab_train.sh の学習で、作った時点に機械が読める形で残す。共通の部品 provenance.py は、このあと ②データセットを作るとき・③実機の試行でも使い回す。
過去のレビュー: docs/review_result_20261031_lab_train_codex*.md・docs/review_result_20261101_model_select_train_fixes_codex*.md

対象（`git diff HEAD -- lab_lib.sh lab_train.sh lab_train_fakes.sh test_lab_train.sh pi05/openpi_addon/hf_upload_ckpt.py pi05/test_hf_upload_ckpt.py` と、新しいファイル `provenance.py`・`test_provenance.py`。ほかのファイルの未コミット差分は対象外）

## 1. provenance.py（r2r2r-scripts 直下）
- `fingerprint(path)` → `files-v1:<sha256>`。ファイルを相対パス（NFC・/ 区切り）の UTF-8 バイト順に並べ、①全部の「相対パス\tバイト数\n」②名前が .json で 1MB 未満のものの「\0相対パス\0」＋中身、の順に sha256。画像・動画・重みの中身は読まない。`._*`・`.cache`・`.incoming_*` は数えない。リンクはたどらない
- `code_info(repo, paths)`: commit・dirty・dirty_files（git にないファイルも数える）・diff_sha256（`git diff HEAD --binary` と、git にないファイルの名前と中身の sha256）。paths を渡すとそのパスの中だけを見る
- `build(spec, data_root, ...)`: schema_version provenance/1・created_at（ISO8601・タイムゾーンつき）・producer（スクリプト・引数・機械）・code・external_code・inputs/outputs（path は r2r2r-data からの相対・指紋を測る）・config・compute・numbering・status（process と task を分ける）・error
- 鍵: 引数の --token / --key / --api-key などの値・hf_ / rpa_ などで始まる鍵らしい文字を `<redacted>`。--secret-file の中身が記録のどこかに出れば置き換える
- `write(rec, path)`: O_EXCL で書く（あれば上書きしない）。フォルダは作らない
- CLI: `fingerprint <path>` / `write --data-root --out ... [--code-path ...] [--secret-file ...] [--repo-dir] < spec.json`（終了コード 0 全部置いた / 3 もとからあって飛ばした / 1 失敗）

## 2. lab_train.sh
- 3f のあと、Pod の openpi の `git rev-parse HEAD` と `git status --porcelain`（addon・設定を入れたあと）を LAB_TO_SHORT の ssh で読む。読めなくても続け、由来に「読めなかった」と残す
- finish（成功・失敗・止めた・借りられなかった・--stop のどれでも通る）の頭で prov_write: lab/<Log>/train/train_provenance.json（あれば dest_of で別名）に置く。今回 KIOXIA に新しく置けたチェックポイント（place_ckpt が最後まで行ったときの PROV_CKPT_DIR）にも置く。もとからあった 1499 には置かない
- Hugging Face: 7b が `HFUP ok`（今回上げた）のときだけ、KIOXIA へ取ったあとで Mac の `hf upload` で train_provenance.json を足す（`HFUP same` のときは足さない）。hf_upload_ckpt.py の比べと lab_train.sh の compare_with_pod では train_provenance.json を数えない（次の回・取り直しで「別の中身」にならないように）
- status: completed（学習が最後まで）/ interrupted（signal・--stop・上限）/ failed（ほか）。task は not_applicable。片付けの一部が失敗したときは completed のまま error に書く
- compute: GPU_USED・Pod の id・作った時刻・学習を始めた時刻・消した時刻・rented_sec・COST_PER_HR・cost_usd・[COST] の行そのもの
- 由来を書く処理は lab_timeout 300 で包み、失敗しても終了コード・通知は変えない。KIOXIA がない（LOGDIR がない）ときは書かない（フォルダを作り直さない）

## 3. テスト
- test_provenance.py 23 件（指紋の作り方そのもの・数えないもの・JSON の中身・NFD・読めないフォルダ・鍵を伏せる・git の clean/dirty/paths・上書きしない・フォルダを作らない・CLI）
- test_lab_train.sh 17 節ほか（成功・落ちた・上限・借りられなかった・same・由来だけ上げられない・openpi が読めない・KIOXIA が外れた・dirty/clean・--stop・TERM・もとからある 1499）。全部で OK 166 / NG 0
- test_hf_upload_ckpt.py に「リポジトリに train_provenance.json があっても same」を足した

## 見てほしい点
1. 由来を書く処理が、学習の片付け（Pod を消す・KIOXIA に置く・通知）を止める・遅らせる・終了コードを変える道がないか（set -u・pipefail・trap の中・cleanup の中の wrap_fail）
2. 鍵（Runpod・Hugging Face）・Pod の IP とポートが由来・Hugging Face に入る道がないか
3. KIOXIA の既存のもの（checkpoints・datasets・runs）に書き足す・上書きする道がないか。KIOXIA が外れたときにフォルダを作り直さないか
4. 指紋の決め方が、②データセット・③実機の試行で使い回すときに困る点（.DS_Store を数える・出力の指紋を由来を置く前に測ることなど）
5. Hugging Face に train_provenance.json を足すことで、lab_start.sh（--model corr の hf download）・hf_upload_ckpt.py・compare_with_pod が壊れないか
6. git の dirty の見方（--code-path で lab_train.sh・lab_lib.sh・provenance.py・pi05 に絞る）が妥当か

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。

## R2 で見てほしいこと
R1 の結果と反映は docs/review_result_20261101_train_provenance_codex.md の「反映」. 反映した9点が直っているか、直したことで新しい問題（--stop の戻し・prepare の失敗時・時間の上限・TRAIN_DONE で completed にする範囲）が出ていないか。テストは test_provenance.py 27 件（1 件は skip）・test_lab_train.sh OK 173 / NG 0。
```

### review_result_20261101_train_provenance_codex_r2.md（Codex のレビュー2回目の結果）

```markdown
# codex レビュー結果 R2：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r2.md（codex exec -s read-only）

**判定：必須3件、推奨2件です。** R1の主な不具合は修正されていますが、時間上限と、開始時に取得できなかった情報の扱いには修正が必要です。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| 由来処理全体の時間上限 | **必須・R1未完了** | [lab_train.sh:193](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:193) の`prov_write`全体には上限がありません。PythonとHFを120秒ずつにしても、`prov_spec`によるSTATE読込み・jq・dateなどは制限外です。また[lab_lib.sh:525](/Users/shimazueiichi/r2r2r-scripts/lab_lib.sh:525) は`LAB_HF_UP_TO`で120秒を超えられます。「合計240秒まで」は保証されません。spec生成・書込み・HF追加を含む子処理全体を300秒で包み、結果だけ親へ返してください。Pod削除は先に実行されますが、通知が待たされる問題は残ります。 |
| prepareで指紋取得に失敗した入力 | **必須・追加による問題** | [provenance.py:231](/Users/shimazueiichi/r2r2r-scripts/provenance.py:231) は`fingerprint=null`なら終了時の`build`でも再測定します。一方、prepareで付けた`measured_at`と`fingerprint_error`は保持されます。開始時に読めず終了時に読めた場合、**終了時の指紋に開始時の測定時刻と過去のエラーが付く**記録になります。取得済みスナップショットは失敗も含めて固定するか、再測定時に時刻・エラーを更新し、開始時の測定ではないことを明記してください。 |
| `--stop`で開始情報がない場合 | **必須・R1部分修正** | [lab_train.sh:543](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:543) でsteps・init・上限・保存済み`prov_start`の復元は直っています。ただし旧STATEやprepare失敗では`prov_start`が空になり、`prov_spec`と`build`が**停止時のコード・入力を新規測定**します。「記録にないものは分からない」というコメントと一致しません。開始時情報を取得できなかった旨を残し、停止時測定は別項目にするか省略してください。 |
| `--stop`でopenpi情報が消える | **推奨** | [lab_train.sh:686](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:686) で取得した`OPENPI_JSON`はSTATEに保存されません。別プロセスの`--stop`では初期値`[]`となり、取得済みのcommit・dirtyも失われます。取得後にSTATEへ保存して復元し、旧STATEでは「不明」を明示してください。 |
| prepare関連の回帰テスト | **推奨** | 追加テストは正常なスナップショット復元を確認していますが、上記の失敗経路は押さえていません。「prepare全体の失敗」「入力単位の指紋取得失敗→終了時に復旧」「旧STATEでのstop」「HF時間上限の環境変数指定」を追加すると有効です。 |
| `HFUP same`で過去の由来が混入 | **据え置き・R1修正済み** | [lab_train.sh:439](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:439) で今回作成した一時フォルダ内の旧由来を除去しています。既存1499には触れず、今回の由来を配置する流れです。fakeと比較テストも追加されています。 |
| `TRAIN_DONE`によるcompletedの範囲 | **据え置き・R1修正済み** | `wrap_success`は終了コード0・最終step一致・完了メタデータありを確認してから呼ばれ、その先頭で`TRAIN_DONE=1`になります。準備失敗をcompletedにする経路は見当たりません。完了後の一覧取得失敗はcompleted＋errorになります。 |
| openpiの部分取得 | **据え置き・R1修正済み** | commit・status両方の成功後に完了マーカーを出し、SSH終了コードも確認しています。途中切断をcleanとする元の問題は解消しています。 |
| 辞書キー・設定内容・境界処理 | **据え置き・R1修正済み** | 辞書キーも伏字対象になり、configの内容SHA256、NFC衝突検出、リンク・staged・renameのテストが追加されています。 |
| 終了コード・trap・Pod削除 | **据え置き** | `finish`は終了コードをローカル変数に保存し、`set -e`もないため、通常の由来処理失敗で終了コードは変わりません。成功・失敗ともPod削除を試みた後に由来処理へ進みます。追加箇所に明白な`set -u`違反は見当たりません。時間上限は上記の修正が必要です。 |
| 鍵・PodのIP／ポート | **据え置き** | 現在のspecにIP・ポートを直接渡す経路は見当たりません。鍵は引数・既知プレフィックス・secret-fileの内容・辞書キーまで伏字対象です。現在の呼出経路で明白な漏えいは確認していません。 |
| 既存KIOXIA・未マウント時 | **据え置き** | `PROV_CKPT_DIR`は新規配置の最終照合成功後だけ設定されます。由来書込みは`O_EXCL`、フォルダ作成なし。由来処理から既存checkpoint・datasets・runsへ追記する通常経路は見当たりません。 |
| HF互換性・dirty対象 | **据え置き** | リモート比較とKIOXIA比較の由来除外は整合しています。`lab_start.sh`の取得・利用経路にも、JSON追加による破綻は見当たりません。dirtyを指定4パスに絞る方針も妥当です。 |
| `.DS_Store`・由来配置前の指紋 | **据え置き・仕様判断待ち** | 現行files-v1の仕様どおりです。Finder操作や由来配置後に再測定すると一致しないため、②③へ展開する前に検証手順を固定してください。除外規則を変える場合は指紋バージョンも更新するのが適切です。 |

読み取りによるレビューです。テスト本体は書込み・プロセス操作を伴うため実行しておらず、**27件（1 skip）・OK 173／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須（全体の時間）: prov_write の中身（spec・書く・Hugging Face）を子のシェルで流し、全体を PROV_TO=300 秒で待つ。過ぎたら子孫ごと TERM→KILL して通知へ進む。LAB_HF_UP_TO はやめ 120 秒に固定。Hugging Face が返らない偽物（hf_prov_hang・LAB_PROV_TO=5）で確かめる
- 必須（prepare で読めなかった入力）: prepare で測ったもの（measured_at がある）は、指紋が null でも build で測り直さない（config の sha256 も）。test_provenance.py に足す
- 必須（--stop で始めたときの記録がない）: 止めた今のコード・入力を測らず、code.commit・入力の指紋を null にして「始めたときの記録がない (--stop)」と書く
- 推奨（--stop の openpi）: 読んだ openpi を lab_pod.json に残し、--stop で戻す。記録になければ「記録にない (--stop)」
- 推奨（テスト）: 上の4つのテストを足した（test_provenance.py 28 件・test_lab_train.sh OK 177 / NG 0）
```

### review_request_20261101_train_provenance_r3.md（Codex のレビュー3回目の依頼）

```markdown
# コードレビュー依頼：学習と checkpoint に由来（provenance）を自動で記録する（2026-11-01 依頼 54・段階①）R3

前提: 学習済みの checkpoint に、どのデータ・どのコード・どの設定で学習したかの記録がない（既存 5 件はどれも `_CHECKPOINT_METADATA` の custom_metadata が空）。これからの lab_train.sh の学習で、作った時点に機械が読める形で残す。共通の部品 provenance.py は、このあと ②データセットを作るとき・③実機の試行でも使い回す。
過去のレビュー: docs/review_result_20261031_lab_train_codex*.md・docs/review_result_20261101_model_select_train_fixes_codex*.md

対象（`git diff HEAD -- lab_lib.sh lab_train.sh lab_train_fakes.sh test_lab_train.sh pi05/openpi_addon/hf_upload_ckpt.py pi05/test_hf_upload_ckpt.py` と、新しいファイル `provenance.py`・`test_provenance.py`。ほかのファイルの未コミット差分は対象外）

## 1. provenance.py（r2r2r-scripts 直下）
- `fingerprint(path)` → `files-v1:<sha256>`。ファイルを相対パス（NFC・/ 区切り）の UTF-8 バイト順に並べ、①全部の「相対パス\tバイト数\n」②名前が .json で 1MB 未満のものの「\0相対パス\0」＋中身、の順に sha256。画像・動画・重みの中身は読まない。`._*`・`.cache`・`.incoming_*` は数えない。リンクはたどらない
- `code_info(repo, paths)`: commit・dirty・dirty_files（git にないファイルも数える）・diff_sha256（`git diff HEAD --binary` と、git にないファイルの名前と中身の sha256）。paths を渡すとそのパスの中だけを見る
- `build(spec, data_root, ...)`: schema_version provenance/1・created_at（ISO8601・タイムゾーンつき）・producer（スクリプト・引数・機械）・code・external_code・inputs/outputs（path は r2r2r-data からの相対・指紋を測る）・config・compute・numbering・status（process と task を分ける）・error
- 鍵: 引数の --token / --key / --api-key などの値・hf_ / rpa_ などで始まる鍵らしい文字を `<redacted>`。--secret-file の中身が記録のどこかに出れば置き換える
- `write(rec, path)`: O_EXCL で書く（あれば上書きしない）。フォルダは作らない
- CLI: `fingerprint <path>` / `write --data-root --out ... [--code-path ...] [--secret-file ...] [--repo-dir] < spec.json`（終了コード 0 全部置いた / 3 もとからあって飛ばした / 1 失敗）

## 2. lab_train.sh
- 3f のあと、Pod の openpi の `git rev-parse HEAD` と `git status --porcelain`（addon・設定を入れたあと）を LAB_TO_SHORT の ssh で読む。読めなくても続け、由来に「読めなかった」と残す
- finish（成功・失敗・止めた・借りられなかった・--stop のどれでも通る）の頭で prov_write: lab/<Log>/train/train_provenance.json（あれば dest_of で別名）に置く。今回 KIOXIA に新しく置けたチェックポイント（place_ckpt が最後まで行ったときの PROV_CKPT_DIR）にも置く。もとからあった 1499 には置かない
- Hugging Face: 7b が `HFUP ok`（今回上げた）のときだけ、KIOXIA へ取ったあとで Mac の `hf upload` で train_provenance.json を足す（`HFUP same` のときは足さない）。hf_upload_ckpt.py の比べと lab_train.sh の compare_with_pod では train_provenance.json を数えない（次の回・取り直しで「別の中身」にならないように）
- status: completed（学習が最後まで）/ interrupted（signal・--stop・上限）/ failed（ほか）。task は not_applicable。片付けの一部が失敗したときは completed のまま error に書く
- compute: GPU_USED・Pod の id・作った時刻・学習を始めた時刻・消した時刻・rented_sec・COST_PER_HR・cost_usd・[COST] の行そのもの
- 由来を書く処理は lab_timeout 300 で包み、失敗しても終了コード・通知は変えない。KIOXIA がない（LOGDIR がない）ときは書かない（フォルダを作り直さない）

## 3. テスト
- test_provenance.py 23 件（指紋の作り方そのもの・数えないもの・JSON の中身・NFD・読めないフォルダ・鍵を伏せる・git の clean/dirty/paths・上書きしない・フォルダを作らない・CLI）
- test_lab_train.sh 17 節ほか（成功・落ちた・上限・借りられなかった・same・由来だけ上げられない・openpi が読めない・KIOXIA が外れた・dirty/clean・--stop・TERM・もとからある 1499）。全部で OK 166 / NG 0
- test_hf_upload_ckpt.py に「リポジトリに train_provenance.json があっても same」を足した

## 見てほしい点
1. 由来を書く処理が、学習の片付け（Pod を消す・KIOXIA に置く・通知）を止める・遅らせる・終了コードを変える道がないか（set -u・pipefail・trap の中・cleanup の中の wrap_fail）
2. 鍵（Runpod・Hugging Face）・Pod の IP とポートが由来・Hugging Face に入る道がないか
3. KIOXIA の既存のもの（checkpoints・datasets・runs）に書き足す・上書きする道がないか。KIOXIA が外れたときにフォルダを作り直さないか
4. 指紋の決め方が、②データセット・③実機の試行で使い回すときに困る点（.DS_Store を数える・出力の指紋を由来を置く前に測ることなど）
5. Hugging Face に train_provenance.json を足すことで、lab_start.sh（--model corr の hf download）・hf_upload_ckpt.py・compare_with_pod が壊れないか
6. git の dirty の見方（--code-path で lab_train.sh・lab_lib.sh・provenance.py・pi05 に絞る）が妥当か

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。

## R3 で見てほしいこと
R1・R2 の結果と反映は docs/review_result_20261101_train_provenance_codex.md・docs/review_result_20261101_train_provenance_codex_r2.md の「反映」。R2 で反映した5点が直っているか、新しい問題（子のシェルで流すことによる trap・Ctrl-C・RESULT_LINES の受け渡し・kill_tree が自分以外を止める道）が出ていないか。テストは test_provenance.py 28 件（1 件 skip）・test_lab_train.sh OK 177 / NG 0。
```

### review_result_20261101_train_provenance_codex_r3.md（Codex のレビュー3回目の結果）

```markdown
# codex レビュー結果 R3：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r3.md（codex exec -s read-only）

**判定：必須1件、推奨2件です。** R2の5点は反映されていますが、全体タイムアウトで新設された停止処理には、子孫の取りこぼしと無関係なプロセスへの誤送信の可能性が残っています。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| 時間切れ時の `kill_tree` | **必須** | [lab_train.sh:206](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:206) は、動いている子孫を一度列挙し、TERM→2秒後に同じPID一覧へKILLします。**走査後に生まれた子孫は取りこぼし、終了後に再利用されたPIDには無関係なプロセスでもKILLを送ります。** また、再帰的な走査自体には時間制限がありません。既存の [lab_lib.sh:80](/Users/shimazueiichi/r2r2r-scripts/lab_lib.sh:80) の停止方式に合わせ、生成を止めながら列挙し、プロセスの同一性を確認してから残存分を停止する構成にしてください。 |
| 子シェル化の回帰テスト | **推奨** | [test_lab_train.sh:410](/Users/shimazueiichi/r2r2r-scripts/test_lab_train.sh:410) は単純なHFハングを確認していますが、新しい停止処理の競合は押さえていません。「子孫を継続生成」「TERM中に子孫を生成」「由来処理中のINT・TERM」を追加してください。既存の見守り中TERMテストとは別経路です。 |
| 強制終了途中の由来ファイル | **推奨** | [provenance.py:307](/Users/shimazueiichi/r2r2r-scripts/provenance.py:307) は出力先へ直接書きます。KILLされると例外処理を通らず、不完全なJSONが残り、次回はO_EXCLでスキップされます。②③で再利用する前に、一時ファイルへ完成させてから上書きなしで公開する方式を検討してください。 |
| R2：全体の時間上限 | **据え置き・部分修正済み** | spec・Python・HF追加は子シェル全体の監視対象になり、HF内部も120秒固定です。ただし上記の停止処理があるため、終了・子孫停止までの保証は未完了です。 |
| R2：prepare失敗入力の固定 | **据え置き・修正済み** | [provenance.py:228](/Users/shimazueiichi/r2r2r-scripts/provenance.py:228) は `measured_at` の存在で再測定を防ぎ、指紋がnullの場合も開始時の失敗記録を保持します。configのSHA256も同様です。 |
| R2：旧STATEでの `--stop` | **据え置き・修正済み** | [lab_train.sh:170](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:170) は開始時情報を不明として記録します。停止時のコード・入力へ置き換わる経路は解消しています。 |
| R2：openpi保存・復元 | **据え置き・修正済み** | STATEへの保存と `--stop` での復元があります。旧STATEでは取得記録がない旨を残します。関連テストも追加されています。 |
| trap・cleanup・終了コード | **据え置き** | `finish` は先に `FINISHED=1` とし、終了コードをローカル変数に保持します。通常の子処理失敗でPod削除を再実行したり、保存した終了コードを変更したりする経路は見当たりません。Pod削除の試行は由来処理より先です。 |
| `RESULT_LINES` の受け渡し | **据え置き** | [lab_train.sh:229](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:229) で親が `PROVLINE` を読み、親自身の `RESULT_LINES` に追記しています。子シェル内の変数変更が消える問題はありません。 |
| 鍵・PodのIP／ポート | **据え置き** | 現在のspecにIP・ポートを直接渡す経路は見当たりません。鍵は引数・既知プレフィックス・secret-file内容・辞書キーの伏字対象です。任意のIPを除去する機能ではない点は従来どおりです。 |
| 既存KIOXIA・未マウント時 | **据え置き** | checkpointへの追記先は新規配置の最終照合成功後だけ設定され、既存1499には設定されません。由来処理はフォルダを作らず、O_EXCLで上書きを防ぎます。 |
| HF互換性・dirty対象 | **据え置き** | HF側・KIOXIA側の比較からルートの由来JSONを除外する処理は整合しています。`lab_start.sh` の利用経路にも破綻は見当たりません。指定4パスへのdirty限定も妥当です。 |
| `.DS_Store`・配置前の出力指紋 | **据え置き・仕様判断待ち** | 現行仕様どおりです。②③への展開時には、Finder操作と由来配置による再測定不一致を考慮した検証手順が必要です。除外規則を変える場合は指紋バージョンも更新してください。 |

読み取りレビューとシェル4ファイルの構文確認を行いました。テスト本体は一時ファイル作成・プロセス操作を伴うため実行しておらず、**28件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須（kill_tree）: 由来を書く子を set -m で自分のプロセスグループにして流し、時間切れのときはグループごと TERM、2 秒のうちに誰か残っていれば KILL（kill_group）。たどったあとに生まれた孫も取りこぼさず、番号の使い回しはグループの全員が終わったあとにしか起きない（KILL の前に pgrep -g で残りを見る）
- 推奨（テスト）: 偽物の hf_prov_hang を「TERM を無視して孫を次々に作る」に変え、打ち切って通知へ進み、孫（sleep 61）が残らないのを確かめる。由来を書く途中の INT・TERM は、wrap の中では trap '' で無視、early_fail の中では親の on_signal → finish（PROV_DONE=1 で2回目は書かない）となり、子は中の 120 秒の上限で終わる
- 推奨（書きかけ）: provenance.write を、同じフォルダの一時ファイルに書き終えてから link で置く形に（exFAT で link できなければ、ないのを確かめてから rename）。一時ファイルは残さない。test_provenance.py に link できない場合を足す（29 件・1 件 skip）
```

### review_request_20261101_train_provenance_r4.md（Codex のレビュー4回目の依頼）

```markdown
# コードレビュー依頼：学習と checkpoint に由来（provenance）を自動で記録する（2026-11-01 依頼 54・段階①）R4

前提: 学習済みの checkpoint に、どのデータ・どのコード・どの設定で学習したかの記録がない（既存 5 件はどれも `_CHECKPOINT_METADATA` の custom_metadata が空）。これからの lab_train.sh の学習で、作った時点に機械が読める形で残す。共通の部品 provenance.py は、このあと ②データセットを作るとき・③実機の試行でも使い回す。
過去のレビュー: docs/review_result_20261031_lab_train_codex*.md・docs/review_result_20261101_model_select_train_fixes_codex*.md

対象（`git diff HEAD -- lab_lib.sh lab_train.sh lab_train_fakes.sh test_lab_train.sh pi05/openpi_addon/hf_upload_ckpt.py pi05/test_hf_upload_ckpt.py` と、新しいファイル `provenance.py`・`test_provenance.py`。ほかのファイルの未コミット差分は対象外）

## 1. provenance.py（r2r2r-scripts 直下）
- `fingerprint(path)` → `files-v1:<sha256>`。ファイルを相対パス（NFC・/ 区切り）の UTF-8 バイト順に並べ、①全部の「相対パス\tバイト数\n」②名前が .json で 1MB 未満のものの「\0相対パス\0」＋中身、の順に sha256。画像・動画・重みの中身は読まない。`._*`・`.cache`・`.incoming_*` は数えない。リンクはたどらない
- `code_info(repo, paths)`: commit・dirty・dirty_files（git にないファイルも数える）・diff_sha256（`git diff HEAD --binary` と、git にないファイルの名前と中身の sha256）。paths を渡すとそのパスの中だけを見る
- `build(spec, data_root, ...)`: schema_version provenance/1・created_at（ISO8601・タイムゾーンつき）・producer（スクリプト・引数・機械）・code・external_code・inputs/outputs（path は r2r2r-data からの相対・指紋を測る）・config・compute・numbering・status（process と task を分ける）・error
- 鍵: 引数の --token / --key / --api-key などの値・hf_ / rpa_ などで始まる鍵らしい文字を `<redacted>`。--secret-file の中身が記録のどこかに出れば置き換える
- `write(rec, path)`: O_EXCL で書く（あれば上書きしない）。フォルダは作らない
- CLI: `fingerprint <path>` / `write --data-root --out ... [--code-path ...] [--secret-file ...] [--repo-dir] < spec.json`（終了コード 0 全部置いた / 3 もとからあって飛ばした / 1 失敗）

## 2. lab_train.sh
- 3f のあと、Pod の openpi の `git rev-parse HEAD` と `git status --porcelain`（addon・設定を入れたあと）を LAB_TO_SHORT の ssh で読む。読めなくても続け、由来に「読めなかった」と残す
- finish（成功・失敗・止めた・借りられなかった・--stop のどれでも通る）の頭で prov_write: lab/<Log>/train/train_provenance.json（あれば dest_of で別名）に置く。今回 KIOXIA に新しく置けたチェックポイント（place_ckpt が最後まで行ったときの PROV_CKPT_DIR）にも置く。もとからあった 1499 には置かない
- Hugging Face: 7b が `HFUP ok`（今回上げた）のときだけ、KIOXIA へ取ったあとで Mac の `hf upload` で train_provenance.json を足す（`HFUP same` のときは足さない）。hf_upload_ckpt.py の比べと lab_train.sh の compare_with_pod では train_provenance.json を数えない（次の回・取り直しで「別の中身」にならないように）
- status: completed（学習が最後まで）/ interrupted（signal・--stop・上限）/ failed（ほか）。task は not_applicable。片付けの一部が失敗したときは completed のまま error に書く
- compute: GPU_USED・Pod の id・作った時刻・学習を始めた時刻・消した時刻・rented_sec・COST_PER_HR・cost_usd・[COST] の行そのもの
- 由来を書く処理は lab_timeout 300 で包み、失敗しても終了コード・通知は変えない。KIOXIA がない（LOGDIR がない）ときは書かない（フォルダを作り直さない）

## 3. テスト
- test_provenance.py 23 件（指紋の作り方そのもの・数えないもの・JSON の中身・NFD・読めないフォルダ・鍵を伏せる・git の clean/dirty/paths・上書きしない・フォルダを作らない・CLI）
- test_lab_train.sh 17 節ほか（成功・落ちた・上限・借りられなかった・same・由来だけ上げられない・openpi が読めない・KIOXIA が外れた・dirty/clean・--stop・TERM・もとからある 1499）。全部で OK 166 / NG 0
- test_hf_upload_ckpt.py に「リポジトリに train_provenance.json があっても same」を足した

## 見てほしい点
1. 由来を書く処理が、学習の片付け（Pod を消す・KIOXIA に置く・通知）を止める・遅らせる・終了コードを変える道がないか（set -u・pipefail・trap の中・cleanup の中の wrap_fail）
2. 鍵（Runpod・Hugging Face）・Pod の IP とポートが由来・Hugging Face に入る道がないか
3. KIOXIA の既存のもの（checkpoints・datasets・runs）に書き足す・上書きする道がないか。KIOXIA が外れたときにフォルダを作り直さないか
4. 指紋の決め方が、②データセット・③実機の試行で使い回すときに困る点（.DS_Store を数える・出力の指紋を由来を置く前に測ることなど）
5. Hugging Face に train_provenance.json を足すことで、lab_start.sh（--model corr の hf download）・hf_upload_ckpt.py・compare_with_pod が壊れないか
6. git の dirty の見方（--code-path で lab_train.sh・lab_lib.sh・provenance.py・pi05 に絞る）が妥当か

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。

## R4 で見てほしいこと
R1〜R3 の結果と反映は docs/review_result_20261101_train_provenance_codex*.md の「反映」。R3 で反映した3点（kill_group と set -m・hf_prov_hang のテスト・provenance.write の一時ファイル→link/rename）が直っているか、新しい問題（set -m がほかの裏のジョブ・trap・端末に与える影響、link/rename の競合）が出ていないか。テストは test_provenance.py 29 件（1 件 skip）・test_lab_train.sh OK 177 / NG 0。
```

### review_result_20261101_train_provenance_codex_r4.md（Codex のレビュー4回目の結果）

```markdown
# codex レビュー結果 R4：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r4.md（codex exec -s read-only）

**判定：必須1件、推奨2件です。** R3のグループ停止は改善されています。由来の公開処理は、exFAT向けフォールバックに上書き競合が残っています。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| exFATでの`rename`競合 | **必須** | [provenance.py:325](/Users/shimazueiichi/r2r2r-scripts/provenance.py:325) は`lexists(path)`確認後に`os.rename(tmp, path)`します。その間に別の実行が同名ファイルを作ると、**既存ファイルを上書きします**。KIOXIAで使う経路なので、「上書きしない」という契約を満たしていません。上書き禁止を原子的に保証する公開方式へ変更してください。対応できない環境では失敗として返す方が安全です。テストには、存在確認後・rename直前に別の書込みが入るケースを追加してください。 |
| 強制終了時の一時ファイル | **推奨** | [provenance.py:313](/Users/shimazueiichi/r2r2r-scripts/provenance.py:313) の一時ファイルは、KILLされると`finally`で消せません。完成先に不完全JSONが残る問題は改善しましたが、「一時ファイルは残さない」は強制終了時には成立しません。また、この名前は`fingerprint()`の除外対象ではなく、以後の指紋に混入します。残存時の扱いと指紋からの除外方針を定めてください。 |
| ハング・signalテスト | **推奨** | [test_lab_train.sh:410](/Users/shimazueiichi/r2r2r-scripts/test_lab_train.sh:410) は、TERMを無視して孫を継続生成するケースへ改善されています。ただし時間の判定は`run`が戻った後なので、本当に戻らない回帰ではテストも停止します。外側にも監視を置き、さらに**由来処理中のINT・TERM**を追加してください。`early_fail`から入る場合は、wrap内のsignal無視とは異なる経路です。 |
| `kill_group`と`set -m` | 据え置き・R3改善確認 | [lab_train.sh:206](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:206) はグループ単位で停止するため、列挙後に生まれた同グループの孫も対象になります。このMacのBashで、内側のパイプライン・非同期処理が同グループに残ることを確認しました。既存のtee・caffeinateを対象グループへ移す処理もありません。由来処理は端末入力を必要とせず、明白な端末アクセスの回帰は見当たりません。 |
| linkによる公開 | 据え置き・R3修正確認 | [provenance.py:322](/Users/shimazueiichi/r2r2r-scripts/provenance.py:322) のlink成功経路は、完成したJSONを上書きなしで公開できます。同名への競合は`FileExistsError`になります。問題は上記のrenameフォールバックです。 |
| 片付け・通知・終了コード | 据え置き | Pod削除の試行は由来処理より先です。spec生成・Python・HF追加は全体監視の内側にあり、通常の失敗で保存済み終了コードを変える経路や、追加部分の明白な`set -u`違反は見当たりません。時間切れ時は300秒に加えて停止猶予などがかかります。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のspecへIP・ポートを直接渡す経路は見当たりません。引数、既知の鍵形式、secret-fileの内容、辞書キーへの伏字処理も維持されています。 |
| 既存KIOXIA・未マウント時 | 据え置き・競合は必須欄 | `PROV_CKPT_DIR`は新規配置の最終照合成功後だけ設定され、既存1499には設定されません。由来処理自身はフォルダを作りません。datasets・runsへ書き足す通常経路も見当たりません。 |
| HF・`lab_start.sh`互換性 | 据え置き | HF側とKIOXIA側の比較除外は整合しています。`same`取得時の過去の由来除去も維持されています。`lab_start.sh --model corr`の取得・利用経路に、JSON追加による破綻は見当たりません。 |
| 指紋・dirty対象 | 据え置き | 指定4パスへのdirty限定と開始時の測定は妥当です。`.DS_Store`を数えること、出力指紋が由来配置前の値であることは現行仕様どおりです。②③では、その前提に合う再検証手順が必要です。 |

シェル4ファイル・Python4ファイルの構文確認は成功しました。テスト本体は書込み・プロセス停止を伴うため実行しておらず、**29件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須（exFAT の rename の競合）: 一時ファイル→link/rename をやめ、置く先を O_EXCL で直に作って1回の write（数 KB）で書く形に戻した。あれば必ず失敗するので上書きはしない（exFAT でも同じ）。書けなければ消す。test_provenance.py に「作る直前に別の実行が置いた」「書けなかった」を足す
- 推奨（一時ファイル）: 一時ファイルを作らなくなったので、残って指紋に入ることはない。残るのは「1回の write の途中で KILL された」ときの書きかけだけ（数 KB・据え置き）
- 推奨（テスト）: hf_prov_hang の回を外側でも 90 秒で止める見張りを置いた。early_fail の中で由来を書いている途中の INT・TERM は、Pod を借りる前で Hugging Face へは足さない（python の 120 秒の上限）ので、テストは足さず据え置き
```

### review_request_20261101_train_provenance_r5.md（Codex のレビュー5回目の依頼）

```markdown
# コードレビュー依頼：学習と checkpoint に由来（provenance）を自動で記録する（2026-11-01 依頼 54・段階①）R5

前提: 学習済みの checkpoint に、どのデータ・どのコード・どの設定で学習したかの記録がない（既存 5 件はどれも `_CHECKPOINT_METADATA` の custom_metadata が空）。これからの lab_train.sh の学習で、作った時点に機械が読める形で残す。共通の部品 provenance.py は、このあと ②データセットを作るとき・③実機の試行でも使い回す。
過去のレビュー: docs/review_result_20261031_lab_train_codex*.md・docs/review_result_20261101_model_select_train_fixes_codex*.md

対象（`git diff HEAD -- lab_lib.sh lab_train.sh lab_train_fakes.sh test_lab_train.sh pi05/openpi_addon/hf_upload_ckpt.py pi05/test_hf_upload_ckpt.py` と、新しいファイル `provenance.py`・`test_provenance.py`。ほかのファイルの未コミット差分は対象外）

## 1. provenance.py（r2r2r-scripts 直下）
- `fingerprint(path)` → `files-v1:<sha256>`。ファイルを相対パス（NFC・/ 区切り）の UTF-8 バイト順に並べ、①全部の「相対パス\tバイト数\n」②名前が .json で 1MB 未満のものの「\0相対パス\0」＋中身、の順に sha256。画像・動画・重みの中身は読まない。`._*`・`.cache`・`.incoming_*` は数えない。リンクはたどらない
- `code_info(repo, paths)`: commit・dirty・dirty_files（git にないファイルも数える）・diff_sha256（`git diff HEAD --binary` と、git にないファイルの名前と中身の sha256）。paths を渡すとそのパスの中だけを見る
- `build(spec, data_root, ...)`: schema_version provenance/1・created_at（ISO8601・タイムゾーンつき）・producer（スクリプト・引数・機械）・code・external_code・inputs/outputs（path は r2r2r-data からの相対・指紋を測る）・config・compute・numbering・status（process と task を分ける）・error
- 鍵: 引数の --token / --key / --api-key などの値・hf_ / rpa_ などで始まる鍵らしい文字を `<redacted>`。--secret-file の中身が記録のどこかに出れば置き換える
- `write(rec, path)`: O_EXCL で書く（あれば上書きしない）。フォルダは作らない
- CLI: `fingerprint <path>` / `write --data-root --out ... [--code-path ...] [--secret-file ...] [--repo-dir] < spec.json`（終了コード 0 全部置いた / 3 もとからあって飛ばした / 1 失敗）

## 2. lab_train.sh
- 3f のあと、Pod の openpi の `git rev-parse HEAD` と `git status --porcelain`（addon・設定を入れたあと）を LAB_TO_SHORT の ssh で読む。読めなくても続け、由来に「読めなかった」と残す
- finish（成功・失敗・止めた・借りられなかった・--stop のどれでも通る）の頭で prov_write: lab/<Log>/train/train_provenance.json（あれば dest_of で別名）に置く。今回 KIOXIA に新しく置けたチェックポイント（place_ckpt が最後まで行ったときの PROV_CKPT_DIR）にも置く。もとからあった 1499 には置かない
- Hugging Face: 7b が `HFUP ok`（今回上げた）のときだけ、KIOXIA へ取ったあとで Mac の `hf upload` で train_provenance.json を足す（`HFUP same` のときは足さない）。hf_upload_ckpt.py の比べと lab_train.sh の compare_with_pod では train_provenance.json を数えない（次の回・取り直しで「別の中身」にならないように）
- status: completed（学習が最後まで）/ interrupted（signal・--stop・上限）/ failed（ほか）。task は not_applicable。片付けの一部が失敗したときは completed のまま error に書く
- compute: GPU_USED・Pod の id・作った時刻・学習を始めた時刻・消した時刻・rented_sec・COST_PER_HR・cost_usd・[COST] の行そのもの
- 由来を書く処理は lab_timeout 300 で包み、失敗しても終了コード・通知は変えない。KIOXIA がない（LOGDIR がない）ときは書かない（フォルダを作り直さない）

## 3. テスト
- test_provenance.py 23 件（指紋の作り方そのもの・数えないもの・JSON の中身・NFD・読めないフォルダ・鍵を伏せる・git の clean/dirty/paths・上書きしない・フォルダを作らない・CLI）
- test_lab_train.sh 17 節ほか（成功・落ちた・上限・借りられなかった・same・由来だけ上げられない・openpi が読めない・KIOXIA が外れた・dirty/clean・--stop・TERM・もとからある 1499）。全部で OK 166 / NG 0
- test_hf_upload_ckpt.py に「リポジトリに train_provenance.json があっても same」を足した

## 見てほしい点
1. 由来を書く処理が、学習の片付け（Pod を消す・KIOXIA に置く・通知）を止める・遅らせる・終了コードを変える道がないか（set -u・pipefail・trap の中・cleanup の中の wrap_fail）
2. 鍵（Runpod・Hugging Face）・Pod の IP とポートが由来・Hugging Face に入る道がないか
3. KIOXIA の既存のもの（checkpoints・datasets・runs）に書き足す・上書きする道がないか。KIOXIA が外れたときにフォルダを作り直さないか
4. 指紋の決め方が、②データセット・③実機の試行で使い回すときに困る点（.DS_Store を数える・出力の指紋を由来を置く前に測ることなど）
5. Hugging Face に train_provenance.json を足すことで、lab_start.sh（--model corr の hf download）・hf_upload_ckpt.py・compare_with_pod が壊れないか
6. git の dirty の見方（--code-path で lab_train.sh・lab_lib.sh・provenance.py・pi05 に絞る）が妥当か

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。

## R5 で見てほしいこと
R1〜R4 の結果と反映は docs/review_result_20261101_train_provenance_codex*.md の「反映」。R4 で反映した provenance.write（O_EXCL で直に1回で書く）が「上書きしない」を満たすか、ほかに必須の問題が残っていないか。テストは test_provenance.py 30 件（1 件 skip）・test_lab_train.sh OK 177 / NG 0。
```

### review_result_20261101_train_provenance_codex_r5.md（Codex のレビュー5回目の結果）

```markdown
# codex レビュー結果 R5：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r5.md（codex exec -s read-only）

**判定：必須0件、推奨2件です。** R4の上書き競合は解消されています。対象範囲の読み取りレビューでは、新たな必須修正は確認しませんでした。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| R4：既存ファイルを上書きしない | **据え置き・修正済み** | [provenance.py:312](/Users/shimazueiichi/r2r2r-scripts/provenance.py:312) は `O_CREAT \| O_EXCL` で直接作成します。先に同名ファイルが作られた場合も失敗し、既存内容を書き換えません。rename競合は解消しています。 |
| 強制終了時の不完全JSON | **推奨** | [provenance.py:315](/Users/shimazueiichi/r2r2r-scripts/provenance.py:315) は短い書込みに対応して反復する実装です。「1回で書く」というコメントとは異なり、単一writeでも完了の原子性は保証されません。KILL時には空・途中のJSONが残り、次回は存在済みとしてスキップされます。②③へ展開する前に、読込側のJSON検証と、不完全記録の扱いを決めてください。既存ファイルの上書き問題ではありません。 |
| 由来処理中のsignalテスト | **推奨** | `hf_prov_hang`には外側の監視が追加されています。一方、`early_fail → finish → prov_write`中のINT・TERMは未検証です。この経路では親のsignal trapが残っているため、通知回数・終了コード・子プロセス残存を確認するテストを推奨します。 |
| Pod削除・通知・終了コード | 据え置き | Pod削除の試行は由来処理より先です。`finish`は終了コードをローカル変数に保持し、通常の由来失敗で変更しません。追加部分に明白な`set -u`違反は見当たりません。全体監視は300秒で、打切り時には停止猶予などが加わります。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のspecに接続先IP・ポートを直接渡す経路は見当たりません。SSH出力のマスク、引数の伏字、鍵形式・secret-file内容・辞書キーの置換が維持されています。 |
| KIOXIAの既存データ・未マウント時 | 据え置き | `PROV_CKPT_DIR`は新規配置と最終照合の成功後だけ設定され、既存1499には設定されません。由来処理はフォルダを作らず、DATA_ROOT・LOGDIRがなければ書きません。datasets・runsへ追記する経路も見当たりません。 |
| Hugging Face・再取得・起動互換性 | 据え置き | `HFUP ok`だけ由来を追加し、`same`では追加しません。比較時の由来除外と、取得用一時フォルダ内の過去の由来除去は整合しています。`lab_start.sh --model corr`の取得・利用経路にも破綻は見当たりません。 |
| status・開始時情報・openpi | 据え置き | 学習完了後は`completed`を維持し、保存・削除失敗はerrorへ記録します。開始時スナップショット、取得失敗入力の固定、`--stop`での復元、不完全なopenpi取得を不明とする修正も維持されています。 |
| 指紋・dirtyの対象 | 据え置き | 指定4パスへの限定は今回の範囲として妥当です。`.DS_Store`を数えることと、出力指紋が由来配置前の値であることは現行仕様どおりです。②③では、この前提に合わせた再検証手順が必要です。 |

シェル4ファイル・Python4ファイルの構文確認は成功しました。テスト本体は書込みやプロセス操作を伴うため実行しておらず、**30件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須なし。ここでレビューを終える
- 推奨（書きかけの JSON）: write の説明を「書き終わるまで write をくり返す・KILL のときだけ書きかけが残りうる・読む側は JSON として読めるかを確かめる」に直した。読む側の扱いは ②③ を作るときにエイイチに決めてもらう
- 推奨（early_fail の中の INT・TERM のテスト）: 据え置き（Pod を借りる前で Hugging Face へは足さない。中の python は 120 秒の上限）
```

### 20261101_56 の結果（Claude Code の報告）

```markdown
# 結果：20261101_56_由来の指紋をfiles-v2にし読めない由来の扱いを決める

- 状態：完了
- 始めた時刻：2026-11-01 12:39
- 終わった時刻：2026-11-01 13:30（51分）
- 作業フォルダ：/Users/shimazueiichi/r2r2r-scripts
- この続きを Claude Code で話すとき：作業フォルダで `claude --resume 314a5953-7d46-4451-8a36-7ab93239a7cc`
- 止められた操作：1件（いちばん下に一覧）

## Claude Code の報告

由来の決まりを確定版（12節）に合わせて直し、コミットまで済ませました。テストは全部通っていますが、test_lab_train.sh だけ 8 回のうち 1 回 NG が 1 件出ました（下に書きます）。

## やったこと
- `git -C ~/r2r2r-content pull` のあと、12節を読みました。r2r2r-content には書いていません。
- **1. 指紋 files-v2**：接頭辞を `files-v2:` にし、`.DS_Store`・`train_provenance.json`・`*_provenance.json` をどの深さでも数えないようにしました。`._`・`.cache`・`.incoming_*` はこれまでどおりです。記録の指紋がどちらの版かを読む関数も足しました。
- **出力の指紋**：由来のファイルを置いたあとに測り直しても `outputs` の指紋と合うことを、単体テストと偽物の実行の両方で確かめました。lab_train.sh の注記「置く前に測った」は要らなくなったので消しました。
- **hf_upload_ckpt.py と KIOXIA 側の比べ**：コードは変えていません。両方が数えないもの（トップの train_provenance.json と .DS_Store）は files-v2 でも数えないので、食い違いはありません。これは実際の一覧の関数を使うテストで確かめました。
- **2・3**：`outputs` と `series_log`（Log18 の形）は今のままです。
- **4. 由来を読む関数**：`provenance.read()` を足しました。結果は ok（由来あり）・missing（ファイルがない、または中に provenance がない）・unreadable（由来なし・読めない）の3つです。書きかけや壊れた JSON は例外で止めず unreadable を返し、警告を1行出します。index.json などに埋め込まれた `provenance` も読めます。
- **5. dirty が毎回 true になる件**：git に入っていない、名前に `.bak` を含むファイルは `untracked_backups` に分け、件数で切らずに全部残します。`dirty` はそれ以外の変更があるときだけ true です。控えのファイルは消しも移しもしていません。
- **codex のレビュー**：R1 で必須1件・推奨3件が出ました。必須は「警告を出せないと例外が漏れる」で、ほかに bytes のパス・整合テストの作り・説明を直しました。R2 で必須は0件です。

## 結果
- test_provenance.py：42 件（1 件 skip）通過。足したのは 12 件です。
- test_lab_train.sh：OK 190 / NG 0。足したのは 3 件です。ただし 8 回流して 1 回だけ NG 1 が出ました。どの項目かは出力が残っておらず分かりません。ほかの 7 回は NG 0 で、時間に左右されるテストの揺れかもしれません。
- test_lab_start.sh：OK 136 / NG 0。
- pi05 の各テスト：gap_fill 7・hf_upload_ckpt 10・merge_and_config 13・recorder_rpc 4・rewind_correct 69・trim_hold 5、全部通りました。
- 偽物の実行（コミットのあと）：`code.dirty=false`、`untracked_backups` に `pi05/build_demo_episodes.py.bak_pre_LOG352_20261019` が入りました。出力の指紋は測り直した値と同じでした。鍵は入っていません。
- **files-v1 の関数は残しました**。12-4 に「同じ版の指紋どうしで比べる」とあり、files-v1 で書いた記録と比べるには前の作り方が要るためです。既定は files-v2 で、v1 は指定したときだけ使います。
- コミット：cb288f6（中身）、79bc11b（偽物の実行の JSON と作業の記録）。

## 変えたファイル
- provenance.py・test_provenance.py・lab_train.sh・test_lab_train.sh
- docs/example_train_provenance_dryrun_20261101_v2.json（新）
- docs/review_request_20261101_provenance_v2.md・docs/review_request_20261101_provenance_v2_r2.md（新）
- docs/review_result_20261101_provenance_v2_codex.md・docs/review_result_20261101_provenance_v2_codex_r2.md（新）
- logs/20261101_1305.md（新）
- 既存の checkpoint・データセット・runs と、依頼54の例の JSON には書き足していません。

## 決めてほしいこと
- **②③で JSON に埋め込むときの測る順番**：index.json などにキー `provenance` を足す形では、その JSON の中身が指紋に入ります。このため、足す前と後で指紋が変わります。足す前に測る、などの順番を決めてください（codex も同じことを挙げています）。
- **2つ目の由来の名前**：lab 側で2つ目の由来に付く名前 `train_provenance_<時刻>.json` は、12-4 の `*_provenance.json` に当たりません。今は決まりどおり、files-v2 でも数えています。数えないことにするかを決めてください。
- **控えとみなす範囲**：名前に `.bak` を含む git に入っていないファイルは、本当のコードでも dirty に数えなくなります。決まりどおりですが、この範囲でよいか確かめてください。

## 止められた操作

- {"tool_name": "Bash", "tool_use_id": "toolu_01ANGYrY2fQtwLZ1ry9n7svq", "tool_input": {"command": "cd ~/r2r2r-scripts; sed -n 20,35p test_lab_train.sh; T=$(mktemp -d); (LAB_DRY_ROOT=\"$T\" LAB_FAKE_SCENARIO=success ./lab_train.sh --log LogT --dry-run </dev/null > \"$T/out.txt\" 2>&1); echo rc=$?; P=$T/data/lab/LogT/train/train_provenance.json; cp \"$P\" docs/example_train_provenance_dryrun_20261101_v2.json; jq '{code, outputs}' \"$P\"; python3 provenance.py fingerprint $T/data/checkpoints/pi05_me
```

### 20261101_56_由来の指紋をfiles-v2にし読めない由来の扱いを決める.md（Claude Code への依頼）

```markdown
---
作業フォルダ: /Users/shimazueiichi/r2r2r-scripts
上限時間: 90
---
# 依頼：由来（provenance）の決まりを確定版に合わせて直す（2026-11-01・依頼54の続き）

依頼54（コミット 1902f37・cadd53d）の報告の「決めてほしいこと」5点を、エイイチが確定した。決まりの正は data_manifest_rules.md の12節。手元の写し `~/r2r2r-content/methodology/data_manifest_rules.md` を今日そろえたので、作業の前に `git -C ~/r2r2r-content pull` してから12節を読む（r2r2r-content には書き込まない）。

## 直すこと

1. **指紋を files-v2 にする**
   - 数えないものに、`.DS_Store` と、由来のファイルそのもの（`train_provenance.json`・`*_provenance.json`）を足す。これまでの `._`・`.cache`・`.incoming_*` はそのまま
   - 接頭辞を `files-v2:` にする。`files-v1` を作る関数は、比べるとき用に残してよい（残すかどうかは判断して報告）
   - これで、由来のファイルを置いたあとに出力のフォルダを測り直しても、`outputs` の指紋と合うことをテストで確かめる
   - `hf_upload_ckpt.py` と KIOXIA 側の比べで、由来のファイルを数えない直し（依頼54で入れたもの）と食い違わないようにする
2. **`outputs` を12節の正式な項目にした**。今の形のままでよい
3. **`series_log` は `Log18` の形**（今のまま）。数字だけにしない
4. **読めない由来の扱い**：`provenance.py` に「由来を読む」関数を足す。読めない（書きかけ・JSON が壊れている）ときは、例外で止めずに「由来なし（読めない）」と分かる結果を返し、警告を1行出す。②（データセットを作るスクリプト）③（実機の試行）で使う前提
5. **`code.dirty` が毎回 true になる件**：原因は `pi05/build_demo_episodes.py.bak_pre_LOG352_20261019`（git に入っていない控え）。このファイルは消さない・移さない。由来の側で、`dirty_files` のうち「git に入っていない控えのファイル（`.bak` を含む名前）」を `untracked_backups` として分けて記録し、`dirty` はそれ以外の変更があるときだけ true にする。ただし Pod へ送る中身には入っていることが分かるよう、`untracked_backups` は必ず残す

## 決まり

- 既存の checkpoint・データセット・runs と、依頼54で書いた example の JSON には書き足さない
- 本物の Pod は借りない。確かめは偽物の実行で
- 関節を動かすコードは触らない
- git は個別ファイルを指定して add する

## 確かめ

- 既存のテスト（test_provenance.py・test_lab_train.sh・test_lab_start.sh・pi05 の各テスト）が全部通ること。1・4・5 のテストを足す
- 偽物の実行で出た `train_provenance.json` の全文を、`docs/example_train_provenance_dryrun_20261101_v2.json` に省かずに置く
- codex のレビューを通す（docs/ に依頼文と結果を残す）

## 報告に書くこと

- 変えたファイル、コミット、テストの件数
- files-v1 の関数を残したかどうかと理由
- 決めてほしいこと
```

### コミット cb288f6 のコードの変更（files-v2・由来を読む関数・控えの分け方・git show）

```diff
commit cb288f6b8133fc9e135619941b4df3c62043ad1e
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sun Nov 1 12:57:11 2026 +0900

    由来の指紋を files-v2 にする. .DS_Store と由来のファイルそのもの (train_provenance.json・*_provenance.json) を数えず, 由来を置いたあとに測り直しても outputs の指紋と合う. files-v1 は同じ版で比べるために fingerprint(version="files-v1") と CLI の --version で残す. 由来を読む read() を足し, 書きかけ・壊れた JSON などは例外で止めず「由来なし (読めない)」を返して警告を1行出す. code では git に入っていない控え (.bak を含む名前) を untracked_backups に分け, dirty はほかの変更があるときだけ true にする (依頼 56)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/lab_train.sh b/lab_train.sh
index f55b640..78f3c46 100755
--- a/lab_train.sh
+++ b/lab_train.sh
@@ -189,8 +189,7 @@ prov_spec() {               # 由来の spec (lab_train.sh が知っているこ
          inputs: $start.inputs,
          code: ($start.code // null),
          outputs: (if $ck_dir == "" then [] else [{kind: "checkpoint", id: ($ckpt_name + "/" + $step), path: $ck_dir,
-                   hf_repo: (if $prov_hf == "1" then $repo_out else null end),
-                   fingerprint_note: "train_provenance.json を置く前に測った"}] end),
+                   hf_repo: (if $prov_hf == "1" then $repo_out else null end)}] end),     # 指紋 files-v2 は由来のファイルを数えない (依頼 56)
          config: {name: $config, exp_name: $exp, num_train_steps: ($steps | num), init_params: ($init | str), data_name: $data,
                   limit_hours: ($limit | num), checkpoint_step: ($step | str)},
          compute: {provider: "runpod", pod_id: ($pod | str), gpu: ($gpu | str), started_at: ($t0 | str), train_started_at: ($tt | str),
diff --git a/provenance.py b/provenance.py
index ac5162e..5dd2bb9 100644
--- a/provenance.py
+++ b/provenance.py
@@ -1,10 +1,11 @@
 #!/usr/bin/env python3
-"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54).
+"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54・56).
 
 決まりの正は data_manifest_rules.md 12節. 出す JSON は schema_version "provenance/1".
 
 bash から:
-  python3 provenance.py fingerprint <フォルダかファイル>            指紋 (files-v1:<sha256>) を1行で出す
+  python3 provenance.py fingerprint [--version files-v1] <フォルダかファイル>
+      指紋 (files-v2:<sha256>) を1行で出す. --version files-v1 は前の作り方 (files-v1 の記録と比べるときだけ)
   python3 provenance.py prepare --data-root <r2r2r-data> [--code-path ...] < spec.json
       始める時点のコード (code) と入力の指紋 (inputs) を JSON で出す. 終わったときに write の spec に入れて渡す
   python3 provenance.py write --data-root <r2r2r-data> --out <置く先> [--out <もう1つ>] \\
@@ -20,15 +21,26 @@ Python から:
                           "status": {"process": "completed", "task": "not_applicable"}},
                          data_root="/Volumes/KIOXIA/r2r2r-data")
   provenance.write(rec, "<出力フォルダ>/dataset_provenance.json")      # あれば FileExistsError
+  r = provenance.read("<出力フォルダ>/dataset_provenance.json")         # 読めなくても止めない (下の read())
+  if r["state"] == "ok": rec = r["provenance"]
 
-指紋 files-v1 (fingerprint()):
+指紋 files-v2 (fingerprint()):
   フォルダの中のファイルを相対パス (NFC・/ 区切り) の UTF-8 のバイト順に並べ,
     1) 全部のファイルの「相対パス\\tバイト数\\n」
     2) そのうち名前が .json で終わり 1MB 未満のファイルについて「\\0相対パス\\0」+ 中身
   をこの順に sha256 に入れる. 画像・動画・重みの中身は読まない.
-  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと).
+  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと)・
+    .DS_Store (Finder で開くだけで作られる)・由来のファイルそのもの (train_provenance.json・*_provenance.json).
+    由来を別のファイルとして置いたあとに測り直しても, 置く前と同じ指紋になる (data_manifest_rules.md 12-4).
+    ただし index.json・recording_meta.json などにキー provenance を足したときは, その JSON の中身が指紋に入るので変わる.
+  files-v1 (2026-11-01 まで) は .DS_Store と由来のファイルも数えていた. それ以外は同じ. 比べるときは同じ版どうしで比べる.
   リンクはたどらない (リンク自身の大きさ). ファイル1つを渡したときは, その名前1つの一覧として同じに作る.
 
+code の dirty: git に入っていない控えのファイル (名前に .bak を含む. 例 build_demo_episodes.py.bak_pre_LOG352_20261019) は
+  dirty_files に入れず untracked_backups に分けて残す (Pod へ送る中身には入るので必ず残す). dirty はそれ以外の変更があるときだけ true.
+
+読む (read()): 書きかけ・JSON が壊れている・由来の形でないものは例外で止めず state "unreadable" (由来なし) を返し, 警告を1行出す.
+
 鍵・トークンは書かない: 引数の --token / --key / --secret / --password などの値と, hf_・rpa_ などで始まる鍵らしい文字は
 「<redacted>」に置き換え, --secret-file で渡した鍵のファイルの中身が記録のどこかに出れば同じく置き換える.
 """
@@ -47,7 +59,9 @@ import unicodedata
 SCHEMA = "provenance/1"
 REPO_NAME = "r2r2r-scripts"
 REPO_DIR = os.path.dirname(os.path.abspath(__file__))
-FP_PREFIX = "files-v1:"
+FP_VERSION = "files-v2"      # いまの指紋の作り方
+FP_VERSIONS = ("files-v1", "files-v2")
+FP_PREFIX = FP_VERSION + ":"
 JSON_MAX = 1 << 20          # これ未満の .json は中身も指紋に入れる
 REDACTED = "<redacted>"
 INPUT_KINDS = ("dataset", "checkpoint", "run", "recording", "config")
@@ -62,15 +76,35 @@ SECRET_VALUE = re.compile(r"\b(hf_[A-Za-z0-9]{10,}|rpa_[A-Za-z0-9]{10,}|sk-[A-Za
 
 # ---- 指紋 ----
 def _skip(name):
+    """フォルダもファイルも数えない名前 (files-v1・v2 とも)"""
     return name.startswith("._") or name == ".cache" or name.startswith(".incoming_")
 
 
+def is_provenance_name(name):
+    """由来のファイルの名前か (train_provenance.json・*_provenance.json)"""
+    return name == "train_provenance.json" or name.endswith("_provenance.json")
+
+
+def _skip_file_v2(name):
+    """files-v2 でだけ数えないファイル: .DS_Store と由来のファイル"""
+    return name == ".DS_Store" or is_provenance_name(name)
+
+
 def _nfc(s):
     return unicodedata.normalize("NFC", s)
 
 
-def list_files(path):
-    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)"""
+def _check_version(version):
+    if version not in FP_VERSIONS:
+        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")
+
+
+def list_files(path, version=FP_VERSION):
+    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)
+
+    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
+    _check_version(version)
+    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
     path = os.path.abspath(path)
     if not os.path.lexists(path):
         raise FileNotFoundError(path)
@@ -89,7 +123,7 @@ def list_files(path):
             files.append(x)
         rel = os.path.relpath(d, path)
         for f in files:
-            if _skip(f):
+            if _skip(f) or skip_file(f):
                 continue
             full = os.path.join(d, f)
             p = f if rel == "." else os.path.join(rel, f)
@@ -101,8 +135,9 @@ def list_files(path):
     return out
 
 
-def fingerprint(path):
-    files = list_files(path)
+def fingerprint(path, version=FP_VERSION):
+    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
+    files = list_files(path, version)
     h = hashlib.sha256()
     for p, n, _ in files:
         h.update(f"{p}\t{n}\n".encode())
@@ -110,7 +145,16 @@ def fingerprint(path):
         if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
             with open(full, "rb") as fh:
                 h.update(b"\0" + p.encode() + b"\0" + fh.read())
-    return FP_PREFIX + h.hexdigest()
+    return version + ":" + h.hexdigest()
+
+
+def fingerprint_version(fp):
+    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
+    if isinstance(fp, str):
+        v, sep, d = fp.partition(":")
+        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
+            return v
+    return None
 
 
 # ---- コード ----
@@ -123,7 +167,8 @@ def _git(args, cwd):
 
 def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
-    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "diff_sha256": None, "measured_at": now_iso()}
+    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
+            "diff_sha256": None, "measured_at": now_iso()}
     if paths:
         info["paths"] = list(paths)
     try:
@@ -131,18 +176,22 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
         sel = ["--", *paths] if paths else []
         st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
         entries = [e for e in st.decode(errors="replace").split("\0") if e]
-        files, untracked, i = [], [], 0
+        files, untracked, backups, i = [], [], [], 0
         while i < len(entries):
             e = entries[i]
             code, f = e[:2], e[3:]
             if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                 i += 1
-            files.append(f)
-            if code == "??":
-                untracked.append(f)
+            if code == "??" and is_untracked_backup(f):
+                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
+            else:
+                files.append(f)
+                if code == "??":
+                    untracked.append(f)
             i += 1
         files = sorted(set(files))
         info["dirty"] = bool(files)
+        info["untracked_backups"] = sorted(set(backups))
         info["dirty_files"] = files[:DIRTY_FILES_MAX]
         if len(files) > DIRTY_FILES_MAX:
             info["dirty_files_total"] = len(files)
@@ -159,6 +208,11 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     return info
 
 
+def is_untracked_backup(path):
+    """git に入っていない控えのファイルとみなす名前か (名前に .bak を含む. 例 a.py.bak・a.py.bak_pre_LOG352_20261019)"""
+    return ".bak" in path.rstrip("/").rsplit("/", 1)[-1]
+
+
 # ---- 鍵を伏せる ----
 def redact_argv(argv):
     out, hide_next = [], False
@@ -328,11 +382,70 @@ def write(rec, path):
     return path
 
 
+# ---- 読む ----
+def read(path, warn=True):
+    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
+
+    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
+    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
+      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
+      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
+      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
+                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
+    try:
+        path = os.fsdecode(os.fspath(path))     # bytes のパスも文字列にそろえる (codex 依頼56 R1)
+    except Exception as e:
+        return _unreadable({"state": "missing", "path": repr(path), "provenance": None, "error": None},
+                           f"パスとして読めない ({type(e).__name__})", warn)
+    res = {"state": "missing", "path": path, "provenance": None, "error": None}
+    try:
+        with open(path, "rb") as fh:
+            raw = fh.read()
+    except FileNotFoundError:
+        res["error"] = "ファイルがない"
+        return res
+    except Exception as e:      # 読めないファイル (権限・フォルダなど)
+        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
+    try:
+        doc = json.loads(raw.decode("utf-8"))
+    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
+        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
+    rec = doc
+    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
+        if "provenance" in doc:
+            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
+        elif not is_provenance_name(os.path.basename(path)):
+            res["error"] = "キー provenance がない"
+            return res
+    if not isinstance(rec, dict):
+        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
+    sv = rec.get("schema_version")
+    if not _is_prov_schema(sv):
+        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
+    res.update(state="ok", provenance=rec)
+    return res
+
+
+def _is_prov_schema(sv):
+    return isinstance(sv, str) and sv.startswith("provenance/")
+
+
+def _unreadable(res, why, warn):
+    res.update(state="unreadable", error=why)
+    if warn:
+        try:                    # 標準エラーが閉じていても止めない (codex 依頼56 R1)
+            print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
+        except Exception:
+            pass
+    return res
+
+
 # ---- CLI ----
 def main(argv=None):
     ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
     sub = ap.add_subparsers(dest="cmd", required=True)
     f = sub.add_parser("fingerprint")
+    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
     f.add_argument("path")
     pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
     pr.add_argument("--data-root", default=None)
@@ -348,7 +461,7 @@ def main(argv=None):
     a = ap.parse_args(argv)
     try:
         if a.cmd == "fingerprint":
-            print(fingerprint(a.path))
+            print(fingerprint(a.path, a.version))
             return 0
         if a.cmd == "prepare":
             print(json.dumps(prepare(json.loads(sys.stdin.read() or "{}"), a.data_root, a.repo_dir, a.code_path), ensure_ascii=False))
diff --git a/test_lab_train.sh b/test_lab_train.sh
index 85683ea..bb13062 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -382,9 +382,10 @@ check "成功: provenance/1・ISO8601 (タイムゾーンつき)・lab_train.sh
 check "成功: 入力とコードは始める時点に測る・記録にも残す (codex R1)" '[ "$(jq -c .prov_start.code "$TD/lab_pod.json")" = "$(jq -c .code "$P")" ] && [ "$(pj ".inputs[0].measured_at <= .compute.started_at")" = true ] && [[ "$(pj ".inputs[2].sha256")" =~ ^[0-9a-f]{64}$ ]]'
 check "成功: code (r2r2r-scripts のコミット・dirty・見たパス)" '[ "$(pj .code.repo)" = r2r2r-scripts ] && [ "$(pj .code.commit)" = "$(git -C "$HERE" rev-parse HEAD)" ] && [[ "$(pj .code.dirty)" =~ ^(true|false)$ ]] && [ "$(pj ".code.paths | join(\",\")")" = "lab_train.sh,lab_lib.sh,provenance.py,pi05" ]'
 check "成功: Pod の openpi のコミットと変更" '[ "$(pj ".external_code[0].name")" = openpi ] && [ "$(pj ".external_code[0].commit")" = 215abfb1234567890abcdef1234567890abcdef0 ] && [ "$(pj ".external_code[0].dirty")" = true ] && [ "$(pj ".external_code[0].dirty_files[0]")" = src/openpi/training/config.py ]'
-check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v1:* ]]'
+check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v2:* ]]'
 check "成功: 設定・compute (GPU・始めと終わり・時間・費用は [COST] と同じ)・numbering・status" '[ "$(pj .config.name)" = pi05_mecharm_trim_corr_lora ] && [ "$(pj .config.num_train_steps)" = 1500 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.cost_per_hr_usd)" = 0.44 ] && [ "$(pj .compute.duration_sec)" = "$(jq .rented_sec "$TD/lab_pod.json")" ] && [ -n "$(pj ".compute.started_at // empty")" ] && [ -n "$(pj ".compute.ended_at // empty")" ] && grep -qF "[COST  ] $(pj .compute.cost_line)" "$T/out.txt" && [ "$(pj .numbering.series)" = フィジカルAI研究室 ] && [ "$(pj .numbering.series_log)" = LogT ] && [ "$(pj ".status | [.process,.task] | join(\" \")")" = "completed not_applicable" ] && [ "$(pj ".error // empty")" = "" ]'
-check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v1:* ]]'
+check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v2:* ]]'
+check "成功: 由来 (と .DS_Store) を置いたあとに測り直しても出力の指紋と合う (files-v2. 依頼 56)" '[ -f "$CK/1499/train_provenance.json" ] && [ -f "$CK/1499/.DS_Store" ] && [ "$(pj ".outputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$CK/1499")" ] && [ "$(pj ".outputs[0].fingerprint")" != "files-v2:$(python3 "$HERE/provenance.py" fingerprint --version files-v1 "$CK/1499" | cut -d: -f2)" ]'
 # Pod の openpi を読むコマンドを, この Mac の bash で一時の git リポジトリに向けて流す (codex R1)
 oc=$(grep -F ": lt=openpi_rev" "$T/ssh.log" | head -1); G=$(mktemp -d)
 (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t && echo a > f && git add f && git commit -qm i && echo b >> f && echo n > new)
@@ -459,6 +460,15 @@ rm -rf "$T"
 echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
 LAB_PROV_REPO="$G" run success
 check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
+rm -rf "$T"
+# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
+(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
+LAB_PROV_REPO="$G" run success
+check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+echo "# changed" >> "$G/lab_lib.sh"
+LAB_PROV_REPO="$G" run success
+check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
 rm -rf "$T" "$G"
 OUT1=""
 
diff --git a/test_provenance.py b/test_provenance.py
index bf1313a..bb226fd 100644
--- a/test_provenance.py
+++ b/test_provenance.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54) の確かめ.
+"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.
 
   python3 test_provenance.py -v
 
@@ -45,7 +45,7 @@ class Fingerprint(unittest.TestCase):
 
     def test_format_and_same_twice(self):
         a = pv.fingerprint(self.d)
-        self.assertRegex(a, r"^files-v1:[0-9a-f]{64}$")
+        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
         self.assertEqual(a, pv.fingerprint(self.d))
 
     def test_definition(self):
@@ -55,7 +55,71 @@ class Fingerprint(unittest.TestCase):
             h.update(f"{p}\t{n}\n".encode())
         h.update(b"\0index.json\0" + b'{"n": 1}')
         h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
-        self.assertEqual(pv.fingerprint(self.d), "files-v1:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身
+
+    def test_v2_skips_ds_store_and_provenance_files(self):
+        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
+        a = pv.fingerprint(self.d)
+        v1 = pv.fingerprint(self.d, "files-v1")
+        put(self.d, ".DS_Store", b"\0" * 6148)
+        put(self.d, "raw/.DS_Store", b"\0" * 10)
+        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
+        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
+        self.assertEqual(a, pv.fingerprint(self.d))
+        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
+        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
+        put(self.d, "raw/DS_Store", "x")
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_v2_same_after_writing_provenance_into_output(self):
+        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
+        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
+        pv.write(rec, self.d / "train_provenance.json")
+        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
+        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")
+
+    def test_version_helpers(self):
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
+        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
+            self.assertIsNone(pv.fingerprint_version(bad))
+        with self.assertRaises(ValueError):
+            pv.fingerprint(self.d, "files-v3")
+
+    def test_skips_agree_with_checkpoint_compares(self):
+        # 由来 (トップの train_provenance.json) と .DS_Store を足したとき, 指紋 files-v2 は変わらない. 比べの一覧は,
+        # それぞれの経路の除き方 (Hugging Face には .DS_Store は上がらない・compare_with_pod は lab_list_files の出力から
+        # トップの train_provenance.json を grep で除く. ここでは同じ除き方をまねる) を当てたあとで足す前と一致する.
+        # compare_with_pod そのものは test_lab_train.sh で確かめる. .gitattributes・train_state/ など由来と .DS_Store の
+        # ほかの数えないものは, 比べと指紋で元から違う (この依頼では変えない)
+        here = Path(__file__).resolve().parent
+        sys.path.insert(0, str(here / "pi05" / "openpi_addon"))
+        import hf_upload_ckpt as hu
+
+        class Item:
+            def __init__(self, path, size):
+                self.path, self.size = path, size
+
+        class Api:
+            def __init__(self, root):
+                self.root = root
+
+            def list_repo_tree(self, repo, recursive, repo_type):
+                return [Item(p, n) for p, n, _ in pv.list_files(self.root, "files-v1")]
+
+        def kioxia(d):
+            r = subprocess.run(["bash", "-c", '. "$1"; lab_list_files "$2"', "_", str(here / "lab_lib.sh"), str(d)],
+                               capture_output=True, text=True, check=True)
+            return r.stdout
+
+        before = (pv.fingerprint(self.d), hu.remote_files(Api(self.d), "r"), kioxia(self.d))
+        put(self.d, ".DS_Store", b"\0" * 6148)
+        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
+        after_fp, after_hf, after_kx = pv.fingerprint(self.d), hu.remote_files(Api(self.d), "r"), kioxia(self.d)
+        self.assertEqual(before[0], after_fp)
+        self.assertEqual(before[1], [x for x in after_hf if x[0] != ".DS_Store"])     # Hugging Face に .DS_Store は上がらない
+        self.assertEqual(before[2], "".join(x for x in after_kx.splitlines(True) if not x.startswith("train_provenance.json\t")))
 
     def test_skips_dotunderscore_cache_incoming(self):
         a = pv.fingerprint(self.d)
@@ -116,7 +180,8 @@ class Fingerprint(unittest.TestCase):
     def test_single_file_and_missing(self):
         f = put(self.tmp.name, "one.json", "{}")
         h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
-        self.assertEqual(pv.fingerprint(f), "files-v1:" + h)
+        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
+        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
         with self.assertRaises(FileNotFoundError):
             pv.fingerprint(Path(self.tmp.name) / "nothing")
 
@@ -200,6 +265,29 @@ class Code(unittest.TestCase):
         put(self.r, "sub/b.py", "print(2)\n")
         self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])
 
+    def test_untracked_backup_not_dirty_but_kept(self):
+        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
+        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
+        put(self.r, "a.sh.bak", "old\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
+        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
+        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
+        self.assertEqual(len(c["untracked_backups"]), 2)
+        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])
+
+    def test_tracked_backup_change_is_dirty(self):
+        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
+        put(self.r, "x.bak", "1\n")
+        git(self.r, "add", "x.bak")
+        git(self.r, "commit", "-qm", "bak")
+        put(self.r, "x.bak", "2\n")
+        put(self.r, "dir.bak/new.py", "x\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))
+
     def test_not_a_repo(self):
         with tempfile.TemporaryDirectory() as t:
             c = pv.code_info(t)
@@ -207,6 +295,92 @@ class Code(unittest.TestCase):
         self.assertIn("error", c)
 
 
+class Read(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.d = Path(self.tmp.name)
+        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def rd(self, p):
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(p)
+        return r, err.getvalue()
+
+    def test_ok(self):
+        p = pv.write(self.rec, self.d / "train_provenance.json")
+        r, err = self.rd(p)
+        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))
+
+    def test_embedded_key(self):
+        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
+        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
+        r, err = self.rd(self.d / "index.json")
+        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))
+
+    def test_missing_no_warning(self):
+        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
+        for p in (self.d / "nothing.json", self.d / "old.json"):
+            r, err = self.rd(p)
+            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))
+
+    def test_unreadable_returns_and_warns_one_line(self):
+        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
+        cases = {
+            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
+            "empty_provenance.json": b"",
+            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
+            "notutf8_provenance.json": b"\xff\xfe{",
+            "list_provenance.json": b"[1, 2]",
+            "nosv_provenance.json": b'{"a": 1}',
+            "badsv_provenance.json": b'{"schema_version": "other/1"}',
+            "nulled.json": b'{"provenance": null}',
+        }
+        for name, data in cases.items():
+            put(self.d, name, data)
+            r, err = self.rd(self.d / name)
+            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
+            self.assertTrue(r["error"], name)
+            self.assertEqual(len(err.splitlines()), 1, name)
+            self.assertIn("由来なし (読めない)", err)
+            self.assertIn(name, err)
+
+    def test_bytes_path_and_closed_stderr(self):
+        # codex 依頼56 R1: bytes のパス・警告を出せない標準エラーでも例外にしない
+        p = put(self.d, "x_provenance.json", "{}")
+        self.assertEqual(pv.read(os.fsencode(p), warn=False)["state"], "unreadable")
+        pv.write(self.rec, self.d / "train_provenance.json")
+        self.assertEqual(pv.read(os.fsencode(self.d / "train_provenance.json"))["state"], "ok")
+
+        class Broken:
+            def write(self, s):
+                raise OSError("closed")
+
+            def flush(self):
+                pass
+        old = sys.stderr
+        sys.stderr = Broken()
+        try:
+            r = pv.read(p)
+        finally:
+            sys.stderr = old
+        self.assertEqual(r["state"], "unreadable")
+        self.assertEqual(pv.read(12345, warn=False)["state"], "unreadable")
+
+    def test_unreadable_dir_and_no_warn(self):
+        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
+        self.assertEqual(r["state"], "unreadable")
+        self.assertEqual(len(err.splitlines()), 1)
+        put(self.d, "x_provenance.json", "{")
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(self.d / "x_provenance.json", warn=False)
+        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))
+
+
 class BuildWrite(unittest.TestCase):
     def setUp(self):
         self.tmp = tempfile.TemporaryDirectory()
```

### example_train_provenance_dryrun_20261101_v2.json（直したあとの偽物の実行で出た由来の記録）

```json
{
  "schema_version": "provenance/1",
  "created_at": "2026-11-01T12:57:21+09:00",
  "producer": {
    "script": "lab_train.sh",
    "argv": [
      "--log",
      "LogT",
      "--dry-run"
    ],
    "machine": "shimadzueiichinoMacBook-Pro.local",
    "platform": "Darwin 25.6.0",
    "python": "3.12.12",
    "dry_run": true
  },
  "code": {
    "repo": "r2r2r-scripts",
    "commit": "cb288f6b8133fc9e135619941b4df3c62043ad1e",
    "dirty": false,
    "dirty_files": [],
    "untracked_backups": [
      "pi05/build_demo_episodes.py.bak_pre_LOG352_20261019"
    ],
    "diff_sha256": null,
    "measured_at": "2026-11-01T12:57:19+09:00",
    "paths": [
      "lab_train.sh",
      "lab_lib.sh",
      "provenance.py",
      "pi05"
    ]
  },
  "external_code": [
    {
      "name": "openpi",
      "commit": "215abfb1234567890abcdef1234567890abcdef0",
      "dirty": true,
      "dirty_files": [
        "src/openpi/training/config.py",
        "src/openpi/policies/mecharm_policy.py"
      ],
      "expected_commit": "215abfb"
    }
  ],
  "inputs": [
    {
      "kind": "dataset",
      "id": "mecharm_redblock_box_teach_v1_trim2_corr_v3",
      "path": "datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3",
      "fingerprint": "files-v2:1c95b8708332ee7feae2149dbf568f952ecd777c4b692a75fa22dfb28e77de4f",
      "measured_at": "2026-11-01T12:57:19+09:00"
    },
    {
      "kind": "checkpoint",
      "id": "pi05_mecharm_trim_lora_v1/3999",
      "path": "checkpoints/pi05_mecharm_trim_lora_v1/3999",
      "fingerprint": "files-v2:262027fc8884bd5f617beb31a7a9dd3b976fde0d29b2bc67f80255658baf3ebd",
      "hf_repo": "PixelstoMotors/pi05-mecharm-trim-lora-v1",
      "pod_path": "/workspace/ckpt/3999/params",
      "measured_at": "2026-11-01T12:57:19+09:00"
    },
    {
      "kind": "config",
      "id": "add_trim_config.py",
      "path": "lab/Log11/pod/add_trim_config.py",
      "fingerprint": "files-v2:c30584c29dcad622e4e52198e916030969d63493af07e7422f1da598d1328149",
      "sha256": "807fd6eb050b197b8eb583dd51561ee7ee92727f7827143997c30edde88fc8ce",
      "measured_at": "2026-11-01T12:57:19+09:00"
    }
  ],
  "outputs": [
    {
      "kind": "checkpoint",
      "id": "pi05_mecharm_trim_corr_lora_v1/1499",
      "path": "checkpoints/pi05_mecharm_trim_corr_lora_v1/1499",
      "fingerprint": "files-v2:06377e87cce312cd74cab323ce40aacdd3f7c32c1dc81a82abb85ce914600697",
      "hf_repo": "PixelstoMotors/pi05-mecharm-trim-corr-lora-v1"
    }
  ],
  "config": {
    "name": "pi05_mecharm_trim_corr_lora",
    "exp_name": "trim_corr_v3",
    "num_train_steps": 1500,
    "init_params": "/workspace/ckpt/3999/params",
    "data_name": "mecharm_redblock_box_teach_v1_trim2_corr_v3",
    "limit_hours": 5,
    "checkpoint_step": "1499"
  },
  "compute": {
    "provider": "runpod",
    "pod_id": "dryrunpod0001",
    "gpu": "NVIDIA A40",
    "started_at": "2026-11-01T12:57:19+09:00",
    "train_started_at": "2026-11-01T12:57:19+09:00",
    "ended_at": "2026-11-01T12:57:21+09:00",
    "duration_sec": 2,
    "cost_per_hr_usd": 0.44,
    "cost_usd": 0,
    "yen_per_usd": 158,
    "cost_line": "借りていた時間 0 分 2 秒 (0.00 時間) × 1時間 0.44 ドル = 約 0.00 ドル (約 0 円. 1ドル=158円)"
  },
  "numbering": {
    "series": "フィジカルAI研究室",
    "series_log": "LogT",
    "article_no": ""
  },
  "status": {
    "process": "completed",
    "task": "not_applicable"
  },
  "notes": {
    "hugging_face": "ok PixelstoMotors/pi05-mecharm-trim-corr-lora-v1 に 5 ファイルを上げ, 名前・バイト数が全部合うのを確かめた",
    "kioxia": "/tmp/prov_dry_B7iq/data/checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 に置いた (5 ファイル 1084292 バイト. Pod 側と名前・バイト数が全部合った)"
  }
}
```

### review_request_20261101_provenance_v2.md（Codex のレビュー1回目の依頼・56番）

```markdown
# コードレビュー依頼：由来（provenance）の指紋を files-v2 にし、読めない由来・控えのファイルの扱いを足す（2026-11-01 依頼 56・依頼 54 の続き）

前提: 依頼 54（コミット 1902f37）で入れた provenance.py の「決めてほしいこと」をエイイチが確定した。決まりの正は `~/r2r2r-content/methodology/data_manifest_rules.md` の 12節（とくに 12-4 指紋・12-5 守ること）。
過去のレビュー: docs/review_result_20261101_train_provenance_codex*.md

対象（`git diff HEAD -- provenance.py test_provenance.py lab_train.sh test_lab_train.sh`。ほかのファイルの未コミット差分は対象外）

## 1. 指紋 files-v2（provenance.py）
- 接頭辞を `files-v2:` にした。v1 からの違いは、ファイルの名前が `.DS_Store`・`train_provenance.json`・`*_provenance.json` のものをどの深さでも数えないことだけ。`._*`・`.cache`・`.incoming_*` はこれまでどおり（フォルダごと）
- `fingerprint(path, version="files-v2")`。`version="files-v1"` で前の作り方を残した（files-v1 の記録と同じ版で比べるため。12-4「同じ版どうしで比べる」）。CLI は `fingerprint [--version files-v1] <path>`。`fingerprint_version(fp)` で記録の指紋の版を読む
- ファイル1つを渡したときは、名前で数えないものでもそのファイル1つで測る
- hf_upload_ckpt.py（Hugging Face 側でトップの train_provenance.json を数えない）・lab_list_files（KIOXIA 側で .DS_Store を数えない）・compare_with_pod（トップの train_provenance.json を数えない）は変えていない。これらが数えないものは files-v2 でも数えないので食い違わない（テストで確かめる）

## 2. 由来を読む `read(path, warn=True)`（provenance.py）
- 由来のファイル、またはキー `provenance` を中に持つ JSON（外側に別の schema_version があってもよい）を読む。戻りは `{state: ok|missing|unreadable, path, provenance, error}`
- 書きかけ・JSON が壊れている・UTF-8 でない・dict でない・schema_version が `provenance/` で始まらない・読めない（フォルダなど）は例外を出さず `unreadable`（由来なし）と警告1行（標準エラー）
- ファイルがない・キー provenance がない（由来を入れる前の JSON）は `missing`（警告なし）

## 3. code の untracked_backups（provenance.py）
- `git status` で `??`（git に入っていない）かつ名前（最後の部分）に `.bak` を含むものは dirty_files に入れず `untracked_backups` に分ける。dirty はそれ以外の変更があるときだけ true。diff_sha256 も控えを除いた変更で作る（変更がなければ null）
- untracked_backups は件数で切らずに全部残す（Pod へ送る中身に入るので）。git に入っている .bak を変えたもの・`.bak` を含むフォルダの中のふつうの名前は控えとみなさない

## 4. lab_train.sh
- outputs の `fingerprint_note`（置く前に測った）を消した。中身はほかに変えていない

## 5. テスト
- test_provenance.py 41 件（1 件 skip）: v2 で数えないもの・由来を置いたあとに測り直しても outputs の指紋と合う・v1 を残す・版の読み・hf/KIOXIA の比べと食い違わない・read の ok/埋め込み/missing/unreadable 8 通り・控え
- test_lab_train.sh OK 190 / NG 0: 偽物の実行で、由来と .DS_Store のあるチェックポイントを測り直しても outputs の指紋と合う・控えだけなら dirty=false・控えとほかの変更
- test_lab_start.sh OK 136 / NG 0・pi05 の各テスト OK

## 見てほしい点
1. files-v2 の数えない決め方が 12-4 と合っているか。由来を置いたあとに測り直しても同じになる・ならない道がないか
2. read() が「読めない記録を由来ありと数えない・止めない」を満たすか。例外が漏れる道がないか
3. untracked_backups の判定（`??` かつ名前に `.bak`）で、本当の変更を見落として dirty=false にする道がないか
4. hf_upload_ckpt.py・compare_with_pod・lab_list_files と files-v2 の食い違い
5. files-v1 を残したことの是非

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。
```

### review_result_20261101_provenance_v2_codex.md（Codex のレビュー1回目の結果・56番）

```markdown
# codex レビュー結果 R1：由来の指紋 files-v2・読めない由来・控えのファイル（2026-11-01 依頼 56）

依頼文: docs/review_request_20261101_provenance_v2.md（codex exec -s read-only）

対象4ファイルの差分を、規則12節と既存の比較処理に照らして読み取り専用で確認します。ファイル変更や実機・Pod・Hugging Faceの操作は行いません。
exec
/bin/zsh -lc 'git diff HEAD -- provenance.py test_provenance.py lab_train.sh test_lab_train.sh' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-GDCpAuXL' (errno=Operation not permitted)
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-lzheLey7' (errno=Operation not permitted)
diff --git a/lab_train.sh b/lab_train.sh
index f55b640..78f3c46 100755
--- a/lab_train.sh
+++ b/lab_train.sh
@@ -189,8 +189,7 @@ prov_spec() {               # 由来の spec (lab_train.sh が知っているこ
          inputs: $start.inputs,
          code: ($start.code // null),
          outputs: (if $ck_dir == "" then [] else [{kind: "checkpoint", id: ($ckpt_name + "/" + $step), path: $ck_dir,
-                   hf_repo: (if $prov_hf == "1" then $repo_out else null end),
-                   fingerprint_note: "train_provenance.json を置く前に測った"}] end),
+                   hf_repo: (if $prov_hf == "1" then $repo_out else null end)}] end),     # 指紋 files-v2 は由来のファイルを数えない (依頼 56)
          config: {name: $config, exp_name: $exp, num_train_steps: ($steps | num), init_params: ($init | str), data_name: $data,
                   limit_hours: ($limit | num), checkpoint_step: ($step | str)},
          compute: {provider: "runpod", pod_id: ($pod | str), gpu: ($gpu | str), started_at: ($t0 | str), train_started_at: ($tt | str),
diff --git a/provenance.py b/provenance.py
index ac5162e..8560951 100644
--- a/provenance.py
+++ b/provenance.py
@@ -1,10 +1,11 @@
 #!/usr/bin/env python3
-"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54).
+"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54・56).
 
 決まりの正は data_manifest_rules.md 12節. 出す JSON は schema_version "provenance/1".
 
 bash から:
-  python3 provenance.py fingerprint <フォルダかファイル>            指紋 (files-v1:<sha256>) を1行で出す
+  python3 provenance.py fingerprint [--version files-v1] <フォルダかファイル>
+      指紋 (files-v2:<sha256>) を1行で出す. --version files-v1 は前の作り方 (files-v1 の記録と比べるときだけ)
   python3 provenance.py prepare --data-root <r2r2r-data> [--code-path ...] < spec.json
       始める時点のコード (code) と入力の指紋 (inputs) を JSON で出す. 終わったときに write の spec に入れて渡す
   python3 provenance.py write --data-root <r2r2r-data> --out <置く先> [--out <もう1つ>] \\
@@ -20,15 +21,25 @@ Python から:
                           "status": {"process": "completed", "task": "not_applicable"}},
                          data_root="/Volumes/KIOXIA/r2r2r-data")
   provenance.write(rec, "<出力フォルダ>/dataset_provenance.json")      # あれば FileExistsError
+  r = provenance.read("<出力フォルダ>/dataset_provenance.json")         # 読めなくても止めない (下の read())
+  if r["state"] == "ok": rec = r["provenance"]
 
-指紋 files-v1 (fingerprint()):
+指紋 files-v2 (fingerprint()):
   フォルダの中のファイルを相対パス (NFC・/ 区切り) の UTF-8 のバイト順に並べ,
     1) 全部のファイルの「相対パス\\tバイト数\\n」
     2) そのうち名前が .json で終わり 1MB 未満のファイルについて「\\0相対パス\\0」+ 中身
   をこの順に sha256 に入れる. 画像・動画・重みの中身は読まない.
-  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと).
+  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと)・
+    .DS_Store (Finder で開くだけで作られる)・由来のファイルそのもの (train_provenance.json・*_provenance.json).
+    由来を置いたあとに測り直しても, 置く前と同じ指紋になる (data_manifest_rules.md 12-4).
+  files-v1 (2026-11-01 まで) は .DS_Store と由来のファイルも数えていた. それ以外は同じ. 比べるときは同じ版どうしで比べる.
   リンクはたどらない (リンク自身の大きさ). ファイル1つを渡したときは, その名前1つの一覧として同じに作る.
 
+code の dirty: git に入っていない控えのファイル (名前に .bak を含む. 例 build_demo_episodes.py.bak_pre_LOG352_20261019) は
+  dirty_files に入れず untracked_backups に分けて残す (Pod へ送る中身には入るので必ず残す). dirty はそれ以外の変更があるときだけ true.
+
+読む (read()): 書きかけ・JSON が壊れている・由来の形でないものは例外で止めず state "unreadable" (由来なし) を返し, 警告を1行出す.
+
 鍵・トークンは書かない: 引数の --token / --key / --secret / --password などの値と, hf_・rpa_ などで始まる鍵らしい文字は
 「<redacted>」に置き換え, --secret-file で渡した鍵のファイルの中身が記録のどこかに出れば同じく置き換える.
 """
@@ -47,7 +58,9 @@ import unicodedata
 SCHEMA = "provenance/1"
 REPO_NAME = "r2r2r-scripts"
 REPO_DIR = os.path.dirname(os.path.abspath(__file__))
-FP_PREFIX = "files-v1:"
+FP_VERSION = "files-v2"      # いまの指紋の作り方
+FP_VERSIONS = ("files-v1", "files-v2")
+FP_PREFIX = FP_VERSION + ":"
 JSON_MAX = 1 << 20          # これ未満の .json は中身も指紋に入れる
 REDACTED = "<redacted>"
 INPUT_KINDS = ("dataset", "checkpoint", "run", "recording", "config")
@@ -62,15 +75,35 @@ SECRET_VALUE = re.compile(r"\b(hf_[A-Za-z0-9]{10,}|rpa_[A-Za-z0-9]{10,}|sk-[A-Za
 
 # ---- 指紋 ----
 def _skip(name):
+    """フォルダもファイルも数えない名前 (files-v1・v2 とも)"""
     return name.startswith("._") or name == ".cache" or name.startswith(".incoming_")
 
 
+def is_provenance_name(name):
+    """由来のファイルの名前か (train_provenance.json・*_provenance.json)"""
+    return name == "train_provenance.json" or name.endswith("_provenance.json")
+
+
+def _skip_file_v2(name):
+    """files-v2 でだけ数えないファイル: .DS_Store と由来のファイル"""
+    return name == ".DS_Store" or is_provenance_name(name)
+
+
 def _nfc(s):
     return unicodedata.normalize("NFC", s)
 
 
-def list_files(path):
-    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)"""
+def _check_version(version):
+    if version not in FP_VERSIONS:
+        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")
+
+
+def list_files(path, version=FP_VERSION):
+    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)
+
+    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
+    _check_version(version)
+    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
     path = os.path.abspath(path)
     if not os.path.lexists(path):
         raise FileNotFoundError(path)
@@ -89,7 +122,7 @@ def list_files(path):
             files.append(x)
         rel = os.path.relpath(d, path)
         for f in files:
-            if _skip(f):
+            if _skip(f) or skip_file(f):
                 continue
             full = os.path.join(d, f)
             p = f if rel == "." else os.path.join(rel, f)
@@ -101,8 +134,9 @@ def list_files(path):
     return out
 
 
-def fingerprint(path):
-    files = list_files(path)
+def fingerprint(path, version=FP_VERSION):
+    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
+    files = list_files(path, version)
     h = hashlib.sha256()
     for p, n, _ in files:
         h.update(f"{p}\t{n}\n".encode())
@@ -110,7 +144,16 @@ def fingerprint(path):
         if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
             with open(full, "rb") as fh:
                 h.update(b"\0" + p.encode() + b"\0" + fh.read())
-    return FP_PREFIX + h.hexdigest()
+    return version + ":" + h.hexdigest()
+
+
+def fingerprint_version(fp):
+    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
+    if isinstance(fp, str):
+        v, sep, d = fp.partition(":")
+        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
+            return v
+    return None
 
 
 # ---- コード ----
@@ -123,7 +166,8 @@ def _git(args, cwd):
 
 def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
-    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "diff_sha256": None, "measured_at": now_iso()}
+    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
+            "diff_sha256": None, "measured_at": now_iso()}
     if paths:
         info["paths"] = list(paths)
     try:
@@ -131,18 +175,22 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
         sel = ["--", *paths] if paths else []
         st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
         entries = [e for e in st.decode(errors="replace").split("\0") if e]
-        files, untracked, i = [], [], 0
+        files, untracked, backups, i = [], [], [], 0
         while i < len(entries):
             e = entries[i]
             code, f = e[:2], e[3:]
             if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                 i += 1
-            files.append(f)
-            if code == "??":
-                untracked.append(f)
+            if code == "??" and is_untracked_backup(f):
+                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
+            else:
+                files.append(f)
+                if code == "??":
+                    untracked.append(f)
             i += 1
         files = sorted(set(files))
         info["dirty"] = bool(files)
+        info["untracked_backups"] = sorted(set(backups))
         info["dirty_files"] = files[:DIRTY_FILES_MAX]
         if len(files) > DIRTY_FILES_MAX:
             info["dirty_files_total"] = len(files)
@@ -159,6 +207,11 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     return info
 
 
+def is_untracked_backup(path):
+    """git に入っていない控えのファイルとみなす名前か (名前に .bak を含む. 例 a.py.bak・a.py.bak_pre_LOG352_20261019)"""
+    return ".bak" in path.rstrip("/").rsplit("/", 1)[-1]
+
+
 # ---- 鍵を伏せる ----
 def redact_argv(argv):
     out, hide_next = [], False
@@ -328,11 +381,63 @@ def write(rec, path):
     return path
 
 
+# ---- 読む ----
+def read(path, warn=True):
+    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
+
+    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
+    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
+      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
+      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
+      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
+                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
+    path = os.fspath(path)
+    res = {"state": "missing", "path": path, "provenance": None, "error": None}
+    try:
+        with open(path, "rb") as fh:
+            raw = fh.read()
+    except FileNotFoundError:
+        res["error"] = "ファイルがない"
+        return res
+    except Exception as e:      # 読めないファイル (権限・フォルダなど)
+        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
+    try:
+        doc = json.loads(raw.decode("utf-8"))
+    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
+        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
+    rec = doc
+    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
+        if "provenance" in doc:
+            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
+        elif not is_provenance_name(os.path.basename(path)):
+            res["error"] = "キー provenance がない"
+            return res
+    if not isinstance(rec, dict):
+        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
+    sv = rec.get("schema_version")
+    if not _is_prov_schema(sv):
+        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
+    res.update(state="ok", provenance=rec)
+    return res
+
+
+def _is_prov_schema(sv):
+    return isinstance(sv, str) and sv.startswith("provenance/")
+
+
+def _unreadable(res, why, warn):
+    res.update(state="unreadable", error=why)
+    if warn:
+        print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
+    return res
+
+
 # ---- CLI ----
 def main(argv=None):
     ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
     sub = ap.add_subparsers(dest="cmd", required=True)
     f = sub.add_parser("fingerprint")
+    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
     f.add_argument("path")
     pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
     pr.add_argument("--data-root", default=None)
@@ -348,7 +453,7 @@ def main(argv=None):
     a = ap.parse_args(argv)
     try:
         if a.cmd == "fingerprint":
-            print(fingerprint(a.path))
+            print(fingerprint(a.path, a.version))
             return 0
         if a.cmd == "prepare":
             print(json.dumps(prepare(json.loads(sys.stdin.read() or "{}"), a.data_root, a.repo_dir, a.code_path), ensure_ascii=False))
diff --git a/test_lab_train.sh b/test_lab_train.sh
index 85683ea..bb13062 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -382,9 +382,10 @@ check "成功: provenance/1・ISO8601 (タイムゾーンつき)・lab_train.sh
 check "成功: 入力とコードは始める時点に測る・記録にも残す (codex R1)" '[ "$(jq -c .prov_start.code "$TD/lab_pod.json")" = "$(jq -c .code "$P")" ] && [ "$(pj ".inputs[0].measured_at <= .compute.started_at")" = true ] && [[ "$(pj ".inputs[2].sha256")" =~ ^[0-9a-f]{64}$ ]]'
 check "成功: code (r2r2r-scripts のコミット・dirty・見たパス)" '[ "$(pj .code.repo)" = r2r2r-scripts ] && [ "$(pj .code.commit)" = "$(git -C "$HERE" rev-parse HEAD)" ] && [[ "$(pj .code.dirty)" =~ ^(true|false)$ ]] && [ "$(pj ".code.paths | join(\",\")")" = "lab_train.sh,lab_lib.sh,provenance.py,pi05" ]'
 check "成功: Pod の openpi のコミットと変更" '[ "$(pj ".external_code[0].name")" = openpi ] && [ "$(pj ".external_code[0].commit")" = 215abfb1234567890abcdef1234567890abcdef0 ] && [ "$(pj ".external_code[0].dirty")" = true ] && [ "$(pj ".external_code[0].dirty_files[0]")" = src/openpi/training/config.py ]'
-check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v1:* ]]'
+check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v2:* ]]'
 check "成功: 設定・compute (GPU・始めと終わり・時間・費用は [COST] と同じ)・numbering・status" '[ "$(pj .config.name)" = pi05_mecharm_trim_corr_lora ] && [ "$(pj .config.num_train_steps)" = 1500 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.cost_per_hr_usd)" = 0.44 ] && [ "$(pj .compute.duration_sec)" = "$(jq .rented_sec "$TD/lab_pod.json")" ] && [ -n "$(pj ".compute.started_at // empty")" ] && [ -n "$(pj ".compute.ended_at // empty")" ] && grep -qF "[COST  ] $(pj .compute.cost_line)" "$T/out.txt" && [ "$(pj .numbering.series)" = フィジカルAI研究室 ] && [ "$(pj .numbering.series_log)" = LogT ] && [ "$(pj ".status | [.process,.task] | join(\" \")")" = "completed not_applicable" ] && [ "$(pj ".error // empty")" = "" ]'
-check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v1:* ]]'
+check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v2:* ]]'
+check "成功: 由来 (と .DS_Store) を置いたあとに測り直しても出力の指紋と合う (files-v2. 依頼 56)" '[ -f "$CK/1499/train_provenance.json" ] && [ -f "$CK/1499/.DS_Store" ] && [ "$(pj ".outputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$CK/1499")" ] && [ "$(pj ".outputs[0].fingerprint")" != "files-v2:$(python3 "$HERE/provenance.py" fingerprint --version files-v1 "$CK/1499" | cut -d: -f2)" ]'
 # Pod の openpi を読むコマンドを, この Mac の bash で一時の git リポジトリに向けて流す (codex R1)
 oc=$(grep -F ": lt=openpi_rev" "$T/ssh.log" | head -1); G=$(mktemp -d)
 (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t && echo a > f && git add f && git commit -qm i && echo b >> f && echo n > new)
@@ -459,6 +460,15 @@ rm -rf "$T"
 echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
 LAB_PROV_REPO="$G" run success
 check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
+rm -rf "$T"
+# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
+(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
+LAB_PROV_REPO="$G" run success
+check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+echo "# changed" >> "$G/lab_lib.sh"
+LAB_PROV_REPO="$G" run success
+check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
 rm -rf "$T" "$G"
 OUT1=""
 
diff --git a/test_provenance.py b/test_provenance.py
index bf1313a..4d1ccfd 100644
--- a/test_provenance.py
+++ b/test_provenance.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54) の確かめ.
+"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.
 
   python3 test_provenance.py -v
 
@@ -45,7 +45,7 @@ class Fingerprint(unittest.TestCase):
 
     def test_format_and_same_twice(self):
         a = pv.fingerprint(self.d)
-        self.assertRegex(a, r"^files-v1:[0-9a-f]{64}$")
+        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
         self.assertEqual(a, pv.fingerprint(self.d))
 
     def test_definition(self):
@@ -55,7 +55,48 @@ class Fingerprint(unittest.TestCase):
             h.update(f"{p}\t{n}\n".encode())
         h.update(b"\0index.json\0" + b'{"n": 1}')
         h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
-        self.assertEqual(pv.fingerprint(self.d), "files-v1:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身
+
+    def test_v2_skips_ds_store_and_provenance_files(self):
+        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
+        a = pv.fingerprint(self.d)
+        v1 = pv.fingerprint(self.d, "files-v1")
+        put(self.d, ".DS_Store", b"\0" * 6148)
+        put(self.d, "raw/.DS_Store", b"\0" * 10)
+        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
+        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
+        self.assertEqual(a, pv.fingerprint(self.d))
+        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
+        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
+        put(self.d, "raw/DS_Store", "x")
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_v2_same_after_writing_provenance_into_output(self):
+        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
+        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
+        pv.write(rec, self.d / "train_provenance.json")
+        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
+        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")
+
+    def test_version_helpers(self):
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
+        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
+            self.assertIsNone(pv.fingerprint_version(bad))
+        with self.assertRaises(ValueError):
+            pv.fingerprint(self.d, "files-v3")
+
+    def test_skips_agree_with_checkpoint_compares(self):
+        # hf_upload_ckpt.py (Hugging Face 側の比べ) と lab_list_files (KIOXIA 側の比べ) が数えない由来・.DS_Store は, 指紋でも数えない
+        sys.path.insert(0, str(Path(__file__).resolve().parent / "pi05" / "openpi_addon"))
+        import hf_upload_ckpt as hu
+        for name in hu.SKIP_REMOTE:
+            if name.endswith(".json"):
+                self.assertTrue(pv.is_provenance_name(name), name)
+        lib = (Path(__file__).resolve().parent / "lab_lib.sh").read_text()
+        self.assertIn('f in (".DS_Store", ".gitattributes")', lib)
+        self.assertTrue(pv._skip_file_v2(".DS_Store"))
 
     def test_skips_dotunderscore_cache_incoming(self):
         a = pv.fingerprint(self.d)
@@ -116,7 +157,8 @@ class Fingerprint(unittest.TestCase):
     def test_single_file_and_missing(self):
         f = put(self.tmp.name, "one.json", "{}")
         h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
-        self.assertEqual(pv.fingerprint(f), "files-v1:" + h)
+        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
+        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
         with self.assertRaises(FileNotFoundError):
             pv.fingerprint(Path(self.tmp.name) / "nothing")
 
@@ -200,6 +242,29 @@ class Code(unittest.TestCase):
         put(self.r, "sub/b.py", "print(2)\n")
         self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])
 
+    def test_untracked_backup_not_dirty_but_kept(self):
+        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
+        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
+        put(self.r, "a.sh.bak", "old\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
+        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
+        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
+        self.assertEqual(len(c["untracked_backups"]), 2)
+        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])
+
+    def test_tracked_backup_change_is_dirty(self):
+        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
+        put(self.r, "x.bak", "1\n")
+        git(self.r, "add", "x.bak")
+        git(self.r, "commit", "-qm", "bak")
+        put(self.r, "x.bak", "2\n")
+        put(self.r, "dir.bak/new.py", "x\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))
+
     def test_not_a_repo(self):
         with tempfile.TemporaryDirectory() as t:
             c = pv.code_info(t)
@@ -207,6 +272,70 @@ class Code(unittest.TestCase):
         self.assertIn("error", c)
 
 
+class Read(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.d = Path(self.tmp.name)
+        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def rd(self, p):
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(p)
+        return r, err.getvalue()
+
+    def test_ok(self):
+        p = pv.write(self.rec, self.d / "train_provenance.json")
+        r, err = self.rd(p)
+        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))
+
+    def test_embedded_key(self):
+        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
+        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
+        r, err = self.rd(self.d / "index.json")
+        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))
+
+    def test_missing_no_warning(self):
+        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
+        for p in (self.d / "nothing.json", self.d / "old.json"):
+            r, err = self.rd(p)
+            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))
+
+    def test_unreadable_returns_and_warns_one_line(self):
+        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
+        cases = {
+            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
+            "empty_provenance.json": b"",
+            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
+            "notutf8_provenance.json": b"\xff\xfe{",
+            "list_provenance.json": b"[1, 2]",
+            "nosv_provenance.json": b'{"a": 1}',
+            "badsv_provenance.json": b'{"schema_version": "other/1"}',
+            "nulled.json": b'{"provenance": null}',
+        }
+        for name, data in cases.items():
+            put(self.d, name, data)
+            r, err = self.rd(self.d / name)
+            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
+            self.assertTrue(r["error"], name)
+            self.assertEqual(len(err.splitlines()), 1, name)
+            self.assertIn("由来なし (読めない)", err)
+            self.assertIn(name, err)
+
+    def test_unreadable_dir_and_no_warn(self):
+        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
+        self.assertEqual(r["state"], "unreadable")
+        self.assertEqual(len(err.splitlines()), 1)
+        put(self.d, "x_provenance.json", "{")
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(self.d / "x_provenance.json", warn=False)
+        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))
+
+
 class BuildWrite(unittest.TestCase):
     def setUp(self):
         self.tmp = tempfile.TemporaryDirectory()

exec
/bin/zsh -lc 'cat /Users/shimazueiichi/r2r2r-content/methodology/data_manifest_rules.md' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
# data_manifest_rules.md

## 台帳・データマニフェスト管理ルール（唯一の正）

*最終更新: 2026-11-01（11節「番号の種類と書き方」・12節「出力に付ける由来（provenance）」を新設し、12節を実装に合わせて直した（指紋 files-v2・outputs・読めない由来の扱い）。台帳の列は変更なし）*

台帳（`pick_place_ledger_v3.csv` / `handover_ledger_v1.csv` / `unity_ledger_v1.csv`）の列・状態語・追記義務はこのファイルが唯一の正。
MASTER のデータ保管セクションが「**どこに置くか**（データの種類 → 場所）」を決めるのに対し、本ファイルは「**台帳の中に何をどう書くか**（列の意味・試行結果の状態語・pose_id 連携・追記義務）」を決める。両者は役割が重ならない。

**優先順位**: データの置き場所（種類 → 場所）は MASTER が優先。台帳の列・状態語・追記条件は本ファイルが優先。矛盾する場合は、MASTER の場所規約を侵さない範囲で本ファイルを優先する。MASTER 側には「台帳の中身は data_manifest_rules.md を参照」とだけ残す。

> 列名・列順の実体は Mac 上の台帳ヘッダを正とする。本ファイルの列定義は推測で焼かず、実測したヘッダで確定する（「パスを推測で焼かない」原則の台帳版）。

---

### 1. 台帳の正体

- 実体: `/Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger_v3.csv`
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない
- 1 試行 = 1 行。横断索引であり、各試行の生成物は `runs/run_pick_place/{run_stem}/` 配下に別途残る
- 列数: 82（2026-07-15 DAY287 で 78 → 82 に拡張。recorder 連続記録 4 列 (79-82) を 8-A 手順で追加。過去 647 データ行は末尾に空セル 4 つ付与で行数・順序不変）。列定義は2節。コードに追記ガードあり（ヘッダ不一致時は `[ABORT-APPEND]` で追記中止）
- 新ヘッダ md5 (ファイル全体・退避元 md5: `384b1e9a3bc8482213157afb5673c204` / 8-A 適用後の live md5: `91dac34289c6701e3b530c15315f2f78`)。archive 退避: `/Volumes/KIOXIA/r2r2r-data/archive/pick_place_ledger_prerestore_20260715/`
- v2 は immutable（歴史的記録として温存・変更禁止）。現行は v3
- 3・4節で定義する状態語の新列を入れるときも、ファイル名は `pick_place_ledger_v3.csv` のまま（リネーム・新バージョン番号を切らない）。旧 v3 を `archive/` にコピー退避してから、新ヘッダの v3 を新規の空台帳として作り直す（8節の 8-B）

---

### 2. 列定義（実体ヘッダで確定・82列。2026-07-15 実測: head -1 | csv.reader = 82）

実体ヘッダ（`pick_place_ledger_v3.csv` の `head -1`）と現行コード `run_pick_place.py` の `LEDGER_COLS` を突合し、82列で一致を確認した。コードには追記ガードが実装済みで、`existing_header != LEDGER_COLS` のとき `[ABORT-APPEND]` で追記を中止する（L758–783）。したがって運用中の v3 にその場で列を足すと追記が弾かれる ── 列を増やすときは旧 v3 を退避して 8-A（過去行を保持したまま末尾に空セル付与）または 8-B（archive→新規空台帳）を踏む。

| #     | 列名                              | 意味（グループ）                                                                       |
| ----- | ------------------------------- | ------------------------------------------------------------------------------ |
| 1     | `timestamp`                     | 試行時刻（ISO8601）                                                                  |
| 2     | `run_stem`                      | 試行識別子（フォルダ名と一致）                                                                |
| 3     | `trial_no`                      | 試行番号                                                                           |
| 4     | `pick_verdict`                  | PICK 成否（yes/no/unknown 系）                                                      |
| 5     | `pick_reason`                   | PICK 判定理由                                                                      |
| 6     | `pick_confidence`               | PICK 判定の確信度                                                                    |
| 7     | `place_verdict`                 | PLACE 成否                                                                       |
| 8     | `place_reason`                  | PLACE 判定理由                                                                     |
| 9     | `place_confidence`              | PLACE 判定の確信度                                                                   |
| 10    | `block_pick_x`                  | PICK 時ブロック X                                                                   |
| 11    | `block_pick_z`                  | PICK 時ブロック Z                                                                   |
| 12    | `block_place_x`                 | PLACE 時ブロック X                                                                  |
| 13    | `block_place_z`                 | PLACE 時ブロック Z                                                                  |
| 14    | `box_x`                         | 箱 X                                                                            |
| 15    | `box_z`                         | 箱 Z                                                                            |
| 16    | `in_box_geom`                   | 箱内判定（幾何）                                                                       |
| 17    | `in_box_dist_mm`                | 箱中心までの距離 mm                                                                    |
| 18–23 | `final_j1`…`final_j6`           | 最終姿勢の各関節角（度）                                                                   |
| 24    | `gripper`                       | グリッパー状態                                                                        |
| 25    | `place_capture_path`            | PLACE 撮影画像パス                                                                   |
| 26    | `tip_pick_x`                    | PICK 時の爪先 X                                                                    |
| 27    | `tip_pick_z`                    | PICK 時の爪先 Z                                                                    |
| 28    | `tip_block_dx_mm`               | 爪先とブロックの X ズレ mm                                                               |
| 29    | `tip_block_dz_mm`               | 爪先とブロックの Z ズレ mm                                                               |
| 30    | `tip_block_dist_mm`             | 爪先とブロックの距離 mm                                                                  |
| 31    | `run_dir`                       | 試行ディレクトリ                                                                       |
| 32    | `tip_capture_path`              | TIP 測定画像パス                                                                     |
| 33    | `pick_capture_path`             | PICK 撮影画像パス                                                                    |
| 34    | `align_j0_final`                | 視覚サーボ後の j0 最終値                                                                 |
| 35    | `align_red_x_mm`                | 赤ブロック X mm                                                                     |
| 36    | `align_converged`               | 視覚サーボ収束フラグ                                                                     |
| 37    | `pick_pose_id`                  | PICK 姿勢の照合キー（4節）                                                               |
| 38–43 | `pick_used_j0`…`pick_used_j5`   | PICK で使った各関節角                                                                  |
| 44    | `place_pose_id`                 | PLACE 姿勢の照合キー（4節）                                                              |
| 45–50 | `place_used_j0`…`place_used_j5` | PLACE で使った各関節角                                                                 |
| 51    | `corr_cell_key`                 | 補正記憶のセルキー（例 `x220_z200`）                                                       |
| 52    | `corr_applied_dx`               | 適用した X 補正量                                                                     |
| 53    | `corr_applied_dz`               | 適用した Z 補正量                                                                     |
| 54    | `target_before`                 | 補正前の狙い                                                                         |
| 55    | `target_after`                  | 補正後の狙い                                                                         |
| 56    | `vlm_pick_verdict`              | VLM-as-Reward の PICK 判定                                                        |
| 57    | `pick_dist_override`            | PICK 距離の手動上書き                                                                  |
| 58    | `side_ap_place_capture_path`    | 横カメラ (Iriun index=1) stage7 到達後撮影の画像パス（判定なし・記録のみ）                              |
| 59    | `side_place_capture_path`       | 横カメラ stage8 到達後撮影の画像パス                                                         |
| 60    | `side_place_verdict`            | 横カメラ PLACE 判定 verdict (yes/no/unknown/"")                                      |
| 61    | `side_place_confidence`         | 横カメラ PLACE 判定 confidence (0.0-1.0)                                             |
| 62    | `side_place_reason`             | 横カメラ PLACE 判定 reason 短文                                                        |
| 63    | `place_corr_cell_key`           | PLACE 補正記憶のセルキー（例 `place_x-40_z100`）— CELL_MM=20mm セル中心・phase プレフィックス "place_" |
| 64    | `place_corr_applied_dx`         | PLACE で適用した X 補正量 mm（EMA 平均後・MAX_CORRECTION_MM=40 でクランプ）                       |
| 65    | `place_corr_applied_dz`         | PLACE で適用した Z 補正量 mm                                                           |
| 66    | `place_target_before`           | 補正前の狙い箱位置 `(+X,+Z)` mm                                                         |
| 67    | `place_target_after`            | 補正後の狙い（fk_lookup へ渡した値）mm                                                      |
| 68    | `box_shift_dx`                  | 箱ズレ補正の X 量（DAY274 追加）                                                          |
| 69    | `box_shift_dz`                  | 箱ズレ補正の Z 量（DAY274 追加）                                                          |
| 70    | `box_shift_dist`                | 箱ズレの距離 mm（DAY274 追加）                                                           |
| 71    | `box_hit_flag`                  | 箱ヒット判定フラグ（DAY274 追加）                                                           |
| 72    | `box_hit_thresh_mm`             | 箱ヒット判定の閾値 mm（DAY274 追加）                                                        |
| 73    | `place_height_cell`             | PLACE 高さ補正記憶のセルキー                                                              |
| 74    | `place_height_dee_y_mm`         | PLACE 高さの dee_y mm                                                             |
| 75    | `place_height_j4_selected`      | PLACE で選択した j4                                                                 |
| 76    | `place_corr_reason`             | PLACE 補正の理由                                                                    |
| 77    | `box_shift_reason`              | 箱ズレ補正の理由                                                                       |
| 78    | `place_img_path`                | PLACE 画像パス                                                                     |
| 79    | `recording_path`                | 連続記録フォルダの相対パス（DAY287 追加）                                                       |
| 80    | `recording_fps`                 | 連続記録の実効 fps（DAY287 追加）                                                         |
| 81    | `recording_frames`              | 連続記録のフレーム数（DAY287 追加）                                                          |
| 82    | `recording_status`              | 連続記録の状態 ok/truncated/camera_lost/mount_lost/none（DAY287 R2 で mount_lost を追加）   |

> 68〜72 は DAY274 追加の箱ズレ系観測/判定列。73〜75 は PLACE 高さ補正記憶、76・77 は補正/箱ズレの理由文字列、78 は PLACE 画像パス。place_img_path(78) と place_capture_path(25)・side_*_capture_path(58,59) の使い分け、place_height 系の細目はコードの書き込み処理を正とする。

> 79〜82 は DAY287 追加の recorder 連続記録メタ列。`recording_path` は `runs/run_pick_place/{run_stem}/recording/` を run_stem 起点の相対パス（recording/）で入れる。`recording_status` の語彙（5 値）: `ok` / `truncated`（前試行の trial_end 未受領・二重 trial_start で強制打切）/ `camera_lost`（read 30 連続失敗）/ `mount_lost`（DAY287 R2・試行中に KIOXIA マウントが消えた）/ `none`（recorder 不在・空欄でなく明示語彙）。recorder が返した `recording_meta.json` を `_append_ledger` が **最大 2.0 秒・100ms 間隔** の有界待ちで存在確認 → 有れば読んで転記、タイムアウト時は none（後からの reconcile はしない・追記のみ原則 7節）。recorder 側は tmp+os.replace の原子的置換で書くので half-written は発生しない。

> 型・必須/任意・単位の細目はコード（`LEDGER_COLS` 周辺の書き込み処理）を正とする。本表は列名・順序・意味の確定版。

確定済みで分かっている列:

- `run_stem` … 1 試行の識別子（フォルダ名と一致）
- `pick_pose_id` / `place_pose_id` … phase 別の照合キー（単一 `pose_id` 列ではない）。j0_lookup 由来は採用 JSON の pose_id、`fk_lookup` 由来は FK テーブル行 ID `fk_row_{selected_idx}` を暫定 pose_id とする（4節）
- `cell` … ブロック検出位置を 20mm 格子に量子化したセルキー（例 `x220_z200`）
- `適用Δz` / `dx_mm` / `dz_mm` … 補正記憶から適用した狙いの補正量
- `tip_dx` / `tip_dz` … 最終 TIP 測定での爪先ズレ（mm）
- `dist` … 爪先と狙いの距離（mm）
- `pick` … 把持結果（現状 yes/空欄。→ 3節の `pick_stage` ＋根拠フラグ列に再設計）

---

### 3. 試行結果の状態語（最重要・保持の山を測るため）

位置には届いたのに `pick` 欄が空のまま終わる試行が出た。これが「未到達」なのか「届いたが保持できなかった」のか、台帳から読めない。保持の山を測定可能にするには、試行結果を段階の状態として持つ必要がある（P008）。

**状態は 2 レイヤーある。列名で必ず分離する**（同じ綴りを別レイヤーで使わない）。

- **レイヤーA: 試行結果の状態** → 列 `pick_stage`（本節で定義）
- **レイヤーB: 姿勢素材の採用状態** → 列 `asset_status` 相当（5節）。語彙 `adopted / superseded / aborted / test`

レイヤーA の中断は `aborted` ではなく `interrupted` と綴る（レイヤーB の `aborted` と同名衝突させない）。

#### レイヤーA: pick_stage（到達段階・要約列）

| pick_stage    | 意味                                 | 判定                           |
| ------------- | ---------------------------------- | ---------------------------- |
| `not_reached` | descend が狙い位置へ届かなかった               | tip dist > 閾値、または送角 timeout  |
| `reached`     | 位置に届いた（把持の成否は別）                    | tip dist ≤ 閾値                |
| `grasped`     | 掴んだが LIFT までに落とした                  | ※現状センサーで一意判定不可・**保留**（下記）    |
| `held`        | LIFT 後も保持（PICK 成功）                 | LIFT 後画像（VLM-as-Reward）で保持確認 |
| `interrupted` | 送角 silent drop / timeout / 安全停止で中断 | `abort_reason` 列に理由を残す       |

`pick_stage` は要約列。これ単独に頼らず、根拠を別のフラグ列で持つ（線形段階だけでは「掴んだが位置はズレていた」のような一直線でないケースを取りこぼすため）。

| 根拠フラグ列            | 型    | 意味                                                                              |
| ----------------- | ---- | ------------------------------------------------------------------------------- |
| `reached_bool`    | bool | tip dist ≤ 閾値で位置到達したか                                                           |
| `grip_commanded`  | bool | GRIP を送ったか                                                                      |
| `held_after_lift` | bool | LIFT 後に保持できていたか（VLM 判定）                                                         |
| `abort_reason`    | str  | 中断理由 `silent_drop`/`timeout`/`j1_reject`/`safety_stop`/`mount_guard` 等。中断でなければ空 |
| `evidence_source` | str  | 判定根拠 `lift_image_vlm` 等                                                         |

`grasped` **は現状保留。** いまのセンシングは LIFT 後画像で pick 成否を見ており、GRIP 直後・LIFT 前の保持確認を持たない。よって「掴んだが LIFT までに落とした（grasped）」を `held`/`reached` から一意に切り分けられない。GRIP 直後の確認画像（または別センサー）が入るまで、`grasped` は使わず `reached`（位置は到達）＋ `held_after_lift=false` で表す。`grasped` を有効化する条件 ＝ GRIP 直後確認の導入、と明記しておく。

なお `reached` ＋ `held_after_lift=false` は、grasp failure（そもそも掴めなかった）と dropped before lift（掴んで落とした）を区別しない暫定表現である（現状センサーでは避けられない）。区別が要るようになったら GRIP 直後確認の導入で `grasped` を有効化する。

> これらの新列（`pick_stage` ＋根拠フラグ列）を運用中の v3 にその場で足すと追記ガードに弾かれる。入れるときは 8節の 8-B（旧 v3 退避 → 新ヘッダで空台帳作り直し）を踏む。

> 状態語彙は `ledger_schema_version` で版を持つ。本節の語彙を v1 とし、語彙を変えるときは版を上げる（過去行の語彙と混ざらないように）。列を増やせない場合でも、本ファイルに「どの版がいつから有効か」を必ず残す。

> ↑ 段階キー名・閾値・理由語彙はドラフト。実機運用の感覚に合わせてレビューで確定する。

---

### 4. pose_id と三系統（live / twin / real）の連携

三系統（live / twin / real）を、台帳は pose_id で結ぶ。

| 系統   | 役割                  | 実体                                                              | 台帳との関係                                                                      |
| ---- | ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| live | 採用姿勢（approach）      | `dynamic_tracking_limits/logs/j0_sweep/j0_sweep_adopted/*.json` | approach で j0_lookup が読む。pose_id を持つ                                        |
| twin | FK 予測器／descend の参照元 | `fk_scan_motion_based_v2.csv`                                   | **descend(pick) で** `fk_lookup` **が runtime に読む**（DAY257 以降）。pose_id で後から照合 |
| real | 試行台帳                | `pick_place_ledger_v3.csv`（本ファイルの対象）                            | 実機が書く。pose_id を残す                                                           |

- 台帳は phase 別に `pick_pose_id` / `place_pose_id` を持つ（単一 `pose_id` 列ではない）
- j0_lookup 由来（approach / place_withblock）は採用 JSON の pose_id を使う
- `fk_lookup` 由来（descend）は、現行コードが返す FK テーブル行 ID `fk_row_{selected_idx}` を**暫定正**の pose_id とする。これで twin（FK テーブル）の該当行と real の結果を後から突き合わせられる
- ただし行 index 単独は永続 ID にしない。FK テーブルは再生成・マージ・ソートで同じ index が別姿勢を指しうる（過去に 512,087→710,704→852,729 と拡張）。テーブル版/ハッシュとセットにして初めて安定識別子とする。推奨形 `fk_pose_id = fk:{table_basename}:{table_hash}:{selected_idx}`。照合補強に 6 軸丸め値 `fk_joint_key = j0..j5` を併記すると強い

---

### 5. データラベリングの status（レイヤーB・既存規約）

姿勢素材・測定バッチの採用状態。試行結果（3節 `pick_stage`）とは別レイヤー。列名 `asset_status` 相当で分離する。

- 語彙は固定 4 語のみ: `adopted` / `superseded` / `aborted` / `test`
- この `aborted` はレイヤーB 専用。試行の中断はレイヤーA の `interrupted` を使う（同名衝突を避ける）
- ラベリング規約: `{phase}_{condition}_{n}pt_{status}_DAY{NNN}_{seq}`

---

### 6. 追記義務（変更禁止）

- 実機の通し完走（PICK→PLACE）を走らせるスクリプトは、本線・派生を問わず、完走／中断時に必ず台帳へ 1 行追記する
- 台帳追記処理を持たない通し完走スクリプトを作らない
- 派生スクリプトが独自の索引 CSV を別に持つ場合も、台帳への追記は省略しない（索引 CSV は補助・台帳が正）

---

### 7. 禁止事項

- `pick_place_ledger_v3.csv` が正本。Mac ローカルに working 台帳を作らない
- `pick_place_ledger_v2.csv` は immutable（変更禁止・温存）
- KIOXIA-only。未マウント時にローカルへ fallback して書かない（mount guard 必須）
- 既存行の上書き・移動・削除をしない（追記のみ）
- 横断索引を `run_pick_place/` ルートに置かない（台帳は `runs/pick_place_ledger_v3.csv` 1 本）

---

### 8. 列を増やすときの手順（v3 一本・リネームしない）

台帳はファイル名 `pick_place_ledger_v3.csv` の一本で通す。新バージョン番号を切ったりリネームしたりしない。運用中の v3 にその場で列を足すと追記ガードに弾かれるので、足す列の種類によって次の 8-A か 8-B のどちらかを踏む。どちらの場合も、列数は実体ヘッダを実測して確かめ、本ファイルに焼いた数字を当てにしない。

#### 8-A. 観測系の列（記録を足すだけで、既存列の意味と試行結果の語彙を変えない列）

撮影パス・観測値・記録メタのような列がこれにあたる（DAY265 の横カメラ 5 列、DAY287 の recorder 4 列が実施例）。

1. **旧 v3 を退避**: 現行の `pick_place_ledger_v3.csv` を `archive/` 配下にコピー退避し、md5 を確認する（移動でなくコピー・元を消さない）
2. **同じファイルの末尾に列を足す**: 現行ヘッダの末尾に新列を加え、過去のデータ行は末尾に空セルを付けて保持する（行数・順序は変えない）
3. **コードを合わせる**: `run_pick_place.py` の `LEDGER_COLS` を新ヘッダと一致させ、追記ガードが通ることを確認する
4. **本ファイルを更新**: 2節の列定義表に実測ヘッダから転記し、1節の列数と md5 を更新する

#### 8-B. 状態語系の列（3節の `pick_stage` 系など、試行結果の語彙を変える列）

1. **旧 v3 を退避**: 現行の `pick_place_ledger_v3.csv` を `archive/pick_place_ledger_premerge_{date}/` にコピー退避する（移動でなくコピー・元を消さない。MASTER 保管ルール準拠）。これで状態語導入前の過去試行は archive 側に残る
2. **新ヘッダの v3 を作り直す**: 同じパス `runs/pick_place_ledger_v3.csv` を、現行ヘッダ（列数は実体ヘッダを実測して確認する）の末尾に新列を加えたヘッダで**新規の空台帳**として作成する。過去行は引き継がず 0 行から開始する（過去行は 1 の archive 側が正）
3. **コードを合わせる**: `run_pick_place.py` の `LEDGER_COLS` を新ヘッダと一致させる。追記ガード（`existing_header != LEDGER_COLS`）が新ヘッダで通るようにする。ファイル名 `LEDGER_CSV_PATH` は変えない
4. **足す新列**: `pick_stage` / `reached_bool` / `grip_commanded` / `held_after_lift` / `abort_reason` / `evidence_source` / `ledger_schema_version`。レイヤーB の `asset_status` も列化するなら同じ手順で末尾に加える
5. **語彙の版**: `ledger_schema_version` に版を持たせ、archive 側（旧）と新台帳で語彙が混ざらないようにする。本ファイル 3節の語彙を v1 とする

> 退避 → 作り直しは過去ログを失う操作ではない（旧 v3 は archive に保全される）。新旧で行が混ざらないぶん、状態語の検証はやりやすい。実施はエイイチの確定後に実装へ渡す。

---

### 9. handover_ledger_v1.csv（受け渡しシリーズの台帳）

受け渡しシリーズ（mechArm 270 Pi 受け渡し動作の成立を探る）の通し台帳。動的追従シリーズの pick_place_ledger_v3.csv とは別ファイル・別シリーズ。シリーズとデータの対応: 動的追従 = `run_pick_place.py` + pick_place_ledger_v3.csv / 受け渡し = `run_pick_handover.py` + handover_ledger_v1.csv。

#### 9-1. 台帳の正体

- 実体: /Volumes/KIOXIA/r2r2r-data/runs/handover_ledger_v1.csv
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない
- 1 試行 = 1 行・追記のみ（既存行の上書き・移動・削除をしない）。横断索引であり、各試行の生成物は runs/run_handover/{run_stem}/ 配下に別途残る
- 列数: 44（初版・設計 r3.3 で確定。実装後に実体ヘッダを正規CSVパースで実測し、本節の列定義表を確定転記する。実測前の本表はドラフト）
- 追記ガードあり: existing_header != HANDOVER_LEDGER_COLS のとき [ABORT-APPEND] で追記中止
- v3（pick_place_ledger_v3.csv）への二重記帳はしない。受け渡し試行の追記義務は本台帳への追記で満たす（1試行1行・1種類1ルート）
- run_dir 列は持たない。試行ディレクトリは run_stem から導出する: runs/run_handover/{run_stem}/

#### 9-2. 追記義務と mount lost 時の扱い

- 受け渡しの通し試行スクリプトは、完走・中断を問わず本台帳へ 1 行追記する（通常時の義務）
- ただし SSD が動作中に失われた場合（mount lost）は同期的に保証できない。このとき記録より物理安全を優先し、試行データは /tmp/handover_emergency_journal/ へ atomic write（tmp 作成 → flush/fsync → os.replace）で退避する
- /tmp は正本の fallback ではなく emergency journal（復旧用の痕跡）。正規データをローカルへ書かない原則は維持する
- recovery: SSD 復旧後、run_stem をキーに台帳の未記帳を確認し、1 回だけ新規 append する。既存行の更新はしない
- マウント確認は各書き込み直前に行う。試行開始前に未マウントなら物理動作を開始せず abort。シーケンス途中の mount lost は新規シーケンスを開始せず abort（trial_status=mount_lost）。人が可動域に手を出した後の mount lost は、インターロックを維持したまま退避シーケンスを完了してから journal へ

#### 9-3. 状態語（v3 とは別レイヤー・handover 用）

- trial_status: completed / pick_failed / motion_failed / release_failed / interrupted / mount_lost / unexpected_error
- outcome（排他）: taken / drop / stuck / unknown / not_applicable
  - stuck = 開いたがブロックが爪に引っかかったまま
  - unknown = 対話記入が得られなかった（中断・入力不能）。unknown の増加は運用異常のシグナルとして扱う
  - not_applicable = PICK 失敗等で提示工程に入らなかった
- outcome_source: operator_visual / operator_with_vlm_reference / unknown（VLM 単独で outcome を確定しない。最終確定は人）
- v3 の pick_stage 系語彙（レイヤーA）とは別の語彙体系。同じ綴りを別レイヤーで使わない原則は本台帳にも適用する

#### 9-4. 列定義（初版 44 列・実装後に実測で確定転記）

| # | 列名 | 意味 |
|---|---|---|
| 1 | timestamp | 試行時刻（ISO8601） |
| 2 | run_stem | 試行識別子（recovery 時の照合キー・run_dir 導出元） |
| 3 | trial_no | 試行番号 |
| 4 | trial_status | 9-3 の状態語 |
| 5 | manual_recovery_required | 手動復帰が必要な状態で終わったか |
| 6 | abort_stage | 中断したシーケンス段階（完走なら空） |
| 7 | abort_reason | 中断理由（arrival_timeout / forbidden_gripper_close 等） |
| 8 | pick_verdict | PICK 成否（VLM・v3 と同語彙） |
| 9 | pick_reason | PICK 判定理由 |
| 10 | pick_confidence | PICK 確信度 |
| 11 | block_pick_x | PICK 時ブロック X |
| 12 | block_pick_z | PICK 時ブロック Z |
| 13 | align_j0_final | 視覚サーボ後 j0 |
| 14 | align_converged | 視覚サーボ収束 |
| 15 | pick_pose_id | PICK 姿勢照合キー（4節の pose_id 規約準拠） |
| 16 | corr_cell_key | PICK 補正セルキー |
| 17 | corr_applied_dx | X 補正量 |
| 18 | corr_applied_dz | Z 補正量 |
| 19 | handover_pose_id | 提示姿勢照合キー（handover_present_pose.json の pose_id） |
| 20 | object_profile_id | ブロック仕様の参照キー（実体は run の handover パラメータ JSON に内包） |
| 21 | wait_plan_id | N 計画の識別子 |
| 22 | wait_plan_index | 計画内の提示順 |
| 23 | shuffle_seed | シャッフル seed |
| 24 | wait_sec_requested | 要求 N |
| 25 | wait_sec_actual | 実測 N（monotonic） |
| 26 | wait_started_at | WAIT 開始時刻 |
| 27 | release_sent_at | RELEASE 送信時刻 |
| 28 | release_responded_at | RELEASE 応答時刻 |
| 29 | release_command_ok | 通信上、指令送信処理を完了したか（物理開放と分離） |
| 30 | release_observed | open / not_open / unknown（物理開放の観測） |
| 31 | release_observed_source | operator / top_camera / side_camera |
| 32 | present_reached_bool | PRESENT 到達判定（実測角ベース）通過 |
| 33 | retreat_completed_bool | RETRACT〜PRESENT_MID 到達判定通過 |
| 34 | home_reached_bool | HOME 到達判定通過 |
| 35 | hand_clear_confirmed | 退避確認入力（インターロック2）が入ったか |
| 36 | outcome | 9-3 の排他語 |
| 37 | outcome_source | 9-3 の判定根拠語 |
| 38 | vlm_taken_verdict | VLM 事後判定（参考値） |
| 39 | vlm_status | ok / http_400_no_credit / error |
| 40 | vlm_reason | VLM 判定理由 |
| 41 | top_capture_status | ok / fail:{reason} |
| 42 | side_capture_status | ok / fail:{reason} |
| 43 | top_capture_path | 上カメラ画像パス（fail 時は空） |
| 44 | side_capture_path | 横カメラ画像パス（fail 時は空） |

型・必須/任意の細目は実装（HANDOVER_LEDGER_COLS 周辺の書き込み処理）を正とする。最終実測角・インターロック入力時刻・入力者区分・object_profile 実体は run_dir 内 JSON に保存し、台帳には持たない。

#### 9-5. 禁止事項

- handover_ledger_v1.csv が正本。Mac ローカルに working 台帳を作らない
- pick_place_ledger_v3.csv への受け渡し試行の記帳をしない（二重記帳禁止）
- 既存行の上書き・移動・削除をしない（追記のみ。recovery も新規 append のみ）
- 未マウント時にローカルへ正規データを書かない（emergency journal は /tmp・atomic write・復旧用の痕跡に限る）
- 列を増やすときは 8節の 8-A / 8-B 判定に従う（本台帳にも同じ手順を適用する。読み替え: `pick_place_ledger_v3.csv` → `handover_ledger_v1.csv`、`run_pick_place.py` の `LEDGER_COLS` → `run_pick_handover.py` の `HANDOVER_LEDGER_COLS`）

---


### 10. unity_ledger_v1.csv（サービスロボット導入シミュレーション・Unity の段階B台帳）

Unity で配膳ロボットの店を回した結果を、1回1行で残す台帳。サイトの試算と比べる段階C の元になる。実機の台帳（v3・handover）とは別シリーズ・別ファイル。

#### 10-1. 台帳の正体

- 実体: `/Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv`（MASTER の保存先「サービスロボット導入シミュレーションの Unity の出力（往復・止まり・条件ごとの数字）→ service_sim/unity/」に含まれる。新しい置き場所は作らない）
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない。未マウントなら回し始めない
- 1回 = 1行・追記のみ（既存行の上書き・移動・削除をしない）。各回の生成物は `service_sim/unity/{出力フォルダ}/` に別に残る（summary.json・trips.csv・stops.csv・dishes.csv・yields.csv など）
- 追記ガードあり: 実体ヘッダとコードの列の並びが違えば追記しない
- 途中で止めた回・失敗した回も1行残す（status で区別）。同じ出力フォルダの行を2度書かない（出力フォルダ名が照合キー）

#### 10-2. 列（初版・実装後に実体ヘッダを実測して確定転記する）

- 回の識別: `timestamp` / `run_folder` / `status`（completed / stopped / failed）/ `unity_cs`（Unity Version Control の changeset）/ `layout_generation` / `params_model`（試算の版。v2.7 など）
- 条件（サイトの unity_conditions と同じ）: `store` / `staff` / `robots` / `arrivals_per_h` / `mode` / `trays` / `fragile_by` / `seed`
- Unity だけの決まりの設定: `staff_first_yield` / `yield_order_enabled` / `one_robot_per_aisle` / `robot_first_wait_s`
- 回り切ったか: `everyone_left` / `total_minutes` / `wall_seconds`
- サイトと同じ定義の数字: `entry_wait_mean` / `staff_wait_mean` / `dish_wait_mean` / `cold_share` / `robot_share` / `staff_util` / `robot_util` / `walk_km_per_staff`
- 同じ条件のサイトの試算の数字（params の site_result から写す）: `site_entry_wait_mean` / `site_staff_wait_mean` / `site_dish_wait_mean` / `site_robot_share`
- Unity が測ってサイトに戻す4つの値: `robot_speed_mps` / `pause_rate` / `pause_s_mean` / `stuck_rate`
- ロボットの往復と止まり: `trips` / `timeouts` / `stuck_count` / `stop_s_total` / `corner_stop_time_share`
- 逃がし弁と助け: `gave_up` / `warped` / `table_freed` / `rescues`
- 料理と譲り: `late_staff_dishes` / `staff_yield_share_of_staff_time`
- `ledger_schema_version`（初版 = 1）

列の意味と計算のしかたは、出力の summary.json と同じ（台帳はそこから写す）。列を増やすときは 8節の 8-A / 8-B に従う（読み替え: `pick_place_ledger_v3.csv` → `unity_ledger_v1.csv`、`LEDGER_COLS` → Unity の台帳の列の定義）。

#### 10-3. 新しいプロジェクトと、夜に何本も同時に回すとき（2026-10-25）

- ServiceSim を新しい Unity プロジェクト（`/Users/shimazueiichi/Robotics_UX/ServiceSim/`）に分けたあとの行は、`unity_cs` を `ServiceSim/cs:N` の形で書く（changeset の番号が1から振り直されるため。今までの `cs:144` などと混ざらないように）
- 何本も同時に回すときは、鍵ファイル `service_sim/unity/unity_ledger_v1.lock` を排他で開けてから1行書く。開けなければ待って試し直す。追記ガードはそのまま
- 夜の実行係が止めた回（時間切れ）・落ちた回は、実行係が status = stopped / failed の行を書く（鍵をかけて）。やり直すときは出力フォルダの名前の後ろに `_try2` を付け、同じ出力フォルダの行を2度書かない決まりを守る
- 何本同時か・どの実行ファイルか・どの夜かは各回の run_info.json に書き、台帳の列は増やさない
- 夜の結果の振り分け（judge_night.py）は台帳を読むだけで書かない。結果は `service_sim/unity/night/{日付}/` の judge.md・judge.csv
- 最初の夜の前に、台帳を `archive/unity_ledger_v1_prenight_{日付}/` にコピー退避する

---

### 11. 番号の種類と書き方（2026-11-01）

記録とデータには、意味の違う番号が何種類もある。見た目が同じ番号を別の意味で使うと、記事とデータを取り違える（例：`lab/Log07` は研究室の7回目、`vision/captured/Log07` は画像認識の7回目で、別物）。番号は付け替えず、種類ごとの意味と書き方を決める。

#### 11-1. 番号の種類

| 種類 | 形 | 意味 |
|---|---|---|
| 記事番号 | `DAY{NNN}` → `LOG{NNN}` | 記事の通し番号。初期は DAY、記事を毎日出さなくなってから LOG に名前を変えた。DAY と LOG は同じ通し番号（LOG は DAY の続き） |
| シリーズ内の番号 | `Log{NN}` | そのシリーズの何回目か。シリーズ名と組でなければ意味を持たない |
| 試行の番号 | `run_stem` の全体（例 `run_0001_20261019_191257`） | 1回の試行。`run_0001` だけでは別の日の試行と重なるので、日付まで含めた全体で1回を指す |
| 学習・推論の回 | `pi05_{YYYYMMDD}_{HHMMSS}` など | 1回の学習・推論の実行 |
| データの版 | `_v1`・`_v2`・`_trim2`・`_corr_v3` など | そのデータセット・モデルの版。記事番号ではない |
| 学習の段数 | checkpoint の step（`1499`・`3999` など） | 学習の何段目のモデルか。記事番号ではない |

記事番号とシリーズ内の番号の正は、公開記事（pixelstomotors.com）とする（MASTER「シリーズの正は公開記事」に従う）。

#### 11-2. 書き方の決まり

- 既存のフォルダ名・ファイル名・台帳の値は付け替えない（記録の改変を始めないため）
- 文章・README・これから出力する meta には、番号を種類つきで書く。例：`記事LOG352`、`研究室Log09`、`画像認識Log07`、`run_0001_20261019_191257`
- これから出力する meta（各回の JSON・`recording_meta.json` など）には、シリーズ名とシリーズ内の番号を組で入れる。分かっていれば記事番号も入れる。記事が後から決まる場合は空のままでよい（対応は11-3で足す）
- 番号どうしの対応（記事番号 ↔ シリーズ内の番号 ↔ 試行・学習の回・データ・checkpoint）は、番号に詰め込まず、索引の関係表に置く（置き場所は MASTER の保存先テーブルに従う）

#### 11-3. 関係表への載せ方

- 載せる根拠は、記事本文・README・meta に書かれた明示の参照を優先する。名前や日付が近いだけのものは「候補」とし、確定と分ける
- 1つの記事が複数のデータを使い、1つのデータが複数の記事で使われることがある（多対多）。関係には種類（作った・使った・比べた など）を付ける
- 仮の番号（例：README に「番号は仮」と書かれたフォルダ）は「仮」として載せ、確定したら確定の行を1行足す（既存の行は書き換えない）

---

### 12. 出力に付ける由来（provenance）（2026-11-01）

データ・モデルが「どのコード・どの入力・どの設定で、どう終わったか」を、作った時点で機械が読める形で残す。主指標の「再現可能」の条件2（依存関係とバージョンが記載されている）を、手書きの README に頼らず満たすため。

#### 12-1. 対象

- **これから作る出力だけ**。既存のデータ・JSON・checkpoint には書き足さない（過去の由来は索引の関係表と README でたどる）
- 段階的に入れる：①学習と checkpoint → ②データセットを作るスクリプト → ③実機の試行（`run_pick_place.py`・recorder など）。③は関節の制御に触れないが、実機の流れの中なので codex のレビューと dry-run を通してから使う

#### 12-2. 置き方

- 既存の出力 JSON（各回の JSON・`recording_meta.json`・データセットの `index.json`・lab の記録など）に、キー `provenance` を1つ足して中に入れる。由来のためだけの JSON を別に増やさない
- 例外：checkpoint は中に書ける JSON がない（`_CHECKPOINT_METADATA` は openpi の形式）。新しく置く checkpoint の各ステップのフォルダ（`checkpoints/<モデル名>/<ステップ>/`）に `train_provenance.json` を1つ置き、同じものをその回の `lab/Log{NN}/` にも写す。Hugging Face に上げるときも同じファイルを入れる
- **台帳の列は増やさない**（8-A の手順を踏まずに済ませるため）。台帳から各回の JSON へは、今までどおり `run_stem` でたどる

#### 12-3. 項目（`schema_version` = `provenance/1`）

| 項目 | 中身 |
|---|---|
| `schema_version` | `provenance/1`。項目を変えるときは版を上げる |
| `created_at` | 作った時刻（ISO8601・タイムゾーンつき） |
| `producer` | スクリプトのパス（r2r2r-scripts からの相対）・実行したコマンドの引数（秘密の値は除く）・実行した機械（Mac / Pod など） |
| `code` | `repo`（r2r2r-scripts）・`commit`・`dirty`（未コミットの変更があるか）・`dirty_files`（変更のあるファイル名）・`diff_sha256`（変更の中身の指紋） |
| `external_code` | 使った外部のコードごとに `name`・`commit`・`dirty`（例 openpi） |
| `inputs` | 入力ごとに `kind`（dataset / checkpoint / run / recording / config）・`id`（フォルダ名やモデル名）・`path`（r2r2r-data からの相対）・`fingerprint` |
| `outputs` | できたものごとに、`inputs` と同じ形（例：学習でできた checkpoint のステップのフォルダ） |
| `config` | 設定の名前と主な値（学習なら設定名・ステップ数・初期の重み・学習率など） |
| `compute` | 学習・推論でクラウドを借りたとき：借り先・GPU の型番・始めと終わりの時刻・時間・費用（MASTER「買う前に借りて測る」の記録） |
| `numbering` | `series`・`series_log`・`article_no`（11節。分からなければ空）。`series_log` は `Log18` のように `Log` を付けた形で書く（数字だけにしない） |
| `status` | `process`（completed / interrupted / failed：処理が最後まで動いたか）と `task`（success / failure / unknown / not_applicable：タスクが成功したか）を分けて書く |

#### 12-4. 指紋（fingerprint）の作り方

- `files-v2:` ＋ sha256。中身は、フォルダ内のファイルを相対パスの順に並べた「相対パス・バイト数」の一覧と、1MB 未満の JSON（`index.json` など）の中身
- 大きなファイル（画像・動画・重み）は中身を読まない。数GBのものを毎回読むと出力が遅くなるため
- 数えないもの：`._` で始まるファイル（exFAT の印）・`.DS_Store`（Finder で開くだけで作られる）・`.cache`・一時フォルダ（`.incoming_*`）・由来のファイルそのもの（`train_provenance.json`・`*_provenance.json`）。由来のファイルを置いたあとに測り直しても、同じ指紋になるようにするため
- 2026-11-01 までの実装は `files-v1`（`.DS_Store` と由来のファイルを数えていた）。`files-v1` で書かれた記録は書き換えず、そのまま残す。比べるときは、同じ版の指紋どうしで比べる

#### 12-5. 守ること

- 未コミットの変更があっても止めない。`dirty: true` と変更のファイル名・指紋を残して進める（研究を止めないため）
- 鍵・トークン・パスワードは書かない。コマンドの引数に秘密が入る場合は値を伏せる
- 由来を書く処理が失敗しても、本来の出力（学習・データ・試行）は止めない。そのときは `provenance` に `error` を書く
- 由来を読む側は、読めない記録（書いている途中で強制終了された・JSON が壊れている）を「由来なし（読めない）」として扱い、警告を出して処理は止めない。読めない記録で、そのデータやモデルを「由来あり」と数えない
- 書き方は共通の部品（r2r2r-scripts の `provenance.py`）にまとめ、各スクリプトはそれを呼ぶ

### 更新履歴

- 2026-11-01（12節を実装に合わせて直した）: 依頼54（学習と checkpoint に由来を記録する・r2r2r-scripts のコミット 1902f37）の報告で Claude Code が挙げた5点を、エイイチの確定で決めた。できたものを書く `outputs` を足した。指紋を `files-v2` にし、`.DS_Store` と由来のファイルそのものを数えないことにした（Finder で開くだけで指紋が変わる・由来を置いたあとに測り直すと合わないため。`files-v1` の記録は書き換えない）。`series_log` は `Log18` の形。読めない由来は「由来なし（読めない）」として警告し、止めない。
- 2026-11-01（12節を新設）: 出力に付ける由来（provenance）の項目と置き方を決めた。Codex の検証で、checkpoint 5件の学習の記録欄（`_CHECKPOINT_METADATA` の custom_metadata）がすべて空で、既存の meta 254件にコードの版を持つものが0件だったため。既存の JSON にキー `provenance` を足す形とし、台帳の列は増やさない。checkpoint だけは `train_provenance.json` を置く。これからの出力だけが対象で、①学習と checkpoint から入れる。

- 2026-11-01（11節を新設）: 番号の種類と書き方を決めた。Codex の検証（iCloud `_index_review/codex_report_20261101.md`）で、意味の違う番号が少なくとも7種類あり、シリーズ名のない `Log{NN}` が別シリーズのデータと取り違えられる実例（研究室 Log07 と画像認識 Log07）が見つかったため。DAY と LOG は同じ記事の通し番号であることをエイイチが確認。既存の名前は付け替えず、これからの meta に種類つきで書き、番号どうしの対応は索引の関係表に置く。台帳の列は変えない。

- 2026-10-25（10-3 を新設）: サービスロボット導入シミュレーションを夜も回すため、新しい Unity プロジェクトでの `unity_cs` の書き方（`ServiceSim/cs:N`）、何本も同時に書くときの鍵ファイル、実行係が書く stopped / failed の行とやり直しの名前（`_try2`）、判定は台帳を書かないこと、最初の夜の前の退避を決めた。列は変えない。設計は iCloud の サービスロボット導入シミュレーション/unity/night_runs_design_v0.md。

- 2026-10-23（unity_ledger_v1 節を新設）: サービスロボット導入シミュレーションの段階B（Unity で全条件を回して残す）のため、台帳 unity_ledger_v1.csv を10節として新設。置き場所は MASTER の既存の行（service_sim/unity/）の中なので MASTER は変えない。1回1行・追記のみ・追記ガード・KIOXIA-only・止めた回と失敗した回も status 付きで残す。列は初版（実装後に実体ヘッダを実測して確定転記する）。
- 2026-10-11（DAY326・ドキュメント整合）: 9-5 が参照していた「8-A / 8-B」が8節に定義されていなかったため、8節に 8-A（観測系の列・同じファイルの末尾に追加・過去行は空セル付与で保持。DAY265・DAY287 の実施例を明文化）と 8-B（状態語系の列・archive 退避→新規空台帳。従来の8節手順）を定義した。8節の手順に焼き付いていた「67 列の後ろに」を「現行ヘッダ（列数は実測して確認）の末尾に」に変更（実体は82列。列数を本文に焼かない）。1節・2節・3節の8節参照を 8-A / 8-B に合わせた。9節の列定義表に行ごとの空行が入っていて表として描画されない状態だったのを修正し、9節の小見出しを H4 に揃えた。9-5 に handover 台帳での読み替えを追記。列定義・列数（v3 は 82 列）・状態語は変更なし。
- 2026-07-16（DAY288・handover_ledger_v1 節を新設）: 受け渡しシリーズの台帳 handover_ledger_v1.csv（runs/ 直下・初版44列・追記ガード・KIOXIA-only）を9節として新設。v3 とはシリーズ・ファイルを分離し二重記帳を禁止。mount lost 時の emergency journal（/tmp・atomic write）と recovery（run_stem キー・1回だけ新規 append）を規定。状態語（trial_status / outcome / outcome_source）は v3 レイヤーA とは別語彙として定義。列定義は設計 r3.3 由来のドラフトであり、実装後に実体ヘッダの正規CSVパース実測で確定転記する（焼き付き乖離の予防）。
- 2026-07-15 (DAY287 R2・codex レビュー反映): recording_status 語彙に `mount_lost` を追加 (試行中に KIOXIA マウントが外れた場合の識別語)。列定義 (2節表) は不変・列数 82 不変。コード側の変更: recorder は tmp+os.replace で recording_meta.json を原子的置換、frame_request は frame_age 0.5s 上限で古いフレームを stale_frame として拒否、frames_captured/frames_written を分離。本線側の変更: `_append_ledger` は recording_meta.json を最大 2.0 秒・100ms 間隔でポーリング (後からの reconcile はしない・追記のみ原則 7節を守る)。j0_sweep_scan `_send_and_wait_arrival` に observer=None 追加 (未指定時は現行完全同一・commanded の ts_send/ts_arrival/attempt/arrived を送信 1 回ごとに通知)。
- 2026-07-15 (DAY287・recorder 導入で 4 列追加 + DAY274/DAY283 chunk の drift 解消): recorder（横カメラ連続録画 + commanded 状態記録）を別プロセスで導入するにあたり、台帳に `recording_path`(79) / `recording_fps`(80) / `recording_frames`(81) / `recording_status`(82) の 4 列を 8-A 手順（過去 647 データ行は末尾に空セル 4 つ付与・順序不変）で追加。列数 78 → 82。並行して 2節表に未転記だった DAY274 の 68〜78 列（`box_shift_*` / `box_hit_*` / `place_height_*` / `place_corr_reason` / `box_shift_reason` / `place_img_path`）も同時に backfill。Step 0 調査: `git log methodology/data_manifest_rules.md` は DAY258 の 2 commit しかなく、DAY283 想定の 68〜78 転記済み版はコミット・working tree いずれにも存在せず、**改訂が未執筆だった**（「認識のみ存在してディスク未反映」の 3 回目 drift を回避）。archive: `archive/pick_place_ledger_prerestore_20260715/`。追記ガードは新 LEDGER_COLS 82 列と新実体ヘッダ 82 列が一致するため通る。既存 pick 系・姿勢制御・完走 HOME 復帰・PLACE 補正記憶ロジックは無変更。recorder 本体はアーム/daemon に一切触らない読み取り観測者（設計原則: recorder が死んでも本線は完走）。
- 2026-07-05 (DAY273): 列数表記を67に統一。実体ヘッダ実測（head -1 | awk NF = 67）で確認。旧記述の57は残存していたもの（経緯不明。DAY258 時点の記録には57とあるが、当時の誤記か以後の列追加かは未追跡。現行実測67が正）。「列数: 67」の既存記述は正しかったため変更なし。
- 2026-07-03（DAY265 後段・PLACE 補正記憶 5 列追加で 67 列化）: PLACE 側の補正記憶 apply/update は DAY262 で既に実装済（`_apply_correction("place", box_xz)` を stage8 で呼び、VLM yes 時に `_corr_update` で EMA 平均・phase プレフィックス `place_` でキー分離・`MAX_CORRECTION_MM=40mm` クランプ）だったが、台帳 CSV に反映される列が無く不可視だった。PICK の corr_cell_key/corr_applied_dx/dz/target_before/target_after (51-55) と同構造の 5 列を末尾に追加：`place_corr_cell_key` (63) / `place_corr_applied_dx` (64) / `place_corr_applied_dz` (65) / `place_target_before` (66) / `place_target_after` (67)。`_append_ledger` は `log_record.correction.place_last` を読み込むだけの追加で、apply/update ロジック・PICK 補正・関節制御に無影響。archive: `archive/pick_place_ledger_premerge_20260703_step2/`。
- 2026-07-03（DAY265・横カメラ観測 5 列追加で 62 列化）: Iriun (index=1) 撮影を stage7/stage8 に追加し、stage8 の横視点画像に対して vlm_judge kind="place_side" で PLACE 状態を判定する経路を導入。新列 `side_ap_place_capture_path` (58) / `side_place_capture_path` (59) / `side_place_verdict` (60) / `side_place_confidence` (61) / `side_place_reason` (62) を 57 列の末尾に追加。8節手順の archive→新規空台帳ルートは、状態語 (3節) の pick_stage 系列で構造化する場合に限定する規定と解釈し、今回の観測系 5 列は「ファイル名 v3 のまま in-file 拡張・過去 279 行は新列を空欄で保持・archive/pick_place_ledger_premerge_20260703/ に退避コピーを保全」で実施。追記ガードは `LEDGER_COLS` の 62 列と実体ヘッダ 62 列が一致するため通る。既存 pick 系・姿勢制御・完走 HOME 復帰は無変更。
- 2026-06-27（DAY258・v3 実体ヘッダ確定）: Mac で `pick_place_ledger_v3.csv` の実体ヘッダ（57列）と `run_pick_place.py` の `LEDGER_COLS` を突合し一致を確認。2節を確定版（57列の列名・順序・意味）に更新。`pick_pose_id`(37)/`place_pose_id`(44) の実在、状態語の新列（`pick_stage` 等）が57列に無いことを実測で確認し、追記ガード（L758–783 `[ABORT-APPEND]`）の存在も確認 ── 運用中の v3 にその場で列を足せないことの裏取りが取れた（列追加は 8節の退避→作り直し手順による）。列数表記を未確定→57に確定。
- 2026-06-27（DAY258・codex 再レビュー反映＋v3 一本方針で確定）: 状態語の新列が現行 57 列に無くヘッダ不一致を誘発する問題（N1）に対し、8節を新設。codex は別ファイル v4 への分離を推したが、台帳はファイル名 `pick_place_ledger_v3.csv` 一本で通す（リネーム・新バージョン番号を切らない）方針をエイイチが確定。列を増やすときは旧 v3 を archive にコピー退避 → 新ヘッダの v3 を新規の空台帳として作り直し → `LEDGER_COLS` を合わせる手順とした（過去行は archive 側が正・新台帳は 0 行から）。`fk_row_{selected_idx}` を行 index 単独で永続 ID にせず、テーブル版/ハッシュとセット（`fk:{table_basename}:{table_hash}:{selected_idx}`）にする規定を追加（N2）。j0_lookup 由来を approach / place_withblock に拡張（R3）。`reached`＋`held_after_lift=false` が grasp failure と dropped before lift を区別しない暫定表現である旨を明記（R5）。
- 2026-06-27（DAY258・codex レビュー反映）: twin を「runtime で読まない」→「descend で `fk_lookup` が runtime に読む（DAY257 以降）」へ訂正。列数 50 を撤回し未確定（現行 `LEDGER_COLS`=57・実体ヘッダ突合で確定）に。単一 `pose_id` を `pick_pose_id` / `place_pose_id` ＋ `fk_lookup` 由来は `fk_row_{selected_idx}` 暫定正へ。レイヤーA を `pick_stage` ＋根拠フラグ列（`reached_bool` / `grip_commanded` / `held_after_lift` / `abort_reason` / `evidence_source`）に再設計。レイヤーA の中断語を `aborted`→`interrupted` に改名（レイヤーB との同名衝突回避）。`grasped` を現状センサーで判定不可として保留（GRIP 直後確認の導入を有効化条件に）。`ledger_schema_version` で語彙を版管理（本節=v1）。MASTER との優先順位（場所=MASTER／中身=本ファイル）と運用前提条件（ヘッダ突合 P0）を明記
- 2026-06-27（DAY258）: 新規作成（ドラフト）。台帳の列・状態語・pose_id 連携・追記義務を一本化。列定義は Mac 実測ヘッダで確定する箇所として保留。試行結果の状態語（レイヤーA: not_reached / reached / grasped / held / aborted）を pick 空欄問題への対応として新設。データラベリング status（レイヤーB・4 語）と別レイヤーである旨を明記

exec
/bin/zsh -lc "sed -n '95,210p' provenance.py; sed -n '1,300p' test_provenance.py" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:

def _check_version(version):
    if version not in FP_VERSIONS:
        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")


def list_files(path, version=FP_VERSION):
    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)

    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
    _check_version(version)
    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
    path = os.path.abspath(path)
    if not os.path.lexists(path):
        raise FileNotFoundError(path)
    if not os.path.isdir(path) or os.path.islink(path):
        return [(_nfc(os.path.basename(path)), os.lstat(path).st_size, path)]
    out = []

    def err(e):
        raise e

    for d, dirs, files in os.walk(path, onerror=err, followlinks=False):
        dirs[:] = [x for x in dirs if not _skip(x)]
        # リンクのフォルダは os.walk が dirs に入れるがたどらない. ファイルとして数える
        for x in [x for x in dirs if os.path.islink(os.path.join(d, x))]:
            dirs.remove(x)
            files.append(x)
        rel = os.path.relpath(d, path)
        for f in files:
            if _skip(f) or skip_file(f):
                continue
            full = os.path.join(d, f)
            p = f if rel == "." else os.path.join(rel, f)
            out.append((_nfc(p.replace(os.sep, "/")), os.lstat(full).st_size, full))
    out.sort(key=lambda x: x[0].encode())
    for a, b in zip(out, out[1:]):
        if a[0] == b[0]:        # NFC にそろえると同じ名前になる別のファイル (並びが決まらない) は指紋にしない
            raise ValueError(f"NFC で同じ名前になるファイルが2つある: {a[0]}")
    return out


def fingerprint(path, version=FP_VERSION):
    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
    files = list_files(path, version)
    h = hashlib.sha256()
    for p, n, _ in files:
        h.update(f"{p}\t{n}\n".encode())
    for p, n, full in files:
        if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
            with open(full, "rb") as fh:
                h.update(b"\0" + p.encode() + b"\0" + fh.read())
    return version + ":" + h.hexdigest()


def fingerprint_version(fp):
    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
    if isinstance(fp, str):
        v, sep, d = fp.partition(":")
        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
            return v
    return None


# ---- コード ----
def _git(args, cwd):
    r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, timeout=60)
    if r.returncode != 0:
        raise RuntimeError(f"git {' '.join(args[:2])}: {r.stderr.decode(errors='replace').strip()[:200]}")
    return r.stdout


def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
    """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
            "diff_sha256": None, "measured_at": now_iso()}
    if paths:
        info["paths"] = list(paths)
    try:
        info["commit"] = _git(["rev-parse", "HEAD"], repo_dir).decode().strip()
        sel = ["--", *paths] if paths else []
        st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
        entries = [e for e in st.decode(errors="replace").split("\0") if e]
        files, untracked, backups, i = [], [], [], 0
        while i < len(entries):
            e = entries[i]
            code, f = e[:2], e[3:]
            if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                i += 1
            if code == "??" and is_untracked_backup(f):
                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
            else:
                files.append(f)
                if code == "??":
                    untracked.append(f)
            i += 1
        files = sorted(set(files))
        info["dirty"] = bool(files)
        info["untracked_backups"] = sorted(set(backups))
        info["dirty_files"] = files[:DIRTY_FILES_MAX]
        if len(files) > DIRTY_FILES_MAX:
            info["dirty_files_total"] = len(files)
        h = hashlib.sha256(_git(["diff", "HEAD", "--binary", *sel], repo_dir))
        for f in sorted(untracked):
            full = os.path.join(repo_dir, f)
            h.update(b"\0untracked\0" + f.encode() + b"\0")
            if os.path.isfile(full) and not os.path.islink(full):
                with open(full, "rb") as fh:
                    h.update(hashlib.sha256(fh.read()).digest())
        info["diff_sha256"] = h.hexdigest() if files else None
    except Exception as e:      # git がない・リポジトリでないなど. 分からないことを残す
        info["error"] = f"{type(e).__name__}: {e}"
    return info


def is_untracked_backup(path):
#!/usr/bin/env python3
"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.

  python3 test_provenance.py -v

一時フォルダと一時の git リポジトリだけを使う (KIOXIA・本物のリポジトリには書かない).
"""
import hashlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unicodedata
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import provenance as pv  # noqa: E402


def put(root, rel, data=b"x"):
    p = Path(root) / rel
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(data if isinstance(data, bytes) else data.encode())
    return p


def git(cwd, *args):
    subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)


class Fingerprint(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.d = Path(self.tmp.name) / "ds"
        put(self.d, "index.json", '{"n": 1}')
        put(self.d, "raw/ep1/frame.jpg", b"\xff" * 50)
        put(self.d, "raw/ep1/meta.json", '{"a": 1}')

    def tearDown(self):
        self.tmp.cleanup()

    def test_format_and_same_twice(self):
        a = pv.fingerprint(self.d)
        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
        self.assertEqual(a, pv.fingerprint(self.d))

    def test_definition(self):
        # 決めた作り方そのもの (相対パス\tバイト数 の一覧 → 1MB 未満の .json の中身)
        h = hashlib.sha256()
        for p, n in [("index.json", 8), ("raw/ep1/frame.jpg", 50), ("raw/ep1/meta.json", 8)]:
            h.update(f"{p}\t{n}\n".encode())
        h.update(b"\0index.json\0" + b'{"n": 1}')
        h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身

    def test_v2_skips_ds_store_and_provenance_files(self):
        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
        a = pv.fingerprint(self.d)
        v1 = pv.fingerprint(self.d, "files-v1")
        put(self.d, ".DS_Store", b"\0" * 6148)
        put(self.d, "raw/.DS_Store", b"\0" * 10)
        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
        self.assertEqual(a, pv.fingerprint(self.d))
        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
        put(self.d, "raw/DS_Store", "x")
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_v2_same_after_writing_provenance_into_output(self):
        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
        pv.write(rec, self.d / "train_provenance.json")
        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")

    def test_version_helpers(self):
        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
            self.assertIsNone(pv.fingerprint_version(bad))
        with self.assertRaises(ValueError):
            pv.fingerprint(self.d, "files-v3")

    def test_skips_agree_with_checkpoint_compares(self):
        # hf_upload_ckpt.py (Hugging Face 側の比べ) と lab_list_files (KIOXIA 側の比べ) が数えない由来・.DS_Store は, 指紋でも数えない
        sys.path.insert(0, str(Path(__file__).resolve().parent / "pi05" / "openpi_addon"))
        import hf_upload_ckpt as hu
        for name in hu.SKIP_REMOTE:
            if name.endswith(".json"):
                self.assertTrue(pv.is_provenance_name(name), name)
        lib = (Path(__file__).resolve().parent / "lab_lib.sh").read_text()
        self.assertIn('f in (".DS_Store", ".gitattributes")', lib)
        self.assertTrue(pv._skip_file_v2(".DS_Store"))

    def test_skips_dotunderscore_cache_incoming(self):
        a = pv.fingerprint(self.d)
        put(self.d, "._index.json", "junk")
        put(self.d, "raw/._ep1", "junk")
        put(self.d, ".cache/huggingface/x.json", "{}")
        put(self.d, ".incoming_1499_x/params/a", "zz")
        self.assertEqual(a, pv.fingerprint(self.d))

    def test_image_content_not_read_but_size_counts(self):
        a = pv.fingerprint(self.d)
        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 50)      # 同じ大きさで中身だけ違う
        self.assertEqual(a, pv.fingerprint(self.d))
        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 51)
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_json_content_counts(self):
        a = pv.fingerprint(self.d)
        put(self.d, "raw/ep1/meta.json", '{"a": 2}')         # 同じ大きさで中身が違う
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_big_json_content_not_read(self):
        put(self.d, "big.json", b"a" * pv.JSON_MAX)
        a = pv.fingerprint(self.d)
        put(self.d, "big.json", b"b" * pv.JSON_MAX)
        self.assertEqual(a, pv.fingerprint(self.d))

    def test_new_file_counts(self):
        a = pv.fingerprint(self.d)
        put(self.d, "raw/ep2/frame.jpg", b"")
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_nfd_names_same_as_nfc(self):
        e = Path(self.tmp.name)
        put(e / "a", unicodedata.normalize("NFC", "データ.txt"), "1")
        put(e / "b", unicodedata.normalize("NFD", "データ.txt"), "1")
        self.assertEqual(pv.fingerprint(e / "a"), pv.fingerprint(e / "b"))

    def test_nfc_collision_raises(self):
        e = Path(self.tmp.name) / "c"
        put(e, unicodedata.normalize("NFC", "が.txt"), "1")
        try:
            put(e, unicodedata.normalize("NFD", "が.txt"), "2")
        except OSError:
            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
        if len(os.listdir(e)) < 2:
            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
        with self.assertRaises(ValueError):
            pv.fingerprint(e)

    def test_symlink_not_followed(self):
        e = Path(self.tmp.name) / "l"
        put(e, "real/a.json", '{"x": 1}')
        os.symlink(self.d, e / "link")
        names = [p for p, _, _ in pv.list_files(e)]
        self.assertEqual(names, ["link", "real/a.json"])

    def test_single_file_and_missing(self):
        f = put(self.tmp.name, "one.json", "{}")
        h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
        with self.assertRaises(FileNotFoundError):
            pv.fingerprint(Path(self.tmp.name) / "nothing")

    def test_unreadable_dir_raises(self):
        locked = self.d / "locked"
        locked.mkdir()
        locked.chmod(0)
        try:
            with self.assertRaises(OSError):
                pv.fingerprint(self.d)
        finally:
            locked.chmod(0o755)


class Redact(unittest.TestCase):
    def test_argv(self):
        argv = ["--log", "Log17", "--hf-token", "abc123", "--api_key=xyz", "--key", "k", "--steps", "1500",
                "hf_ABCDEFGHIJKLMNOP", "--author", "me"]
        self.assertEqual(pv.redact_argv(argv), ["--log", "Log17", "--hf-token", "<redacted>", "--api_key=<redacted>",
                                                "--key", "<redacted>", "--steps", "1500", "<redacted>", "--author", "me"])

    def test_secret_in_dict_key(self):
        rec = pv.build({"notes": {"hf_ABCDEFGHIJKLMNOPQ": 1}}, repo_dir=tempfile.gettempdir())
        self.assertNotIn("hf_ABCDEFGHIJKLMNOPQ", json.dumps(rec))

    def test_secret_file_values_removed_everywhere(self):
        with tempfile.TemporaryDirectory() as t:
            kf = put(t, "runpod.txt", "rpa_SECRETSECRETSECRET\n")
            rec = pv.build({"producer": {"script": "x.py", "argv": ["plain"]},
                            "notes": {"log": "header Bearer rpa_SECRETSECRETSECRET end", "other": "MYSECRET99"},
                            "status": {"process": "completed", "task": "not_applicable"}},
                           repo_dir=t, secrets=pv.read_secrets([kf, put(t, "k2", "MYSECRET99")]))
            s = json.dumps(rec)
            self.assertNotIn("SECRETSECRET", s)
            self.assertNotIn("MYSECRET99", s)


class Code(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.r = Path(self.tmp.name)
        git(self.r, "init", "-q")
        git(self.r, "config", "user.email", "t@example.com")
        git(self.r, "config", "user.name", "t")
        put(self.r, "a.sh", "echo a\n")
        put(self.r, "sub/b.py", "print(1)\n")
        put(self.r, "other.txt", "o\n")
        git(self.r, "add", "a.sh", "sub/b.py", "other.txt")
        git(self.r, "commit", "-qm", "init")

    def tearDown(self):
        self.tmp.cleanup()

    def test_clean(self):
        c = pv.code_info(self.r)
        self.assertRegex(c["commit"], r"^[0-9a-f]{40}$")
        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))

    def test_dirty_modified_and_untracked(self):
        put(self.r, "a.sh", "echo A\n")
        put(self.r, "sub/new.py", "x\n")
        c = pv.code_info(self.r)
        self.assertTrue(c["dirty"])
        self.assertEqual(c["dirty_files"], ["a.sh", "sub/new.py"])
        self.assertRegex(c["diff_sha256"], r"^[0-9a-f]{64}$")
        d1 = c["diff_sha256"]
        put(self.r, "sub/new.py", "y\n")                     # まだ git にないファイルの中身も差分に入る
        self.assertNotEqual(d1, pv.code_info(self.r)["diff_sha256"])

    def test_staged_and_renamed(self):
        git(self.r, "mv", "other.txt", "renamed.txt")
        put(self.r, "a.sh", "echo staged\n")
        git(self.r, "add", "a.sh")
        c = pv.code_info(self.r)
        self.assertEqual(c["dirty_files"], ["a.sh", "renamed.txt"])

    def test_paths_limit(self):
        put(self.r, "other.txt", "changed\n")
        c = pv.code_info(self.r, paths=["a.sh", "sub"])
        self.assertEqual((c["dirty"], c["paths"]), (False, ["a.sh", "sub"]))
        put(self.r, "sub/b.py", "print(2)\n")
        self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])

    def test_untracked_backup_not_dirty_but_kept(self):
        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
        put(self.r, "a.sh.bak", "old\n")
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
        self.assertEqual(len(c["untracked_backups"]), 2)
        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])

    def test_tracked_backup_change_is_dirty(self):
        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
        put(self.r, "x.bak", "1\n")
        git(self.r, "add", "x.bak")
        git(self.r, "commit", "-qm", "bak")
        put(self.r, "x.bak", "2\n")
        put(self.r, "dir.bak/new.py", "x\n")
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))

    def test_not_a_repo(self):
        with tempfile.TemporaryDirectory() as t:
            c = pv.code_info(t)
        self.assertIsNone(c["commit"])
        self.assertIn("error", c)


class Read(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.d = Path(self.tmp.name)
        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)

    def tearDown(self):
        self.tmp.cleanup()

    def rd(self, p):
        err = io.StringIO()
        with redirect_stderr(err):
            r = pv.read(p)
        return r, err.getvalue()

    def test_ok(self):
        p = pv.write(self.rec, self.d / "train_provenance.json")
        r, err = self.rd(p)
        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))

    def test_embedded_key(self):
        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
        r, err = self.rd(self.d / "index.json")
        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))


exec
/bin/zsh -lc "rg -n 'compare_with_pod|lab_list_files|provenance|DS_Store|incoming_|\\.cache' hf_upload_ckpt.py lab_lib.sh; rg --files docs | rg 'review_result_20261101_train_provenance_codex' " in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
rg: hf_upload_ckpt.py: No such file or directory (os error 2)
lab_lib.sh:6:#   - Hugging Face の鍵は ssh の標準入力で Pod の ~/.cache/huggingface/token に書く
lab_lib.sh:203:    #          (.cache/huggingface/download の *.incomplete) が残っている・探せないときは 0
lab_lib.sh:212:  && { [ ! -d $2/.cache/huggingface ] || { r=\$(find $2/.cache/huggingface -name '*.incomplete' -print -quit 2>/dev/null) && [ -z "\$r" ]; }; } && c=1
lab_lib.sh:255:    tr -d '\r\n' < "$HF_TOKEN_FILE" | pod_ssh 'umask 077; mkdir -p ~/.cache/huggingface; cat > ~/.cache/huggingface/token'
lab_lib.sh:434:lab_list_files() {          # lab_list_files フォルダ → 「パス<TAB>バイト数」を名前順に (Mac 側. du は KIOXIA でずれるので stat の大きさ)
lab_lib.sh:435:    # 数えない: .cache/ (hf download の印)・._* (exFAT が付ける)・.DS_Store・.gitattributes・train_state/
lab_lib.sh:445:        dirs[:] = [x for x in dirs if x not in (".cache", "train_state")]
lab_lib.sh:447:        if f.startswith("._") or f in (".DS_Store", ".gitattributes"):
docs/review_result_20261101_train_provenance_codex_r3.md
docs/review_result_20261101_train_provenance_codex_r2.md
docs/review_result_20261101_train_provenance_codex.md
docs/review_result_20261101_train_provenance_codex_r5.md
docs/review_result_20261101_train_provenance_codex_r4.md

exec
/bin/zsh -lc "sed -n '300,370p' test_provenance.py; cat docs/review_result_20261101_train_provenance_codex_r5.md; sed -n '434,465p' lab_lib.sh; rg -n 'SKIP_REMOTE|compare_with_pod|skip|train_state|gitattributes|provenance' pi05/openpi_addon/hf_upload_ckpt.py; rg -n 'compare_with_pod' lab*.sh; sed -n '1,90p' docs/review_result_20261101_train_provenance_codex_r4.md" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:

    def test_missing_no_warning(self):
        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
        for p in (self.d / "nothing.json", self.d / "old.json"):
            r, err = self.rd(p)
            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))

    def test_unreadable_returns_and_warns_one_line(self):
        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
        cases = {
            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
            "empty_provenance.json": b"",
            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
            "notutf8_provenance.json": b"\xff\xfe{",
            "list_provenance.json": b"[1, 2]",
            "nosv_provenance.json": b'{"a": 1}',
            "badsv_provenance.json": b'{"schema_version": "other/1"}',
            "nulled.json": b'{"provenance": null}',
        }
        for name, data in cases.items():
            put(self.d, name, data)
            r, err = self.rd(self.d / name)
            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
            self.assertTrue(r["error"], name)
            self.assertEqual(len(err.splitlines()), 1, name)
            self.assertIn("由来なし (読めない)", err)
            self.assertIn(name, err)

    def test_unreadable_dir_and_no_warn(self):
        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
        self.assertEqual(r["state"], "unreadable")
        self.assertEqual(len(err.splitlines()), 1)
        put(self.d, "x_provenance.json", "{")
        err = io.StringIO()
        with redirect_stderr(err):
            r = pv.read(self.d / "x_provenance.json", warn=False)
        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))


class BuildWrite(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name) / "r2r2r-data"
        put(self.root, "datasets/ds1/index.json", "{}")

    def tearDown(self):
        self.tmp.cleanup()

    def spec(self, **kw):
        s = {"producer": {"script": "lab_train.sh", "argv": ["--log", "Log9"]},
             "inputs": [{"kind": "dataset", "id": "ds1", "path": str(self.root / "datasets/ds1")},
                        {"kind": "checkpoint", "id": "c", "path": "checkpoints/none"}],
             "numbering": {"series": "フィジカルAI研究室", "series_log": "Log9"},
             "status": {"process": "completed", "task": "not_applicable"}}
        s.update(kw)
        return s

    def test_build_fields(self):
        rec = pv.build(self.spec(), data_root=str(self.root), repo_dir=self.tmp.name)
        for k in ("schema_version", "created_at", "producer", "code", "external_code", "inputs", "config",
                  "compute", "numbering", "status"):
            self.assertIn(k, rec)
        self.assertEqual(rec["schema_version"], "provenance/1")
        self.assertRegex(rec["created_at"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d$")
        self.assertEqual(rec["code"]["repo"], "r2r2r-scripts")
        self.assertEqual(rec["inputs"][0]["path"], "datasets/ds1")              # r2r2r-data からの相対
        self.assertEqual(rec["inputs"][0]["fingerprint"], pv.fingerprint(self.root / "datasets/ds1"))
        self.assertEqual((rec["inputs"][1]["fingerprint"], rec["inputs"][1]["fingerprint_error"]), (None, "FileNotFoundError"))
        self.assertEqual(rec["numbering"], {"series": "フィジカルAI研究室", "series_log": "Log9", "article_no": ""})
        self.assertNotIn("error", rec)

# codex レビュー結果 R5：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r5.md（codex exec -s read-only）

**判定：必須0件、推奨2件です。** R4の上書き競合は解消されています。対象範囲の読み取りレビューでは、新たな必須修正は確認しませんでした。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| R4：既存ファイルを上書きしない | **据え置き・修正済み** | [provenance.py:312](/Users/shimazueiichi/r2r2r-scripts/provenance.py:312) は `O_CREAT \| O_EXCL` で直接作成します。先に同名ファイルが作られた場合も失敗し、既存内容を書き換えません。rename競合は解消しています。 |
| 強制終了時の不完全JSON | **推奨** | [provenance.py:315](/Users/shimazueiichi/r2r2r-scripts/provenance.py:315) は短い書込みに対応して反復する実装です。「1回で書く」というコメントとは異なり、単一writeでも完了の原子性は保証されません。KILL時には空・途中のJSONが残り、次回は存在済みとしてスキップされます。②③へ展開する前に、読込側のJSON検証と、不完全記録の扱いを決めてください。既存ファイルの上書き問題ではありません。 |
| 由来処理中のsignalテスト | **推奨** | `hf_prov_hang`には外側の監視が追加されています。一方、`early_fail → finish → prov_write`中のINT・TERMは未検証です。この経路では親のsignal trapが残っているため、通知回数・終了コード・子プロセス残存を確認するテストを推奨します。 |
| Pod削除・通知・終了コード | 据え置き | Pod削除の試行は由来処理より先です。`finish`は終了コードをローカル変数に保持し、通常の由来失敗で変更しません。追加部分に明白な`set -u`違反は見当たりません。全体監視は300秒で、打切り時には停止猶予などが加わります。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のspecに接続先IP・ポートを直接渡す経路は見当たりません。SSH出力のマスク、引数の伏字、鍵形式・secret-file内容・辞書キーの置換が維持されています。 |
| KIOXIAの既存データ・未マウント時 | 据え置き | `PROV_CKPT_DIR`は新規配置と最終照合の成功後だけ設定され、既存1499には設定されません。由来処理はフォルダを作らず、DATA_ROOT・LOGDIRがなければ書きません。datasets・runsへ追記する経路も見当たりません。 |
| Hugging Face・再取得・起動互換性 | 据え置き | `HFUP ok`だけ由来を追加し、`same`では追加しません。比較時の由来除外と、取得用一時フォルダ内の過去の由来除去は整合しています。`lab_start.sh --model corr`の取得・利用経路にも破綻は見当たりません。 |
| status・開始時情報・openpi | 据え置き | 学習完了後は`completed`を維持し、保存・削除失敗はerrorへ記録します。開始時スナップショット、取得失敗入力の固定、`--stop`での復元、不完全なopenpi取得を不明とする修正も維持されています。 |
| 指紋・dirtyの対象 | 据え置き | 指定4パスへの限定は今回の範囲として妥当です。`.DS_Store`を数えることと、出力指紋が由来配置前の値であることは現行仕様どおりです。②③では、この前提に合わせた再検証手順が必要です。 |

シェル4ファイル・Python4ファイルの構文確認は成功しました。テスト本体は書込みやプロセス操作を伴うため実行しておらず、**30件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須なし。ここでレビューを終える
- 推奨（書きかけの JSON）: write の説明を「書き終わるまで write をくり返す・KILL のときだけ書きかけが残りうる・読む側は JSON として読めるかを確かめる」に直した。読む側の扱いは ②③ を作るときにエイイチに決めてもらう
- 推奨（early_fail の中の INT・TERM のテスト）: 据え置き（Pod を借りる前で Hugging Face へは足さない。中の python は 120 秒の上限）
lab_list_files() {          # lab_list_files フォルダ → 「パス<TAB>バイト数」を名前順に (Mac 側. du は KIOXIA でずれるので stat の大きさ)
    # 数えない: .cache/ (hf download の印)・._* (exFAT が付ける)・.DS_Store・.gitattributes・train_state/
    python3 -I -c '
import os, sys
root = sys.argv[1]
out = []
def err(e):
    raise e                 # 読めないフォルダを黙って飛ばさない (一覧が足りないまま「合った」としない)
for d, dirs, files in os.walk(root, onerror=err):
    rel = os.path.relpath(d, root)
    if rel == ".":
        dirs[:] = [x for x in dirs if x not in (".cache", "train_state")]
    for f in files:
        if f.startswith("._") or f in (".DS_Store", ".gitattributes"):
            continue
        p = os.path.normpath(os.path.join(rel, f))
        out.append((p, os.path.getsize(os.path.join(d, f))))
for p, s in sorted(out, key=lambda x: x[0].encode()):
    print(f"{p}\t{s}")
' "$1"
}

lab_watchdog_script() {     # Pod 側の上限の見張り (lab_train.sh が /workspace/r2r2r/lab_watchdog.sh に置き, setsid nohup で裏で回す)
    cat <<'WATCHDOG_EOF'
#!/bin/bash
# lab_watchdog.sh — 上限の時刻を過ぎたら, この Pod を自分で消す (lab_train.sh が置く. 依頼 49)
# Mac が止まって lab_train.sh が Pod を消せなくても, Pod が残り続けないための保険.
# 鍵は Mac から送らない. Runpod が Pod に入れる RUNPOD_POD_ID と Pod 用の鍵 RUNPOD_API_KEY を使う
# (ssh のシェルには入っていないことがあるので, 1 番のプロセスの環境と /etc/rp_environment からも探す). 鍵は書き出さない
F=${LAB_WD_DEADLINE:-/workspace/logs/.lab_deadline}     # 上限の時刻 (epoch). lab_train.sh が書き換えて延ばす
L=${LAB_WD_LOG:-/workspace/logs/watchdog.log}
RUNPOD_REST=${LAB_WD_API:-https://rest.runpod.io/v1}
7:上げるもの: train_state/ 以外の全部 (params/・assets/・_CHECKPOINT_METADATA). リポジトリの一番上に置く
22:SKIP_TOP = ("train_state",)
23:SKIP_REMOTE = (".gitattributes", "train_provenance.json")     # 由来は Mac の lab_train.sh があとで足す (依頼 54)
27:    """train_state/ を除くファイルの (パス, バイト数) を名前順に"""
44:    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes・train_provenance.json は数えない)"""
48:        if size is None or x.path in SKIP_REMOTE:      # フォルダには size がない
81:    print(f"[HF    ] 上げるもの: {len(mine)} ファイル {total:,} バイト (train_state/ は上げない)", flush=True)
lab_train.sh:438:compare_with_pod() {        # compare_with_pod フォルダ → Pod 側の一覧と名前・バイト数が全部同じなら 0
lab_train.sh:456:        if compare_with_pod "$dest"; then KIOXIA_MSG="$dest はもとからあり, Pod 側と全部同じ (書き換えていない)"; return 0; fi
lab_train.sh:473:    if ! compare_with_pod "$tmp"; then KIOXIA_MSG="取ったものが Pod 側の一覧と合わない (そのまま $tmp に置いた)"; return 1; fi
lab_train.sh:481:    compare_with_pod "$dest" || { KIOXIA_MSG="$dest に移したあとの一覧が Pod 側と合わない"; return 1; }
# codex レビュー結果 R4：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r4.md（codex exec -s read-only）

**判定：必須1件、推奨2件です。** R3のグループ停止は改善されています。由来の公開処理は、exFAT向けフォールバックに上書き競合が残っています。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| exFATでの`rename`競合 | **必須** | [provenance.py:325](/Users/shimazueiichi/r2r2r-scripts/provenance.py:325) は`lexists(path)`確認後に`os.rename(tmp, path)`します。その間に別の実行が同名ファイルを作ると、**既存ファイルを上書きします**。KIOXIAで使う経路なので、「上書きしない」という契約を満たしていません。上書き禁止を原子的に保証する公開方式へ変更してください。対応できない環境では失敗として返す方が安全です。テストには、存在確認後・rename直前に別の書込みが入るケースを追加してください。 |
| 強制終了時の一時ファイル | **推奨** | [provenance.py:313](/Users/shimazueiichi/r2r2r-scripts/provenance.py:313) の一時ファイルは、KILLされると`finally`で消せません。完成先に不完全JSONが残る問題は改善しましたが、「一時ファイルは残さない」は強制終了時には成立しません。また、この名前は`fingerprint()`の除外対象ではなく、以後の指紋に混入します。残存時の扱いと指紋からの除外方針を定めてください。 |
| ハング・signalテスト | **推奨** | [test_lab_train.sh:410](/Users/shimazueiichi/r2r2r-scripts/test_lab_train.sh:410) は、TERMを無視して孫を継続生成するケースへ改善されています。ただし時間の判定は`run`が戻った後なので、本当に戻らない回帰ではテストも停止します。外側にも監視を置き、さらに**由来処理中のINT・TERM**を追加してください。`early_fail`から入る場合は、wrap内のsignal無視とは異なる経路です。 |
| `kill_group`と`set -m` | 据え置き・R3改善確認 | [lab_train.sh:206](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:206) はグループ単位で停止するため、列挙後に生まれた同グループの孫も対象になります。このMacのBashで、内側のパイプライン・非同期処理が同グループに残ることを確認しました。既存のtee・caffeinateを対象グループへ移す処理もありません。由来処理は端末入力を必要とせず、明白な端末アクセスの回帰は見当たりません。 |
| linkによる公開 | 据え置き・R3修正確認 | [provenance.py:322](/Users/shimazueiichi/r2r2r-scripts/provenance.py:322) のlink成功経路は、完成したJSONを上書きなしで公開できます。同名への競合は`FileExistsError`になります。問題は上記のrenameフォールバックです。 |
| 片付け・通知・終了コード | 据え置き | Pod削除の試行は由来処理より先です。spec生成・Python・HF追加は全体監視の内側にあり、通常の失敗で保存済み終了コードを変える経路や、追加部分の明白な`set -u`違反は見当たりません。時間切れ時は300秒に加えて停止猶予などがかかります。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のspecへIP・ポートを直接渡す経路は見当たりません。引数、既知の鍵形式、secret-fileの内容、辞書キーへの伏字処理も維持されています。 |
| 既存KIOXIA・未マウント時 | 据え置き・競合は必須欄 | `PROV_CKPT_DIR`は新規配置の最終照合成功後だけ設定され、既存1499には設定されません。由来処理自身はフォルダを作りません。datasets・runsへ書き足す通常経路も見当たりません。 |
| HF・`lab_start.sh`互換性 | 据え置き | HF側とKIOXIA側の比較除外は整合しています。`same`取得時の過去の由来除去も維持されています。`lab_start.sh --model corr`の取得・利用経路に、JSON追加による破綻は見当たりません。 |
| 指紋・dirty対象 | 据え置き | 指定4パスへのdirty限定と開始時の測定は妥当です。`.DS_Store`を数えること、出力指紋が由来配置前の値であることは現行仕様どおりです。②③では、その前提に合う再検証手順が必要です。 |

シェル4ファイル・Python4ファイルの構文確認は成功しました。テスト本体は書込み・プロセス停止を伴うため実行しておらず、**29件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須（exFAT の rename の競合）: 一時ファイル→link/rename をやめ、置く先を O_EXCL で直に作って1回の write（数 KB）で書く形に戻した。あれば必ず失敗するので上書きはしない（exFAT でも同じ）。書けなければ消す。test_provenance.py に「作る直前に別の実行が置いた」「書けなかった」を足す
- 推奨（一時ファイル）: 一時ファイルを作らなくなったので、残って指紋に入ることはない。残るのは「1回の write の途中で KILL された」ときの書きかけだけ（数 KB・据え置き）
- 推奨（テスト）: hf_prov_hang の回を外側でも 90 秒で止める見張りを置いた。early_fail の中で由来を書いている途中の INT・TERM は、Pod を借りる前で Hugging Face へは足さない（python の 120 秒の上限）ので、テストは足さず据え置き

codex
checkpoint内の独立した由来ファイルは、files-v2で除外されます。一方、`index.json`などへ埋め込む由来は指紋に含まれるため、②③への展開時は区別が必要です。`read()`の例外処理と、既存の比較処理との除外範囲も確認しています。
exec
/bin/zsh -lc "sed -n '22,60p' pi05/openpi_addon/hf_upload_ckpt.py; sed -n '418,450p' lab_train.sh; sed -n '450,490p' test_lab_train.sh; nl -ba provenance.py | sed -n '382,443p'; nl -ba test_provenance.py | sed -n '90,108p'" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
SKIP_TOP = ("train_state",)
SKIP_REMOTE = (".gitattributes", "train_provenance.json")     # 由来は Mac の lab_train.sh があとで足す (依頼 54)


def local_files(root):
    """train_state/ を除くファイルの (パス, バイト数) を名前順に"""
    out = []

    def err(e):
        raise e             # 読めないフォルダを黙って飛ばさない

    for d, dirs, files in os.walk(root, onerror=err):
        rel = os.path.relpath(d, root)
        if rel == ".":
            dirs[:] = [x for x in dirs if x not in SKIP_TOP]
        for f in files:
            p = os.path.normpath(os.path.join(rel, f))
            out.append((p, os.path.getsize(os.path.join(d, f))))
    return sorted(out, key=lambda x: x[0].encode())


def remote_files(api, repo):
    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes・train_provenance.json は数えない)"""
    out = []
    for x in api.list_repo_tree(repo, recursive=True, repo_type="model"):
        size = getattr(x, "size", None)
        if size is None or x.path in SKIP_REMOTE:      # フォルダには size がない
            continue
        out.append((x.path, int(size)))
    return sorted(out, key=lambda x: x[0].encode())


def diff_lines(a, b, n=10):
    da, db = dict(a), dict(b)
    lines = []
    for p in sorted(set(da) | set(db), key=str.encode):
        if da.get(p) != db.get(p):
            lines.append(f"  {p}: 手元 {da.get(p, 'なし')} / リポジトリ {db.get(p, 'なし')}")
    return lines[:n] + ([f"  ほか {len(lines) - n} 件"] if len(lines) > n else [])
    if [ "$kind" != crash ] && [ -n "$TRAIN_T0" ]; then
        LAB_TO=$LAB_TO_SHORT pssh "$(lab_train_kill_cmd)" >/dev/null
    fi
    if fetch_logs; then logs="ログを持ち帰った ($FETCHED 個)"; else logs="ログは持ち帰れなかった (Pod に入れない)"; fi
    if delete_pod; then
        RESULT_LINES=$(printf '%s\n' "わけ: $why" "$logs → $LOGDIR" "Pod (id $POD_ID) は消した (API で確かめた)" "$COST_LINE")
        finish 1 "失敗: $why. $logs. Pod は消しました. ${COST_LINE#借りていた時間 }"
    else
        RESULT_LINES=$(printf '%s\n' "わけ: $why" "$logs → $LOGDIR" "$(pod_left_msg)" "${COST_LINE:-}")
        finish 1 "失敗: $why. $logs. $(pod_left_msg)"
    fi
}

on_signal() { wrap_fail signal "$1 で止めた"; }
trap 'on_signal Ctrl-C' INT
trap 'on_signal "TERM (kill)"' TERM
trap 'on_signal "ターミナルが閉じた (HUP)"' HUP

# ---- KIOXIA の checkpoints/ に置く ----
POD_LIST=""                  # Pod 側の最後のチェックポイントの一覧 (パス<TAB>バイト数. train_state なし)
compare_with_pod() {        # compare_with_pod フォルダ → Pod 側の一覧と名前・バイト数が全部同じなら 0
    local mine d rc
    mine=$(mktemp)
    # 由来 (train_provenance.json) は Mac があとで足すので数えない (依頼 54)
    if ! { lab_list_files "$1" | { grep -v $'^train_provenance\.json\t' || true; }; } > "$mine" || [ ! -s "$POD_LIST" ]; then
        echo "[KIOXIA] 一覧を作れない (KIOXIA 側 $1 か Pod 側 $POD_LIST). 合ったとはしない"; rm -f "$mine"; return 1
    fi
    d=$(diff "$mine" "$POD_LIST"); rc=$?; rm -f "$mine"
    [ $rc = 0 ] && return 0          # diff の終了コードが 0 のときだけ「合った」(2 = 読めない も合わないとする)
    echo "[KIOXIA] Pod 側の一覧と合わない (diff の終了コード $rc. < KIOXIA / > Pod. 先頭 20 行):"
    head -20 <<<"$d" | sed 's/^/         /'
    return 1
}
run kioxia_gone
check "KIOXIA が外れた: 由来は書かない (フォルダも作り直さない)・最後まで片付けて通知" '[ $RC = 1 ] && [ ! -e "$T/data" ] && ! find "$T/data_unmounted" -name "train_provenance*.json" | grep -q . && has out.txt "由来: KIOXIA がマウントされていない" && [ $(n_call DELETE) = 1 ] && [ $(wc -l < "$T/notify.log") = 1 ]'
rm -rf "$T"
# dirty: 一時の git リポジトリに lab_train.sh などを入れ, 変える前・変えたあとで code を見る
G=$(mktemp -d); (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t \
    && cp "$HERE/lab_train.sh" "$HERE/lab_lib.sh" "$HERE/provenance.py" . && mkdir pi05 && echo x > pi05/a.py && echo y > other.txt \
    && git add lab_train.sh lab_lib.sh provenance.py pi05/a.py other.txt && git commit -qm init)
LAB_PROV_REPO="$G" run success
check "dirty でない: code.dirty=false・dirty_files なし" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r .code.commit "$TD/train_provenance.json")" = "$(git -C "$G" rev-parse HEAD)" ]'
rm -rf "$T"
echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
LAB_PROV_REPO="$G" run success
check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
rm -rf "$T"
# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
LAB_PROV_REPO="$G" run success
check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
rm -rf "$T"
echo "# changed" >> "$G/lab_lib.sh"
LAB_PROV_REPO="$G" run success
check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
rm -rf "$T" "$G"
OUT1=""

rm -rf "${OUT1:-}"
echo
echo "OK $OK / NG $NG"
[ $NG = 0 ]
   382	
   383	
   384	# ---- 読む ----
   385	def read(path, warn=True):
   386	    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
   387	
   388	    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
   389	    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
   390	      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
   391	      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
   392	      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
   393	                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
   394	    path = os.fspath(path)
   395	    res = {"state": "missing", "path": path, "provenance": None, "error": None}
   396	    try:
   397	        with open(path, "rb") as fh:
   398	            raw = fh.read()
   399	    except FileNotFoundError:
   400	        res["error"] = "ファイルがない"
   401	        return res
   402	    except Exception as e:      # 読めないファイル (権限・フォルダなど)
   403	        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
   404	    try:
   405	        doc = json.loads(raw.decode("utf-8"))
   406	    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
   407	        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
   408	    rec = doc
   409	    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
   410	        if "provenance" in doc:
   411	            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
   412	        elif not is_provenance_name(os.path.basename(path)):
   413	            res["error"] = "キー provenance がない"
   414	            return res
   415	    if not isinstance(rec, dict):
   416	        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
   417	    sv = rec.get("schema_version")
   418	    if not _is_prov_schema(sv):
   419	        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
   420	    res.update(state="ok", provenance=rec)
   421	    return res
   422	
   423	
   424	def _is_prov_schema(sv):
   425	    return isinstance(sv, str) and sv.startswith("provenance/")
   426	
   427	
   428	def _unreadable(res, why, warn):
   429	    res.update(state="unreadable", error=why)
   430	    if warn:
   431	        print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
   432	    return res
   433	
   434	
   435	# ---- CLI ----
   436	def main(argv=None):
   437	    ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
   438	    sub = ap.add_subparsers(dest="cmd", required=True)
   439	    f = sub.add_parser("fingerprint")
   440	    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
   441	    f.add_argument("path")
   442	    pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
   443	    pr.add_argument("--data-root", default=None)
    90	    def test_skips_agree_with_checkpoint_compares(self):
    91	        # hf_upload_ckpt.py (Hugging Face 側の比べ) と lab_list_files (KIOXIA 側の比べ) が数えない由来・.DS_Store は, 指紋でも数えない
    92	        sys.path.insert(0, str(Path(__file__).resolve().parent / "pi05" / "openpi_addon"))
    93	        import hf_upload_ckpt as hu
    94	        for name in hu.SKIP_REMOTE:
    95	            if name.endswith(".json"):
    96	                self.assertTrue(pv.is_provenance_name(name), name)
    97	        lib = (Path(__file__).resolve().parent / "lab_lib.sh").read_text()
    98	        self.assertIn('f in (".DS_Store", ".gitattributes")', lib)
    99	        self.assertTrue(pv._skip_file_v2(".DS_Store"))
   100	
   101	    def test_skips_dotunderscore_cache_incoming(self):
   102	        a = pv.fingerprint(self.d)
   103	        put(self.d, "._index.json", "junk")
   104	        put(self.d, "raw/._ep1", "junk")
   105	        put(self.d, ".cache/huggingface/x.json", "{}")
   106	        put(self.d, ".incoming_1499_x/params/a", "zz")
   107	        self.assertEqual(a, pv.fingerprint(self.d))
   108	

exec
/bin/zsh -lc "python3 -B -c 'import io; import provenance as p; from unittest.mock import patch
class BrokenStderr:
 def write(self,s): raise OSError(\"stderr unavailable\")
 def flush(self): pass
with patch(\"builtins.open\",return_value=io.BytesIO(b\"{\")),patch(\"sys.stderr\",BrokenStderr()):
 try: p.read(\"train_provenance.json\")
 except Exception as e: print(\"stderr:\",type(e).__name__,str(e))
with patch(\"builtins.open\",return_value=io.BytesIO(b\"{}\")):
 try: p.read(b\"train_provenance.json\")
 except Exception as e: print(\"bytes path:\",type(e).__name__,str(e))'" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 50ms:
python3: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
python3: error: couldn't create cache file '/tmp/xcrun_db-THBtWiwX' (errno=Operation not permitted)
python3: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
python3: error: couldn't create cache file '/tmp/xcrun_db-e2mxTDp9' (errno=Operation not permitted)
stderr: OSError stderr unavailable
bytes path: TypeError endswith first arg must be bytes or a tuple of bytes, not str

codex
**判定：必須1件、推奨3件です。** checkpointのfiles-v2化と控えの分離は依頼どおりです。`read()`には、警告の出力失敗で例外が漏れる経路があります。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| 警告出力で処理が止まる | **必須** | [provenance.py:431](/Users/shimazueiichi/r2r2r-scripts/provenance.py:431) の`print(..., file=sys.stderr)`が保護されていません。標準エラーが閉じている・出力先が失われた場合、壊れたJSONを読むと警告側の例外が漏れます。モックで`OSError`の伝播を確認しました。警告出力の失敗を捕捉し、`unreadable`を返してください。12-5の「処理は止めない」に関わります。 |
| bytes形式のパス | **推奨** | [provenance.py:412](/Users/shimazueiichi/r2r2r-scripts/provenance.py:412) は、`read(b"train_provenance.json")`で中身が`{}`の場合、名前判定の`endswith(str)`から`TypeError`が漏れます。モックで確認済みです。`os.fsdecode(os.fspath(path))`で文字列に統一するか、受け付ける型を明記してください。 |
| 既存比較との整合テスト | **推奨** | [test_provenance.py:90](/Users/shimazueiichi/r2r2r-scripts/test_provenance.py:90) は定数とソース文字列の確認で、比較処理の実行結果は検証していません。また、既存比較が除く`.gitattributes`・トップの`train_state/`はfiles-v2では数えます。「除外条件全体が一致する」という説明は成立しません。今回追加した由来・`.DS_Store`に限定した整合性と、既存の差をテスト・説明で分けてください。 |
| 埋め込み由来と再測定 | **推奨** | `index.json`・`recording_meta.json`内の`provenance`は、小さなJSONの中身として指紋に入ります。したがって、**埋め込み後は指紋が変わります**。今回のcheckpoint経路の不具合ではありませんが、「由来を置いたあとも同じ」という説明は独立した除外対象ファイルに限定し、②③へ展開する前に測定順序を決めてください。 |
| files-v2の除外 | 据え置き | `.DS_Store`・`*_provenance.json`を全階層のファイルから除外し、従来の`._*`・`.cache`・`.incoming_*`の除外を維持しています。単一ファイルを明示した場合の例外も依頼どおりです。checkpointへ由来を追加・更新しても、その追加による指紋変化はありません。 |
| `read()`の通常経路 | 据え置き | 壊れたJSON・非UTF-8・非dict・不正schema・フォルダは`unreadable`になります。外側に別schemaがある埋め込みJSONも読めます。ファイル不存在・由来キーのない旧JSONは`missing`です。上記の例外漏れを除き、依頼の分類を満たします。 |
| `untracked_backups` | 据え置き | `??`かつ最後の名前に`.bak`を含むものだけを分離します。追跡済み`.bak`の変更と、`dir.bak/new.py`はdirtyに残ります。控え一覧の件数制限はなく、差分指紋からも除外されます。ただし実コードでも名前に`.bak`があれば除外される点は、確定した名前規則そのものの性質です。 |
| files-v1の保持 | 据え置き | 同じ版で過去記録と比較するため妥当です。確認した差分では、v1の計算処理は維持され、追加除外だけがv2に適用されます。版取得とCLI指定も整合しています。 |
| `fingerprint_note`削除 | 据え置き | checkpointの独立した由来ファイルが除外されたため、配置前という注記を削除して問題ありません。 |

規則12節と過去レビューを参照し、対象4ファイルの差分を確認しました。テスト本体は一時ファイルへの書込みを伴うため実行せず、上記2件のみメモリ上のモックで確認しました。報告されたテスト件数・成功数は未追試です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映
- 必須「警告出力で処理が止まる」: `_unreadable` の print を try で包み、標準エラーに書けなくても `unreadable` を返す。テストを足した
- 推奨「bytes 形式のパス」: `os.fsdecode(os.fspath(path))` で文字列にそろえる。パスにならないもの（数など）も `unreadable` で返す。テストを足した
- 推奨「既存比較との整合テスト」: ソースの文字列を見るテストをやめ、hf_upload_ckpt.remote_files（偽の api）と lab_list_files（bash で実際に流す）と指紋を、由来と .DS_Store を足す前・あとで比べるテストにした。.gitattributes・train_state/ などは元から比べと指紋で違うことをテストの説明に書いた
- 推奨「埋め込み由来と再測定」: provenance.py の説明に「キー provenance を JSON に足したときは指紋が変わる」と書いた。②③での測る順番はエイイチが決めること（報告に書く）
```

### review_request_20261101_provenance_v2_r2.md（Codex のレビュー2回目の依頼・56番）

```markdown
# コードレビュー依頼：由来（provenance）の指紋を files-v2 にし、読めない由来・控えのファイルの扱いを足す（2026-11-01 依頼 56・依頼 54 の続き） R2

前提: 依頼 54（コミット 1902f37）で入れた provenance.py の「決めてほしいこと」をエイイチが確定した。決まりの正は `~/r2r2r-content/methodology/data_manifest_rules.md` の 12節（とくに 12-4 指紋・12-5 守ること）。
過去のレビュー: docs/review_result_20261101_train_provenance_codex*.md

対象（`git diff HEAD -- provenance.py test_provenance.py lab_train.sh test_lab_train.sh`。ほかのファイルの未コミット差分は対象外）

## 1. 指紋 files-v2（provenance.py）
- 接頭辞を `files-v2:` にした。v1 からの違いは、ファイルの名前が `.DS_Store`・`train_provenance.json`・`*_provenance.json` のものをどの深さでも数えないことだけ。`._*`・`.cache`・`.incoming_*` はこれまでどおり（フォルダごと）
- `fingerprint(path, version="files-v2")`。`version="files-v1"` で前の作り方を残した（files-v1 の記録と同じ版で比べるため。12-4「同じ版どうしで比べる」）。CLI は `fingerprint [--version files-v1] <path>`。`fingerprint_version(fp)` で記録の指紋の版を読む
- ファイル1つを渡したときは、名前で数えないものでもそのファイル1つで測る
- hf_upload_ckpt.py（Hugging Face 側でトップの train_provenance.json を数えない）・lab_list_files（KIOXIA 側で .DS_Store を数えない）・compare_with_pod（トップの train_provenance.json を数えない）は変えていない。これらが数えないものは files-v2 でも数えないので食い違わない（テストで確かめる）

## 2. 由来を読む `read(path, warn=True)`（provenance.py）
- 由来のファイル、またはキー `provenance` を中に持つ JSON（外側に別の schema_version があってもよい）を読む。戻りは `{state: ok|missing|unreadable, path, provenance, error}`
- 書きかけ・JSON が壊れている・UTF-8 でない・dict でない・schema_version が `provenance/` で始まらない・読めない（フォルダなど）は例外を出さず `unreadable`（由来なし）と警告1行（標準エラー）
- ファイルがない・キー provenance がない（由来を入れる前の JSON）は `missing`（警告なし）

## 3. code の untracked_backups（provenance.py）
- `git status` で `??`（git に入っていない）かつ名前（最後の部分）に `.bak` を含むものは dirty_files に入れず `untracked_backups` に分ける。dirty はそれ以外の変更があるときだけ true。diff_sha256 も控えを除いた変更で作る（変更がなければ null）
- untracked_backups は件数で切らずに全部残す（Pod へ送る中身に入るので）。git に入っている .bak を変えたもの・`.bak` を含むフォルダの中のふつうの名前は控えとみなさない

## 4. lab_train.sh
- outputs の `fingerprint_note`（置く前に測った）を消した。中身はほかに変えていない

## 5. テスト
- test_provenance.py 41 件（1 件 skip）: v2 で数えないもの・由来を置いたあとに測り直しても outputs の指紋と合う・v1 を残す・版の読み・hf/KIOXIA の比べと食い違わない・read の ok/埋め込み/missing/unreadable 8 通り・控え
- test_lab_train.sh OK 190 / NG 0: 偽物の実行で、由来と .DS_Store のあるチェックポイントを測り直しても outputs の指紋と合う・控えだけなら dirty=false・控えとほかの変更
- test_lab_start.sh OK 136 / NG 0・pi05 の各テスト OK

## 見てほしい点
1. files-v2 の数えない決め方が 12-4 と合っているか。由来を置いたあとに測り直しても同じになる・ならない道がないか
2. read() が「読めない記録を由来ありと数えない・止めない」を満たすか。例外が漏れる道がないか
3. untracked_backups の判定（`??` かつ名前に `.bak`）で、本当の変更を見落として dirty=false にする道がないか
4. hf_upload_ckpt.py・compare_with_pod・lab_list_files と files-v2 の食い違い
5. files-v1 を残したことの是非

ファイル変更・実機操作・Pod の操作・Hugging Face の操作はしないでください。読み取りだけでお願いします。日本語で、表（項目 / 判定 / 確認結果・修正案）と、必須 / 推奨 / 据え置きの分類で返してください。

## R2 で見てほしいこと
R1 の結果と反映は docs/review_result_20261101_provenance_v2_codex.md の「反映」。read() の警告・bytes のパスの直しと、比べとの整合テスト（test_skips_agree_with_checkpoint_compares）が妥当か、ほかに必須の問題が残っていないか。テストは test_provenance.py 42 件（1 件 skip）。
```

### review_result_20261101_provenance_v2_codex_r2.md（Codex のレビュー2回目の結果・56番）

```markdown
# codex レビュー結果 R2：由来の指紋 files-v2・読めない由来・控えのファイル（2026-11-01 依頼 56）

依頼文: docs/review_request_20261101_provenance_v2_r2.md（codex exec -s read-only）

指定の4ファイルの差分、規則12節、R1の反映内容を読み取りで確認します。ファイル変更や実機・外部サービスの操作は行いません。
exec
/bin/zsh -lc 'git diff HEAD -- provenance.py test_provenance.py lab_train.sh test_lab_train.sh' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-BTuns7Tv' (errno=Operation not permitted)
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-mau4IPcM' (errno=Operation not permitted)
diff --git a/lab_train.sh b/lab_train.sh
index f55b640..78f3c46 100755
--- a/lab_train.sh
+++ b/lab_train.sh
@@ -189,8 +189,7 @@ prov_spec() {               # 由来の spec (lab_train.sh が知っているこ
          inputs: $start.inputs,
          code: ($start.code // null),
          outputs: (if $ck_dir == "" then [] else [{kind: "checkpoint", id: ($ckpt_name + "/" + $step), path: $ck_dir,
-                   hf_repo: (if $prov_hf == "1" then $repo_out else null end),
-                   fingerprint_note: "train_provenance.json を置く前に測った"}] end),
+                   hf_repo: (if $prov_hf == "1" then $repo_out else null end)}] end),     # 指紋 files-v2 は由来のファイルを数えない (依頼 56)
          config: {name: $config, exp_name: $exp, num_train_steps: ($steps | num), init_params: ($init | str), data_name: $data,
                   limit_hours: ($limit | num), checkpoint_step: ($step | str)},
          compute: {provider: "runpod", pod_id: ($pod | str), gpu: ($gpu | str), started_at: ($t0 | str), train_started_at: ($tt | str),
diff --git a/provenance.py b/provenance.py
index ac5162e..5dd2bb9 100644
--- a/provenance.py
+++ b/provenance.py
@@ -1,10 +1,11 @@
 #!/usr/bin/env python3
-"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54).
+"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54・56).
 
 決まりの正は data_manifest_rules.md 12節. 出す JSON は schema_version "provenance/1".
 
 bash から:
-  python3 provenance.py fingerprint <フォルダかファイル>            指紋 (files-v1:<sha256>) を1行で出す
+  python3 provenance.py fingerprint [--version files-v1] <フォルダかファイル>
+      指紋 (files-v2:<sha256>) を1行で出す. --version files-v1 は前の作り方 (files-v1 の記録と比べるときだけ)
   python3 provenance.py prepare --data-root <r2r2r-data> [--code-path ...] < spec.json
       始める時点のコード (code) と入力の指紋 (inputs) を JSON で出す. 終わったときに write の spec に入れて渡す
   python3 provenance.py write --data-root <r2r2r-data> --out <置く先> [--out <もう1つ>] \\
@@ -20,15 +21,26 @@ Python から:
                           "status": {"process": "completed", "task": "not_applicable"}},
                          data_root="/Volumes/KIOXIA/r2r2r-data")
   provenance.write(rec, "<出力フォルダ>/dataset_provenance.json")      # あれば FileExistsError
+  r = provenance.read("<出力フォルダ>/dataset_provenance.json")         # 読めなくても止めない (下の read())
+  if r["state"] == "ok": rec = r["provenance"]
 
-指紋 files-v1 (fingerprint()):
+指紋 files-v2 (fingerprint()):
   フォルダの中のファイルを相対パス (NFC・/ 区切り) の UTF-8 のバイト順に並べ,
     1) 全部のファイルの「相対パス\\tバイト数\\n」
     2) そのうち名前が .json で終わり 1MB 未満のファイルについて「\\0相対パス\\0」+ 中身
   をこの順に sha256 に入れる. 画像・動画・重みの中身は読まない.
-  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと).
+  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと)・
+    .DS_Store (Finder で開くだけで作られる)・由来のファイルそのもの (train_provenance.json・*_provenance.json).
+    由来を別のファイルとして置いたあとに測り直しても, 置く前と同じ指紋になる (data_manifest_rules.md 12-4).
+    ただし index.json・recording_meta.json などにキー provenance を足したときは, その JSON の中身が指紋に入るので変わる.
+  files-v1 (2026-11-01 まで) は .DS_Store と由来のファイルも数えていた. それ以外は同じ. 比べるときは同じ版どうしで比べる.
   リンクはたどらない (リンク自身の大きさ). ファイル1つを渡したときは, その名前1つの一覧として同じに作る.
 
+code の dirty: git に入っていない控えのファイル (名前に .bak を含む. 例 build_demo_episodes.py.bak_pre_LOG352_20261019) は
+  dirty_files に入れず untracked_backups に分けて残す (Pod へ送る中身には入るので必ず残す). dirty はそれ以外の変更があるときだけ true.
+
+読む (read()): 書きかけ・JSON が壊れている・由来の形でないものは例外で止めず state "unreadable" (由来なし) を返し, 警告を1行出す.
+
 鍵・トークンは書かない: 引数の --token / --key / --secret / --password などの値と, hf_・rpa_ などで始まる鍵らしい文字は
 「<redacted>」に置き換え, --secret-file で渡した鍵のファイルの中身が記録のどこかに出れば同じく置き換える.
 """
@@ -47,7 +59,9 @@ import unicodedata
 SCHEMA = "provenance/1"
 REPO_NAME = "r2r2r-scripts"
 REPO_DIR = os.path.dirname(os.path.abspath(__file__))
-FP_PREFIX = "files-v1:"
+FP_VERSION = "files-v2"      # いまの指紋の作り方
+FP_VERSIONS = ("files-v1", "files-v2")
+FP_PREFIX = FP_VERSION + ":"
 JSON_MAX = 1 << 20          # これ未満の .json は中身も指紋に入れる
 REDACTED = "<redacted>"
 INPUT_KINDS = ("dataset", "checkpoint", "run", "recording", "config")
@@ -62,15 +76,35 @@ SECRET_VALUE = re.compile(r"\b(hf_[A-Za-z0-9]{10,}|rpa_[A-Za-z0-9]{10,}|sk-[A-Za
 
 # ---- 指紋 ----
 def _skip(name):
+    """フォルダもファイルも数えない名前 (files-v1・v2 とも)"""
     return name.startswith("._") or name == ".cache" or name.startswith(".incoming_")
 
 
+def is_provenance_name(name):
+    """由来のファイルの名前か (train_provenance.json・*_provenance.json)"""
+    return name == "train_provenance.json" or name.endswith("_provenance.json")
+
+
+def _skip_file_v2(name):
+    """files-v2 でだけ数えないファイル: .DS_Store と由来のファイル"""
+    return name == ".DS_Store" or is_provenance_name(name)
+
+
 def _nfc(s):
     return unicodedata.normalize("NFC", s)
 
 
-def list_files(path):
-    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)"""
+def _check_version(version):
+    if version not in FP_VERSIONS:
+        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")
+
+
+def list_files(path, version=FP_VERSION):
+    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)
+
+    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
+    _check_version(version)
+    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
     path = os.path.abspath(path)
     if not os.path.lexists(path):
         raise FileNotFoundError(path)
@@ -89,7 +123,7 @@ def list_files(path):
             files.append(x)
         rel = os.path.relpath(d, path)
         for f in files:
-            if _skip(f):
+            if _skip(f) or skip_file(f):
                 continue
             full = os.path.join(d, f)
             p = f if rel == "." else os.path.join(rel, f)
@@ -101,8 +135,9 @@ def list_files(path):
     return out
 
 
-def fingerprint(path):
-    files = list_files(path)
+def fingerprint(path, version=FP_VERSION):
+    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
+    files = list_files(path, version)
     h = hashlib.sha256()
     for p, n, _ in files:
         h.update(f"{p}\t{n}\n".encode())
@@ -110,7 +145,16 @@ def fingerprint(path):
         if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
             with open(full, "rb") as fh:
                 h.update(b"\0" + p.encode() + b"\0" + fh.read())
-    return FP_PREFIX + h.hexdigest()
+    return version + ":" + h.hexdigest()
+
+
+def fingerprint_version(fp):
+    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
+    if isinstance(fp, str):
+        v, sep, d = fp.partition(":")
+        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
+            return v
+    return None
 
 
 # ---- コード ----
@@ -123,7 +167,8 @@ def _git(args, cwd):
 
 def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
-    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "diff_sha256": None, "measured_at": now_iso()}
+    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
+            "diff_sha256": None, "measured_at": now_iso()}
     if paths:
         info["paths"] = list(paths)
     try:
@@ -131,18 +176,22 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
         sel = ["--", *paths] if paths else []
         st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
         entries = [e for e in st.decode(errors="replace").split("\0") if e]
-        files, untracked, i = [], [], 0
+        files, untracked, backups, i = [], [], [], 0
         while i < len(entries):
             e = entries[i]
             code, f = e[:2], e[3:]
             if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                 i += 1
-            files.append(f)
-            if code == "??":
-                untracked.append(f)
+            if code == "??" and is_untracked_backup(f):
+                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
+            else:
+                files.append(f)
+                if code == "??":
+                    untracked.append(f)
             i += 1
         files = sorted(set(files))
         info["dirty"] = bool(files)
+        info["untracked_backups"] = sorted(set(backups))
         info["dirty_files"] = files[:DIRTY_FILES_MAX]
         if len(files) > DIRTY_FILES_MAX:
             info["dirty_files_total"] = len(files)
@@ -159,6 +208,11 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     return info
 
 
+def is_untracked_backup(path):
+    """git に入っていない控えのファイルとみなす名前か (名前に .bak を含む. 例 a.py.bak・a.py.bak_pre_LOG352_20261019)"""
+    return ".bak" in path.rstrip("/").rsplit("/", 1)[-1]
+
+
 # ---- 鍵を伏せる ----
 def redact_argv(argv):
     out, hide_next = [], False
@@ -328,11 +382,70 @@ def write(rec, path):
     return path
 
 
+# ---- 読む ----
+def read(path, warn=True):
+    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
+
+    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
+    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
+      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
+      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
+      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
+                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
+    try:
+        path = os.fsdecode(os.fspath(path))     # bytes のパスも文字列にそろえる (codex 依頼56 R1)
+    except Exception as e:
+        return _unreadable({"state": "missing", "path": repr(path), "provenance": None, "error": None},
+                           f"パスとして読めない ({type(e).__name__})", warn)
+    res = {"state": "missing", "path": path, "provenance": None, "error": None}
+    try:
+        with open(path, "rb") as fh:
+            raw = fh.read()
+    except FileNotFoundError:
+        res["error"] = "ファイルがない"
+        return res
+    except Exception as e:      # 読めないファイル (権限・フォルダなど)
+        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
+    try:
+        doc = json.loads(raw.decode("utf-8"))
+    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
+        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
+    rec = doc
+    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
+        if "provenance" in doc:
+            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
+        elif not is_provenance_name(os.path.basename(path)):
+            res["error"] = "キー provenance がない"
+            return res
+    if not isinstance(rec, dict):
+        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
+    sv = rec.get("schema_version")
+    if not _is_prov_schema(sv):
+        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
+    res.update(state="ok", provenance=rec)
+    return res
+
+
+def _is_prov_schema(sv):
+    return isinstance(sv, str) and sv.startswith("provenance/")
+
+
+def _unreadable(res, why, warn):
+    res.update(state="unreadable", error=why)
+    if warn:
+        try:                    # 標準エラーが閉じていても止めない (codex 依頼56 R1)
+            print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
+        except Exception:
+            pass
+    return res
+
+
 # ---- CLI ----
 def main(argv=None):
     ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
     sub = ap.add_subparsers(dest="cmd", required=True)
     f = sub.add_parser("fingerprint")
+    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
     f.add_argument("path")
     pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
     pr.add_argument("--data-root", default=None)
@@ -348,7 +461,7 @@ def main(argv=None):
     a = ap.parse_args(argv)
     try:
         if a.cmd == "fingerprint":
-            print(fingerprint(a.path))
+            print(fingerprint(a.path, a.version))
             return 0
         if a.cmd == "prepare":
             print(json.dumps(prepare(json.loads(sys.stdin.read() or "{}"), a.data_root, a.repo_dir, a.code_path), ensure_ascii=False))
diff --git a/test_lab_train.sh b/test_lab_train.sh
index 85683ea..bb13062 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -382,9 +382,10 @@ check "成功: provenance/1・ISO8601 (タイムゾーンつき)・lab_train.sh
 check "成功: 入力とコードは始める時点に測る・記録にも残す (codex R1)" '[ "$(jq -c .prov_start.code "$TD/lab_pod.json")" = "$(jq -c .code "$P")" ] && [ "$(pj ".inputs[0].measured_at <= .compute.started_at")" = true ] && [[ "$(pj ".inputs[2].sha256")" =~ ^[0-9a-f]{64}$ ]]'
 check "成功: code (r2r2r-scripts のコミット・dirty・見たパス)" '[ "$(pj .code.repo)" = r2r2r-scripts ] && [ "$(pj .code.commit)" = "$(git -C "$HERE" rev-parse HEAD)" ] && [[ "$(pj .code.dirty)" =~ ^(true|false)$ ]] && [ "$(pj ".code.paths | join(\",\")")" = "lab_train.sh,lab_lib.sh,provenance.py,pi05" ]'
 check "成功: Pod の openpi のコミットと変更" '[ "$(pj ".external_code[0].name")" = openpi ] && [ "$(pj ".external_code[0].commit")" = 215abfb1234567890abcdef1234567890abcdef0 ] && [ "$(pj ".external_code[0].dirty")" = true ] && [ "$(pj ".external_code[0].dirty_files[0]")" = src/openpi/training/config.py ]'
-check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v1:* ]]'
+check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v2:* ]]'
 check "成功: 設定・compute (GPU・始めと終わり・時間・費用は [COST] と同じ)・numbering・status" '[ "$(pj .config.name)" = pi05_mecharm_trim_corr_lora ] && [ "$(pj .config.num_train_steps)" = 1500 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.cost_per_hr_usd)" = 0.44 ] && [ "$(pj .compute.duration_sec)" = "$(jq .rented_sec "$TD/lab_pod.json")" ] && [ -n "$(pj ".compute.started_at // empty")" ] && [ -n "$(pj ".compute.ended_at // empty")" ] && grep -qF "[COST  ] $(pj .compute.cost_line)" "$T/out.txt" && [ "$(pj .numbering.series)" = フィジカルAI研究室 ] && [ "$(pj .numbering.series_log)" = LogT ] && [ "$(pj ".status | [.process,.task] | join(\" \")")" = "completed not_applicable" ] && [ "$(pj ".error // empty")" = "" ]'
-check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v1:* ]]'
+check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v2:* ]]'
+check "成功: 由来 (と .DS_Store) を置いたあとに測り直しても出力の指紋と合う (files-v2. 依頼 56)" '[ -f "$CK/1499/train_provenance.json" ] && [ -f "$CK/1499/.DS_Store" ] && [ "$(pj ".outputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$CK/1499")" ] && [ "$(pj ".outputs[0].fingerprint")" != "files-v2:$(python3 "$HERE/provenance.py" fingerprint --version files-v1 "$CK/1499" | cut -d: -f2)" ]'
 # Pod の openpi を読むコマンドを, この Mac の bash で一時の git リポジトリに向けて流す (codex R1)
 oc=$(grep -F ": lt=openpi_rev" "$T/ssh.log" | head -1); G=$(mktemp -d)
 (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t && echo a > f && git add f && git commit -qm i && echo b >> f && echo n > new)
@@ -459,6 +460,15 @@ rm -rf "$T"
 echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
 LAB_PROV_REPO="$G" run success
 check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
+rm -rf "$T"
+# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
+(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
+LAB_PROV_REPO="$G" run success
+check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+echo "# changed" >> "$G/lab_lib.sh"
+LAB_PROV_REPO="$G" run success
+check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
 rm -rf "$T" "$G"
 OUT1=""
 
diff --git a/test_provenance.py b/test_provenance.py
index bf1313a..dc53249 100644
--- a/test_provenance.py
+++ b/test_provenance.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54) の確かめ.
+"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.
 
   python3 test_provenance.py -v
 
@@ -45,7 +45,7 @@ class Fingerprint(unittest.TestCase):
 
     def test_format_and_same_twice(self):
         a = pv.fingerprint(self.d)
-        self.assertRegex(a, r"^files-v1:[0-9a-f]{64}$")
+        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
         self.assertEqual(a, pv.fingerprint(self.d))
 
     def test_definition(self):
@@ -55,7 +55,69 @@ class Fingerprint(unittest.TestCase):
             h.update(f"{p}\t{n}\n".encode())
         h.update(b"\0index.json\0" + b'{"n": 1}')
         h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
-        self.assertEqual(pv.fingerprint(self.d), "files-v1:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身
+
+    def test_v2_skips_ds_store_and_provenance_files(self):
+        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
+        a = pv.fingerprint(self.d)
+        v1 = pv.fingerprint(self.d, "files-v1")
+        put(self.d, ".DS_Store", b"\0" * 6148)
+        put(self.d, "raw/.DS_Store", b"\0" * 10)
+        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
+        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
+        self.assertEqual(a, pv.fingerprint(self.d))
+        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
+        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
+        put(self.d, "raw/DS_Store", "x")
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_v2_same_after_writing_provenance_into_output(self):
+        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
+        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
+        pv.write(rec, self.d / "train_provenance.json")
+        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
+        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")
+
+    def test_version_helpers(self):
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
+        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
+            self.assertIsNone(pv.fingerprint_version(bad))
+        with self.assertRaises(ValueError):
+            pv.fingerprint(self.d, "files-v3")
+
+    def test_skips_agree_with_checkpoint_compares(self):
+        # 由来 (トップの train_provenance.json) と .DS_Store を足したとき, hf_upload_ckpt.py (Hugging Face 側の一覧) と
+        # lab_list_files (KIOXIA 側の一覧) と指紋 files-v2 のどれも変わらない. 由来・.DS_Store のほかの数えないもの
+        # (.gitattributes・train_state/ など) は比べと指紋で元から違う (この依頼では変えない)
+        here = Path(__file__).resolve().parent
+        sys.path.insert(0, str(here / "pi05" / "openpi_addon"))
+        import hf_upload_ckpt as hu
+
+        class Item:
+            def __init__(self, path, size):
+                self.path, self.size = path, size
+
+        class Api:
+            def __init__(self, root):
+                self.root = root
+
+            def list_repo_tree(self, repo, recursive, repo_type):
+                return [Item(p, n) for p, n, _ in pv.list_files(self.root, "files-v1")]
+
+        def kioxia(d):
+            r = subprocess.run(["bash", "-c", '. "$1"; lab_list_files "$2"', "_", str(here / "lab_lib.sh"), str(d)],
+                               capture_output=True, text=True, check=True)
+            return r.stdout
+
+        before = (pv.fingerprint(self.d), hu.remote_files(Api(self.d), "r"), kioxia(self.d))
+        put(self.d, ".DS_Store", b"\0" * 6148)
+        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
+        after_fp, after_hf, after_kx = pv.fingerprint(self.d), hu.remote_files(Api(self.d), "r"), kioxia(self.d)
+        self.assertEqual(before[0], after_fp)
+        self.assertEqual(before[1], [x for x in after_hf if x[0] != ".DS_Store"])     # Hugging Face に .DS_Store は上がらない
+        self.assertEqual(before[2], "".join(x for x in after_kx.splitlines(True) if not x.startswith("train_provenance.json\t")))
 
     def test_skips_dotunderscore_cache_incoming(self):
         a = pv.fingerprint(self.d)
@@ -116,7 +178,8 @@ class Fingerprint(unittest.TestCase):
     def test_single_file_and_missing(self):
         f = put(self.tmp.name, "one.json", "{}")
         h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
-        self.assertEqual(pv.fingerprint(f), "files-v1:" + h)
+        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
+        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
         with self.assertRaises(FileNotFoundError):
             pv.fingerprint(Path(self.tmp.name) / "nothing")
 
@@ -200,6 +263,29 @@ class Code(unittest.TestCase):
         put(self.r, "sub/b.py", "print(2)\n")
         self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])
 
+    def test_untracked_backup_not_dirty_but_kept(self):
+        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
+        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
+        put(self.r, "a.sh.bak", "old\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
+        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
+        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
+        self.assertEqual(len(c["untracked_backups"]), 2)
+        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])
+
+    def test_tracked_backup_change_is_dirty(self):
+        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
+        put(self.r, "x.bak", "1\n")
+        git(self.r, "add", "x.bak")
+        git(self.r, "commit", "-qm", "bak")
+        put(self.r, "x.bak", "2\n")
+        put(self.r, "dir.bak/new.py", "x\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))
+
     def test_not_a_repo(self):
         with tempfile.TemporaryDirectory() as t:
             c = pv.code_info(t)
@@ -207,6 +293,92 @@ class Code(unittest.TestCase):
         self.assertIn("error", c)
 
 
+class Read(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.d = Path(self.tmp.name)
+        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def rd(self, p):
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(p)
+        return r, err.getvalue()
+
+    def test_ok(self):
+        p = pv.write(self.rec, self.d / "train_provenance.json")
+        r, err = self.rd(p)
+        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))
+
+    def test_embedded_key(self):
+        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
+        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
+        r, err = self.rd(self.d / "index.json")
+        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))
+
+    def test_missing_no_warning(self):
+        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
+        for p in (self.d / "nothing.json", self.d / "old.json"):
+            r, err = self.rd(p)
+            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))
+
+    def test_unreadable_returns_and_warns_one_line(self):
+        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
+        cases = {
+            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
+            "empty_provenance.json": b"",
+            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
+            "notutf8_provenance.json": b"\xff\xfe{",
+            "list_provenance.json": b"[1, 2]",
+            "nosv_provenance.json": b'{"a": 1}',
+            "badsv_provenance.json": b'{"schema_version": "other/1"}',
+            "nulled.json": b'{"provenance": null}',
+        }
+        for name, data in cases.items():
+            put(self.d, name, data)
+            r, err = self.rd(self.d / name)
+            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
+            self.assertTrue(r["error"], name)
+            self.assertEqual(len(err.splitlines()), 1, name)
+            self.assertIn("由来なし (読めない)", err)
+            self.assertIn(name, err)
+
+    def test_bytes_path_and_closed_stderr(self):
+        # codex 依頼56 R1: bytes のパス・警告を出せない標準エラーでも例外にしない
+        p = put(self.d, "x_provenance.json", "{}")
+        self.assertEqual(pv.read(os.fsencode(p), warn=False)["state"], "unreadable")
+        pv.write(self.rec, self.d / "train_provenance.json")
+        self.assertEqual(pv.read(os.fsencode(self.d / "train_provenance.json"))["state"], "ok")
+
+        class Broken:
+            def write(self, s):
+                raise OSError("closed")
+
+            def flush(self):
+                pass
+        old = sys.stderr
+        sys.stderr = Broken()
+        try:
+            r = pv.read(p)
+        finally:
+            sys.stderr = old
+        self.assertEqual(r["state"], "unreadable")
+        self.assertEqual(pv.read(12345, warn=False)["state"], "unreadable")
+
+    def test_unreadable_dir_and_no_warn(self):
+        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
+        self.assertEqual(r["state"], "unreadable")
+        self.assertEqual(len(err.splitlines()), 1)
+        put(self.d, "x_provenance.json", "{")
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(self.d / "x_provenance.json", warn=False)
+        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))
+
+
 class BuildWrite(unittest.TestCase):
     def setUp(self):
         self.tmp = tempfile.TemporaryDirectory()

exec
/bin/zsh -lc 'cat /Users/shimazueiichi/r2r2r-content/methodology/data_manifest_rules.md' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
# data_manifest_rules.md

## 台帳・データマニフェスト管理ルール（唯一の正）

*最終更新: 2026-11-01（11節「番号の種類と書き方」・12節「出力に付ける由来（provenance）」を新設し、12節を実装に合わせて直した（指紋 files-v2・outputs・読めない由来の扱い）。台帳の列は変更なし）*

台帳（`pick_place_ledger_v3.csv` / `handover_ledger_v1.csv` / `unity_ledger_v1.csv`）の列・状態語・追記義務はこのファイルが唯一の正。
MASTER のデータ保管セクションが「**どこに置くか**（データの種類 → 場所）」を決めるのに対し、本ファイルは「**台帳の中に何をどう書くか**（列の意味・試行結果の状態語・pose_id 連携・追記義務）」を決める。両者は役割が重ならない。

**優先順位**: データの置き場所（種類 → 場所）は MASTER が優先。台帳の列・状態語・追記条件は本ファイルが優先。矛盾する場合は、MASTER の場所規約を侵さない範囲で本ファイルを優先する。MASTER 側には「台帳の中身は data_manifest_rules.md を参照」とだけ残す。

> 列名・列順の実体は Mac 上の台帳ヘッダを正とする。本ファイルの列定義は推測で焼かず、実測したヘッダで確定する（「パスを推測で焼かない」原則の台帳版）。

---

### 1. 台帳の正体

- 実体: `/Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger_v3.csv`
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない
- 1 試行 = 1 行。横断索引であり、各試行の生成物は `runs/run_pick_place/{run_stem}/` 配下に別途残る
- 列数: 82（2026-07-15 DAY287 で 78 → 82 に拡張。recorder 連続記録 4 列 (79-82) を 8-A 手順で追加。過去 647 データ行は末尾に空セル 4 つ付与で行数・順序不変）。列定義は2節。コードに追記ガードあり（ヘッダ不一致時は `[ABORT-APPEND]` で追記中止）
- 新ヘッダ md5 (ファイル全体・退避元 md5: `384b1e9a3bc8482213157afb5673c204` / 8-A 適用後の live md5: `91dac34289c6701e3b530c15315f2f78`)。archive 退避: `/Volumes/KIOXIA/r2r2r-data/archive/pick_place_ledger_prerestore_20260715/`
- v2 は immutable（歴史的記録として温存・変更禁止）。現行は v3
- 3・4節で定義する状態語の新列を入れるときも、ファイル名は `pick_place_ledger_v3.csv` のまま（リネーム・新バージョン番号を切らない）。旧 v3 を `archive/` にコピー退避してから、新ヘッダの v3 を新規の空台帳として作り直す（8節の 8-B）

---

### 2. 列定義（実体ヘッダで確定・82列。2026-07-15 実測: head -1 | csv.reader = 82）

実体ヘッダ（`pick_place_ledger_v3.csv` の `head -1`）と現行コード `run_pick_place.py` の `LEDGER_COLS` を突合し、82列で一致を確認した。コードには追記ガードが実装済みで、`existing_header != LEDGER_COLS` のとき `[ABORT-APPEND]` で追記を中止する（L758–783）。したがって運用中の v3 にその場で列を足すと追記が弾かれる ── 列を増やすときは旧 v3 を退避して 8-A（過去行を保持したまま末尾に空セル付与）または 8-B（archive→新規空台帳）を踏む。

| #     | 列名                              | 意味（グループ）                                                                       |
| ----- | ------------------------------- | ------------------------------------------------------------------------------ |
| 1     | `timestamp`                     | 試行時刻（ISO8601）                                                                  |
| 2     | `run_stem`                      | 試行識別子（フォルダ名と一致）                                                                |
| 3     | `trial_no`                      | 試行番号                                                                           |
| 4     | `pick_verdict`                  | PICK 成否（yes/no/unknown 系）                                                      |
| 5     | `pick_reason`                   | PICK 判定理由                                                                      |
| 6     | `pick_confidence`               | PICK 判定の確信度                                                                    |
| 7     | `place_verdict`                 | PLACE 成否                                                                       |
| 8     | `place_reason`                  | PLACE 判定理由                                                                     |
| 9     | `place_confidence`              | PLACE 判定の確信度                                                                   |
| 10    | `block_pick_x`                  | PICK 時ブロック X                                                                   |
| 11    | `block_pick_z`                  | PICK 時ブロック Z                                                                   |
| 12    | `block_place_x`                 | PLACE 時ブロック X                                                                  |
| 13    | `block_place_z`                 | PLACE 時ブロック Z                                                                  |
| 14    | `box_x`                         | 箱 X                                                                            |
| 15    | `box_z`                         | 箱 Z                                                                            |
| 16    | `in_box_geom`                   | 箱内判定（幾何）                                                                       |
| 17    | `in_box_dist_mm`                | 箱中心までの距離 mm                                                                    |
| 18–23 | `final_j1`…`final_j6`           | 最終姿勢の各関節角（度）                                                                   |
| 24    | `gripper`                       | グリッパー状態                                                                        |
| 25    | `place_capture_path`            | PLACE 撮影画像パス                                                                   |
| 26    | `tip_pick_x`                    | PICK 時の爪先 X                                                                    |
| 27    | `tip_pick_z`                    | PICK 時の爪先 Z                                                                    |
| 28    | `tip_block_dx_mm`               | 爪先とブロックの X ズレ mm                                                               |
| 29    | `tip_block_dz_mm`               | 爪先とブロックの Z ズレ mm                                                               |
| 30    | `tip_block_dist_mm`             | 爪先とブロックの距離 mm                                                                  |
| 31    | `run_dir`                       | 試行ディレクトリ                                                                       |
| 32    | `tip_capture_path`              | TIP 測定画像パス                                                                     |
| 33    | `pick_capture_path`             | PICK 撮影画像パス                                                                    |
| 34    | `align_j0_final`                | 視覚サーボ後の j0 最終値                                                                 |
| 35    | `align_red_x_mm`                | 赤ブロック X mm                                                                     |
| 36    | `align_converged`               | 視覚サーボ収束フラグ                                                                     |
| 37    | `pick_pose_id`                  | PICK 姿勢の照合キー（4節）                                                               |
| 38–43 | `pick_used_j0`…`pick_used_j5`   | PICK で使った各関節角                                                                  |
| 44    | `place_pose_id`                 | PLACE 姿勢の照合キー（4節）                                                              |
| 45–50 | `place_used_j0`…`place_used_j5` | PLACE で使った各関節角                                                                 |
| 51    | `corr_cell_key`                 | 補正記憶のセルキー（例 `x220_z200`）                                                       |
| 52    | `corr_applied_dx`               | 適用した X 補正量                                                                     |
| 53    | `corr_applied_dz`               | 適用した Z 補正量                                                                     |
| 54    | `target_before`                 | 補正前の狙い                                                                         |
| 55    | `target_after`                  | 補正後の狙い                                                                         |
| 56    | `vlm_pick_verdict`              | VLM-as-Reward の PICK 判定                                                        |
| 57    | `pick_dist_override`            | PICK 距離の手動上書き                                                                  |
| 58    | `side_ap_place_capture_path`    | 横カメラ (Iriun index=1) stage7 到達後撮影の画像パス（判定なし・記録のみ）                              |
| 59    | `side_place_capture_path`       | 横カメラ stage8 到達後撮影の画像パス                                                         |
| 60    | `side_place_verdict`            | 横カメラ PLACE 判定 verdict (yes/no/unknown/"")                                      |
| 61    | `side_place_confidence`         | 横カメラ PLACE 判定 confidence (0.0-1.0)                                             |
| 62    | `side_place_reason`             | 横カメラ PLACE 判定 reason 短文                                                        |
| 63    | `place_corr_cell_key`           | PLACE 補正記憶のセルキー（例 `place_x-40_z100`）— CELL_MM=20mm セル中心・phase プレフィックス "place_" |
| 64    | `place_corr_applied_dx`         | PLACE で適用した X 補正量 mm（EMA 平均後・MAX_CORRECTION_MM=40 でクランプ）                       |
| 65    | `place_corr_applied_dz`         | PLACE で適用した Z 補正量 mm                                                           |
| 66    | `place_target_before`           | 補正前の狙い箱位置 `(+X,+Z)` mm                                                         |
| 67    | `place_target_after`            | 補正後の狙い（fk_lookup へ渡した値）mm                                                      |
| 68    | `box_shift_dx`                  | 箱ズレ補正の X 量（DAY274 追加）                                                          |
| 69    | `box_shift_dz`                  | 箱ズレ補正の Z 量（DAY274 追加）                                                          |
| 70    | `box_shift_dist`                | 箱ズレの距離 mm（DAY274 追加）                                                           |
| 71    | `box_hit_flag`                  | 箱ヒット判定フラグ（DAY274 追加）                                                           |
| 72    | `box_hit_thresh_mm`             | 箱ヒット判定の閾値 mm（DAY274 追加）                                                        |
| 73    | `place_height_cell`             | PLACE 高さ補正記憶のセルキー                                                              |
| 74    | `place_height_dee_y_mm`         | PLACE 高さの dee_y mm                                                             |
| 75    | `place_height_j4_selected`      | PLACE で選択した j4                                                                 |
| 76    | `place_corr_reason`             | PLACE 補正の理由                                                                    |
| 77    | `box_shift_reason`              | 箱ズレ補正の理由                                                                       |
| 78    | `place_img_path`                | PLACE 画像パス                                                                     |
| 79    | `recording_path`                | 連続記録フォルダの相対パス（DAY287 追加）                                                       |
| 80    | `recording_fps`                 | 連続記録の実効 fps（DAY287 追加）                                                         |
| 81    | `recording_frames`              | 連続記録のフレーム数（DAY287 追加）                                                          |
| 82    | `recording_status`              | 連続記録の状態 ok/truncated/camera_lost/mount_lost/none（DAY287 R2 で mount_lost を追加）   |

> 68〜72 は DAY274 追加の箱ズレ系観測/判定列。73〜75 は PLACE 高さ補正記憶、76・77 は補正/箱ズレの理由文字列、78 は PLACE 画像パス。place_img_path(78) と place_capture_path(25)・side_*_capture_path(58,59) の使い分け、place_height 系の細目はコードの書き込み処理を正とする。

> 79〜82 は DAY287 追加の recorder 連続記録メタ列。`recording_path` は `runs/run_pick_place/{run_stem}/recording/` を run_stem 起点の相対パス（recording/）で入れる。`recording_status` の語彙（5 値）: `ok` / `truncated`（前試行の trial_end 未受領・二重 trial_start で強制打切）/ `camera_lost`（read 30 連続失敗）/ `mount_lost`（DAY287 R2・試行中に KIOXIA マウントが消えた）/ `none`（recorder 不在・空欄でなく明示語彙）。recorder が返した `recording_meta.json` を `_append_ledger` が **最大 2.0 秒・100ms 間隔** の有界待ちで存在確認 → 有れば読んで転記、タイムアウト時は none（後からの reconcile はしない・追記のみ原則 7節）。recorder 側は tmp+os.replace の原子的置換で書くので half-written は発生しない。

> 型・必須/任意・単位の細目はコード（`LEDGER_COLS` 周辺の書き込み処理）を正とする。本表は列名・順序・意味の確定版。

確定済みで分かっている列:

- `run_stem` … 1 試行の識別子（フォルダ名と一致）
- `pick_pose_id` / `place_pose_id` … phase 別の照合キー（単一 `pose_id` 列ではない）。j0_lookup 由来は採用 JSON の pose_id、`fk_lookup` 由来は FK テーブル行 ID `fk_row_{selected_idx}` を暫定 pose_id とする（4節）
- `cell` … ブロック検出位置を 20mm 格子に量子化したセルキー（例 `x220_z200`）
- `適用Δz` / `dx_mm` / `dz_mm` … 補正記憶から適用した狙いの補正量
- `tip_dx` / `tip_dz` … 最終 TIP 測定での爪先ズレ（mm）
- `dist` … 爪先と狙いの距離（mm）
- `pick` … 把持結果（現状 yes/空欄。→ 3節の `pick_stage` ＋根拠フラグ列に再設計）

---

### 3. 試行結果の状態語（最重要・保持の山を測るため）

位置には届いたのに `pick` 欄が空のまま終わる試行が出た。これが「未到達」なのか「届いたが保持できなかった」のか、台帳から読めない。保持の山を測定可能にするには、試行結果を段階の状態として持つ必要がある（P008）。

**状態は 2 レイヤーある。列名で必ず分離する**（同じ綴りを別レイヤーで使わない）。

- **レイヤーA: 試行結果の状態** → 列 `pick_stage`（本節で定義）
- **レイヤーB: 姿勢素材の採用状態** → 列 `asset_status` 相当（5節）。語彙 `adopted / superseded / aborted / test`

レイヤーA の中断は `aborted` ではなく `interrupted` と綴る（レイヤーB の `aborted` と同名衝突させない）。

#### レイヤーA: pick_stage（到達段階・要約列）

| pick_stage    | 意味                                 | 判定                           |
| ------------- | ---------------------------------- | ---------------------------- |
| `not_reached` | descend が狙い位置へ届かなかった               | tip dist > 閾値、または送角 timeout  |
| `reached`     | 位置に届いた（把持の成否は別）                    | tip dist ≤ 閾値                |
| `grasped`     | 掴んだが LIFT までに落とした                  | ※現状センサーで一意判定不可・**保留**（下記）    |
| `held`        | LIFT 後も保持（PICK 成功）                 | LIFT 後画像（VLM-as-Reward）で保持確認 |
| `interrupted` | 送角 silent drop / timeout / 安全停止で中断 | `abort_reason` 列に理由を残す       |

`pick_stage` は要約列。これ単独に頼らず、根拠を別のフラグ列で持つ（線形段階だけでは「掴んだが位置はズレていた」のような一直線でないケースを取りこぼすため）。

| 根拠フラグ列            | 型    | 意味                                                                              |
| ----------------- | ---- | ------------------------------------------------------------------------------- |
| `reached_bool`    | bool | tip dist ≤ 閾値で位置到達したか                                                           |
| `grip_commanded`  | bool | GRIP を送ったか                                                                      |
| `held_after_lift` | bool | LIFT 後に保持できていたか（VLM 判定）                                                         |
| `abort_reason`    | str  | 中断理由 `silent_drop`/`timeout`/`j1_reject`/`safety_stop`/`mount_guard` 等。中断でなければ空 |
| `evidence_source` | str  | 判定根拠 `lift_image_vlm` 等                                                         |

`grasped` **は現状保留。** いまのセンシングは LIFT 後画像で pick 成否を見ており、GRIP 直後・LIFT 前の保持確認を持たない。よって「掴んだが LIFT までに落とした（grasped）」を `held`/`reached` から一意に切り分けられない。GRIP 直後の確認画像（または別センサー）が入るまで、`grasped` は使わず `reached`（位置は到達）＋ `held_after_lift=false` で表す。`grasped` を有効化する条件 ＝ GRIP 直後確認の導入、と明記しておく。

なお `reached` ＋ `held_after_lift=false` は、grasp failure（そもそも掴めなかった）と dropped before lift（掴んで落とした）を区別しない暫定表現である（現状センサーでは避けられない）。区別が要るようになったら GRIP 直後確認の導入で `grasped` を有効化する。

> これらの新列（`pick_stage` ＋根拠フラグ列）を運用中の v3 にその場で足すと追記ガードに弾かれる。入れるときは 8節の 8-B（旧 v3 退避 → 新ヘッダで空台帳作り直し）を踏む。

> 状態語彙は `ledger_schema_version` で版を持つ。本節の語彙を v1 とし、語彙を変えるときは版を上げる（過去行の語彙と混ざらないように）。列を増やせない場合でも、本ファイルに「どの版がいつから有効か」を必ず残す。

> ↑ 段階キー名・閾値・理由語彙はドラフト。実機運用の感覚に合わせてレビューで確定する。

---

### 4. pose_id と三系統（live / twin / real）の連携

三系統（live / twin / real）を、台帳は pose_id で結ぶ。

| 系統   | 役割                  | 実体                                                              | 台帳との関係                                                                      |
| ---- | ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| live | 採用姿勢（approach）      | `dynamic_tracking_limits/logs/j0_sweep/j0_sweep_adopted/*.json` | approach で j0_lookup が読む。pose_id を持つ                                        |
| twin | FK 予測器／descend の参照元 | `fk_scan_motion_based_v2.csv`                                   | **descend(pick) で** `fk_lookup` **が runtime に読む**（DAY257 以降）。pose_id で後から照合 |
| real | 試行台帳                | `pick_place_ledger_v3.csv`（本ファイルの対象）                            | 実機が書く。pose_id を残す                                                           |

- 台帳は phase 別に `pick_pose_id` / `place_pose_id` を持つ（単一 `pose_id` 列ではない）
- j0_lookup 由来（approach / place_withblock）は採用 JSON の pose_id を使う
- `fk_lookup` 由来（descend）は、現行コードが返す FK テーブル行 ID `fk_row_{selected_idx}` を**暫定正**の pose_id とする。これで twin（FK テーブル）の該当行と real の結果を後から突き合わせられる
- ただし行 index 単独は永続 ID にしない。FK テーブルは再生成・マージ・ソートで同じ index が別姿勢を指しうる（過去に 512,087→710,704→852,729 と拡張）。テーブル版/ハッシュとセットにして初めて安定識別子とする。推奨形 `fk_pose_id = fk:{table_basename}:{table_hash}:{selected_idx}`。照合補強に 6 軸丸め値 `fk_joint_key = j0..j5` を併記すると強い

---

### 5. データラベリングの status（レイヤーB・既存規約）

姿勢素材・測定バッチの採用状態。試行結果（3節 `pick_stage`）とは別レイヤー。列名 `asset_status` 相当で分離する。

- 語彙は固定 4 語のみ: `adopted` / `superseded` / `aborted` / `test`
- この `aborted` はレイヤーB 専用。試行の中断はレイヤーA の `interrupted` を使う（同名衝突を避ける）
- ラベリング規約: `{phase}_{condition}_{n}pt_{status}_DAY{NNN}_{seq}`

---

### 6. 追記義務（変更禁止）

- 実機の通し完走（PICK→PLACE）を走らせるスクリプトは、本線・派生を問わず、完走／中断時に必ず台帳へ 1 行追記する
- 台帳追記処理を持たない通し完走スクリプトを作らない
- 派生スクリプトが独自の索引 CSV を別に持つ場合も、台帳への追記は省略しない（索引 CSV は補助・台帳が正）

---

### 7. 禁止事項

- `pick_place_ledger_v3.csv` が正本。Mac ローカルに working 台帳を作らない
- `pick_place_ledger_v2.csv` は immutable（変更禁止・温存）
- KIOXIA-only。未マウント時にローカルへ fallback して書かない（mount guard 必須）
- 既存行の上書き・移動・削除をしない（追記のみ）
- 横断索引を `run_pick_place/` ルートに置かない（台帳は `runs/pick_place_ledger_v3.csv` 1 本）

---

### 8. 列を増やすときの手順（v3 一本・リネームしない）

台帳はファイル名 `pick_place_ledger_v3.csv` の一本で通す。新バージョン番号を切ったりリネームしたりしない。運用中の v3 にその場で列を足すと追記ガードに弾かれるので、足す列の種類によって次の 8-A か 8-B のどちらかを踏む。どちらの場合も、列数は実体ヘッダを実測して確かめ、本ファイルに焼いた数字を当てにしない。

#### 8-A. 観測系の列（記録を足すだけで、既存列の意味と試行結果の語彙を変えない列）

撮影パス・観測値・記録メタのような列がこれにあたる（DAY265 の横カメラ 5 列、DAY287 の recorder 4 列が実施例）。

1. **旧 v3 を退避**: 現行の `pick_place_ledger_v3.csv` を `archive/` 配下にコピー退避し、md5 を確認する（移動でなくコピー・元を消さない）
2. **同じファイルの末尾に列を足す**: 現行ヘッダの末尾に新列を加え、過去のデータ行は末尾に空セルを付けて保持する（行数・順序は変えない）
3. **コードを合わせる**: `run_pick_place.py` の `LEDGER_COLS` を新ヘッダと一致させ、追記ガードが通ることを確認する
4. **本ファイルを更新**: 2節の列定義表に実測ヘッダから転記し、1節の列数と md5 を更新する

#### 8-B. 状態語系の列（3節の `pick_stage` 系など、試行結果の語彙を変える列）

1. **旧 v3 を退避**: 現行の `pick_place_ledger_v3.csv` を `archive/pick_place_ledger_premerge_{date}/` にコピー退避する（移動でなくコピー・元を消さない。MASTER 保管ルール準拠）。これで状態語導入前の過去試行は archive 側に残る
2. **新ヘッダの v3 を作り直す**: 同じパス `runs/pick_place_ledger_v3.csv` を、現行ヘッダ（列数は実体ヘッダを実測して確認する）の末尾に新列を加えたヘッダで**新規の空台帳**として作成する。過去行は引き継がず 0 行から開始する（過去行は 1 の archive 側が正）
3. **コードを合わせる**: `run_pick_place.py` の `LEDGER_COLS` を新ヘッダと一致させる。追記ガード（`existing_header != LEDGER_COLS`）が新ヘッダで通るようにする。ファイル名 `LEDGER_CSV_PATH` は変えない
4. **足す新列**: `pick_stage` / `reached_bool` / `grip_commanded` / `held_after_lift` / `abort_reason` / `evidence_source` / `ledger_schema_version`。レイヤーB の `asset_status` も列化するなら同じ手順で末尾に加える
5. **語彙の版**: `ledger_schema_version` に版を持たせ、archive 側（旧）と新台帳で語彙が混ざらないようにする。本ファイル 3節の語彙を v1 とする

> 退避 → 作り直しは過去ログを失う操作ではない（旧 v3 は archive に保全される）。新旧で行が混ざらないぶん、状態語の検証はやりやすい。実施はエイイチの確定後に実装へ渡す。

---

### 9. handover_ledger_v1.csv（受け渡しシリーズの台帳）

受け渡しシリーズ（mechArm 270 Pi 受け渡し動作の成立を探る）の通し台帳。動的追従シリーズの pick_place_ledger_v3.csv とは別ファイル・別シリーズ。シリーズとデータの対応: 動的追従 = `run_pick_place.py` + pick_place_ledger_v3.csv / 受け渡し = `run_pick_handover.py` + handover_ledger_v1.csv。

#### 9-1. 台帳の正体

- 実体: /Volumes/KIOXIA/r2r2r-data/runs/handover_ledger_v1.csv
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない
- 1 試行 = 1 行・追記のみ（既存行の上書き・移動・削除をしない）。横断索引であり、各試行の生成物は runs/run_handover/{run_stem}/ 配下に別途残る
- 列数: 44（初版・設計 r3.3 で確定。実装後に実体ヘッダを正規CSVパースで実測し、本節の列定義表を確定転記する。実測前の本表はドラフト）
- 追記ガードあり: existing_header != HANDOVER_LEDGER_COLS のとき [ABORT-APPEND] で追記中止
- v3（pick_place_ledger_v3.csv）への二重記帳はしない。受け渡し試行の追記義務は本台帳への追記で満たす（1試行1行・1種類1ルート）
- run_dir 列は持たない。試行ディレクトリは run_stem から導出する: runs/run_handover/{run_stem}/

#### 9-2. 追記義務と mount lost 時の扱い

- 受け渡しの通し試行スクリプトは、完走・中断を問わず本台帳へ 1 行追記する（通常時の義務）
- ただし SSD が動作中に失われた場合（mount lost）は同期的に保証できない。このとき記録より物理安全を優先し、試行データは /tmp/handover_emergency_journal/ へ atomic write（tmp 作成 → flush/fsync → os.replace）で退避する
- /tmp は正本の fallback ではなく emergency journal（復旧用の痕跡）。正規データをローカルへ書かない原則は維持する
- recovery: SSD 復旧後、run_stem をキーに台帳の未記帳を確認し、1 回だけ新規 append する。既存行の更新はしない
- マウント確認は各書き込み直前に行う。試行開始前に未マウントなら物理動作を開始せず abort。シーケンス途中の mount lost は新規シーケンスを開始せず abort（trial_status=mount_lost）。人が可動域に手を出した後の mount lost は、インターロックを維持したまま退避シーケンスを完了してから journal へ

#### 9-3. 状態語（v3 とは別レイヤー・handover 用）

- trial_status: completed / pick_failed / motion_failed / release_failed / interrupted / mount_lost / unexpected_error
- outcome（排他）: taken / drop / stuck / unknown / not_applicable
  - stuck = 開いたがブロックが爪に引っかかったまま
  - unknown = 対話記入が得られなかった（中断・入力不能）。unknown の増加は運用異常のシグナルとして扱う
  - not_applicable = PICK 失敗等で提示工程に入らなかった
- outcome_source: operator_visual / operator_with_vlm_reference / unknown（VLM 単独で outcome を確定しない。最終確定は人）
- v3 の pick_stage 系語彙（レイヤーA）とは別の語彙体系。同じ綴りを別レイヤーで使わない原則は本台帳にも適用する

#### 9-4. 列定義（初版 44 列・実装後に実測で確定転記）

| # | 列名 | 意味 |
|---|---|---|
| 1 | timestamp | 試行時刻（ISO8601） |
| 2 | run_stem | 試行識別子（recovery 時の照合キー・run_dir 導出元） |
| 3 | trial_no | 試行番号 |
| 4 | trial_status | 9-3 の状態語 |
| 5 | manual_recovery_required | 手動復帰が必要な状態で終わったか |
| 6 | abort_stage | 中断したシーケンス段階（完走なら空） |
| 7 | abort_reason | 中断理由（arrival_timeout / forbidden_gripper_close 等） |
| 8 | pick_verdict | PICK 成否（VLM・v3 と同語彙） |
| 9 | pick_reason | PICK 判定理由 |
| 10 | pick_confidence | PICK 確信度 |
| 11 | block_pick_x | PICK 時ブロック X |
| 12 | block_pick_z | PICK 時ブロック Z |
| 13 | align_j0_final | 視覚サーボ後 j0 |
| 14 | align_converged | 視覚サーボ収束 |
| 15 | pick_pose_id | PICK 姿勢照合キー（4節の pose_id 規約準拠） |
| 16 | corr_cell_key | PICK 補正セルキー |
| 17 | corr_applied_dx | X 補正量 |
| 18 | corr_applied_dz | Z 補正量 |
| 19 | handover_pose_id | 提示姿勢照合キー（handover_present_pose.json の pose_id） |
| 20 | object_profile_id | ブロック仕様の参照キー（実体は run の handover パラメータ JSON に内包） |
| 21 | wait_plan_id | N 計画の識別子 |
| 22 | wait_plan_index | 計画内の提示順 |
| 23 | shuffle_seed | シャッフル seed |
| 24 | wait_sec_requested | 要求 N |
| 25 | wait_sec_actual | 実測 N（monotonic） |
| 26 | wait_started_at | WAIT 開始時刻 |
| 27 | release_sent_at | RELEASE 送信時刻 |
| 28 | release_responded_at | RELEASE 応答時刻 |
| 29 | release_command_ok | 通信上、指令送信処理を完了したか（物理開放と分離） |
| 30 | release_observed | open / not_open / unknown（物理開放の観測） |
| 31 | release_observed_source | operator / top_camera / side_camera |
| 32 | present_reached_bool | PRESENT 到達判定（実測角ベース）通過 |
| 33 | retreat_completed_bool | RETRACT〜PRESENT_MID 到達判定通過 |
| 34 | home_reached_bool | HOME 到達判定通過 |
| 35 | hand_clear_confirmed | 退避確認入力（インターロック2）が入ったか |
| 36 | outcome | 9-3 の排他語 |
| 37 | outcome_source | 9-3 の判定根拠語 |
| 38 | vlm_taken_verdict | VLM 事後判定（参考値） |
| 39 | vlm_status | ok / http_400_no_credit / error |
| 40 | vlm_reason | VLM 判定理由 |
| 41 | top_capture_status | ok / fail:{reason} |
| 42 | side_capture_status | ok / fail:{reason} |
| 43 | top_capture_path | 上カメラ画像パス（fail 時は空） |
| 44 | side_capture_path | 横カメラ画像パス（fail 時は空） |

型・必須/任意の細目は実装（HANDOVER_LEDGER_COLS 周辺の書き込み処理）を正とする。最終実測角・インターロック入力時刻・入力者区分・object_profile 実体は run_dir 内 JSON に保存し、台帳には持たない。

#### 9-5. 禁止事項

- handover_ledger_v1.csv が正本。Mac ローカルに working 台帳を作らない
- pick_place_ledger_v3.csv への受け渡し試行の記帳をしない（二重記帳禁止）
- 既存行の上書き・移動・削除をしない（追記のみ。recovery も新規 append のみ）
- 未マウント時にローカルへ正規データを書かない（emergency journal は /tmp・atomic write・復旧用の痕跡に限る）
- 列を増やすときは 8節の 8-A / 8-B 判定に従う（本台帳にも同じ手順を適用する。読み替え: `pick_place_ledger_v3.csv` → `handover_ledger_v1.csv`、`run_pick_place.py` の `LEDGER_COLS` → `run_pick_handover.py` の `HANDOVER_LEDGER_COLS`）

---


### 10. unity_ledger_v1.csv（サービスロボット導入シミュレーション・Unity の段階B台帳）

Unity で配膳ロボットの店を回した結果を、1回1行で残す台帳。サイトの試算と比べる段階C の元になる。実機の台帳（v3・handover）とは別シリーズ・別ファイル。

#### 10-1. 台帳の正体

- 実体: `/Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv`（MASTER の保存先「サービスロボット導入シミュレーションの Unity の出力（往復・止まり・条件ごとの数字）→ service_sim/unity/」に含まれる。新しい置き場所は作らない）
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない。未マウントなら回し始めない
- 1回 = 1行・追記のみ（既存行の上書き・移動・削除をしない）。各回の生成物は `service_sim/unity/{出力フォルダ}/` に別に残る（summary.json・trips.csv・stops.csv・dishes.csv・yields.csv など）
- 追記ガードあり: 実体ヘッダとコードの列の並びが違えば追記しない
- 途中で止めた回・失敗した回も1行残す（status で区別）。同じ出力フォルダの行を2度書かない（出力フォルダ名が照合キー）

#### 10-2. 列（初版・実装後に実体ヘッダを実測して確定転記する）

- 回の識別: `timestamp` / `run_folder` / `status`（completed / stopped / failed）/ `unity_cs`（Unity Version Control の changeset）/ `layout_generation` / `params_model`（試算の版。v2.7 など）
- 条件（サイトの unity_conditions と同じ）: `store` / `staff` / `robots` / `arrivals_per_h` / `mode` / `trays` / `fragile_by` / `seed`
- Unity だけの決まりの設定: `staff_first_yield` / `yield_order_enabled` / `one_robot_per_aisle` / `robot_first_wait_s`
- 回り切ったか: `everyone_left` / `total_minutes` / `wall_seconds`
- サイトと同じ定義の数字: `entry_wait_mean` / `staff_wait_mean` / `dish_wait_mean` / `cold_share` / `robot_share` / `staff_util` / `robot_util` / `walk_km_per_staff`
- 同じ条件のサイトの試算の数字（params の site_result から写す）: `site_entry_wait_mean` / `site_staff_wait_mean` / `site_dish_wait_mean` / `site_robot_share`
- Unity が測ってサイトに戻す4つの値: `robot_speed_mps` / `pause_rate` / `pause_s_mean` / `stuck_rate`
- ロボットの往復と止まり: `trips` / `timeouts` / `stuck_count` / `stop_s_total` / `corner_stop_time_share`
- 逃がし弁と助け: `gave_up` / `warped` / `table_freed` / `rescues`
- 料理と譲り: `late_staff_dishes` / `staff_yield_share_of_staff_time`
- `ledger_schema_version`（初版 = 1）

列の意味と計算のしかたは、出力の summary.json と同じ（台帳はそこから写す）。列を増やすときは 8節の 8-A / 8-B に従う（読み替え: `pick_place_ledger_v3.csv` → `unity_ledger_v1.csv`、`LEDGER_COLS` → Unity の台帳の列の定義）。

#### 10-3. 新しいプロジェクトと、夜に何本も同時に回すとき（2026-10-25）

- ServiceSim を新しい Unity プロジェクト（`/Users/shimazueiichi/Robotics_UX/ServiceSim/`）に分けたあとの行は、`unity_cs` を `ServiceSim/cs:N` の形で書く（changeset の番号が1から振り直されるため。今までの `cs:144` などと混ざらないように）
- 何本も同時に回すときは、鍵ファイル `service_sim/unity/unity_ledger_v1.lock` を排他で開けてから1行書く。開けなければ待って試し直す。追記ガードはそのまま
- 夜の実行係が止めた回（時間切れ）・落ちた回は、実行係が status = stopped / failed の行を書く（鍵をかけて）。やり直すときは出力フォルダの名前の後ろに `_try2` を付け、同じ出力フォルダの行を2度書かない決まりを守る
- 何本同時か・どの実行ファイルか・どの夜かは各回の run_info.json に書き、台帳の列は増やさない
- 夜の結果の振り分け（judge_night.py）は台帳を読むだけで書かない。結果は `service_sim/unity/night/{日付}/` の judge.md・judge.csv
- 最初の夜の前に、台帳を `archive/unity_ledger_v1_prenight_{日付}/` にコピー退避する

---

### 11. 番号の種類と書き方（2026-11-01）

記録とデータには、意味の違う番号が何種類もある。見た目が同じ番号を別の意味で使うと、記事とデータを取り違える（例：`lab/Log07` は研究室の7回目、`vision/captured/Log07` は画像認識の7回目で、別物）。番号は付け替えず、種類ごとの意味と書き方を決める。

#### 11-1. 番号の種類

| 種類 | 形 | 意味 |
|---|---|---|
| 記事番号 | `DAY{NNN}` → `LOG{NNN}` | 記事の通し番号。初期は DAY、記事を毎日出さなくなってから LOG に名前を変えた。DAY と LOG は同じ通し番号（LOG は DAY の続き） |
| シリーズ内の番号 | `Log{NN}` | そのシリーズの何回目か。シリーズ名と組でなければ意味を持たない |
| 試行の番号 | `run_stem` の全体（例 `run_0001_20261019_191257`） | 1回の試行。`run_0001` だけでは別の日の試行と重なるので、日付まで含めた全体で1回を指す |
| 学習・推論の回 | `pi05_{YYYYMMDD}_{HHMMSS}` など | 1回の学習・推論の実行 |
| データの版 | `_v1`・`_v2`・`_trim2`・`_corr_v3` など | そのデータセット・モデルの版。記事番号ではない |
| 学習の段数 | checkpoint の step（`1499`・`3999` など） | 学習の何段目のモデルか。記事番号ではない |

記事番号とシリーズ内の番号の正は、公開記事（pixelstomotors.com）とする（MASTER「シリーズの正は公開記事」に従う）。

#### 11-2. 書き方の決まり

- 既存のフォルダ名・ファイル名・台帳の値は付け替えない（記録の改変を始めないため）
- 文章・README・これから出力する meta には、番号を種類つきで書く。例：`記事LOG352`、`研究室Log09`、`画像認識Log07`、`run_0001_20261019_191257`
- これから出力する meta（各回の JSON・`recording_meta.json` など）には、シリーズ名とシリーズ内の番号を組で入れる。分かっていれば記事番号も入れる。記事が後から決まる場合は空のままでよい（対応は11-3で足す）
- 番号どうしの対応（記事番号 ↔ シリーズ内の番号 ↔ 試行・学習の回・データ・checkpoint）は、番号に詰め込まず、索引の関係表に置く（置き場所は MASTER の保存先テーブルに従う）

#### 11-3. 関係表への載せ方

- 載せる根拠は、記事本文・README・meta に書かれた明示の参照を優先する。名前や日付が近いだけのものは「候補」とし、確定と分ける
- 1つの記事が複数のデータを使い、1つのデータが複数の記事で使われることがある（多対多）。関係には種類（作った・使った・比べた など）を付ける
- 仮の番号（例：README に「番号は仮」と書かれたフォルダ）は「仮」として載せ、確定したら確定の行を1行足す（既存の行は書き換えない）

---

### 12. 出力に付ける由来（provenance）（2026-11-01）

データ・モデルが「どのコード・どの入力・どの設定で、どう終わったか」を、作った時点で機械が読める形で残す。主指標の「再現可能」の条件2（依存関係とバージョンが記載されている）を、手書きの README に頼らず満たすため。

#### 12-1. 対象

- **これから作る出力だけ**。既存のデータ・JSON・checkpoint には書き足さない（過去の由来は索引の関係表と README でたどる）
- 段階的に入れる：①学習と checkpoint → ②データセットを作るスクリプト → ③実機の試行（`run_pick_place.py`・recorder など）。③は関節の制御に触れないが、実機の流れの中なので codex のレビューと dry-run を通してから使う

#### 12-2. 置き方

- 既存の出力 JSON（各回の JSON・`recording_meta.json`・データセットの `index.json`・lab の記録など）に、キー `provenance` を1つ足して中に入れる。由来のためだけの JSON を別に増やさない
- 例外：checkpoint は中に書ける JSON がない（`_CHECKPOINT_METADATA` は openpi の形式）。新しく置く checkpoint の各ステップのフォルダ（`checkpoints/<モデル名>/<ステップ>/`）に `train_provenance.json` を1つ置き、同じものをその回の `lab/Log{NN}/` にも写す。Hugging Face に上げるときも同じファイルを入れる
- **台帳の列は増やさない**（8-A の手順を踏まずに済ませるため）。台帳から各回の JSON へは、今までどおり `run_stem` でたどる

#### 12-3. 項目（`schema_version` = `provenance/1`）

| 項目 | 中身 |
|---|---|
| `schema_version` | `provenance/1`。項目を変えるときは版を上げる |
| `created_at` | 作った時刻（ISO8601・タイムゾーンつき） |
| `producer` | スクリプトのパス（r2r2r-scripts からの相対）・実行したコマンドの引数（秘密の値は除く）・実行した機械（Mac / Pod など） |
| `code` | `repo`（r2r2r-scripts）・`commit`・`dirty`（未コミットの変更があるか）・`dirty_files`（変更のあるファイル名）・`diff_sha256`（変更の中身の指紋） |
| `external_code` | 使った外部のコードごとに `name`・`commit`・`dirty`（例 openpi） |
| `inputs` | 入力ごとに `kind`（dataset / checkpoint / run / recording / config）・`id`（フォルダ名やモデル名）・`path`（r2r2r-data からの相対）・`fingerprint` |
| `outputs` | できたものごとに、`inputs` と同じ形（例：学習でできた checkpoint のステップのフォルダ） |
| `config` | 設定の名前と主な値（学習なら設定名・ステップ数・初期の重み・学習率など） |
| `compute` | 学習・推論でクラウドを借りたとき：借り先・GPU の型番・始めと終わりの時刻・時間・費用（MASTER「買う前に借りて測る」の記録） |
| `numbering` | `series`・`series_log`・`article_no`（11節。分からなければ空）。`series_log` は `Log18` のように `Log` を付けた形で書く（数字だけにしない） |
| `status` | `process`（completed / interrupted / failed：処理が最後まで動いたか）と `task`（success / failure / unknown / not_applicable：タスクが成功したか）を分けて書く |

#### 12-4. 指紋（fingerprint）の作り方

- `files-v2:` ＋ sha256。中身は、フォルダ内のファイルを相対パスの順に並べた「相対パス・バイト数」の一覧と、1MB 未満の JSON（`index.json` など）の中身
- 大きなファイル（画像・動画・重み）は中身を読まない。数GBのものを毎回読むと出力が遅くなるため
- 数えないもの：`._` で始まるファイル（exFAT の印）・`.DS_Store`（Finder で開くだけで作られる）・`.cache`・一時フォルダ（`.incoming_*`）・由来のファイルそのもの（`train_provenance.json`・`*_provenance.json`）。由来のファイルを置いたあとに測り直しても、同じ指紋になるようにするため
- 2026-11-01 までの実装は `files-v1`（`.DS_Store` と由来のファイルを数えていた）。`files-v1` で書かれた記録は書き換えず、そのまま残す。比べるときは、同じ版の指紋どうしで比べる

#### 12-5. 守ること

- 未コミットの変更があっても止めない。`dirty: true` と変更のファイル名・指紋を残して進める（研究を止めないため）
- 鍵・トークン・パスワードは書かない。コマンドの引数に秘密が入る場合は値を伏せる
- 由来を書く処理が失敗しても、本来の出力（学習・データ・試行）は止めない。そのときは `provenance` に `error` を書く
- 由来を読む側は、読めない記録（書いている途中で強制終了された・JSON が壊れている）を「由来なし（読めない）」として扱い、警告を出して処理は止めない。読めない記録で、そのデータやモデルを「由来あり」と数えない
- 書き方は共通の部品（r2r2r-scripts の `provenance.py`）にまとめ、各スクリプトはそれを呼ぶ

### 更新履歴

- 2026-11-01（12節を実装に合わせて直した）: 依頼54（学習と checkpoint に由来を記録する・r2r2r-scripts のコミット 1902f37）の報告で Claude Code が挙げた5点を、エイイチの確定で決めた。できたものを書く `outputs` を足した。指紋を `files-v2` にし、`.DS_Store` と由来のファイルそのものを数えないことにした（Finder で開くだけで指紋が変わる・由来を置いたあとに測り直すと合わないため。`files-v1` の記録は書き換えない）。`series_log` は `Log18` の形。読めない由来は「由来なし（読めない）」として警告し、止めない。
- 2026-11-01（12節を新設）: 出力に付ける由来（provenance）の項目と置き方を決めた。Codex の検証で、checkpoint 5件の学習の記録欄（`_CHECKPOINT_METADATA` の custom_metadata）がすべて空で、既存の meta 254件にコードの版を持つものが0件だったため。既存の JSON にキー `provenance` を足す形とし、台帳の列は増やさない。checkpoint だけは `train_provenance.json` を置く。これからの出力だけが対象で、①学習と checkpoint から入れる。

- 2026-11-01（11節を新設）: 番号の種類と書き方を決めた。Codex の検証（iCloud `_index_review/codex_report_20261101.md`）で、意味の違う番号が少なくとも7種類あり、シリーズ名のない `Log{NN}` が別シリーズのデータと取り違えられる実例（研究室 Log07 と画像認識 Log07）が見つかったため。DAY と LOG は同じ記事の通し番号であることをエイイチが確認。既存の名前は付け替えず、これからの meta に種類つきで書き、番号どうしの対応は索引の関係表に置く。台帳の列は変えない。

- 2026-10-25（10-3 を新設）: サービスロボット導入シミュレーションを夜も回すため、新しい Unity プロジェクトでの `unity_cs` の書き方（`ServiceSim/cs:N`）、何本も同時に書くときの鍵ファイル、実行係が書く stopped / failed の行とやり直しの名前（`_try2`）、判定は台帳を書かないこと、最初の夜の前の退避を決めた。列は変えない。設計は iCloud の サービスロボット導入シミュレーション/unity/night_runs_design_v0.md。

- 2026-10-23（unity_ledger_v1 節を新設）: サービスロボット導入シミュレーションの段階B（Unity で全条件を回して残す）のため、台帳 unity_ledger_v1.csv を10節として新設。置き場所は MASTER の既存の行（service_sim/unity/）の中なので MASTER は変えない。1回1行・追記のみ・追記ガード・KIOXIA-only・止めた回と失敗した回も status 付きで残す。列は初版（実装後に実体ヘッダを実測して確定転記する）。
- 2026-10-11（DAY326・ドキュメント整合）: 9-5 が参照していた「8-A / 8-B」が8節に定義されていなかったため、8節に 8-A（観測系の列・同じファイルの末尾に追加・過去行は空セル付与で保持。DAY265・DAY287 の実施例を明文化）と 8-B（状態語系の列・archive 退避→新規空台帳。従来の8節手順）を定義した。8節の手順に焼き付いていた「67 列の後ろに」を「現行ヘッダ（列数は実測して確認）の末尾に」に変更（実体は82列。列数を本文に焼かない）。1節・2節・3節の8節参照を 8-A / 8-B に合わせた。9節の列定義表に行ごとの空行が入っていて表として描画されない状態だったのを修正し、9節の小見出しを H4 に揃えた。9-5 に handover 台帳での読み替えを追記。列定義・列数（v3 は 82 列）・状態語は変更なし。
- 2026-07-16（DAY288・handover_ledger_v1 節を新設）: 受け渡しシリーズの台帳 handover_ledger_v1.csv（runs/ 直下・初版44列・追記ガード・KIOXIA-only）を9節として新設。v3 とはシリーズ・ファイルを分離し二重記帳を禁止。mount lost 時の emergency journal（/tmp・atomic write）と recovery（run_stem キー・1回だけ新規 append）を規定。状態語（trial_status / outcome / outcome_source）は v3 レイヤーA とは別語彙として定義。列定義は設計 r3.3 由来のドラフトであり、実装後に実体ヘッダの正規CSVパース実測で確定転記する（焼き付き乖離の予防）。
- 2026-07-15 (DAY287 R2・codex レビュー反映): recording_status 語彙に `mount_lost` を追加 (試行中に KIOXIA マウントが外れた場合の識別語)。列定義 (2節表) は不変・列数 82 不変。コード側の変更: recorder は tmp+os.replace で recording_meta.json を原子的置換、frame_request は frame_age 0.5s 上限で古いフレームを stale_frame として拒否、frames_captured/frames_written を分離。本線側の変更: `_append_ledger` は recording_meta.json を最大 2.0 秒・100ms 間隔でポーリング (後からの reconcile はしない・追記のみ原則 7節を守る)。j0_sweep_scan `_send_and_wait_arrival` に observer=None 追加 (未指定時は現行完全同一・commanded の ts_send/ts_arrival/attempt/arrived を送信 1 回ごとに通知)。
- 2026-07-15 (DAY287・recorder 導入で 4 列追加 + DAY274/DAY283 chunk の drift 解消): recorder（横カメラ連続録画 + commanded 状態記録）を別プロセスで導入するにあたり、台帳に `recording_path`(79) / `recording_fps`(80) / `recording_frames`(81) / `recording_status`(82) の 4 列を 8-A 手順（過去 647 データ行は末尾に空セル 4 つ付与・順序不変）で追加。列数 78 → 82。並行して 2節表に未転記だった DAY274 の 68〜78 列（`box_shift_*` / `box_hit_*` / `place_height_*` / `place_corr_reason` / `box_shift_reason` / `place_img_path`）も同時に backfill。Step 0 調査: `git log methodology/data_manifest_rules.md` は DAY258 の 2 commit しかなく、DAY283 想定の 68〜78 転記済み版はコミット・working tree いずれにも存在せず、**改訂が未執筆だった**（「認識のみ存在してディスク未反映」の 3 回目 drift を回避）。archive: `archive/pick_place_ledger_prerestore_20260715/`。追記ガードは新 LEDGER_COLS 82 列と新実体ヘッダ 82 列が一致するため通る。既存 pick 系・姿勢制御・完走 HOME 復帰・PLACE 補正記憶ロジックは無変更。recorder 本体はアーム/daemon に一切触らない読み取り観測者（設計原則: recorder が死んでも本線は完走）。
- 2026-07-05 (DAY273): 列数表記を67に統一。実体ヘッダ実測（head -1 | awk NF = 67）で確認。旧記述の57は残存していたもの（経緯不明。DAY258 時点の記録には57とあるが、当時の誤記か以後の列追加かは未追跡。現行実測67が正）。「列数: 67」の既存記述は正しかったため変更なし。
- 2026-07-03（DAY265 後段・PLACE 補正記憶 5 列追加で 67 列化）: PLACE 側の補正記憶 apply/update は DAY262 で既に実装済（`_apply_correction("place", box_xz)` を stage8 で呼び、VLM yes 時に `_corr_update` で EMA 平均・phase プレフィックス `place_` でキー分離・`MAX_CORRECTION_MM=40mm` クランプ）だったが、台帳 CSV に反映される列が無く不可視だった。PICK の corr_cell_key/corr_applied_dx/dz/target_before/target_after (51-55) と同構造の 5 列を末尾に追加：`place_corr_cell_key` (63) / `place_corr_applied_dx` (64) / `place_corr_applied_dz` (65) / `place_target_before` (66) / `place_target_after` (67)。`_append_ledger` は `log_record.correction.place_last` を読み込むだけの追加で、apply/update ロジック・PICK 補正・関節制御に無影響。archive: `archive/pick_place_ledger_premerge_20260703_step2/`。
- 2026-07-03（DAY265・横カメラ観測 5 列追加で 62 列化）: Iriun (index=1) 撮影を stage7/stage8 に追加し、stage8 の横視点画像に対して vlm_judge kind="place_side" で PLACE 状態を判定する経路を導入。新列 `side_ap_place_capture_path` (58) / `side_place_capture_path` (59) / `side_place_verdict` (60) / `side_place_confidence` (61) / `side_place_reason` (62) を 57 列の末尾に追加。8節手順の archive→新規空台帳ルートは、状態語 (3節) の pick_stage 系列で構造化する場合に限定する規定と解釈し、今回の観測系 5 列は「ファイル名 v3 のまま in-file 拡張・過去 279 行は新列を空欄で保持・archive/pick_place_ledger_premerge_20260703/ に退避コピーを保全」で実施。追記ガードは `LEDGER_COLS` の 62 列と実体ヘッダ 62 列が一致するため通る。既存 pick 系・姿勢制御・完走 HOME 復帰は無変更。
- 2026-06-27（DAY258・v3 実体ヘッダ確定）: Mac で `pick_place_ledger_v3.csv` の実体ヘッダ（57列）と `run_pick_place.py` の `LEDGER_COLS` を突合し一致を確認。2節を確定版（57列の列名・順序・意味）に更新。`pick_pose_id`(37)/`place_pose_id`(44) の実在、状態語の新列（`pick_stage` 等）が57列に無いことを実測で確認し、追記ガード（L758–783 `[ABORT-APPEND]`）の存在も確認 ── 運用中の v3 にその場で列を足せないことの裏取りが取れた（列追加は 8節の退避→作り直し手順による）。列数表記を未確定→57に確定。
- 2026-06-27（DAY258・codex 再レビュー反映＋v3 一本方針で確定）: 状態語の新列が現行 57 列に無くヘッダ不一致を誘発する問題（N1）に対し、8節を新設。codex は別ファイル v4 への分離を推したが、台帳はファイル名 `pick_place_ledger_v3.csv` 一本で通す（リネーム・新バージョン番号を切らない）方針をエイイチが確定。列を増やすときは旧 v3 を archive にコピー退避 → 新ヘッダの v3 を新規の空台帳として作り直し → `LEDGER_COLS` を合わせる手順とした（過去行は archive 側が正・新台帳は 0 行から）。`fk_row_{selected_idx}` を行 index 単独で永続 ID にせず、テーブル版/ハッシュとセット（`fk:{table_basename}:{table_hash}:{selected_idx}`）にする規定を追加（N2）。j0_lookup 由来を approach / place_withblock に拡張（R3）。`reached`＋`held_after_lift=false` が grasp failure と dropped before lift を区別しない暫定表現である旨を明記（R5）。
- 2026-06-27（DAY258・codex レビュー反映）: twin を「runtime で読まない」→「descend で `fk_lookup` が runtime に読む（DAY257 以降）」へ訂正。列数 50 を撤回し未確定（現行 `LEDGER_COLS`=57・実体ヘッダ突合で確定）に。単一 `pose_id` を `pick_pose_id` / `place_pose_id` ＋ `fk_lookup` 由来は `fk_row_{selected_idx}` 暫定正へ。レイヤーA を `pick_stage` ＋根拠フラグ列（`reached_bool` / `grip_commanded` / `held_after_lift` / `abort_reason` / `evidence_source`）に再設計。レイヤーA の中断語を `aborted`→`interrupted` に改名（レイヤーB との同名衝突回避）。`grasped` を現状センサーで判定不可として保留（GRIP 直後確認の導入を有効化条件に）。`ledger_schema_version` で語彙を版管理（本節=v1）。MASTER との優先順位（場所=MASTER／中身=本ファイル）と運用前提条件（ヘッダ突合 P0）を明記
- 2026-06-27（DAY258）: 新規作成（ドラフト）。台帳の列・状態語・pose_id 連携・追記義務を一本化。列定義は Mac 実測ヘッダで確定する箇所として保留。試行結果の状態語（レイヤーA: not_reached / reached / grasped / held / aborted）を pick 空欄問題への対応として新設。データラベリング status（レイヤーB・4 語）と別レイヤーである旨を明記

exec
/bin/zsh -lc 'cat docs/review_result_20261101_provenance_v2_codex.md' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
# codex レビュー結果 R1：由来の指紋 files-v2・読めない由来・控えのファイル（2026-11-01 依頼 56）

依頼文: docs/review_request_20261101_provenance_v2.md（codex exec -s read-only）

対象4ファイルの差分を、規則12節と既存の比較処理に照らして読み取り専用で確認します。ファイル変更や実機・Pod・Hugging Faceの操作は行いません。
exec
/bin/zsh -lc 'git diff HEAD -- provenance.py test_provenance.py lab_train.sh test_lab_train.sh' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-GDCpAuXL' (errno=Operation not permitted)
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-lzheLey7' (errno=Operation not permitted)
diff --git a/lab_train.sh b/lab_train.sh
index f55b640..78f3c46 100755
--- a/lab_train.sh
+++ b/lab_train.sh
@@ -189,8 +189,7 @@ prov_spec() {               # 由来の spec (lab_train.sh が知っているこ
          inputs: $start.inputs,
          code: ($start.code // null),
          outputs: (if $ck_dir == "" then [] else [{kind: "checkpoint", id: ($ckpt_name + "/" + $step), path: $ck_dir,
-                   hf_repo: (if $prov_hf == "1" then $repo_out else null end),
-                   fingerprint_note: "train_provenance.json を置く前に測った"}] end),
+                   hf_repo: (if $prov_hf == "1" then $repo_out else null end)}] end),     # 指紋 files-v2 は由来のファイルを数えない (依頼 56)
          config: {name: $config, exp_name: $exp, num_train_steps: ($steps | num), init_params: ($init | str), data_name: $data,
                   limit_hours: ($limit | num), checkpoint_step: ($step | str)},
          compute: {provider: "runpod", pod_id: ($pod | str), gpu: ($gpu | str), started_at: ($t0 | str), train_started_at: ($tt | str),
diff --git a/provenance.py b/provenance.py
index ac5162e..8560951 100644
--- a/provenance.py
+++ b/provenance.py
@@ -1,10 +1,11 @@
 #!/usr/bin/env python3
-"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54).
+"""provenance.py — 作ったもの (checkpoint・データセット・試行の記録) に「由来」を書く共通の部品 (2026-11-01 依頼 54・56).
 
 決まりの正は data_manifest_rules.md 12節. 出す JSON は schema_version "provenance/1".
 
 bash から:
-  python3 provenance.py fingerprint <フォルダかファイル>            指紋 (files-v1:<sha256>) を1行で出す
+  python3 provenance.py fingerprint [--version files-v1] <フォルダかファイル>
+      指紋 (files-v2:<sha256>) を1行で出す. --version files-v1 は前の作り方 (files-v1 の記録と比べるときだけ)
   python3 provenance.py prepare --data-root <r2r2r-data> [--code-path ...] < spec.json
       始める時点のコード (code) と入力の指紋 (inputs) を JSON で出す. 終わったときに write の spec に入れて渡す
   python3 provenance.py write --data-root <r2r2r-data> --out <置く先> [--out <もう1つ>] \\
@@ -20,15 +21,25 @@ Python から:
                           "status": {"process": "completed", "task": "not_applicable"}},
                          data_root="/Volumes/KIOXIA/r2r2r-data")
   provenance.write(rec, "<出力フォルダ>/dataset_provenance.json")      # あれば FileExistsError
+  r = provenance.read("<出力フォルダ>/dataset_provenance.json")         # 読めなくても止めない (下の read())
+  if r["state"] == "ok": rec = r["provenance"]
 
-指紋 files-v1 (fingerprint()):
+指紋 files-v2 (fingerprint()):
   フォルダの中のファイルを相対パス (NFC・/ 区切り) の UTF-8 のバイト順に並べ,
     1) 全部のファイルの「相対パス\\tバイト数\\n」
     2) そのうち名前が .json で終わり 1MB 未満のファイルについて「\\0相対パス\\0」+ 中身
   をこの順に sha256 に入れる. 画像・動画・重みの中身は読まない.
-  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと).
+  数えないもの: 名前が ._ で始まるもの (exFAT が付ける)・.cache・.incoming_* (フォルダごと)・
+    .DS_Store (Finder で開くだけで作られる)・由来のファイルそのもの (train_provenance.json・*_provenance.json).
+    由来を置いたあとに測り直しても, 置く前と同じ指紋になる (data_manifest_rules.md 12-4).
+  files-v1 (2026-11-01 まで) は .DS_Store と由来のファイルも数えていた. それ以外は同じ. 比べるときは同じ版どうしで比べる.
   リンクはたどらない (リンク自身の大きさ). ファイル1つを渡したときは, その名前1つの一覧として同じに作る.
 
+code の dirty: git に入っていない控えのファイル (名前に .bak を含む. 例 build_demo_episodes.py.bak_pre_LOG352_20261019) は
+  dirty_files に入れず untracked_backups に分けて残す (Pod へ送る中身には入るので必ず残す). dirty はそれ以外の変更があるときだけ true.
+
+読む (read()): 書きかけ・JSON が壊れている・由来の形でないものは例外で止めず state "unreadable" (由来なし) を返し, 警告を1行出す.
+
 鍵・トークンは書かない: 引数の --token / --key / --secret / --password などの値と, hf_・rpa_ などで始まる鍵らしい文字は
 「<redacted>」に置き換え, --secret-file で渡した鍵のファイルの中身が記録のどこかに出れば同じく置き換える.
 """
@@ -47,7 +58,9 @@ import unicodedata
 SCHEMA = "provenance/1"
 REPO_NAME = "r2r2r-scripts"
 REPO_DIR = os.path.dirname(os.path.abspath(__file__))
-FP_PREFIX = "files-v1:"
+FP_VERSION = "files-v2"      # いまの指紋の作り方
+FP_VERSIONS = ("files-v1", "files-v2")
+FP_PREFIX = FP_VERSION + ":"
 JSON_MAX = 1 << 20          # これ未満の .json は中身も指紋に入れる
 REDACTED = "<redacted>"
 INPUT_KINDS = ("dataset", "checkpoint", "run", "recording", "config")
@@ -62,15 +75,35 @@ SECRET_VALUE = re.compile(r"\b(hf_[A-Za-z0-9]{10,}|rpa_[A-Za-z0-9]{10,}|sk-[A-Za
 
 # ---- 指紋 ----
 def _skip(name):
+    """フォルダもファイルも数えない名前 (files-v1・v2 とも)"""
     return name.startswith("._") or name == ".cache" or name.startswith(".incoming_")
 
 
+def is_provenance_name(name):
+    """由来のファイルの名前か (train_provenance.json・*_provenance.json)"""
+    return name == "train_provenance.json" or name.endswith("_provenance.json")
+
+
+def _skip_file_v2(name):
+    """files-v2 でだけ数えないファイル: .DS_Store と由来のファイル"""
+    return name == ".DS_Store" or is_provenance_name(name)
+
+
 def _nfc(s):
     return unicodedata.normalize("NFC", s)
 
 
-def list_files(path):
-    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)"""
+def _check_version(version):
+    if version not in FP_VERSIONS:
+        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")
+
+
+def list_files(path, version=FP_VERSION):
+    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)
+
+    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
+    _check_version(version)
+    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
     path = os.path.abspath(path)
     if not os.path.lexists(path):
         raise FileNotFoundError(path)
@@ -89,7 +122,7 @@ def list_files(path):
             files.append(x)
         rel = os.path.relpath(d, path)
         for f in files:
-            if _skip(f):
+            if _skip(f) or skip_file(f):
                 continue
             full = os.path.join(d, f)
             p = f if rel == "." else os.path.join(rel, f)
@@ -101,8 +134,9 @@ def list_files(path):
     return out
 
 
-def fingerprint(path):
-    files = list_files(path)
+def fingerprint(path, version=FP_VERSION):
+    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
+    files = list_files(path, version)
     h = hashlib.sha256()
     for p, n, _ in files:
         h.update(f"{p}\t{n}\n".encode())
@@ -110,7 +144,16 @@ def fingerprint(path):
         if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
             with open(full, "rb") as fh:
                 h.update(b"\0" + p.encode() + b"\0" + fh.read())
-    return FP_PREFIX + h.hexdigest()
+    return version + ":" + h.hexdigest()
+
+
+def fingerprint_version(fp):
+    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
+    if isinstance(fp, str):
+        v, sep, d = fp.partition(":")
+        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
+            return v
+    return None
 
 
 # ---- コード ----
@@ -123,7 +166,8 @@ def _git(args, cwd):
 
 def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
-    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "diff_sha256": None, "measured_at": now_iso()}
+    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
+            "diff_sha256": None, "measured_at": now_iso()}
     if paths:
         info["paths"] = list(paths)
     try:
@@ -131,18 +175,22 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
         sel = ["--", *paths] if paths else []
         st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
         entries = [e for e in st.decode(errors="replace").split("\0") if e]
-        files, untracked, i = [], [], 0
+        files, untracked, backups, i = [], [], [], 0
         while i < len(entries):
             e = entries[i]
             code, f = e[:2], e[3:]
             if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                 i += 1
-            files.append(f)
-            if code == "??":
-                untracked.append(f)
+            if code == "??" and is_untracked_backup(f):
+                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
+            else:
+                files.append(f)
+                if code == "??":
+                    untracked.append(f)
             i += 1
         files = sorted(set(files))
         info["dirty"] = bool(files)
+        info["untracked_backups"] = sorted(set(backups))
         info["dirty_files"] = files[:DIRTY_FILES_MAX]
         if len(files) > DIRTY_FILES_MAX:
             info["dirty_files_total"] = len(files)
@@ -159,6 +207,11 @@ def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
     return info
 
 
+def is_untracked_backup(path):
+    """git に入っていない控えのファイルとみなす名前か (名前に .bak を含む. 例 a.py.bak・a.py.bak_pre_LOG352_20261019)"""
+    return ".bak" in path.rstrip("/").rsplit("/", 1)[-1]
+
+
 # ---- 鍵を伏せる ----
 def redact_argv(argv):
     out, hide_next = [], False
@@ -328,11 +381,63 @@ def write(rec, path):
     return path
 
 
+# ---- 読む ----
+def read(path, warn=True):
+    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
+
+    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
+    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
+      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
+      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
+      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
+                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
+    path = os.fspath(path)
+    res = {"state": "missing", "path": path, "provenance": None, "error": None}
+    try:
+        with open(path, "rb") as fh:
+            raw = fh.read()
+    except FileNotFoundError:
+        res["error"] = "ファイルがない"
+        return res
+    except Exception as e:      # 読めないファイル (権限・フォルダなど)
+        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
+    try:
+        doc = json.loads(raw.decode("utf-8"))
+    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
+        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
+    rec = doc
+    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
+        if "provenance" in doc:
+            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
+        elif not is_provenance_name(os.path.basename(path)):
+            res["error"] = "キー provenance がない"
+            return res
+    if not isinstance(rec, dict):
+        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
+    sv = rec.get("schema_version")
+    if not _is_prov_schema(sv):
+        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
+    res.update(state="ok", provenance=rec)
+    return res
+
+
+def _is_prov_schema(sv):
+    return isinstance(sv, str) and sv.startswith("provenance/")
+
+
+def _unreadable(res, why, warn):
+    res.update(state="unreadable", error=why)
+    if warn:
+        print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
+    return res
+
+
 # ---- CLI ----
 def main(argv=None):
     ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
     sub = ap.add_subparsers(dest="cmd", required=True)
     f = sub.add_parser("fingerprint")
+    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
     f.add_argument("path")
     pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
     pr.add_argument("--data-root", default=None)
@@ -348,7 +453,7 @@ def main(argv=None):
     a = ap.parse_args(argv)
     try:
         if a.cmd == "fingerprint":
-            print(fingerprint(a.path))
+            print(fingerprint(a.path, a.version))
             return 0
         if a.cmd == "prepare":
             print(json.dumps(prepare(json.loads(sys.stdin.read() or "{}"), a.data_root, a.repo_dir, a.code_path), ensure_ascii=False))
diff --git a/test_lab_train.sh b/test_lab_train.sh
index 85683ea..bb13062 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -382,9 +382,10 @@ check "成功: provenance/1・ISO8601 (タイムゾーンつき)・lab_train.sh
 check "成功: 入力とコードは始める時点に測る・記録にも残す (codex R1)" '[ "$(jq -c .prov_start.code "$TD/lab_pod.json")" = "$(jq -c .code "$P")" ] && [ "$(pj ".inputs[0].measured_at <= .compute.started_at")" = true ] && [[ "$(pj ".inputs[2].sha256")" =~ ^[0-9a-f]{64}$ ]]'
 check "成功: code (r2r2r-scripts のコミット・dirty・見たパス)" '[ "$(pj .code.repo)" = r2r2r-scripts ] && [ "$(pj .code.commit)" = "$(git -C "$HERE" rev-parse HEAD)" ] && [[ "$(pj .code.dirty)" =~ ^(true|false)$ ]] && [ "$(pj ".code.paths | join(\",\")")" = "lab_train.sh,lab_lib.sh,provenance.py,pi05" ]'
 check "成功: Pod の openpi のコミットと変更" '[ "$(pj ".external_code[0].name")" = openpi ] && [ "$(pj ".external_code[0].commit")" = 215abfb1234567890abcdef1234567890abcdef0 ] && [ "$(pj ".external_code[0].dirty")" = true ] && [ "$(pj ".external_code[0].dirty_files[0]")" = src/openpi/training/config.py ]'
-check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v1:* ]]'
+check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v2:* ]]'
 check "成功: 設定・compute (GPU・始めと終わり・時間・費用は [COST] と同じ)・numbering・status" '[ "$(pj .config.name)" = pi05_mecharm_trim_corr_lora ] && [ "$(pj .config.num_train_steps)" = 1500 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.cost_per_hr_usd)" = 0.44 ] && [ "$(pj .compute.duration_sec)" = "$(jq .rented_sec "$TD/lab_pod.json")" ] && [ -n "$(pj ".compute.started_at // empty")" ] && [ -n "$(pj ".compute.ended_at // empty")" ] && grep -qF "[COST  ] $(pj .compute.cost_line)" "$T/out.txt" && [ "$(pj .numbering.series)" = フィジカルAI研究室 ] && [ "$(pj .numbering.series_log)" = LogT ] && [ "$(pj ".status | [.process,.task] | join(\" \")")" = "completed not_applicable" ] && [ "$(pj ".error // empty")" = "" ]'
-check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v1:* ]]'
+check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v2:* ]]'
+check "成功: 由来 (と .DS_Store) を置いたあとに測り直しても出力の指紋と合う (files-v2. 依頼 56)" '[ -f "$CK/1499/train_provenance.json" ] && [ -f "$CK/1499/.DS_Store" ] && [ "$(pj ".outputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$CK/1499")" ] && [ "$(pj ".outputs[0].fingerprint")" != "files-v2:$(python3 "$HERE/provenance.py" fingerprint --version files-v1 "$CK/1499" | cut -d: -f2)" ]'
 # Pod の openpi を読むコマンドを, この Mac の bash で一時の git リポジトリに向けて流す (codex R1)
 oc=$(grep -F ": lt=openpi_rev" "$T/ssh.log" | head -1); G=$(mktemp -d)
 (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t && echo a > f && git add f && git commit -qm i && echo b >> f && echo n > new)
@@ -459,6 +460,15 @@ rm -rf "$T"
 echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
 LAB_PROV_REPO="$G" run success
 check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
+rm -rf "$T"
+# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
+(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
+LAB_PROV_REPO="$G" run success
+check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+echo "# changed" >> "$G/lab_lib.sh"
+LAB_PROV_REPO="$G" run success
+check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
 rm -rf "$T" "$G"
 OUT1=""
 
diff --git a/test_provenance.py b/test_provenance.py
index bf1313a..4d1ccfd 100644
--- a/test_provenance.py
+++ b/test_provenance.py
@@ -1,5 +1,5 @@
 #!/usr/bin/env python3
-"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54) の確かめ.
+"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.
 
   python3 test_provenance.py -v
 
@@ -45,7 +45,7 @@ class Fingerprint(unittest.TestCase):
 
     def test_format_and_same_twice(self):
         a = pv.fingerprint(self.d)
-        self.assertRegex(a, r"^files-v1:[0-9a-f]{64}$")
+        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
         self.assertEqual(a, pv.fingerprint(self.d))
 
     def test_definition(self):
@@ -55,7 +55,48 @@ class Fingerprint(unittest.TestCase):
             h.update(f"{p}\t{n}\n".encode())
         h.update(b"\0index.json\0" + b'{"n": 1}')
         h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
-        self.assertEqual(pv.fingerprint(self.d), "files-v1:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
+        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身
+
+    def test_v2_skips_ds_store_and_provenance_files(self):
+        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
+        a = pv.fingerprint(self.d)
+        v1 = pv.fingerprint(self.d, "files-v1")
+        put(self.d, ".DS_Store", b"\0" * 6148)
+        put(self.d, "raw/.DS_Store", b"\0" * 10)
+        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
+        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
+        self.assertEqual(a, pv.fingerprint(self.d))
+        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
+        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
+        put(self.d, "raw/DS_Store", "x")
+        self.assertNotEqual(a, pv.fingerprint(self.d))
+
+    def test_v2_same_after_writing_provenance_into_output(self):
+        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
+        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
+        pv.write(rec, self.d / "train_provenance.json")
+        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
+        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")
+
+    def test_version_helpers(self):
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
+        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
+        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
+            self.assertIsNone(pv.fingerprint_version(bad))
+        with self.assertRaises(ValueError):
+            pv.fingerprint(self.d, "files-v3")
+
+    def test_skips_agree_with_checkpoint_compares(self):
+        # hf_upload_ckpt.py (Hugging Face 側の比べ) と lab_list_files (KIOXIA 側の比べ) が数えない由来・.DS_Store は, 指紋でも数えない
+        sys.path.insert(0, str(Path(__file__).resolve().parent / "pi05" / "openpi_addon"))
+        import hf_upload_ckpt as hu
+        for name in hu.SKIP_REMOTE:
+            if name.endswith(".json"):
+                self.assertTrue(pv.is_provenance_name(name), name)
+        lib = (Path(__file__).resolve().parent / "lab_lib.sh").read_text()
+        self.assertIn('f in (".DS_Store", ".gitattributes")', lib)
+        self.assertTrue(pv._skip_file_v2(".DS_Store"))
 
     def test_skips_dotunderscore_cache_incoming(self):
         a = pv.fingerprint(self.d)
@@ -116,7 +157,8 @@ class Fingerprint(unittest.TestCase):
     def test_single_file_and_missing(self):
         f = put(self.tmp.name, "one.json", "{}")
         h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
-        self.assertEqual(pv.fingerprint(f), "files-v1:" + h)
+        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
+        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
         with self.assertRaises(FileNotFoundError):
             pv.fingerprint(Path(self.tmp.name) / "nothing")
 
@@ -200,6 +242,29 @@ class Code(unittest.TestCase):
         put(self.r, "sub/b.py", "print(2)\n")
         self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])
 
+    def test_untracked_backup_not_dirty_but_kept(self):
+        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
+        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
+        put(self.r, "a.sh.bak", "old\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
+        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
+        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
+        self.assertEqual(len(c["untracked_backups"]), 2)
+        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])
+
+    def test_tracked_backup_change_is_dirty(self):
+        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
+        put(self.r, "x.bak", "1\n")
+        git(self.r, "add", "x.bak")
+        git(self.r, "commit", "-qm", "bak")
+        put(self.r, "x.bak", "2\n")
+        put(self.r, "dir.bak/new.py", "x\n")
+        c = pv.code_info(self.r)
+        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))
+
     def test_not_a_repo(self):
         with tempfile.TemporaryDirectory() as t:
             c = pv.code_info(t)
@@ -207,6 +272,70 @@ class Code(unittest.TestCase):
         self.assertIn("error", c)
 
 
+class Read(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.d = Path(self.tmp.name)
+        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def rd(self, p):
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(p)
+        return r, err.getvalue()
+
+    def test_ok(self):
+        p = pv.write(self.rec, self.d / "train_provenance.json")
+        r, err = self.rd(p)
+        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))
+
+    def test_embedded_key(self):
+        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
+        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
+        r, err = self.rd(self.d / "index.json")
+        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))
+
+    def test_missing_no_warning(self):
+        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
+        for p in (self.d / "nothing.json", self.d / "old.json"):
+            r, err = self.rd(p)
+            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))
+
+    def test_unreadable_returns_and_warns_one_line(self):
+        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
+        cases = {
+            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
+            "empty_provenance.json": b"",
+            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
+            "notutf8_provenance.json": b"\xff\xfe{",
+            "list_provenance.json": b"[1, 2]",
+            "nosv_provenance.json": b'{"a": 1}',
+            "badsv_provenance.json": b'{"schema_version": "other/1"}',
+            "nulled.json": b'{"provenance": null}',
+        }
+        for name, data in cases.items():
+            put(self.d, name, data)
+            r, err = self.rd(self.d / name)
+            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
+            self.assertTrue(r["error"], name)
+            self.assertEqual(len(err.splitlines()), 1, name)
+            self.assertIn("由来なし (読めない)", err)
+            self.assertIn(name, err)
+
+    def test_unreadable_dir_and_no_warn(self):
+        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
+        self.assertEqual(r["state"], "unreadable")
+        self.assertEqual(len(err.splitlines()), 1)
+        put(self.d, "x_provenance.json", "{")
+        err = io.StringIO()
+        with redirect_stderr(err):
+            r = pv.read(self.d / "x_provenance.json", warn=False)
+        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))
+
+
 class BuildWrite(unittest.TestCase):
     def setUp(self):
         self.tmp = tempfile.TemporaryDirectory()

exec
/bin/zsh -lc 'cat /Users/shimazueiichi/r2r2r-content/methodology/data_manifest_rules.md' in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
# data_manifest_rules.md

## 台帳・データマニフェスト管理ルール（唯一の正）

*最終更新: 2026-11-01（11節「番号の種類と書き方」・12節「出力に付ける由来（provenance）」を新設し、12節を実装に合わせて直した（指紋 files-v2・outputs・読めない由来の扱い）。台帳の列は変更なし）*

台帳（`pick_place_ledger_v3.csv` / `handover_ledger_v1.csv` / `unity_ledger_v1.csv`）の列・状態語・追記義務はこのファイルが唯一の正。
MASTER のデータ保管セクションが「**どこに置くか**（データの種類 → 場所）」を決めるのに対し、本ファイルは「**台帳の中に何をどう書くか**（列の意味・試行結果の状態語・pose_id 連携・追記義務）」を決める。両者は役割が重ならない。

**優先順位**: データの置き場所（種類 → 場所）は MASTER が優先。台帳の列・状態語・追記条件は本ファイルが優先。矛盾する場合は、MASTER の場所規約を侵さない範囲で本ファイルを優先する。MASTER 側には「台帳の中身は data_manifest_rules.md を参照」とだけ残す。

> 列名・列順の実体は Mac 上の台帳ヘッダを正とする。本ファイルの列定義は推測で焼かず、実測したヘッダで確定する（「パスを推測で焼かない」原則の台帳版）。

---

### 1. 台帳の正体

- 実体: `/Volumes/KIOXIA/r2r2r-data/runs/pick_place_ledger_v3.csv`
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない
- 1 試行 = 1 行。横断索引であり、各試行の生成物は `runs/run_pick_place/{run_stem}/` 配下に別途残る
- 列数: 82（2026-07-15 DAY287 で 78 → 82 に拡張。recorder 連続記録 4 列 (79-82) を 8-A 手順で追加。過去 647 データ行は末尾に空セル 4 つ付与で行数・順序不変）。列定義は2節。コードに追記ガードあり（ヘッダ不一致時は `[ABORT-APPEND]` で追記中止）
- 新ヘッダ md5 (ファイル全体・退避元 md5: `384b1e9a3bc8482213157afb5673c204` / 8-A 適用後の live md5: `91dac34289c6701e3b530c15315f2f78`)。archive 退避: `/Volumes/KIOXIA/r2r2r-data/archive/pick_place_ledger_prerestore_20260715/`
- v2 は immutable（歴史的記録として温存・変更禁止）。現行は v3
- 3・4節で定義する状態語の新列を入れるときも、ファイル名は `pick_place_ledger_v3.csv` のまま（リネーム・新バージョン番号を切らない）。旧 v3 を `archive/` にコピー退避してから、新ヘッダの v3 を新規の空台帳として作り直す（8節の 8-B）

---

### 2. 列定義（実体ヘッダで確定・82列。2026-07-15 実測: head -1 | csv.reader = 82）

実体ヘッダ（`pick_place_ledger_v3.csv` の `head -1`）と現行コード `run_pick_place.py` の `LEDGER_COLS` を突合し、82列で一致を確認した。コードには追記ガードが実装済みで、`existing_header != LEDGER_COLS` のとき `[ABORT-APPEND]` で追記を中止する（L758–783）。したがって運用中の v3 にその場で列を足すと追記が弾かれる ── 列を増やすときは旧 v3 を退避して 8-A（過去行を保持したまま末尾に空セル付与）または 8-B（archive→新規空台帳）を踏む。

| #     | 列名                              | 意味（グループ）                                                                       |
| ----- | ------------------------------- | ------------------------------------------------------------------------------ |
| 1     | `timestamp`                     | 試行時刻（ISO8601）                                                                  |
| 2     | `run_stem`                      | 試行識別子（フォルダ名と一致）                                                                |
| 3     | `trial_no`                      | 試行番号                                                                           |
| 4     | `pick_verdict`                  | PICK 成否（yes/no/unknown 系）                                                      |
| 5     | `pick_reason`                   | PICK 判定理由                                                                      |
| 6     | `pick_confidence`               | PICK 判定の確信度                                                                    |
| 7     | `place_verdict`                 | PLACE 成否                                                                       |
| 8     | `place_reason`                  | PLACE 判定理由                                                                     |
| 9     | `place_confidence`              | PLACE 判定の確信度                                                                   |
| 10    | `block_pick_x`                  | PICK 時ブロック X                                                                   |
| 11    | `block_pick_z`                  | PICK 時ブロック Z                                                                   |
| 12    | `block_place_x`                 | PLACE 時ブロック X                                                                  |
| 13    | `block_place_z`                 | PLACE 時ブロック Z                                                                  |
| 14    | `box_x`                         | 箱 X                                                                            |
| 15    | `box_z`                         | 箱 Z                                                                            |
| 16    | `in_box_geom`                   | 箱内判定（幾何）                                                                       |
| 17    | `in_box_dist_mm`                | 箱中心までの距離 mm                                                                    |
| 18–23 | `final_j1`…`final_j6`           | 最終姿勢の各関節角（度）                                                                   |
| 24    | `gripper`                       | グリッパー状態                                                                        |
| 25    | `place_capture_path`            | PLACE 撮影画像パス                                                                   |
| 26    | `tip_pick_x`                    | PICK 時の爪先 X                                                                    |
| 27    | `tip_pick_z`                    | PICK 時の爪先 Z                                                                    |
| 28    | `tip_block_dx_mm`               | 爪先とブロックの X ズレ mm                                                               |
| 29    | `tip_block_dz_mm`               | 爪先とブロックの Z ズレ mm                                                               |
| 30    | `tip_block_dist_mm`             | 爪先とブロックの距離 mm                                                                  |
| 31    | `run_dir`                       | 試行ディレクトリ                                                                       |
| 32    | `tip_capture_path`              | TIP 測定画像パス                                                                     |
| 33    | `pick_capture_path`             | PICK 撮影画像パス                                                                    |
| 34    | `align_j0_final`                | 視覚サーボ後の j0 最終値                                                                 |
| 35    | `align_red_x_mm`                | 赤ブロック X mm                                                                     |
| 36    | `align_converged`               | 視覚サーボ収束フラグ                                                                     |
| 37    | `pick_pose_id`                  | PICK 姿勢の照合キー（4節）                                                               |
| 38–43 | `pick_used_j0`…`pick_used_j5`   | PICK で使った各関節角                                                                  |
| 44    | `place_pose_id`                 | PLACE 姿勢の照合キー（4節）                                                              |
| 45–50 | `place_used_j0`…`place_used_j5` | PLACE で使った各関節角                                                                 |
| 51    | `corr_cell_key`                 | 補正記憶のセルキー（例 `x220_z200`）                                                       |
| 52    | `corr_applied_dx`               | 適用した X 補正量                                                                     |
| 53    | `corr_applied_dz`               | 適用した Z 補正量                                                                     |
| 54    | `target_before`                 | 補正前の狙い                                                                         |
| 55    | `target_after`                  | 補正後の狙い                                                                         |
| 56    | `vlm_pick_verdict`              | VLM-as-Reward の PICK 判定                                                        |
| 57    | `pick_dist_override`            | PICK 距離の手動上書き                                                                  |
| 58    | `side_ap_place_capture_path`    | 横カメラ (Iriun index=1) stage7 到達後撮影の画像パス（判定なし・記録のみ）                              |
| 59    | `side_place_capture_path`       | 横カメラ stage8 到達後撮影の画像パス                                                         |
| 60    | `side_place_verdict`            | 横カメラ PLACE 判定 verdict (yes/no/unknown/"")                                      |
| 61    | `side_place_confidence`         | 横カメラ PLACE 判定 confidence (0.0-1.0)                                             |
| 62    | `side_place_reason`             | 横カメラ PLACE 判定 reason 短文                                                        |
| 63    | `place_corr_cell_key`           | PLACE 補正記憶のセルキー（例 `place_x-40_z100`）— CELL_MM=20mm セル中心・phase プレフィックス "place_" |
| 64    | `place_corr_applied_dx`         | PLACE で適用した X 補正量 mm（EMA 平均後・MAX_CORRECTION_MM=40 でクランプ）                       |
| 65    | `place_corr_applied_dz`         | PLACE で適用した Z 補正量 mm                                                           |
| 66    | `place_target_before`           | 補正前の狙い箱位置 `(+X,+Z)` mm                                                         |
| 67    | `place_target_after`            | 補正後の狙い（fk_lookup へ渡した値）mm                                                      |
| 68    | `box_shift_dx`                  | 箱ズレ補正の X 量（DAY274 追加）                                                          |
| 69    | `box_shift_dz`                  | 箱ズレ補正の Z 量（DAY274 追加）                                                          |
| 70    | `box_shift_dist`                | 箱ズレの距離 mm（DAY274 追加）                                                           |
| 71    | `box_hit_flag`                  | 箱ヒット判定フラグ（DAY274 追加）                                                           |
| 72    | `box_hit_thresh_mm`             | 箱ヒット判定の閾値 mm（DAY274 追加）                                                        |
| 73    | `place_height_cell`             | PLACE 高さ補正記憶のセルキー                                                              |
| 74    | `place_height_dee_y_mm`         | PLACE 高さの dee_y mm                                                             |
| 75    | `place_height_j4_selected`      | PLACE で選択した j4                                                                 |
| 76    | `place_corr_reason`             | PLACE 補正の理由                                                                    |
| 77    | `box_shift_reason`              | 箱ズレ補正の理由                                                                       |
| 78    | `place_img_path`                | PLACE 画像パス                                                                     |
| 79    | `recording_path`                | 連続記録フォルダの相対パス（DAY287 追加）                                                       |
| 80    | `recording_fps`                 | 連続記録の実効 fps（DAY287 追加）                                                         |
| 81    | `recording_frames`              | 連続記録のフレーム数（DAY287 追加）                                                          |
| 82    | `recording_status`              | 連続記録の状態 ok/truncated/camera_lost/mount_lost/none（DAY287 R2 で mount_lost を追加）   |

> 68〜72 は DAY274 追加の箱ズレ系観測/判定列。73〜75 は PLACE 高さ補正記憶、76・77 は補正/箱ズレの理由文字列、78 は PLACE 画像パス。place_img_path(78) と place_capture_path(25)・side_*_capture_path(58,59) の使い分け、place_height 系の細目はコードの書き込み処理を正とする。

> 79〜82 は DAY287 追加の recorder 連続記録メタ列。`recording_path` は `runs/run_pick_place/{run_stem}/recording/` を run_stem 起点の相対パス（recording/）で入れる。`recording_status` の語彙（5 値）: `ok` / `truncated`（前試行の trial_end 未受領・二重 trial_start で強制打切）/ `camera_lost`（read 30 連続失敗）/ `mount_lost`（DAY287 R2・試行中に KIOXIA マウントが消えた）/ `none`（recorder 不在・空欄でなく明示語彙）。recorder が返した `recording_meta.json` を `_append_ledger` が **最大 2.0 秒・100ms 間隔** の有界待ちで存在確認 → 有れば読んで転記、タイムアウト時は none（後からの reconcile はしない・追記のみ原則 7節）。recorder 側は tmp+os.replace の原子的置換で書くので half-written は発生しない。

> 型・必須/任意・単位の細目はコード（`LEDGER_COLS` 周辺の書き込み処理）を正とする。本表は列名・順序・意味の確定版。

確定済みで分かっている列:

- `run_stem` … 1 試行の識別子（フォルダ名と一致）
- `pick_pose_id` / `place_pose_id` … phase 別の照合キー（単一 `pose_id` 列ではない）。j0_lookup 由来は採用 JSON の pose_id、`fk_lookup` 由来は FK テーブル行 ID `fk_row_{selected_idx}` を暫定 pose_id とする（4節）
- `cell` … ブロック検出位置を 20mm 格子に量子化したセルキー（例 `x220_z200`）
- `適用Δz` / `dx_mm` / `dz_mm` … 補正記憶から適用した狙いの補正量
- `tip_dx` / `tip_dz` … 最終 TIP 測定での爪先ズレ（mm）
- `dist` … 爪先と狙いの距離（mm）
- `pick` … 把持結果（現状 yes/空欄。→ 3節の `pick_stage` ＋根拠フラグ列に再設計）

---

### 3. 試行結果の状態語（最重要・保持の山を測るため）

位置には届いたのに `pick` 欄が空のまま終わる試行が出た。これが「未到達」なのか「届いたが保持できなかった」のか、台帳から読めない。保持の山を測定可能にするには、試行結果を段階の状態として持つ必要がある（P008）。

**状態は 2 レイヤーある。列名で必ず分離する**（同じ綴りを別レイヤーで使わない）。

- **レイヤーA: 試行結果の状態** → 列 `pick_stage`（本節で定義）
- **レイヤーB: 姿勢素材の採用状態** → 列 `asset_status` 相当（5節）。語彙 `adopted / superseded / aborted / test`

レイヤーA の中断は `aborted` ではなく `interrupted` と綴る（レイヤーB の `aborted` と同名衝突させない）。

#### レイヤーA: pick_stage（到達段階・要約列）

| pick_stage    | 意味                                 | 判定                           |
| ------------- | ---------------------------------- | ---------------------------- |
| `not_reached` | descend が狙い位置へ届かなかった               | tip dist > 閾値、または送角 timeout  |
| `reached`     | 位置に届いた（把持の成否は別）                    | tip dist ≤ 閾値                |
| `grasped`     | 掴んだが LIFT までに落とした                  | ※現状センサーで一意判定不可・**保留**（下記）    |
| `held`        | LIFT 後も保持（PICK 成功）                 | LIFT 後画像（VLM-as-Reward）で保持確認 |
| `interrupted` | 送角 silent drop / timeout / 安全停止で中断 | `abort_reason` 列に理由を残す       |

`pick_stage` は要約列。これ単独に頼らず、根拠を別のフラグ列で持つ（線形段階だけでは「掴んだが位置はズレていた」のような一直線でないケースを取りこぼすため）。

| 根拠フラグ列            | 型    | 意味                                                                              |
| ----------------- | ---- | ------------------------------------------------------------------------------- |
| `reached_bool`    | bool | tip dist ≤ 閾値で位置到達したか                                                           |
| `grip_commanded`  | bool | GRIP を送ったか                                                                      |
| `held_after_lift` | bool | LIFT 後に保持できていたか（VLM 判定）                                                         |
| `abort_reason`    | str  | 中断理由 `silent_drop`/`timeout`/`j1_reject`/`safety_stop`/`mount_guard` 等。中断でなければ空 |
| `evidence_source` | str  | 判定根拠 `lift_image_vlm` 等                                                         |

`grasped` **は現状保留。** いまのセンシングは LIFT 後画像で pick 成否を見ており、GRIP 直後・LIFT 前の保持確認を持たない。よって「掴んだが LIFT までに落とした（grasped）」を `held`/`reached` から一意に切り分けられない。GRIP 直後の確認画像（または別センサー）が入るまで、`grasped` は使わず `reached`（位置は到達）＋ `held_after_lift=false` で表す。`grasped` を有効化する条件 ＝ GRIP 直後確認の導入、と明記しておく。

なお `reached` ＋ `held_after_lift=false` は、grasp failure（そもそも掴めなかった）と dropped before lift（掴んで落とした）を区別しない暫定表現である（現状センサーでは避けられない）。区別が要るようになったら GRIP 直後確認の導入で `grasped` を有効化する。

> これらの新列（`pick_stage` ＋根拠フラグ列）を運用中の v3 にその場で足すと追記ガードに弾かれる。入れるときは 8節の 8-B（旧 v3 退避 → 新ヘッダで空台帳作り直し）を踏む。

> 状態語彙は `ledger_schema_version` で版を持つ。本節の語彙を v1 とし、語彙を変えるときは版を上げる（過去行の語彙と混ざらないように）。列を増やせない場合でも、本ファイルに「どの版がいつから有効か」を必ず残す。

> ↑ 段階キー名・閾値・理由語彙はドラフト。実機運用の感覚に合わせてレビューで確定する。

---

### 4. pose_id と三系統（live / twin / real）の連携

三系統（live / twin / real）を、台帳は pose_id で結ぶ。

| 系統   | 役割                  | 実体                                                              | 台帳との関係                                                                      |
| ---- | ------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------- |
| live | 採用姿勢（approach）      | `dynamic_tracking_limits/logs/j0_sweep/j0_sweep_adopted/*.json` | approach で j0_lookup が読む。pose_id を持つ                                        |
| twin | FK 予測器／descend の参照元 | `fk_scan_motion_based_v2.csv`                                   | **descend(pick) で** `fk_lookup` **が runtime に読む**（DAY257 以降）。pose_id で後から照合 |
| real | 試行台帳                | `pick_place_ledger_v3.csv`（本ファイルの対象）                            | 実機が書く。pose_id を残す                                                           |

- 台帳は phase 別に `pick_pose_id` / `place_pose_id` を持つ（単一 `pose_id` 列ではない）
- j0_lookup 由来（approach / place_withblock）は採用 JSON の pose_id を使う
- `fk_lookup` 由来（descend）は、現行コードが返す FK テーブル行 ID `fk_row_{selected_idx}` を**暫定正**の pose_id とする。これで twin（FK テーブル）の該当行と real の結果を後から突き合わせられる
- ただし行 index 単独は永続 ID にしない。FK テーブルは再生成・マージ・ソートで同じ index が別姿勢を指しうる（過去に 512,087→710,704→852,729 と拡張）。テーブル版/ハッシュとセットにして初めて安定識別子とする。推奨形 `fk_pose_id = fk:{table_basename}:{table_hash}:{selected_idx}`。照合補強に 6 軸丸め値 `fk_joint_key = j0..j5` を併記すると強い

---

### 5. データラベリングの status（レイヤーB・既存規約）

姿勢素材・測定バッチの採用状態。試行結果（3節 `pick_stage`）とは別レイヤー。列名 `asset_status` 相当で分離する。

- 語彙は固定 4 語のみ: `adopted` / `superseded` / `aborted` / `test`
- この `aborted` はレイヤーB 専用。試行の中断はレイヤーA の `interrupted` を使う（同名衝突を避ける）
- ラベリング規約: `{phase}_{condition}_{n}pt_{status}_DAY{NNN}_{seq}`

---

### 6. 追記義務（変更禁止）

- 実機の通し完走（PICK→PLACE）を走らせるスクリプトは、本線・派生を問わず、完走／中断時に必ず台帳へ 1 行追記する
- 台帳追記処理を持たない通し完走スクリプトを作らない
- 派生スクリプトが独自の索引 CSV を別に持つ場合も、台帳への追記は省略しない（索引 CSV は補助・台帳が正）

---

### 7. 禁止事項

- `pick_place_ledger_v3.csv` が正本。Mac ローカルに working 台帳を作らない
- `pick_place_ledger_v2.csv` は immutable（変更禁止・温存）
- KIOXIA-only。未マウント時にローカルへ fallback して書かない（mount guard 必須）
- 既存行の上書き・移動・削除をしない（追記のみ）
- 横断索引を `run_pick_place/` ルートに置かない（台帳は `runs/pick_place_ledger_v3.csv` 1 本）

---

### 8. 列を増やすときの手順（v3 一本・リネームしない）

台帳はファイル名 `pick_place_ledger_v3.csv` の一本で通す。新バージョン番号を切ったりリネームしたりしない。運用中の v3 にその場で列を足すと追記ガードに弾かれるので、足す列の種類によって次の 8-A か 8-B のどちらかを踏む。どちらの場合も、列数は実体ヘッダを実測して確かめ、本ファイルに焼いた数字を当てにしない。

#### 8-A. 観測系の列（記録を足すだけで、既存列の意味と試行結果の語彙を変えない列）

撮影パス・観測値・記録メタのような列がこれにあたる（DAY265 の横カメラ 5 列、DAY287 の recorder 4 列が実施例）。

1. **旧 v3 を退避**: 現行の `pick_place_ledger_v3.csv` を `archive/` 配下にコピー退避し、md5 を確認する（移動でなくコピー・元を消さない）
2. **同じファイルの末尾に列を足す**: 現行ヘッダの末尾に新列を加え、過去のデータ行は末尾に空セルを付けて保持する（行数・順序は変えない）
3. **コードを合わせる**: `run_pick_place.py` の `LEDGER_COLS` を新ヘッダと一致させ、追記ガードが通ることを確認する
4. **本ファイルを更新**: 2節の列定義表に実測ヘッダから転記し、1節の列数と md5 を更新する

#### 8-B. 状態語系の列（3節の `pick_stage` 系など、試行結果の語彙を変える列）

1. **旧 v3 を退避**: 現行の `pick_place_ledger_v3.csv` を `archive/pick_place_ledger_premerge_{date}/` にコピー退避する（移動でなくコピー・元を消さない。MASTER 保管ルール準拠）。これで状態語導入前の過去試行は archive 側に残る
2. **新ヘッダの v3 を作り直す**: 同じパス `runs/pick_place_ledger_v3.csv` を、現行ヘッダ（列数は実体ヘッダを実測して確認する）の末尾に新列を加えたヘッダで**新規の空台帳**として作成する。過去行は引き継がず 0 行から開始する（過去行は 1 の archive 側が正）
3. **コードを合わせる**: `run_pick_place.py` の `LEDGER_COLS` を新ヘッダと一致させる。追記ガード（`existing_header != LEDGER_COLS`）が新ヘッダで通るようにする。ファイル名 `LEDGER_CSV_PATH` は変えない
4. **足す新列**: `pick_stage` / `reached_bool` / `grip_commanded` / `held_after_lift` / `abort_reason` / `evidence_source` / `ledger_schema_version`。レイヤーB の `asset_status` も列化するなら同じ手順で末尾に加える
5. **語彙の版**: `ledger_schema_version` に版を持たせ、archive 側（旧）と新台帳で語彙が混ざらないようにする。本ファイル 3節の語彙を v1 とする

> 退避 → 作り直しは過去ログを失う操作ではない（旧 v3 は archive に保全される）。新旧で行が混ざらないぶん、状態語の検証はやりやすい。実施はエイイチの確定後に実装へ渡す。

---

### 9. handover_ledger_v1.csv（受け渡しシリーズの台帳）

受け渡しシリーズ（mechArm 270 Pi 受け渡し動作の成立を探る）の通し台帳。動的追従シリーズの pick_place_ledger_v3.csv とは別ファイル・別シリーズ。シリーズとデータの対応: 動的追従 = `run_pick_place.py` + pick_place_ledger_v3.csv / 受け渡し = `run_pick_handover.py` + handover_ledger_v1.csv。

#### 9-1. 台帳の正体

- 実体: /Volumes/KIOXIA/r2r2r-data/runs/handover_ledger_v1.csv
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない
- 1 試行 = 1 行・追記のみ（既存行の上書き・移動・削除をしない）。横断索引であり、各試行の生成物は runs/run_handover/{run_stem}/ 配下に別途残る
- 列数: 44（初版・設計 r3.3 で確定。実装後に実体ヘッダを正規CSVパースで実測し、本節の列定義表を確定転記する。実測前の本表はドラフト）
- 追記ガードあり: existing_header != HANDOVER_LEDGER_COLS のとき [ABORT-APPEND] で追記中止
- v3（pick_place_ledger_v3.csv）への二重記帳はしない。受け渡し試行の追記義務は本台帳への追記で満たす（1試行1行・1種類1ルート）
- run_dir 列は持たない。試行ディレクトリは run_stem から導出する: runs/run_handover/{run_stem}/

#### 9-2. 追記義務と mount lost 時の扱い

- 受け渡しの通し試行スクリプトは、完走・中断を問わず本台帳へ 1 行追記する（通常時の義務）
- ただし SSD が動作中に失われた場合（mount lost）は同期的に保証できない。このとき記録より物理安全を優先し、試行データは /tmp/handover_emergency_journal/ へ atomic write（tmp 作成 → flush/fsync → os.replace）で退避する
- /tmp は正本の fallback ではなく emergency journal（復旧用の痕跡）。正規データをローカルへ書かない原則は維持する
- recovery: SSD 復旧後、run_stem をキーに台帳の未記帳を確認し、1 回だけ新規 append する。既存行の更新はしない
- マウント確認は各書き込み直前に行う。試行開始前に未マウントなら物理動作を開始せず abort。シーケンス途中の mount lost は新規シーケンスを開始せず abort（trial_status=mount_lost）。人が可動域に手を出した後の mount lost は、インターロックを維持したまま退避シーケンスを完了してから journal へ

#### 9-3. 状態語（v3 とは別レイヤー・handover 用）

- trial_status: completed / pick_failed / motion_failed / release_failed / interrupted / mount_lost / unexpected_error
- outcome（排他）: taken / drop / stuck / unknown / not_applicable
  - stuck = 開いたがブロックが爪に引っかかったまま
  - unknown = 対話記入が得られなかった（中断・入力不能）。unknown の増加は運用異常のシグナルとして扱う
  - not_applicable = PICK 失敗等で提示工程に入らなかった
- outcome_source: operator_visual / operator_with_vlm_reference / unknown（VLM 単独で outcome を確定しない。最終確定は人）
- v3 の pick_stage 系語彙（レイヤーA）とは別の語彙体系。同じ綴りを別レイヤーで使わない原則は本台帳にも適用する

#### 9-4. 列定義（初版 44 列・実装後に実測で確定転記）

| # | 列名 | 意味 |
|---|---|---|
| 1 | timestamp | 試行時刻（ISO8601） |
| 2 | run_stem | 試行識別子（recovery 時の照合キー・run_dir 導出元） |
| 3 | trial_no | 試行番号 |
| 4 | trial_status | 9-3 の状態語 |
| 5 | manual_recovery_required | 手動復帰が必要な状態で終わったか |
| 6 | abort_stage | 中断したシーケンス段階（完走なら空） |
| 7 | abort_reason | 中断理由（arrival_timeout / forbidden_gripper_close 等） |
| 8 | pick_verdict | PICK 成否（VLM・v3 と同語彙） |
| 9 | pick_reason | PICK 判定理由 |
| 10 | pick_confidence | PICK 確信度 |
| 11 | block_pick_x | PICK 時ブロック X |
| 12 | block_pick_z | PICK 時ブロック Z |
| 13 | align_j0_final | 視覚サーボ後 j0 |
| 14 | align_converged | 視覚サーボ収束 |
| 15 | pick_pose_id | PICK 姿勢照合キー（4節の pose_id 規約準拠） |
| 16 | corr_cell_key | PICK 補正セルキー |
| 17 | corr_applied_dx | X 補正量 |
| 18 | corr_applied_dz | Z 補正量 |
| 19 | handover_pose_id | 提示姿勢照合キー（handover_present_pose.json の pose_id） |
| 20 | object_profile_id | ブロック仕様の参照キー（実体は run の handover パラメータ JSON に内包） |
| 21 | wait_plan_id | N 計画の識別子 |
| 22 | wait_plan_index | 計画内の提示順 |
| 23 | shuffle_seed | シャッフル seed |
| 24 | wait_sec_requested | 要求 N |
| 25 | wait_sec_actual | 実測 N（monotonic） |
| 26 | wait_started_at | WAIT 開始時刻 |
| 27 | release_sent_at | RELEASE 送信時刻 |
| 28 | release_responded_at | RELEASE 応答時刻 |
| 29 | release_command_ok | 通信上、指令送信処理を完了したか（物理開放と分離） |
| 30 | release_observed | open / not_open / unknown（物理開放の観測） |
| 31 | release_observed_source | operator / top_camera / side_camera |
| 32 | present_reached_bool | PRESENT 到達判定（実測角ベース）通過 |
| 33 | retreat_completed_bool | RETRACT〜PRESENT_MID 到達判定通過 |
| 34 | home_reached_bool | HOME 到達判定通過 |
| 35 | hand_clear_confirmed | 退避確認入力（インターロック2）が入ったか |
| 36 | outcome | 9-3 の排他語 |
| 37 | outcome_source | 9-3 の判定根拠語 |
| 38 | vlm_taken_verdict | VLM 事後判定（参考値） |
| 39 | vlm_status | ok / http_400_no_credit / error |
| 40 | vlm_reason | VLM 判定理由 |
| 41 | top_capture_status | ok / fail:{reason} |
| 42 | side_capture_status | ok / fail:{reason} |
| 43 | top_capture_path | 上カメラ画像パス（fail 時は空） |
| 44 | side_capture_path | 横カメラ画像パス（fail 時は空） |

型・必須/任意の細目は実装（HANDOVER_LEDGER_COLS 周辺の書き込み処理）を正とする。最終実測角・インターロック入力時刻・入力者区分・object_profile 実体は run_dir 内 JSON に保存し、台帳には持たない。

#### 9-5. 禁止事項

- handover_ledger_v1.csv が正本。Mac ローカルに working 台帳を作らない
- pick_place_ledger_v3.csv への受け渡し試行の記帳をしない（二重記帳禁止）
- 既存行の上書き・移動・削除をしない（追記のみ。recovery も新規 append のみ）
- 未マウント時にローカルへ正規データを書かない（emergency journal は /tmp・atomic write・復旧用の痕跡に限る）
- 列を増やすときは 8節の 8-A / 8-B 判定に従う（本台帳にも同じ手順を適用する。読み替え: `pick_place_ledger_v3.csv` → `handover_ledger_v1.csv`、`run_pick_place.py` の `LEDGER_COLS` → `run_pick_handover.py` の `HANDOVER_LEDGER_COLS`）

---


### 10. unity_ledger_v1.csv（サービスロボット導入シミュレーション・Unity の段階B台帳）

Unity で配膳ロボットの店を回した結果を、1回1行で残す台帳。サイトの試算と比べる段階C の元になる。実機の台帳（v3・handover）とは別シリーズ・別ファイル。

#### 10-1. 台帳の正体

- 実体: `/Volumes/KIOXIA/r2r2r-data/service_sim/unity/unity_ledger_v1.csv`（MASTER の保存先「サービスロボット導入シミュレーションの Unity の出力（往復・止まり・条件ごとの数字）→ service_sim/unity/」に含まれる。新しい置き場所は作らない）
- 単一正本（KIOXIA-only）。Mac ローカルに working 台帳を置かない。未マウントなら回し始めない
- 1回 = 1行・追記のみ（既存行の上書き・移動・削除をしない）。各回の生成物は `service_sim/unity/{出力フォルダ}/` に別に残る（summary.json・trips.csv・stops.csv・dishes.csv・yields.csv など）
- 追記ガードあり: 実体ヘッダとコードの列の並びが違えば追記しない
- 途中で止めた回・失敗した回も1行残す（status で区別）。同じ出力フォルダの行を2度書かない（出力フォルダ名が照合キー）

#### 10-2. 列（初版・実装後に実体ヘッダを実測して確定転記する）

- 回の識別: `timestamp` / `run_folder` / `status`（completed / stopped / failed）/ `unity_cs`（Unity Version Control の changeset）/ `layout_generation` / `params_model`（試算の版。v2.7 など）
- 条件（サイトの unity_conditions と同じ）: `store` / `staff` / `robots` / `arrivals_per_h` / `mode` / `trays` / `fragile_by` / `seed`
- Unity だけの決まりの設定: `staff_first_yield` / `yield_order_enabled` / `one_robot_per_aisle` / `robot_first_wait_s`
- 回り切ったか: `everyone_left` / `total_minutes` / `wall_seconds`
- サイトと同じ定義の数字: `entry_wait_mean` / `staff_wait_mean` / `dish_wait_mean` / `cold_share` / `robot_share` / `staff_util` / `robot_util` / `walk_km_per_staff`
- 同じ条件のサイトの試算の数字（params の site_result から写す）: `site_entry_wait_mean` / `site_staff_wait_mean` / `site_dish_wait_mean` / `site_robot_share`
- Unity が測ってサイトに戻す4つの値: `robot_speed_mps` / `pause_rate` / `pause_s_mean` / `stuck_rate`
- ロボットの往復と止まり: `trips` / `timeouts` / `stuck_count` / `stop_s_total` / `corner_stop_time_share`
- 逃がし弁と助け: `gave_up` / `warped` / `table_freed` / `rescues`
- 料理と譲り: `late_staff_dishes` / `staff_yield_share_of_staff_time`
- `ledger_schema_version`（初版 = 1）

列の意味と計算のしかたは、出力の summary.json と同じ（台帳はそこから写す）。列を増やすときは 8節の 8-A / 8-B に従う（読み替え: `pick_place_ledger_v3.csv` → `unity_ledger_v1.csv`、`LEDGER_COLS` → Unity の台帳の列の定義）。

#### 10-3. 新しいプロジェクトと、夜に何本も同時に回すとき（2026-10-25）

- ServiceSim を新しい Unity プロジェクト（`/Users/shimazueiichi/Robotics_UX/ServiceSim/`）に分けたあとの行は、`unity_cs` を `ServiceSim/cs:N` の形で書く（changeset の番号が1から振り直されるため。今までの `cs:144` などと混ざらないように）
- 何本も同時に回すときは、鍵ファイル `service_sim/unity/unity_ledger_v1.lock` を排他で開けてから1行書く。開けなければ待って試し直す。追記ガードはそのまま
- 夜の実行係が止めた回（時間切れ）・落ちた回は、実行係が status = stopped / failed の行を書く（鍵をかけて）。やり直すときは出力フォルダの名前の後ろに `_try2` を付け、同じ出力フォルダの行を2度書かない決まりを守る
- 何本同時か・どの実行ファイルか・どの夜かは各回の run_info.json に書き、台帳の列は増やさない
- 夜の結果の振り分け（judge_night.py）は台帳を読むだけで書かない。結果は `service_sim/unity/night/{日付}/` の judge.md・judge.csv
- 最初の夜の前に、台帳を `archive/unity_ledger_v1_prenight_{日付}/` にコピー退避する

---

### 11. 番号の種類と書き方（2026-11-01）

記録とデータには、意味の違う番号が何種類もある。見た目が同じ番号を別の意味で使うと、記事とデータを取り違える（例：`lab/Log07` は研究室の7回目、`vision/captured/Log07` は画像認識の7回目で、別物）。番号は付け替えず、種類ごとの意味と書き方を決める。

#### 11-1. 番号の種類

| 種類 | 形 | 意味 |
|---|---|---|
| 記事番号 | `DAY{NNN}` → `LOG{NNN}` | 記事の通し番号。初期は DAY、記事を毎日出さなくなってから LOG に名前を変えた。DAY と LOG は同じ通し番号（LOG は DAY の続き） |
| シリーズ内の番号 | `Log{NN}` | そのシリーズの何回目か。シリーズ名と組でなければ意味を持たない |
| 試行の番号 | `run_stem` の全体（例 `run_0001_20261019_191257`） | 1回の試行。`run_0001` だけでは別の日の試行と重なるので、日付まで含めた全体で1回を指す |
| 学習・推論の回 | `pi05_{YYYYMMDD}_{HHMMSS}` など | 1回の学習・推論の実行 |
| データの版 | `_v1`・`_v2`・`_trim2`・`_corr_v3` など | そのデータセット・モデルの版。記事番号ではない |
| 学習の段数 | checkpoint の step（`1499`・`3999` など） | 学習の何段目のモデルか。記事番号ではない |

記事番号とシリーズ内の番号の正は、公開記事（pixelstomotors.com）とする（MASTER「シリーズの正は公開記事」に従う）。

#### 11-2. 書き方の決まり

- 既存のフォルダ名・ファイル名・台帳の値は付け替えない（記録の改変を始めないため）
- 文章・README・これから出力する meta には、番号を種類つきで書く。例：`記事LOG352`、`研究室Log09`、`画像認識Log07`、`run_0001_20261019_191257`
- これから出力する meta（各回の JSON・`recording_meta.json` など）には、シリーズ名とシリーズ内の番号を組で入れる。分かっていれば記事番号も入れる。記事が後から決まる場合は空のままでよい（対応は11-3で足す）
- 番号どうしの対応（記事番号 ↔ シリーズ内の番号 ↔ 試行・学習の回・データ・checkpoint）は、番号に詰め込まず、索引の関係表に置く（置き場所は MASTER の保存先テーブルに従う）

#### 11-3. 関係表への載せ方

- 載せる根拠は、記事本文・README・meta に書かれた明示の参照を優先する。名前や日付が近いだけのものは「候補」とし、確定と分ける
- 1つの記事が複数のデータを使い、1つのデータが複数の記事で使われることがある（多対多）。関係には種類（作った・使った・比べた など）を付ける
- 仮の番号（例：README に「番号は仮」と書かれたフォルダ）は「仮」として載せ、確定したら確定の行を1行足す（既存の行は書き換えない）

---

### 12. 出力に付ける由来（provenance）（2026-11-01）

データ・モデルが「どのコード・どの入力・どの設定で、どう終わったか」を、作った時点で機械が読める形で残す。主指標の「再現可能」の条件2（依存関係とバージョンが記載されている）を、手書きの README に頼らず満たすため。

#### 12-1. 対象

- **これから作る出力だけ**。既存のデータ・JSON・checkpoint には書き足さない（過去の由来は索引の関係表と README でたどる）
- 段階的に入れる：①学習と checkpoint → ②データセットを作るスクリプト → ③実機の試行（`run_pick_place.py`・recorder など）。③は関節の制御に触れないが、実機の流れの中なので codex のレビューと dry-run を通してから使う

#### 12-2. 置き方

- 既存の出力 JSON（各回の JSON・`recording_meta.json`・データセットの `index.json`・lab の記録など）に、キー `provenance` を1つ足して中に入れる。由来のためだけの JSON を別に増やさない
- 例外：checkpoint は中に書ける JSON がない（`_CHECKPOINT_METADATA` は openpi の形式）。新しく置く checkpoint の各ステップのフォルダ（`checkpoints/<モデル名>/<ステップ>/`）に `train_provenance.json` を1つ置き、同じものをその回の `lab/Log{NN}/` にも写す。Hugging Face に上げるときも同じファイルを入れる
- **台帳の列は増やさない**（8-A の手順を踏まずに済ませるため）。台帳から各回の JSON へは、今までどおり `run_stem` でたどる

#### 12-3. 項目（`schema_version` = `provenance/1`）

| 項目 | 中身 |
|---|---|
| `schema_version` | `provenance/1`。項目を変えるときは版を上げる |
| `created_at` | 作った時刻（ISO8601・タイムゾーンつき） |
| `producer` | スクリプトのパス（r2r2r-scripts からの相対）・実行したコマンドの引数（秘密の値は除く）・実行した機械（Mac / Pod など） |
| `code` | `repo`（r2r2r-scripts）・`commit`・`dirty`（未コミットの変更があるか）・`dirty_files`（変更のあるファイル名）・`diff_sha256`（変更の中身の指紋） |
| `external_code` | 使った外部のコードごとに `name`・`commit`・`dirty`（例 openpi） |
| `inputs` | 入力ごとに `kind`（dataset / checkpoint / run / recording / config）・`id`（フォルダ名やモデル名）・`path`（r2r2r-data からの相対）・`fingerprint` |
| `outputs` | できたものごとに、`inputs` と同じ形（例：学習でできた checkpoint のステップのフォルダ） |
| `config` | 設定の名前と主な値（学習なら設定名・ステップ数・初期の重み・学習率など） |
| `compute` | 学習・推論でクラウドを借りたとき：借り先・GPU の型番・始めと終わりの時刻・時間・費用（MASTER「買う前に借りて測る」の記録） |
| `numbering` | `series`・`series_log`・`article_no`（11節。分からなければ空）。`series_log` は `Log18` のように `Log` を付けた形で書く（数字だけにしない） |
| `status` | `process`（completed / interrupted / failed：処理が最後まで動いたか）と `task`（success / failure / unknown / not_applicable：タスクが成功したか）を分けて書く |

#### 12-4. 指紋（fingerprint）の作り方

- `files-v2:` ＋ sha256。中身は、フォルダ内のファイルを相対パスの順に並べた「相対パス・バイト数」の一覧と、1MB 未満の JSON（`index.json` など）の中身
- 大きなファイル（画像・動画・重み）は中身を読まない。数GBのものを毎回読むと出力が遅くなるため
- 数えないもの：`._` で始まるファイル（exFAT の印）・`.DS_Store`（Finder で開くだけで作られる）・`.cache`・一時フォルダ（`.incoming_*`）・由来のファイルそのもの（`train_provenance.json`・`*_provenance.json`）。由来のファイルを置いたあとに測り直しても、同じ指紋になるようにするため
- 2026-11-01 までの実装は `files-v1`（`.DS_Store` と由来のファイルを数えていた）。`files-v1` で書かれた記録は書き換えず、そのまま残す。比べるときは、同じ版の指紋どうしで比べる

#### 12-5. 守ること

- 未コミットの変更があっても止めない。`dirty: true` と変更のファイル名・指紋を残して進める（研究を止めないため）
- 鍵・トークン・パスワードは書かない。コマンドの引数に秘密が入る場合は値を伏せる
- 由来を書く処理が失敗しても、本来の出力（学習・データ・試行）は止めない。そのときは `provenance` に `error` を書く
- 由来を読む側は、読めない記録（書いている途中で強制終了された・JSON が壊れている）を「由来なし（読めない）」として扱い、警告を出して処理は止めない。読めない記録で、そのデータやモデルを「由来あり」と数えない
- 書き方は共通の部品（r2r2r-scripts の `provenance.py`）にまとめ、各スクリプトはそれを呼ぶ

### 更新履歴

- 2026-11-01（12節を実装に合わせて直した）: 依頼54（学習と checkpoint に由来を記録する・r2r2r-scripts のコミット 1902f37）の報告で Claude Code が挙げた5点を、エイイチの確定で決めた。できたものを書く `outputs` を足した。指紋を `files-v2` にし、`.DS_Store` と由来のファイルそのものを数えないことにした（Finder で開くだけで指紋が変わる・由来を置いたあとに測り直すと合わないため。`files-v1` の記録は書き換えない）。`series_log` は `Log18` の形。読めない由来は「由来なし（読めない）」として警告し、止めない。
- 2026-11-01（12節を新設）: 出力に付ける由来（provenance）の項目と置き方を決めた。Codex の検証で、checkpoint 5件の学習の記録欄（`_CHECKPOINT_METADATA` の custom_metadata）がすべて空で、既存の meta 254件にコードの版を持つものが0件だったため。既存の JSON にキー `provenance` を足す形とし、台帳の列は増やさない。checkpoint だけは `train_provenance.json` を置く。これからの出力だけが対象で、①学習と checkpoint から入れる。

- 2026-11-01（11節を新設）: 番号の種類と書き方を決めた。Codex の検証（iCloud `_index_review/codex_report_20261101.md`）で、意味の違う番号が少なくとも7種類あり、シリーズ名のない `Log{NN}` が別シリーズのデータと取り違えられる実例（研究室 Log07 と画像認識 Log07）が見つかったため。DAY と LOG は同じ記事の通し番号であることをエイイチが確認。既存の名前は付け替えず、これからの meta に種類つきで書き、番号どうしの対応は索引の関係表に置く。台帳の列は変えない。

- 2026-10-25（10-3 を新設）: サービスロボット導入シミュレーションを夜も回すため、新しい Unity プロジェクトでの `unity_cs` の書き方（`ServiceSim/cs:N`）、何本も同時に書くときの鍵ファイル、実行係が書く stopped / failed の行とやり直しの名前（`_try2`）、判定は台帳を書かないこと、最初の夜の前の退避を決めた。列は変えない。設計は iCloud の サービスロボット導入シミュレーション/unity/night_runs_design_v0.md。

- 2026-10-23（unity_ledger_v1 節を新設）: サービスロボット導入シミュレーションの段階B（Unity で全条件を回して残す）のため、台帳 unity_ledger_v1.csv を10節として新設。置き場所は MASTER の既存の行（service_sim/unity/）の中なので MASTER は変えない。1回1行・追記のみ・追記ガード・KIOXIA-only・止めた回と失敗した回も status 付きで残す。列は初版（実装後に実体ヘッダを実測して確定転記する）。
- 2026-10-11（DAY326・ドキュメント整合）: 9-5 が参照していた「8-A / 8-B」が8節に定義されていなかったため、8節に 8-A（観測系の列・同じファイルの末尾に追加・過去行は空セル付与で保持。DAY265・DAY287 の実施例を明文化）と 8-B（状態語系の列・archive 退避→新規空台帳。従来の8節手順）を定義した。8節の手順に焼き付いていた「67 列の後ろに」を「現行ヘッダ（列数は実測して確認）の末尾に」に変更（実体は82列。列数を本文に焼かない）。1節・2節・3節の8節参照を 8-A / 8-B に合わせた。9節の列定義表に行ごとの空行が入っていて表として描画されない状態だったのを修正し、9節の小見出しを H4 に揃えた。9-5 に handover 台帳での読み替えを追記。列定義・列数（v3 は 82 列）・状態語は変更なし。
- 2026-07-16（DAY288・handover_ledger_v1 節を新設）: 受け渡しシリーズの台帳 handover_ledger_v1.csv（runs/ 直下・初版44列・追記ガード・KIOXIA-only）を9節として新設。v3 とはシリーズ・ファイルを分離し二重記帳を禁止。mount lost 時の emergency journal（/tmp・atomic write）と recovery（run_stem キー・1回だけ新規 append）を規定。状態語（trial_status / outcome / outcome_source）は v3 レイヤーA とは別語彙として定義。列定義は設計 r3.3 由来のドラフトであり、実装後に実体ヘッダの正規CSVパース実測で確定転記する（焼き付き乖離の予防）。
- 2026-07-15 (DAY287 R2・codex レビュー反映): recording_status 語彙に `mount_lost` を追加 (試行中に KIOXIA マウントが外れた場合の識別語)。列定義 (2節表) は不変・列数 82 不変。コード側の変更: recorder は tmp+os.replace で recording_meta.json を原子的置換、frame_request は frame_age 0.5s 上限で古いフレームを stale_frame として拒否、frames_captured/frames_written を分離。本線側の変更: `_append_ledger` は recording_meta.json を最大 2.0 秒・100ms 間隔でポーリング (後からの reconcile はしない・追記のみ原則 7節を守る)。j0_sweep_scan `_send_and_wait_arrival` に observer=None 追加 (未指定時は現行完全同一・commanded の ts_send/ts_arrival/attempt/arrived を送信 1 回ごとに通知)。
- 2026-07-15 (DAY287・recorder 導入で 4 列追加 + DAY274/DAY283 chunk の drift 解消): recorder（横カメラ連続録画 + commanded 状態記録）を別プロセスで導入するにあたり、台帳に `recording_path`(79) / `recording_fps`(80) / `recording_frames`(81) / `recording_status`(82) の 4 列を 8-A 手順（過去 647 データ行は末尾に空セル 4 つ付与・順序不変）で追加。列数 78 → 82。並行して 2節表に未転記だった DAY274 の 68〜78 列（`box_shift_*` / `box_hit_*` / `place_height_*` / `place_corr_reason` / `box_shift_reason` / `place_img_path`）も同時に backfill。Step 0 調査: `git log methodology/data_manifest_rules.md` は DAY258 の 2 commit しかなく、DAY283 想定の 68〜78 転記済み版はコミット・working tree いずれにも存在せず、**改訂が未執筆だった**（「認識のみ存在してディスク未反映」の 3 回目 drift を回避）。archive: `archive/pick_place_ledger_prerestore_20260715/`。追記ガードは新 LEDGER_COLS 82 列と新実体ヘッダ 82 列が一致するため通る。既存 pick 系・姿勢制御・完走 HOME 復帰・PLACE 補正記憶ロジックは無変更。recorder 本体はアーム/daemon に一切触らない読み取り観測者（設計原則: recorder が死んでも本線は完走）。
- 2026-07-05 (DAY273): 列数表記を67に統一。実体ヘッダ実測（head -1 | awk NF = 67）で確認。旧記述の57は残存していたもの（経緯不明。DAY258 時点の記録には57とあるが、当時の誤記か以後の列追加かは未追跡。現行実測67が正）。「列数: 67」の既存記述は正しかったため変更なし。
- 2026-07-03（DAY265 後段・PLACE 補正記憶 5 列追加で 67 列化）: PLACE 側の補正記憶 apply/update は DAY262 で既に実装済（`_apply_correction("place", box_xz)` を stage8 で呼び、VLM yes 時に `_corr_update` で EMA 平均・phase プレフィックス `place_` でキー分離・`MAX_CORRECTION_MM=40mm` クランプ）だったが、台帳 CSV に反映される列が無く不可視だった。PICK の corr_cell_key/corr_applied_dx/dz/target_before/target_after (51-55) と同構造の 5 列を末尾に追加：`place_corr_cell_key` (63) / `place_corr_applied_dx` (64) / `place_corr_applied_dz` (65) / `place_target_before` (66) / `place_target_after` (67)。`_append_ledger` は `log_record.correction.place_last` を読み込むだけの追加で、apply/update ロジック・PICK 補正・関節制御に無影響。archive: `archive/pick_place_ledger_premerge_20260703_step2/`。
- 2026-07-03（DAY265・横カメラ観測 5 列追加で 62 列化）: Iriun (index=1) 撮影を stage7/stage8 に追加し、stage8 の横視点画像に対して vlm_judge kind="place_side" で PLACE 状態を判定する経路を導入。新列 `side_ap_place_capture_path` (58) / `side_place_capture_path` (59) / `side_place_verdict` (60) / `side_place_confidence` (61) / `side_place_reason` (62) を 57 列の末尾に追加。8節手順の archive→新規空台帳ルートは、状態語 (3節) の pick_stage 系列で構造化する場合に限定する規定と解釈し、今回の観測系 5 列は「ファイル名 v3 のまま in-file 拡張・過去 279 行は新列を空欄で保持・archive/pick_place_ledger_premerge_20260703/ に退避コピーを保全」で実施。追記ガードは `LEDGER_COLS` の 62 列と実体ヘッダ 62 列が一致するため通る。既存 pick 系・姿勢制御・完走 HOME 復帰は無変更。
- 2026-06-27（DAY258・v3 実体ヘッダ確定）: Mac で `pick_place_ledger_v3.csv` の実体ヘッダ（57列）と `run_pick_place.py` の `LEDGER_COLS` を突合し一致を確認。2節を確定版（57列の列名・順序・意味）に更新。`pick_pose_id`(37)/`place_pose_id`(44) の実在、状態語の新列（`pick_stage` 等）が57列に無いことを実測で確認し、追記ガード（L758–783 `[ABORT-APPEND]`）の存在も確認 ── 運用中の v3 にその場で列を足せないことの裏取りが取れた（列追加は 8節の退避→作り直し手順による）。列数表記を未確定→57に確定。
- 2026-06-27（DAY258・codex 再レビュー反映＋v3 一本方針で確定）: 状態語の新列が現行 57 列に無くヘッダ不一致を誘発する問題（N1）に対し、8節を新設。codex は別ファイル v4 への分離を推したが、台帳はファイル名 `pick_place_ledger_v3.csv` 一本で通す（リネーム・新バージョン番号を切らない）方針をエイイチが確定。列を増やすときは旧 v3 を archive にコピー退避 → 新ヘッダの v3 を新規の空台帳として作り直し → `LEDGER_COLS` を合わせる手順とした（過去行は archive 側が正・新台帳は 0 行から）。`fk_row_{selected_idx}` を行 index 単独で永続 ID にせず、テーブル版/ハッシュとセット（`fk:{table_basename}:{table_hash}:{selected_idx}`）にする規定を追加（N2）。j0_lookup 由来を approach / place_withblock に拡張（R3）。`reached`＋`held_after_lift=false` が grasp failure と dropped before lift を区別しない暫定表現である旨を明記（R5）。
- 2026-06-27（DAY258・codex レビュー反映）: twin を「runtime で読まない」→「descend で `fk_lookup` が runtime に読む（DAY257 以降）」へ訂正。列数 50 を撤回し未確定（現行 `LEDGER_COLS`=57・実体ヘッダ突合で確定）に。単一 `pose_id` を `pick_pose_id` / `place_pose_id` ＋ `fk_lookup` 由来は `fk_row_{selected_idx}` 暫定正へ。レイヤーA を `pick_stage` ＋根拠フラグ列（`reached_bool` / `grip_commanded` / `held_after_lift` / `abort_reason` / `evidence_source`）に再設計。レイヤーA の中断語を `aborted`→`interrupted` に改名（レイヤーB との同名衝突回避）。`grasped` を現状センサーで判定不可として保留（GRIP 直後確認の導入を有効化条件に）。`ledger_schema_version` で語彙を版管理（本節=v1）。MASTER との優先順位（場所=MASTER／中身=本ファイル）と運用前提条件（ヘッダ突合 P0）を明記
- 2026-06-27（DAY258）: 新規作成（ドラフト）。台帳の列・状態語・pose_id 連携・追記義務を一本化。列定義は Mac 実測ヘッダで確定する箇所として保留。試行結果の状態語（レイヤーA: not_reached / reached / grasped / held / aborted）を pick 空欄問題への対応として新設。データラベリング status（レイヤーB・4 語）と別レイヤーである旨を明記

exec
/bin/zsh -lc "sed -n '95,210p' provenance.py; sed -n '1,300p' test_provenance.py" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:

def _check_version(version):
    if version not in FP_VERSIONS:
        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")


def list_files(path, version=FP_VERSION):
    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)

    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
    _check_version(version)
    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
    path = os.path.abspath(path)
    if not os.path.lexists(path):
        raise FileNotFoundError(path)
    if not os.path.isdir(path) or os.path.islink(path):
        return [(_nfc(os.path.basename(path)), os.lstat(path).st_size, path)]
    out = []

    def err(e):
        raise e

    for d, dirs, files in os.walk(path, onerror=err, followlinks=False):
        dirs[:] = [x for x in dirs if not _skip(x)]
        # リンクのフォルダは os.walk が dirs に入れるがたどらない. ファイルとして数える
        for x in [x for x in dirs if os.path.islink(os.path.join(d, x))]:
            dirs.remove(x)
            files.append(x)
        rel = os.path.relpath(d, path)
        for f in files:
            if _skip(f) or skip_file(f):
                continue
            full = os.path.join(d, f)
            p = f if rel == "." else os.path.join(rel, f)
            out.append((_nfc(p.replace(os.sep, "/")), os.lstat(full).st_size, full))
    out.sort(key=lambda x: x[0].encode())
    for a, b in zip(out, out[1:]):
        if a[0] == b[0]:        # NFC にそろえると同じ名前になる別のファイル (並びが決まらない) は指紋にしない
            raise ValueError(f"NFC で同じ名前になるファイルが2つある: {a[0]}")
    return out


def fingerprint(path, version=FP_VERSION):
    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
    files = list_files(path, version)
    h = hashlib.sha256()
    for p, n, _ in files:
        h.update(f"{p}\t{n}\n".encode())
    for p, n, full in files:
        if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
            with open(full, "rb") as fh:
                h.update(b"\0" + p.encode() + b"\0" + fh.read())
    return version + ":" + h.hexdigest()


def fingerprint_version(fp):
    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
    if isinstance(fp, str):
        v, sep, d = fp.partition(":")
        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
            return v
    return None


# ---- コード ----
def _git(args, cwd):
    r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, timeout=60)
    if r.returncode != 0:
        raise RuntimeError(f"git {' '.join(args[:2])}: {r.stderr.decode(errors='replace').strip()[:200]}")
    return r.stdout


def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
    """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
            "diff_sha256": None, "measured_at": now_iso()}
    if paths:
        info["paths"] = list(paths)
    try:
        info["commit"] = _git(["rev-parse", "HEAD"], repo_dir).decode().strip()
        sel = ["--", *paths] if paths else []
        st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
        entries = [e for e in st.decode(errors="replace").split("\0") if e]
        files, untracked, backups, i = [], [], [], 0
        while i < len(entries):
            e = entries[i]
            code, f = e[:2], e[3:]
            if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                i += 1
            if code == "??" and is_untracked_backup(f):
                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
            else:
                files.append(f)
                if code == "??":
                    untracked.append(f)
            i += 1
        files = sorted(set(files))
        info["dirty"] = bool(files)
        info["untracked_backups"] = sorted(set(backups))
        info["dirty_files"] = files[:DIRTY_FILES_MAX]
        if len(files) > DIRTY_FILES_MAX:
            info["dirty_files_total"] = len(files)
        h = hashlib.sha256(_git(["diff", "HEAD", "--binary", *sel], repo_dir))
        for f in sorted(untracked):
            full = os.path.join(repo_dir, f)
            h.update(b"\0untracked\0" + f.encode() + b"\0")
            if os.path.isfile(full) and not os.path.islink(full):
                with open(full, "rb") as fh:
                    h.update(hashlib.sha256(fh.read()).digest())
        info["diff_sha256"] = h.hexdigest() if files else None
    except Exception as e:      # git がない・リポジトリでないなど. 分からないことを残す
        info["error"] = f"{type(e).__name__}: {e}"
    return info


def is_untracked_backup(path):
#!/usr/bin/env python3
"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.

  python3 test_provenance.py -v

一時フォルダと一時の git リポジトリだけを使う (KIOXIA・本物のリポジトリには書かない).
"""
import hashlib
import io
import json
import os
import subprocess
import sys
import tempfile
import unicodedata
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import provenance as pv  # noqa: E402


def put(root, rel, data=b"x"):
    p = Path(root) / rel
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(data if isinstance(data, bytes) else data.encode())
    return p


def git(cwd, *args):
    subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)


class Fingerprint(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.d = Path(self.tmp.name) / "ds"
        put(self.d, "index.json", '{"n": 1}')
        put(self.d, "raw/ep1/frame.jpg", b"\xff" * 50)
        put(self.d, "raw/ep1/meta.json", '{"a": 1}')

    def tearDown(self):
        self.tmp.cleanup()

    def test_format_and_same_twice(self):
        a = pv.fingerprint(self.d)
        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
        self.assertEqual(a, pv.fingerprint(self.d))

    def test_definition(self):
        # 決めた作り方そのもの (相対パス\tバイト数 の一覧 → 1MB 未満の .json の中身)
        h = hashlib.sha256()
        for p, n in [("index.json", 8), ("raw/ep1/frame.jpg", 50), ("raw/ep1/meta.json", 8)]:
            h.update(f"{p}\t{n}\n".encode())
        h.update(b"\0index.json\0" + b'{"n": 1}')
        h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身

    def test_v2_skips_ds_store_and_provenance_files(self):
        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
        a = pv.fingerprint(self.d)
        v1 = pv.fingerprint(self.d, "files-v1")
        put(self.d, ".DS_Store", b"\0" * 6148)
        put(self.d, "raw/.DS_Store", b"\0" * 10)
        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
        self.assertEqual(a, pv.fingerprint(self.d))
        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
        put(self.d, "raw/DS_Store", "x")
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_v2_same_after_writing_provenance_into_output(self):
        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
        pv.write(rec, self.d / "train_provenance.json")
        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")

    def test_version_helpers(self):
        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
            self.assertIsNone(pv.fingerprint_version(bad))
        with self.assertRaises(ValueError):
            pv.fingerprint(self.d, "files-v3")

    def test_skips_agree_with_checkpoint_compares(self):
        # hf_upload_ckpt.py (Hugging Face 側の比べ) と lab_list_files (KIOXIA 側の比べ) が数えない由来・.DS_Store は, 指紋でも数えない
        sys.path.insert(0, str(Path(__file__).resolve().parent / "pi05" / "openpi_addon"))
        import hf_upload_ckpt as hu
        for name in hu.SKIP_REMOTE:
            if name.endswith(".json"):
                self.assertTrue(pv.is_provenance_name(name), name)
        lib = (Path(__file__).resolve().parent / "lab_lib.sh").read_text()
        self.assertIn('f in (".DS_Store", ".gitattributes")', lib)
        self.assertTrue(pv._skip_file_v2(".DS_Store"))

    def test_skips_dotunderscore_cache_incoming(self):
        a = pv.fingerprint(self.d)
        put(self.d, "._index.json", "junk")
        put(self.d, "raw/._ep1", "junk")
        put(self.d, ".cache/huggingface/x.json", "{}")
        put(self.d, ".incoming_1499_x/params/a", "zz")
        self.assertEqual(a, pv.fingerprint(self.d))

    def test_image_content_not_read_but_size_counts(self):
        a = pv.fingerprint(self.d)
        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 50)      # 同じ大きさで中身だけ違う
        self.assertEqual(a, pv.fingerprint(self.d))
        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 51)
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_json_content_counts(self):
        a = pv.fingerprint(self.d)
        put(self.d, "raw/ep1/meta.json", '{"a": 2}')         # 同じ大きさで中身が違う
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_big_json_content_not_read(self):
        put(self.d, "big.json", b"a" * pv.JSON_MAX)
        a = pv.fingerprint(self.d)
        put(self.d, "big.json", b"b" * pv.JSON_MAX)
        self.assertEqual(a, pv.fingerprint(self.d))

    def test_new_file_counts(self):
        a = pv.fingerprint(self.d)
        put(self.d, "raw/ep2/frame.jpg", b"")
        self.assertNotEqual(a, pv.fingerprint(self.d))

    def test_nfd_names_same_as_nfc(self):
        e = Path(self.tmp.name)
        put(e / "a", unicodedata.normalize("NFC", "データ.txt"), "1")
        put(e / "b", unicodedata.normalize("NFD", "データ.txt"), "1")
        self.assertEqual(pv.fingerprint(e / "a"), pv.fingerprint(e / "b"))

    def test_nfc_collision_raises(self):
        e = Path(self.tmp.name) / "c"
        put(e, unicodedata.normalize("NFC", "が.txt"), "1")
        try:
            put(e, unicodedata.normalize("NFD", "が.txt"), "2")
        except OSError:
            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
        if len(os.listdir(e)) < 2:
            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
        with self.assertRaises(ValueError):
            pv.fingerprint(e)

    def test_symlink_not_followed(self):
        e = Path(self.tmp.name) / "l"
        put(e, "real/a.json", '{"x": 1}')
        os.symlink(self.d, e / "link")
        names = [p for p, _, _ in pv.list_files(e)]
        self.assertEqual(names, ["link", "real/a.json"])

    def test_single_file_and_missing(self):
        f = put(self.tmp.name, "one.json", "{}")
        h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
        with self.assertRaises(FileNotFoundError):
            pv.fingerprint(Path(self.tmp.name) / "nothing")

    def test_unreadable_dir_raises(self):
        locked = self.d / "locked"
        locked.mkdir()
        locked.chmod(0)
        try:
            with self.assertRaises(OSError):
                pv.fingerprint(self.d)
        finally:
            locked.chmod(0o755)


class Redact(unittest.TestCase):
    def test_argv(self):
        argv = ["--log", "Log17", "--hf-token", "abc123", "--api_key=xyz", "--key", "k", "--steps", "1500",
                "hf_ABCDEFGHIJKLMNOP", "--author", "me"]
        self.assertEqual(pv.redact_argv(argv), ["--log", "Log17", "--hf-token", "<redacted>", "--api_key=<redacted>",
                                                "--key", "<redacted>", "--steps", "1500", "<redacted>", "--author", "me"])

    def test_secret_in_dict_key(self):
        rec = pv.build({"notes": {"hf_ABCDEFGHIJKLMNOPQ": 1}}, repo_dir=tempfile.gettempdir())
        self.assertNotIn("hf_ABCDEFGHIJKLMNOPQ", json.dumps(rec))

    def test_secret_file_values_removed_everywhere(self):
        with tempfile.TemporaryDirectory() as t:
            kf = put(t, "runpod.txt", "rpa_SECRETSECRETSECRET\n")
            rec = pv.build({"producer": {"script": "x.py", "argv": ["plain"]},
                            "notes": {"log": "header Bearer rpa_SECRETSECRETSECRET end", "other": "MYSECRET99"},
                            "status": {"process": "completed", "task": "not_applicable"}},
                           repo_dir=t, secrets=pv.read_secrets([kf, put(t, "k2", "MYSECRET99")]))
            s = json.dumps(rec)
            self.assertNotIn("SECRETSECRET", s)
            self.assertNotIn("MYSECRET99", s)


class Code(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.r = Path(self.tmp.name)
        git(self.r, "init", "-q")
        git(self.r, "config", "user.email", "t@example.com")
        git(self.r, "config", "user.name", "t")
        put(self.r, "a.sh", "echo a\n")
        put(self.r, "sub/b.py", "print(1)\n")
        put(self.r, "other.txt", "o\n")
        git(self.r, "add", "a.sh", "sub/b.py", "other.txt")
        git(self.r, "commit", "-qm", "init")

    def tearDown(self):
        self.tmp.cleanup()

    def test_clean(self):
        c = pv.code_info(self.r)
        self.assertRegex(c["commit"], r"^[0-9a-f]{40}$")
        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))

    def test_dirty_modified_and_untracked(self):
        put(self.r, "a.sh", "echo A\n")
        put(self.r, "sub/new.py", "x\n")
        c = pv.code_info(self.r)
        self.assertTrue(c["dirty"])
        self.assertEqual(c["dirty_files"], ["a.sh", "sub/new.py"])
        self.assertRegex(c["diff_sha256"], r"^[0-9a-f]{64}$")
        d1 = c["diff_sha256"]
        put(self.r, "sub/new.py", "y\n")                     # まだ git にないファイルの中身も差分に入る
        self.assertNotEqual(d1, pv.code_info(self.r)["diff_sha256"])

    def test_staged_and_renamed(self):
        git(self.r, "mv", "other.txt", "renamed.txt")
        put(self.r, "a.sh", "echo staged\n")
        git(self.r, "add", "a.sh")
        c = pv.code_info(self.r)
        self.assertEqual(c["dirty_files"], ["a.sh", "renamed.txt"])

    def test_paths_limit(self):
        put(self.r, "other.txt", "changed\n")
        c = pv.code_info(self.r, paths=["a.sh", "sub"])
        self.assertEqual((c["dirty"], c["paths"]), (False, ["a.sh", "sub"]))
        put(self.r, "sub/b.py", "print(2)\n")
        self.assertEqual(pv.code_info(self.r, paths=["a.sh", "sub"])["dirty_files"], ["sub/b.py"])

    def test_untracked_backup_not_dirty_but_kept(self):
        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
        put(self.r, "a.sh.bak", "old\n")
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
        self.assertEqual(len(c["untracked_backups"]), 2)
        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])

    def test_tracked_backup_change_is_dirty(self):
        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
        put(self.r, "x.bak", "1\n")
        git(self.r, "add", "x.bak")
        git(self.r, "commit", "-qm", "bak")
        put(self.r, "x.bak", "2\n")
        put(self.r, "dir.bak/new.py", "x\n")
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))

    def test_not_a_repo(self):
        with tempfile.TemporaryDirectory() as t:
            c = pv.code_info(t)
        self.assertIsNone(c["commit"])
        self.assertIn("error", c)


class Read(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.d = Path(self.tmp.name)
        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)

    def tearDown(self):
        self.tmp.cleanup()

    def rd(self, p):
        err = io.StringIO()
        with redirect_stderr(err):
            r = pv.read(p)
        return r, err.getvalue()

    def test_ok(self):
        p = pv.write(self.rec, self.d / "train_provenance.json")
        r, err = self.rd(p)
        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))

    def test_embedded_key(self):
        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
        r, err = self.rd(self.d / "index.json")
        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))


exec
/bin/zsh -lc "rg -n 'compare_with_pod|lab_list_files|provenance|DS_Store|incoming_|\\.cache' hf_upload_ckpt.py lab_lib.sh; rg --files docs | rg 'review_result_20261101_train_provenance_codex' " in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
rg: hf_upload_ckpt.py: No such file or directory (os error 2)
lab_lib.sh:6:#   - Hugging Face の鍵は ssh の標準入力で Pod の ~/.cache/huggingface/token に書く
lab_lib.sh:203:    #          (.cache/huggingface/download の *.incomplete) が残っている・探せないときは 0
lab_lib.sh:212:  && { [ ! -d $2/.cache/huggingface ] || { r=\$(find $2/.cache/huggingface -name '*.incomplete' -print -quit 2>/dev/null) && [ -z "\$r" ]; }; } && c=1
lab_lib.sh:255:    tr -d '\r\n' < "$HF_TOKEN_FILE" | pod_ssh 'umask 077; mkdir -p ~/.cache/huggingface; cat > ~/.cache/huggingface/token'
lab_lib.sh:434:lab_list_files() {          # lab_list_files フォルダ → 「パス<TAB>バイト数」を名前順に (Mac 側. du は KIOXIA でずれるので stat の大きさ)
lab_lib.sh:435:    # 数えない: .cache/ (hf download の印)・._* (exFAT が付ける)・.DS_Store・.gitattributes・train_state/
lab_lib.sh:445:        dirs[:] = [x for x in dirs if x not in (".cache", "train_state")]
lab_lib.sh:447:        if f.startswith("._") or f in (".DS_Store", ".gitattributes"):
docs/review_result_20261101_train_provenance_codex_r3.md
docs/review_result_20261101_train_provenance_codex_r2.md
docs/review_result_20261101_train_provenance_codex.md
docs/review_result_20261101_train_provenance_codex_r5.md
docs/review_result_20261101_train_provenance_codex_r4.md

exec
/bin/zsh -lc "sed -n '300,370p' test_provenance.py; cat docs/review_result_20261101_train_provenance_codex_r5.md; sed -n '434,465p' lab_lib.sh; rg -n 'SKIP_REMOTE|compare_with_pod|skip|train_state|gitattributes|provenance' pi05/openpi_addon/hf_upload_ckpt.py; rg -n 'compare_with_pod' lab*.sh; sed -n '1,90p' docs/review_result_20261101_train_provenance_codex_r4.md" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:

    def test_missing_no_warning(self):
        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
        for p in (self.d / "nothing.json", self.d / "old.json"):
            r, err = self.rd(p)
            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))

    def test_unreadable_returns_and_warns_one_line(self):
        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
        cases = {
            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
            "empty_provenance.json": b"",
            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
            "notutf8_provenance.json": b"\xff\xfe{",
            "list_provenance.json": b"[1, 2]",
            "nosv_provenance.json": b'{"a": 1}',
            "badsv_provenance.json": b'{"schema_version": "other/1"}',
            "nulled.json": b'{"provenance": null}',
        }
        for name, data in cases.items():
            put(self.d, name, data)
            r, err = self.rd(self.d / name)
            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
            self.assertTrue(r["error"], name)
            self.assertEqual(len(err.splitlines()), 1, name)
            self.assertIn("由来なし (読めない)", err)
            self.assertIn(name, err)

    def test_unreadable_dir_and_no_warn(self):
        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
        self.assertEqual(r["state"], "unreadable")
        self.assertEqual(len(err.splitlines()), 1)
        put(self.d, "x_provenance.json", "{")
        err = io.StringIO()
        with redirect_stderr(err):
            r = pv.read(self.d / "x_provenance.json", warn=False)
        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))


class BuildWrite(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name) / "r2r2r-data"
        put(self.root, "datasets/ds1/index.json", "{}")

    def tearDown(self):
        self.tmp.cleanup()

    def spec(self, **kw):
        s = {"producer": {"script": "lab_train.sh", "argv": ["--log", "Log9"]},
             "inputs": [{"kind": "dataset", "id": "ds1", "path": str(self.root / "datasets/ds1")},
                        {"kind": "checkpoint", "id": "c", "path": "checkpoints/none"}],
             "numbering": {"series": "フィジカルAI研究室", "series_log": "Log9"},
             "status": {"process": "completed", "task": "not_applicable"}}
        s.update(kw)
        return s

    def test_build_fields(self):
        rec = pv.build(self.spec(), data_root=str(self.root), repo_dir=self.tmp.name)
        for k in ("schema_version", "created_at", "producer", "code", "external_code", "inputs", "config",
                  "compute", "numbering", "status"):
            self.assertIn(k, rec)
        self.assertEqual(rec["schema_version"], "provenance/1")
        self.assertRegex(rec["created_at"], r"^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d$")
        self.assertEqual(rec["code"]["repo"], "r2r2r-scripts")
        self.assertEqual(rec["inputs"][0]["path"], "datasets/ds1")              # r2r2r-data からの相対
        self.assertEqual(rec["inputs"][0]["fingerprint"], pv.fingerprint(self.root / "datasets/ds1"))
        self.assertEqual((rec["inputs"][1]["fingerprint"], rec["inputs"][1]["fingerprint_error"]), (None, "FileNotFoundError"))
        self.assertEqual(rec["numbering"], {"series": "フィジカルAI研究室", "series_log": "Log9", "article_no": ""})
        self.assertNotIn("error", rec)

# codex レビュー結果 R5：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r5.md（codex exec -s read-only）

**判定：必須0件、推奨2件です。** R4の上書き競合は解消されています。対象範囲の読み取りレビューでは、新たな必須修正は確認しませんでした。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| R4：既存ファイルを上書きしない | **据え置き・修正済み** | [provenance.py:312](/Users/shimazueiichi/r2r2r-scripts/provenance.py:312) は `O_CREAT \| O_EXCL` で直接作成します。先に同名ファイルが作られた場合も失敗し、既存内容を書き換えません。rename競合は解消しています。 |
| 強制終了時の不完全JSON | **推奨** | [provenance.py:315](/Users/shimazueiichi/r2r2r-scripts/provenance.py:315) は短い書込みに対応して反復する実装です。「1回で書く」というコメントとは異なり、単一writeでも完了の原子性は保証されません。KILL時には空・途中のJSONが残り、次回は存在済みとしてスキップされます。②③へ展開する前に、読込側のJSON検証と、不完全記録の扱いを決めてください。既存ファイルの上書き問題ではありません。 |
| 由来処理中のsignalテスト | **推奨** | `hf_prov_hang`には外側の監視が追加されています。一方、`early_fail → finish → prov_write`中のINT・TERMは未検証です。この経路では親のsignal trapが残っているため、通知回数・終了コード・子プロセス残存を確認するテストを推奨します。 |
| Pod削除・通知・終了コード | 据え置き | Pod削除の試行は由来処理より先です。`finish`は終了コードをローカル変数に保持し、通常の由来失敗で変更しません。追加部分に明白な`set -u`違反は見当たりません。全体監視は300秒で、打切り時には停止猶予などが加わります。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のspecに接続先IP・ポートを直接渡す経路は見当たりません。SSH出力のマスク、引数の伏字、鍵形式・secret-file内容・辞書キーの置換が維持されています。 |
| KIOXIAの既存データ・未マウント時 | 据え置き | `PROV_CKPT_DIR`は新規配置と最終照合の成功後だけ設定され、既存1499には設定されません。由来処理はフォルダを作らず、DATA_ROOT・LOGDIRがなければ書きません。datasets・runsへ追記する経路も見当たりません。 |
| Hugging Face・再取得・起動互換性 | 据え置き | `HFUP ok`だけ由来を追加し、`same`では追加しません。比較時の由来除外と、取得用一時フォルダ内の過去の由来除去は整合しています。`lab_start.sh --model corr`の取得・利用経路にも破綻は見当たりません。 |
| status・開始時情報・openpi | 据え置き | 学習完了後は`completed`を維持し、保存・削除失敗はerrorへ記録します。開始時スナップショット、取得失敗入力の固定、`--stop`での復元、不完全なopenpi取得を不明とする修正も維持されています。 |
| 指紋・dirtyの対象 | 据え置き | 指定4パスへの限定は今回の範囲として妥当です。`.DS_Store`を数えることと、出力指紋が由来配置前の値であることは現行仕様どおりです。②③では、この前提に合わせた再検証手順が必要です。 |

シェル4ファイル・Python4ファイルの構文確認は成功しました。テスト本体は書込みやプロセス操作を伴うため実行しておらず、**30件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須なし。ここでレビューを終える
- 推奨（書きかけの JSON）: write の説明を「書き終わるまで write をくり返す・KILL のときだけ書きかけが残りうる・読む側は JSON として読めるかを確かめる」に直した。読む側の扱いは ②③ を作るときにエイイチに決めてもらう
- 推奨（early_fail の中の INT・TERM のテスト）: 据え置き（Pod を借りる前で Hugging Face へは足さない。中の python は 120 秒の上限）
lab_list_files() {          # lab_list_files フォルダ → 「パス<TAB>バイト数」を名前順に (Mac 側. du は KIOXIA でずれるので stat の大きさ)
    # 数えない: .cache/ (hf download の印)・._* (exFAT が付ける)・.DS_Store・.gitattributes・train_state/
    python3 -I -c '
import os, sys
root = sys.argv[1]
out = []
def err(e):
    raise e                 # 読めないフォルダを黙って飛ばさない (一覧が足りないまま「合った」としない)
for d, dirs, files in os.walk(root, onerror=err):
    rel = os.path.relpath(d, root)
    if rel == ".":
        dirs[:] = [x for x in dirs if x not in (".cache", "train_state")]
    for f in files:
        if f.startswith("._") or f in (".DS_Store", ".gitattributes"):
            continue
        p = os.path.normpath(os.path.join(rel, f))
        out.append((p, os.path.getsize(os.path.join(d, f))))
for p, s in sorted(out, key=lambda x: x[0].encode()):
    print(f"{p}\t{s}")
' "$1"
}

lab_watchdog_script() {     # Pod 側の上限の見張り (lab_train.sh が /workspace/r2r2r/lab_watchdog.sh に置き, setsid nohup で裏で回す)
    cat <<'WATCHDOG_EOF'
#!/bin/bash
# lab_watchdog.sh — 上限の時刻を過ぎたら, この Pod を自分で消す (lab_train.sh が置く. 依頼 49)
# Mac が止まって lab_train.sh が Pod を消せなくても, Pod が残り続けないための保険.
# 鍵は Mac から送らない. Runpod が Pod に入れる RUNPOD_POD_ID と Pod 用の鍵 RUNPOD_API_KEY を使う
# (ssh のシェルには入っていないことがあるので, 1 番のプロセスの環境と /etc/rp_environment からも探す). 鍵は書き出さない
F=${LAB_WD_DEADLINE:-/workspace/logs/.lab_deadline}     # 上限の時刻 (epoch). lab_train.sh が書き換えて延ばす
L=${LAB_WD_LOG:-/workspace/logs/watchdog.log}
RUNPOD_REST=${LAB_WD_API:-https://rest.runpod.io/v1}
7:上げるもの: train_state/ 以外の全部 (params/・assets/・_CHECKPOINT_METADATA). リポジトリの一番上に置く
22:SKIP_TOP = ("train_state",)
23:SKIP_REMOTE = (".gitattributes", "train_provenance.json")     # 由来は Mac の lab_train.sh があとで足す (依頼 54)
27:    """train_state/ を除くファイルの (パス, バイト数) を名前順に"""
44:    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes・train_provenance.json は数えない)"""
48:        if size is None or x.path in SKIP_REMOTE:      # フォルダには size がない
81:    print(f"[HF    ] 上げるもの: {len(mine)} ファイル {total:,} バイト (train_state/ は上げない)", flush=True)
lab_train.sh:438:compare_with_pod() {        # compare_with_pod フォルダ → Pod 側の一覧と名前・バイト数が全部同じなら 0
lab_train.sh:456:        if compare_with_pod "$dest"; then KIOXIA_MSG="$dest はもとからあり, Pod 側と全部同じ (書き換えていない)"; return 0; fi
lab_train.sh:473:    if ! compare_with_pod "$tmp"; then KIOXIA_MSG="取ったものが Pod 側の一覧と合わない (そのまま $tmp に置いた)"; return 1; fi
lab_train.sh:481:    compare_with_pod "$dest" || { KIOXIA_MSG="$dest に移したあとの一覧が Pod 側と合わない"; return 1; }
# codex レビュー結果 R4：学習と checkpoint の由来（2026-11-01 依頼 54）

依頼文: docs/review_request_20261101_train_provenance_r4.md（codex exec -s read-only）

**判定：必須1件、推奨2件です。** R3のグループ停止は改善されています。由来の公開処理は、exFAT向けフォールバックに上書き競合が残っています。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| exFATでの`rename`競合 | **必須** | [provenance.py:325](/Users/shimazueiichi/r2r2r-scripts/provenance.py:325) は`lexists(path)`確認後に`os.rename(tmp, path)`します。その間に別の実行が同名ファイルを作ると、**既存ファイルを上書きします**。KIOXIAで使う経路なので、「上書きしない」という契約を満たしていません。上書き禁止を原子的に保証する公開方式へ変更してください。対応できない環境では失敗として返す方が安全です。テストには、存在確認後・rename直前に別の書込みが入るケースを追加してください。 |
| 強制終了時の一時ファイル | **推奨** | [provenance.py:313](/Users/shimazueiichi/r2r2r-scripts/provenance.py:313) の一時ファイルは、KILLされると`finally`で消せません。完成先に不完全JSONが残る問題は改善しましたが、「一時ファイルは残さない」は強制終了時には成立しません。また、この名前は`fingerprint()`の除外対象ではなく、以後の指紋に混入します。残存時の扱いと指紋からの除外方針を定めてください。 |
| ハング・signalテスト | **推奨** | [test_lab_train.sh:410](/Users/shimazueiichi/r2r2r-scripts/test_lab_train.sh:410) は、TERMを無視して孫を継続生成するケースへ改善されています。ただし時間の判定は`run`が戻った後なので、本当に戻らない回帰ではテストも停止します。外側にも監視を置き、さらに**由来処理中のINT・TERM**を追加してください。`early_fail`から入る場合は、wrap内のsignal無視とは異なる経路です。 |
| `kill_group`と`set -m` | 据え置き・R3改善確認 | [lab_train.sh:206](/Users/shimazueiichi/r2r2r-scripts/lab_train.sh:206) はグループ単位で停止するため、列挙後に生まれた同グループの孫も対象になります。このMacのBashで、内側のパイプライン・非同期処理が同グループに残ることを確認しました。既存のtee・caffeinateを対象グループへ移す処理もありません。由来処理は端末入力を必要とせず、明白な端末アクセスの回帰は見当たりません。 |
| linkによる公開 | 据え置き・R3修正確認 | [provenance.py:322](/Users/shimazueiichi/r2r2r-scripts/provenance.py:322) のlink成功経路は、完成したJSONを上書きなしで公開できます。同名への競合は`FileExistsError`になります。問題は上記のrenameフォールバックです。 |
| 片付け・通知・終了コード | 据え置き | Pod削除の試行は由来処理より先です。spec生成・Python・HF追加は全体監視の内側にあり、通常の失敗で保存済み終了コードを変える経路や、追加部分の明白な`set -u`違反は見当たりません。時間切れ時は300秒に加えて停止猶予などがかかります。 |
| 鍵・PodのIP／ポート | 据え置き | 現在のspecへIP・ポートを直接渡す経路は見当たりません。引数、既知の鍵形式、secret-fileの内容、辞書キーへの伏字処理も維持されています。 |
| 既存KIOXIA・未マウント時 | 据え置き・競合は必須欄 | `PROV_CKPT_DIR`は新規配置の最終照合成功後だけ設定され、既存1499には設定されません。由来処理自身はフォルダを作りません。datasets・runsへ書き足す通常経路も見当たりません。 |
| HF・`lab_start.sh`互換性 | 据え置き | HF側とKIOXIA側の比較除外は整合しています。`same`取得時の過去の由来除去も維持されています。`lab_start.sh --model corr`の取得・利用経路に、JSON追加による破綻は見当たりません。 |
| 指紋・dirty対象 | 据え置き | 指定4パスへのdirty限定と開始時の測定は妥当です。`.DS_Store`を数えること、出力指紋が由来配置前の値であることは現行仕様どおりです。②③では、その前提に合う再検証手順が必要です。 |

シェル4ファイル・Python4ファイルの構文確認は成功しました。テスト本体は書込み・プロセス停止を伴うため実行しておらず、**29件（1 skip）・OK 177／NG 0は未追試**です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映（Claude Code）
- 必須（exFAT の rename の競合）: 一時ファイル→link/rename をやめ、置く先を O_EXCL で直に作って1回の write（数 KB）で書く形に戻した。あれば必ず失敗するので上書きはしない（exFAT でも同じ）。書けなければ消す。test_provenance.py に「作る直前に別の実行が置いた」「書けなかった」を足す
- 推奨（一時ファイル）: 一時ファイルを作らなくなったので、残って指紋に入ることはない。残るのは「1回の write の途中で KILL された」ときの書きかけだけ（数 KB・据え置き）
- 推奨（テスト）: hf_prov_hang の回を外側でも 90 秒で止める見張りを置いた。early_fail の中で由来を書いている途中の INT・TERM は、Pod を借りる前で Hugging Face へは足さない（python の 120 秒の上限）ので、テストは足さず据え置き

codex
checkpoint内の独立した由来ファイルは、files-v2で除外されます。一方、`index.json`などへ埋め込む由来は指紋に含まれるため、②③への展開時は区別が必要です。`read()`の例外処理と、既存の比較処理との除外範囲も確認しています。
exec
/bin/zsh -lc "sed -n '22,60p' pi05/openpi_addon/hf_upload_ckpt.py; sed -n '418,450p' lab_train.sh; sed -n '450,490p' test_lab_train.sh; nl -ba provenance.py | sed -n '382,443p'; nl -ba test_provenance.py | sed -n '90,108p'" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
SKIP_TOP = ("train_state",)
SKIP_REMOTE = (".gitattributes", "train_provenance.json")     # 由来は Mac の lab_train.sh があとで足す (依頼 54)


def local_files(root):
    """train_state/ を除くファイルの (パス, バイト数) を名前順に"""
    out = []

    def err(e):
        raise e             # 読めないフォルダを黙って飛ばさない

    for d, dirs, files in os.walk(root, onerror=err):
        rel = os.path.relpath(d, root)
        if rel == ".":
            dirs[:] = [x for x in dirs if x not in SKIP_TOP]
        for f in files:
            p = os.path.normpath(os.path.join(rel, f))
            out.append((p, os.path.getsize(os.path.join(d, f))))
    return sorted(out, key=lambda x: x[0].encode())


def remote_files(api, repo):
    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes・train_provenance.json は数えない)"""
    out = []
    for x in api.list_repo_tree(repo, recursive=True, repo_type="model"):
        size = getattr(x, "size", None)
        if size is None or x.path in SKIP_REMOTE:      # フォルダには size がない
            continue
        out.append((x.path, int(size)))
    return sorted(out, key=lambda x: x[0].encode())


def diff_lines(a, b, n=10):
    da, db = dict(a), dict(b)
    lines = []
    for p in sorted(set(da) | set(db), key=str.encode):
        if da.get(p) != db.get(p):
            lines.append(f"  {p}: 手元 {da.get(p, 'なし')} / リポジトリ {db.get(p, 'なし')}")
    return lines[:n] + ([f"  ほか {len(lines) - n} 件"] if len(lines) > n else [])
    if [ "$kind" != crash ] && [ -n "$TRAIN_T0" ]; then
        LAB_TO=$LAB_TO_SHORT pssh "$(lab_train_kill_cmd)" >/dev/null
    fi
    if fetch_logs; then logs="ログを持ち帰った ($FETCHED 個)"; else logs="ログは持ち帰れなかった (Pod に入れない)"; fi
    if delete_pod; then
        RESULT_LINES=$(printf '%s\n' "わけ: $why" "$logs → $LOGDIR" "Pod (id $POD_ID) は消した (API で確かめた)" "$COST_LINE")
        finish 1 "失敗: $why. $logs. Pod は消しました. ${COST_LINE#借りていた時間 }"
    else
        RESULT_LINES=$(printf '%s\n' "わけ: $why" "$logs → $LOGDIR" "$(pod_left_msg)" "${COST_LINE:-}")
        finish 1 "失敗: $why. $logs. $(pod_left_msg)"
    fi
}

on_signal() { wrap_fail signal "$1 で止めた"; }
trap 'on_signal Ctrl-C' INT
trap 'on_signal "TERM (kill)"' TERM
trap 'on_signal "ターミナルが閉じた (HUP)"' HUP

# ---- KIOXIA の checkpoints/ に置く ----
POD_LIST=""                  # Pod 側の最後のチェックポイントの一覧 (パス<TAB>バイト数. train_state なし)
compare_with_pod() {        # compare_with_pod フォルダ → Pod 側の一覧と名前・バイト数が全部同じなら 0
    local mine d rc
    mine=$(mktemp)
    # 由来 (train_provenance.json) は Mac があとで足すので数えない (依頼 54)
    if ! { lab_list_files "$1" | { grep -v $'^train_provenance\.json\t' || true; }; } > "$mine" || [ ! -s "$POD_LIST" ]; then
        echo "[KIOXIA] 一覧を作れない (KIOXIA 側 $1 か Pod 側 $POD_LIST). 合ったとはしない"; rm -f "$mine"; return 1
    fi
    d=$(diff "$mine" "$POD_LIST"); rc=$?; rm -f "$mine"
    [ $rc = 0 ] && return 0          # diff の終了コードが 0 のときだけ「合った」(2 = 読めない も合わないとする)
    echo "[KIOXIA] Pod 側の一覧と合わない (diff の終了コード $rc. < KIOXIA / > Pod. 先頭 20 行):"
    head -20 <<<"$d" | sed 's/^/         /'
    return 1
}
run kioxia_gone
check "KIOXIA が外れた: 由来は書かない (フォルダも作り直さない)・最後まで片付けて通知" '[ $RC = 1 ] && [ ! -e "$T/data" ] && ! find "$T/data_unmounted" -name "train_provenance*.json" | grep -q . && has out.txt "由来: KIOXIA がマウントされていない" && [ $(n_call DELETE) = 1 ] && [ $(wc -l < "$T/notify.log") = 1 ]'
rm -rf "$T"
# dirty: 一時の git リポジトリに lab_train.sh などを入れ, 変える前・変えたあとで code を見る
G=$(mktemp -d); (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t \
    && cp "$HERE/lab_train.sh" "$HERE/lab_lib.sh" "$HERE/provenance.py" . && mkdir pi05 && echo x > pi05/a.py && echo y > other.txt \
    && git add lab_train.sh lab_lib.sh provenance.py pi05/a.py other.txt && git commit -qm init)
LAB_PROV_REPO="$G" run success
check "dirty でない: code.dirty=false・dirty_files なし" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r .code.commit "$TD/train_provenance.json")" = "$(git -C "$G" rev-parse HEAD)" ]'
rm -rf "$T"
echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
LAB_PROV_REPO="$G" run success
check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
rm -rf "$T"
# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
LAB_PROV_REPO="$G" run success
check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
rm -rf "$T"
echo "# changed" >> "$G/lab_lib.sh"
LAB_PROV_REPO="$G" run success
check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
rm -rf "$T" "$G"
OUT1=""

rm -rf "${OUT1:-}"
echo
echo "OK $OK / NG $NG"
[ $NG = 0 ]
   382	
   383	
   384	# ---- 読む ----
   385	def read(path, warn=True):
   386	    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
   387	
   388	    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
   389	    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
   390	      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
   391	      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
   392	      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
   393	                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
   394	    path = os.fspath(path)
   395	    res = {"state": "missing", "path": path, "provenance": None, "error": None}
   396	    try:
   397	        with open(path, "rb") as fh:
   398	            raw = fh.read()
   399	    except FileNotFoundError:
   400	        res["error"] = "ファイルがない"
   401	        return res
   402	    except Exception as e:      # 読めないファイル (権限・フォルダなど)
   403	        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
   404	    try:
   405	        doc = json.loads(raw.decode("utf-8"))
   406	    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
   407	        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
   408	    rec = doc
   409	    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
   410	        if "provenance" in doc:
   411	            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
   412	        elif not is_provenance_name(os.path.basename(path)):
   413	            res["error"] = "キー provenance がない"
   414	            return res
   415	    if not isinstance(rec, dict):
   416	        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
   417	    sv = rec.get("schema_version")
   418	    if not _is_prov_schema(sv):
   419	        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
   420	    res.update(state="ok", provenance=rec)
   421	    return res
   422	
   423	
   424	def _is_prov_schema(sv):
   425	    return isinstance(sv, str) and sv.startswith("provenance/")
   426	
   427	
   428	def _unreadable(res, why, warn):
   429	    res.update(state="unreadable", error=why)
   430	    if warn:
   431	        print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
   432	    return res
   433	
   434	
   435	# ---- CLI ----
   436	def main(argv=None):
   437	    ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
   438	    sub = ap.add_subparsers(dest="cmd", required=True)
   439	    f = sub.add_parser("fingerprint")
   440	    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
   441	    f.add_argument("path")
   442	    pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
   443	    pr.add_argument("--data-root", default=None)
    90	    def test_skips_agree_with_checkpoint_compares(self):
    91	        # hf_upload_ckpt.py (Hugging Face 側の比べ) と lab_list_files (KIOXIA 側の比べ) が数えない由来・.DS_Store は, 指紋でも数えない
    92	        sys.path.insert(0, str(Path(__file__).resolve().parent / "pi05" / "openpi_addon"))
    93	        import hf_upload_ckpt as hu
    94	        for name in hu.SKIP_REMOTE:
    95	            if name.endswith(".json"):
    96	                self.assertTrue(pv.is_provenance_name(name), name)
    97	        lib = (Path(__file__).resolve().parent / "lab_lib.sh").read_text()
    98	        self.assertIn('f in (".DS_Store", ".gitattributes")', lib)
    99	        self.assertTrue(pv._skip_file_v2(".DS_Store"))
   100	
   101	    def test_skips_dotunderscore_cache_incoming(self):
   102	        a = pv.fingerprint(self.d)
   103	        put(self.d, "._index.json", "junk")
   104	        put(self.d, "raw/._ep1", "junk")
   105	        put(self.d, ".cache/huggingface/x.json", "{}")
   106	        put(self.d, ".incoming_1499_x/params/a", "zz")
   107	        self.assertEqual(a, pv.fingerprint(self.d))
   108	

exec
/bin/zsh -lc "python3 -B -c 'import io; import provenance as p; from unittest.mock import patch
class BrokenStderr:
 def write(self,s): raise OSError(\"stderr unavailable\")
 def flush(self): pass
with patch(\"builtins.open\",return_value=io.BytesIO(b\"{\")),patch(\"sys.stderr\",BrokenStderr()):
 try: p.read(\"train_provenance.json\")
 except Exception as e: print(\"stderr:\",type(e).__name__,str(e))
with patch(\"builtins.open\",return_value=io.BytesIO(b\"{}\")):
 try: p.read(b\"train_provenance.json\")
 except Exception as e: print(\"bytes path:\",type(e).__name__,str(e))'" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 50ms:
python3: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
python3: error: couldn't create cache file '/tmp/xcrun_db-THBtWiwX' (errno=Operation not permitted)
python3: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
python3: error: couldn't create cache file '/tmp/xcrun_db-e2mxTDp9' (errno=Operation not permitted)
stderr: OSError stderr unavailable
bytes path: TypeError endswith first arg must be bytes or a tuple of bytes, not str

codex
**判定：必須1件、推奨3件です。** checkpointのfiles-v2化と控えの分離は依頼どおりです。`read()`には、警告の出力失敗で例外が漏れる経路があります。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| 警告出力で処理が止まる | **必須** | [provenance.py:431](/Users/shimazueiichi/r2r2r-scripts/provenance.py:431) の`print(..., file=sys.stderr)`が保護されていません。標準エラーが閉じている・出力先が失われた場合、壊れたJSONを読むと警告側の例外が漏れます。モックで`OSError`の伝播を確認しました。警告出力の失敗を捕捉し、`unreadable`を返してください。12-5の「処理は止めない」に関わります。 |
| bytes形式のパス | **推奨** | [provenance.py:412](/Users/shimazueiichi/r2r2r-scripts/provenance.py:412) は、`read(b"train_provenance.json")`で中身が`{}`の場合、名前判定の`endswith(str)`から`TypeError`が漏れます。モックで確認済みです。`os.fsdecode(os.fspath(path))`で文字列に統一するか、受け付ける型を明記してください。 |
| 既存比較との整合テスト | **推奨** | [test_provenance.py:90](/Users/shimazueiichi/r2r2r-scripts/test_provenance.py:90) は定数とソース文字列の確認で、比較処理の実行結果は検証していません。また、既存比較が除く`.gitattributes`・トップの`train_state/`はfiles-v2では数えます。「除外条件全体が一致する」という説明は成立しません。今回追加した由来・`.DS_Store`に限定した整合性と、既存の差をテスト・説明で分けてください。 |
| 埋め込み由来と再測定 | **推奨** | `index.json`・`recording_meta.json`内の`provenance`は、小さなJSONの中身として指紋に入ります。したがって、**埋め込み後は指紋が変わります**。今回のcheckpoint経路の不具合ではありませんが、「由来を置いたあとも同じ」という説明は独立した除外対象ファイルに限定し、②③へ展開する前に測定順序を決めてください。 |
| files-v2の除外 | 据え置き | `.DS_Store`・`*_provenance.json`を全階層のファイルから除外し、従来の`._*`・`.cache`・`.incoming_*`の除外を維持しています。単一ファイルを明示した場合の例外も依頼どおりです。checkpointへ由来を追加・更新しても、その追加による指紋変化はありません。 |
| `read()`の通常経路 | 据え置き | 壊れたJSON・非UTF-8・非dict・不正schema・フォルダは`unreadable`になります。外側に別schemaがある埋め込みJSONも読めます。ファイル不存在・由来キーのない旧JSONは`missing`です。上記の例外漏れを除き、依頼の分類を満たします。 |
| `untracked_backups` | 据え置き | `??`かつ最後の名前に`.bak`を含むものだけを分離します。追跡済み`.bak`の変更と、`dir.bak/new.py`はdirtyに残ります。控え一覧の件数制限はなく、差分指紋からも除外されます。ただし実コードでも名前に`.bak`があれば除外される点は、確定した名前規則そのものの性質です。 |
| files-v1の保持 | 据え置き | 同じ版で過去記録と比較するため妥当です。確認した差分では、v1の計算処理は維持され、追加除外だけがv2に適用されます。版取得とCLI指定も整合しています。 |
| `fingerprint_note`削除 | 据え置き | checkpointの独立した由来ファイルが除外されたため、配置前という注記を削除して問題ありません。 |

規則12節と過去レビューを参照し、対象4ファイルの差分を確認しました。テスト本体は一時ファイルへの書込みを伴うため実行せず、上記2件のみメモリ上のモックで確認しました。報告されたテスト件数・成功数は未追試です。ファイル変更、実機・Pod・Hugging Faceの操作は行っていません。

## 反映
- 必須「警告出力で処理が止まる」: `_unreadable` の print を try で包み、標準エラーに書けなくても `unreadable` を返す。テストを足した
- 推奨「bytes 形式のパス」: `os.fsdecode(os.fspath(path))` で文字列にそろえる。パスにならないもの（数など）も `unreadable` で返す。テストを足した
- 推奨「既存比較との整合テスト」: ソースの文字列を見るテストをやめ、hf_upload_ckpt.remote_files（偽の api）と lab_list_files（bash で実際に流す）と指紋を、由来と .DS_Store を足す前・あとで比べるテストにした。.gitattributes・train_state/ などは元から比べと指紋で違うことをテストの説明に書いた
- 推奨「埋め込み由来と再測定」: provenance.py の説明に「キー provenance を JSON に足したときは指紋が変わる」と書いた。②③での測る順番はエイイチが決めること（報告に書く）

exec
/bin/zsh -lc "sed -n '/"'^## 12/,$p'"' /Users/shimazueiichi/r2r2r-content/methodology/data_manifest_rules.md" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
exec
/bin/zsh -lc "nl -ba test_provenance.py | sed -n '1,250p'" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
     1	#!/usr/bin/env python3
     2	"""test_provenance.py — 由来の共通の部品 provenance.py (依頼 54・56) の確かめ.
     3	
     4	  python3 test_provenance.py -v
     5	
     6	一時フォルダと一時の git リポジトリだけを使う (KIOXIA・本物のリポジトリには書かない).
     7	"""
     8	import hashlib
     9	import io
    10	import json
    11	import os
    12	import subprocess
    13	import sys
    14	import tempfile
    15	import unicodedata
    16	import unittest
    17	from contextlib import redirect_stderr, redirect_stdout
    18	from pathlib import Path
    19	
    20	sys.path.insert(0, str(Path(__file__).resolve().parent))
    21	import provenance as pv  # noqa: E402
    22	
    23	
    24	def put(root, rel, data=b"x"):
    25	    p = Path(root) / rel
    26	    p.parent.mkdir(parents=True, exist_ok=True)
    27	    p.write_bytes(data if isinstance(data, bytes) else data.encode())
    28	    return p
    29	
    30	
    31	def git(cwd, *args):
    32	    subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True)
    33	
    34	
    35	class Fingerprint(unittest.TestCase):
    36	    def setUp(self):
    37	        self.tmp = tempfile.TemporaryDirectory()
    38	        self.d = Path(self.tmp.name) / "ds"
    39	        put(self.d, "index.json", '{"n": 1}')
    40	        put(self.d, "raw/ep1/frame.jpg", b"\xff" * 50)
    41	        put(self.d, "raw/ep1/meta.json", '{"a": 1}')
    42	
    43	    def tearDown(self):
    44	        self.tmp.cleanup()
    45	
    46	    def test_format_and_same_twice(self):
    47	        a = pv.fingerprint(self.d)
    48	        self.assertRegex(a, r"^files-v2:[0-9a-f]{64}$")
    49	        self.assertEqual(a, pv.fingerprint(self.d))
    50	
    51	    def test_definition(self):
    52	        # 決めた作り方そのもの (相対パス\tバイト数 の一覧 → 1MB 未満の .json の中身)
    53	        h = hashlib.sha256()
    54	        for p, n in [("index.json", 8), ("raw/ep1/frame.jpg", 50), ("raw/ep1/meta.json", 8)]:
    55	            h.update(f"{p}\t{n}\n".encode())
    56	        h.update(b"\0index.json\0" + b'{"n": 1}')
    57	        h.update(b"\0raw/ep1/meta.json\0" + b'{"a": 1}')
    58	        self.assertEqual(pv.fingerprint(self.d), "files-v2:" + h.hexdigest())
    59	        self.assertEqual(pv.fingerprint(self.d, "files-v1"), "files-v1:" + h.hexdigest())   # 数えないものがなければ v1 と同じ中身
    60	
    61	    def test_v2_skips_ds_store_and_provenance_files(self):
    62	        # 依頼 56: .DS_Store と由来のファイルそのものは数えない (どの深さでも)
    63	        a = pv.fingerprint(self.d)
    64	        v1 = pv.fingerprint(self.d, "files-v1")
    65	        put(self.d, ".DS_Store", b"\0" * 6148)
    66	        put(self.d, "raw/.DS_Store", b"\0" * 10)
    67	        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
    68	        put(self.d, "raw/ep1/dataset_provenance.json", "{}")
    69	        self.assertEqual(a, pv.fingerprint(self.d))
    70	        self.assertNotEqual(v1, pv.fingerprint(self.d, "files-v1"))     # v1 は数えていた (比べるとき用に残す)
    71	        put(self.d, "provenance.json", "{}")                 # 「_provenance.json」で終わらない名前は数える
    72	        put(self.d, "raw/DS_Store", "x")
    73	        self.assertNotEqual(a, pv.fingerprint(self.d))
    74	
    75	    def test_v2_same_after_writing_provenance_into_output(self):
    76	        # 由来を置く前に測った outputs の指紋と, 置いたあとに測り直した指紋が合う
    77	        rec = pv.build({"outputs": [{"kind": "checkpoint", "id": "ds/1", "path": str(self.d)}]}, repo_dir=self.tmp.name)
    78	        pv.write(rec, self.d / "train_provenance.json")
    79	        self.assertEqual(rec["outputs"][0]["fingerprint"], pv.fingerprint(self.d))
    80	        self.assertRegex(rec["outputs"][0]["fingerprint"], r"^files-v2:")
    81	
    82	    def test_version_helpers(self):
    83	        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d)), "files-v2")
    84	        self.assertEqual(pv.fingerprint_version(pv.fingerprint(self.d, "files-v1")), "files-v1")
    85	        for bad in (None, "", "files-v1:00", "files-v3:" + "0" * 64, "sha256:" + "0" * 64):
    86	            self.assertIsNone(pv.fingerprint_version(bad))
    87	        with self.assertRaises(ValueError):
    88	            pv.fingerprint(self.d, "files-v3")
    89	
    90	    def test_skips_agree_with_checkpoint_compares(self):
    91	        # 由来 (トップの train_provenance.json) と .DS_Store を足したとき, hf_upload_ckpt.py (Hugging Face 側の一覧) と
    92	        # lab_list_files (KIOXIA 側の一覧) と指紋 files-v2 のどれも変わらない. 由来・.DS_Store のほかの数えないもの
    93	        # (.gitattributes・train_state/ など) は比べと指紋で元から違う (この依頼では変えない)
    94	        here = Path(__file__).resolve().parent
    95	        sys.path.insert(0, str(here / "pi05" / "openpi_addon"))
    96	        import hf_upload_ckpt as hu
    97	
    98	        class Item:
    99	            def __init__(self, path, size):
   100	                self.path, self.size = path, size
   101	
   102	        class Api:
   103	            def __init__(self, root):
   104	                self.root = root
   105	
   106	            def list_repo_tree(self, repo, recursive, repo_type):
   107	                return [Item(p, n) for p, n, _ in pv.list_files(self.root, "files-v1")]
   108	
   109	        def kioxia(d):
   110	            r = subprocess.run(["bash", "-c", '. "$1"; lab_list_files "$2"', "_", str(here / "lab_lib.sh"), str(d)],
   111	                               capture_output=True, text=True, check=True)
   112	            return r.stdout
   113	
   114	        before = (pv.fingerprint(self.d), hu.remote_files(Api(self.d), "r"), kioxia(self.d))
   115	        put(self.d, ".DS_Store", b"\0" * 6148)
   116	        put(self.d, "train_provenance.json", '{"schema_version": "provenance/1"}')
   117	        after_fp, after_hf, after_kx = pv.fingerprint(self.d), hu.remote_files(Api(self.d), "r"), kioxia(self.d)
   118	        self.assertEqual(before[0], after_fp)
   119	        self.assertEqual(before[1], [x for x in after_hf if x[0] != ".DS_Store"])     # Hugging Face に .DS_Store は上がらない
   120	        self.assertEqual(before[2], "".join(x for x in after_kx.splitlines(True) if not x.startswith("train_provenance.json\t")))
   121	
   122	    def test_skips_dotunderscore_cache_incoming(self):
   123	        a = pv.fingerprint(self.d)
   124	        put(self.d, "._index.json", "junk")
   125	        put(self.d, "raw/._ep1", "junk")
   126	        put(self.d, ".cache/huggingface/x.json", "{}")
   127	        put(self.d, ".incoming_1499_x/params/a", "zz")
   128	        self.assertEqual(a, pv.fingerprint(self.d))
   129	
   130	    def test_image_content_not_read_but_size_counts(self):
   131	        a = pv.fingerprint(self.d)
   132	        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 50)      # 同じ大きさで中身だけ違う
   133	        self.assertEqual(a, pv.fingerprint(self.d))
   134	        put(self.d, "raw/ep1/frame.jpg", b"\x00" * 51)
   135	        self.assertNotEqual(a, pv.fingerprint(self.d))
   136	
   137	    def test_json_content_counts(self):
   138	        a = pv.fingerprint(self.d)
   139	        put(self.d, "raw/ep1/meta.json", '{"a": 2}')         # 同じ大きさで中身が違う
   140	        self.assertNotEqual(a, pv.fingerprint(self.d))
   141	
   142	    def test_big_json_content_not_read(self):
   143	        put(self.d, "big.json", b"a" * pv.JSON_MAX)
   144	        a = pv.fingerprint(self.d)
   145	        put(self.d, "big.json", b"b" * pv.JSON_MAX)
   146	        self.assertEqual(a, pv.fingerprint(self.d))
   147	
   148	    def test_new_file_counts(self):
   149	        a = pv.fingerprint(self.d)
   150	        put(self.d, "raw/ep2/frame.jpg", b"")
   151	        self.assertNotEqual(a, pv.fingerprint(self.d))
   152	
   153	    def test_nfd_names_same_as_nfc(self):
   154	        e = Path(self.tmp.name)
   155	        put(e / "a", unicodedata.normalize("NFC", "データ.txt"), "1")
   156	        put(e / "b", unicodedata.normalize("NFD", "データ.txt"), "1")
   157	        self.assertEqual(pv.fingerprint(e / "a"), pv.fingerprint(e / "b"))
   158	
   159	    def test_nfc_collision_raises(self):
   160	        e = Path(self.tmp.name) / "c"
   161	        put(e, unicodedata.normalize("NFC", "が.txt"), "1")
   162	        try:
   163	            put(e, unicodedata.normalize("NFD", "が.txt"), "2")
   164	        except OSError:
   165	            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
   166	        if len(os.listdir(e)) < 2:
   167	            self.skipTest("このファイルシステムは NFC と NFD を同じ名前とみなす")
   168	        with self.assertRaises(ValueError):
   169	            pv.fingerprint(e)
   170	
   171	    def test_symlink_not_followed(self):
   172	        e = Path(self.tmp.name) / "l"
   173	        put(e, "real/a.json", '{"x": 1}')
   174	        os.symlink(self.d, e / "link")
   175	        names = [p for p, _, _ in pv.list_files(e)]
   176	        self.assertEqual(names, ["link", "real/a.json"])
   177	
   178	    def test_single_file_and_missing(self):
   179	        f = put(self.tmp.name, "one.json", "{}")
   180	        h = hashlib.sha256(b"one.json\t2\n" + b"\0one.json\0{}").hexdigest()
   181	        self.assertEqual(pv.fingerprint(f), "files-v2:" + h)
   182	        self.assertEqual(pv.fingerprint(f, "files-v1"), "files-v1:" + h)
   183	        with self.assertRaises(FileNotFoundError):
   184	            pv.fingerprint(Path(self.tmp.name) / "nothing")
   185	
   186	    def test_unreadable_dir_raises(self):
   187	        locked = self.d / "locked"
   188	        locked.mkdir()
   189	        locked.chmod(0)
   190	        try:
   191	            with self.assertRaises(OSError):
   192	                pv.fingerprint(self.d)
   193	        finally:
   194	            locked.chmod(0o755)
   195	
   196	
   197	class Redact(unittest.TestCase):
   198	    def test_argv(self):
   199	        argv = ["--log", "Log17", "--hf-token", "abc123", "--api_key=xyz", "--key", "k", "--steps", "1500",
   200	                "hf_ABCDEFGHIJKLMNOP", "--author", "me"]
   201	        self.assertEqual(pv.redact_argv(argv), ["--log", "Log17", "--hf-token", "<redacted>", "--api_key=<redacted>",
   202	                                                "--key", "<redacted>", "--steps", "1500", "<redacted>", "--author", "me"])
   203	
   204	    def test_secret_in_dict_key(self):
   205	        rec = pv.build({"notes": {"hf_ABCDEFGHIJKLMNOPQ": 1}}, repo_dir=tempfile.gettempdir())
   206	        self.assertNotIn("hf_ABCDEFGHIJKLMNOPQ", json.dumps(rec))
   207	
   208	    def test_secret_file_values_removed_everywhere(self):
   209	        with tempfile.TemporaryDirectory() as t:
   210	            kf = put(t, "runpod.txt", "rpa_SECRETSECRETSECRET\n")
   211	            rec = pv.build({"producer": {"script": "x.py", "argv": ["plain"]},
   212	                            "notes": {"log": "header Bearer rpa_SECRETSECRETSECRET end", "other": "MYSECRET99"},
   213	                            "status": {"process": "completed", "task": "not_applicable"}},
   214	                           repo_dir=t, secrets=pv.read_secrets([kf, put(t, "k2", "MYSECRET99")]))
   215	            s = json.dumps(rec)
   216	            self.assertNotIn("SECRETSECRET", s)
   217	            self.assertNotIn("MYSECRET99", s)
   218	
   219	
   220	class Code(unittest.TestCase):
   221	    def setUp(self):
   222	        self.tmp = tempfile.TemporaryDirectory()
   223	        self.r = Path(self.tmp.name)
   224	        git(self.r, "init", "-q")
   225	        git(self.r, "config", "user.email", "t@example.com")
   226	        git(self.r, "config", "user.name", "t")
   227	        put(self.r, "a.sh", "echo a\n")
   228	        put(self.r, "sub/b.py", "print(1)\n")
   229	        put(self.r, "other.txt", "o\n")
   230	        git(self.r, "add", "a.sh", "sub/b.py", "other.txt")
   231	        git(self.r, "commit", "-qm", "init")
   232	
   233	    def tearDown(self):
   234	        self.tmp.cleanup()
   235	
   236	    def test_clean(self):
   237	        c = pv.code_info(self.r)
   238	        self.assertRegex(c["commit"], r"^[0-9a-f]{40}$")
   239	        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
   240	
   241	    def test_dirty_modified_and_untracked(self):
   242	        put(self.r, "a.sh", "echo A\n")
   243	        put(self.r, "sub/new.py", "x\n")
   244	        c = pv.code_info(self.r)
   245	        self.assertTrue(c["dirty"])
   246	        self.assertEqual(c["dirty_files"], ["a.sh", "sub/new.py"])
   247	        self.assertRegex(c["diff_sha256"], r"^[0-9a-f]{64}$")
   248	        d1 = c["diff_sha256"]
   249	        put(self.r, "sub/new.py", "y\n")                     # まだ git にないファイルの中身も差分に入る
   250	        self.assertNotEqual(d1, pv.code_info(self.r)["diff_sha256"])

exec
/bin/zsh -lc "nl -ba provenance.py | sed -n '75,215p'; nl -ba provenance.py | sed -n '384,455p'; rg -n 'def test_|read\\(|stderr|bytes' test_provenance.py" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
    75	
    76	
    77	# ---- 指紋 ----
    78	def _skip(name):
    79	    """フォルダもファイルも数えない名前 (files-v1・v2 とも)"""
    80	    return name.startswith("._") or name == ".cache" or name.startswith(".incoming_")
    81	
    82	
    83	def is_provenance_name(name):
    84	    """由来のファイルの名前か (train_provenance.json・*_provenance.json)"""
    85	    return name == "train_provenance.json" or name.endswith("_provenance.json")
    86	
    87	
    88	def _skip_file_v2(name):
    89	    """files-v2 でだけ数えないファイル: .DS_Store と由来のファイル"""
    90	    return name == ".DS_Store" or is_provenance_name(name)
    91	
    92	
    93	def _nfc(s):
    94	    return unicodedata.normalize("NFC", s)
    95	
    96	
    97	def _check_version(version):
    98	    if version not in FP_VERSIONS:
    99	        raise ValueError(f"指紋の版が違う: {version} ({' / '.join(FP_VERSIONS)})")
   100	
   101	
   102	def list_files(path, version=FP_VERSION):
   103	    """[(相対パス, バイト数, 本当のパス)] を相対パスのバイト順に. 読めないフォルダは OSError (黙って飛ばさない)
   104	
   105	    ファイル1つを渡したときは, 名前で数えないものでも, そのファイル1つの一覧にする (渡したものを測る)"""
   106	    _check_version(version)
   107	    skip_file = _skip_file_v2 if version == "files-v2" else (lambda name: False)
   108	    path = os.path.abspath(path)
   109	    if not os.path.lexists(path):
   110	        raise FileNotFoundError(path)
   111	    if not os.path.isdir(path) or os.path.islink(path):
   112	        return [(_nfc(os.path.basename(path)), os.lstat(path).st_size, path)]
   113	    out = []
   114	
   115	    def err(e):
   116	        raise e
   117	
   118	    for d, dirs, files in os.walk(path, onerror=err, followlinks=False):
   119	        dirs[:] = [x for x in dirs if not _skip(x)]
   120	        # リンクのフォルダは os.walk が dirs に入れるがたどらない. ファイルとして数える
   121	        for x in [x for x in dirs if os.path.islink(os.path.join(d, x))]:
   122	            dirs.remove(x)
   123	            files.append(x)
   124	        rel = os.path.relpath(d, path)
   125	        for f in files:
   126	            if _skip(f) or skip_file(f):
   127	                continue
   128	            full = os.path.join(d, f)
   129	            p = f if rel == "." else os.path.join(rel, f)
   130	            out.append((_nfc(p.replace(os.sep, "/")), os.lstat(full).st_size, full))
   131	    out.sort(key=lambda x: x[0].encode())
   132	    for a, b in zip(out, out[1:]):
   133	        if a[0] == b[0]:        # NFC にそろえると同じ名前になる別のファイル (並びが決まらない) は指紋にしない
   134	            raise ValueError(f"NFC で同じ名前になるファイルが2つある: {a[0]}")
   135	    return out
   136	
   137	
   138	def fingerprint(path, version=FP_VERSION):
   139	    """指紋 (既定 files-v2). version="files-v1" は前の作り方 (files-v1 の記録と同じ版で比べるときだけ使う)"""
   140	    files = list_files(path, version)
   141	    h = hashlib.sha256()
   142	    for p, n, _ in files:
   143	        h.update(f"{p}\t{n}\n".encode())
   144	    for p, n, full in files:
   145	        if p.lower().endswith(".json") and n < JSON_MAX and os.path.isfile(full) and not os.path.islink(full):
   146	            with open(full, "rb") as fh:
   147	                h.update(b"\0" + p.encode() + b"\0" + fh.read())
   148	    return version + ":" + h.hexdigest()
   149	
   150	
   151	def fingerprint_version(fp):
   152	    """"files-v2:<sha256>" → "files-v2". 形が違えば None"""
   153	    if isinstance(fp, str):
   154	        v, sep, d = fp.partition(":")
   155	        if sep and v in FP_VERSIONS and re.fullmatch(r"[0-9a-f]{64}", d):
   156	            return v
   157	    return None
   158	
   159	
   160	# ---- コード ----
   161	def _git(args, cwd):
   162	    r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, timeout=60)
   163	    if r.returncode != 0:
   164	        raise RuntimeError(f"git {' '.join(args[:2])}: {r.stderr.decode(errors='replace').strip()[:200]}")
   165	    return r.stdout
   166	
   167	
   168	def code_info(repo_dir=REPO_DIR, paths=None, name=REPO_NAME):
   169	    """コミット・変更の有無・変えたファイル・差分の sha256. paths を渡せば, そのパスの中だけを見る (まだ git にないファイルも数える)"""
   170	    info = {"repo": name, "commit": None, "dirty": None, "dirty_files": [], "untracked_backups": [],
   171	            "diff_sha256": None, "measured_at": now_iso()}
   172	    if paths:
   173	        info["paths"] = list(paths)
   174	    try:
   175	        info["commit"] = _git(["rev-parse", "HEAD"], repo_dir).decode().strip()
   176	        sel = ["--", *paths] if paths else []
   177	        st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
   178	        entries = [e for e in st.decode(errors="replace").split("\0") if e]
   179	        files, untracked, backups, i = [], [], [], 0
   180	        while i < len(entries):
   181	            e = entries[i]
   182	            code, f = e[:2], e[3:]
   183	            if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
   184	                i += 1
   185	            if code == "??" and is_untracked_backup(f):
   186	                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
   187	            else:
   188	                files.append(f)
   189	                if code == "??":
   190	                    untracked.append(f)
   191	            i += 1
   192	        files = sorted(set(files))
   193	        info["dirty"] = bool(files)
   194	        info["untracked_backups"] = sorted(set(backups))
   195	        info["dirty_files"] = files[:DIRTY_FILES_MAX]
   196	        if len(files) > DIRTY_FILES_MAX:
   197	            info["dirty_files_total"] = len(files)
   198	        h = hashlib.sha256(_git(["diff", "HEAD", "--binary", *sel], repo_dir))
   199	        for f in sorted(untracked):
   200	            full = os.path.join(repo_dir, f)
   201	            h.update(b"\0untracked\0" + f.encode() + b"\0")
   202	            if os.path.isfile(full) and not os.path.islink(full):
   203	                with open(full, "rb") as fh:
   204	                    h.update(hashlib.sha256(fh.read()).digest())
   205	        info["diff_sha256"] = h.hexdigest() if files else None
   206	    except Exception as e:      # git がない・リポジトリでないなど. 分からないことを残す
   207	        info["error"] = f"{type(e).__name__}: {e}"
   208	    return info
   209	
   210	
   211	def is_untracked_backup(path):
   212	    """git に入っていない控えのファイルとみなす名前か (名前に .bak を含む. 例 a.py.bak・a.py.bak_pre_LOG352_20261019)"""
   213	    return ".bak" in path.rstrip("/").rsplit("/", 1)[-1]
   214	
   215	
   384	
   385	# ---- 読む ----
   386	def read(path, warn=True):
   387	    """由来を読む. 読めなくても例外で止めない (data_manifest_rules.md 12-5. ②データセットを作るスクリプト・③実機の試行で使う)
   388	
   389	    path は由来のファイル (train_provenance.json など) か, キー provenance を中に持つ JSON (各回の JSON・index.json など).
   390	    戻り: {"state": ..., "path": path, "provenance": dict か None, "error": 読めないわけ か None}
   391	      state "ok"         由来あり (provenance に中身. 由来を書くときに失敗して error を持つ記録も, 読めたので ok)
   392	      state "missing"    ファイルがない・JSON にキー provenance がない (由来なし. 警告は出さない)
   393	      state "unreadable" 由来なし (読めない): 書きかけ・JSON が壊れている・schema_version が provenance/ でない など.
   394	                         warn=True なら標準エラーに警告を1行出す. この結果で「由来あり」と数えない"""
   395	    try:
   396	        path = os.fsdecode(os.fspath(path))     # bytes のパスも文字列にそろえる (codex 依頼56 R1)
   397	    except Exception as e:
   398	        return _unreadable({"state": "missing", "path": repr(path), "provenance": None, "error": None},
   399	                           f"パスとして読めない ({type(e).__name__})", warn)
   400	    res = {"state": "missing", "path": path, "provenance": None, "error": None}
   401	    try:
   402	        with open(path, "rb") as fh:
   403	            raw = fh.read()
   404	    except FileNotFoundError:
   405	        res["error"] = "ファイルがない"
   406	        return res
   407	    except Exception as e:      # 読めないファイル (権限・フォルダなど)
   408	        return _unreadable(res, f"{type(e).__name__}: {e}", warn)
   409	    try:
   410	        doc = json.loads(raw.decode("utf-8"))
   411	    except Exception as e:      # 書きかけ (途中で切れた)・壊れた JSON・UTF-8 でない
   412	        return _unreadable(res, f"JSON として読めない ({type(e).__name__}: {e})", warn)
   413	    rec = doc
   414	    if isinstance(doc, dict) and not _is_prov_schema(doc.get("schema_version")):
   415	        if "provenance" in doc:
   416	            rec = doc["provenance"]         # 既存の JSON に足したキー provenance (12-2)
   417	        elif not is_provenance_name(os.path.basename(path)):
   418	            res["error"] = "キー provenance がない"
   419	            return res
   420	    if not isinstance(rec, dict):
   421	        return _unreadable(res, f"由来が dict でない ({type(rec).__name__})", warn)
   422	    sv = rec.get("schema_version")
   423	    if not _is_prov_schema(sv):
   424	        return _unreadable(res, f"schema_version が provenance/ でない ({sv!r})", warn)
   425	    res.update(state="ok", provenance=rec)
   426	    return res
   427	
   428	
   429	def _is_prov_schema(sv):
   430	    return isinstance(sv, str) and sv.startswith("provenance/")
   431	
   432	
   433	def _unreadable(res, why, warn):
   434	    res.update(state="unreadable", error=why)
   435	    if warn:
   436	        try:                    # 標準エラーが閉じていても止めない (codex 依頼56 R1)
   437	            print(f"[WARN  ] 由来なし (読めない): {res['path']}: {why}"[:500].replace("\n", " "), file=sys.stderr)
   438	        except Exception:
   439	            pass
   440	    return res
   441	
   442	
   443	# ---- CLI ----
   444	def main(argv=None):
   445	    ap = argparse.ArgumentParser(description="由来 (provenance/1) を作る・指紋を出す")
   446	    sub = ap.add_subparsers(dest="cmd", required=True)
   447	    f = sub.add_parser("fingerprint")
   448	    f.add_argument("--version", choices=FP_VERSIONS, default=FP_VERSION, help="指紋の作り方 (既定 %(default)s)")
   449	    f.add_argument("path")
   450	    pr = sub.add_parser("prepare", help="始める時点のコードと入力の指紋を JSON で出す (spec の inputs を標準入力から)")
   451	    pr.add_argument("--data-root", default=None)
   452	    pr.add_argument("--repo-dir", default=REPO_DIR)
   453	    pr.add_argument("--code-path", action="append", default=None)
   454	    w = sub.add_parser("write")
   455	    w.add_argument("--data-root", default=None, help="r2r2r-data (inputs・outputs の path をここからの相対にする)")
17:from contextlib import redirect_stderr, redirect_stdout
27:    p.write_bytes(data if isinstance(data, bytes) else data.encode())
46:    def test_format_and_same_twice(self):
51:    def test_definition(self):
61:    def test_v2_skips_ds_store_and_provenance_files(self):
75:    def test_v2_same_after_writing_provenance_into_output(self):
82:    def test_version_helpers(self):
90:    def test_skips_agree_with_checkpoint_compares(self):
122:    def test_skips_dotunderscore_cache_incoming(self):
130:    def test_image_content_not_read_but_size_counts(self):
137:    def test_json_content_counts(self):
142:    def test_big_json_content_not_read(self):
148:    def test_new_file_counts(self):
153:    def test_nfd_names_same_as_nfc(self):
159:    def test_nfc_collision_raises(self):
171:    def test_symlink_not_followed(self):
178:    def test_single_file_and_missing(self):
186:    def test_unreadable_dir_raises(self):
198:    def test_argv(self):
204:    def test_secret_in_dict_key(self):
208:    def test_secret_file_values_removed_everywhere(self):
236:    def test_clean(self):
241:    def test_dirty_modified_and_untracked(self):
252:    def test_staged_and_renamed(self):
259:    def test_paths_limit(self):
266:    def test_untracked_backup_not_dirty_but_kept(self):
279:    def test_tracked_backup_change_is_dirty(self):
289:    def test_not_a_repo(self):
307:        with redirect_stderr(err):
308:            r = pv.read(p)
311:    def test_ok(self):
316:    def test_embedded_key(self):
322:    def test_missing_no_warning(self):
328:    def test_unreadable_returns_and_warns_one_line(self):
349:    def test_bytes_path_and_closed_stderr(self):
350:        # codex 依頼56 R1: bytes のパス・警告を出せない標準エラーでも例外にしない
352:        self.assertEqual(pv.read(os.fsencode(p), warn=False)["state"], "unreadable")
354:        self.assertEqual(pv.read(os.fsencode(self.d / "train_provenance.json"))["state"], "ok")
362:        old = sys.stderr
363:        sys.stderr = Broken()
365:            r = pv.read(p)
367:            sys.stderr = old
369:        self.assertEqual(pv.read(12345, warn=False)["state"], "unreadable")
371:    def test_unreadable_dir_and_no_warn(self):
377:        with redirect_stderr(err):
378:            r = pv.read(self.d / "x_provenance.json", warn=False)
400:    def test_build_fields(self):
414:    def test_prepare_snapshot_kept(self):
430:    def test_status_defaults(self):
435:    def test_write_no_overwrite_no_mkdir(self):
446:    def test_write_race_never_overwrites(self):
463:    def test_write_failure_removes_partial(self):
479:            with redirect_stdout(out), redirect_stderr(err):
485:    def test_cli_write(self):
495:    def test_cli_unmounted(self):
500:    def test_cli_bad_spec(self):
505:    def test_cli_fingerprint(self):

exec
/bin/zsh -lc "rg -n -A45 'lab_list_files\\(\\)' lab_lib.sh; sed -n '20,55p' pi05/openpi_addon/hf_upload_ckpt.py; git diff HEAD -- test_lab_train.sh" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
434:lab_list_files() {          # lab_list_files フォルダ → 「パス<TAB>バイト数」を名前順に (Mac 側. du は KIOXIA でずれるので stat の大きさ)
435-    # 数えない: .cache/ (hf download の印)・._* (exFAT が付ける)・.DS_Store・.gitattributes・train_state/
436-    python3 -I -c '
437-import os, sys
438-root = sys.argv[1]
439-out = []
440-def err(e):
441-    raise e                 # 読めないフォルダを黙って飛ばさない (一覧が足りないまま「合った」としない)
442-for d, dirs, files in os.walk(root, onerror=err):
443-    rel = os.path.relpath(d, root)
444-    if rel == ".":
445-        dirs[:] = [x for x in dirs if x not in (".cache", "train_state")]
446-    for f in files:
447-        if f.startswith("._") or f in (".DS_Store", ".gitattributes"):
448-            continue
449-        p = os.path.normpath(os.path.join(rel, f))
450-        out.append((p, os.path.getsize(os.path.join(d, f))))
451-for p, s in sorted(out, key=lambda x: x[0].encode()):
452-    print(f"{p}\t{s}")
453-' "$1"
454-}
455-
456-lab_watchdog_script() {     # Pod 側の上限の見張り (lab_train.sh が /workspace/r2r2r/lab_watchdog.sh に置き, setsid nohup で裏で回す)
457-    cat <<'WATCHDOG_EOF'
458-#!/bin/bash
459-# lab_watchdog.sh — 上限の時刻を過ぎたら, この Pod を自分で消す (lab_train.sh が置く. 依頼 49)
460-# Mac が止まって lab_train.sh が Pod を消せなくても, Pod が残り続けないための保険.
461-# 鍵は Mac から送らない. Runpod が Pod に入れる RUNPOD_POD_ID と Pod 用の鍵 RUNPOD_API_KEY を使う
462-# (ssh のシェルには入っていないことがあるので, 1 番のプロセスの環境と /etc/rp_environment からも探す). 鍵は書き出さない
463-F=${LAB_WD_DEADLINE:-/workspace/logs/.lab_deadline}     # 上限の時刻 (epoch). lab_train.sh が書き換えて延ばす
464-L=${LAB_WD_LOG:-/workspace/logs/watchdog.log}
465-RUNPOD_REST=${LAB_WD_API:-https://rest.runpod.io/v1}
466-penv() {
467-    { tr '\0' '\n' < /proc/1/environ; sed -E 's/^export //; s/"//g' /etc/rp_environment; } 2>/dev/null \
468-        | sed -n "s/^$1=//p" | head -1
469-}
470-while :; do
471-    d=$(cat "$F" 2>/dev/null)
472-    case "$d" in ''|*[!0-9]*) ;; *) [ "$(date +%s)" -ge "$d" ] && break ;; esac
473-    sleep "${LAB_WD_POLL:-60}"
474-done
475-echo "$(date '+%F %T') 上限の時刻を過ぎた. この Pod を消します" >> "$L"
476-id=${RUNPOD_POD_ID:-$(penv RUNPOD_POD_ID)}
477-key=${RUNPOD_API_KEY:-$(penv RUNPOD_API_KEY)}
478-[ -n "$id" ] || { echo "RUNPOD_POD_ID が見つからない. 消せない" >> "$L"; exit 1; }
479-# 通信が一時だめでも残らないよう, DELETE と runpodctl remove を間を置いてくり返す (runpodctl の出力は鍵を含まないが, 念のため捨てる)
import sys

SKIP_TOP = ("train_state",)
SKIP_REMOTE = (".gitattributes", "train_provenance.json")     # 由来は Mac の lab_train.sh があとで足す (依頼 54)


def local_files(root):
    """train_state/ を除くファイルの (パス, バイト数) を名前順に"""
    out = []

    def err(e):
        raise e             # 読めないフォルダを黙って飛ばさない

    for d, dirs, files in os.walk(root, onerror=err):
        rel = os.path.relpath(d, root)
        if rel == ".":
            dirs[:] = [x for x in dirs if x not in SKIP_TOP]
        for f in files:
            p = os.path.normpath(os.path.join(rel, f))
            out.append((p, os.path.getsize(os.path.join(d, f))))
    return sorted(out, key=lambda x: x[0].encode())


def remote_files(api, repo):
    """リポジトリのファイルの (パス, バイト数) を名前順に (.gitattributes・train_provenance.json は数えない)"""
    out = []
    for x in api.list_repo_tree(repo, recursive=True, repo_type="model"):
        size = getattr(x, "size", None)
        if size is None or x.path in SKIP_REMOTE:      # フォルダには size がない
            continue
        out.append((x.path, int(size)))
    return sorted(out, key=lambda x: x[0].encode())


def diff_lines(a, b, n=10):
    da, db = dict(a), dict(b)
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-zXSyPWKm' (errno=Operation not permitted)
git: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
git: error: couldn't create cache file '/tmp/xcrun_db-R42OkZvL' (errno=Operation not permitted)
diff --git a/test_lab_train.sh b/test_lab_train.sh
index 85683ea..bb13062 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -382,9 +382,10 @@ check "成功: provenance/1・ISO8601 (タイムゾーンつき)・lab_train.sh
 check "成功: 入力とコードは始める時点に測る・記録にも残す (codex R1)" '[ "$(jq -c .prov_start.code "$TD/lab_pod.json")" = "$(jq -c .code "$P")" ] && [ "$(pj ".inputs[0].measured_at <= .compute.started_at")" = true ] && [[ "$(pj ".inputs[2].sha256")" =~ ^[0-9a-f]{64}$ ]]'
 check "成功: code (r2r2r-scripts のコミット・dirty・見たパス)" '[ "$(pj .code.repo)" = r2r2r-scripts ] && [ "$(pj .code.commit)" = "$(git -C "$HERE" rev-parse HEAD)" ] && [[ "$(pj .code.dirty)" =~ ^(true|false)$ ]] && [ "$(pj ".code.paths | join(\",\")")" = "lab_train.sh,lab_lib.sh,provenance.py,pi05" ]'
 check "成功: Pod の openpi のコミットと変更" '[ "$(pj ".external_code[0].name")" = openpi ] && [ "$(pj ".external_code[0].commit")" = 215abfb1234567890abcdef1234567890abcdef0 ] && [ "$(pj ".external_code[0].dirty")" = true ] && [ "$(pj ".external_code[0].dirty_files[0]")" = src/openpi/training/config.py ]'
-check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v1:* ]]'
+check "成功: 入力は学習データと 3999 (r2r2r-data からの相対・指紋)" '[ "$(pj ".inputs[0] | [.kind,.id,.path] | join(\" \")")" = "dataset mecharm_redblock_box_teach_v1_trim2_corr_v3 datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3" ] && [ "$(pj ".inputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/datasets/mecharm_redblock_box_teach_v1_trim2_corr_v3")" ] && [ "$(pj ".inputs[1] | [.kind,.path] | join(\" \")")" = "checkpoint checkpoints/pi05_mecharm_trim_lora_v1/3999" ] && [[ "$(pj ".inputs[1].fingerprint")" == files-v2:* ]]'
 check "成功: 設定・compute (GPU・始めと終わり・時間・費用は [COST] と同じ)・numbering・status" '[ "$(pj .config.name)" = pi05_mecharm_trim_corr_lora ] && [ "$(pj .config.num_train_steps)" = 1500 ] && [ "$(pj .compute.gpu)" = "NVIDIA A40" ] && [ "$(pj .compute.cost_per_hr_usd)" = 0.44 ] && [ "$(pj .compute.duration_sec)" = "$(jq .rented_sec "$TD/lab_pod.json")" ] && [ -n "$(pj ".compute.started_at // empty")" ] && [ -n "$(pj ".compute.ended_at // empty")" ] && grep -qF "[COST  ] $(pj .compute.cost_line)" "$T/out.txt" && [ "$(pj .numbering.series)" = フィジカルAI研究室 ] && [ "$(pj .numbering.series_log)" = LogT ] && [ "$(pj ".status | [.process,.task] | join(\" \")")" = "completed not_applicable" ] && [ "$(pj ".error // empty")" = "" ]'
-check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v1:* ]]'
+check "成功: 出力 (置いたチェックポイントと指紋)" '[ "$(pj ".outputs[0].path")" = checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 ] && [[ "$(pj ".outputs[0].fingerprint")" == files-v2:* ]]'
+check "成功: 由来 (と .DS_Store) を置いたあとに測り直しても出力の指紋と合う (files-v2. 依頼 56)" '[ -f "$CK/1499/train_provenance.json" ] && [ -f "$CK/1499/.DS_Store" ] && [ "$(pj ".outputs[0].fingerprint")" = "$(python3 "$HERE/provenance.py" fingerprint "$CK/1499")" ] && [ "$(pj ".outputs[0].fingerprint")" != "files-v2:$(python3 "$HERE/provenance.py" fingerprint --version files-v1 "$CK/1499" | cut -d: -f2)" ]'
 # Pod の openpi を読むコマンドを, この Mac の bash で一時の git リポジトリに向けて流す (codex R1)
 oc=$(grep -F ": lt=openpi_rev" "$T/ssh.log" | head -1); G=$(mktemp -d)
 (cd "$G" && git init -q && git config user.email t@example.com && git config user.name t && echo a > f && git add f && git commit -qm i && echo b >> f && echo n > new)
@@ -459,6 +460,15 @@ rm -rf "$T"
 echo "# changed" >> "$G/lab_lib.sh"; echo z > "$G/pi05/new.py"; echo w >> "$G/other.txt"
 LAB_PROV_REPO="$G" run success
 check "dirty: code.dirty=true・変えたファイル (見るパスの中だけ)・diff_sha256" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = "lab_lib.sh,pi05/new.py" ] && [[ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" =~ ^[0-9a-f]{64}$ ]]'
+rm -rf "$T"
+# 依頼 56: git に入っていない控え (.bak を含む名前) だけなら dirty にしない. untracked_backups には残す
+(cd "$G" && git checkout -q -- lab_lib.sh other.txt && rm pi05/new.py && echo old > pi05/a.py.bak_pre_LOG352_20261019)
+LAB_PROV_REPO="$G" run success
+check "控えだけ: code.dirty=false・dirty_files なし・untracked_backups に控え (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = false ] && [ "$(jq -r ".code.dirty_files | length" "$TD/train_provenance.json")" = 0 ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ] && [ "$(jq -r .code.diff_sha256 "$TD/train_provenance.json")" = null ]'
+rm -rf "$T"
+echo "# changed" >> "$G/lab_lib.sh"
+LAB_PROV_REPO="$G" run success
+check "控えとほかの変更: code.dirty=true・dirty_files はほかの変更だけ・控えも残す (依頼 56)" '[ "$(jq -r .code.dirty "$TD/train_provenance.json")" = true ] && [ "$(jq -r ".code.dirty_files | join(\",\")" "$TD/train_provenance.json")" = lab_lib.sh ] && [ "$(jq -r ".code.untracked_backups | join(\",\")" "$TD/train_provenance.json")" = pi05/a.py.bak_pre_LOG352_20261019 ]'
 rm -rf "$T" "$G"
 OUT1=""
 

exec
/bin/zsh -lc "rg -n '12[-－]|files-v2|止めない|bak' /Users/shimazueiichi/r2r2r-content/methodology/data_manifest_rules.md" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
5:*最終更新: 2026-11-01（11節「番号の種類と書き方」・12節「出力に付ける由来（provenance）」を新設し、12節を実装に合わせて直した（指紋 files-v2・outputs・読めない由来の扱い）。台帳の列は変更なし）*
402:#### 12-1. 対象
407:#### 12-2. 置き方
413:#### 12-3. 項目（`schema_version` = `provenance/1`）
429:#### 12-4. 指紋（fingerprint）の作り方
431:- `files-v2:` ＋ sha256。中身は、フォルダ内のファイルを相対パスの順に並べた「相対パス・バイト数」の一覧と、1MB 未満の JSON（`index.json` など）の中身
436:#### 12-5. 守ること
438:- 未コミットの変更があっても止めない。`dirty: true` と変更のファイル名・指紋を残して進める（研究を止めないため）
440:- 由来を書く処理が失敗しても、本来の出力（学習・データ・試行）は止めない。そのときは `provenance` に `error` を書く
441:- 由来を読む側は、読めない記録（書いている途中で強制終了された・JSON が壊れている）を「由来なし（読めない）」として扱い、警告を出して処理は止めない。読めない記録で、そのデータやモデルを「由来あり」と数えない
446:- 2026-11-01（12節を実装に合わせて直した）: 依頼54（学習と checkpoint に由来を記録する・r2r2r-scripts のコミット 1902f37）の報告で Claude Code が挙げた5点を、エイイチの確定で決めた。できたものを書く `outputs` を足した。指紋を `files-v2` にし、`.DS_Store` と由来のファイルそのものを数えないことにした（Finder で開くだけで指紋が変わる・由来を置いたあとに測り直すと合わないため。`files-v1` の記録は書き換えない）。`series_log` は `Log18` の形。読めない由来は「由来なし（読めない）」として警告し、止めない。

exec
/bin/zsh -lc "sed -n '175,207p' provenance.py; sed -n '266,289p' test_provenance.py; sed -n '299,382p' test_provenance.py; rg -n 'DS_Store|ignore_patterns|upload_folder' pi05/openpi_addon/hf_upload_ckpt.py; head -45 lab_lib.sh" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
        info["commit"] = _git(["rev-parse", "HEAD"], repo_dir).decode().strip()
        sel = ["--", *paths] if paths else []
        st = _git(["status", "--porcelain=v1", "-z", "--untracked-files=all", *sel], repo_dir)
        entries = [e for e in st.decode(errors="replace").split("\0") if e]
        files, untracked, backups, i = [], [], [], 0
        while i < len(entries):
            e = entries[i]
            code, f = e[:2], e[3:]
            if code[0] in "RC":     # 名前を変えたものは次の項目が元の名前
                i += 1
            if code == "??" and is_untracked_backup(f):
                backups.append(f)       # git に入っていない控え: dirty には数えないが必ず残す (依頼 56)
            else:
                files.append(f)
                if code == "??":
                    untracked.append(f)
            i += 1
        files = sorted(set(files))
        info["dirty"] = bool(files)
        info["untracked_backups"] = sorted(set(backups))
        info["dirty_files"] = files[:DIRTY_FILES_MAX]
        if len(files) > DIRTY_FILES_MAX:
            info["dirty_files_total"] = len(files)
        h = hashlib.sha256(_git(["diff", "HEAD", "--binary", *sel], repo_dir))
        for f in sorted(untracked):
            full = os.path.join(repo_dir, f)
            h.update(b"\0untracked\0" + f.encode() + b"\0")
            if os.path.isfile(full) and not os.path.islink(full):
                with open(full, "rb") as fh:
                    h.update(hashlib.sha256(fh.read()).digest())
        info["diff_sha256"] = h.hexdigest() if files else None
    except Exception as e:      # git がない・リポジトリでないなど. 分からないことを残す
        info["error"] = f"{type(e).__name__}: {e}"
    def test_untracked_backup_not_dirty_but_kept(self):
        # 依頼 56: git に入っていない控え (.bak を含む名前) は dirty にしないが untracked_backups に必ず残す
        put(self.r, "sub/b.py.bak_pre_LOG352_20261019", "old\n")
        put(self.r, "a.sh.bak", "old\n")
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"], c["diff_sha256"]), (False, [], None))
        self.assertEqual(c["untracked_backups"], ["a.sh.bak", "sub/b.py.bak_pre_LOG352_20261019"])
        put(self.r, "sub/new.py", "x\n")                    # ほかの変更があれば dirty
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"]), (True, ["sub/new.py"]))
        self.assertEqual(len(c["untracked_backups"]), 2)
        self.assertEqual(pv.code_info(self.r, paths=["sub"])["untracked_backups"], ["sub/b.py.bak_pre_LOG352_20261019"])

    def test_tracked_backup_change_is_dirty(self):
        # git に入っている .bak を変えたもの・.bak を含むフォルダの中のふつうの名前は控えとみなさない
        put(self.r, "x.bak", "1\n")
        git(self.r, "add", "x.bak")
        git(self.r, "commit", "-qm", "bak")
        put(self.r, "x.bak", "2\n")
        put(self.r, "dir.bak/new.py", "x\n")
        c = pv.code_info(self.r)
        self.assertEqual((c["dirty"], c["dirty_files"], c["untracked_backups"]), (True, ["dir.bak/new.py", "x.bak"], []))

    def test_not_a_repo(self):
        self.d = Path(self.tmp.name)
        self.rec = pv.build({"status": {"process": "completed"}}, repo_dir=self.tmp.name)

    def tearDown(self):
        self.tmp.cleanup()

    def rd(self, p):
        err = io.StringIO()
        with redirect_stderr(err):
            r = pv.read(p)
        return r, err.getvalue()

    def test_ok(self):
        p = pv.write(self.rec, self.d / "train_provenance.json")
        r, err = self.rd(p)
        self.assertEqual((r["state"], r["provenance"], r["error"], err), ("ok", self.rec, None, ""))

    def test_embedded_key(self):
        # 既存の JSON に足したキー provenance (12-2). 外側に別の schema_version があってもよい
        put(self.d, "index.json", json.dumps({"schema_version": "dataset/2", "n": 3, "provenance": self.rec}))
        r, err = self.rd(self.d / "index.json")
        self.assertEqual((r["state"], r["provenance"], err), ("ok", self.rec, ""))

    def test_missing_no_warning(self):
        put(self.d, "old.json", '{"n": 1}')                  # 由来を入れる前の JSON
        for p in (self.d / "nothing.json", self.d / "old.json"):
            r, err = self.rd(p)
            self.assertEqual((r["state"], r["provenance"], err), ("missing", None, ""))

    def test_unreadable_returns_and_warns_one_line(self):
        full = json.dumps(self.rec, ensure_ascii=False, indent=2).encode()
        cases = {
            "half_provenance.json": full[: len(full) // 2],             # 書きかけ (途中で KILL)
            "empty_provenance.json": b"",
            "broken.json": b'{"provenance": {"schema_version": "provenance/1",,}',
            "notutf8_provenance.json": b"\xff\xfe{",
            "list_provenance.json": b"[1, 2]",
            "nosv_provenance.json": b'{"a": 1}',
            "badsv_provenance.json": b'{"schema_version": "other/1"}',
            "nulled.json": b'{"provenance": null}',
        }
        for name, data in cases.items():
            put(self.d, name, data)
            r, err = self.rd(self.d / name)
            self.assertEqual((r["state"], r["provenance"]), ("unreadable", None), name)
            self.assertTrue(r["error"], name)
            self.assertEqual(len(err.splitlines()), 1, name)
            self.assertIn("由来なし (読めない)", err)
            self.assertIn(name, err)

    def test_bytes_path_and_closed_stderr(self):
        # codex 依頼56 R1: bytes のパス・警告を出せない標準エラーでも例外にしない
        p = put(self.d, "x_provenance.json", "{}")
        self.assertEqual(pv.read(os.fsencode(p), warn=False)["state"], "unreadable")
        pv.write(self.rec, self.d / "train_provenance.json")
        self.assertEqual(pv.read(os.fsencode(self.d / "train_provenance.json"))["state"], "ok")

        class Broken:
            def write(self, s):
                raise OSError("closed")

            def flush(self):
                pass
        old = sys.stderr
        sys.stderr = Broken()
        try:
            r = pv.read(p)
        finally:
            sys.stderr = old
        self.assertEqual(r["state"], "unreadable")
        self.assertEqual(pv.read(12345, warn=False)["state"], "unreadable")

    def test_unreadable_dir_and_no_warn(self):
        r, err = self.rd(self.d)                             # フォルダを渡した (読めない)
        self.assertEqual(r["state"], "unreadable")
        self.assertEqual(len(err.splitlines()), 1)
        put(self.d, "x_provenance.json", "{")
        err = io.StringIO()
        with redirect_stderr(err):
            r = pv.read(self.d / "x_provenance.json", warn=False)
        self.assertEqual((r["state"], err.getvalue()), ("unreadable", ""))


class BuildWrite(unittest.TestCase):
13:3. upload_folder で 1 回のコミットで上げる
95:        api.upload_folder(folder_path=args.dir, repo_id=args.repo, repo_type="model",
96:                          ignore_patterns=[f"{t}/**" for t in SKIP_TOP] + [f"{t}/*" for t in SKIP_TOP],
# lab_lib.sh — lab_start.sh / lab_stop.sh / lab_train.sh が読む共通の関数 (単体では動かさない)
#
# 鍵の扱い:
#   - Runpod の API キーは curl のヘッダーを <(printf ...) で渡す (printf は bash の組み込みなので
#     ps にもシェルの履歴にも出ない)
#   - Hugging Face の鍵は ssh の標準入力で Pod の ~/.cache/huggingface/token に書く
#   - Pod の IP とポートは画面・ログに出さない. Pod に入る ssh の出力は lab_mask で <POD_IP>・<SSH_PORT> に置き換える

LAB_ROOT=/Volumes/KIOXIA/r2r2r-data/lab
LAB_CURRENT="$LAB_ROOT/.lab_current"      # 今使っている Log 名 (lab_stop.sh が --log なしで読む)
RUNPOD_KEY_FILE=/Volumes/KIOXIA/keys/runpod_api_key.txt
HF_TOKEN_FILE=/Volumes/KIOXIA/keys/hf_token.txt
POD_SSH_KEY="$HOME/.ssh/id_ed25519"
RUNPOD_API=https://rest.runpod.io/v1
TUNNEL_PORT=8000
YEN_PER_USD=158
RUNPOD_HTTP=""             # runpod_api が最後に受けた HTTP の番号 ($(...) の中で呼ぶと親には残らない)

lab_need_val() {            # lab_need_val 例の値 "$@" (引数を読むループの中で) → 値がない・次が --… なら「--x に値がない (例 --x 例)」を出して終了コード 2 (依頼 55)
    local ex="$1"; shift
    if [ $# -lt 2 ] || [[ "$2" == --* ]]; then
        echo "[ERROR] $1 に値がない (例 $1 $ex)" >&2; exit 2
    fi
}

lab_need_key() {            # 鍵のファイルがない・空なら, どこに書けばよいかを出して止まる
    local f="$1" what="$2"
    if [ ! -s "$f" ]; then
        echo "[ERROR] $what がありません (または空です)." >&2
        echo "        次のファイルに鍵だけを1行で書いてください: $f" >&2
        exit 1
    fi
}

runpod_api() {              # runpod_api METHOD PATH [JSON]  → 本文を出す. 失敗の HTTP は戻り値 1
    local method="$1" path="$2" data="${3:-}" out code
    out=$(mktemp)
    if [ -n "$data" ]; then
        code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" \
            -H @<(printf 'Authorization: Bearer %s\n' "$(tr -d '\r\n' < "$RUNPOD_KEY_FILE")") \
            -H 'Content-Type: application/json' --data "$data" "$RUNPOD_API$path") || code=000
    else
        code=$(curl -sS -o "$out" -w '%{http_code}' -X "$method" \
            -H @<(printf 'Authorization: Bearer %s\n' "$(tr -d '\r\n' < "$RUNPOD_KEY_FILE")") \
            "$RUNPOD_API$path") || code=000

codex
R1の警告出力とbytesパスの修正は、該当する例外経路を塞いでいます。整合テストも実際の一覧関数を使う形に改善されていますが、テスト側で除外を補っているため、比較処理全体の一致を保証するものではありません。必須の問題が残るか、規則と照合して詰めています。
exec
/bin/zsh -lc "sed -n '398,443p' /Users/shimazueiichi/r2r2r-content/methodology/data_manifest_rules.md; head -22 pi05/openpi_addon/hf_upload_ckpt.py; sed -n '86,104p' pi05/openpi_addon/hf_upload_ckpt.py" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 0ms:
### 12. 出力に付ける由来（provenance）（2026-11-01）

データ・モデルが「どのコード・どの入力・どの設定で、どう終わったか」を、作った時点で機械が読める形で残す。主指標の「再現可能」の条件2（依存関係とバージョンが記載されている）を、手書きの README に頼らず満たすため。

#### 12-1. 対象

- **これから作る出力だけ**。既存のデータ・JSON・checkpoint には書き足さない（過去の由来は索引の関係表と README でたどる）
- 段階的に入れる：①学習と checkpoint → ②データセットを作るスクリプト → ③実機の試行（`run_pick_place.py`・recorder など）。③は関節の制御に触れないが、実機の流れの中なので codex のレビューと dry-run を通してから使う

#### 12-2. 置き方

- 既存の出力 JSON（各回の JSON・`recording_meta.json`・データセットの `index.json`・lab の記録など）に、キー `provenance` を1つ足して中に入れる。由来のためだけの JSON を別に増やさない
- 例外：checkpoint は中に書ける JSON がない（`_CHECKPOINT_METADATA` は openpi の形式）。新しく置く checkpoint の各ステップのフォルダ（`checkpoints/<モデル名>/<ステップ>/`）に `train_provenance.json` を1つ置き、同じものをその回の `lab/Log{NN}/` にも写す。Hugging Face に上げるときも同じファイルを入れる
- **台帳の列は増やさない**（8-A の手順を踏まずに済ませるため）。台帳から各回の JSON へは、今までどおり `run_stem` でたどる

#### 12-3. 項目（`schema_version` = `provenance/1`）

| 項目 | 中身 |
|---|---|
| `schema_version` | `provenance/1`。項目を変えるときは版を上げる |
| `created_at` | 作った時刻（ISO8601・タイムゾーンつき） |
| `producer` | スクリプトのパス（r2r2r-scripts からの相対）・実行したコマンドの引数（秘密の値は除く）・実行した機械（Mac / Pod など） |
| `code` | `repo`（r2r2r-scripts）・`commit`・`dirty`（未コミットの変更があるか）・`dirty_files`（変更のあるファイル名）・`diff_sha256`（変更の中身の指紋） |
| `external_code` | 使った外部のコードごとに `name`・`commit`・`dirty`（例 openpi） |
| `inputs` | 入力ごとに `kind`（dataset / checkpoint / run / recording / config）・`id`（フォルダ名やモデル名）・`path`（r2r2r-data からの相対）・`fingerprint` |
| `outputs` | できたものごとに、`inputs` と同じ形（例：学習でできた checkpoint のステップのフォルダ） |
| `config` | 設定の名前と主な値（学習なら設定名・ステップ数・初期の重み・学習率など） |
| `compute` | 学習・推論でクラウドを借りたとき：借り先・GPU の型番・始めと終わりの時刻・時間・費用（MASTER「買う前に借りて測る」の記録） |
| `numbering` | `series`・`series_log`・`article_no`（11節。分からなければ空）。`series_log` は `Log18` のように `Log` を付けた形で書く（数字だけにしない） |
| `status` | `process`（completed / interrupted / failed：処理が最後まで動いたか）と `task`（success / failure / unknown / not_applicable：タスクが成功したか）を分けて書く |

#### 12-4. 指紋（fingerprint）の作り方

- `files-v2:` ＋ sha256。中身は、フォルダ内のファイルを相対パスの順に並べた「相対パス・バイト数」の一覧と、1MB 未満の JSON（`index.json` など）の中身
- 大きなファイル（画像・動画・重み）は中身を読まない。数GBのものを毎回読むと出力が遅くなるため
- 数えないもの：`._` で始まるファイル（exFAT の印）・`.DS_Store`（Finder で開くだけで作られる）・`.cache`・一時フォルダ（`.incoming_*`）・由来のファイルそのもの（`train_provenance.json`・`*_provenance.json`）。由来のファイルを置いたあとに測り直しても、同じ指紋になるようにするため
- 2026-11-01 までの実装は `files-v1`（`.DS_Store` と由来のファイルを数えていた）。`files-v1` で書かれた記録は書き換えず、そのまま残す。比べるときは、同じ版の指紋どうしで比べる

#### 12-5. 守ること

- 未コミットの変更があっても止めない。`dirty: true` と変更のファイル名・指紋を残して進める（研究を止めないため）
- 鍵・トークン・パスワードは書かない。コマンドの引数に秘密が入る場合は値を伏せる
- 由来を書く処理が失敗しても、本来の出力（学習・データ・試行）は止めない。そのときは `provenance` に `error` を書く
- 由来を読む側は、読めない記録（書いている途中で強制終了された・JSON が壊れている）を「由来なし（読めない）」として扱い、警告を出して処理は止めない。読めない記録で、そのデータやモデルを「由来あり」と数えない
- 書き方は共通の部品（r2r2r-scripts の `provenance.py`）にまとめ、各スクリプトはそれを呼ぶ

#!/usr/bin/env python3
"""学習の最後のチェックポイントを Hugging Face の非公開リポジトリへ上げる (Pod で動かす. 2026-10-31 依頼 49).

lab_train.sh が学習のあとに Pod で流す:
  python3 hf_upload_ckpt.py --dir /workspace/openpi/checkpoints/<設定>/<exp>/<ステップ> --repo PixelstoMotors/<名前>

上げるもの: train_state/ 以外の全部 (params/・assets/・_CHECKPOINT_METADATA). リポジトリの一番上に置く
(lab_start.sh の 3c が hf download で /workspace/ckpt/3999 に取る 3999 と同じ形).
鍵は hf が読む ~/.cache/huggingface/token (lab_start.sh / lab_train.sh の 3b が置く). 鍵は出さない.

1. リポジトリを非公開で作る (あればそのまま). 非公開でなければ上げずに止まる
2. リポジトリにもうファイルがあれば, 上げるものと名前・バイト数が全部同じなら上げない (same). 違えば止まる
3. upload_folder で 1 回のコミットで上げる
4. リポジトリの一覧 (名前・バイト数) が手元と全部合うかを確かめる

最後の行: HFUP <結果> <説明>. 終了コード 0 = ok / same, 3 = not_private, 4 = other_content, 5 = mismatch, 6 = error
"""
import argparse
import os
import sys

SKIP_TOP = ("train_state",)
        api.create_repo(args.repo, repo_type="model", private=True, exist_ok=True)
        if not api.model_info(args.repo).private:
            return done("not_private", f"{args.repo} が非公開になっていない. 上げずに止めた", 3)
        have = remote_files(api, args.repo)
        if have:
            if have == mine:
                return done("same", f"{args.repo} にはもう同じ中身がある ({len(mine)} ファイル). 上げ直さない", 0)
            print("\n".join(diff_lines(mine, have)), flush=True)
            return done("other_content", f"{args.repo} に別の中身がある. 上書きしないで止めた", 4)
        api.upload_folder(folder_path=args.dir, repo_id=args.repo, repo_type="model",
                          ignore_patterns=[f"{t}/**" for t in SKIP_TOP] + [f"{t}/*" for t in SKIP_TOP],
                          commit_message=args.message or f"upload {os.path.basename(os.path.normpath(args.dir))}")
        have = remote_files(api, args.repo)
    except Exception as e:      # 鍵は例外の文に入らない (hf はヘッダーの中身を出さない)
        return done("error", f"{type(e).__name__}: {str(e).splitlines()[0] if str(e) else ''}", 6)
    if have != mine:
        print("\n".join(diff_lines(mine, have)), flush=True)
        return done("mismatch", f"上げたあとのリポジトリの一覧が手元と合わない", 5)
    return done("ok", f"{args.repo} に {len(mine)} ファイル {total:,} バイトを上げ, 名前・バイト数が全部合うのを確かめた", 0)

exec
/bin/zsh -lc "python3 -B -c 'import io; from unittest.mock import patch; import provenance as p
class Broken:
 def write(self,s): raise OSError(\"closed\")
 def flush(self): pass
for path,raw,state in [(b\"x_provenance.json\",b\"{}\",\"unreadable\"),(b\"train_provenance.json\",b\"{\\\"schema_version\\\":\\\"provenance/1\\\"}\",\"ok\"),(\"index.json\",b\"{\\\"schema_version\\\":\\\"dataset/2\\\",\\\"provenance\\\":{\\\"schema_version\\\":\\\"provenance/1\\\"}}\",\"ok\")]:
 with patch(\"builtins.open\",return_value=io.BytesIO(raw)),patch(\"sys.stderr\",Broken()):
  r=p.read(path); assert r[\"state\"]==state; assert isinstance(r[\"path\"],str)
assert p.read(12345,warn=False)[\"state\"]==\"unreadable\"
print(\"memory-only checks: OK\")'" in /Users/shimazueiichi/r2r2r-scripts
 succeeded in 29ms:
python3: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
python3: error: couldn't create cache file '/tmp/xcrun_db-5Qom5GyQ' (errno=Operation not permitted)
python3: warning: confstr() failed with code 5: couldn't get path of DARWIN_USER_TEMP_DIR; using /tmp instead
python3: error: couldn't create cache file '/tmp/xcrun_db-MEEgxNFv' (errno=Operation not permitted)
memory-only checks: OK

codex
**R2判定：必須0件、推奨1件です。** R1の警告・bytesパスの問題は修正されています。対象差分に、新たな必須修正は見つかりませんでした。

| 項目 | 判定 | 確認結果・修正案 |
|---|---|---|
| `read()`の警告出力 | 据え置き | `_unreadable()`が標準エラーへの出力失敗を捕捉し、`unreadable`を返します。R1の例外漏れは解消。出力先が壊れたケースをメモリ上のモックでも確認しました。 |
| bytes・不正なパス | 据え置き | `os.fsdecode(os.fspath(path))`による統一は妥当です。bytesパスでの正常・不正記録と、整数を渡した場合の`unreadable`をモックで確認しました。 |
| `read()`の分類 | 据え置き | JSON破損・非UTF-8・非dict・不正schema・フォルダは由来なしとして扱います。外側に別schemaのある埋め込み由来も読めます。通常のファイル／JSON入力について、追加の例外漏れは見当たりません。 |
| 比較との整合テスト | **推奨** | [test_provenance.py:90](/Users/shimazueiichi/r2r2r-scripts/test_provenance.py:90) は実関数を使う形に改善されています。ただし119行はHF側の`.DS_Store`をテスト側で除き、120行は`compare_with_pod`の除外を再実装しています。コメントの「どれも変わらない」は実際の戻り値とは異なります。「各経路の除外を適用した後に一致」と説明を直すことを推奨します。`compare_with_pod`自体の回帰検知は、このテスト単独では保証しません。 |
| files-v2の除外 | 据え置き | 12-4と合致します。指定名のファイルを全階層で除外し、従来の除外も維持。単一ファイル指定では測定する例外も依頼どおりです。 |
| 由来配置後の再測定 | 据え置き | 独立した`train_provenance.json`等の配置では変わりません。`index.json`等への埋め込みでは変わることも説明済みです。②③へ展開する際の測定順序は別途確定が必要です。 |
| `untracked_backups` | 据え置き | `??`かつ最後の名前に`.bak`を含むものだけ分離します。追跡済み`.bak`の変更や`dir.bak/new.py`はdirtyに残ります。控え一覧は省略されず、差分指紋からも除外されます。確定した名前規則を超える見落としは確認できませんでした。 |
| 既存比較との除外範囲 | 据え置き | 除外集合全体は一致しません。`.gitattributes`・`train_state/`等の既存差や、深い階層の由来を比較側が数える差は残ります。今回のトップ階層のcheckpoint由来追加を妨げる問題ではありません。 |
| files-v1保持・注記削除 | 据え置き | 過去記録と同じ版で比較するためv1保持は妥当です。checkpointの由来ファイルがv2で除外されるため、`fingerprint_note`削除も問題ありません。 |

規則12節とR1の「反映」、指定4ファイルの差分を確認しました。**42件のテスト本体は書込みを伴うため未実行**で、成功件数は未追試です。確認実行はメモリ上のモックのみで、ファイル変更・実機・Pod・Hugging Faceの操作はしていません。

## 反映
- 推奨「比較との整合テスト」の説明: テストの説明を「各経路の除き方を当てたあとで一致する・compare_with_pod そのものは test_lab_train.sh で確かめる」に直した（中身は変えていない）
- 必須 0 件のため、レビューはここで終える
```
