diff --git a/src/repo2rlenv/reward.py b/src/repo2rlenv/reward.py index 2caa3fe..f0bb267 100644 --- a/src/repo2rlenv/reward.py +++ b/src/repo2rlenv/reward.py @@ -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) @@ -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 diff --git a/tests/test_reward.py b/tests/test_reward.py index d2a5c1f..067813e 100644 --- a/tests/test_reward.py +++ b/tests/test_reward.py @@ -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