diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b8c5f2..c53cfc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Fixed +- **2000 字现场切片留住未跟踪新文件(DEBUG #57 续)**:失败 episode 与成功事件不再对 `capture_diff_snapshot` 的结果做 `[:2000]`。两臂都传入 `max_chars=2000`,截断在 helper 内完成。已跟踪 diff 会占满预算时,先裁跟踪前缀,固定留 480 字(不超过预算的四分之一)给未跟踪新文件块;没有合格未跟踪文件时,这截尾部还给跟踪 diff。返回长度不超过预算。`.venv` / `node_modules` / `__pycache__` / `.git` / 受保护文件 / `memory/`、`.evolver/` 等运行态目录 / 含 NUL 的二进制仍不进入补充。不改固化门,不写 Parent baseline,不开 val 密封。 - **失败现场 diff 包含未跟踪新文件(DEBUG #57)**:`capture_diff_snapshot` 在 `git diff HEAD` 之后追加未跟踪新文件的 unified diff。失败侧 `_failure_diff` 与成功侧共用这一 helper。依赖树、缓存、运行态目录、受保护路径和含 NUL 的二进制不进入这份补充。不改固化门,不写 Parent baseline,不开 val 密封。 ### Docs diff --git a/DEBUG.md b/DEBUG.md index 52abec3..d405569 100644 --- a/DEBUG.md +++ b/DEBUG.md @@ -1098,9 +1098,22 @@ (`memory/`、`.evolver/` 等,与 `_is_runtime_state` 同口径)以及文件头 含 NUL 的二进制;读入量不超过快照剩余预算。不改固化门,不写 Parent baseline,不开 val 密封。 +- **续(2000 字切片仍会切掉补丁)**:上面的补丁接在跟踪 diff 末尾。失败侧 + `_failure_diff` 与成功事件随后做 `[:2000]`;helper 自己在 + `len(raw) > max_chars` 时也直接返回跟踪 diff 前缀。已跟踪 diff 超过或 + 接近 2000 字时,未跟踪块被前缀切掉,`notes.md` 的标记在真实大 diff 的 + 失败现场和成功 `diff_snapshot` 里仍看不见。 +- **修复(续)**:两臂改为 `capture_diff_snapshot(..., max_chars=2000)` + (`_STORED_DIFF_CHARS`),截断只发生在 helper 内,不再做第二次前缀切片。 + 已跟踪 diff 会把尾部挤到 480 字以下时,先裁跟踪前缀,把这 480 字 + (且不超过预算的四分之一)留给未跟踪新文件块;没有合格未跟踪文件时, + 尾部还给跟踪 diff。返回长度不超过 `max_chars`,不抬高 2000 字预算。 + 跳过名单不变。不改固化门,不写 Parent baseline,不开 val 密封。 + DEBUG #61 与 #68 的宿主契约余项不动。 - **经验**:**凡断言 diff 内容,先确认文件是不是未跟踪**——修之前 `git diff HEAD` 看不见新文件。快照要看见新文件,就改共用 helper, 不要给失败侧单开一条路。运行态与依赖树不能跟候选正文抢那 2000 字。 + **截断也要在同一 helper 里做**:先拼再 `[:2000]`,补在末尾的证据还是会被切掉。 ### 58. 外部 MCP 评审 P1 四项(评估会话,全修) diff --git a/src/evolver/gep/git_ops.py b/src/evolver/gep/git_ops.py index 2c5e94b..5ab2bc4 100644 --- a/src/evolver/gep/git_ops.py +++ b/src/evolver/gep/git_ops.py @@ -121,6 +121,11 @@ def git_list_untracked_files(cwd: Path | str | None = None) -> list[str]: } ) _BINARY_SNIFF_BYTES: Final = 8192 +_TRUNCATION_MARK: Final = "\n...[truncated]\n" +# One short new-file hunk is about 120 characters. 480 keeps that hunk (or +# the start of a larger one) inside the caller's budget. A quarter cap stops +# a tiny max_chars from handing the whole snapshot to the supplement. +_UNTRACKED_TAIL_CHARS: Final = 480 def _untracked_snapshot_candidate(rel: str) -> bool: @@ -187,6 +192,25 @@ def _untracked_diff_text(cwd: Path, *, budget: int) -> str: return "".join(chunks) +def _clip_diff(text: str, limit: int) -> str: + """Clip ``text`` to ``limit`` characters, truncation mark included.""" + if limit <= 0 or not text: + return "" + if len(text) <= limit: + return text + mark = _TRUNCATION_MARK + if limit <= len(mark): + return text[:limit] + return text[: limit - len(mark)] + mark + + +def _untracked_tail_budget(max_chars: int) -> int: + """Fixed untracked tail. Never more than a quarter of ``max_chars``.""" + if max_chars <= 0: + return 0 + return min(_UNTRACKED_TAIL_CHARS, max_chars // 4) + + def capture_diff_snapshot( cwd: Path | str | None = None, max_chars: int = 80_000, @@ -198,20 +222,26 @@ def capture_diff_snapshot( its content is appended as a ``diff --git`` new-file hunk. Failure and success snapshots share this helper. Noise trees, runtime-state dirs, protected paths, and binaries are left out of the untracked supplement. - The result is truncated to ``max_chars``. + + The result is at most ``max_chars``. When the tracked diff would leave + less than the untracked tail, the tracked prefix is clipped first so the + tail stays inside the budget. With nothing eligible to append, that tail + goes back to the tracked diff. """ root = Path(cwd) if cwd is not None else Path.cwd() - raw = try_run_cmd(["diff", "HEAD"], cwd=root) - if len(raw) > max_chars: - return raw[:max_chars] + "\n...[truncated]\n" - extra = _untracked_diff_text(root, budget=max_chars - len(raw)) - if extra: - if raw and not raw.endswith("\n"): - raw += "\n" - raw += extra - if len(raw) > max_chars: - return raw[:max_chars] + "\n...[truncated]\n" - return raw + if max_chars <= 0: + return "" + tracked = try_run_cmd(["diff", "HEAD"], cwd=root) + tail = _untracked_tail_budget(max_chars) + tracked_over_tail = tail and len(tracked) > max_chars - tail + tracked_limit = max_chars - tail if tracked_over_tail else max_chars + tracked_view = _clip_diff(tracked, tracked_limit) + joiner_len = 1 if tracked_view and not tracked_view.endswith("\n") else 0 + extra = _untracked_diff_text(root, budget=max_chars - len(tracked_view) - joiner_len) + if not extra: + return _clip_diff(tracked, max_chars) + joiner = "\n" if joiner_len else "" + return tracked_view + joiner + extra def is_critical_protected_path(rel_path: str) -> bool: diff --git a/src/evolver/gep/solidify.py b/src/evolver/gep/solidify.py index 8f3ff7f..c2656fa 100644 --- a/src/evolver/gep/solidify.py +++ b/src/evolver/gep/solidify.py @@ -132,9 +132,15 @@ def _failure_event( return event +# Stored scene budget for failure episodes and success events. Applied inside +# capture_diff_snapshot so a large tracked diff cannot push untracked hunks +# past the cut (DEBUG #57). +_STORED_DIFF_CHARS: Final = 2000 + + def _failure_diff(cwd: Path) -> str: """The mutation's diff, captured before rollback. After rollback it is gone.""" - return capture_diff_snapshot(cwd)[:2000] + return capture_diff_snapshot(cwd, max_chars=_STORED_DIFF_CHARS) def _remember_failure_scene( @@ -2212,7 +2218,7 @@ def _solidify_cycle( return pack_rejection blast_radius = _compute_blast_radius() - diff_snapshot = capture_diff_snapshot(cwd) + diff_snapshot = capture_diff_snapshot(cwd, max_chars=_STORED_DIFF_CHARS) # S26.3 strict-improvement gate (wikiskill R_val > R_best): compare the # measured score against the fitness ledger. Shadow period: verdict is @@ -2246,7 +2252,7 @@ def _solidify_cycle( "signals": last_run.get("signals", []), "mutation": mutation, "blast_radius": blast_radius, - "diff_snapshot": diff_snapshot[:2000], + "diff_snapshot": diff_snapshot, # S26 honest score: a MEASURED cascade result (1.0 = all stages green); # an unvalidated success claims nothing (score=None + marker) so the # ledger distinguishes "tested good" from "never tested". diff --git a/tests/gep/test_solidify.py b/tests/gep/test_solidify.py index 5fcaa68..944b23b 100644 --- a/tests/gep/test_solidify.py +++ b/tests/gep/test_solidify.py @@ -251,6 +251,31 @@ def test_hypothesis_rejection_records_untracked_new_file(git_ws: Path) -> None: assert not (git_ws / "notes.md").exists() +def _write_oversized_tracked_readme(git_ws: Path) -> None: + (git_ws / "README.md").write_text("TRACKED_HEAD_" + ("n" * 3000) + "\n", encoding="utf-8") + + +def test_hypothesis_rejection_keeps_untracked_when_tracked_diff_fills_budget(git_ws: Path) -> None: + """DEBUG #57: a 2000-char failure scene still shows notes.md when tracked diff is huge.""" + _write_oversized_tracked_readme(git_ws) + (git_ws / "notes.md").write_text("untracked-notes-marker\n", encoding="utf-8") + write_state_for_solidify(_last_run()) + result = solidify() + assert result["ok"] is False + assert result["error"] == "hypothesis_missing" + from evolver.gep.episode_record import load_episode, record_round + + stored = record_round(event_id=str(result["event_id"])) + body = load_episode(stored["id"]) + assert body is not None + diff = str(body["diff"]) + assert "TRACKED_HEAD_" in diff + assert "diff --git a/notes.md b/notes.md" in diff + assert "untracked-notes-marker" in diff + assert len(diff) <= 2000 + assert not (git_ws / "notes.md").exists() + + def test_success_diff_snapshot_includes_untracked_new_file( git_ws: Path, armed_pack: dict[str, Any], @@ -277,6 +302,36 @@ def test_success_diff_snapshot_includes_untracked_new_file( assert "diff --git a/notes.md" in str(evt.get("diff_snapshot") or "") +def test_success_diff_snapshot_keeps_untracked_when_tracked_diff_fills_budget( + git_ws: Path, + armed_pack: dict[str, Any], + declared_hypothesis: dict[str, Any], +) -> None: + """The success event uses the same 2000-char budget and still shows notes.md.""" + _ = armed_pack, declared_hypothesis + set_flag("enable_fitness_cascade", False, persist=False) + _write_oversized_tracked_readme(git_ws) + (git_ws / "notes.md").write_text("untracked-notes-marker\n", encoding="utf-8") + write_state_for_solidify( + _last_run( + mutation={ + "id": "mut_untracked_large", + "validation": [[sys.executable, "-c", "print(1)"]], + } + ) + ) + result = solidify() + assert result["ok"] is True + evt = json.loads( + (get_gep_assets_dir() / "events.jsonl").read_text(encoding="utf-8").strip().splitlines()[-1] + ) + diff = str(evt.get("diff_snapshot") or "") + assert "TRACKED_HEAD_" in diff + assert "diff --git a/notes.md" in diff + assert "untracked-notes-marker" in diff + assert len(diff) <= 2000 + + def test_hypothesis_rejection_leaves_a_recordable_scene(git_ws: Path) -> None: """A settled Reject is an engine scene: gene, diff, and the gate verdict.""" (git_ws / "README.md").write_text("changed by the candidate\n", encoding="utf-8") diff --git a/tests/test_git_ops.py b/tests/test_git_ops.py index 09e1015..9ca5bfa 100644 --- a/tests/test_git_ops.py +++ b/tests/test_git_ops.py @@ -6,6 +6,7 @@ from pathlib import Path from evolver.gep.git_ops import ( + _format_new_file_diff, capture_diff_snapshot, count_file_lines, is_constraint_counted_path, @@ -163,6 +164,73 @@ def test_capture_diff_snapshot_skips_noise_and_binaries(tmp_path: Path) -> None: assert marker not in snap +def _stripped_git_diff(repo: Path) -> str: + result = subprocess.run( + ["git", "diff", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + return result.stdout.strip() + + +def _write_tracked_diff_of_length(repo: Path, target: int) -> str: + """Replace README.md so the stripped ``git diff HEAD`` is ``target`` chars.""" + prefix = "TRACKED_HEAD_" + (repo / "README.md").write_text(prefix + "\n", encoding="utf-8") + base = _stripped_git_diff(repo) + extra = target - len(base) + assert extra >= 0, f"diff header {len(base)} already exceeds {target}" + (repo / "README.md").write_text(prefix + ("n" * extra) + "\n", encoding="utf-8") + raw = _stripped_git_diff(repo) + assert len(raw) == target + return raw + + +def test_capture_diff_snapshot_keeps_untracked_when_tracked_exceeds_budget(tmp_path: Path) -> None: + """A tracked diff longer than max_chars must not eat the untracked tail.""" + _init_repo(tmp_path) + raw = _write_tracked_diff_of_length(tmp_path, 2500) + (tmp_path / "notes.md").write_text("untracked-notes-marker\n", encoding="utf-8") + assert "untracked-notes-marker" not in raw[:2000] + snap = capture_diff_snapshot(tmp_path, max_chars=2000) + assert "TRACKED_HEAD_" in snap + assert "diff --git a/notes.md b/notes.md" in snap + assert "untracked-notes-marker" in snap + assert "[truncated]" in snap + assert len(snap) <= 2000 + + +def test_capture_diff_snapshot_keeps_untracked_when_tracked_nearly_fills_budget( + tmp_path: Path, +) -> None: + """A tracked diff that leaves less than one new-file hunk still keeps the hunk.""" + _init_repo(tmp_path) + raw = _write_tracked_diff_of_length(tmp_path, 1900) + note = "untracked-notes-marker\n" + (tmp_path / "notes.md").write_text(note, encoding="utf-8") + hunk = _format_new_file_diff("notes.md", note) + assert len(hunk) > 2000 - len(raw) + snap = capture_diff_snapshot(tmp_path, max_chars=2000) + assert "TRACKED_HEAD_" in snap + assert "untracked-notes-marker" in snap + assert len(snap) <= 2000 + + +def test_capture_diff_snapshot_does_not_reserve_tail_for_noise(tmp_path: Path) -> None: + """Dependency noise is not untracked evidence, so the tracked diff keeps the budget.""" + _init_repo(tmp_path) + _write_tracked_diff_of_length(tmp_path, 2500) + noise = tmp_path / ".venv" / "lib" / "x.py" + noise.parent.mkdir(parents=True) + noise.write_text("noise-venv-marker\n", encoding="utf-8") + snap = capture_diff_snapshot(tmp_path, max_chars=2000) + assert "noise-venv-marker" not in snap + assert "TRACKED_HEAD_" in snap + assert len(snap) == 2000 + + def test_capture_diff_snapshot_truncates(tmp_path: Path) -> None: subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) subprocess.run(