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

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

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