# 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 件のため、レビューはここで終える
