From e2da8ae8982e25e5a534690483b29e4268c49a92 Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:33:51 +0000 Subject: [PATCH 1/3] Chain local repairs after random wins and recover stale deep-search drafts --- README.md | 8 + ROADMAP.md | 5 + src/decompnet/app/deep_permuter.py | 120 +++++++++---- src/decompnet/app/deep_sweep.py | 13 +- src/decompnet/app/draft_repairs.py | 4 + src/decompnet/app/local_experiments.py | 5 +- src/decompnet/app/permute.py | 15 +- src/decompnet/app/prototype_alignment.py | 44 +++++ src/decompnet/app/register_reuse_repairs.py | 94 +++++++++++ src/decompnet/app/repair_ranking.py | 2 +- tests/test_deep_feedback.py | 178 ++++++++++++++++++++ 11 files changed, 447 insertions(+), 41 deletions(-) create mode 100644 src/decompnet/app/prototype_alignment.py create mode 100644 src/decompnet/app/register_reuse_repairs.py create mode 100644 tests/test_deep_feedback.py diff --git a/README.md b/README.md index 9ddc7c33..5f0d4c5a 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,14 @@ project-wide prototypes stay unchanged. These are hypotheses: an improved simila and only a byte-exact result counts as matched. The families also reach iterative/deep search and `try_local_repairs`; loops, incoming jumps, shadowing and ambiguous declarations limit what is proposed. +Deep search now revisits local repairs after a random rewrite improves the saved candidate. Each burst +tries at most 32 proposals and shares the original time/compile allowance. Small constant/copy reuse and +final integer shift/scale candidates target register-only differences without assigning machine registers. +A score-zero open function is still freshly verified before being marked matched. If an otherwise unusable +ROM draft conflicts with one plain shared return declaration, deep search can align the candidate to that +header and remeasure it. The old draft stays in history, the shared header stays unchanged, and the new +baseline may have a lower percentage. Ambiguous signatures still need review. + For close ROM misses it also tests cached compiler flag alternatives, two probes per pass. Later passes skip already tested choices and can reach optimization levels as well as debug flags. Only exact results qualify for a narrow compiler-range change. When automatic compiler changes are disabled, the result becomes a suggestion for diff --git a/ROADMAP.md b/ROADMAP.md index 3e69fbed..1b410b08 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,10 @@ # Roadmap +Deep search now chains bounded local repairs after random improvements. Register reuse hypotheses share +all normal draft/permuter paths. Fresh exact judgments repair score-zero/open bookkeeping; plain return +conflicts can recover a buildable ROM candidate with an honest baseline and retained history. Adaptive +per-function budgets and a pool of alternative search candidates remain planned follow-ups. + Bounded integer/call repairs now include evidence-led local widening, candidate-owned callee prototype widths, integer lifetime splits/merges, call distribution into branches, terminal goto leaves and switch layout variants. They share the ROM/imported-project judge and existing budgets; a better similarity score remains a draft. diff --git a/src/decompnet/app/deep_permuter.py b/src/decompnet/app/deep_permuter.py index ae3fae72..bfb480d7 100644 --- a/src/decompnet/app/deep_permuter.py +++ b/src/decompnet/app/deep_permuter.py @@ -10,9 +10,12 @@ from .. import scoring from ..network import NetworkError from ..project import compiler_for +from ..repair_feedback import trial as repair_trial from . import agent_tokens, draft_repairs, permuter_local, stack_padding_repairs from .permute import Permuter, State +AFTER_RANDOM_TRIALS = 32 + class SearchLimit(Exception): pass @@ -51,6 +54,8 @@ def __init__(self, net, minutes, max_variants, log, fn=None): self.measuring_baseline = False self.record_improvement = lambda score: None self.recorded_best = None + self.local_seen = set() + self.refinement_rounds = 0 def check(self): self.log.check_cancel() @@ -74,17 +79,89 @@ def report(self): self.recorded_best = self.best self.log.progress({'phase': self.phase, 'tested': self.tested, 'skipped': self.skipped, **self.metrics(), 'variant_limit': self.limit, 'rejected': self.rejected, - 'seconds_left': max(0, round(self.deadline-time.monotonic()))}) + 'seconds_left': max(0, round(self.deadline-time.monotonic())), 'refinement_rounds': self.refinement_rounds}) - def _baseline(self, *args): - # A cached scalar score cannot supply the instruction rows needed by targeted repairs. - # The parent still caches a measured baseline within this search instance. + def _baseline(self, agent, proj, fn, state, fork): + # Baseline evidence must come from today's compiler/header, including score-zero open rows. self.measuring_baseline = True try: - return super()._baseline(*args) + measured = super()._baseline(agent, proj, fn, state, fork) + if measured[0] is not None or fork: + return measured + from .prototype_alignment import variants + header = self.net._context(proj['id'], fn['unit'])['header'] + for candidate in variants(fn['best_source'], fn['name'], header): + score = self._score(agent, proj, fn, candidate, float('inf'), fork) + fresh = self.net._function(fn['name'], proj['slug']) + if score is not None and fresh['status'] == 'open' and fresh['best_source'] == fn['best_source']: + # The old percentage described C which no longer compiles. Retain that source in + # history and rebase only this broken draft after a normal compiler/judge result. + self.net._record(fn, agent, None, fn['best_source'], + self.net._context(proj['id'], fn['unit'])['version'], + compiled=False, via='permuter') + with self.net.store.tx() as db: + current = self.net._function(fn['name'], proj['slug']) + unchanged = (current['status'] == 'open' and current['best_source'] == fn['best_source'] + and all(current[k] == fn[k] for k in ('best_score', 'target', 'relocs', 'vram')) + and self.net._project(proj['slug'])['config'] == proj['config'] + and self.net._context(proj['id'], fn['unit'])['header'] == header) + if unchanged: + db.execute("UPDATE functions SET best_source=?,best_score=?,best_agent_id=? WHERE id=? AND status='open'", + (candidate, score, agent.id, fn['id'])) + fresh = self.net._function(fn['name'], proj['slug']) + if score is not None and (fresh['best_source'] == candidate or fresh['status'] == 'matched'): + self.log('Repaired a conflicting return type in the candidate; shared header unchanged. ' + 'The broken draft is retained in history; the new baseline may have a lower percentage.') + return score, measured[1]+1 + return measured finally: self.measuring_baseline = False + def local_repairs(self, agent, proj, fn, best, fork, max_trials=None): + """Regenerate from each saved winner, sharing this search's deadline, cache and allowance.""" + flags = compiler_for(self.net.cfg(proj), fn['vram']).get('flags', []) if not fork else [] + trials = 0 + source = fn['best_source'] + while best > 0: + self.check() + self.local_seen.add(source) + rows = getattr(self.net, '_last_diffs', {}).get((agent.id, fn['id']), ([], ''))[0] + labels = {} + choices = draft_repairs.variants(source, fn['name'], proj['arch'], fn['asm'] or '', flags, labels=labels, limit=None) + choices += stack_padding_repairs.grow(source, fn['name'], proj['arch'], rows) + choices += [s for _, s in permuter_local.variants(source, fn['name'])] + for candidate in dict.fromkeys(choices): + if candidate in self.local_seen: + continue + if max_trials is not None and trials >= max_trials: + return best, source + self.local_seen.add(candidate) + trials += 1 + with repair_trial({'kind': 'deep local repair', 'family': labels.get(candidate, 'local_repair'), + 'base_source_sha256': hashlib.sha256(source.encode()).hexdigest()}): + score = self._score(agent, proj, fn, candidate, best, fork) + if score is not None and score < best: + best = score + fn = self.net._function(fn['name'], proj['slug']) + source = fn['best_source'] + break + else: + break + return best, source + + def _after_improvement(self, agent, proj, fn, state, fork, best, source): + previous = self.phase + self.phase = 'local repairs after random improvement' + self.refinement_rounds += 1 + try: + self.log('Random rewrite improved the candidate; trying targeted local repairs on its new diff.') + # Keep the exploratory lane alive: at most 32 local proposals after each random win. + # The original global time/compile limit always wins over this burst limit. + fresh = self.net._function(fn['name'], proj['slug']) + return self.local_repairs(agent, proj, fresh, best, fork, max_trials=AFTER_RANDOM_TRIALS) + finally: + self.phase = previous + def _score(self, agent, proj, fn, source, best, fork): self.check() self.visited += 1 @@ -147,39 +224,16 @@ def run(net, slug, name, minutes, max_variants, log): raise NetworkError('This function was claimed meanwhile; try again after it is released.') try: fork = net.fork_slug(slug) - flags = compiler_for(net.cfg(proj), fn['vram']).get('flags', []) if not fork else [] - # Fresh baseline evidence enables measured stack repairs and diagnoses stale saved scores. + # Fresh baseline evidence also diagnoses obsolete candidate-owned prototypes. baseline, _ = search._baseline(agent, proj, fn, state, fork) if baseline is None: - raise NetworkError('The saved C does not compile in the current context; fix it before deep search.') + raise NetworkError('The saved C does not compile in the current context; fix its declarations before deep search.') if baseline == 0: reason = 'Matched' else: - best = baseline - seen = {fn['best_source']} - repair_fn = fn - while best > 0: - search.check() - source = repair_fn['best_source'] - rows = getattr(net, '_last_diffs', {}).get((agent.id, repair_fn['id']), ([], ''))[0] - choices = draft_repairs.variants(source, repair_fn['name'], proj['arch'], repair_fn['asm'] or '', flags, limit=None) - choices += stack_padding_repairs.grow(source, repair_fn['name'], proj['arch'], rows) - choices += [s for _,s in permuter_local.variants(source, repair_fn['name'])] - for candidate in dict.fromkeys(choices): - if candidate in seen: - continue - seen.add(candidate) - score = search._score(agent, proj, repair_fn, candidate, best, fork) - if score == 0: - reason = 'Matched' - if score is not None and score < best: - best = score - # Chain repairs from the saved winner and its new diff; keep the same - # deadline/compile budget and avoid retesting candidates from earlier rounds. - repair_fn = net._function(name, slug) - break - else: - break + best, _ = search.local_repairs(agent, proj, net._function(name, slug), baseline, fork) + if best == 0: + reason = 'Matched' finally: search._let_go(agent, fn) if reason != 'Matched': diff --git a/src/decompnet/app/deep_sweep.py b/src/decompnet/app/deep_sweep.py index 30ccd9f4..c744ec11 100644 --- a/src/decompnet/app/deep_sweep.py +++ b/src/decompnet/app/deep_sweep.py @@ -16,8 +16,17 @@ def signature(net, proj, fn, minutes, variants): - return hashlib.sha256(repr(('deep-pass-v1', agent_tokens.target_scope(net, fn, ''), - fn['best_source'], minutes, variants)).encode()).hexdigest() + inputs = ('deep-pass-v1', agent_tokens.target_scope(net, fn, ''), fn['best_source'], minutes, variants) + # Retry the two formerly blocked states once under the new recovery rules. Keep unrelated + # completed passes resumable; users can explicitly repeat them to try the new search recipes. + if fn['status'] == 'open' and fn['best_score'] == 0: + inputs += ('reverify-zero-v1',) + elif fn['best_source'] and not net.fork_slug(proj['slug']): + from .prototype_alignment import variants as align + ctx = net._context(proj['id'], fn['unit']) + if ctx and align(fn['best_source'], fn['name'], ctx['header']): + inputs += ('return-alignment-v1',) + return hashlib.sha256(repr(inputs).encode()).hexdigest() class Sweeps: diff --git a/src/decompnet/app/draft_repairs.py b/src/decompnet/app/draft_repairs.py index 6d1799b1..fdea228c 100644 --- a/src/decompnet/app/draft_repairs.py +++ b/src/decompnet/app/draft_repairs.py @@ -24,6 +24,7 @@ pattern_repairs, pointer_cast_repairs, register_repairs, + register_reuse_repairs, stack_padding_repairs, stack_repairs, storage_scope_repairs, @@ -57,6 +58,9 @@ def variants(source: str, name: str, arch: str, asm: str = '', flags=(), labels= for family, text in register_repairs.variants(source, name, arch): proposals.append(text) labels[text] = family + for family, text in register_reuse_repairs.variants(source, name): + proposals.append(text) + labels[text] = family for text in mmio_repairs.variants(source, name, arch): proposals.append(text) labels[text] = 'mmio_address' diff --git a/src/decompnet/app/local_experiments.py b/src/decompnet/app/local_experiments.py index bc308825..8e0ea844 100644 --- a/src/decompnet/app/local_experiments.py +++ b/src/decompnet/app/local_experiments.py @@ -21,12 +21,13 @@ pattern_repairs, pointer_cast_repairs, register_repairs, + register_reuse_repairs, repair_ranking, stack_padding_repairs, store_address_repairs, ) -FAMILIES = ('signedness','declaration_order','typed_temporary','branch_orientation','narrowing','source_lines', +FAMILIES = ('constant_register_reuse', 'copy_register_reuse', 'last_shift_reuse', 'signedness','declaration_order','typed_temporary','branch_orientation','narrowing','source_lines', 'address_temporary','constant_local','comparison_load','stack_array','loop_induction', 'parameter_width','value_lifetime','sum_shape','call_result_inline','comparison_reuse','integer_address_order','mmio_address','call_arity','struct_byte_offset','switch_call','paired_load_order','array_index_width','split_scalar_lifetime','pointer_before_store','store_address_lifetime','stack_padding','local_width','pointer_cast_inline', 'local_widening','callee_parameter_width','callee_return_width','split_integer_lifetime','merge_integer_lifetimes','branch_call','goto_leaf','switch_layout') @@ -69,7 +70,7 @@ def run(net, agent, slug, function, revision, families=None, max_trials=6): for family,candidate in lifetime_repairs.variants(source,fn['name']): if family in pools: pools[family].append(candidate) - for module in (callee_prototype_repairs, integer_lifetime_repairs, branch_layout_repairs): + for module in (callee_prototype_repairs, integer_lifetime_repairs, branch_layout_repairs, register_reuse_repairs): for family, candidate in module.variants(source, fn['name']): if family in pools: pools[family].append(candidate) diff --git a/src/decompnet/app/permute.py b/src/decompnet/app/permute.py index abadb83f..cfada0b2 100644 --- a/src/decompnet/app/permute.py +++ b/src/decompnet/app/permute.py @@ -365,6 +365,10 @@ def _let_go(self, agent, fn) -> None: with self.net.store.tx() as db: db.execute("DELETE FROM claims WHERE function_id=? AND agent_id=?", (fn["id"], agent.id)) + def _after_improvement(self, agent, proj, fn, st, fork, best, source): + """Deep search can run bounded local repairs here without changing normal random turns.""" + return best, source + def _permute(self, agent, proj, fn, st: State) -> None: seconds = self.seconds if st.stop.is_set() or not self._hold(agent, fn, seconds=seconds): @@ -435,13 +439,18 @@ def _permute(self, agent, proj, fn, st: State) -> None: continue if score < best: best, best_src = score, cand + st.add(improved=1) + if best > 0: + best, best_src = self._after_improvement(agent, proj, fn, st, fork, best, best_src) rows = repair_ranking.evidence(self.net, fn['id'], agent.id) focus, boosts = permuter_focus.classify(rows), permuter_focus.boosts(rows) - st.add(improved=1) - if score == 0: + if best == 0: st.add(matched=1) log.info("permuter: %s matched", fn["name"]) break + if best < score: + cur, cur_src = best, best_src + break # generate the next batch from the refined winner, not its parent if score <= cur or rng.random() < 0.02: # mostly downhill, now and then sideways off a plateau cur, cur_src = score, cand finally: @@ -574,6 +583,6 @@ def _score(self, agent, proj, fn, source: str, best: int, fork: str | None) -> i if not hasattr(self.net, '_agent_outcomes'): self.net._agent_outcomes = {} self.net._agent_outcomes[agent.id, fn['id']] = {'source': source, 'score': d.score, 'rows': d.rows} - if d.score < best: + if d.score < best or (d.bytes_match and fn['status'] != 'matched'): self.net._record(fn, agent, d.score, source, ctx["version"], matched=d.bytes_match, via="permuter", effective_settings=getattr(result,"effective_settings",None)) return 0 if d.bytes_match else max(1, d.score) diff --git a/src/decompnet/app/prototype_alignment.py b/src/decompnet/app/prototype_alignment.py new file mode 100644 index 00000000..db15ba26 --- /dev/null +++ b/src/decompnet/app/prototype_alignment.py @@ -0,0 +1,44 @@ +"""Repair one plain definition's return type to an unambiguous shared declaration. + +The header stays untouched. Ambiguous/conditional declarations, old-style parameters and C++ need +human review; the ordinary compiler and exact judge decide whether this candidate may be kept. +""" +import re + +from ..forks.csource import _blank, find +from .integer_repair_support import IDENT, INTEGER, edit, split_args +from .repair_preflight import _PROTO + + +def variants(source, name, header): + span = find(source, name) + clean, shared = _blank(source), _blank(header) + if not span or re.search(r'^\s*#\s*(?:if|ifdef|ifndef|include)\b|::', shared, re.M): + return [] + protos = [p for p in _PROTO.finditer(shared) if p[1] == name] + if len(protos) != 1 or not protos[0][2].strip() or '...' in protos[0][2]: + return [] + proto = protos[0] + result = re.fullmatch(rf'\s*(?:extern\s+)?({INTEGER}|void)\s*', proto[0][:proto.start(1)-proto.start()]) + own = re.match(rf'\s*(?P{INTEGER}|void)\s+{re.escape(name)}\s*\((?P[^(){{}};]*)\)\s*{{', clean[span[0]:span[1]]) + if not result or not own or result[1] == own['type']: + return [] + words = set(re.findall(IDENT, result[1]+' '+own['type']+' '+name+' '+proto[2]+' '+own['args'])) + if any(m[1] in words for m in re.finditer(r'^\s*#\s*define\s+('+IDENT+r')\b', shared, re.M)): + return [] + + def types(args): + if args.strip() == 'void': + return [] + out = [] + for start, end in split_args(args): + match = re.fullmatch(rf'\s*({INTEGER})(?:\s+{IDENT})?\s*', args[start:end]) + if not match: + return None + out.append(' '.join(match[1].split())) + return out + + expected, actual = types(proto[2]), types(own['args']) + if expected is None or expected != actual: + return [] + return [edit(source, [(span[0]+own.start('type'), span[0]+own.end('type'), result[1])])] diff --git a/src/decompnet/app/register_reuse_repairs.py b/src/decompnet/app/register_reuse_repairs.py new file mode 100644 index 00000000..214230ce --- /dev/null +++ b/src/decompnet/app/register_reuse_repairs.py @@ -0,0 +1,94 @@ +"""Small def/use hypotheses for register-only near misses, always checked by the normal judge. + +A nearly identical instruction stream can still read an undefined temporary or keep a redundant copy +live. These source candidates expose constant/copy reuse and an in-place final shift to old allocators. +They do not prescribe machine registers or edit the generated object. +""" +import re + +from .integer_repair_support import IDENT, INTEGER, NUMBER, edit, refs, view + +LIMIT = 16 +DECL = re.compile(rf'\b(?P{INTEGER})\s+(?P{IDENT})\s*(?:=\s*(?P{NUMBER}))?\s*;') + + +def variants(source, name): + part = view(source, name) + if not part or re.search(r'\b(?:volatile|asm|__asm__)\b', part.clean): + return [] + locals_ = [] + for decl in DECL.finditer(part.clean, part.opening+1, part.end): + prefix = part.clean[part.opening+1:decl.start()].rstrip() + if prefix and prefix[-1] not in ';{}': + continue + variable = decl['name'] + shadows = re.findall(rf'\b(?:{INTEGER}|void|float|double)\s*\**\s*{variable}\s*(?=[;=,\[])', part.body) + if len(shadows) != 1 or re.search(rf'&\s*\(?\s*{variable}\b', part.body): + continue + locals_.append(decl) + result = [] + + def add(family, edits): + candidate = edit(source, edits) + if candidate != source and (family, candidate) not in result and len(result) < LIMIT: + result.append((family, candidate)) + + for decl in locals_: + hits = refs(part, decl) + if not hits: + continue + variable = decl['name'] + writes = [hit for hit in hits if re.match(r'\s*(?:=(?!=)|[-+*/%&|^]?=|\+\+|--)', part.clean[hit.end():]) + or re.search(r'(?:\+\+|--)\s*$', part.clean[:hit.start()])] + # A read before any definition is a drafting mistake, not evidence that its arbitrary register + # value is correct. Try an already initialized, same-type constant without changing the header. + if decl['value'] is None: + stop = writes[0].start() if writes else part.end + early = [hit for hit in hits if hit.start() < stop] + for constant in locals_: + if not early or constant['value'] is None or constant['type'] != decl['type'] or constant.end() > early[0].start(): + continue + if any(re.match(r'\s*(?:[+\-*/%&|^]?=|\+\+|--)', part.clean[h.end():]) + for h in refs(part, constant) if constant.end() < h.start() < early[-1].end()): + continue + add('constant_register_reuse', [(h.start(), h.end(), constant['name']) for h in early]) + # Reuse a named copy only while its source remains unchanged. Keep the assignment and every + # evaluation; one selected use at a time avoids a Cartesian product of aliases. + for assignment in re.finditer(rf'\b{variable}\s*=\s*({IDENT})\s*;', part.body): + end = part.opening+1+assignment.end() + origin = next((d for d in locals_ if d['name'] == assignment[1] and d['type'] == decl['type']), None) + if origin is None or origin['name'] == variable: + continue + selected = [] + for hit in hits: + if hit.start() < end: + continue + gap = part.clean[end:hit.start()] + if re.search(rf'\b(?:{variable}|{origin["name"]})\s*(?:[+\-*/%&|^]?=|\+\+|--)|' + rf'(?:\+\+|--)\s*(?:{variable}|{origin["name"]})\b|\b(?:goto|for|while|do|case|default)\b', gap): + break + if re.match(r'\s*(?:[+\-*/%&|^]?=|\+\+|--)', part.clean[hit.end():]): + break + selected.append((hit.start(), hit.end(), origin['name'])) + add('copy_register_reuse', [(hit.start(), hit.end(), origin['name'])]) + if len(selected) > 1: + add('copy_register_reuse', selected) + # If this is the entire copy's lifetime, also try eliminating its now-dead declaration + # and assignment. Early reads or another write keep those statements intact. + assignment_start = part.opening+1+assignment.start() + covered = {a for a, _, _ in selected} | {assignment_start} + prefix = part.clean[part.opening+1:assignment_start].rstrip() + if (selected and decl['value'] is None and prefix[-1:] in {';', '{', '}'} + and all(hit.start() in covered for hit in hits)): + add('copy_register_reuse', selected+[(assignment_start, end, ''), (decl.start(), decl.end(), '')]) + # A final 32-bit shift can use its input's register directly. The shift is unchanged and + # assigning its result adds no narrowing conversion; never do this for a byte/short local. + if hits and decl['type'] in {'s32', 'u32', 'int', 'signed int', 'unsigned int'}: + last = hits[-1] + shift = re.match(r'\s*<<\s*([1-9]|[12][0-9]|3[01])\b', part.clean[last.end():]) + if shift: + add('last_shift_reuse', [(last.start(), last.end()+shift.end(), f'({variable} <<= {shift[1]})')]) + scale = re.match(r'\s*\*\s*(2|4|8|16)\b', part.clean[last.end():]) + if scale: + add('last_shift_reuse', [(last.start(), last.end()+scale.end(), f'({variable} *= {scale[1]})')]) + return result diff --git a/src/decompnet/app/repair_ranking.py b/src/decompnet/app/repair_ranking.py index f91a7caf..f1490e4a 100644 --- a/src/decompnet/app/repair_ranking.py +++ b/src/decompnet/app/repair_ranking.py @@ -9,7 +9,7 @@ def order(families, rows): elif markers and markers <= {'r', 's'}: preferred = ('stack_padding', 'unused_scalar_local', 'local_storage', 'parameter_storage', 'local_declaration_order', 'stack_array', 'parameter_width', 'declaration_order') if 's' in markers else ( - 'store_address_lifetime', 'pointer_cast_inline', 'comparison_reuse', 'integer_address_order', + 'constant_register_reuse', 'copy_register_reuse', 'last_shift_reuse', 'store_address_lifetime', 'pointer_cast_inline', 'comparison_reuse', 'integer_address_order', 'typed_temporary', 'value_lifetime', 'split_integer_lifetime', 'merge_integer_lifetimes', 'split_scalar_lifetime', 'array_index_width', 'local_width', 'declaration_order') elif markers & {'<', '>', '|', 'o'}: diff --git a/tests/test_deep_feedback.py b/tests/test_deep_feedback.py new file mode 100644 index 00000000..28ebf2a3 --- /dev/null +++ b/tests/test_deep_feedback.py @@ -0,0 +1,178 @@ +"""The random/local feedback loop and recovery of drafts invalidated by their current header.""" +from types import SimpleNamespace + +import pytest + +from decompnet import scoring +from decompnet.app import deep_permuter, draft_repairs, prototype_alignment, register_reuse_repairs +from decompnet.app.permute import Permuter, State +from tests.test_deep_permuter import Log, setup +from tests.test_permute import AGENT, batches + + +@pytest.mark.parametrize('imported', [False, True]) +def test_a_random_win_runs_local_repairs_with_its_new_source_and_diff(monkeypatch, imported): + net = setup(monkeypatch, imported) + original = net.function()['best_source'] + generated, tested, evidence = [], [], [] + def variants(source, *args, **kwargs): + generated.append(source) + return ['exact'] if source == 'random win' else [] + monkeypatch.setattr(draft_repairs, 'variants', variants) + monkeypatch.setattr(deep_permuter.stack_padding_repairs, 'grow', lambda s,n,a,rows: evidence.append(rows) or []) + net._last_diffs = {} + def judge(self, agent, proj, fn, source, best, fork): + tested.append((source, fork)) + score = {'random win':20, 'exact':0}.get(source, 50) + if score < best: + with net.store.tx() as db: + db.execute('UPDATE functions SET best_score=?,best_source=?,status=? WHERE id=?', + (score,source,'matched' if score==0 else 'open',fn['id'])) + net._last_diffs[agent.id,fn['id']] = ([source], '') + return score + monkeypatch.setattr(Permuter, '_score', judge) + batches(monkeypatch, ['random win','obsolete batch remainder']) + log = Log() + result = deep_permuter.run(net,'game','f',1,10,log) + assert result['matched'] and result['tested']==2 + assert generated == [original,'random win'] + assert evidence[-1] == ['random win'] + assert [s for s,_ in tested] == ['random win','exact'] + assert all(fork == ('fork' if imported else None) for _,fork in tested) + assert any(p['phase']=='local repairs after random improvement' for p in log.updates) + assert net.store.one('SELECT COUNT(*) n FROM claims')['n']==0 + + +def test_feedback_keeps_one_compile_allowance_and_releases_the_claim(monkeypatch): + net = setup(monkeypatch) + original=net.function()['best_source'] + monkeypatch.setattr(draft_repairs,'variants',lambda source,*a,**k: [] if source==original else ['local win','exact']) + def judge(self,agent,proj,fn,source,best,fork): + score={'random win':30,'local win':20,'exact':0}[source] + with net.store.tx() as db: + db.execute('UPDATE functions SET best_source=?,best_score=?',(source,score)) + return score + monkeypatch.setattr(Permuter,'_score',judge) + batches(monkeypatch,['random win']) + result=deep_permuter.run(net,'game','f',1,2,Log()) + assert result['reason']=='Variant limit reached' and result['tested']==2 + assert net.function()['best_source']=='local win' + assert net.store.one('SELECT COUNT(*) n FROM claims')['n']==0 + + +@pytest.mark.parametrize('verdict', [True,False]) +def test_a_zero_saved_score_is_marked_matched_only_after_the_real_exact_verdict(monkeypatch,verdict): + net=setup(monkeypatch) + with net.store.tx() as db: db.execute('UPDATE functions SET best_score=0') + net._compile=lambda *a: SimpleNamespace(ok=True,obj=object()) + net._evaluate=lambda *a: (object(),verdict,[]) + net._target_obj=lambda *a: object() + net.keep_diff=lambda *a: None + monkeypatch.setattr(scoring,'diff',lambda *a: scoring.DiffResult(0,1000,[],True)) + recorded=[] + def record(fn,agent,score,source,version,**kwargs): + recorded.append(kwargs['matched']) + with net.store.tx() as db: db.execute("UPDATE functions SET status='matched'") + net._record=record + fn=net.function() + score=Permuter(net)._score(AGENT,net.project_row('game'),fn,fn['best_source'],0,None) + assert score==(0 if verdict else 1) + assert recorded==([True] if verdict else []) + + +def test_return_alignment_keeps_body_and_parameters_and_does_not_change_the_header(): + source='int f(int x){return x*3;}' + header='#define NULL ((void*)0)\nshort f(int);' + assert prototype_alignment.variants(source,'f',header)==['short f(int x){return x*3;}'] + for bad in ['short f(short);','short f();','short f(int,...);','short f(int);int f(int);', + '#if X\nshort f(int);\n#endif','#define short int\nshort f(int);','short f(int (*cb)(int));']: + assert not prototype_alignment.variants(source,'f',bad) + + +def test_a_broken_prototype_can_recover_with_a_higher_honest_baseline_and_old_source_history(monkeypatch): + baseline=deep_permuter.Search._baseline + net=setup(monkeypatch) + monkeypatch.setattr(deep_permuter.Search,'_baseline',baseline) + net._project=net.project_row + original='int f(int x){return x*3;}' + with net.store.tx() as db: db.execute('UPDATE functions SET best_source=?',(original,)) + net._context=lambda *a: {'header':'short f(int);','version':1} + monkeypatch.setattr(Permuter,'_baseline',lambda *a: (None,1)) + history=[] + net._record=lambda fn,agent,score,source,*a,**k: history.append((score,source)) + def judge(self,agent,proj,fn,source,best,fork): + net._record(fn,agent,215,source,1) + return 215 + monkeypatch.setattr(Permuter,'_score',judge) + search=deep_permuter.Search(net,1,10,Log(),net.function()) + measured=search._baseline(AGENT,net.project_row('game'),net.function(),State(),None) + assert measured[0]==215 and net.function()['best_score']==215 + assert net.function()['best_source']=='short f(int x){return x*3;}' + assert history==[(215,'short f(int x){return x*3;}'),(None,original)] + assert search.starting_score==215 + + +def test_register_candidates_target_constant_copy_and_last_scaled_use_without_touching_other_functions(): + constant='int f(int x){int missing;int k=13;if(x!=missing)x++;missing=k;return x;}' + choices=register_reuse_repairs.variants(constant,'f') + assert any(family=='constant_register_reuse' and 'if(x!=k)' in source for family,source in choices) + copy='int f(int x){int a;int b;a=x+3;b=a;return b+7;}' + assert any(family=='copy_register_reuse' and 'return a+7' in source for family,source in register_reuse_repairs.variants(copy,'f')) + shift='int f(int x){int index;index=x;return index*4;}' + assert any(family=='last_shift_reuse' and '(index *= 4)' in source for family,source in register_reuse_repairs.variants(shift,'f')) + for source in [shift.replace('int index;','volatile int index;'),shift.replace('return index*4;','escape(&index);return index*4;')]: + assert not register_reuse_repairs.variants(source,'f') + assert not any(family=='last_shift_reuse' for family,_ in register_reuse_repairs.variants(shift.replace('int index;','short index;'),'f')) + prefix='void untouched(void){int y;y=4;use(y);}\n' + assert all(s.startswith(prefix) for _,s in register_reuse_repairs.variants(prefix+copy,'f')) + + +def test_a_refinement_burst_cannot_consume_the_whole_random_lane(monkeypatch): + net=setup(monkeypatch) + monkeypatch.setattr(draft_repairs,'variants',lambda *a,**k: [f'candidate {i}' for i in range(100)]) + monkeypatch.setattr(Permuter,'_score',lambda *a: 99) + search=deep_permuter.Search(net,1,1000,Log(),net.function()) + best,source=search._after_improvement(AGENT,net.project_row('game'),net.function(),State(),None,40,net.function()['best_source']) + assert best==40 and search.tested==deep_permuter.AFTER_RANDOM_TRIALS + assert search.phase=='local repairs' and search.refinement_rounds==1 + + +def test_recovery_does_not_overwrite_a_concurrent_header_change(monkeypatch): + baseline=deep_permuter.Search._baseline + net=setup(monkeypatch) + monkeypatch.setattr(deep_permuter.Search,'_baseline',baseline) + net._project=net.project_row + original='int f(int x){return x*3;}' + with net.store.tx() as db: db.execute('UPDATE functions SET best_source=?',(original,)) + header=['short f(int);'] + net._context=lambda *a: {'header':header[0],'version':1} + net._record=lambda *a,**k: None + monkeypatch.setattr(Permuter,'_baseline',lambda *a: (None,1)) + def judge(*args): + header[0]='unsigned short f(int);' + return 215 + monkeypatch.setattr(Permuter,'_score',judge) + search=deep_permuter.Search(net,1,10,Log(),net.function()) + assert search._baseline(AGENT,net.project_row('game'),net.function(),State(),None)[0] is None + assert net.function()['best_source']==original and net.function()['best_score']==40 + + +def test_constant_register_reuse_can_recover_a_real_exact_object(tmp_path): + from decompnet import toolchains + from decompnet.compiler import compile_source + from decompnet.elfobj import extract_function + from decompnet.project import compiler_config + if not toolchains.installed('gcc-2.7.2-kmc'): + pytest.skip('needs pinned gcc-2.7.2-kmc') + cfg=compiler_config(tmp_path,{'compiler':{'toolchain':'gcc-2.7.2-kmc'}}) + source='int f(int x){int missing;int k=13;if(x!=missing)x++;missing=k;return x;}' + target=compile_source(cfg,'','int f(int x){if(x!=13)x++;return x;}') + before=compile_source(cfg,'',source) + assert target.ok and before.ok + expected=extract_function(target.obj,'f') + assert scoring.diff(expected,extract_function(before.obj,'f')).score>0 + candidates=[s for family,s in register_reuse_repairs.variants(source,'f') if family=='constant_register_reuse'] + assert candidates + results=[compile_source(cfg,'',s) for s in candidates] + assert all(result.ok for result in results) + assert any(scoring.diff(expected,extract_function(result.obj,'f')).score==0 for result in results) From 4722ae10919f5d888d2b2c321982ff781d16200b Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:34:18 +0000 Subject: [PATCH 2/3] Retain refinement burst counts in completed deep-pass results --- src/decompnet/app/deep_permuter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/decompnet/app/deep_permuter.py b/src/decompnet/app/deep_permuter.py index bfb480d7..3fe6331d 100644 --- a/src/decompnet/app/deep_permuter.py +++ b/src/decompnet/app/deep_permuter.py @@ -257,7 +257,8 @@ def run(net, slug, name, minutes, max_variants, log): search.report() log(f'{reason}: {search.tested} candidates tested, {search.skipped} cached misses skipped, {search.rejected} invalid calls skipped. Verified improvements are saved immediately.') result = {'function': name, 'reason': reason, 'tested': search.tested, 'skipped': search.skipped, - 'rejected': search.rejected, 'matched': net._function(name, slug)['status'] == 'matched', **search.metrics()} + 'rejected': search.rejected, 'refinement_rounds': search.refinement_rounds, + 'matched': net._function(name, slug)['status'] == 'matched', **search.metrics()} if sweeps: sweeps.completed(slug, fn, minutes, max_variants, result, build_scope) return result From 798ed2479d98897bb90247fe0b5b9d19a7ea9f4b Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Thu, 29 Oct 2026 20:38:14 +0000 Subject: [PATCH 3/3] Restore the saved winner diff after each local refinement burst --- src/decompnet/app/deep_permuter.py | 18 ++++++++++++++++++ tests/test_deep_feedback.py | 16 ++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/src/decompnet/app/deep_permuter.py b/src/decompnet/app/deep_permuter.py index 3fe6331d..596f8b7a 100644 --- a/src/decompnet/app/deep_permuter.py +++ b/src/decompnet/app/deep_permuter.py @@ -56,6 +56,8 @@ def __init__(self, net, minutes, max_variants, log, fn=None): self.recorded_best = None self.local_seen = set() self.refinement_rounds = 0 + self.winner_diff = None + self.winner_source = None def check(self): self.log.check_cancel() @@ -160,6 +162,16 @@ def _after_improvement(self, agent, proj, fn, state, fork, best, source): fresh = self.net._function(fn['name'], proj['slug']) return self.local_repairs(agent, proj, fresh, best, fork, max_trials=AFTER_RANDOM_TRIALS) finally: + if self.winner_diff is not None: + # A losing local trial must not steer the next random batch's weights. Restore + # evidence for the saved winner, without spending another compiler invocation. + if hasattr(self.net, 'keep_diff'): + self.net.keep_diff(agent.id, fn['id'], self.winner_diff[0]) + else: + self.net._last_diffs[agent.id, fn['id']] = self.winner_diff + if hasattr(self.net, '_agent_outcomes'): + self.net._agent_outcomes[agent.id, fn['id']] = { + 'source': self.winner_source, 'score': self.best, 'rows': self.winner_diff[0]} self.phase = previous def _score(self, agent, proj, fn, source, best, fork): @@ -189,13 +201,19 @@ def _score(self, agent, proj, fn, source, best, fork): db.execute('INSERT OR REPLACE INTO reuse_trials(function_id,signature,outcome,reason,feedback) VALUES(?,?,?,?,?)', (fn['id'], key, 'miss', 'deep function search', json.dumps(score))) if score is not None: + winner = False if self.measuring_baseline and not self.baseline_measured: self.starting_score = self.best = score self.baseline_measured = True + winner = True elif not reused and not self.measuring_baseline and (self.best is None or score < self.best): if self.best is not None: self.improvements += 1 self.best = score + winner = True + if winner: + self.winner_diff = getattr(self.net, '_last_diffs', {}).get((agent.id, fn['id'])) + self.winner_source = source self.report() return score diff --git a/tests/test_deep_feedback.py b/tests/test_deep_feedback.py index 28ebf2a3..b378e31b 100644 --- a/tests/test_deep_feedback.py +++ b/tests/test_deep_feedback.py @@ -176,3 +176,19 @@ def test_constant_register_reuse_can_recover_a_real_exact_object(tmp_path): results=[compile_source(cfg,'',s) for s in candidates] assert all(result.ok for result in results) assert any(scoring.diff(expected,extract_function(result.obj,'f')).score==0 for result in results) + + +def test_losing_local_trials_do_not_replace_evidence_for_the_next_random_batch(monkeypatch): + net=setup(monkeypatch) + net._last_diffs={} + net._agent_outcomes={} + monkeypatch.setattr(draft_repairs,'variants',lambda *a,**k: ['losing local']) + def judge(self,agent,proj,fn,source,best,fork): + net._last_diffs[agent.id,fn['id']]=([source],'') + return 20 if source=='winner' else 50 + monkeypatch.setattr(Permuter,'_score',judge) + search=deep_permuter.Search(net,1,100,Log(),net.function()) + search._score(AGENT,net.project_row('game'),net.function(),'winner',40,None) + search._after_improvement(AGENT,net.project_row('game'),net.function(),State(),None,20,'winner') + assert net._last_diffs[AGENT.id,1][0]==['winner'] + assert net._agent_outcomes[AGENT.id,1]['source']=='winner'