Skip to content

Commit dd183f2

Browse files
committed
Replace LLM-written memory with a deterministic evidential store
Memory is now an objective log written client-side. This removes the create_conformance_test_memory API call entirely: one billable LLM call per conformance run, carrying the whole existing-files set in its prompt, is gone. Everything a record needs was already in the client. Store and retrieval: - MemoryStore is render-scoped (<build>/.memory), shared by every module in the requires chain, since a failure seen while rendering one module is just as relevant to the next one - retrieval.py ranks records instead of loading all of them: a symbolic view (exact fingerprint, then test, then file overlap) unioned with a BM25 lexical view over failure signatures, no new dependency and no embeddings - depth adapts to fix_attempts, so a first pass retrieves nothing and a deep fix loop retrieves widely. The query comes entirely from render state, so no planner call is needed - --memory-mode {off,verified,refuted,all} drives the benchmark ablation Lifecycle: - nothing is deleted any more. delete_unresolved_memory_files() discarded validated negative knowledge ("this intervention did not fix this failure"), which is the most useful thing to know inside a 20-attempt loop. Refuted records now persist and prune the search space - a repeat observation of the same attempt increments occurrences, and a later green run promotes a refuted record rather than duplicating it - a full render starts from an empty store; --render-from and --render-range continue one logical render and keep it. PrepareRepositories used to clear memory as a side effect of wiping the module folder; now that the store sits above the modules, clearing is an explicit render-level decision Two adjacent fixes: - broke the file_utils <-> plain_modules import cycle by moving the folder name constants to file_utils, which sits upstream. `import plain_modules` as a first import previously failed outright - the render-scoped .memory path is now correctly excluded from code diffs by file_utils.is_system_folder_path, which only inspects the first path segment and so never matched the old <module>/.memory location
1 parent 604abc8 commit dd183f2

21 files changed

Lines changed: 1072 additions & 208 deletions

‎codeplain_REST_api.py‎

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

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

257-
def create_conformance_test_memory(
258-
self,
259-
frid,
260-
plain_source_tree,
261-
linked_resources,
262-
existing_files_content,
263-
memory_files_content,
264-
module_name,
265-
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,
272-
run_state: RunState,
273-
):
274-
endpoint_url = f"{self.api_url}/create_conformance_test_memory"
275-
headers = {"X-API-Key": self.api_key, "Content-Type": "application/json"}
276-
277-
payload = {
278-
"frid": frid,
279-
"plain_source_tree": plain_source_tree,
280-
"linked_resources": linked_resources,
281-
"existing_files_content": existing_files_content,
282-
"memory_files_content": memory_files_content,
283-
"module_name": module_name,
284-
"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,
291-
}
292-
293-
return self.post_request(endpoint_url, headers, payload, run_state)
294-
295257
def refactor_source_files_if_needed(
296258
self, frid, module_name: str, files_to_check, existing_files_content, run_state: RunState
297259
):

‎file_utils.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,15 @@
1111
from plain2code_exceptions import UnsupportedBase64Content, UnsupportedResourceType
1212
from plain2code_nodes import Plain2CodeIncludeTag, Plain2CodeLoaderMixin
1313
from plain2code_utils import find_large_base64_blob
14-
from plain_modules import CODEPLAIN_MEMORY_SUBFOLDER, CODEPLAIN_METADATA_FOLDER
1514

1615
BINARY_FILE_EXTENSIONS = [".pyc"]
1716

17+
# Per-render bookkeeping folders that live alongside generated code but are never part
18+
# of it. Defined here rather than in plain_modules because file_utils sits upstream of
19+
# plain_modules in the import graph.
20+
CODEPLAIN_MEMORY_SUBFOLDER = ".memory"
21+
CODEPLAIN_METADATA_FOLDER = ".codeplain"
22+
1823
# Dictionary mapping of file extensions to type names
1924
FILE_EXTENSION_MAPPING = {
2025
"": "plaintext",

‎memory_management/__init__.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,26 +3,36 @@
33
from memory_management.fingerprint import fingerprint_output, normalize_output
44
from memory_management.record import (
55
AttributionConfidence,
6+
Failure,
67
Flag,
8+
Intervention,
79
InterventionTarget,
810
MemoryRecord,
11+
Scope,
912
Status,
1013
Suite,
1114
Transition,
1215
build_record,
1316
)
14-
from memory_management.store import MemoryManager
17+
from memory_management.retrieval import MemoryMode, retrieval_depth, select_records
18+
from memory_management.store import MemoryStore
1519

1620
__all__ = [
1721
"AttributionConfidence",
22+
"Failure",
1823
"Flag",
24+
"Intervention",
1925
"InterventionTarget",
20-
"MemoryManager",
26+
"MemoryMode",
2127
"MemoryRecord",
28+
"MemoryStore",
29+
"Scope",
2230
"Status",
2331
"Suite",
2432
"Transition",
2533
"build_record",
2634
"fingerprint_output",
2735
"normalize_output",
36+
"retrieval_depth",
37+
"select_records",
2838
]

‎memory_management/record.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from __future__ import annotations
1111

12+
import hashlib
1213
import json
1314
from dataclasses import asdict, dataclass, field
1415
from enum import Enum
@@ -110,7 +111,8 @@ class Scope:
110111
testing_module: str
111112
testing_frid: Optional[str]
112113
suite: str = Suite.CONFORMANCE.value
113-
test_folder: Optional[str] = None
114+
# The addressable test unit that ran: the conformance test folder for conformance
115+
# runs, the test package for unit tests.
114116
test_name: Optional[str] = None
115117

116118

@@ -203,6 +205,18 @@ def _build(dataclass_type: Any, data: dict[str, Any]) -> Any:
203205
return dataclass_type(**{key: value for key, value in data.items() if key in known_fields})
204206

205207

208+
def build_memory_id(suite: str, fingerprint: Optional[str], intervention: Intervention) -> str:
209+
"""Build the record's identity from its dedup key.
210+
211+
The id deliberately encodes exactly what ``MemoryRecord.dedup_key`` compares - the
212+
failure plus the intervention - and deliberately omits the attempt index. That makes
213+
the on-disk file name the dedup key, so re-observing the same attempt updates one
214+
record instead of accumulating near-duplicates.
215+
"""
216+
intervention_hash = hashlib.sha256(intervention.signature().encode("utf-8")).hexdigest()[:6]
217+
return f"{suite}-{fingerprint or 'none'}-{intervention_hash}"
218+
219+
206220
def build_record(
207221
scope: Scope,
208222
failure: Failure,
@@ -217,7 +231,7 @@ def build_record(
217231
flags = [Flag.TEST_FILES_MODIFIED.value] if intervention.touched_test_files else []
218232

219233
return MemoryRecord(
220-
memory_id=f"{scope.suite}-{failure.fingerprint or 'none'}-{intervention.attempt_index:02d}",
234+
memory_id=build_memory_id(scope.suite, failure.fingerprint, intervention),
221235
scope=scope,
222236
failure=failure,
223237
intervention=intervention,

‎memory_management/retrieval.py‎

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
"""Ranked retrieval over evidential memory records.
2+
3+
Two views are queried and unioned, following the multi-view indexing idea without the
4+
cost of embeddings:
5+
6+
* the **symbolic** view matches on recorded facts - same failure fingerprint, same test,
7+
overlapping files - and is the strongest signal for test failures, because an identical
8+
fingerprint is an exact match rather than a similarity guess
9+
* the **lexical** view scores normalized failure signatures with BM25, which is what
10+
surfaces near-miss failures the symbolic view cannot see
11+
12+
Retrieval depth adapts to how stuck the render is. Unlike a conversational agent we do
13+
not have to infer intent: the render already knows the current failure and how many fix
14+
attempts have been made, so no planner call is needed.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
import math
20+
import re
21+
from collections import Counter
22+
from enum import Enum
23+
from typing import Iterable, Optional
24+
25+
from memory_management.record import MemoryRecord, Status
26+
27+
# Retrieval depth by number of fix attempts already made. A first pass retrieves nothing:
28+
# there is no failure to match against yet, and injecting memory speculatively is how
29+
# context gets wasted.
30+
_DEPTH_BY_ATTEMPTS = ((0, 0), (2, 3), (5, 6))
31+
_MAX_DEPTH = 12
32+
33+
_BM25_K1 = 1.5
34+
_BM25_B = 0.75
35+
_TOKEN_PATTERN = re.compile(r"[a-z0-9_]+")
36+
37+
38+
class MemoryMode(str, Enum):
39+
"""Which records may be retrieved. Exposed as ``--memory-mode`` for benchmarking."""
40+
41+
OFF = "off"
42+
VERIFIED = "verified"
43+
REFUTED = "refuted"
44+
ALL = "all"
45+
46+
47+
def retrieval_depth(fix_attempts: int) -> int:
48+
"""How many records to retrieve, scaled to how stuck the current fix loop is."""
49+
for threshold, depth in _DEPTH_BY_ATTEMPTS:
50+
if fix_attempts <= threshold:
51+
return depth
52+
return _MAX_DEPTH
53+
54+
55+
def _allowed_statuses(mode: MemoryMode) -> set[str]:
56+
if mode is MemoryMode.OFF:
57+
return set()
58+
if mode is MemoryMode.VERIFIED:
59+
return {Status.VERIFIED.value}
60+
if mode is MemoryMode.REFUTED:
61+
return {Status.REFUTED.value}
62+
return {Status.VERIFIED.value, Status.REFUTED.value}
63+
64+
65+
def _tokenize(text: str) -> list[str]:
66+
return _TOKEN_PATTERN.findall(text.lower())
67+
68+
69+
def _bm25_scores(query: str, records: list[MemoryRecord]) -> dict[str, float]:
70+
"""Score records against the query signature. Corpus is one render, so this is cheap."""
71+
query_tokens = _tokenize(query)
72+
if not query_tokens or not records:
73+
return {}
74+
75+
documents = {record.memory_id: _tokenize(record.failure.signature) for record in records}
76+
lengths = [len(tokens) for tokens in documents.values()]
77+
average_length = sum(lengths) / len(lengths) if lengths else 0.0
78+
if average_length == 0.0:
79+
return {}
80+
81+
document_frequency: Counter = Counter()
82+
for tokens in documents.values():
83+
for token in set(tokens):
84+
document_frequency[token] += 1
85+
86+
total_documents = len(documents)
87+
scores: dict[str, float] = {}
88+
for memory_id, tokens in documents.items():
89+
if not tokens:
90+
continue
91+
term_frequency = Counter(tokens)
92+
score = 0.0
93+
for token in query_tokens:
94+
occurrences = term_frequency.get(token, 0)
95+
if occurrences == 0:
96+
continue
97+
matching_documents = document_frequency[token]
98+
idf = math.log(1 + (total_documents - matching_documents + 0.5) / (matching_documents + 0.5))
99+
denominator = occurrences + _BM25_K1 * (1 - _BM25_B + _BM25_B * len(tokens) / average_length)
100+
score += idf * (occurrences * (_BM25_K1 + 1)) / denominator
101+
if score > 0.0:
102+
scores[memory_id] = score
103+
104+
return scores
105+
106+
107+
def _symbolic_tier(
108+
record: MemoryRecord,
109+
fingerprint: Optional[str],
110+
test_name: Optional[str],
111+
files_changed: Optional[Iterable[str]],
112+
) -> Optional[int]:
113+
"""Lower tiers are stronger matches. ``None`` means the symbolic view does not match."""
114+
if fingerprint is not None and record.failure.fingerprint == fingerprint:
115+
# An exact fingerprint match on a verified intervention is the single most useful
116+
# record we can surface, so it outranks everything else.
117+
return 0 if record.status == Status.VERIFIED.value else 1
118+
if test_name is not None and record.scope.test_name == test_name:
119+
return 2
120+
if files_changed:
121+
if set(files_changed) & set(record.intervention.files_changed):
122+
return 3
123+
return None
124+
125+
126+
def rank_records(
127+
records: list[MemoryRecord],
128+
fingerprint: Optional[str] = None,
129+
test_name: Optional[str] = None,
130+
files_changed: Optional[Iterable[str]] = None,
131+
signature: Optional[str] = None,
132+
mode: MemoryMode = MemoryMode.ALL,
133+
) -> list[MemoryRecord]:
134+
"""Order records by relevance to the failure currently being worked on."""
135+
allowed = _allowed_statuses(mode)
136+
candidates = [record for record in records if record.status in allowed]
137+
if not candidates:
138+
return []
139+
140+
lexical_scores = _bm25_scores(signature, candidates) if signature else {}
141+
142+
scored: list[tuple[int, float, int, str, MemoryRecord]] = []
143+
for record in candidates:
144+
tier = _symbolic_tier(record, fingerprint, test_name, files_changed)
145+
lexical_score = lexical_scores.get(record.memory_id, 0.0)
146+
if tier is None:
147+
if lexical_score <= 0.0:
148+
continue
149+
# Lexical-only matches rank behind every symbolic match.
150+
tier = 4
151+
# More occurrences means the observation repeated within this render, which is
152+
# independent evidence; ties break toward it.
153+
scored.append((tier, -lexical_score, -record.occurrences, record.memory_id, record))
154+
155+
scored.sort(key=lambda item: item[:4])
156+
return [item[4] for item in scored]
157+
158+
159+
def select_records(
160+
records: list[MemoryRecord],
161+
depth: int,
162+
fingerprint: Optional[str] = None,
163+
test_name: Optional[str] = None,
164+
files_changed: Optional[Iterable[str]] = None,
165+
signature: Optional[str] = None,
166+
mode: MemoryMode = MemoryMode.ALL,
167+
) -> list[MemoryRecord]:
168+
"""Rank, then cut to the adaptive depth."""
169+
if depth <= 0 or mode is MemoryMode.OFF:
170+
return []
171+
172+
ranked = rank_records(
173+
records,
174+
fingerprint=fingerprint,
175+
test_name=test_name,
176+
files_changed=files_changed,
177+
signature=signature,
178+
mode=mode,
179+
)
180+
return ranked[:depth]

0 commit comments

Comments
 (0)