#!/usr/bin/env python3 """推論の記録 (runs/pi05_policy/) の判定ファイルを読む・足す共通の部品 (依頼 183). 判定ファイル: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy_verdicts_v1.csv (MASTER の保存先テーブル) - 追記だけ. 既存の行は書き換えない. 同じ run_stem はいちばん新しい行 (ファイルの後ろの行) が正 - labeler=ai の行は未確定. 確定は run_input・人の行 (human / human_fixed_ai / human_only)・rule (依頼 185. run_input は推論を終えたときにエイイチが y/n を打った判定なので人の確定に数える. rule はエイイチの決めで機械的に付けた行: 送ったチャンク 0 → not_applicable, 素材が足りない → not_judgeable) - 成功率は確定した回の success / (success + failure). not_applicable・not_judgeable・unknown は分母に入れない - 書く前に KIOXIA のマウントを確かめる. なければ止める (ローカルに逃がさない) 元の記録 (runs/pi05_policy/ の中身) は読むだけ. """ import csv import fcntl import io import json import os import sys from datetime import datetime from pathlib import Path KIOXIA_MOUNT = Path("/Volumes/KIOXIA") DATA_ROOT = KIOXIA_MOUNT / "r2r2r-data" RUNS_DIR = DATA_ROOT / "runs" / "pi05_policy" VERDICTS_CSV = DATA_ROOT / "runs" / "pi05_policy_verdicts_v1.csv" COLUMNS = ["labeled_at", "run_stem", "pick", "place", "outcome", "labeler", "ai_outcome", "ai_reason", "evidence", "note"] PICK_PLACE = ("yes", "no", "unknown") PLACE_VALUES = PICK_PLACE + ("not_applicable",) # つかめず place まで行かなかった回 (依頼 193. pick には足さない) OUTCOMES = ("success", "failure", "unknown", "not_judgeable", "not_applicable") AI_OUTCOMES = ("success", "failure", "unknown", "not_judgeable") # AI が付けてよい outcome (not_applicable は rule だけ) LABELERS = ("run_input", "ai", "human", "human_fixed_ai", "human_only", "rule") HUMAN_LABELERS = ("human", "human_fixed_ai", "human_only") CONFIRMED_LABELERS = ("run_input",) + HUMAN_LABELERS + ("rule",) # 確定の扱い (依頼 185) RULE_NOTE = "エイイチの決め 2026-11-14 依頼185" EVIDENCES = ("top.mp4", "frames", "both", "") MAX_NOTE = 500 MAX_REASON = 2000 class NotMounted(RuntimeError): pass def ensure_ssd_mounted(csv_path=VERDICTS_CSV): """KIOXIA の下に書くときはマウントを確かめる. なければ NotMounted (ローカルへは書かない). テストで KIOXIA の外の一時ファイルに書くときは確かめない.""" p = Path(csv_path) if str(p).startswith(str(KIOXIA_MOUNT) + os.sep) or p == KIOXIA_MOUNT: if not os.path.ismount(str(KIOXIA_MOUNT)): raise NotMounted(f"KIOXIA がマウントされていません ({KIOXIA_MOUNT}). 書かずに止めます") if not p.parent.is_dir(): raise NotMounted(f"書き込み先のフォルダがありません ({p.parent}). 書かずに止めます") def now_iso(): return datetime.now().astimezone().isoformat(timespec="seconds") def validate_row(row): r = {c: ("" if row.get(c) is None else str(row.get(c))) for c in COLUMNS} # 長すぎる値は切る (csv の読み取りの上限を超えると以後読めなくなる. codex 3 回目 R3) for c, n in (("note", MAX_NOTE), ("ai_reason", MAX_REASON)): if len(r[c]) > n: r[c] = r[c][:n] + "…" if not r["run_stem"]: raise ValueError("run_stem が空です") if r["pick"] not in PICK_PLACE: raise ValueError(f"pick は {PICK_PLACE} のどれか: {r['pick']!r}") if r["place"] not in PLACE_VALUES: raise ValueError(f"place は {PLACE_VALUES} のどれか: {r['place']!r}") if r["outcome"] not in OUTCOMES: raise ValueError(f"outcome は {OUTCOMES} のどれか: {r['outcome']!r}") if r["labeler"] not in LABELERS: raise ValueError(f"labeler は {LABELERS} のどれか: {r['labeler']!r}") if r["ai_outcome"] not in OUTCOMES + ("",): raise ValueError(f"ai_outcome は {OUTCOMES} のどれか か空: {r['ai_outcome']!r}") if r["evidence"] not in EVIDENCES: raise ValueError(f"evidence は {EVIDENCES} のどれか: {r['evidence']!r}") if not r["labeled_at"]: r["labeled_at"] = now_iso() return r class BrokenCsv(RuntimeError): pass def _check_existing(data): """追記の前に今のファイルを確かめる. 見出しがちがう・最後が改行で終わらない・列の数がちがう行があれば止める (そのまま足すと既存の最後の行に続けて書いてしまう. codex R4).""" if not data: return [] if not data.endswith(b"\n"): raise BrokenCsv("判定ファイルの最後が改行で終わっていません (書きかけの行がある). 書かずに止めます") try: # strict: 閉じていない引用符も止める (codex 2 回目 R1) rows = list(csv.reader(io.StringIO(data.decode("utf-8"), newline=""), strict=True)) except (csv.Error, UnicodeDecodeError) as e: raise BrokenCsv(f"判定ファイルを読めません ({e}). 書かずに止めます") if not rows or rows[0] != COLUMNS: raise BrokenCsv("判定ファイルの見出しがちがいます. 書かずに止めます") for i, r in enumerate(rows[1:], 2): if len(r) != len(COLUMNS): raise BrokenCsv(f"判定ファイルの {i} 行目の列の数が {len(r)} です ({len(COLUMNS)} のはず). 書かずに止めます") return [dict(zip(COLUMNS, r)) for r in rows[1:]] def append_rows(rows, csv_path=VERDICTS_CSV, skip_if_labeled_by=None, forbid_dirs=()): """行を末尾に足すだけ. 既存のバイトは触らない (追記モードで開く). 見出しはファイルが空のときだけ書く. skip_if_labeled_by: この labeler の行がすでにある run_stem の行は足さない. 確かめはロックの中で行う (seed が人の確定のあとに AI の行を足して未確定へ戻さないため. codex R2). forbid_dirs: この中には書かない (使っている記録のフォルダ. codex 2 回目 R3). 書いた行を返す.""" rows = [validate_row(r) for r in rows] ensure_ssd_mounted(csv_path) if not rows: return [] p = Path(csv_path) if (p.suffix != ".csv" or within(RUNS_DIR, p) or within(DATA_ROOT / "datasets", p) or any(within(d, p) for d in forbid_dirs)): raise BrokenCsv(f"判定ファイルとして書けない場所です ({p}). 元の記録・datasets の中や .csv でないファイルには書きません") if p.is_symlink(): raise BrokenCsv(f"判定ファイルがシンボリックリンクです ({p}). 書かずに止めます") fd = os.open(str(p), os.O_RDWR | os.O_APPEND | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o644) with os.fdopen(fd, "r+b") as f: fcntl.flock(f, fcntl.LOCK_EX) try: f.seek(0) existing = _check_existing(f.read()) if skip_if_labeled_by: done = {r["run_stem"] for r in existing if r.get("labeler") in skip_if_labeled_by} rows = [r for r in rows if r["run_stem"] not in done] if not rows: return [] buf = io.StringIO() w = csv.DictWriter(buf, fieldnames=COLUMNS, lineterminator="\n") if not existing and f.seek(0, os.SEEK_END) == 0: w.writeheader() for r in rows: w.writerow(r) f.write(buf.getvalue().encode("utf-8")) f.flush() os.fsync(f.fileno()) finally: fcntl.flock(f, fcntl.LOCK_UN) return rows def within(root, path): """path がリンクをたどっても root の中にあるか (codex R6).""" try: Path(path).resolve().relative_to(Path(root).resolve()) return True except (ValueError, OSError): return False def read_rows(csv_path=VERDICTS_CSV): p = Path(csv_path) if not p.exists(): return [] with open(p, newline="", encoding="utf-8") as f: return list(csv.DictReader(f)) def latest_by_stem(rows, labelers=None): """run_stem ごとにいちばん新しい行 (ファイルの後ろの行). labelers を渡すとその種類の行だけから選ぶ.""" out = {} for r in rows: if labelers is not None and r.get("labeler") not in labelers: continue out[r["run_stem"]] = r return out def current_by_stem(rows): """run_stem ごとの今の判定. 確定の行があればそのいちばん新しい行, なければいちばん新しい行 (AI など). 確定のあとに足された AI の行で未確定へ戻らない.""" out = latest_by_stem(rows) out.update(latest_by_stem(rows, labelers=CONFIRMED_LABELERS)) return out def success_rate(rows): """確定した回だけの成功率. 分母は success + failure (not_applicable・not_judgeable・unknown は入れない). {"success", "failure", "denominator", "rate" (分母 0 なら None), "confirmed", "by_outcome"}.""" conf = latest_by_stem(rows, labelers=CONFIRMED_LABELERS) by = {} for r in conf.values(): by[r["outcome"]] = by.get(r["outcome"], 0) + 1 s, f = by.get("success", 0), by.get("failure", 0) return {"success": s, "failure": f, "denominator": s + f, "rate": (s / (s + f)) if s + f else None, "confirmed": len(conf), "by_outcome": by} def rule_row(run_stem, n_exec, ai_row): """エイイチの決め (2026-11-14 依頼 185) に当たる回の labeler=rule の行. 当たらなければ None. 決め 2: 送ったチャンク 0 (アームを動かしていない) → not_applicable. 2 と 3 の両方に当たれば 2 決め 3: AI が frames/ だけで not_judgeable とした回・素材がなく API を呼ばなかった回 → not_judgeable""" a = ai_row or {} if n_exec == 0: oc, note = "not_applicable", f"{RULE_NOTE} 決め2: 送ったチャンク 0 (アームを動かしていない・試行になっていない)" elif a.get("outcome") == "not_judgeable" and a.get("evidence") in ("frames", ""): src = "frames/ だけ" if a.get("evidence") == "frames" else "素材なし (API を呼んでいない)" oc, note = "not_judgeable", f"{RULE_NOTE} 決め3: AI が not_judgeable ({src}). 人が見ずに確定" else: return None return {"labeled_at": now_iso(), "run_stem": run_stem, "pick": "unknown", "place": "unknown", "outcome": oc, "labeler": "rule", "ai_outcome": a.get("outcome", ""), "ai_reason": a.get("ai_reason", ""), "evidence": a.get("evidence", ""), "note": note} def human_labeler_for(outcome, ai_row): """人が付けたときの labeler. AI の行がなければ human_only, 同じなら human, 違えば human_fixed_ai.""" if not ai_row: return "human_only" return "human" if ai_row.get("outcome") == outcome else "human_fixed_ai" # ---- 元の記録を読む (読むだけ) ---- def list_run_stems(runs_dir=RUNS_DIR): return sorted(d.name for d in Path(runs_dir).iterdir() if d.is_dir() and d.name.startswith("pi05_") and within(runs_dir, d)) def safe_file(run_dir, *rel): """記録の中のファイル. リンクをたどって記録の外へ出るもの・ないものは None (codex 2 回目 R4).""" p = Path(run_dir).joinpath(*rel) return p if p.is_file() and within(run_dir, p) else None def read_run_json(run_dir): p = safe_file(run_dir, f"{Path(run_dir).name}.json") if p is None: return None try: return json.loads(p.read_text(encoding="utf-8")) except Exception: return None def frame_files(run_dir): d = Path(run_dir) / "frames" if not d.is_dir(): return [] return sorted(f.name for f in d.iterdir() if f.suffix.lower() == ".jpg" and not f.name.startswith("._") and within(run_dir, f)) def read_log(run_dir): p = safe_file(run_dir, "log.jsonl") out = [] if p is None: return out for line in p.read_text(encoding="utf-8", errors="replace").splitlines(): line = line.strip() if not line: continue try: out.append(json.loads(line)) except Exception: pass return out def executed_chunks(log): """送ったチャンクの数: log.jsonl の chunk のある行のうち executed が True の行 (log.jsonl がなければ 0).""" return sum(1 for x in log if "chunk" in x and str(x.get("executed")) == "True") def has_top_mp4(run_dir): return safe_file(run_dir, "recording", "top.mp4") is not None def run_input_row(run_stem, run_json): """{run_stem}.json の outcome を写した行 (labeler=run_input). outcome がない / null なら None.""" if not run_json or run_json.get("outcome") in (None, ""): return None oc = run_json["outcome"] if oc not in OUTCOMES: oc = "unknown" jd = run_json.get("judgments") or {} def pv(k): v = (jd.get(k) or {}).get("verdict") return v if v in PICK_PLACE else "unknown" return {"labeled_at": run_json.get("ended_at") or now_iso(), "run_stem": run_stem, "pick": pv("pick"), "place": pv("place"), "outcome": oc, "labeler": "run_input", "ai_outcome": "", "ai_reason": "", "evidence": "", "note": "{run_stem}.json の outcome を写した (記録したときの入力)"} if __name__ == "__main__": rows = read_rows() lat = latest_by_stem(rows) print(f"{VERDICTS_CSV}: {len(rows)} 行 / {len(lat)} 回") sys.exit(0)