Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions DEBUG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 四项(评估会话,全修)

Expand Down
54 changes: 42 additions & 12 deletions src/evolver/gep/git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
12 changes: 9 additions & 3 deletions src/evolver/gep/solidify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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".
Expand Down
55 changes: 55 additions & 0 deletions tests/gep/test_solidify.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand All @@ -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")
Expand Down
68 changes: 68 additions & 0 deletions tests/test_git_ops.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
Loading