Skip to content

Commit 604abc8

Browse files
committed
Add evidential memory schema and failure fingerprinting
Introduces the foundation for replacing LLM-interpreted conformance test memory with an objective observation record. No behaviour change yet: the existing MemoryManager moves into the new package unchanged and is re-exported, so every current import site keeps working. - memory_management/ becomes a package (matching the cli_output/ layout) so everything memory-related lives in one place - record.py: the record schema. A record logs an observed failure, the intervention applied against it, and the outcome. status and attribution_confidence are derived from observations, never assigned: a green run is VERIFIED, anything else is REFUTED, and confidence falls off with diff size - fingerprint.py: normalizes test-runner output into a stable fingerprint by scrubbing run-to-run varying tokens (paths, line numbers, timings, addresses, uuids). Stability is the property that matters, so tests cover pytest, Maven Surefire and Jest output across repeated runs symbols_touched is deliberately absent from the schema: diff_utils uses difflib, whose hunk headers carry no enclosing-symbol context, so it cannot be filled without language-aware parsing. Files and line counts are objective; symbol names would be a guess.
1 parent 6ddfa74 commit 604abc8

7 files changed

Lines changed: 859 additions & 2 deletions

File tree

‎CLAUDE.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ black . --check && isort . --check-only && flake8 . && mypy . --check-untyped-de
143143
- Transitions handled by triggers in `render_machine/triggers.py`
144144
- Config in `render_machine/state_machine_config.py`
145145

146-
**Memory Management** (`memory_management.py`): Tracks previously rendered code to provide context to the LLM for incremental changes. Uses git commits to persist checkpoints.
146+
**Memory Management** (`memory_management/`): Records what was already tried during a render so later steps get that context. Each memory is an objective record of an observed test failure, the intervention applied against it, and the outcome that followed — see `memory_management/record.py` for the schema and `memory_management/fingerprint.py` for the failure normalization that makes records addressable.
147147

148148
**Partial Rendering** (`partial_rendering.py`): Detects when specs or code changed and determines the optimal starting point to resume rendering (avoids full re-renders).
149149

@@ -172,7 +172,7 @@ black . --check && isort . --check-only && flake8 . && mypy . --check-untyped-de
172172
- `dry_run.py` - Dry run output (`--dry-run` flag)
173173
- `render_summary.py` - Render completion summary
174174
- `codeplain_REST_api.py` - API client
175-
- `memory_management.py` - Context tracking
175+
- `memory_management/` - Evidential memory: failure fingerprinting, record schema, store, retrieval
176176
- `git_utils.py` - Git operations for checkpointing
177177
- `file_utils.py` - File I/O utilities
178178
- `concept_utils.py` - Concept validation (***plain language feature)

‎memory_management/__init__.py‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"""Evidential memory: an objective log of failure -> intervention -> outcome observations."""
2+
3+
from memory_management.fingerprint import fingerprint_output, normalize_output
4+
from memory_management.record import (
5+
AttributionConfidence,
6+
Flag,
7+
InterventionTarget,
8+
MemoryRecord,
9+
Status,
10+
Suite,
11+
Transition,
12+
build_record,
13+
)
14+
from memory_management.store import MemoryManager
15+
16+
__all__ = [
17+
"AttributionConfidence",
18+
"Flag",
19+
"InterventionTarget",
20+
"MemoryManager",
21+
"MemoryRecord",
22+
"Status",
23+
"Suite",
24+
"Transition",
25+
"build_record",
26+
"fingerprint_output",
27+
"normalize_output",
28+
]

‎memory_management/fingerprint.py‎

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
"""Normalization of raw test-runner output into a stable failure fingerprint.
2+
3+
The requirement here is *stability*, not semantic perfection: the same underlying failure
4+
must produce the same fingerprint across attempts, even though the raw output differs in
5+
paths, line numbers, timings and object addresses between runs.
6+
7+
Everything in this module is a pure function. Console-clearing escape codes are already
8+
stripped upstream by ``render_machine.render_utils._sanitize_script_output``; this module
9+
handles the run-to-run variation that survives that step.
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import hashlib
15+
import re
16+
from typing import Optional
17+
18+
FINGERPRINT_LENGTH = 12
19+
SIGNATURE_MAX_LINES = 5
20+
EXCERPT_MAX_CHARS = 1500
21+
22+
# Applied in order. Earlier patterns must consume the structured tokens (timestamps,
23+
# UUIDs, hashes, paths) before the generic number scrubber reaches them, otherwise the
24+
# generic pattern shreds them into unrecognisable fragments.
25+
_SCRUBBERS: list[tuple[re.Pattern, str]] = [
26+
# Residual ANSI escape sequences (colour codes, cursor movement).
27+
(re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]"), ""),
28+
# ISO-8601 and common log timestamps.
29+
(
30+
re.compile(r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?"),
31+
"<TS>",
32+
),
33+
(re.compile(r"\b\d{2}:\d{2}:\d{2}(?:[.,]\d+)?\b"), "<TS>"),
34+
(re.compile(r"\b\d{4}-\d{2}-\d{2}\b"), "<DATE>"),
35+
# UUIDs before hashes, hashes before addresses, all before generic numbers.
36+
(
37+
re.compile(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"),
38+
"<UUID>",
39+
),
40+
(re.compile(r"\b0x[0-9a-fA-F]+\b"), "<ADDR>"),
41+
(re.compile(r"\b[0-9a-f]{7,40}\b"), "<HEX>"),
42+
# Paths collapse to their basename: the file name carries signal, the directory
43+
# prefix (build folder, temp dir, CI workspace) changes on every run. This must run
44+
# before any temp-directory handling, otherwise the prefix rule would swallow the
45+
# basename along with the prefix.
46+
(re.compile(r"(?:[A-Za-z]:)?(?:\\[^\s\\:,)\"']+)+\\([^\s\\:,)\"']+)"), r"\1"),
47+
(re.compile(r"/(?:[^\s/:,)\"']+/)+([^\s/:,)\"']+)"), r"\1"),
48+
# Randomly named temp files/dirs that survived as a basename (pytest tmpdir and friends).
49+
(re.compile(r"\btmp[0-9a-z_]{4,}\b", re.IGNORECASE), "<TMP>"),
50+
# Elapsed times and durations.
51+
(
52+
re.compile(r"\b\d+(?:[.,]\d+)?\s?(?:ms|us|ns|s|sec|secs|seconds|m|min|mins|h|hrs)\b"),
53+
"<DUR>",
54+
),
55+
# file.py:123:45 style source references.
56+
(re.compile(r":\d+(?::\d+)?\b"), ":<N>"),
57+
# Anything numeric that survived the above.
58+
(re.compile(r"\b\d+(?:[.,]\d+)?\b"), "<N>"),
59+
# Collapse whitespace runs so indentation changes do not alter the fingerprint.
60+
(re.compile(r"[ \t]+"), " "),
61+
]
62+
63+
# Lines matching any of these carry the actual failure. Ordered scanning of these markers
64+
# is what keeps the signature focused on the error rather than on runner chatter.
65+
_FAILURE_MARKERS: list[re.Pattern] = [
66+
re.compile(r"\bassert(?:ion)?\s*(?:error|failed)?\b", re.IGNORECASE),
67+
re.compile(r"\b\w*(?:Error|Exception)\b"),
68+
re.compile(r"^\s*E\s{2,}"), # pytest's error continuation lines
69+
re.compile(r"\bFAILED?\b"),
70+
re.compile(r"\bFAILURE!?\b", re.IGNORECASE),
71+
re.compile(r"\bTraceback\b"),
72+
re.compile(r"^\s*panic:"), # go
73+
re.compile(r"\bexpected\b.*\b(?:but|actual|got|received)\b", re.IGNORECASE),
74+
re.compile(r"^\s*×"), # vitest / jest failure bullets
75+
]
76+
77+
78+
def normalize_output(raw_output: str) -> str:
79+
"""Scrub every run-to-run varying token out of test-runner output."""
80+
normalized = raw_output
81+
for pattern, replacement in _SCRUBBERS:
82+
normalized = pattern.sub(replacement, normalized)
83+
return normalized
84+
85+
86+
def _is_failure_line(line: str) -> bool:
87+
return any(marker.search(line) for marker in _FAILURE_MARKERS)
88+
89+
90+
def extract_signature(normalized_output: str) -> str:
91+
"""Pick the lines that identify the failure.
92+
93+
Failure-marker lines are preferred. When a runner emits none that we recognise, the
94+
tail of the output is used instead, since test runners put their summary last.
95+
"""
96+
lines = [line.strip() for line in normalized_output.splitlines() if line.strip()]
97+
if not lines:
98+
return ""
99+
100+
signature_lines: list[str] = []
101+
for line in lines:
102+
if _is_failure_line(line) and line not in signature_lines:
103+
signature_lines.append(line)
104+
if len(signature_lines) == SIGNATURE_MAX_LINES:
105+
break
106+
107+
if not signature_lines:
108+
signature_lines = lines[-SIGNATURE_MAX_LINES:]
109+
110+
return "\n".join(signature_lines)
111+
112+
113+
def compute_fingerprint(signature: str) -> Optional[str]:
114+
"""Hash a signature into a short stable identifier. Empty signature has no identity."""
115+
if not signature:
116+
return None
117+
return hashlib.sha256(signature.encode("utf-8")).hexdigest()[:FINGERPRINT_LENGTH]
118+
119+
120+
def _build_excerpt(normalized_output: str) -> str:
121+
"""Bound the normalized output, keeping the tail where failures are reported."""
122+
excerpt = normalized_output.strip()
123+
if len(excerpt) <= EXCERPT_MAX_CHARS:
124+
return excerpt
125+
return "...\n" + excerpt[-EXCERPT_MAX_CHARS:]
126+
127+
128+
def fingerprint_output(raw_output: Optional[str]) -> tuple[Optional[str], str, str]:
129+
"""Normalize test output into ``(fingerprint, signature, excerpt)``.
130+
131+
A passing or empty run has no failure identity and yields ``(None, "", "")``.
132+
"""
133+
if not raw_output or not raw_output.strip():
134+
return None, "", ""
135+
136+
normalized = normalize_output(raw_output)
137+
signature = extract_signature(normalized)
138+
return compute_fingerprint(signature), signature, _build_excerpt(normalized)

0 commit comments

Comments
 (0)