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

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

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