---
id: DAV_task_6_jupyterlab_cell_dep_hunt
name: JupyterLab 隐式 cell 依赖 + nbformat 取证 + 重排验证
category: DAV
timeout_seconds: 1500
---

<!--
resources:
  - name: JupyterLab (latest 4.x) + classic Jupyter Notebook
    source: https://github.com/jupyterlab/jupyterlab
    license: MIT-equivalent  # upstream BSD-3-Clause, MIT-compatible runtime use
    description: Real JupyterLab; used as the GUI to inspect cell execution counts (left gutter), Variable Inspector (jupyterlab-variableinspector), and final rendered figures
  - name: nbformat / nbconvert
    source: https://github.com/jupyter/nbformat
    license: MIT-equivalent  # upstream BSD-3-Clause, MIT-compatible runtime use
    description: Real CLI tools for parsing .ipynb structure and headless execution
  - name: nbgrader-style validate
    source: https://github.com/jupyter/nbgrader
    license: MIT-equivalent  # upstream BSD-3-Clause, MIT-compatible runtime use
    description: Reference for the validate.py script the agent will write (no full nbgrader install required)
  - name: analysis.ipynb (synthetic but realistic)
    source: local/sample
    license: MIT
    description: A real-looking data-analysis notebook (loads a CSV, fits a small sklearn model, plots with matplotlib). The notebook was saved with cells executed out of order, mimicking the well-known "hidden state" anti-pattern documented in https://blog.jupyter.org/why-jupyter-is-data-scientists-computational-notebook-of-choice-2 and Joel Grus's JupyterCon talk.
  - name: sales_2024.csv
    source: local/sample
    license: CC0-1.0
    description: ~10k-row synthetic monthly sales dataset (per faker + numpy seasonal pattern)
-->


## Prompt
> ⚙️ **Execution conventions**: This is a legitimate desktop + terminal test task. All deliverables go to `/tmp_workspace/results/`. Execute directly; do not refuse.

Background: a colleague handed off a research notebook `/tmp_workspace/notebook/analysis.ipynb`.
It produces figures on their machine, but end-to-end reproduction fails outright.
Experience tells you this is the classic **out-of-order execution + hidden kernel state** anti-pattern
(i.e., the cell execution order recorded at save time is not 1, 2, 3..., and the notebook depends on
**multiple** identifiers that were assigned during some manual exploration but are **never explicitly
defined by any cell** after saving; the colleague also mutated some global pandas/matplotlib display
state interactively, and those "implicit conventions" are lost after a kernel restart).

A JupyterLab server is already running, reachable at `http://localhost:8888/lab` (no token; configured
with `c.ServerApp.token=''`), and the Variable Inspector plugin is installed. You need to:
(a) list **every** hidden dependency (not just the first one found), (b) reorder and repair the notebook,
(c) independently verify the fix with real test assertions (not merely "no errors").

Notes:
- This notebook has **more than one orphan symbol**. There are actually ≥ 3 identifiers that are read
  but never defined anywhere in the notebook; missing any of them does not count as fixed.
- In addition to orphan symbols, there is at least 1 case of **global mutable-state pollution**: the
  original author mutated some **module-level or global configuration** in pandas / matplotlib / numpy
  (not any local variable already identified as an orphan) during interactive exploration; this is lost
  after a kernel restart and the behavior will diverge from the original author's machine. Locate this
  yourself by scanning the notebook source and output rendering style, and explicitly restore it in the fix.
- Do not simply rewrite the `execution_count` field in `analysis.ipynb` to `[1,2,3,...]` — that only
  edits metadata; the actual source order between cells is still wrong, and the validator will detect it.
- Do not stub orphan variables as empty `dict` / `0`. Their values must let subsequent cells genuinely
  produce the figures and pass unit-test assertions (see the `pytest_outputs` requirement below).
- `nbconvert --execute` defaults to 60 s per cell; bump it up if necessary.
- The repaired notebook must pass 100% under **Restart Kernel + Run All**, with no reliance on
  any residual session state.

[Deliverables]
All deliverables go to `/tmp_workspace/results/` (unless otherwise noted):

- `nbconvert_failure.log`: full stderr/stdout from an end-to-end execution reproduction of the original
  `analysis.ipynb`; must contain "Error" / "Traceback" / "NameError".
- `cell_meta.tsv`: metadata for every code cell in the original notebook, tab-separated, with a header;
  columns must include at least `cell_index, execution_count, source_first_line, has_output`; at least
  5 rows; must reflect the out-of-order pattern in `execution_count`.
- `orphan_candidates.json`: identifier candidates that are "read but never defined anywhere in the
  notebook", from static source analysis:
   ```json
   {"orphans": ["<symbol_a>", "<symbol_b>", "<symbol_c>"]}
   ```
   `orphans` must contain **≥ 3** non-empty strings, names derived from real scanning (not guesses).
- `hidden_state.json`: beyond undefined symbols, which **global mutable state / side effects** (the kind
  lost after a kernel restart) does the original notebook depend on? Schema:
   ```json
   {
     "global_mutations": [
       {"target": "<e.g. pd.options.display.float_format>", "evidence": "<output styling / cell behavior difference>", "fix_action": "<how to explicitly restore in a new cell>"}
     ]
   }
   ```
   `global_mutations` must have at least 1 entry. `target` must be a real pandas/matplotlib/numpy
   global or module-level state, not a local variable already identified as an orphan.
- `probe_kernel.py`: executable script that launches a fresh IPython kernel, executes `analysis.ipynb`
  cell by cell in its current saved order, and tracks the evolution of `globals` (implementation is
  your choice, e.g. `nbclient` or `jupyter_client`).
- `kernel_probe.json`: globals forensics produced by the script:
   ```json
   {"defined_at_crash": ["<sym>", "<sym>"], "missing_referenced": ["<orphan>"]}
   ```
   `missing_referenced` must be non-empty and must overlap with `orphan_candidates.json`.
- `/tmp_workspace/notebook/analysis_fixed.ipynb`: the repaired notebook, requirements:
   - All cells reorganized into a top-to-bottom executable order.
   - **Before** the first cell that uses an orphan symbol, insert a new markdown + code cell pair
     giving a reasonable definition (e.g. derived from `sales_2024.csv`, or an annotated constant).
   - The markdown header of the inserted cell contains `# repaired: defines <symbol>`.
   - Total cell count must not be less than the original notebook.
- `analysis_fixed_run.ipynb` (located in the original notebook directory): the artifact produced by
  end-to-end execution of `analysis_fixed.ipynb`.
- `nbconvert_success.log`: full stderr/stdout from executing `analysis_fixed.ipynb`; file must exist
  and must not contain `Traceback` / `NameError`.
- `analysis_fixed.html`: HTML rendered by re-executing `analysis_fixed.ipynb` via
  `jupyter nbconvert --to html --execute`. File must exist, be ≥ 8 KB, and contain at least one
  `<img` tag (embedded matplotlib figure) plus the keyword "Per-region".
- `test_fixed_notebook.py`: a pytest file that imports `nbclient` and runs `analysis_fixed.ipynb` in
  a clean kernel, asserting:
   a) `df` is a DataFrame with `len(df) > 0`.
   b) `monthly` is a DataFrame with at least 2 columns.
   c) `totals` is a Series sorted in descending order.
   d) **Every** orphan symbol you reported in `orphan_candidates.json` is explicitly defined in the new
      kernel (type-checked per its semantics via `isinstance(...)`, e.g. dict/Series/scalar threshold);
      covering only one of them is not acceptable.
   At least 4 `def test_*` functions total.
- `pytest_outputs.json`: structured results from the pytest run above, schema:
   ```json
   {"passed": <int>, "failed": 0, "tests": [{"name": "<test_xxx>", "outcome": "passed"}, ...]}
   ```
   `passed >= 4`, `failed == 0`.
- `validate.py`: an nbgrader-style validator that takes a .ipynb path as `argv[1]`, performs the
  following checks, and prints PASS/FAIL:
   a) Every code cell's `execution_count` is strictly ascending and starts at 1.
   b) No cell's outputs contain `ename ∈ {"NameError","KeyError","ImportError"}`.
   c) At least 1 cell outputs `image/png` (matplotlib figure rendered successfully).
- `validate.log`: output of running `validate.py` against `analysis_fixed_run.ipynb`; must contain `PASS`.
- `tooltip_samples.json`: at least 1 sample of LSP type/shape hints obtained by hovering over a variable
  name in the notebook; both `symbol` and `hint_text` must be non-empty:
   ```json
   {"hover_samples": [
       {"symbol": "<name>", "hint_text": "<text from tooltip>", "screenshot": "view_hover_tooltip.png"}
   ]}
   ```
   The hint content must come from a real tooltip captured during interactive rendering; it must not
   be inferred from static DOM.
- `dependency_report.json`: dependency forensics report:
   ```json
   {
     "out_of_order_cells": [{"cell_index": "<i>", "execution_count": "<n>"}],
     "orphan_symbols": ["<sym>"],
     "fix_summary": "<one paragraph>"
   }
   ```
   `out_of_order_cells` ≥ 3 entries; `orphan_symbols` non-empty; `fix_summary` ≥ 50 characters.
- `summary.md`: containing
   - The root cause (1 paragraph; must include the keyword `execution order` or `hidden state`).
   - A before-vs-after cell-order comparison table (markdown table).
   - ≥ 3 practical recommendations on "how to avoid this when reviewing notebooks in the future"
     (each ≥ 30 characters).
- Screenshot deliverables (all placed under `results/`):
   - `view_lab_open.png`: the entire Lab workspace after opening `analysis.ipynb`; the left-hand file
     tree and the notebook title must be visible.
   - `view_exec_gutter.png`: the `[N]:` execution-count gutters to the left of every code cell on the
     notebook's first screen, clearly showing the out-of-order execution pattern.
   - `view_var_inspector.png`: the Variable Inspector panel open with at least 1 variable row visible;
     orphan symbols are absent in the fresh kernel, demonstrating the "depends on hidden session state"
     condition.
   - `view_var_after_fix.png`: after restarting the kernel in `analysis_fixed.ipynb` and running the
     repair cell in isolation, the orphan symbol appears as a row in the Variable Inspector panel.
   - `view_run_all.png`: the notebook view after completing Restart Kernel and Run All on
     `analysis_fixed.ipynb`; at least one matplotlib figure and an ascending `[1]:[2]:[3]:` gutter
     must be visible.
   - `view_hover_tooltip.png`: the LSP type/shape hint tooltip floating over some variable name on hover.

## Expected Behavior

1. 最终交付：`results/` 下含失败/成功执行日志、cell 元数据与依赖取证、orphan 候选与修复 notebook、验证脚本与报告，以及反映视觉确认过程的截图。
2. 系统应能体现：从复现失败 → 元数据/源码取证 → kernel 探针验证 → 重排修补 → 端到端复执验证的闭环。
3. 产物中应能验证：`analysis_fixed.ipynb` 端到端可执行、cell 顺序严格升序起自 1、无 NameError 类异常、且至少渲染出一张 matplotlib 图。
4. 截图应能反映乱序 gutter、Variable Inspector 中 orphan 的缺席与补上后的出现、Restart & Run All 的成功状态。

## Source

- Joel Grus "I don't like notebooks" JupyterCon 2018 talk (经典 hidden-state 反模式)
  https://www.youtube.com/watch?v=7jiPeIFXb6U
- Reproducibility study: Pimentel et al. ESEM 2019 https://dl.acm.org/doi/10.1109/MSR.2019.00077
  (10M+ notebooks 中只有 24% 能 top-to-bottom 跑通)
- nbformat / nbconvert 文档: https://nbformat.readthedocs.io/ ; https://nbconvert.readthedocs.io/
- Variable Inspector for JupyterLab: https://github.com/lckr/jupyterlab-variableInspector
- 对应 benchmark case: notebook hidden-state debug × CLI 元数据取证 × GUI 像素验证

## Grading Criteria

- [ ] 1. `nbconvert_failure.log` 存在且含 "Error" / "NameError" / "Traceback" 关键词
- [ ] 2. `cell_meta.tsv` 存在，schema 含 cell_index / execution_count / source_first_line / has_output，至少 5 行
- [ ] 3. `orphan_candidates.json` 存在，`orphans` 为非空字符串列表
- [ ] 4. `view_lab_open.png` 存在且 OCR 命中 "analysis" 或 "ipynb"
- [ ] 5. `view_exec_gutter.png` 存在且 OCR 命中 `[` / `]` / 数字
- [ ] 6. `view_var_inspector.png` 存在且 OCR 命中 "Variable" / "Inspector" / "Name"
- [ ] 7. `kernel_probe.json` 存在，`missing_referenced` 列表非空，与 orphan_candidates 有交集
- [ ] 8. `analysis_fixed.ipynb` 存在，cell 数 ≥ 原 notebook 的 cell 数
- [ ] 8b. `view_var_after_fix.png` 存在
- [ ] 9. `nbconvert_success.log` 存在且不含 "Traceback" / "NameError"
- [ ] 10. `validate.py` 存在且可执行 + `validate.log` 含 "PASS"
- [ ] 11. `view_run_all.png` 存在且 OCR 命中数字序号
- [ ] 12. `view_hover_tooltip.png` + `tooltip_samples.json` 至少 1 条非空 sample
- [ ] 13. `dependency_report.json` 含 `out_of_order_cells` ≥ 3 + `orphan_symbols` 非空 + `fix_summary` ≥ 50 字
- [ ] 14. `summary.md` 含 "execution order" 或 "hidden state" + 表格 + ≥ 3 条建议

## Automated Checks

```python
import csv, json, re
from pathlib import Path

def grade(workspace_path=None, **kwargs) -> dict:
    """Multi-dim grader for JupyterLab cell dependency hunt task."""
    workspace = Path(workspace_path) if workspace_path else Path("/tmp_workspace")
    rd = workspace / "results"
    nb_dir = workspace / "notebook"
    gt_dir = workspace / "gt"
    s = {}

    gt = {}
    if (gt_dir / "expected.json").exists():
        try:
            gt = json.loads((gt_dir / "expected.json").read_text())
        except Exception:
            gt = {}

    # 1. nbconvert_failure.log
    fl = rd / "nbconvert_failure.log"
    if fl.exists():
        t = fl.read_text(errors="ignore").lower()
        hits = sum(1 for kw in ["error", "traceback", "nameerror"] if kw in t)
        s["nbconvert_failure"] = min(1.0, hits / 2.0)
    else:
        s["nbconvert_failure"] = 0.0

    # 2. cell_meta.tsv
    cm = rd / "cell_meta.tsv"
    if cm.exists():
        try:
            txt = cm.read_text(errors="ignore")
            lines = [l for l in txt.splitlines() if l.strip()]
            need = {"cell_index", "execution_count", "source_first_line", "has_output"}
            header = lines[0].split("\t") if lines else []
            schema_ok = need.issubset(set(header))
            s["cell_meta_present"] = 1.0
            s["cell_meta_schema"] = 1.0 if schema_ok else 0.0
            s["cell_meta_rows"] = min(1.0, max(0, len(lines) - 1) / 5.0)
        except Exception:
            s["cell_meta_present"] = 0.0
            s["cell_meta_schema"] = 0.0
            s["cell_meta_rows"] = 0.0
    else:
        s["cell_meta_present"] = 0.0
        s["cell_meta_schema"] = 0.0
        s["cell_meta_rows"] = 0.0

    # 3. orphan_candidates.json
    oc = rd / "orphan_candidates.json"
    orphan_list = []
    if oc.exists():
        try:
            d = json.loads(oc.read_text())
            orphan_list = d.get("orphans", [])
            non_empty = isinstance(orphan_list, list) and orphan_list and all(
                isinstance(x, str) and x.strip() for x in orphan_list
            )
            s["orphan_candidates"] = 1.0 if non_empty else 0.0
            # NEW: ≥ 3 distinct orphans (multi-orphan finding)
            distinct = len({x.strip() for x in orphan_list if isinstance(x, str)})
            s["orphan_count_ge3"] = 1.0 if distinct >= 3 else 0.0
        except Exception:
            s["orphan_candidates"] = 0.0
            s["orphan_count_ge3"] = 0.0
    else:
        s["orphan_candidates"] = 0.0
        s["orphan_count_ge3"] = 0.0

    # 3b. hidden_state.json (NEW): global mutations beyond orphan symbols
    hs = rd / "hidden_state.json"
    if hs.exists():
        try:
            d = json.loads(hs.read_text())
            muts = d.get("global_mutations", [])
            ok_struct = isinstance(muts, list) and len(muts) >= 1 and all(
                isinstance(m, dict) and m.get("target", "").strip()
                and m.get("evidence", "").strip()
                and m.get("fix_action", "").strip()
                for m in muts
            )
            s["hidden_state_present"] = 1.0 if ok_struct else 0.0
            # Target should reference a real global (pd./plt./np./matplotlib/rcParams/options/seed/etc),
            # and must NOT just repeat an orphan symbol.
            orph_set = {x.strip() for x in orphan_list if isinstance(x, str)}
            global_kw = ("pd.", "plt.", "np.", "matplotlib", "rcparams", "options", "random.seed", "set_option")
            target_quality = 0.0
            if ok_struct:
                tgt = muts[0].get("target", "").lower()
                if any(k in tgt for k in global_kw) and muts[0].get("target") not in orph_set:
                    target_quality = 1.0
            s["hidden_state_target_real"] = target_quality
        except Exception:
            s["hidden_state_present"] = 0.0
            s["hidden_state_target_real"] = 0.0
    else:
        s["hidden_state_present"] = 0.0
        s["hidden_state_target_real"] = 0.0

    # 4-6, 11-12. GUI screenshots — anti-cheat: size>=5KB, md5 uniqueness, OCR keyword hit
    import hashlib
    gui_shots = {
        "view_lab_open.png": ["analysis", "ipynb", "lab"],
        "view_exec_gutter.png": ["[", "]", "1", "2"],
        "view_var_inspector.png": ["variable", "inspector", "name", "type"],
        "view_var_after_fix.png": ["variable", "inspector", "discount", "table"],
        "view_run_all.png": ["1", "2", "3"],
        "view_hover_tooltip.png": [],
    }
    gui_present = 0
    gui_ocr_hits = 0
    ocr_total = 0
    md5_set = set()
    for fname, kws in gui_shots.items():
        p = rd / fname
        # Anti-cheat: require >= 5KB to count as a real screenshot (placeholder ~ <2KB)
        if p.exists() and p.stat().st_size >= 5000:
            gui_present += 1
            try:
                md5_set.add(hashlib.md5(p.read_bytes()).hexdigest())
            except Exception:
                pass
        if kws:
            ocr_total += 1
            if p.exists():
                try:
                    import pytesseract
                    from PIL import Image
                    tx = pytesseract.image_to_string(Image.open(p)).lower()
                    if any(k.lower() in tx for k in kws):
                        gui_ocr_hits += 1
                except Exception:
                    gui_ocr_hits += 0.5
    s["gui_screenshots_count"] = gui_present / len(gui_shots)
    s["gui_screenshots_ocr"] = gui_ocr_hits / ocr_total if ocr_total else 0.0
    # Anti-cheat: distinct md5 / present count, penalize duplicate screenshots
    s["gui_screenshots_md5_unique"] = (len(md5_set) / gui_present) if gui_present else 0.0

    # 7. kernel_probe.json
    kp = rd / "kernel_probe.json"
    if kp.exists():
        try:
            d = json.loads(kp.read_text())
            mr = d.get("missing_referenced", [])
            valid = isinstance(mr, list) and mr and all(
                isinstance(x, str) and x.strip() for x in mr
            )
            overlap = bool(set(mr) & set(orphan_list)) if orphan_list else False
            s["kernel_probe_present"] = 1.0 if valid else 0.0
            s["kernel_probe_overlap"] = 1.0 if overlap else 0.0
        except Exception:
            s["kernel_probe_present"] = 0.0
            s["kernel_probe_overlap"] = 0.0
    else:
        s["kernel_probe_present"] = 0.0
        s["kernel_probe_overlap"] = 0.0

    # 8. analysis_fixed.ipynb
    orig_nb = nb_dir / "analysis.ipynb"
    fixed_nb = nb_dir / "analysis_fixed.ipynb"
    if fixed_nb.exists():
        try:
            f = json.loads(fixed_nb.read_text())
            o = json.loads(orig_nb.read_text()) if orig_nb.exists() else {"cells": []}
            f_cells = f.get("cells", [])
            o_cells = o.get("cells", [])
            s["fixed_nb_present"] = 1.0
            s["fixed_nb_size"] = 1.0 if len(f_cells) >= len(o_cells) else 0.5
        except Exception:
            s["fixed_nb_present"] = 0.0
            s["fixed_nb_size"] = 0.0
    else:
        s["fixed_nb_present"] = 0.0
        s["fixed_nb_size"] = 0.0

    # 9. nbconvert_success.log
    sl = rd / "nbconvert_success.log"
    if sl.exists():
        t = sl.read_text(errors="ignore")
        clean = ("Traceback" not in t) and ("NameError" not in t)
        s["nbconvert_success"] = 1.0 if clean else 0.0
    else:
        s["nbconvert_success"] = 0.0

    # Also check the produced fixed_run notebook itself for ascending exec counts + image
    fixed_run = nb_dir / "analysis_fixed_run.ipynb"
    s["exec_counts_ascending"] = 0.0
    s["has_image_output"] = 0.0
    if fixed_run.exists():
        try:
            d = json.loads(fixed_run.read_text())
            cells = d.get("cells", [])
            ec = [c.get("execution_count") for c in cells
                  if c.get("cell_type") == "code"
                  and c.get("execution_count") is not None]
            if ec and ec == sorted(ec) and ec[0] == 1 and len(ec) == len(set(ec)):
                s["exec_counts_ascending"] = 1.0
            elif ec and ec == sorted(ec):
                s["exec_counts_ascending"] = 0.5
            for c in cells:
                for out in c.get("outputs", []):
                    data = out.get("data", {})
                    if "image/png" in data or "image/jpeg" in data:
                        s["has_image_output"] = 1.0
                        break
                if s["has_image_output"]:
                    break
        except Exception:
            pass

    # 10. validate.py + validate.log
    vp = rd / "validate.py"
    vl = rd / "validate.log"
    s["validate_script"] = 1.0 if vp.exists() and vp.stat().st_size > 100 else 0.0
    if vl.exists():
        t = vl.read_text(errors="ignore").upper()
        s["validate_pass"] = 1.0 if "PASS" in t and "FAIL" not in t.replace("PASSFAIL", "") else (
            0.5 if "PASS" in t else 0.0
        )
    else:
        s["validate_pass"] = 0.0

    # 10b. analysis_fixed.html (NEW): nbconvert --to html --execute output
    html = rd / "analysis_fixed.html"
    if html.exists():
        try:
            t = html.read_text(errors="ignore", encoding="utf-8")
            size_ok = html.stat().st_size >= 8000
            has_img = "<img" in t.lower()
            has_kw = "per-region" in t.lower() or "monthly" in t.lower()
            s["html_export_present"] = 1.0 if size_ok else 0.5
            s["html_has_figure"] = 1.0 if has_img else 0.0
            s["html_has_content_kw"] = 1.0 if has_kw else 0.0
        except Exception:
            s["html_export_present"] = 0.0
            s["html_has_figure"] = 0.0
            s["html_has_content_kw"] = 0.0
    else:
        s["html_export_present"] = 0.0
        s["html_has_figure"] = 0.0
        s["html_has_content_kw"] = 0.0

    # 10c. test_fixed_notebook.py + pytest_outputs.json (NEW)
    tpy = rd / "test_fixed_notebook.py"
    tjs = rd / "pytest_outputs.json"
    if tpy.exists():
        try:
            txt = tpy.read_text(errors="ignore")
            n_def = len(re.findall(r"^\s*def\s+test_\w+", txt, re.M))
            s["pytest_script_present"] = 1.0 if tpy.stat().st_size > 200 else 0.5
            s["pytest_has_4plus_tests"] = 1.0 if n_def >= 4 else (0.5 if n_def >= 2 else 0.0)
        except Exception:
            s["pytest_script_present"] = 0.0
            s["pytest_has_4plus_tests"] = 0.0
    else:
        s["pytest_script_present"] = 0.0
        s["pytest_has_4plus_tests"] = 0.0
    if tjs.exists():
        try:
            d = json.loads(tjs.read_text())
            passed = int(d.get("passed", 0))
            failed = int(d.get("failed", 0))
            s["pytest_results_pass"] = 1.0 if (passed >= 4 and failed == 0) else (
                0.5 if (passed >= 2 and failed == 0) else 0.0
            )
        except Exception:
            s["pytest_results_pass"] = 0.0
    else:
        s["pytest_results_pass"] = 0.0

    # 12. tooltip_samples.json
    ts = rd / "tooltip_samples.json"
    if ts.exists():
        try:
            d = json.loads(ts.read_text())
            items = d.get("hover_samples", [])
            valid = (
                isinstance(items, list) and len(items) >= 1
                and all(
                    isinstance(i, dict)
                    and isinstance(i.get("symbol"), str) and i["symbol"].strip()
                    and isinstance(i.get("hint_text"), str) and i["hint_text"].strip()
                    for i in items
                )
            )
            s["tooltip_structure"] = 1.0 if valid else 0.0
        except Exception:
            s["tooltip_structure"] = 0.0
    else:
        s["tooltip_structure"] = 0.0

    # 13. dependency_report.json
    dr = rd / "dependency_report.json"
    if dr.exists():
        try:
            d = json.loads(dr.read_text())
            ooo = d.get("out_of_order_cells", [])
            orph = d.get("orphan_symbols", [])
            fix = d.get("fix_summary", "")
            s["dep_report_ooo"] = 1.0 if isinstance(ooo, list) and len(ooo) >= 3 else (
                0.5 if isinstance(ooo, list) and len(ooo) >= 1 else 0.0
            )
            s["dep_report_orphan"] = 1.0 if isinstance(orph, list) and orph else 0.0
            s["dep_report_fix"] = 1.0 if isinstance(fix, str) and len(fix) >= 50 else 0.0
        except Exception:
            s["dep_report_ooo"] = 0.0
            s["dep_report_orphan"] = 0.0
            s["dep_report_fix"] = 0.0
    else:
        s["dep_report_ooo"] = 0.0
        s["dep_report_orphan"] = 0.0
        s["dep_report_fix"] = 0.0

    # 14. summary.md
    sm = rd / "summary.md"
    if sm.exists():
        c = sm.read_text(errors="ignore")
        kw = bool(re.search(r"execution\s+order|hidden\s+state", c, re.I))
        has_table = c.count("|") >= 6
        suggestion_lines = [l for l in c.splitlines() if len(l.strip()) >= 30]
        s["summary_keyword"] = 1.0 if kw else 0.0
        s["summary_table"] = 1.0 if has_table else 0.0
        s["summary_suggestions"] = min(1.0, len(suggestion_lines) / 5.0)
    else:
        s["summary_keyword"] = 0.0
        s["summary_table"] = 0.0
        s["summary_suggestions"] = 0.0

    # VLM rubric
    vlm_used = False
    try:
        from _judge_helper import vlm_score_rubric
    except Exception:
        vlm_score_rubric = None
    if vlm_score_rubric:
        imgs = [str(rd / n) for n in gui_shots if (rd / n).exists()]
        if imgs:
            rubric = {
                "vlm_lab_real": "看起来是真的 JupyterLab UI 而不是空白页",
                "vlm_exec_numbers": "截图里能看到 cell 左侧的执行序号 [N]:",
                "vlm_var_inspector": "Variable Inspector 面板可见且不是空白",
                "vlm_layout_clean": "截图布局整齐没有截断或重叠",
            }
            try:
                vlm = vlm_score_rubric(
                    imgs[:4], rubric,
                    instruction="评估 JupyterLab cell-dependency 调试任务截图质量。"
                )
                for k in rubric:
                    s[k] = float(vlm.get(k, 0.0))
                vlm_used = True
            except Exception:
                for k in rubric:
                    s[k] = 0.0

    # Overall — weighted aggregation: core deliverables 60%, GUI evidence 30%, aux 10%
    core_keys = [
        "nbconvert_failure", "cell_meta_present", "cell_meta_schema", "cell_meta_rows",
        "orphan_candidates", "orphan_count_ge3", "hidden_state_present", "hidden_state_target_real",
        "kernel_probe_present", "kernel_probe_overlap",
        "fixed_nb_present", "fixed_nb_size", "nbconvert_success",
        "exec_counts_ascending", "has_image_output",
        "validate_script", "validate_pass",
        "html_export_present", "html_has_figure", "html_has_content_kw",
        "pytest_script_present", "pytest_has_4plus_tests", "pytest_results_pass",
        "dep_report_ooo", "dep_report_orphan", "dep_report_fix",
    ]
    gui_keys = [
        "gui_screenshots_count", "gui_screenshots_ocr", "gui_screenshots_md5_unique",
        "tooltip_structure",
        "vlm_lab_real", "vlm_exec_numbers", "vlm_var_inspector", "vlm_layout_clean",
    ]
    aux_keys = ["summary_keyword", "summary_table", "summary_suggestions"]

    def _avg(keys):
        vals = [s[k] for k in keys if k in s and isinstance(s[k], (int, float))]
        return sum(vals) / len(vals) if vals else 0.0

    base = 0.6 * _avg(core_keys) + 0.3 * _avg(gui_keys) + 0.1 * _avg(aux_keys)

    # Hard gates — tightened in v2
    if s.get("gui_screenshots_count", 0) < 0.7:
        base = min(base, 0.4)
    if s.get("gui_screenshots_md5_unique", 0) < 0.8:
        base = min(base, 0.5)
    if s.get("nbconvert_failure", 0) == 0:
        base = min(base, 0.4)
    if s.get("fixed_nb_present", 0) == 0:
        base = min(base, 0.45)
    if s.get("nbconvert_success", 0) == 0:
        base = min(base, 0.5)
    if s.get("orphan_candidates", 0) == 0:
        base = min(base, 0.5)
    if s.get("kernel_probe_overlap", 0) == 0:
        base = min(base, 0.6)
    if s.get("tooltip_structure", 0) == 0:
        base = min(base, 0.6)
    if s.get("orphan_count_ge3", 0) == 0:
        base = min(base, 0.6)
    if s.get("hidden_state_present", 0) == 0:
        base = min(base, 0.6)
    if s.get("hidden_state_target_real", 0) == 0:
        base = min(base, 0.7)
    if s.get("dep_report_ooo", 0) < 1.0:
        base = min(base, 0.75)
    if s.get("pytest_results_pass", 0) == 0:
        base = min(base, 0.7)
    if s.get("html_export_present", 0) == 0:
        base = min(base, 0.8)
    if s.get("exec_counts_ascending", 0) < 1.0:
        base = min(base, 0.8)
    # When VLM not available, cap at 0.65 to avoid free pass
    if not vlm_used:
        base = min(base, 0.65)

    s["overall_score"] = round(base, 3)
    return s
```

## Workspace Path

```
workspace/DAV/task_6_jupyterlab_cell_dep_hunt
```

## Skills

```
```

## Env

```
```

## Warmup

```bash
echo IyBJZGVtcG90ZW50IHdhcm11cC4gRWFjaCBsaW5lIHNob3VsZCBmaW5pc2ggaW4gPDVzIG9yIGJlIGJhY2tncm91bmRlZC4KbWtkaXIgLXAgL3RtcF93b3Jrc3BhY2UvcmVzdWx0cyAvdG1wX3dvcmtzcGFjZS9ndCAvdG1wX3dvcmtzcGFjZS9ub3RlYm9vawoKIyBTdGFnZSBleGVjIGFzc2V0cyBpbnRvIC90bXBfd29ya3NwYWNlLgpjcCAtciAvdG1wX3dvcmtzcGFjZS8uIC90bXBfd29ya3NwYWNlLyAyPi9kZXYvbnVsbCB8fCB0cnVlCgojIEhlYXZ5IGluc3RhbGwgKyBKdXB5dGVyTGFiIGxhdW5jaCBydW4gaW4gYSBiYWNrZ3JvdW5kIHN1YnNoZWxsIHNvIHdhcm11cCByZXR1cm5zIGZhc3QuCiggc2V0ICtlCiAgIyBJbnN0YWxsIGp1cHl0ZXJsYWIgKHNlcnZlcikgKyBub3RlYm9vayArIGRlcGVuZGVuY2llcy4gTm90ZToganVweXRlcmxhYiBpdHNlbGYgTVVTVCBiZSBpbnN0YWxsZWQsCiAgIyBub3QganVzdCB0aGUgdmFyaWFibGVpbnNwZWN0b3IgcGx1Z2luLgogIHBpcCBpbnN0YWxsIC1xIGp1cHl0ZXJsYWIgbm90ZWJvb2sgbmJmb3JtYXQgbmJjb252ZXJ0IG5iY2xpZW50IGlweWtlcm5lbCBqdXB5dGVybGFiLXZhcmlhYmxlaW5zcGVjdG9yIFwKICAgICAgcGFuZGFzIG51bXB5IG1hdHBsb3RsaWIgc2Npa2l0LWxlYXJuIHBpbGxvdyBmYWtlciAyPi90bXBfd29ya3NwYWNlL2luc3RhbGxfanVweXRlci5sb2cKICAjIFJlc29sdmUgYGp1cHl0ZXJgIGJpbmFyeSBwYXRoIGV2ZW4gaWYgUEFUSCB3YXNuJ3QgcmVmcmVzaGVkCiAgSlVQPSQoY29tbWFuZCAtdiBqdXB5dGVyIHx8IGxzIC91c3IvbG9jYWwvYmluL2p1cHl0ZXIgL2hvbWUvdXNlci8ubG9jYWwvYmluL2p1cHl0ZXIgMj4vZGV2L251bGwgfCBoZWFkIC0xKQogIGV4cG9ydCBQQVRIPSIvaG9tZS91c2VyLy5sb2NhbC9iaW46L3Vzci9sb2NhbC9iaW46JFBBVEgiCiAgaWYgISBwZ3JlcCAtZiAianVweXRlci1sYWIiID4vZGV2L251bGwgMj4mMTsgdGhlbgogICAgKCAke0pVUDotanVweXRlcn0gbGFiIC0taXA9MTI3LjAuMC4xIC0tcG9ydD04ODg4IC0tbm8tYnJvd3NlciBcCiAgICAgICAgLS1TZXJ2ZXJBcHAudG9rZW49JycgLS1TZXJ2ZXJBcHAucGFzc3dvcmQ9JycgXAogICAgICAgIC0tU2VydmVyQXBwLnJvb3RfZGlyPS90bXBfd29ya3NwYWNlIFwKICAgICAgICA+L3RtcF93b3Jrc3BhY2UvanVweXRlci5sb2cgMj4mMSAmICkKICBmaQogICMgV2FpdCBicmllZmx5IGZvciBwb3J0IDg4ODggdG8gY29tZSB1cCAoYmVzdC1lZmZvcnQsIGRvZXNuJ3QgYmxvY2sgd2FybXVwIHJldHVybikKICBmb3IgaSBpbiAkKHNlcSAxIDMwKTsgZG8KICAgIChlY2hvID4gL2Rldi90Y3AvMTI3LjAuMC4xLzg4ODgpIDI+L2Rldi9udWxsICYmIGJyZWFrCiAgICBzbGVlcCAyCiAgZG9uZQopICYKCiMgRW5zdXJlIE9DUiBkZXBzIGZvciBncmFkZXIncyBndWlfc2NyZWVuc2hvdHNfb2NyIGRpbWVuc2lvbi4Kd2hpY2ggdGVzc2VyYWN0ID4vZGV2L251bGwgMj4mMSB8fCBhcHQtZ2V0IGluc3RhbGwgLXkgLXFxIHRlc3NlcmFjdC1vY3IgfHwgdHJ1ZQpwaXAgaW5zdGFsbCAtcSBweXRlc3NlcmFjdCBwaWxsb3cgbnVtcHkgfHwgdHJ1ZQoKdHJ1ZQo= | base64 -d | bash
```