commit 31113c0d27bfb195a1577d5becde7f61f89520f0
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 14 22:45:17 2026 +0900

    判定の決め 3 つを判定ファイルと画面に入れる (依頼 185・エイイチの決め 2026-11-14). verdicts.py に outcome=not_applicable と labeler=rule を足し, 確定の扱いを run_input・human・human_fixed_ai・human_only・rule にする (CONFIRMED_LABELERS. 行は書き換えない). current_by_stem (確定の行があればそれ. 後ろの AI の行で未確定へ戻らない)・success_rate (確定した回の success / (success + failure). not_applicable・not_judgeable・unknown は分母に入れない)・rule_row (決め 2: 送ったチャンク 0 → not_applicable. 決め 3: AI が frames/ だけ・素材なしで not_judgeable → not_judgeable. 両方なら 2. note に「エイイチの決め 2026-11-14 依頼185」と決めの番号). seed_verdicts.py に --rule (送ったチャンクは log.jsonl の chunk の行で executed が True の数. 確定の行がある回には足さない・ロックの中でも確かめる). AI は not_applicable を付けない・確定した回に AI の行を足さない. 画面: 既定の一覧を「まだ確定していない回」に, 上に成功率と分母に入れない数. 判定ファイルに rule 84 行 (決め 2 51・決め 3 33・重なり 44) を追記. 未確定 24 回・成功率 41/68 = 60.3%. テスト test_verdicts 39 (依頼 185. codex 1 回・指摘なし)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---
 pi05/verdict_viewer/index.html       | 23 +++++++--
 pi05/verdict_viewer/seed_verdicts.py | 50 +++++++++++++++++--
 pi05/verdict_viewer/server.py        | 13 +++--
 pi05/verdict_viewer/test_verdicts.py | 97 ++++++++++++++++++++++++++++++++++++
 pi05/verdict_viewer/verdicts.py      | 50 +++++++++++++++++--
 5 files changed, 216 insertions(+), 17 deletions(-)

diff --git a/pi05/verdict_viewer/index.html b/pi05/verdict_viewer/index.html
index 461bab8..f9008e1 100644
--- a/pi05/verdict_viewer/index.html
+++ b/pi05/verdict_viewer/index.html
@@ -18,6 +18,8 @@
   #side header { padding: 10px; border-bottom: 1px solid var(--border); }
   #side h1 { font-size: 14px; margin: 0 0 6px; }
   #filter { width: 100%; font-size: 13px; padding: 3px; }
+  #rate { margin-top: 6px; font-size: 13px; }
+  #rate b { font-size: 16px; }
   #count { color: var(--text-secondary); margin-top: 4px; font-size: 12px; }
   #list { overflow-y: auto; flex: 1; }
   .item { padding: 5px 10px; border-bottom: 1px solid var(--border); cursor: pointer; display: flex; justify-content: space-between; gap: 6px; }
@@ -28,7 +30,7 @@
   .tag { font-size: 11px; padding: 0 5px; border-radius: 3px; border: 1px solid var(--border); white-space: nowrap; align-self: center; }
   .tag.success { color: var(--ok); border-color: var(--ok); }
   .tag.failure { color: var(--bad); border-color: var(--bad); }
-  .tag.unknown, .tag.not_judgeable { color: var(--warn); border-color: var(--warn); }
+  .tag.unknown, .tag.not_judgeable, .tag.not_applicable { color: var(--warn); border-color: var(--warn); }
   .tag.ai { border-style: dashed; }
   #main { flex: 1; overflow-y: auto; padding: 12px 16px; }
   #title { font-family: ui-monospace, Menlo, monospace; font-size: 15px; font-weight: 600; }
@@ -69,11 +71,12 @@
   <header>
     <h1>推論の記録の判定</h1>
     <select id="filter">
-      <option value="todo">人の確定がまだの回</option>
+      <option value="todo">まだ確定していない回</option>
       <option value="all">全部</option>
       <option value="failure">失敗だけ (今の判定)</option>
       <option value="diff">AI と人が違う回</option>
     </select>
+    <div id="rate"></div>
     <div id="count"></div>
   </header>
   <div id="list"></div>
@@ -109,8 +112,8 @@
   <div id="chart"></div>
 </div>
 <script>
-const OC_JA = {success: "成功", failure: "失敗", unknown: "分からない", not_judgeable: "判定できない", "": "なし"};
-const LB_JA = {run_input: "記録時の入力", ai: "AI", human: "人 (AI と同じ)", human_fixed_ai: "人 (AI を直した)", human_only: "人 (AI なし)", "": ""};
+const OC_JA = {success: "成功", failure: "失敗", unknown: "分からない", not_judgeable: "判定できない", not_applicable: "試行でない", "": "なし"};
+const LB_JA = {run_input: "記録時の入力", ai: "AI", human: "人 (AI と同じ)", human_fixed_ai: "人 (AI を直した)", human_only: "人 (AI なし)", rule: "決め (依頼185)", "": ""};
 const COLORS = ["--s1", "--s2", "--s3", "--s4", "--s5", "--s6"].map(v => getComputedStyle(document.documentElement).getPropertyValue(v).trim());
 const pendingOps = {};  // run_stem → 書けたか分からない操作 (回ごとに残す. codex 6 回目 R1)
 let selSeq = 0;  // 応答が届かなかった操作. 同じ中身で押し直したら同じ op_id で送る (codex 2 回目 R2)
@@ -125,7 +128,7 @@ function applyFilter() {
   const f = $("#filter").value;
   view = runs.filter(r => f === "all" ? true : f === "todo" ? !r.confirmed : f === "failure" ? r.outcome === "failure"
                           : (r.ai_outcome && r.human_outcome && r.ai_outcome !== r.human_outcome));
-  $("#count").textContent = `${view.length} 回 / 全 ${runs.length} 回 (人の確定済み ${runs.filter(r => r.confirmed).length})`;
+  $("#count").textContent = `${view.length} 回 / 全 ${runs.length} 回 (確定済み ${runs.filter(r => r.confirmed).length})`;
   $("#list").innerHTML = view.map(r => `<div class="item ${cur === r.run_stem ? "sel" : ""}" data-stem="${r.run_stem}">
       <div><div class="stem">${r.run_stem.replace("pi05_", "")}</div>
       <div class="sub">${r.has_mp4 ? "top.mp4" : r.n_frames ? "frames " + r.n_frames : "素材なし"} ・ ${LB_JA[r.labeler] || "—"}</div></div>
@@ -133,10 +136,20 @@ function applyFilter() {
   document.querySelectorAll(".item").forEach(el => el.onclick = () => select(el.dataset.stem));
 }
 
+// 成功率は確定した回だけ. 分母は 成功 + 失敗 (試行でない・判定できない・分からない は入れない. 依頼 185)
+function renderRate(st) {
+  if (!st) { $("#rate").textContent = ""; return; }
+  const b = st.by_outcome || {};
+  const pct = st.rate === null ? "—" : (st.rate * 100).toFixed(1) + "%";
+  $("#rate").innerHTML = `成功率 <b>${pct}</b> (成功 ${st.success} / (成功 ${st.success} + 失敗 ${st.failure}))`
+    + `<div class="muted">分母に入れない: 試行でない ${b.not_applicable || 0} ・ 判定できない ${b.not_judgeable || 0} ・ 分からない ${b.unknown || 0} (確定 ${st.confirmed} 回のうち)</div>`;
+}
+
 async function loadRuns() {
   const res = await fetch("/api/runs"); const j = await res.json();
   if (!res.ok || !Array.isArray(j.runs)) throw new Error(j.error || "一覧を読めません");  // 今の一覧は残す (codex 4 回目 R1)
   runs = j.runs;
+  renderRate(j.stats);
   applyFilter();
 }
 
diff --git a/pi05/verdict_viewer/seed_verdicts.py b/pi05/verdict_viewer/seed_verdicts.py
index 9deec01..19dafb6 100644
--- a/pi05/verdict_viewer/seed_verdicts.py
+++ b/pi05/verdict_viewer/seed_verdicts.py
@@ -4,6 +4,7 @@
   python3 pi05/verdict_viewer/seed_verdicts.py --run-input      # {run_stem}.json の outcome を写す (labeler=run_input)
   python3 pi05/verdict_viewer/seed_verdicts.py --ai             # 印のない回を AI が判定 (labeler=ai)
   python3 pi05/verdict_viewer/seed_verdicts.py --ai --dry-run   # API を呼ばずに素材だけ数える
+  python3 pi05/verdict_viewer/seed_verdicts.py --rule           # エイイチの決め (依頼 185) の行を足す (labeler=rule)
 
 - 元の記録は読むだけ. 判定ファイルには追記だけ (verdicts.append_rows)
 - すでに同じ種類の行がある回は足さない (何度流しても同じ行が増えない)
@@ -13,6 +14,8 @@
   (終わりの wait / home では人がブロックを箱から出していることがあるので, そこは使わない)
 - API は今の vlm_judge (dynamic_tracking_limits/place/place_modules/vlm_judge.py) と同じモデル・キーの読み方.
   使えない (キーがない・通信やクレジットのエラー) 回は行を書かずに飛ばし, 最後に数を出す
+- --rule (依頼 185): 送ったチャンク 0 の回 → not_applicable (決め 2), AI が frames/ だけ・素材なしで
+  not_judgeable とした回 → not_judgeable (決め 3). 両方なら 2. 確定の行 (run_input・人・rule) がある回には足さない
 """
 import argparse
 import base64
@@ -179,7 +182,7 @@ def ai_row(run_stem, parsed, evidence, n_exec):
     def pv(v):
         return v if v in V.PICK_PLACE else "unknown"
     oc = parsed.get("outcome") if isinstance(parsed, dict) else None
-    oc = oc if oc in V.OUTCOMES else "unknown"
+    oc = oc if oc in V.AI_OUTCOMES else "unknown"
     reason = str(parsed.get("reason", "")).replace("\n", " ").strip() if isinstance(parsed, dict) else ""
     return {"labeled_at": V.now_iso(), "run_stem": run_stem,
             "pick": pv(parsed.get("pick")), "place": pv(parsed.get("place")), "outcome": oc, "labeler": "ai",
@@ -210,7 +213,7 @@ def do_ai(csv_path, runs_dir, dry_run, limit, only):
     rows = V.read_rows(csv_path)
     marked = set(V.latest_by_stem(rows, labelers=("run_input",)))
     have_ai = set(V.latest_by_stem(rows, labelers=("ai",)))
-    human = set(V.latest_by_stem(rows, labelers=V.HUMAN_LABELERS))  # 人が確定した回には AI の行を足さない (codex R2)
+    human = set(V.latest_by_stem(rows, labelers=V.CONFIRMED_LABELERS))  # 確定した回には AI の行を足さない (codex R2・依頼 185)
     targets = []
     for stem in V.list_run_stems(runs_dir):
         j = V.read_run_json(Path(runs_dir) / stem)
@@ -260,7 +263,7 @@ def do_ai(csv_path, runs_dir, dry_run, limit, only):
             counts["not_judgeable"] += 1
             continue
         # 1 回ずつ書く (途中で止まっても済んだ回は残る). API を待つ間に人が確定していたら足さない
-        if not V.append_rows([row], csv_path, skip_if_labeled_by=("ai", "run_input") + V.HUMAN_LABELERS,
+        if not V.append_rows([row], csv_path, skip_if_labeled_by=("ai",) + V.CONFIRMED_LABELERS,
                             forbid_dirs=(runs_dir,)):
             counts["skipped_human"] += 1
             print(f"  {i:3d} {stem} 足さなかった (待つ間に人か写しの行が足されていた)")
@@ -272,17 +275,52 @@ def do_ai(csv_path, runs_dir, dry_run, limit, only):
     return counts
 
 
+def do_rule(csv_path, runs_dir, dry_run):
+    """エイイチの決め (2026-11-14 依頼 185) の行を足す. 送ったチャンクは log.jsonl の chunk の行で executed が True の数."""
+    rows = V.read_rows(csv_path)
+    conf = V.latest_by_stem(rows, labelers=V.CONFIRMED_LABELERS)
+    ai = V.latest_by_stem(rows, labelers=("ai",))
+    out, counts = [], Counter()
+    for stem in V.list_run_stems(runs_dir):
+        rd = Path(runs_dir) / stem
+        n_exec = V.executed_chunks(V.read_log(rd))
+        a = ai.get(stem) or {}
+        hit2 = n_exec == 0
+        hit3 = a.get("outcome") == "not_judgeable" and a.get("evidence") in ("frames", "")
+        if stem in conf:
+            if hit2 or hit3:
+                counts[f"skip_{conf[stem]['labeler']}"] += 1
+                print(f"  {stem} 足さない (確定の行あり: {conf[stem]['labeler']} {conf[stem]['outcome']}・"
+                      f"送ったチャンク {n_exec})")
+            continue
+        r = V.rule_row(stem, n_exec, ai.get(stem))
+        if r is None:
+            continue
+        counts["rule2" if hit2 else "rule3"] += 1
+        counts["both"] += hit2 and hit3
+        out.append(r)
+    print(f"[RULE ] 足す行 {len(out)} (決め2 not_applicable {counts['rule2']} (うち 3 にも当たる {counts['both']}) / "
+          f"決め3 not_judgeable {counts['rule3']})" + (" ※ dry-run なので書いていません" if dry_run else ""))
+    if not dry_run and out:
+        written = V.append_rows(out, csv_path, skip_if_labeled_by=V.CONFIRMED_LABELERS, forbid_dirs=(runs_dir,))
+        if len(written) != len(out):
+            print(f"[RULE ] 書くまでの間に確定の行が足されていた {len(out) - len(written)} 回は足さなかった")
+        counts["written"] = len(written)
+    return counts
+
+
 def main():
     ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
     ap.add_argument("--run-input", action="store_true", help="{run_stem}.json の outcome を写す")
     ap.add_argument("--ai", action="store_true", help="印のない回を AI が判定する")
+    ap.add_argument("--rule", action="store_true", help="エイイチの決め (依頼 185) の行を足す")
     ap.add_argument("--dry-run", action="store_true", help="書かない・API を呼ばない")
     ap.add_argument("--limit", type=int, default=0, help="AI の対象を先頭から N 回まで")
     ap.add_argument("--stems", nargs="*", default=None, help="AI の対象をこの run_stem だけにする")
     ap.add_argument("--runs-dir", default=str(V.RUNS_DIR))
     a = ap.parse_args()
-    if not (a.run_input or a.ai):
-        ap.error("--run-input か --ai を付けてください")
+    if not (a.run_input or a.ai or a.rule):
+        ap.error("--run-input か --ai か --rule を付けてください")
     try:
         csv_path = V.VERDICTS_CSV  # 書き先は保存先テーブルの 1 か所だけ (codex R3. テストは関数に渡す)
         V.ensure_ssd_mounted(csv_path)
@@ -290,6 +328,8 @@ def main():
             do_run_input(csv_path, a.runs_dir, a.dry_run)
         if a.ai:
             do_ai(csv_path, a.runs_dir, a.dry_run, a.limit, set(a.stems) if a.stems else None)
+        if a.rule:
+            do_rule(csv_path, a.runs_dir, a.dry_run)
     except (V.NotMounted, V.BrokenCsv) as e:
         print(f"[ABORT] {e}")
         return 2
diff --git a/pi05/verdict_viewer/server.py b/pi05/verdict_viewer/server.py
index 7e67f1e..79e8055 100644
--- a/pi05/verdict_viewer/server.py
+++ b/pi05/verdict_viewer/server.py
@@ -109,7 +109,7 @@ class App:
 
     def runs_summary(self):
         rows = V.read_rows(self.csv_path)
-        latest = V.latest_by_stem(rows)
+        latest = V.current_by_stem(rows)  # 確定の行があればそれ (依頼 185)
         ai = V.latest_by_stem(rows, labelers=("ai",))
         human = V.latest_by_stem(rows, labelers=V.HUMAN_LABELERS)
         out = []
@@ -124,10 +124,15 @@ class App:
                 "labeler": lt["labeler"] if lt else "",
                 "ai_outcome": a["outcome"] if a else "",
                 "human_outcome": h["outcome"] if h else "",
-                "confirmed": bool(lt and lt["labeler"] in V.HUMAN_LABELERS),
+                "confirmed": bool(lt and lt["labeler"] in V.CONFIRMED_LABELERS),
             })
         return out
 
+    def stats(self):
+        """確定した回だけの成功率 (依頼 185). 記録のフォルダにある回だけ数える."""
+        stems = set(V.list_run_stems(self.runs_dir))
+        return V.success_rate([r for r in V.read_rows(self.csv_path) if r["run_stem"] in stems])
+
     def run_detail(self, stem):
         rd = self.runs_dir / stem
         rows = [r for r in V.read_rows(self.csv_path) if r["run_stem"] == stem]
@@ -142,7 +147,7 @@ class App:
             "states": st,
             "video_t": video_times(rd, st.get("t0")) if V.has_top_mp4(rd) else None,
             "ai": ai[-1] if ai else None,
-            "latest": rows[-1] if rows else None,
+            "latest": V.current_by_stem(rows).get(stem),
             "history": rows,
             "executed_chunks": V.executed_chunks(log),
             "end_reason": (j or {}).get("end_reason", ""),
@@ -255,7 +260,7 @@ def make_handler(app):
                 if not parts:
                     return self._send(200, (HERE / "index.html").read_bytes(), "text/html; charset=utf-8")
                 if parts == ["api", "runs"]:
-                    return self._send(200, {"runs": app.runs_summary(), "csv": str(app.csv_path)})
+                    return self._send(200, {"runs": app.runs_summary(), "stats": app.stats(), "csv": str(app.csv_path)})
                 if (len(parts) == 3 and parts[:2] == ["api", "run"] and STEM_RE.match(parts[2])
                         and (app.runs_dir / parts[2]).is_dir() and V.within(app.runs_dir, app.runs_dir / parts[2])):
                     return self._send(200, app.run_detail(parts[2]))
diff --git a/pi05/verdict_viewer/test_verdicts.py b/pi05/verdict_viewer/test_verdicts.py
index 07519a9..9985e43 100644
--- a/pi05/verdict_viewer/test_verdicts.py
+++ b/pi05/verdict_viewer/test_verdicts.py
@@ -374,5 +374,102 @@ class TestServer(unittest.TestCase):
             self.assertEqual(cm.exception.code, 404)
 
 
+class TestRule185(unittest.TestCase):
+    """エイイチの決め (2026-11-14 依頼 185): run_input も確定・送ったチャンク 0 → not_applicable・素材不足 → not_judgeable."""
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.runs = Path(self.tmp.name) / "runs"
+        self.csv = Path(self.tmp.name) / "v.csv"
+        # stem: (送ったチャンク, 先に入れる行)
+        self.case = {
+            "pi05_20261002_000001": (0, [row("pi05_20261002_000001", "not_judgeable", "ai", evidence="frames")]),  # 2 と 3 → 2
+            "pi05_20261002_000002": (2, [row("pi05_20261002_000002", "not_judgeable", "ai", evidence="frames")]),  # 3
+            "pi05_20261002_000003": (2, [row("pi05_20261002_000003", "not_judgeable", "ai", evidence="")]),        # 3 (素材なし)
+            "pi05_20261002_000004": (0, [row("pi05_20261002_000004", "failure", "ai", evidence="top.mp4")]),       # 2
+            "pi05_20261002_000005": (0, [row("pi05_20261002_000005", "not_judgeable", "ai", evidence="frames"),
+                                         row("pi05_20261002_000005", "failure", "human_fixed_ai")]),             # 人の行 → 足さない
+            "pi05_20261002_000006": (2, [row("pi05_20261002_000006", "not_judgeable", "ai", evidence=""),
+                                         row("pi05_20261002_000006", "success", "human_only")]),                 # 人の行 → 足さない
+            "pi05_20261002_000007": (0, [row("pi05_20261002_000007", "success", "run_input")]),                  # run_input → 足さない
+            "pi05_20261002_000008": (2, [row("pi05_20261002_000008", "not_judgeable", "ai", evidence="top.mp4")]),  # 当たらない
+            "pi05_20261002_000009": (3, [row("pi05_20261002_000009", "failure", "ai", evidence="frames")]),       # 当たらない
+        }
+        rows = []
+        for stem, (n, rs) in self.case.items():
+            d = self.runs / stem
+            d.mkdir(parents=True)
+            lines = [{"type": "model"}] + [{"chunk": str(i), "executed": "True"} for i in range(n)] + [{"chunk": "9", "executed": "False"}]
+            (d / "log.jsonl").write_text("\n".join(json.dumps(x) for x in lines) + "\n")
+            rows += rs
+        V.append_rows(rows, self.csv)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def rule_rows(self):
+        return {r["run_stem"]: r for r in V.read_rows(self.csv) if r["labeler"] == "rule"}
+
+    def test_rule_rows(self):
+        c = S.do_rule(self.csv, self.runs, dry_run=False)
+        got = {k[-1]: v["outcome"] for k, v in self.rule_rows().items()}
+        self.assertEqual(got, {"1": "not_applicable", "2": "not_judgeable", "3": "not_judgeable", "4": "not_applicable"})
+        self.assertEqual((c["rule2"], c["rule3"], c["both"]), (2, 2, 1))
+        for r in self.rule_rows().values():
+            self.assertIn("エイイチの決め 2026-11-14 依頼185", r["note"])
+        self.assertIn("決め2", self.rule_rows()["pi05_20261002_000001"]["note"])
+        self.assertIn("決め3", self.rule_rows()["pi05_20261002_000002"]["note"])
+
+    def test_rule_never_after_human_or_run_input(self):
+        S.do_rule(self.csv, self.runs, dry_run=False)
+        for stem in ("pi05_20261002_000005", "pi05_20261002_000006", "pi05_20261002_000007"):
+            self.assertNotIn(stem, self.rule_rows())
+
+    def test_rule_twice_adds_nothing(self):
+        S.do_rule(self.csv, self.runs, dry_run=False)
+        before = self.csv.read_bytes()
+        S.do_rule(self.csv, self.runs, dry_run=False)
+        self.assertEqual(self.csv.read_bytes(), before)
+
+    def test_rule_rechecks_inside_lock(self):
+        # 一覧を読んだあとに人の行が足されたら, rule の行は足さない
+        real = V.read_rows
+        def late_human(p):
+            rows = real(p)
+            V.append_rows([row("pi05_20261002_000002", "failure", "human")], self.csv)
+            return rows
+        with mock.patch.object(V, "read_rows", side_effect=late_human):
+            S.do_rule(self.csv, self.runs, dry_run=False)
+        self.assertNotIn("pi05_20261002_000002", self.rule_rows())
+
+    def test_confirmed_and_success_rate(self):
+        S.do_rule(self.csv, self.runs, dry_run=False)
+        app = server.App(self.runs, self.csv)
+        conf = {r["run_stem"][-1]: r["confirmed"] for r in app.runs_summary()}
+        self.assertEqual(conf, {"1": True, "2": True, "3": True, "4": True, "5": True, "6": True, "7": True,
+                                "8": False, "9": False})
+        st = app.stats()
+        # 確定: success 2 (6・7) / failure 1 (5). not_applicable 2・not_judgeable 2 は分母に入れない. AI の行も入れない
+        self.assertEqual((st["success"], st["failure"], st["denominator"], st["confirmed"]), (2, 1, 3, 7))
+        self.assertAlmostEqual(st["rate"], 2 / 3)
+        self.assertEqual(st["by_outcome"]["not_applicable"], 2)
+
+    def test_success_rate_denominator(self):
+        rows = [row("a", "success", "human"), row("b", "failure", "run_input"), row("c", "unknown", "human"),
+                row("d", "not_judgeable", "rule"), row("e", "not_applicable", "rule"), row("f", "success", "ai"),
+                row("b", "success", "human_fixed_ai")]  # b は後ろの人の行が正
+        st = V.success_rate(rows)
+        self.assertEqual((st["success"], st["failure"], st["denominator"]), (2, 0, 2))
+        self.assertEqual(st["rate"], 1.0)
+        self.assertIsNone(V.success_rate([row("x", "not_applicable", "rule")])["rate"])
+
+    def test_ai_after_confirmed_does_not_unconfirm(self):
+        rows = [row("a", "not_applicable", "rule"), row("a", "failure", "ai")]
+        self.assertEqual(V.current_by_stem(rows)["a"]["labeler"], "rule")
+
+    def test_ai_cannot_write_not_applicable(self):
+        r = S.ai_row("pi05_x", {"outcome": "not_applicable", "pick": "no", "place": "no"}, "frames", 0)
+        self.assertEqual(r["outcome"], "unknown")
+
+
 if __name__ == "__main__":
     unittest.main(verbosity=1)
diff --git a/pi05/verdict_viewer/verdicts.py b/pi05/verdict_viewer/verdicts.py
index 652fce9..e88a95e 100644
--- a/pi05/verdict_viewer/verdicts.py
+++ b/pi05/verdict_viewer/verdicts.py
@@ -3,7 +3,10 @@
 
 判定ファイル: /Volumes/KIOXIA/r2r2r-data/runs/pi05_policy_verdicts_v1.csv (MASTER の保存先テーブル)
   - 追記だけ. 既存の行は書き換えない. 同じ run_stem はいちばん新しい行 (ファイルの後ろの行) が正
-  - labeler=ai の行は未確定. 確定は人の行 (human / human_fixed_ai / human_only)
+  - 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/ の中身) は読むだけ.
 """
@@ -24,9 +27,12 @@ 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")
-OUTCOMES = ("success", "failure", "unknown", "not_judgeable")
-LABELERS = ("run_input", "ai", "human", "human_fixed_ai", "human_only")
+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
@@ -164,6 +170,43 @@ def latest_by_stem(rows, labelers=None):
     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:
@@ -219,6 +262,7 @@ def read_log(run_dir):
 
 
 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")
