Skip to content

Commit aedeff5

Browse files
authored
[ENG-175] Fix-attempt journal and learnings distillation (#301)
* Record conformance test fix attempts in a journal and distill on pass The per-attempt create_conformance_test_memory call is gone; the client now records every fix attempt itself in an append-only journal so the fixer stops repeating failed attempts. - New conformance_fix_journal.FixAttemptJournal: one JSONL file per tested (module, frid) under .memory/conformance_test_journal/, holding the issue before/after each attempt, the fixer's self-reported hypothesis/approach, files changed and a normalized diff hash. A repeated diff is marked as a duplicate of the earlier attempt. - FixConformanceTest sends a compact journal digest with every /fix_conformance_tests_issue call and stores the fix_attempt_summary the server now returns as the third response element. - New DistillConformanceTestMemory postprocessing action (first step after all conformance tests pass) calls /distill_conformance_test_memory once, stores the returned key-learnings memories and deletes the journals. Failures are best-effort: journals are kept for the next distillation. - Removed the per-attempt memory bookkeeping: previous_conformance_tests_ issue_* and code_diff_files fields, delete_unresolved_memory_files. Requires the codeplain-api branch of the same name (MIN_CLIENT_VERSION 0.4.0). * Journal the prepared issue from the fix response instead of truncating raw output The journal previously kept a blind tail (8000 chars) of the raw script output, which for verbose runners like Maven could cut away the actual failure while keeping noise. The fix endpoint now returns the prepared issue it built for the fix prompt (trimmed, and LLM-summarized or truncated when oversized), and the journal records that instead: - Raw script output is stored untruncated on disk (debugging fidelity; it never travels in a payload). - Each attempt records the prepared issue the fixer actually saw; the fix digest now carries it per attempt (capped at 2000 chars) so the fixer can see how the issue evolved across attempts. - The distillation payload's initial_issue prefers the prepared form and falls back to a tail-trimmed raw output. - issue_after is gone: a failed run's output is simply the next attempt's raw issue, so nothing was stored twice. Pairs with the codeplain-api commit returning the prepared issue as the fourth element of the /fix_conformance_tests_issue response. * Exclude open attempts from the fix digest and stop cutting prepared issues Two defects surfaced by a live render: - The digest sent with a fix request included the entry just opened for the attempt being made - an empty stub the fixer has no use for. Digests (and the distillation payload) now include only attempts whose fix has been recorded, which also drops dangling entries an interrupted render leaves behind. - The 2000-char per-attempt issue cap routinely tail-truncated the server's prepared issues - including LLM summaries purpose-built for the prompt (~5k chars). The digest and payload caps are unified into a single 8000-char excerpt cap, so the bounded prepared form travels whole and only the rare oversized case is trimmed. Also opens journal files with explicit utf-8 encoding. * Share the conformance test memory store across all modules of a project Memory was scoped per module (<build>/<module>/.memory), so in a multi-module requires chain every module started with empty memory and relearned the same stack-level lessons (Maven classifier issues, logging conflicts, ...) its predecessors had already distilled. The MemoryManager now uses a project-level store at <build>/.memory, shared by every module: learnings distilled while rendering one module reach all modules rendered after it, and fix journals live there too (which also unifies the regression case where one module fixes another module's tests). Lifecycle: decided once per invocation, at the first module render. A render that starts from scratch wipes the store (mirroring the previous per-module semantics); a resumed render (--render-from past the first functionality) keeps it, including the fix attempt journals of the interrupted run. * Move the memory store back into each module's folder and inherit it along the module chain The conformance test memory (distilled learnings and fix attempt journals) now lives in <build>/<module>/.memory, next to .codeplain, code and tests. To keep learnings shared across modules, PrepareRepositories copies the previous module's conformance_test_memory into the new module's store on a fresh render - the same way the code repo is cloned from it. Journals do not travel; they are transient state of one module's render. The lifecycle needs no special handling anymore: the fresh-render wipe of the module folder removes stale memory, and a resumed render keeps memories and journals untouched.
1 parent f3a1344 commit aedeff5

14 files changed

Lines changed: 824 additions & 164 deletions

codeplain_REST_api.py

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ def fix_unittests_issue(
254254

255255
return self.post_request(endpoint_url, headers, payload, run_state)
256256

257-
def create_conformance_test_memory(
257+
def distill_conformance_test_memory(
258258
self,
259259
frid,
260260
plain_source_tree,
@@ -263,15 +263,10 @@ def create_conformance_test_memory(
263263
memory_files_content,
264264
module_name,
265265
required_modules,
266-
code_diff,
267-
conformance_tests_files,
268-
acceptance_tests,
269-
conformance_tests_issue,
270-
conformance_tests_folder_name,
271-
previous_conformance_tests_issue_old,
266+
fix_journals,
272267
run_state: RunState,
273268
):
274-
endpoint_url = f"{self.api_url}/create_conformance_test_memory"
269+
endpoint_url = f"{self.api_url}/distill_conformance_test_memory"
275270
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
276271

277272
payload = {
@@ -282,12 +277,7 @@ def create_conformance_test_memory(
282277
"memory_files_content": memory_files_content,
283278
"module_name": module_name,
284279
"required_modules": required_modules,
285-
"code_diff": code_diff,
286-
"conformance_tests_files": conformance_tests_files,
287-
"acceptance_tests": acceptance_tests,
288-
"conformance_tests_issue": conformance_tests_issue,
289-
"conformance_tests_folder_name": conformance_tests_folder_name,
290-
"previous_conformance_tests_issue": previous_conformance_tests_issue_old,
280+
"fix_journals": fix_journals,
291281
}
292282

293283
return self.post_request(endpoint_url, headers, payload, run_state)
@@ -381,6 +371,7 @@ def fix_conformance_tests_issue(
381371
conformance_tests_folder_name,
382372
current_testing_frid_high_level_implementation_plan: Optional[str],
383373
conflicting_requirements_count: int,
374+
fix_attempts_journal: list,
384375
run_state: RunState,
385376
):
386377
endpoint_url = f"{self.api_url}/fix_conformance_tests_issue"
@@ -403,6 +394,7 @@ def fix_conformance_tests_issue(
403394
"conformance_tests_folder_name": conformance_tests_folder_name,
404395
"current_testing_frid_high_level_implementation_plan": current_testing_frid_high_level_implementation_plan,
405396
"conflicting_requirements_count": conflicting_requirements_count,
397+
"fix_attempts_journal": fix_attempts_journal,
406398
}
407399

408400
if acceptance_tests is not None:

conformance_fix_journal.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
"""Deterministic journal of conformance tests fix attempts.
2+
3+
While a functionality's conformance tests are failing, every fix attempt is recorded here: the
4+
issue that triggered it, what the fixer said it tried, which files it changed and whether the tests
5+
passed afterwards. A digest of the journal is sent back with every fix request so the fixer does
6+
not repeat attempts that already failed, and once the functionality's conformance tests all pass
7+
the journal is distilled into durable memories and deleted.
8+
9+
Each entry carries the issue in two forms: the raw script output (kept untruncated, on disk only)
10+
and the prepared form the server built for the fix prompt (trimmed, and summarized or truncated
11+
when oversized) - the issue exactly the way the fixer saw it. The prepared form is what travels in
12+
the fix digest and the distillation payload; the raw form exists for humans debugging a render.
13+
An attempt's outcome text is not stored twice: the output produced after an attempt is the raw
14+
issue of the next attempt.
15+
16+
Journals are keyed by the (module, frid) whose tests are being fixed - during the regression sweep
17+
that can be a previously implemented functionality, possibly from a required module.
18+
"""
19+
20+
import hashlib
21+
import json
22+
import os
23+
import shutil
24+
25+
from plain2code_console import console
26+
27+
CONFORMANCE_TEST_JOURNAL_SUBFOLDER = "conformance_test_journal"
28+
29+
# Cap for the issue excerpts included in payloads (the per-attempt issue in the fix digest, and the
30+
# initial issue in the distillation payload). The server-prepared issue is already bounded (LLM
31+
# summary or ~10k-char truncation), so this only trims the rare oversized case - it must not
32+
# routinely cut into summaries the server already sized for a prompt.
33+
MAX_ISSUE_EXCERPT_CHARS = 8000
34+
35+
# The keys of a journal entry that make up the digest sent to the fixer and the distiller.
36+
DIGEST_KEYS = ["attempt", "hypothesis", "approach", "target", "files_changed", "duplicate_of", "result"]
37+
38+
39+
def _trim_issue(issue: str | None, max_chars: int) -> str | None:
40+
if issue is None or len(issue) <= max_chars:
41+
return issue
42+
return f"(TRUNCATED - showing the last {max_chars} characters)\n" + issue[-max_chars:]
43+
44+
45+
def normalized_diff_hash(diff_files: dict[str, str] | None) -> str | None:
46+
"""Hashes a fix's diff so that essentially identical retries can be detected.
47+
48+
Whitespace-only differences are ignored - a fix that reproduces an earlier failed change with
49+
different formatting is still the same attempt.
50+
"""
51+
if not diff_files:
52+
return None
53+
54+
normalized_parts = []
55+
for file_name in sorted(diff_files):
56+
content_lines = [line.strip() for line in (diff_files[file_name] or "").splitlines() if line.strip()]
57+
normalized_parts.append(file_name + "\n" + "\n".join(content_lines))
58+
59+
return hashlib.sha256("\n".join(normalized_parts).encode("utf-8")).hexdigest()
60+
61+
62+
class FixAttemptJournal:
63+
"""File-backed journal, one JSONL file per (module, frid) whose conformance tests are fixed."""
64+
65+
def __init__(self, memory_folder: str):
66+
self.journal_folder = os.path.join(memory_folder, CONFORMANCE_TEST_JOURNAL_SUBFOLDER)
67+
68+
def _journal_path(self, module_name: str, frid: str) -> str:
69+
return os.path.join(self.journal_folder, f"{module_name}__{frid}.jsonl")
70+
71+
def _read_entries(self, module_name: str, frid: str) -> list[dict]:
72+
journal_path = self._journal_path(module_name, frid)
73+
if not os.path.exists(journal_path):
74+
return []
75+
76+
entries = []
77+
with open(journal_path, "r", encoding="utf-8") as journal_file:
78+
for line in journal_file:
79+
line = line.strip()
80+
if not line:
81+
continue
82+
try:
83+
entries.append(json.loads(line))
84+
except json.JSONDecodeError:
85+
console.error(f"Skipping malformed journal entry in {journal_path}.")
86+
87+
return entries
88+
89+
def _write_entries(self, module_name: str, frid: str, entries: list[dict]):
90+
os.makedirs(self.journal_folder, exist_ok=True)
91+
with open(self._journal_path(module_name, frid), "w", encoding="utf-8") as journal_file:
92+
for entry in entries:
93+
journal_file.write(json.dumps(entry) + "\n")
94+
95+
def open_attempt(self, module_name: str, frid: str, attempt_no: int, issue_before: str):
96+
"""Starts a new journal entry for a fix attempt that is about to be made.
97+
98+
The raw issue is stored untruncated - it never travels in a payload, only its prepared form
99+
(recorded later by record_fix) does.
100+
"""
101+
entries = self._read_entries(module_name, frid)
102+
entries.append({"attempt": attempt_no, "issue_before_raw": issue_before})
103+
self._write_entries(module_name, frid, entries)
104+
105+
def record_fix(
106+
self,
107+
module_name: str,
108+
frid: str,
109+
fix_attempt_summary: dict | None,
110+
files_changed: list[str],
111+
target: str,
112+
diff_files: dict[str, str] | None,
113+
prepared_issue: str | None,
114+
):
115+
"""Completes the open attempt with what the fix actually did.
116+
117+
The prepared issue is the form the server built for the fix prompt - the issue exactly the
118+
way the fixer saw it. The diff hash marks the attempt as a duplicate when an earlier attempt
119+
in the same journal produced essentially the same change - the strongest signal that the
120+
fixer is going in circles.
121+
"""
122+
entries = self._read_entries(module_name, frid)
123+
if not entries:
124+
console.error("Cannot record a fix - no journal entry is open.")
125+
return
126+
127+
entry = entries[-1]
128+
if isinstance(fix_attempt_summary, dict):
129+
entry["hypothesis"] = fix_attempt_summary.get("hypothesis")
130+
entry["approach"] = fix_attempt_summary.get("approach")
131+
if prepared_issue:
132+
entry["issue_before"] = prepared_issue
133+
entry["files_changed"] = sorted(files_changed)
134+
entry["target"] = target
135+
136+
diff_hash = normalized_diff_hash(diff_files)
137+
entry["diff_hash"] = diff_hash
138+
if diff_hash is not None:
139+
for earlier_entry in entries[:-1]:
140+
if earlier_entry.get("diff_hash") == diff_hash:
141+
entry["duplicate_of"] = earlier_entry["attempt"]
142+
break
143+
144+
self._write_entries(module_name, frid, entries)
145+
146+
def record_result(self, module_name: str, frid: str, passed: bool):
147+
"""Records whether the conformance tests passed after the last fix attempt was applied.
148+
149+
A failed run's output is not stored here - it becomes the raw issue of the next attempt.
150+
Does nothing when no attempt is pending: the first test run of a functionality has no fix
151+
attempt behind it.
152+
"""
153+
entries = self._read_entries(module_name, frid)
154+
if not entries or "result" in entries[-1]:
155+
return
156+
157+
entries[-1]["result"] = "conformance tests passed" if passed else "conformance tests still failed"
158+
self._write_entries(module_name, frid, entries)
159+
160+
def build_digest(self, module_name: str, frid: str) -> list[dict]:
161+
"""Builds the compact attempt history sent with a fix request.
162+
163+
Only attempts whose fix has been recorded are included - the entry just opened for the
164+
attempt being made (and any dangling entry an interrupted render left behind) carries no
165+
information for the fixer. Each included attempt carries the prepared issue it addressed,
166+
so the fixer can see how the issue evolved across attempts. Raw outputs never travel - the
167+
current issue is sent with the request separately.
168+
"""
169+
return [self._digest_entry(entry) for entry in self._read_entries(module_name, frid) if "target" in entry]
170+
171+
@staticmethod
172+
def _digest_entry(entry: dict) -> dict:
173+
digest_entry = {key: entry[key] for key in DIGEST_KEYS if key in entry}
174+
if entry.get("issue_before"):
175+
digest_entry["issue"] = _trim_issue(entry["issue_before"], MAX_ISSUE_EXCERPT_CHARS)
176+
return digest_entry
177+
178+
def collect_all(self) -> list[dict]:
179+
"""Collects the digests of every journal, keyed by the tested module and frid, for distillation."""
180+
if not os.path.exists(self.journal_folder):
181+
return []
182+
183+
journals = []
184+
for file_name in sorted(os.listdir(self.journal_folder)):
185+
if not file_name.endswith(".jsonl") or "__" not in file_name:
186+
continue
187+
module_name, _, frid = file_name[: -len(".jsonl")].rpartition("__")
188+
entries = [entry for entry in self._read_entries(module_name, frid) if "target" in entry]
189+
if not entries:
190+
continue
191+
initial_issue = entries[0].get("issue_before") or _trim_issue(
192+
entries[0].get("issue_before_raw"), MAX_ISSUE_EXCERPT_CHARS
193+
)
194+
journal = {
195+
"module": module_name,
196+
"frid": frid,
197+
"initial_issue": initial_issue,
198+
"attempts": [self._digest_entry(entry) for entry in entries],
199+
}
200+
journals.append(journal)
201+
202+
return journals
203+
204+
def clear_all(self):
205+
"""Deletes every journal. Called after the journals have been distilled into memories."""
206+
if os.path.exists(self.journal_folder):
207+
shutil.rmtree(self.journal_folder)

0 commit comments

Comments
 (0)