Skip to content
Open
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
65 changes: 51 additions & 14 deletions src/repo2rlenv/reward.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@
_FILE_HEADER_RE = re.compile(r"^(?:---|\+\+\+) ")
_INDEX_LINE_RE = re.compile(r"^index ")
_DIFF_GIT_RE = re.compile(r"^diff --git ")
_SIMILARITY_LINE_RE = re.compile(r"^(?:similarity index|dissimilarity index)\b")
_MODE_OR_RENAME_RE = re.compile(
r"^(?:old mode|new mode|new file mode|deleted file mode|copy from|copy to|rename from|rename to)\b"
)
_NO_NEWLINE_RE = re.compile(r"^\\ No newline at end of file")


@dataclass(slots=True)
Expand All @@ -42,22 +47,54 @@ class DiffRewardMetadata:
parse_error: str | None = None


def _normalize_diff(diff: str) -> list[str]:
"""Strip volatile metadata (hunk line numbers, indices, file headers context)."""
lines: list[str] = []
def _split_diff_sections(diff: str) -> list[list[str]]:
sections: list[list[str]] = []
current: list[str] = []
for line in diff.splitlines():
if _DIFF_GIT_RE.match(line):
continue
if _INDEX_LINE_RE.match(line):
continue
if _HUNK_HEADER_RE.match(line):
lines.append("@@") # keep as a separator but drop line numbers
continue
if _FILE_HEADER_RE.match(line):
# Keep filename markers but normalize whitespace
lines.append(line.split("\t")[0].strip())
continue
lines.append(line)
if current:
sections.append(current)
current = [line]
else:
current.append(line)
if current:
sections.append(current)
return sections


def _normalize_diff(diff: str) -> list[str]:
"""Strip volatile metadata while preserving mode-only and rename-only changes."""
lines: list[str] = []
for section in _split_diff_sections(diff):
has_hunks = any(_HUNK_HEADER_RE.match(line) for line in section)
has_file_headers = any(_FILE_HEADER_RE.match(line) for line in section)

for line in section:
if _DIFF_GIT_RE.match(line):
# Keep diff --git if there are no ---/+++ markers (e.g. mode-only or rename-only changes)
if not has_file_headers:
lines.append(line.strip())
continue
if _INDEX_LINE_RE.match(line):
continue
if _SIMILARITY_LINE_RE.match(line):
continue
if _NO_NEWLINE_RE.match(line):
continue
if _MODE_OR_RENAME_RE.match(line):
# Extended headers are volatile when content hunks are present,
# but essential to preserve when there are mode-only or rename-only changes.
if not has_hunks:
lines.append(line.strip())
continue
if _HUNK_HEADER_RE.match(line):
lines.append("@@") # keep as a separator but drop line numbers
continue
if _FILE_HEADER_RE.match(line):
# Keep filename markers but normalize whitespace
lines.append(line.split("\t")[0].strip())
continue
lines.append(line)
return lines


Expand Down
84 changes: 84 additions & 0 deletions tests/test_reward.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,87 @@ def test_partial_match_scores_in_between():
b = SAMPLE_DIFF.replace("hello, world", "goodbye")
reward, _ = calculate_diff_similarity_reward(a, b)
assert 0.5 < reward < 1.0


def test_normalization_ignores_git_extended_headers_and_mode_changes():
"""Diffs that differ only in file modes or extended git headers should score 1.0."""
oracle = """diff --git a/script.py b/script.py
new file mode 100755
index 0000000..abcdef1
--- /dev/null
+++ b/script.py
@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+print("run")
\\ No newline at end of file
"""
predicted = """diff --git a/script.py b/script.py
index 0000000..abcdef1
--- /dev/null
+++ b/script.py
@@ -0,0 +1,2 @@
+#!/usr/bin/env python3
+print("run")
"""
reward, meta = calculate_diff_similarity_reward(oracle, predicted)
assert reward == 1.0
assert meta.parse_error is None


def test_mode_only_patch_scores_one():
"""An identical mode-only patch (e.g. 100644 to 100755) should score 1.0."""
diff = """diff --git a/script.sh b/script.sh
old mode 100644
new mode 100755
"""
reward, meta = calculate_diff_similarity_reward(diff, diff)
assert reward == 1.0
assert meta.parse_error is None


def test_mode_only_patch_mismatch():
"""A mode-only patch with differing target mode should score less than 1.0."""
oracle = """diff --git a/script.sh b/script.sh
old mode 100644
new mode 100755
"""
predicted = """diff --git a/script.sh b/script.sh
old mode 100644
new mode 100644
"""
reward, _ = calculate_diff_similarity_reward(oracle, predicted)
assert reward < 1.0


def test_rename_only_patch_scores_one():
"""An identical rename-only patch should score 1.0."""
oracle = """diff --git a/old.py b/new.py
similarity index 100%
rename from old.py
rename to new.py
"""
predicted = """diff --git a/old.py b/new.py
similarity index 100%
rename from old.py
rename to new.py
"""
reward, meta = calculate_diff_similarity_reward(oracle, predicted)
assert reward == 1.0
assert meta.parse_error is None


def test_multi_file_diff_with_mode_only_change():
"""Diff with both code hunks and mode-only changes should score 1.0 when identical."""
diff = """diff --git a/foo.py b/foo.py
--- a/foo.py
+++ b/foo.py
@@ -1,2 +1,2 @@
-print(1)
+print(2)
diff --git a/run.sh b/run.sh
old mode 100644
new mode 100755
"""
reward, meta = calculate_diff_similarity_reward(diff, diff)
assert reward == 1.0
assert meta.parse_error is None