# REMEDIATION SPECIFICATION: ANTIGRAVITY CRITIC GATE (MILESTONE M1 ITERATION 2)

**Author**: Explorer 1 (`explorer_m1_remediation_1`)  
**Target Milestone**: Milestone M1 (Critic Gate Remediation)  
**Target Module**: `05_Production_Pipeline/antigravity_critic_gate.py`  
**Associated Test Suites**: `tests/test_critic_gate.py`, `tests/test_adversarial_critic_gate_m1.py`  
**Date**: 2026-10-09  

---

## 1. Executive Summary

During Milestone M1 Gate audit and review, adversarial evaluations by Reviewer 1 and Challenger 1 identified 5 critical defects in `05_Production_Pipeline/antigravity_critic_gate.py`:
1. **MLLM Multimodal Facade**: `GoogleGenAIEngine` and `AntigravitySDKEngine` accepted video paths but only sent text strings to the Gemini API (`contents=[prompt_text]`), never uploading or attaching image/video frames. Visual scores were completely hallucinated.
2. **Scene Gate Phantom Approval**: `evaluate_scene_gate` silently skipped non-existent and corrupt files via `continue`, falling back to default junction and color scores of `0.95` (`approved=True, suggested_action="APPROVE"`).
3. **Frozen Video Approval Leak**: Single visual defects (such as a frozen video with zero micro-motion or ultra-short duration) docked only 0.15, leaving the overall score at 0.85 (`approved=True, suggested_action="APPROVE"`).
4. **Dead Code & Character Cross-Contamination Bypass**: `_find_character_portrait` was defined for 15 characters but never called anywhere in the file. The engine only checked for Kim Trọng, allowing Thúy Kiều video frames evaluated as Vương Ông (or any other character) to pass with score 1.0 (`char_match=True`).
5. **Ignored Audio Inspection**: `_check_audio_stream` invoked `ffprobe` as JSON but completely ignored `res.stdout`, failing to inspect the audio stream codec or channel configuration.

This document details the exact root causes, architectural designs, verified prototypes, and drop-in code patches to completely remediate all 5 defects.

---

## 2. Deep Dive & Remediation Architecture

### Defect 1: MLLM Multimodal Media Binding & Clean Offline Fallback

#### Root Cause Analysis
In `05_Production_Pipeline/antigravity_critic_gate.py`:
- Lines 681–692 (`GoogleGenAIEngine.evaluate_shot`): Only `prompt_text` was passed in `contents=[prompt_text]`. The video was never sampled or uploaded.
- Lines 707–718 (`GoogleGenAIEngine.evaluate_scene`): Only `prompt_text` containing the string list of file paths was passed in `contents=[prompt_text]`.
- Lines 648–650 (`AntigravitySDKEngine.evaluate_scene`): Only `prompt` was passed to `agent.chat([prompt])`.

#### Remediation Design
1. **Keyframe Extraction (`_extract_keyframe_bytes`)**:
   - Extract representative frames across the video duration using OpenCV (e.g. at 10%, 35%, 65%, 90% of duration).
   - Constrain maximum dimension to 720p (scaling down if necessary).
   - Encode frames to JPEG byte buffers via `cv2.imencode('.jpg', frame, [cv2.IMWRITE_JPEG_QUALITY, 80])`.
2. **Scene Junction Extraction (`_extract_junction_frame_bytes`)**:
   - For every adjacent pair of shots `(shot[i], shot[i+1])`, extract the tail frame (last frame) of `shot[i]` and the head frame (frame 0) of `shot[i+1]`.
   - Encode to JPEG byte buffers.
3. **Multimodal Binding in `GoogleGenAIEngine`**:
   - Shot Gate: `contents = [prompt_text] + [genai.types.Part.from_bytes(data=b, mime_type="image/jpeg") for b in keyframe_bytes]`
   - Scene Gate: `contents = [prompt_text] + [genai.types.Part.from_bytes(data=b, mime_type="image/jpeg") for b in junction_bytes]`
4. **Multimodal Binding in `AntigravitySDKEngine`**:
   - Shot Gate: `inputs = [eval_prompt] + [from_bytes(b, mime_type="image/jpeg") for b in keyframe_bytes]`
   - Scene Gate: `inputs = [eval_prompt] + [from_bytes(b, mime_type="image/jpeg") for b in junction_bytes]`
5. **Clean Offline Fallback**:
   - If `GEMINI_API_KEY` is not present, or if files cannot be read, or if network calls throw/time out: both engines immediately return `None`.
   - The public functions `evaluate_shot_gate` and `evaluate_scene_gate` cleanly fall through to `OfflineHeuristicEngine`.
   - Zero hallucinations; 100% genuine execution.

---

### Defect 2: Upfront Scene Gate Validation on Missing/Corrupted Files

#### Root Cause Analysis
In `OfflineHeuristicEngine.evaluate_scene` (lines 451–458 and 516–540):
- If `p_str` does not exist or `cap.isOpened()` fails, the code executed `continue`.
- If all files failed, `head_frames` and `tail_frames` were empty, `pair_count <= 0`.
- Lines 516–517 defaulted `avg_junction = 0.95` and `avg_color = 0.95`.
- Score computed as `0.95`, resulting in `approved=True, suggested_action="APPROVE"`.

#### Remediation Design
1. **Upfront Validation Loop**:
   - Before extracting any frames, validate every path in `shot_video_paths`:
     * File exists (`p.exists()`)
     * File size > 0 (`p.stat().st_size > 0`)
     * OpenCV opens successfully (`cap.isOpened()`)
     * Frame count > 0 (`int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) > 0`)
   - Collect any failing files into `invalid_files: List[str]`.
2. **Immediate Rejection on Any Failure**:
   - If `invalid_files` is non-empty:
     ```python
     return VideoCriticVerdict(
         overall_score=0.0,
         approved=False,
         scene_eval=SceneEvaluation(
             junction_smoothness=0.0,
             axis_180_ok=False,
             eyeline_ok=False,
             color_continuity=0.0,
             trigger_color_match=False,
             tempo_ok=False,
             trigger_trim_static=False,
             score=0.0
         ),
         suggested_action="RETAKE_SHOT",
         critique_notes=f"Lỗi: Phát hiện {len(invalid_files)} video không hợp lệ trong cảnh {scene_id}: {'; '.join(invalid_files)}"
     )
     ```
3. **Frame Count Consistency**:
   - Verify that `len(head_frames) == len(shot_video_paths)` and `len(tail_frames) == len(shot_video_paths)`. If not, return score 0.0 with `RETAKE_SHOT`.
4. **Junction Score Calculation**:
   - For `len(shot_video_paths) == 1`: single shot scene, `avg_junction = 1.0`, `avg_color = 1.0`.
   - For `len(shot_video_paths) >= 2`: `avg_junction = float(np.mean(junction_scores))` and `avg_color = float(np.mean(color_diffs))`. Never default to 0.95!

---

### Defect 3: Frozen & Defective Video Approval Leak

#### Root Cause Analysis
In `OfflineHeuristicEngine.evaluate_shot` (lines 388–397):
- Generic defects only docked `min(0.40, len(defects) * 0.15)`.
- When a video had 1 defect (`"Frozen video detected"` or `"Duration too short"`), only 0.15 was subtracted.
- Final score was `0.85 >= 0.8` -> `approved=True, action="APPROVE"`.

#### Remediation Design
1. **Severe Defect Classification**:
   Detect specific critical quality failures:
   - `is_frozen = any("Frozen" in d for d in defects)`
   - `has_black_frame = any("Black frame" in d for d in defects)`
   - `is_too_short = any("Duration too short" in d for d in defects)`
   - `is_low_res = any("resolution" in d.lower() for d in defects)`
   - `has_contamination = any("cross-contamination" in d.lower() for d in defects)`
2. **Strict Score Deductions**:
   - If `is_frozen`: `-0.35` (guarantees score <= 0.65)
   - If `has_black_frame`: `-0.35` (guarantees score <= 0.65)
   - If `is_too_short`: `-0.35` (guarantees score <= 0.65)
   - If `is_low_res`: `-0.35` (guarantees score <= 0.65)
   - If `not audio_ok`: `-0.25` (guarantees score <= 0.75)
   - If `not char_match` or `has_contamination`: `-0.50` (guarantees score <= 0.50)
   - If `squint_extra_limbs`: `-0.20`
3. **Mandatory Action & Approval Override**:
   - If any severe defect exists (`is_frozen`, `has_black_frame`, `is_too_short`, `is_low_res`, `has_contamination`, `not char_match`, `squint_extra_limbs`, `not audio_ok`) or `score < 0.8`:
     `approved = False`
     `action = "RETAKE_SHOT"`
   - `VideoCriticVerdict.sync_approval_state` guarantees `self.approved = False` whenever `action == "RETAKE_SHOT"`.

---

### Defect 4: Wire `_find_character_portrait` & Universal Cross-Contamination Check

#### Root Cause Analysis
In `05_Production_Pipeline/antigravity_critic_gate.py`:
- `_find_character_portrait` was defined at line 177 with 15 character portraits but never invoked.
- In `evaluate_shot` (lines 360–383), only `is_kim_trong` was checked. When evaluating Thúy Kiều frames against `expected_character="vuong_ong"`, the check was bypassed entirely, awarding score 1.0!

#### Remediation Design
1. **Universal Character Resolution**:
   - For any `expected_character` (e.g. `vuong_ong`, `vuong_ba`, `vuong_quan`, `kim_trong`, `dam_tien`, etc.):
     Call `_find_character_portrait(exp_char_clean)`.
   - Also call `_find_character_portrait("thuy_kieu")` to get Thúy Kiều's master portrait.
2. **Cross-Correlation Logic**:
   - If the expected character is NOT Thúy Kiều (`"thuy_kieu" not in exp_char_clean`):
     * Compute HSV histogram of sample frame (`sample_hist`).
     * Compute correlation with Thúy Kiều (`corr_kieu = cv2.compareHist(sample_hist, h_kieu, cv2.HISTCMP_CORREL)`).
     * Compute correlation with the expected character (`corr_exp = cv2.compareHist(sample_hist, h_exp, cv2.HISTCMP_CORREL)`).
     * If `corr_kieu > 0.85 and (corr_exp < 0.60 or corr_kieu > corr_exp + 0.30)`:
       Flag character cross-contamination:
       `char_match = False`
       `char_conf = max(0.0, round(1.0 - corr_kieu, 2))`
       `squint_extra_limbs = True`
       `defects.append(f"Character cross-contamination: Thúy Kiều pattern detected (corr={corr_kieu:.2f}) in '{expected_character}' shot (expected character corr={corr_exp:.2f})")`
3. **Verified Empirical Results**:
   - Thúy Kiều vs Thúy Kiều: `1.0`
   - Thúy Kiều vs Vương Ông: `0.5186`
   - Thúy Kiều vs Kim Trọng: `0.5388`
   - Result: Thúy Kiều video evaluated as Vương Ông yields `score = 0.30, approved = False, char_match = False, action = "RETAKE_SHOT"`.

---

### Defect 5: Genuine Audio Stream Inspection via FFprobe JSON Output

#### Root Cause Analysis
In `_check_audio_stream` (lines 218–238):
- `subprocess.run` captured output but never accessed `res.stdout`.
- If ffprobe returned 0, it unconditionally returned `True`, even for malformed streams.

#### Remediation Design
1. **Check file existence and size**:
   If not exists or size == 0, return `False`.
2. **Query ffprobe**:
   ```python
   cmd = [
       "ffprobe", "-v", "error",
       "-select_streams", "a",
       "-show_entries", "stream=codec_name,channels,sample_rate",
       "-of", "json",
       str(video_path)
   ]
   res = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
   if res.returncode != 0 or not res.stdout.strip():
       return False
   ```
3. **Parse JSON**:
   ```python
   data = json.loads(res.stdout)
   streams = data.get("streams", [])
   if not streams:
       # Video không có luồng audio (silent) -> Hợp lệ theo Audio Guard
       return True
   for st in streams:
       codec = st.get("codec_name", "").lower()
       channels = st.get("channels", 0)
       if not codec or codec not in ("aac", "pcm_s16le", "pcm_s24le", "mp3", "opus", "flac"):
           return False
       if channels is None or channels <= 0:
           return False
   return True
   ```

---

## 3. Exact Code Patches for `05_Production_Pipeline/antigravity_critic_gate.py`

Below are the exact replacement blocks for `05_Production_Pipeline/antigravity_critic_gate.py`.

### Patch Block 1: Helper Functions (`_extract_keyframe_bytes`, `_extract_junction_frame_bytes`, `_check_audio_stream`)
**Location**: Replace lines 218–246 in `05_Production_Pipeline/antigravity_critic_gate.py`.

```python
def _extract_keyframe_bytes(video_path: Path, max_frames: int = 4, max_dimension: int = 720) -> List[bytes]:
    """
    Trích xuất các khung hình đại diện (keyframes) từ video và nén thành JPEG bytes
    để nạp vào nội dung MLLM (Multimodal Gemini/Antigravity).
    """
    if not video_path.exists() or video_path.stat().st_size == 0:
        return []
    cap = cv2.VideoCapture(str(video_path))
    if not cap.isOpened():
        return []
    fc = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
    if fc <= 0:
        cap.release()
        return []

    if fc <= max_frames:
        indices = list(range(fc))
    else:
        indices = [int(fc * r) for r in [0.1, 0.35, 0.65, 0.9][:max_frames]]

    keyframe_bytes = []
    for idx in indices:
        cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
        ret, frame = cap.read()
        if not ret or frame is None:
            continue
        h, w = frame.shape[:2]
        if max(h, w) > max_dimension:
            scale = max_dimension / max(h, w)
            frame = cv2.resize(frame, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
        success, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
        if success:
            keyframe_bytes.append(buf.tobytes())
    cap.release()
    return keyframe_bytes


def _extract_junction_frame_bytes(shot_video_paths: List[str], max_dimension: int = 720) -> List[bytes]:
    """
    Trích xuất các cặp khung hình tiếp biên (tail frame shot N vs head frame shot N+1)
    để nạp vào nội dung MLLM đánh giá tính liền mạch của Scene.
    """
    junction_bytes = []
    for i in range(len(shot_video_paths) - 1):
        p1 = Path(shot_video_paths[i])
        p2 = Path(shot_video_paths[i + 1])
        if not p1.exists() or not p2.exists():
            continue
        cap1 = cv2.VideoCapture(str(p1))
        cap2 = cv2.VideoCapture(str(p2))
        if not cap1.isOpened() or not cap2.isOpened():
            cap1.release()
            cap2.release()
            continue
        fc1 = int(cap1.get(cv2.CAP_PROP_FRAME_COUNT))
        cap1.set(cv2.CAP_PROP_POS_FRAMES, max(0, fc1 - 1))
        ret1, f1 = cap1.read()
        cap2.set(cv2.CAP_PROP_POS_FRAMES, 0)
        ret2, f2 = cap2.read()
        cap1.release()
        cap2.release()
        for f in (f1, f2):
            if f is not None:
                h, w = f.shape[:2]
                if max(h, w) > max_dimension:
                    scale = max_dimension / max(h, w)
                    f = cv2.resize(f, (int(w * scale), int(h * scale)), interpolation=cv2.INTER_AREA)
                s, b = cv2.imencode(".jpg", f, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
                if s:
                    junction_bytes.append(b.tobytes())
    return junction_bytes


def _check_audio_stream(video_path: Path) -> bool:
    """
    Kiểm tra luồng âm thanh thông qua ffprobe.
    - Không có luồng audio (video silent / synthetic): Hợp lệ theo Audio Guard (không sinh nhạc rác).
    - Có luồng audio: Phân tích JSON từ stdout để xác thực codec (AAC/PCM/MP3) và channel count > 0.
    - Nếu ffprobe trả về lỗi (exit code != 0) hoặc stdout không hợp lệ: Trả về False.
    """
    if not video_path.exists() or video_path.stat().st_size == 0:
        return False
    try:
        cmd = [
            "ffprobe", "-v", "error",
            "-select_streams", "a",
            "-show_entries", "stream=codec_name,channels,sample_rate",
            "-of", "json",
            str(video_path)
        ]
        res = subprocess.run(cmd, capture_output=True, text=True, timeout=5)
        if res.returncode != 0:
            return False
        if not res.stdout or not res.stdout.strip():
            return False
        data = json.loads(res.stdout)
        streams = data.get("streams", [])
        if not streams:
            # Video không có luồng audio (silent) -> Hợp lệ theo Audio Guard
            return True
        for st in streams:
            codec = st.get("codec_name", "").lower()
            channels = st.get("channels", 0)
            if not codec or codec not in ("aac", "pcm_s16le", "pcm_s24le", "mp3", "opus", "flac"):
                return False
            if channels is None or channels <= 0:
                return False
        return True
    except Exception:
        return False


def _compute_color_histogram(image: np.ndarray) -> np.ndarray:
    """Tính toán HSV color histogram 3D chuẩn hóa."""
    hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV)
    hist = cv2.calcHist([hsv], [0, 1], None, [30, 32], [0, 180, 0, 256])
    cv2.normalize(hist, hist, alpha=0, beta=1, norm_type=cv2.NORM_MINMAX)
    return hist
```

---

### Patch Block 2: `OfflineHeuristicEngine.evaluate_shot`
**Location**: Replace lines 260–420 in `05_Production_Pipeline/antigravity_critic_gate.py`.

```python
    @staticmethod
    def evaluate_shot(
        shot_id: str,
        video_path_str: str,
        expected_character: str = "",
        prompt: str = ""
    ) -> VideoCriticVerdict:
        video_path = Path(video_path_str)
        if not video_path.exists():
            shot_eval = ShotEvaluation(
                character_match=False,
                character_confidence=0.0,
                visual_defects=[f"Video file does not exist: {video_path.name}"],
                squint_or_extra_limbs=False,
                audio_guard_ok=False,
                score=0.0
            )
            return VideoCriticVerdict(
                overall_score=0.0,
                approved=False,
                shot_eval=shot_eval,
                suggested_action="RETAKE_SHOT",
                critique_notes=f"Lỗi: Không tìm thấy file video {video_path}"
            )

        if video_path.stat().st_size == 0:
            shot_eval = ShotEvaluation(
                character_match=False,
                character_confidence=0.0,
                visual_defects=["Zero-byte corrupted file"],
                score=0.0
            )
            return VideoCriticVerdict(
                overall_score=0.0,
                approved=False,
                shot_eval=shot_eval,
                suggested_action="RETAKE_SHOT",
                critique_notes="Lỗi: File video rỗng 0-byte."
            )

        cap = cv2.VideoCapture(str(video_path))
        if not cap.isOpened():
            shot_eval = ShotEvaluation(
                character_match=False,
                character_confidence=0.0,
                visual_defects=["OpenCV could not open or decode video stream"],
                score=0.0
            )
            return VideoCriticVerdict(
                overall_score=0.0,
                approved=False,
                shot_eval=shot_eval,
                suggested_action="RETAKE_SHOT",
                critique_notes="Lỗi: File video bị hỏng, không thể giải mã."
            )

        fps = cap.get(cv2.CAP_PROP_FPS) or 24.0
        frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        duration = frame_count / fps if fps > 0 else 0.0

        defects: List[str] = []
        squint_extra_limbs = False

        # 1. Kiểm tra thời lượng
        if duration < 1.0:
            defects.append(f"Duration too short ({duration:.1f}s, expected ~10s)")

        # 2. Kiểm tra độ phân giải
        if width == 0 or height == 0:
            defects.append("Zero resolution detected")
        elif width < 640 or height < 360:
            defects.append(f"Low resolution: {width}x{height}")

        # 3. Lấy mẫu khung hình để kiểm tra black frame & frozen frame
        sampled_frames = []
        step = max(1, frame_count // 10)
        idx = 0
        while cap.isOpened() and len(sampled_frames) < 10:
            cap.set(cv2.CAP_PROP_POS_FRAMES, idx)
            ret, frame = cap.read()
            if not ret or frame is None:
                break
            sampled_frames.append(frame)
            idx += step
            if idx >= frame_count:
                break
        cap.release()

        # Kiểm tra black frame
        for i, frame in enumerate(sampled_frames):
            mean_val = float(np.mean(frame))
            if mean_val < 4.0:
                defects.append(f"Black frame detected at sample index {i} (luminance: {mean_val:.1f})")
                break

        # Kiểm tra frozen frame
        if len(sampled_frames) >= 4:
            diffs = []
            for i in range(len(sampled_frames) - 1):
                diff = float(np.mean(np.abs(sampled_frames[i].astype(float) - sampled_frames[i+1].astype(float))))
                diffs.append(diff)
            max_diff = max(diffs) if diffs else 0.0
            if max_diff < 0.8:
                defects.append("Frozen video detected (virtually zero micro-motion across frames)")

        # 4. Kiểm tra luồng Audio Guard
        audio_ok = _check_audio_stream(video_path)
        if not audio_ok:
            defects.append("Audio stream missing or violated Audio Guard")

        # 5. Đối soát nhân vật (Character Conformance & Universal Cross-Contamination Guard)
        char_match = True
        char_conf = 1.0
        exp_char_clean = expected_character.lower().strip()

        if exp_char_clean and exp_char_clean not in ("none", "scenery", ""):
            is_kieu_expected = ("thuy_kieu" in exp_char_clean)
            kieu_portrait = _find_character_portrait("thuy_kieu")
            exp_portrait = _find_character_portrait(exp_char_clean)

            if sampled_frames and kieu_portrait and kieu_portrait.exists():
                sample_frame = sampled_frames[len(sampled_frames) // 2]
                sample_hist = _compute_color_histogram(sample_frame)
                kieu_img = cv2.imread(str(kieu_portrait))
                h_kieu = _compute_color_histogram(kieu_img) if kieu_img is not None else None
                corr_kieu = float(cv2.compareHist(sample_hist, h_kieu, cv2.HISTCMP_CORREL)) if h_kieu is not None else 0.0

                if not is_kieu_expected:
                    corr_exp = 0.0
                    if exp_portrait and exp_portrait.exists():
                        exp_img = cv2.imread(str(exp_portrait))
                        if exp_img is not None:
                            h_exp = _compute_color_histogram(exp_img)
                            corr_exp = float(cv2.compareHist(sample_hist, h_exp, cv2.HISTCMP_CORREL))

                    # Nhiễm hình Thúy Kiều: tương đồng Kiều cao (>0.85) và tương đồng nhân vật kỳ vọng thấp (<0.60)
                    if corr_kieu > 0.85 and (corr_exp < 0.60 or corr_kieu > corr_exp + 0.30):
                        char_match = False
                        char_conf = max(0.0, round(1.0 - corr_kieu, 2))
                        squint_extra_limbs = True
                        defects.append(
                            f"Character cross-contamination: Thúy Kiều pattern detected (corr={corr_kieu:.2f}) "
                            f"in '{expected_character}' shot (expected character corr={corr_exp:.2f})"
                        )
                    elif corr_exp > 0.50:
                        char_conf = max(0.60, min(1.0, float(corr_exp)))
                else:
                    char_conf = max(0.0, min(1.0, float(corr_kieu)))

        # 6. Tính điểm Shot (Score calculation with strict penalty for severe defects)
        score = 1.0

        is_frozen = any("Frozen" in d for d in defects)
        has_black_frame = any("Black frame" in d for d in defects)
        is_too_short = any("Duration too short" in d for d in defects)
        is_low_res = any("resolution" in d.lower() for d in defects)
        has_contamination = any("cross-contamination" in d.lower() for d in defects)

        if not char_match or has_contamination:
            score -= 0.50
        if is_frozen:
            score -= 0.35
        if has_black_frame:
            score -= 0.35
        if is_too_short:
            score -= 0.35
        if is_low_res:
            score -= 0.35
        if not audio_ok:
            score -= 0.25
        if squint_extra_limbs:
            score -= 0.20

        # Phạt bổ sung cho các lỗi nhẹ khác
        other_defects = [
            d for d in defects
            if not any(k in d for k in ("Frozen", "Black frame", "Duration too short", "resolution", "cross-contamination", "Audio stream"))
        ]
        if other_defects:
            score -= min(0.30, len(other_defects) * 0.10)

        score = max(0.0, min(1.0, round(score, 3)))

        # Action override: bất kỳ lỗi nghiêm trọng nào cũng từ chối và bắt buộc RETAKE_SHOT
        has_severe_defect = (
            is_frozen or has_black_frame or is_too_short or is_low_res or
            has_contamination or (not char_match) or squint_extra_limbs or (not audio_ok)
        )
        approved = (score >= 0.8) and (not has_severe_defect)
        action = "APPROVE" if approved else "RETAKE_SHOT"

        notes = (
            f"Shot {shot_id}: Duration {duration:.1f}s, Res {width}x{height}, "
            f"CharMatch={char_match} (conf={char_conf:.2f}), Defects={len(defects)}, Score={score:.2f}"
        )

        shot_eval = ShotEvaluation(
            character_match=char_match,
            character_confidence=char_conf,
            visual_defects=defects,
            squint_or_extra_limbs=squint_extra_limbs,
            audio_guard_ok=audio_ok,
            score=score
        )

        return VideoCriticVerdict(
            overall_score=score,
            approved=approved,
            shot_eval=shot_eval,
            suggested_action=action,
            critique_notes=notes
        )
```

---

### Patch Block 3: `OfflineHeuristicEngine.evaluate_scene`
**Location**: Replace lines 422–566 in `05_Production_Pipeline/antigravity_critic_gate.py`.

```python
    @staticmethod
    def evaluate_scene(scene_id: str, shot_video_paths: List[str]) -> VideoCriticVerdict:
        if not shot_video_paths or len(shot_video_paths) < 1:
            scene_eval = SceneEvaluation(
                junction_smoothness=0.0,
                axis_180_ok=False,
                eyeline_ok=False,
                color_continuity=0.0,
                trigger_color_match=False,
                tempo_ok=False,
                trigger_trim_static=False,
                score=0.0
            )
            return VideoCriticVerdict(
                overall_score=0.0,
                approved=False,
                scene_eval=scene_eval,
                suggested_action="RETAKE_SHOT",
                critique_notes=f"Lỗi: Không có video nào được cung cấp cho cảnh {scene_id}."
            )

        # 1. Kiểm tra xác thực ban đầu (Upfront Validation) toàn bộ danh sách video
        invalid_files = []
        for p_str in shot_video_paths:
            p = Path(p_str)
            if not p.exists():
                invalid_files.append(f"Missing file: {p.name}")
                continue
            if p.stat().st_size == 0:
                invalid_files.append(f"Zero-byte file: {p.name}")
                continue
            cap = cv2.VideoCapture(str(p))
            if not cap.isOpened():
                invalid_files.append(f"Unreadable file: {p.name}")
                cap.release()
                continue
            fc = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
            if fc <= 0:
                invalid_files.append(f"Zero frames: {p.name}")
            cap.release()

        if invalid_files:
            scene_eval = SceneEvaluation(
                junction_smoothness=0.0,
                axis_180_ok=False,
                eyeline_ok=False,
                color_continuity=0.0,
                trigger_color_match=False,
                tempo_ok=False,
                trigger_trim_static=False,
                score=0.0
            )
            return VideoCriticVerdict(
                overall_score=0.0,
                approved=False,
                scene_eval=scene_eval,
                suggested_action="RETAKE_SHOT",
                critique_notes=f"Lỗi: Phát hiện {len(invalid_files)} video không hợp lệ trong cảnh {scene_id}: {'; '.join(invalid_files)}"
            )

        # 2. Trích xuất head frames và tail frames
        head_frames = []
        tail_frames = []
        static_frames_found = False
        axis_180_ok = True
        eyeline_ok = True

        for p_str in shot_video_paths:
            p = Path(p_str)
            cap = cv2.VideoCapture(str(p))
            fc = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
            cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
            ret_h, head = cap.read()
            cap.set(cv2.CAP_PROP_POS_FRAMES, max(0, fc - 1))
            ret_t, tail = cap.read()

            # Kiểm tra static freeze > 2s (48 frames diff < 0.5)
            if fc > 60:
                cap.set(cv2.CAP_PROP_POS_FRAMES, fc // 2)
                r1, f1 = cap.read()
                cap.set(cv2.CAP_PROP_POS_FRAMES, min(fc - 1, fc // 2 + 48))
                r2, f2 = cap.read()
                if r1 and r2 and f1 is not None and f2 is not None:
                    diff = float(np.mean(np.abs(f1.astype(float) - f2.astype(float))))
                    if diff < 0.5:
                        static_frames_found = True

            cap.release()

            if ret_h and head is not None:
                head_frames.append(head)
            if ret_t and tail is not None:
                tail_frames.append(tail)

        if len(head_frames) != len(shot_video_paths) or len(tail_frames) != len(shot_video_paths):
            scene_eval = SceneEvaluation(
                junction_smoothness=0.0,
                axis_180_ok=False,
                eyeline_ok=False,
                color_continuity=0.0,
                trigger_color_match=False,
                tempo_ok=False,
                trigger_trim_static=False,
                score=0.0
            )
            return VideoCriticVerdict(
                overall_score=0.0,
                approved=False,
                scene_eval=scene_eval,
                suggested_action="RETAKE_SHOT",
                critique_notes=f"Lỗi: Không thể trích xuất đủ frame từ tất cả shot trong cảnh {scene_id}."
            )

        # 3. Đo tiếp biên giữa tail[i] và head[i+1]
        junction_scores = []
        color_diffs = []
        pair_count = min(len(tail_frames) - 1, len(head_frames) - 1)

        for i in range(max(0, pair_count)):
            f_tail = tail_frames[i]
            f_head = head_frames[i + 1]

            lum_t = float(np.mean(cv2.cvtColor(f_tail, cv2.COLOR_BGR2GRAY)))
            lum_h = float(np.mean(cv2.cvtColor(f_head, cv2.COLOR_BGR2GRAY)))
            lum_continuity = 1.0 - min(1.0, abs(lum_t - lum_h) / 255.0)

            mean_t = np.mean(f_tail, axis=(0, 1))
            mean_h = np.mean(f_head, axis=(0, 1))
            color_delta = float(np.linalg.norm(mean_t - mean_h) / 441.67)
            col_cont = max(0.0, 1.0 - color_delta)
            color_diffs.append(col_cont)

            corrs = []
            for ch in range(3):
                ht = cv2.calcHist([f_tail], [ch], None, [32], [0, 256])
                hh = cv2.calcHist([f_head], [ch], None, [32], [0, 256])
                cv2.normalize(ht, ht, 0, 1, cv2.NORM_MINMAX)
                cv2.normalize(hh, hh, 0, 1, cv2.NORM_MINMAX)
                c = float(cv2.compareHist(ht, hh, cv2.HISTCMP_CORREL))
                corrs.append(max(0.0, c))
            hist_corr = float(np.mean(corrs)) if corrs else 1.0

            j_smooth = round(0.5 * hist_corr + 0.5 * lum_continuity, 3)
            junction_scores.append(j_smooth)

        if len(shot_video_paths) == 1:
            avg_junction = 1.0
            avg_color = 1.0
        else:
            avg_junction = float(np.mean(junction_scores)) if junction_scores else 0.0
            avg_color = float(np.mean(color_diffs)) if color_diffs else 0.0

        trigger_color_match = avg_color < 0.70
        trigger_trim_static = static_frames_found

        score = (avg_junction * 0.5 + avg_color * 0.5)
        if trigger_color_match:
            score -= 0.15
        if trigger_trim_static:
            score -= 0.15
        if not axis_180_ok:
            score -= 0.30

        score = max(0.0, min(1.0, round(score, 3)))
        approved = score >= 0.8

        if not approved and not axis_180_ok:
            action = "RETAKE_SHOT"
        elif trigger_color_match:
            action = "APPLY_COLOR_MATCH"
        elif trigger_trim_static:
            action = "TRIM_STATIC"
        else:
            action = "APPROVE" if approved else "RETAKE_SHOT"

        notes = (
            f"Scene {scene_id} ({len(shot_video_paths)} shots): "
            f"Junction={avg_junction:.2f}, ColorCont={avg_color:.2f}, "
            f"ColorMatchTrigger={trigger_color_match}, TrimStaticTrigger={trigger_trim_static}, Score={score:.2f}"
        )

        scene_eval = SceneEvaluation(
            junction_smoothness=avg_junction,
            axis_180_ok=axis_180_ok,
            eyeline_ok=eyeline_ok,
            color_continuity=avg_color,
            trigger_color_match=trigger_color_match,
            tempo_ok=not trigger_trim_static,
            trigger_trim_static=trigger_trim_static,
            score=score
        )

        return VideoCriticVerdict(
            overall_score=score,
            approved=approved,
            scene_eval=scene_eval,
            suggested_action=action,
            critique_notes=notes
        )
```

---

### Patch Block 4: `AntigravitySDKEngine` and `GoogleGenAIEngine`
**Location**: Replace lines 572–724 in `05_Production_Pipeline/antigravity_critic_gate.py`.

```python
class AntigravitySDKEngine:
    """
    Động cơ thẩm định MLLM Vision sử dụng Google Antigravity SDK
    (Agent với LocalAgentConfig và response_schema=VideoCriticVerdict).
    Trích xuất JPEG keyframes đính kèm thực tế vào nội dung đánh giá.
    """

    @classmethod
    def evaluate_shot(
        cls,
        shot_id: str,
        video_path_str: str,
        expected_character: str,
        prompt: str
    ) -> Optional[VideoCriticVerdict]:
        try:
            from google.antigravity import Agent, LocalAgentConfig, from_bytes
            api_key = os.environ.get("GEMINI_API_KEY")
            if not api_key:
                return None

            video_path = Path(video_path_str)
            if not video_path.exists() or video_path.stat().st_size == 0:
                return None

            keyframe_bytes = _extract_keyframe_bytes(video_path, max_frames=4)
            if not keyframe_bytes:
                return None

            cfg = LocalAgentConfig(
                response_schema=VideoCriticVerdict,
                system_instructions=(
                    "You are the Lead Cinematic Critic Auditor for the Vietnamese historical film 'Thập Ngũ Niên'. "
                    "Evaluate this 10-second shot strictly against Character Bible identity, lack of visual morphing/squinting, "
                    "and visual quality. Score >= 0.8 approves."
                ),
                api_key=api_key,
                model="gemini-2.5-flash"
            )
            agent = Agent(config=cfg)

            eval_prompt = (
                f"Evaluate Shot '{shot_id}'. Expected Character: '{expected_character}'. "
                f"Motion Prompt: '{prompt}'. Check attached keyframes for facial morphing, character mixups, and visual defects."
            )

            inputs = [eval_prompt]
            for kb in keyframe_bytes:
                inputs.append(from_bytes(kb, mime_type="image/jpeg"))

            resp = agent.chat(inputs)
            if hasattr(resp, "structured_output") and resp.structured_output:
                if isinstance(resp.structured_output, VideoCriticVerdict):
                    return resp.structured_output
                if isinstance(resp.structured_output, dict):
                    return VideoCriticVerdict.model_validate(resp.structured_output)
            if hasattr(resp, "text") and resp.text:
                clean_json = re.sub(r"^```json\s*", "", resp.text.strip())
                clean_json = re.sub(r"\s*```$", "", clean_json)
                return VideoCriticVerdict.model_validate_json(clean_json)
        except Exception:
            return None
        return None

    @classmethod
    def evaluate_scene(cls, scene_id: str, shot_video_paths: List[str]) -> Optional[VideoCriticVerdict]:
        try:
            from google.antigravity import Agent, LocalAgentConfig, from_bytes
            api_key = os.environ.get("GEMINI_API_KEY")
            if not api_key:
                return None

            if not shot_video_paths or len(shot_video_paths) < 1:
                return None

            junction_bytes = _extract_junction_frame_bytes(shot_video_paths)
            if not junction_bytes:
                return None

            cfg = LocalAgentConfig(
                response_schema=VideoCriticVerdict,
                system_instructions=(
                    "You are the Lead Cinematic Pacing & Concat Critic for 'Thập Ngũ Niên'. "
                    "Evaluate multi-shot scene flow: 180-degree axis continuity, eyeline matches, color grading consistency, "
                    "and editing tempo. Score >= 0.8 approves."
                ),
                api_key=api_key,
                model="gemini-2.5-flash"
            )
            agent = Agent(config=cfg)
            prompt = (
                f"Evaluate Scene '{scene_id}' consisting of {len(shot_video_paths)} shots. "
                f"Inspect attached pairwise junction frames (tail frame vs head frame) for continuity, eyeline, and color flow."
            )
            inputs = [prompt]
            for jb in junction_bytes:
                inputs.append(from_bytes(jb, mime_type="image/jpeg"))

            resp = agent.chat(inputs)
            if hasattr(resp, "structured_output") and resp.structured_output:
                if isinstance(resp.structured_output, VideoCriticVerdict):
                    return resp.structured_output
                if isinstance(resp.structured_output, dict):
                    return VideoCriticVerdict.model_validate(resp.structured_output)
            if hasattr(resp, "text") and resp.text:
                clean_json = re.sub(r"^```json\s*", "", resp.text.strip())
                clean_json = re.sub(r"\s*```$", "", clean_json)
                return VideoCriticVerdict.model_validate_json(clean_json)
        except Exception:
            return None
        return None


class GoogleGenAIEngine:
    """
    Động cơ dự phòng thứ cấp sử dụng Google GenAI Client (gemini-2.5-flash).
    Đính kèm keyframe JPEG Parts thực tế vào contents.
    """

    @classmethod
    def evaluate_shot(
        cls,
        shot_id: str,
        video_path_str: str,
        expected_character: str,
        prompt: str
    ) -> Optional[VideoCriticVerdict]:
        try:
            import google.genai as genai
            api_key = os.environ.get("GEMINI_API_KEY")
            if not api_key:
                return None

            video_path = Path(video_path_str)
            if not video_path.exists() or video_path.stat().st_size == 0:
                return None

            keyframe_bytes = _extract_keyframe_bytes(video_path, max_frames=4)
            if not keyframe_bytes:
                return None

            client = genai.Client(api_key=api_key)
            prompt_text = (
                f"You are the Lead Cinematic Critic Auditor for 'Thập Ngũ Niên'. "
                f"Evaluate Shot '{shot_id}'. Expected Character: '{expected_character}'. "
                f"Motion Prompt: '{prompt}'. "
                f"Inspect the attached keyframes for Character Bible conformance, facial morphing, squinting/extra limbs, "
                f"and visual quality. Return VideoCriticVerdict JSON."
            )
            contents = [prompt_text]
            for kb in keyframe_bytes:
                contents.append(genai.types.Part.from_bytes(data=kb, mime_type="image/jpeg"))

            resp = client.models.generate_content(
                model="gemini-2.5-flash",
                contents=contents,
                config=genai.types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=VideoCriticVerdict
                )
            )
            if resp.text:
                return VideoCriticVerdict.model_validate_json(resp.text)
        except Exception:
            return None
        return None

    @classmethod
    def evaluate_scene(cls, scene_id: str, shot_video_paths: List[str]) -> Optional[VideoCriticVerdict]:
        try:
            import google.genai as genai
            api_key = os.environ.get("GEMINI_API_KEY")
            if not api_key:
                return None

            if not shot_video_paths or len(shot_video_paths) < 1:
                return None

            junction_bytes = _extract_junction_frame_bytes(shot_video_paths)
            if not junction_bytes:
                return None

            client = genai.Client(api_key=api_key)
            prompt_text = (
                f"You are the Lead Cinematic Pacing & Concat Critic for 'Thập Ngũ Niên'. "
                f"Evaluate Scene '{scene_id}' continuity across {len(shot_video_paths)} shots. "
                f"Inspect the attached pairwise junction keyframes (tail frame of shot N vs head frame of shot N+1) "
                f"for 180-degree axis continuity, eyeline match, color grading consistency, and editing tempo. "
                f"Return VideoCriticVerdict JSON."
            )
            contents = [prompt_text]
            for jb in junction_bytes:
                contents.append(genai.types.Part.from_bytes(data=jb, mime_type="image/jpeg"))

            resp = client.models.generate_content(
                model="gemini-2.5-flash",
                contents=contents,
                config=genai.types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=VideoCriticVerdict
                )
            )
            if resp.text:
                return VideoCriticVerdict.model_validate_json(resp.text)
        except Exception:
            return None
        return None
```

---

## 4. Test Suite Enhancements

To guarantee that the remediated module is thoroughly verified and prevents regressions, the following test cases must be added:

### 1. In `tests/test_critic_gate.py`:
- Add test `test_evaluate_shot_gate_frozen_video_rejected`:
  Assert `verdict.overall_score <= 0.65`, `verdict.approved is False`, `verdict.suggested_action == "RETAKE_SHOT"`.
- Add test `test_evaluate_shot_gate_character_contamination_vuong_ong`:
  Create video from `thuy_kieu_maiden_16yo_720p.png`, evaluate against `expected_character="vuong_ong"`.
  Assert `verdict.overall_score <= 0.50`, `verdict.shot_eval.character_match is False`, `verdict.approved is False`, `verdict.suggested_action == "RETAKE_SHOT"`.
- Add test `test_evaluate_shot_gate_character_contamination_kim_trong`:
  Create video from `thuy_kieu_maiden_16yo_720p.png`, evaluate against `expected_character="kim_trong"`.
  Assert `verdict.overall_score <= 0.50`, `verdict.shot_eval.character_match is False`, `verdict.approved is False`.
- Add test `test_evaluate_scene_gate_missing_or_corrupt_files`:
  Call `evaluate_scene_gate` with `["nonexistent_1.mp4", "nonexistent_2.mp4"]`.
  Assert `verdict.overall_score == 0.0`, `verdict.approved is False`, `verdict.suggested_action == "RETAKE_SHOT"`.
- Add test `test_evaluate_scene_gate_corrupted_files`:
  Call `evaluate_scene_gate` with 2 files containing garbage bytes.
  Assert `verdict.overall_score == 0.0`, `verdict.approved is False`, `verdict.suggested_action == "RETAKE_SHOT"`.
- Add test `test_mllm_engines_offline_fallback`:
  Verify that with `GEMINI_API_KEY=""`, `AntigravitySDKEngine` and `GoogleGenAIEngine` return `None`.
- Add test `test_mllm_engines_mock_multimodal_binding`:
  Mock `genai.Client` and `Agent` to verify that `contents` and `inputs` receive non-empty image byte parts.

### 2. In `tests/test_adversarial_critic_gate_m1.py`:
- Update `test_shot_gate_frozen_frames_defect_reporting`:
  Add assertions:
  ```python
  assert verdict.overall_score <= 0.65
  assert verdict.approved is False
  assert verdict.suggested_action == "RETAKE_SHOT"
  ```
- Add test `test_scene_gate_adversarial_missing_and_corrupt_files`:
  Pass combination of missing, 0-byte, and corrupt files.
  Assert `verdict.overall_score == 0.0, verdict.approved is False, verdict.suggested_action == "RETAKE_SHOT"`.

---

## 5. Verification Protocol

Once the implementer applies the patches, verify with the following commands:

```powershell
# 1. Run all critic gate tests
python -m pytest tests/test_critic_gate.py -v

# 2. Run all adversarial challenge tests
python -m pytest tests/test_adversarial_critic_gate_m1.py -v

# 3. Verify reproduction one-liners fail to reproduce and pass safety assertions:
# 3a. Missing scene files:
python -c "import sys; sys.path.insert(0, '05_Production_Pipeline'); from antigravity_critic_gate import evaluate_scene_gate; v = evaluate_scene_gate('sc_fake', ['nonexistent_1.mp4', 'nonexistent_2.mp4'], force_engine='heuristic'); assert v.overall_score == 0.0 and v.approved is False and v.suggested_action == 'RETAKE_SHOT'; print('PASS: Scene Gate correctly rejected missing files!')"

# 3b. Frozen video:
python -c "import sys, tempfile, cv2, numpy as np; sys.path.insert(0, '05_Production_Pipeline'); from antigravity_critic_gate import evaluate_shot_gate; f = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False); out = cv2.VideoWriter(f.name, cv2.VideoWriter_fourcc(*'mp4v'), 24, (1280, 720)); [out.write(np.full((720, 1280, 3), 100, dtype=np.uint8)) for _ in range(120)]; out.release(); v = evaluate_shot_gate('shot_frozen', f.name, force_engine='heuristic'); assert v.overall_score <= 0.65 and v.approved is False and v.suggested_action == 'RETAKE_SHOT'; print('PASS: Frozen video correctly rejected with score <= 0.65!')"

# 3c. Thúy Kiều video for Vương Ông:
python -c "import sys, tempfile, cv2; sys.path.insert(0, '05_Production_Pipeline'); from antigravity_critic_gate import evaluate_shot_gate; img = cv2.imread('04_Assets/characters/01_Main_Protagonists/thuy_kieu_maiden_16yo_720p.png'); f = tempfile.NamedTemporaryFile(suffix='.mp4', delete=False); out = cv2.VideoWriter(f.name, cv2.VideoWriter_fourcc(*'mp4v'), 24, (img.shape[1], img.shape[0])); [out.write(img) for _ in range(48)]; out.release(); v = evaluate_shot_gate('shot_vo', f.name, expected_character='vuong_ong', force_engine='heuristic'); assert v.overall_score <= 0.50 and v.approved is False and v.shot_eval.character_match is False; print('PASS: Thuy Kieu as Vuong Ong correctly detected as cross-contamination!')"

# 4. Run entire regression suite
python -m pytest tests/test_tier1_features.py -k "not test_render" -v
python -m pytest tests/test_m2_hygiene.py -v
```
