"""게이트를 **bgfirst·confined 까지** 넓힌다 (2026-09-20 ③).

## 왜 표준 갈래만이었나

1단계를 켤 때 「원리상 불가능해서가 아니라, 가장 흔한 경로에서 결과를
먼저 보려고」 단계를 나눴다. 그런데 **막고 있던 것이 하나 더 있었다**:
재판정기를 **후보 3개짜리 하나로** 지어 뒀다. 갈래마다 롤 수가 달라서
그대로 넓히면 라벨이 안 맞는다.

## 이 판에서 고친 것

    · 재판정기를 **(후보 수, 판정 머리말)마다** 짓는다.
      후보 수 = **그 갈래의 롤 수 + 1**.
    · 머리말도 **그 갈래 것**이다 — bgfirst 는 중립 머리말을 쓴다(체인·
      무콘티 후보는 참조·문안이 달라 기본 머리말이 **거짓**이다).
      재판정만 기본 머리말로 물으면 **다른 질문**이 된다.
    · 배선은 `_run_branch` **한 자리**. 호출 자리는 「대상인가」만 말한다.

★conti A/B 는 **여전히 비대상**이다. 두 갈래가 outer 선택 **전에** 각각
 재롤하면 「샷당 1장」이 아니라 **최대 2장**이 된다.

## 이 시험이 잠그는 범위

대개는 `run_multiroll_select` 에 **실제로 넘어간** 인자를 본다(생성·판정은
막는다). 그 판은 **표준·bgfirst 2택1** 이고, confined·bgfirst 체인단독을
실제로 태운 것이 **아니다** — 넓혀 쓰지 않는다(Codex).

★마지막 셋은 **진짜 `run_multiroll_select` 를 태운다**. 재롤본 `C` 가
 채택된 **뒤에** 나는 일(계보 기록)은 대역으로는 못 보기 때문이다.

재롤이 **실제로 돌아 그림이 좋아지는지**는 여기서 안 잰다.
"""
from __future__ import annotations

import json
from contextlib import ExitStack
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch

PID, EID = "SAMPLE_P", "SAMPLE_E"
TAG = "S1sh1"


def _write_cp(tmp_path, step_id, data):
    d = tmp_path / PID / "checkpoints" / "episodes" / EID / step_id
    d.mkdir(parents=True, exist_ok=True)
    (d / "manifest.json").write_text(
        json.dumps({"status": "completed", "data": data},
                   ensure_ascii=False), encoding="utf-8")


def _still(sid, si, shi):
    return {
        "id": sid, "still_index": 0, "scene_index": si, "shot_index": shi,
        "screenplay_scene_heading": f"S#{si}. SAMPLE",
        "beat_title": "", "still_frame_prompt": "SAMPLE still prompt",
        "camera_json": "{}", "lighting_json": "{}",
        "visible_entities_json": "[]", "dependent_scene_id": None,
    }


def _persistence():
    """영속 대역 — 자산 저장은 이 시험의 대상이 아니지만 **실재 값**을
    돌려줘야 뒤의 체크포인트 기록이 JSON 으로 적힌다."""
    per = MagicMock()
    per.safety_ladder_call_provenance.return_value = None
    per._resolve_generation_call_id.return_value = None
    per.save_single_scene_asset.side_effect = lambda *a, **k: MagicMock(
        id="G", is_primary=1, file_path="/x/g.png")
    return per


def _db(asset=None, prior=None):
    """`asset` 를 주면 자산 조회가 그것을 돌려준다.

    ★bgfirst 는 플레이트의 **asset 신원**을 요구한다(lineage 없이
     영속 금지). 대역이 `None` 만 돌려주면 그 자리에서 fail-closed 라
     선정까지 오지 못한다 — 막는 것이 맞고, 시험이 그 앞을 채워야 한다.
    """
    def _q(first_value):
        q = MagicMock()
        for m in ("filter", "filter_by", "join", "order_by", "options"):
            getattr(q, m).return_value = q
        q.all.return_value = []
        q.first.return_value = first_value
        q.count.return_value = 0
        return q

    # ★`query(ImageAsset)` 와 `query(ImageAsset.id)` 는 **다른 모양**을
    #  돌려준다 — 뒤엣것은 **행 튜플**이라 `row[0]` 로 읽는다. 하나로
    #  뭉개면 조회가 조용히 실패해 fail-closed 로 떨어진다(실측).
    rows = _q((asset.id,) if asset is not None else None)
    # ★`query(ImageAsset)` 는 **지난번에 보관한 산출**을 찾는 자리이기도
    #  하다. 대역이 엉뚱한 자산을 돌려주면 산출 bytes 가 늘 달라 보여
    #  **무변경 재방문이 거짓 재생성**으로 세어진다(실측: 이 하네스가
    #  플레이트를 돌려줘서 그랬다).
    objs = _q(prior if prior is not None else asset)
    db = MagicMock()
    db.query.side_effect = lambda *cols: (
        rows if cols and getattr(cols[0], "key", None) == "id" else objs)
    return db


class _FakeJudge:
    """지어질 때 받은 스키마 라벨·머리말·step_tag 를 들고 있는 대역."""

    def __init__(self, kw):
        self.schema_labels = list(
            kw["judge_schema"]["properties"]["winner"]["enum"])
        self.header = kw.get("prompt_header")
        self.step_tag = kw.get("step_tag")
        self.owns_order = True
        self.emits_readings = True

    def __call__(self, *a, **k):  # pragma: no cover — 생성이 막혀 안 불린다
        raise AssertionError("judge must not be called in this harness")


_BASE_FLAGS = [
    ("still_variants_enabled", False),
    ("still_conti_ab_enabled", False),
    ("multiroll_fix_rejudge_enabled", False),
    ("multiroll_gpt_composition_enabled", False),
    ("still_recipe_camera_frame_enabled", False),
    ("still_recipe_lighting_enabled", False),
    ("still_recipe_conduct_enabled", False),
    ("outdoor_lane_pipe_enabled", False),
    ("outdoor_lane_plan_enabled", False),
    ("background_share_plan_enabled", False),
    ("still_lane_prev_bgfirst_enabled", False),
    ("still_cine_transform_enabled", False),
    ("still_fix_ref_gate_enabled", False),
    ("signage_author_enabled", False),
    ("era_research_enabled", False),
    ("still_recipe_roll_count", 2),
    # ★대상 — 게이트 + 재롤
    ("still_winner_gate_enabled", True),
    ("still_gate_reroll_enabled", True),
]


def _run(tmp_path, *, flags, plate=False, conti=False, live=None,
         judge=None, rejudge=None, out=None, done=(), limit=None):
    """`live` 를 주면 `run_multiroll_select` 를 **대역으로 안 바꾼다**.

    그 판에서는 생성·판정 대역만 갈아 끼우고 **진짜 선정 함수**를 태운다 —
    재롤본 C 가 채택된 **뒤에** 나는 일(계보 기록)은 대역으로는 못 본다.
    """
    from app.services.still_recipe_service import (
        run_still_recipe_generation,
    )

    if conti:
        from app.modules.pipeline.shot_conti_light import (
            resolve_prompt_version as conti_pack_version,
        )

        conti_png = tmp_path / "conti.png"
        conti_png.parent.mkdir(parents=True, exist_ok=True)
        conti_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"CONTI")
        # ★`asset_id` 가 있어야 bgfirst 가 「lineage 없이 진행 금지」로
        #  막지 않는다(결손이면 fail-closed 가 맞다).
        _cp: Dict[str, Any] = {
            "contis": {TAG: {"image_path": str(conti_png),
                             "asset_id": "CONTI_ASSET"}}}
        if plate:
            # bgfirst full 은 **콘티 팩 신원**을 검사한다(구 팩 혼용 금지)
            _cp["conti_pack"] = conti_pack_version("5")
        _write_cp(tmp_path, "shot_conti_light", _cp)
    else:
        _write_cp(tmp_path, "shot_conti_light", {"contis": {}})
    plate_asset = None
    if plate:
        from app.models.project import ImageAsset

        plate_png = tmp_path / "plate.png"
        plate_png.write_bytes(b"\x89PNG\r\n\x1a\n" + b"PLATE")
        # ★`SimpleNamespace` 흉내를 쓰지 않는다 — 칸 이름이 틀려도 안 막는다
        plate_asset = ImageAsset(id="PLATE_ASSET", file_path=str(plate_png))
        _write_cp(tmp_path, "background_render", {"groups": {"BG1": {
            "status": "ok", "png_path": str(plate_png),
            "shot_ids": ["S1_Shot1"]}}})
    _write_cp(tmp_path, "shot_ref_classify",
              {"shots": {}, "scenes": {}, "world_anchor_en": ""})
    _write_cp(tmp_path, "shot_continuity", {"pose_canon": []})
    # ★「지난번 산출」 대역 — 실제 선정본 경로를 가리킨다. 두 걷기가 **같은
    #  객체**를 봐야 자산 신원까지 그대로다.
    prior_asset = None
    if live is not None:
        from app.models.project import ImageAsset

        prior_asset = ImageAsset(
            id="PRIOR_SEL", prompt_used="p",
            file_path=str(tmp_path / "scene" / "recipe" / f"{TAG}_sel.png"))

    scene_cp = MagicMock()
    runs: List[Dict[str, Any]] = []

    def spy_run(**kw):
        runs.append(kw)
        sel = tmp_path / f"{kw['tag']}_sel.png"
        sel.write_bytes(b"\x89PNG\r\n\x1a\n" + b"SEL")
        return str(sel), {"selected": "A"}

    def _judge_factory(**kw):
        if judge is None:
            return _FakeJudge(kw)
        fn = (rejudge if kw.get("step_tag") == "still_recipe_gate_rejudge"
              else judge)
        fn.schema_labels = list(
            kw["judge_schema"]["properties"]["winner"]["enum"])
        fn.header = kw.get("prompt_header")
        fn.step_tag = kw.get("step_tag")
        return fn

    patches = [
        patch("app.core.config.settings.projects_dir", str(tmp_path)),
        patch("app.modules.pipeline.multiroll_gemini.make_nb2_gen_fn",
              return_value=(live if live is not None else MagicMock())),
        patch("app.modules.pipeline.multiroll_gemini.make_gemini_judge_fn",
              side_effect=_judge_factory),
        patch("app.modules.pipeline.multiroll_gemini.make_gemini_critique_fn",
              return_value=MagicMock()),
        # ★bgfirst 는 선정 **앞에서** 배경본을 한 장 산다(gpt-image edit).
        #  이 시험의 대상이 아니므로 그 자리에서 막는다 — 유료 0.
        patch("app.modules.llm.gpt_image_primitive.call_gpt_image_bytes",
              return_value=b"\x89PNG\r\n\x1a\n" + b"BG"),
    ] + ([] if live is not None else [patch(
        "app.modules.pipeline.multiroll_select.run_multiroll_select",
        side_effect=spy_run)]) + ([patch(
            "app.core.config.settings.still_jit_regen_limit", limit,
            create=True)] if limit is not None else []) + [
        patch(f"app.core.config.settings.{n}", v, create=True)
        for n, v in (_BASE_FLAGS + list(flags))]

    with ExitStack() as st:
        for p in patches:
            st.enter_context(p)
        run_still_recipe_generation(
            db=_db(plate_asset, prior_asset), project_id=PID, episode_id=EID,
            stills=[_still("st_1", 1, 1)], stills_orm=[],
            entity_lookup={}, ref_image_map={}, reference_svc=MagicMock(),
            scene_ref_image_map={}, scene_ref_asset_id_map={},
            staging_map={}, scene_cp=scene_cp,
            persistence_svc=_persistence(), progress=MagicMock(),
            project_config=None, scene_dir=tmp_path / "scene",
            already_done_stills=set(done), target_scenes=None)
    _failed = [str(c.args[1]) if len(c.args) > 1 else ""
               for c in scene_cp.mark_failed.call_args_list]
    if live is not None:
        assert not _failed, f"걷기가 샷을 실패로 닫았다: {_failed}"
        if out is not None:
            out["records"] = json.loads(
                (tmp_path / "scene" / "recipe" / "records.json")
                .read_text(encoding="utf-8"))
        return None
    assert runs, f"걷기가 선정까지 안 왔다(이 시험이 무의미): {_failed}"
    return runs[-1]


_STANDARD = [("still_confined_fp_enabled", False),
             ("still_bgfirst_enabled", False),
             ("still_bgfirst_full_enabled", False),
             ("still_plate_select_enabled", False)]
_BGFIRST = [("still_confined_fp_enabled", False),
            ("still_bgfirst_enabled", True),
            ("still_bgfirst_full_enabled", True),
            ("still_plate_select_enabled", False)]


def test_the_standard_branch_still_gets_the_gate(tmp_path):
    """기준선 — 표준 갈래는 그대로 대상이고 후보 3개로 재판정한다."""
    kw = _run(tmp_path, flags=_STANDARD)
    assert kw["winner_gate_applicable"] is True
    assert kw["gate_reroll_enabled"] is True
    assert kw["gate_rejudge_fn"].schema_labels == ["A", "B", "C"], (
        "재판정 후보 수가 「이 갈래 롤 수 + 1」이 아니다")
    assert kw["gate_rejudge_fn"].step_tag == "still_recipe_gate_rejudge"
    assert kw["gate_rejudge_identity"]["rejudge_labels"] == 3
    assert kw["gate_regen_texts"], "재생성 문안이 안 실렸다"


def test_the_bgfirst_branch_is_now_a_target(tmp_path):
    """★★bgfirst 가 **대상이 된다** — 배경판이 카메라 권위라도 실격은 실격."""
    kw = _run(tmp_path, flags=_BGFIRST, plate=True, conti=True)
    assert kw["winner_gate_applicable"] is True, (
        "★bgfirst 갈래가 아직 비대상이다")
    assert kw["gate_reroll_enabled"] is True
    assert kw["gate_rejudge_fn"].schema_labels == ["A", "B", "C"]


def test_the_bgfirst_rejudge_asks_with_its_own_header(tmp_path):
    """★★재판정 머리말이 **그 갈래 것**이어야 한다.

    bgfirst 는 중립 머리말을 쓴다 — 체인·무콘티 후보는 참조·문안이 달라
    기본 머리말('generated from this')이 **거짓**이다. 재판정만 기본
    머리말로 물으면 **다른 질문**이 된다.
    """
    kw = _run(tmp_path, flags=_BGFIRST, plate=True, conti=True)
    sel_header = kw["judge_fn"].header
    assert sel_header, "이 갈래의 선정 판정에 머리말이 없다(이 반례가 무의미)"
    assert kw["gate_rejudge_fn"].header == sel_header, (
        "★재판정이 **다른 머리말**로 묻는다")
    assert kw["gate_rejudge_identity"]["rejudge_header_sha"], (
        "무엇으로 재판정했는지가 기록에 안 남는다")


def test_the_standard_rejudge_carries_no_header(tmp_path):
    """★표준 갈래는 머리말이 없다 — 신원에도 빈 값이다(둘이 안 섞인다)."""
    kw = _run(tmp_path, flags=_STANDARD)
    assert kw["gate_rejudge_fn"].header is None
    assert kw["gate_rejudge_identity"]["rejudge_header_sha"] == ""


def test_two_branches_do_not_share_one_rejudge_identity(tmp_path):
    """★갈래가 다르면 **재판정 신원도 다르다**.

    ★★**이 신원은 지금 「감사 메타」다** (Codex NON-BLOCK 정정).
     `reroll_attempt_matches` 는 **정책과 생성 지문만** 읽는다 — 이 칸들이
     재개에서 시도를 가르지 **않는다**. 갈래 차이는 이미 **생성 지문**이
     가른다(롤 수·`roll_refs`·판정 머리말이 거기 접혀 있다).
     여기서 잠그는 것은 **「무엇으로 재판정했나」가 기록에 남는다**까지다.
    """
    std = _run(tmp_path / "a", flags=_STANDARD)
    bgf = _run(tmp_path / "b", flags=_BGFIRST, plate=True, conti=True)
    assert (std["gate_rejudge_identity"]
            != bgf["gate_rejudge_identity"]), "두 갈래 신원이 같다"


def test_the_rejudge_follows_this_branch_roll_count(tmp_path):
    """★★재판정 후보 수는 **이 갈래의 롤 수 + 1** 이다.

    ★이 시험이 없으면 「3」을 박아 둬도 안 걸린다 — 지금 네 갈래가 전부
     2롤이라 우연히 맞기 때문이다(실측: 3을 박고도 다섯 시험이 다 통과
     했다). 그래서 **롤 수를 바꿔** 잰다.
    """
    kw = _run(tmp_path, flags=_STANDARD + [("still_recipe_roll_count", 3)])
    assert kw["roll_count"] == 3, "롤 수가 안 바뀌었다(이 반례가 무의미)"
    assert kw["gate_rejudge_fn"].schema_labels == ["A", "B", "C", "D"], (
        "★재판정 후보 수가 **이 갈래 롤 수 + 1** 이 아니다")
    assert kw["gate_rejudge_identity"]["rejudge_labels"] == 4


# ── ★재롤본이 이겼을 때 **출처 갈래** (2026-09-20 Codex BLOCK) ──────

def _live_gen():
    """생성 대역 — 부른 태그마다 파일을 만든다."""
    made: List[str] = []

    def _gen(tag, prompt, labeled_refs, out_path):
        made.append(str(out_path.stem).rsplit("_", 1)[-1])
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + out_path.stem.encode())
        return out_path

    _gen.made = made
    return _gen


def _live_judge(violations: Dict[str, List[str]], *, ranking=None):
    calls: List[Any] = []

    def judge_fn(tag, prompt, labeled_refs, cand_paths, labels):
        calls.append(list(labels))
        rank = list(ranking or labels)
        return {
            "winner": rank[0], "ranking": rank,
            "verdicts": [{"label": l, "score": 10 - rank.index(l),
                          "verdict_ko": "ok"} for l in labels],
            "readings": [{"label": l, "hard_violations": violations.get(l, [])}
                         for l in labels],
        }

    judge_fn.owns_order = True
    judge_fn.emits_readings = True
    judge_fn.calls = calls
    return judge_fn


def _walk_bgfirst_reroll(tmp_path, *, base_label):
    """bgfirst 2택1 에서 **`base_label` 을 고쳐 산 C** 가 이기게 한다."""
    gen = _live_gen()
    # 초기 판정: `base_label` 이 이기지만 **둘 다 실격** → 재롤로 간다
    other = "B" if base_label == "A" else "A"
    judge = _live_judge({base_label: ["x"], other: ["y"]},
                        ranking=[base_label, other])
    # 재판정: C 만 허용이고 1등
    rejudge = _live_judge({base_label: ["x"], other: ["y"], "C": []},
                          ranking=["C", base_label, other])
    out: Dict[str, Any] = {}
    _run(tmp_path, flags=_BGFIRST, plate=True, conti=True,
         live=gen, judge=judge, rejudge=rejudge, out=out)
    rec = out["records"][TAG]
    assert "c" in gen.made, f"재롤을 안 샀다(이 반례가 무의미): {gen.made}"
    assert rec["selected"] == "C", f"C 가 안 뽑혔다: {rec['selected']}"
    return rec


def test_a_reroll_born_from_the_chain_candidate_keeps_the_chain_lineage(
        tmp_path):
    """★★체인 후보 A 를 고쳐 산 C 가 이기면 **체인 계보**다.

    C 는 `from_selected=A` 의 문안과 **A 의 참조**로 만들어진다. 그런데
    채택되면 `selected` 가 C 라, 라벨만 보면 `C != A` 여서 **무콘티 승**
    으로 기록됐다 — 실제로 쓴 재투영 배경·콘티가 빠지고 **안 쓴 플레이트**
    가 직접 입력으로 붙는다. 로그 오기가 아니라 **최종 자산의 참조 계보
    오기**다.
    """
    rec = _walk_bgfirst_reroll(tmp_path, base_label="A")
    assert rec["bgfirst"]["winner_origin"] == "A", (
        "재롤본의 출처를 안 읽었다")
    assert rec["bgfirst"]["chain_winner"] is True, (
        "★A 로 만든 C 를 **무콘티 승**으로 적었다 — 참조 계보가 거짓이 된다")


def test_a_reroll_born_from_the_other_candidate_is_not_the_chain(tmp_path):
    """★★그렇다고 C 를 **무조건 체인으로 치면** 반대로 틀린다.

    B(무콘티)를 고쳐 산 C 는 무콘티다. 출처를 읽어야 둘 다 맞는다.
    """
    rec = _walk_bgfirst_reroll(tmp_path, base_label="B")
    assert rec["bgfirst"]["winner_origin"] == "B"
    assert rec["bgfirst"]["chain_winner"] is False, (
        "★B 로 만든 C 를 체인 승으로 적었다")


def test_the_origin_is_read_from_the_record_so_a_resume_agrees(tmp_path):
    """★캐시·재개로 C 가 채택되는 길도 **같은 판단**이어야 한다.

    출처는 기록(`gate_reroll`)에서 읽으므로 방문이 달라도 답이 같다.
    """
    from app.services.still_recipe_service import _reroll_origin_label

    rec = _walk_bgfirst_reroll(tmp_path, base_label="A")
    # 이 기록만 들고 다시 물어도 같은 답이 나온다(방문 상태를 안 본다)
    assert _reroll_origin_label(rec, "C") == "A"
    # 초기 후보가 최종이면 그 라벨 그대로다
    assert _reroll_origin_label(rec, "A") == "A"
    assert _reroll_origin_label({}, "B") == "B"


# ── ★감사 칸이 **지출을 발명하면 안 된다** (Codex BLOCK) ───────────

def test_the_audit_origin_field_is_not_a_spend_trace():
    """★★`bgfirst.winner_origin` 하나만 새로 생긴 것은 **지출이 아니다**.

    이 칸이 **없던 옛 기록**이 다음 방문에 그것만 얻으면, 생성·판정 0 인
    캐시 재사용이 「기록이 움직였다」가 되어 JIT 재생성으로 세어지고 상한
    래치까지 건다 — 게이트를 켜기 **전에도** 난다.

    ★`bgfirst` 를 통째로 빼면 안 된다 — 아래 둘은 **계속 달라야** 한다.
    """
    from app.services.still_recipe_service import _jit_tag_snapshot

    class _R:
        def __init__(self, d):
            self.data = d

    def _rec(**bg):
        return _R({TAG: {"selected": "A", "bgfirst": {
            "bg_path": "/x/bg.png", "bg_asset_id": "BG1",
            "chain_winner": True, **bg}}})

    old = _rec()
    new = _rec(winner_origin="A")
    assert (_jit_tag_snapshot(old, TAG, {TAG})
            == _jit_tag_snapshot(new, TAG, {TAG})), (
        "★감사 칸 하나가 **지출로 읽힌다** — 무구매 재개가 상한을 깎는다")

    # ★진짜 수정과 유료 흔적은 **숨기지 않는다**
    flipped = _R({TAG: {"selected": "A", "bgfirst": {
        "bg_path": "/x/bg.png", "bg_asset_id": "BG1",
        "chain_winner": False, "winner_origin": "B"}}})
    assert (_jit_tag_snapshot(new, TAG, {TAG})
            != _jit_tag_snapshot(flipped, TAG, {TAG})), (
        "체인 승패가 뒤집혔는데 같다고 읽는다")
    moved = _R({TAG: {"selected": "A", "bgfirst": {
        "bg_path": "/x/bg.png", "bg_asset_id": "BG2",
        "chain_winner": True, "winner_origin": "A"}}})
    assert (_jit_tag_snapshot(new, TAG, {TAG})
            != _jit_tag_snapshot(moved, TAG, {TAG})), (
        "배경 자산이 바뀌었는데 같다고 읽는다")


def test_a_resume_that_only_gains_the_audit_field_does_not_trip_the_latch(
        tmp_path):
    """★★**진짜 재개**를 태워 본다 — 생성·판정 0 · 래치 없음.

    게이트 OFF · 완료 샷 재방문 · 캐시 적중. 이 방문이 하는 일은 감사 칸
    하나를 새로 적는 것뿐인데, 그것이 지출로 세어지면 상한 1 에서 **래치가
    걸려** 정상 재개가 멈춘다.
    """
    from app.services.still_recipe_service import JIT_LATCH_FILENAME

    _off = [("still_winner_gate_enabled", False),
            ("still_gate_reroll_enabled", False)]
    gen1 = _live_gen()
    j1 = _live_judge({})
    _run(tmp_path, flags=_BGFIRST + _off, plate=True, conti=True,
         live=gen1, judge=j1, rejudge=_live_judge({}))
    assert gen1.made, "1차에서 그림을 안 샀다(이 반례가 무의미)"

    # ★감사 칸을 **없던 옛 기록**으로 되돌린다 — 이 판이 재현하려는 상태다
    recipe = tmp_path / "scene" / "recipe"
    recs = json.loads((recipe / "records.json").read_text(encoding="utf-8"))
    assert recs[TAG]["bgfirst"].pop("winner_origin", None) is not None
    (recipe / "records.json").write_text(
        json.dumps(recs, ensure_ascii=False), encoding="utf-8")

    gen2, j2 = _live_gen(), _live_judge({})
    out: Dict[str, Any] = {}
    _run(tmp_path, flags=_BGFIRST + _off, plate=True, conti=True,
         live=gen2, judge=j2, rejudge=_live_judge({}),
         done=("st_1",), limit=1, out=out)

    assert gen2.made == [], f"재개가 그림을 샀다: {gen2.made}"
    assert j2.calls == [], f"재개가 판정을 샀다: {j2.calls}"
    assert out["records"][TAG]["bgfirst"]["winner_origin"] == "A", (
        "감사 칸이 안 붙었다(이 반례가 무의미)")
    latch = recipe / JIT_LATCH_FILENAME
    assert not latch.exists(), (
        "★감사 칸 하나로 상한을 소모해 **래치가 걸렸다** — 정상 재개가 멈춘다")
