diff --git a/docs/FINDINGS.md b/docs/FINDINGS.md new file mode 100644 index 0000000..72d5ab5 --- /dev/null +++ b/docs/FINDINGS.md @@ -0,0 +1,122 @@ +# Embryo staging: experiment log & findings + +Classifying C. elegans embryo developmental stages from light-sheet volumes, +evaluated on embryos 5–8 (session `2cfd8f4e`, 705 frames, ~3.7 min/frame), +3 seeds per experiment, claude-opus-4-6. Starting point: 62.2% exact. Current +champion: **hybrid_annot2 at 69.5% ± 0.2 exact / 87.8% adjacent** — all of the +gain from text rules, none of it from richer visual input. + +## The ladder + +| # | experiment | exact | adjacent | verdict | +|---|------------|-------|----------|---------| +| 0 | hybrid (baseline, May 28) | 62.2 ± 3.1 | 83.2 | starting point | +| 1 | hybrid_nodefer — remove defer-to-previous instructions | 64.2 ± 0.8 | 83.1 | **+2.0**, variance collapsed | +| 2 | hybrid_3dviews — static rotated MIPs (45/90/135°) | 61.9 ± 0.8 | 83.6 | −2.3: coils merge in projections, model undercounts folds | +| 3 | hybrid_3dviews2 — MIPs demoted to 1.5f/2f tie-breaker | 58.9 ± 3.8 | 82.7 | worse: views destabilize pretzel anyway | +| 4 | hybrid_annot — stage criteria rewritten from the annotator's notes (tail progress + 60-min timing rule) | 66.3 ± 0.2 | 87.0 | **+2.1**, gains exactly where aimed | +| 5 | hybrid_annot2 — late-pretzel vs hatching disambiguation, cadence-aware | **69.5 ± 0.2** | **87.8** | **+3.2**, killed the 99-frame hatching cascade; champion | +| 6 | hybrid_annotviews — static raymarched views at the annotator's poses | 66.5 ± 4.0 | 85.2 | −3.0: even faithful 3D renders dilute | +| 7 | hybrid_agentic3d — model rotates the 3D render itself (view3d tool) | 64.9 ± 5.3 | 82.0 | −4.6: renders re-litigated the settled hatching call | +| 8 | hybrid_explorer3d — full viewer autonomy (rotate + zoom + click) | 67.1 ± 1.1 | 85.9 | −2.4: best-behaved 3D variant, still loses | +| 9 | hybrid_contrast3d — explorer + contrastive boundary descriptions | 67.0 ± 1.6 | 86.4 | flat vs explorer; target stages unmoved | +| 10 | hybrid_pairwise — previous frame's image + comparison framing | 68.9 ± 1.3 | 87.0 | tie (paired Δ −0.5, CI [−1.6, +0.5]); lag unmoved | +| 11 | hybrid_prevonly — previous frame's image alone (ablation) | 63.7 ± 1.6 | 85.0 | **−5.7, CI [−7.2, −4.3]** — decisively harmful | + +Reference points from embryos 1–4 (session `59799c78`): hybrid 86.2%, +agentic_baseline (all tools) 80.2% — embryos 5–8 are substantially harder, +and tools cost accuracy there too. + +## Findings + +### 1. Every win was a decision rule; every image-side change was flat or negative + +The +7.3 points from baseline to champion decompose entirely into text: +removing the defer-to-previous anchor (+2.0), the annotator's tail-progress +criteria and his explicit 2fold→pretzel timing rule (+2.1), and the +late-pretzel vs hatching cadence rules (+3.2). Six experiments added visual +information — rotated MIPs, faithful raymarched views, model-driven 3D +navigation with zoom, the previous frame — and not one of them improved on +its text-only base. Agentic tool use carries a consistent ~2.4-point tax on +this task (explorer3d/contrast3d vs annot2), with tool selection itself +behaving exactly as prompted. + +### 2. The model under-updates: transition lag is a decision behavior, not a perception gap + +The dominant failure all along has been one-stage lag at boundaries (median +8–9 frames) plus brief stages missed outright (comma's window is ~5 frames; +it scores 0–5% everywhere). Three increasingly direct perception fixes failed +to move the lag at all: contrastive descriptions of each boundary's first +visible change (written from the e1–4 transition frames), full 3D viewing +autonomy, and finally showing the previous and current frames side by side — +lag median 8 vs 9, behind-failures 655 vs 641, identical clusters. The model +sees the change and does not act on it. + +The prevonly ablation is the sharpest evidence: an **unguided** previous +frame made things much worse (−5.7), shifting every confusion laggier — +consecutive frames are ~95% identical pixels, and visual similarity reads as +"same stage" louder than any boundary cue. The comparison-first framing in +pairwise repaired ~5 of those 6 points. Inputs that support persistence +amplify the bias; framings that force the update question neutralize it. + +### 3. The labels themselves are partly time-derived + +Kesavan's notes state outright that he placed the 2fold→pretzel boundary +with a stereotypic-timing rule ("~60 minutes after 2fold onset") because +coils are genuinely hard to count in projections. Appearance-only +classification therefore fights the ground truth at that boundary. Telling +the model this honestly — and converting all time rules to run-time-measured +cadence (frame interval from acquisition timestamps, so the same solver works +on any series) — produced the single largest win. + +### 4. What the failure map looks like now (annot2) + +~30% of predictions still miss. Composition: late arrival (~390/seed at its +peak, now the lag tail), brief windows missed (33 of 81 transition windows), +ahead-errors eliminated (99 → ~0–5 after the hatching rules). Per stage: +early/hatched ~100%, pretzel ~70, bean ~33, 2fold ~26, 1.5fold ~14, comma ~5. + +### 5. Infrastructure lessons + +Transient API errors (grammar-compilation 400s, 529s) killed four multi-hour +runs at top level — `harness/core/model.py` has no retry; seeds were +recovered with a resume script. The tool dispatch cache is content-addressed +on params but not on tool code version, so changing a tool's implementation +silently serves stale renders. Tool media files key on turn index, so +parallel calls in one turn overwrite each other (the report now re-renders +steps from recorded params). `view3d` post-processing (brightness stretch + +content crop) broke pixel-equivalence with the annotator viewer — replays are +faithful to what the model saw, but what the model saw was not what the +annotator saw. + +## Suggested next steps + +1. **Generalize the timing-window recipe to every boundary.** The one + boundary with an elapsed-time rule and an explicit exception to the + prefer-earlier bias (2fold→pretzel) is the one transition that improved. + Derive per-stage duration windows from the e1–4 session, and at each + boundary have the prompt switch from "prefer earlier" to "actively hunt + the next stage's first visible change" once the window passes. This is the + only intervention class with a positive track record, aimed at the + dominant remaining failure. +2. **Anchor-free second opinion at suspected transitions.** When the timing + window has passed, reclassify the frame blind (no history, no previous + stage) and reconcile with the monotonic verifier. The bias is + persistence; removing the anchor only where lag is likely keeps stability + elsewhere. +3. **Cross-session validation.** annot2's rules were developed against + embryos 5–8. Run it on embryos 1–4 to check nothing regressed vs the 86.2% + baseline and that the cadence machinery generalizes (different session, + same ~4-min cadence). +4. **If 3D gets another attempt: annotator-native rendering.** Rebuild + view3d with no stretch/crop, the annotator's contrast knob, true camera + zoom, and a code-versioned cache — then rerun explorer3d. Five negatives + say don't expect much, but the current tool never showed the model what + the human actually saw. +5. **Data asks for the annotator.** More notes of the embryo_5/6/8 kind — + they bought +4 points. Specifically: per-boundary "what changed in this + frame" notes, typical stage durations from his experience, and ideally a + third annotated session for a held-out test set. +6. **Harness hardening.** Exponential-backoff retry around the model call in + core; tool-code version in the dispatch cache key; per-call media + filenames. diff --git a/gently_perception/render/context.py b/gently_perception/render/context.py index 2d08c98..90367aa 100644 --- a/gently_perception/render/context.py +++ b/gently_perception/render/context.py @@ -23,8 +23,21 @@ def make_context(allow_cpu: bool = False) -> moderngl.Context: - """Create a standalone moderngl context, refusing CPU fallback by default.""" - ctx = moderngl.create_context(standalone=True) + """Create a standalone moderngl context, refusing CPU fallback by default. + + Tries the platform default backend first (X11 on Linux desktops), then + EGL, which is what works on headless machines (CI, remote boxes). + """ + try: + ctx = moderngl.create_context(standalone=True) + except Exception as default_err: # glcontext raises bare Exception for backend failures + try: + ctx = moderngl.create_context(standalone=True, backend="egl") + except Exception as egl_err: + raise RuntimeError( + f"No usable GL backend: default backend failed ({default_err}); " + f"EGL failed ({egl_err})." + ) from egl_err info = ctx.info renderer = (info.get("GL_RENDERER") or "").lower() vendor = (info.get("GL_VENDOR") or "").lower() diff --git a/harness/eval/html_report.py b/harness/eval/html_report.py index 7dce3ee..cc15ebe 100644 --- a/harness/eval/html_report.py +++ b/harness/eval/html_report.py @@ -15,13 +15,15 @@ import io import json import os +import re +from datetime import datetime, timedelta from pathlib import Path from typing import Any from PIL import Image -from harness.core.render import cached_render -from harness.core.types import STAGE_ORDER, Stage +from harness.core.render import cached_render, cached_tool_result, load_volume +from harness.core.types import STAGE_ORDER, ImageResult, Stage from harness.eval import report as text_report from harness.eval.score import score_run from harness.io.events import read_events @@ -31,6 +33,14 @@ _REPO_ROOT = Path(__file__).resolve().parents[2] THUMB_LONG_EDGE = 260 +_FS_UNSAFE = re.compile(r"[^A-Za-z0-9_\-]") + + +def _fs_name(value: Any) -> str: + """Filesystem/URL-safe asset-name component. Embryo ids come from + events.jsonl, which may not be trusted (shared/downloaded run dirs).""" + return _FS_UNSAFE.sub("_", str(value)) + # --- Data assembly ---------------------------------------------------------- @@ -100,7 +110,7 @@ def _thumbnails(frames: list[dict[str, Any]], assets_dir: Path, volumes_dir: Pat key = (r["e"], r["t"]) if key not in paths: continue - thumb = assets_dir / f"{r['e']}_T{r['t']:03d}.jpg" + thumb = assets_dir / f"{_fs_name(r['e'])}_T{int(r['t']):03d}.jpg" r["thumb"] = f"report_assets/{thumb.name}" if thumb.exists(): continue @@ -133,7 +143,7 @@ def _rotated_thumbnails( continue rot: list[dict[str, Any]] = [] for angle in angles: - name = f"{r['e']}_T{r['t']:03d}_rot{int(angle)}.jpg" + name = f"{_fs_name(r['e'])}_T{int(r['t']):03d}_rot{int(angle)}.jpg" out = assets_dir / name rot.append({"a": int(angle), "src": f"report_assets/{name}"}) if out.exists(): @@ -146,6 +156,50 @@ def _rotated_thumbnails( r["rot"] = rot +def _view3d_step_assets(frames: list[dict[str, Any]], run_dir: Path, volumes_dir: Path) -> None: + """Re-render every view3d step image from its recorded params. + + The harness keys media files by model-turn index, so parallel tool calls + in one turn overwrite each other's image. view3d is deterministic, so the + recorded params are the authoritative source; renders hit the run's own + dispatch cache (same (volume, tool, params) key) and are cheap. + """ + needs = [ + (r, j, s) + for r in frames + for j, s in enumerate(r.get("steps", [])) + if s.get("name") == "view3d" and s.get("params") is not None + ] + if not needs or not volumes_dir.exists(): + return + from harness.tools import REGISTRY, _fill_defaults # deferred: pulls in GL deps + + spec = REGISTRY.get("view3d") + if spec is None: + return + paths = {(e, t): p for e, t, p in OfflineSource(volumes_dir)} + assets = run_dir / "report_assets" + assets.mkdir(parents=True, exist_ok=True) + for r, j, s in needs: + vol_ref = paths.get((r["e"], r["t"])) + if vol_ref is None: + continue + params = s["params"] + + def compute(vol_ref: Path = vol_ref, params: dict[str, Any] = params) -> str: + result = spec.fn(load_volume(vol_ref), **_fill_defaults(spec, params)) + assert isinstance(result, ImageResult) + return result.b64 + + name = f"{_fs_name(r['e'])}_T{int(r['t']):03d}_nav{j}.jpg" + out = assets / name + assert out.resolve().is_relative_to(assets.resolve()) + if not out.exists(): + b64 = cached_tool_result(Path(vol_ref), "view3d", params, compute) + out.write_bytes(base64.b64decode(b64)) + s["img"] = f"report_assets/{name}" + + def _summary(run_dir: Path, gt: GroundTruth) -> dict[str, Any]: seed_files = sorted(run_dir.glob("seed*/events.jsonl")) scores = [score_run(p, gt) for p in seed_files] @@ -305,8 +359,18 @@ def _embryo_span(ids: list[str]) -> str: return "e" + ",".join(str(n) for n in nums) +def _week_prefix(ts: str) -> str: + """'20260609-...' → '[Week of 6/8]' (the Monday of that run's week).""" + try: + run_day = datetime.strptime(ts[:8], "%Y%m%d").date() + except ValueError: + return "" + monday = run_day - timedelta(days=run_day.weekday()) + return f"[Week of {monday.month}/{monday.day}]" + + def _run_label(d: Path, gt_embryos: dict[str, str]) -> str: - """Human-readable dropdown label: what the solver change was, dataset, date.""" + """Human-readable dropdown label: week group, what the change was, dataset, date.""" solver, _model, ts = d.parts[-3:] desc = solver try: @@ -321,7 +385,33 @@ def _run_label(d: Path, gt_embryos: dict[str, str]) -> str: except Exception: pass date = f"{ts[4:6]}/{ts[6:8]}" if len(ts) >= 8 else ts - return " · ".join(x for x in (desc, span, date) if x) + body = " · ".join(x for x in (desc, span, date) if x) + week = _week_prefix(ts) + return f"{week} {body}" if week else body + + +def _experiment_description(solver_name: str) -> dict[str, Any]: + """The solver docstring's full pre-RESULT content — what was tried and why. + + Returns {"paras": [...], "setup": "..."}. RESULT paragraphs are excluded; + the report's own numbers speak for the outcome. + """ + try: + mod = importlib.import_module(f"harness.solvers.{solver_name}") + except Exception: + return {"paras": [], "setup": ""} + paras: list[str] = [] + for block in (mod.__doc__ or "").strip().split("\n\n"): + if block.strip().startswith("RESULT"): + break + paras.append(" ".join(line.strip() for line in block.splitlines())) + setup = "" + solver = getattr(mod, "SOLVER", None) + if solver is not None: + tools = ", ".join(solver.tools) if solver.tools else "none" + mode = "one-shot" if solver.max_steps == 1 else f"agentic, {solver.max_steps} steps max" + setup = f"solver {solver.name} · tools: {tools} · {mode}" + return {"paras": paras, "setup": setup} def _sibling_runs(run_dir: Path) -> list[dict[str, Any]]: @@ -339,7 +429,7 @@ def _sibling_runs(run_dir: Path) -> list[dict[str, Any]]: } dirs = {p.parent for p in runs_root.glob("*/*/*/report.html")} | {run_dir} out: list[dict[str, Any]] = [] - for d in sorted(dirs, key=lambda p: (p.parts[-3], p.parts[-1]), reverse=True): + for d in sorted(dirs, key=lambda p: p.parts[-1], reverse=True): # newest first → weeks cluster out.append( { "label": _run_label(d, gt_embryos), @@ -369,11 +459,14 @@ def generate( vols = volumes_dir or (_REPO_ROOT / "data" / "volumes") _thumbnails(frames, run_dir / "report_assets", vols) _rotated_thumbnails(frames, run_dir / "report_assets", vols, str(config.get("solver", ""))) + _view3d_step_assets(frames, run_dir, vols) data: dict[str, Any] = { "config": config, "run_dir": str(run_dir), + "experiment": _experiment_description(str(config.get("solver", ""))), "runs": _sibling_runs(run_dir), + "findings_href": os.path.relpath(_REPO_ROOT / "runs" / "findings.html", run_dir.resolve()), "seed": seed, "summary": _summary(run_dir, gt), "clusters": _failure_clusters(run_dir, gt, detail_seed=seed), @@ -485,9 +578,12 @@ def generate( .rotRow figure{flex:1;margin:0} .rotRow img{width:100%;border-radius:5px;background:#000;display:block} .rotRow figcaption{font:10px var(--mono);color:var(--dim);letter-spacing:.08em;text-align:center;margin-top:5px;text-transform:uppercase} -.step{border:1px solid var(--line);border-radius:6px;background:var(--panel2);padding:11px 14px;margin-bottom:10px;font:12px var(--mono)} -.step .sn{color:var(--accent);margin-right:8px} -.step img{max-width:340px;display:block;margin-top:9px;border-radius:4px;background:#000} +.navHdr{font:11px var(--mono);color:var(--dim);letter-spacing:.14em;text-transform:uppercase;margin:18px 0 10px} +.navStrip{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px;margin-bottom:6px} +.navStrip .step{flex:0 0 auto;width:300px;border:1px solid var(--line);border-radius:8px;background:var(--panel2);padding:10px 12px;font:11px/1.5 var(--mono)} +.step .sn{display:inline-block;min-width:18px;height:18px;line-height:18px;text-align:center;background:var(--accent);color:#000;border-radius:9px;font-weight:700;margin-right:8px} +.step .cap{color:var(--bright)} +.step img{width:100%;display:block;margin-top:9px;border-radius:5px;background:#000} .step .val{color:var(--bright);font-weight:600} .step .err{color:var(--bad)} .reason{font:13px/1.6 var(--sans);color:var(--txt);background:var(--panel2);border:1px solid var(--line);border-radius:6px;padding:13px 16px;white-space:pre-wrap} @@ -500,13 +596,24 @@ def generate( .runSel{display:flex;align-items:center;gap:10px;font:11px var(--mono);color:var(--dim);letter-spacing:.14em;text-transform:uppercase} .runSel select{background:var(--panel2);color:var(--txt);border:1px solid var(--line);border-radius:6px;padding:7px 11px;font:12px var(--mono);max-width:380px;cursor:pointer} .runSel select:hover{border-color:var(--accent)} +.tabs{display:flex;gap:4px;margin-bottom:20px;border-bottom:1px solid var(--line)} +.tabs a,.tabs .tab{font:12px var(--mono);letter-spacing:.08em;text-transform:uppercase;color:var(--dim);text-decoration:none;padding:8px 16px;border:1px solid transparent;border-bottom:none;border-radius:7px 7px 0 0} +.tabs a:hover{color:var(--bright)} +.tabs .on{color:var(--bright);background:var(--panel2);border-color:var(--line)} +.expDesc{margin:18px 0 0;padding:16px 20px;background:var(--panel2);border:1px solid var(--line);border-left:3px solid var(--accent);border-radius:8px;font:13px/1.7 var(--sans);color:var(--txt);max-width:980px} +.expDesc::before{content:"experiment";display:block;font:11px var(--mono);color:var(--dim);letter-spacing:.14em;text-transform:uppercase;margin-bottom:8px} +.expDesc p{margin:0 0 10px} +.expDesc p:last-of-type{margin-bottom:0} +.expSetup{margin-top:12px;padding-top:10px;border-top:1px dashed var(--line);font:11px var(--mono);color:var(--dim);letter-spacing:.04em}
+