From 988afecf02bcefa13b40385276d77c283f719ea7 Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:14:44 +0000 Subject: [PATCH 1/5] Add resumable deep permutation passes across unmatched functions --- ROADMAP.md | 6 + src/decompnet/app/deep_permuter.py | 11 +- src/decompnet/app/deep_sweep.py | 191 ++++++++++++++++++++++++ src/decompnet/app/engine/gui.py | 5 +- src/decompnet/app/routes/projects.py | 14 ++ src/decompnet/app/server.py | 5 + src/decompnet/app/static/index.html | 14 ++ src/decompnet/app/static/js/base.js | 4 +- src/decompnet/app/static/js/function.js | 2 + src/decompnet/app/static/js/steering.js | 57 +++++++ tests/gui_progress.js | 31 +++- tests/test_app_server.py | 14 ++ tests/test_deep_sweep.py | 190 +++++++++++++++++++++++ 13 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 src/decompnet/app/deep_sweep.py create mode 100644 tests/test_deep_sweep.py diff --git a/ROADMAP.md b/ROADMAP.md index a7ae8e8b..fd414353 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -888,3 +888,9 @@ agent briefs and deep-search output. Planned: compiler-specific diagnostic flags opt-in diagnostic build command for imported projects (their ordinary build diagnostics remain available). Reuse discovery batches fingerprint names/cache reads and persists a separate cheap-discovery cursor; at most half the remaining shared reuse time goes to that phase, leaving time for wider matching. + +Project settings now offer a resumable deep permutation pass across unmatched functions in ROM and imported +projects. Each function's completed bounded turn is persisted with its source/build scope and budget. Unfinished +turns resume after restart; changed inputs become eligible again. Busy functions and candidates without scored C +wait without being marked complete. The pass uses the same local/random searches, claims and judges as a single +function, with a visible progress bar and stop button. No paid helper is launched. diff --git a/src/decompnet/app/deep_permuter.py b/src/decompnet/app/deep_permuter.py index 096ca351..b7b126b5 100644 --- a/src/decompnet/app/deep_permuter.py +++ b/src/decompnet/app/deep_permuter.py @@ -100,6 +100,10 @@ def _score(self, agent, proj, fn, source, best, fork): def run(net, slug, name, minutes, max_variants, log): """Visit the supported local repairs, then spend the remaining budget on random permutations.""" proj, fn = validate(net, slug, name, minutes, max_variants) + sweeps = getattr(net, 'deep_sweeps', None) + if sweeps: + from ..candidate_scope import key + build_scope = key(net, proj, fn) search = Search(net, minutes, max_variants, log) state = State() agent, _ = net.local_agent('permuter') @@ -157,5 +161,8 @@ def run(net, slug, name, minutes, max_variants, log): reason = str(error) search.report() log(f'{reason}: {search.tested} candidates tested, {search.skipped} cached misses skipped, {search.rejected} invalid calls skipped. Verified improvements are saved immediately.') - return {'function': name, 'reason': reason, 'tested': search.tested, 'skipped': search.skipped, - 'rejected': search.rejected, 'matched': net._function(name, slug)['status'] == 'matched', 'best_score': search.best} + result = {'function': name, 'reason': reason, 'tested': search.tested, 'skipped': search.skipped, + 'rejected': search.rejected, 'matched': net._function(name, slug)['status'] == 'matched', 'best_score': search.best} + if sweeps: + sweeps.completed(slug, fn, minutes, max_variants, result, build_scope) + return result diff --git a/src/decompnet/app/deep_sweep.py b/src/decompnet/app/deep_sweep.py new file mode 100644 index 00000000..ee261802 --- /dev/null +++ b/src/decompnet/app/deep_sweep.py @@ -0,0 +1,191 @@ +"""Durable project-wide deep passes; each candidate gets a bounded turn through the normal judges.""" +import hashlib +import json +import threading +import time + +from .. import candidate_scope +from ..network import NetworkError +from . import agent_tokens, deep_permuter + +POLL_SECONDS = 15 +SCHEMA = '''CREATE TABLE IF NOT EXISTS deep_permutation_passes( + function_id INTEGER PRIMARY KEY, scope TEXT NOT NULL, state TEXT NOT NULL, + minutes INTEGER NOT NULL, max_variants INTEGER NOT NULL, completed_at REAL, + result TEXT NOT NULL DEFAULT '{}', error TEXT NOT NULL DEFAULT '')''' + + +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() + + +class Sweeps: + def __init__(self, net, jobs): + self.net, self.jobs = net, jobs + self.active = {} + self.lock = threading.RLock() + with net.store.tx() as db: + db.execute(SCHEMA) + + def config(self, slug): + proj = self.net.project_row(slug) + row = self.net.store.one('SELECT value FROM settings WHERE key=?', f'deep-sweep:{proj["id"]}') + return json.loads(row['value']) if row else {'enabled': False, 'minutes': 5, 'max_variants': 10000} + + def save(self, slug, cfg): + proj = self.net.project_row(slug) + with self.net.store.tx() as db: + db.execute('INSERT OR REPLACE INTO settings(key,value) VALUES(?,?)', + (f'deep-sweep:{proj["id"]}', json.dumps(cfg))) + + def history(self, slug, fn): + row = self.net.store.one('SELECT * FROM deep_permutation_passes WHERE function_id=?', fn['id']) + if not row: + return {'state': 'not tried', 'completed': False} + result = dict(row) + result['stale'] = row['scope'] != signature(self.net, self.net.project_row(slug), fn, + row['minutes'], row['max_variants']) + result['completed'] = row['state'] == 'completed' and not result['stale'] + result['result'] = json.loads(row['result']) + return result + + def status(self, slug): + proj = self.net.project_row(slug) + counts = self.net.store.one("SELECT COUNT(*) total, SUM(best_source IS NOT NULL AND best_score IS NOT NULL) ready " + "FROM functions WHERE project_id=? AND status='open'", proj['id']) + completed = self.net.store.one("SELECT COUNT(*) n FROM deep_permutation_passes d JOIN functions f ON f.id=d.function_id " + "WHERE f.project_id=? AND d.state='completed'", proj['id'])['n'] + errors = self.net.store.one("SELECT COUNT(*) n FROM deep_permutation_passes d JOIN functions f ON f.id=d.function_id WHERE f.project_id=? AND d.state='error'", proj['id'])['n'] + with self.lock: + current = dict(self.active.get(slug, {})) + job = self.jobs.jobs.get(current.get('job'), {}) + return self.config(slug) | {'total': counts['total'], 'ready': counts['ready'] or 0, + 'completed': completed, 'errors': errors, 'state': job.get('state', 'idle'), **current, + 'progress': job.get('progress', {}), 'error': job.get('error')} + + def start(self, slug, minutes=5, max_variants=10000, repeat=False): + if (isinstance(minutes, bool) or not isinstance(minutes, int) or not 1 <= minutes <= 120 or + isinstance(max_variants, bool) or not isinstance(max_variants, int) or not 1 <= max_variants <= 1000000): + raise NetworkError('Choose 1–120 minutes and 1–1,000,000 variants per function.') + self.net.project_row(slug) + if slug in self.net.links: + raise NetworkError('Run the deep pass on the room host’s project.') + with self.lock: + previous = self.active.get(slug, {}) + if self.jobs.jobs.get(previous.get('job'), {}).get('state') == 'running': + raise NetworkError('A deep pass is already running for this project. Stop it before starting another.') + if repeat: + with self.net.store.tx() as db: + db.execute("DELETE FROM deep_permutation_passes WHERE function_id IN " + "(SELECT id FROM functions WHERE project_id=? AND status='open')", + self.net.project_row(slug)['id']) + self.save(slug, {'enabled': True, 'minutes': minutes, 'max_variants': max_variants}) + job = self.jobs.start('deep-permutation-pass', lambda log: self.run(slug, minutes, max_variants, log), + cancel_message='Deep pass stopped. Verified improvements and completed passes are saved.') + self.active[slug] = {'job': job} + return self.status(slug) + + def stop(self, slug): + with self.lock: + cfg = self.config(slug) + self.save(slug, cfg | {'enabled': False}) + job = self.active.get(slug, {}).get('job') + if job: + self.jobs.cancel(job) + return self.status(slug) + + def resume(self): + # An interrupted function is retried; only completed bounded turns are skipped. + for proj in self.net.store.all('SELECT slug FROM projects'): + cfg = self.config(proj['slug']) + if cfg['enabled']: + try: + self.start(proj['slug'], cfg['minutes'], cfg['max_variants']) + except NetworkError as error: + self.save(proj['slug'], cfg | {'enabled': False, 'resume_error': str(error)}) + self.jobs.logger.warning('Deep pass could not resume: %s', error) + + def shutdown(self): + # Preserve enabled intent for the next launch, but let current searches release claims. + with self.lock: + for current in self.active.values(): + self.jobs.cancel(current['job']) + + def completed(self, slug, before, minutes, variants, result, build_scope): + fresh = self.net._function(before['name'], slug) + proj = self.net.project_row(slug) + stable = build_scope == candidate_scope.key(self.net, proj, before) + self.record(fresh, signature(self.net, proj, fresh, minutes, variants), + 'completed' if stable else 'pending', minutes, variants, result) + + def record(self, fn, scope, state, minutes, variants, result=None, error=''): + with self.net.store.tx() as db: + db.execute('INSERT OR REPLACE INTO deep_permutation_passes ' + '(function_id,scope,state,minutes,max_variants,completed_at,result,error) VALUES(?,?,?,?,?,?,?,?)', + (fn['id'], scope, state, minutes, variants, time.time() if state == 'completed' else None, + json.dumps(result or {}), error)) + + def run(self, slug, minutes, variants, log): + """Finish one turn per unchanged candidate; wait for claims or usable C without marking them done.""" + visited = tested = matched = 0 + while True: + log.check_cancel() + proj = self.net.project_row(slug) + pending = waiting = 0 + rows = self.net.store.all("SELECT * FROM functions WHERE project_id=? AND status='open' ORDER BY size,id", proj['id']) + for row in rows: + log.check_cancel() + fn = self.net._function(row['name'], slug) + if fn['status'] != 'open': + continue + if not fn['best_source'] or fn['best_score'] is None: + waiting += 1 + continue + scope = signature(self.net, proj, fn, minutes, variants) + previous = self.net.store.one('SELECT * FROM deep_permutation_passes WHERE function_id=?', fn['id']) + if previous and previous['scope'] == scope and previous['state'] in ('completed', 'error'): + continue + pending += 1 + if self.net.store.one('SELECT 1 FROM claims WHERE function_id=? AND expires_at>?', fn['id'], time.time()): + waiting += 1 + continue + build_scope = candidate_scope.key(self.net, proj, fn) + self.record(fn, scope, 'running', minutes, variants) + log(f'Deep pass: {fn["name"]} · up to {minutes} minutes / {variants:,} variants') + total = len(rows) + def progress(value, name=fn['name'], done=visited, wins=matched, total=total, blocked=waiting): + log.progress({'function': name, 'functions_tested': done, 'matches': wins, + 'total_unmatched': total, 'waiting': blocked} | value) + def function_log(line): + log(line) + function_log.check_cancel = log.check_cancel + function_log.progress = progress + try: + result = deep_permuter.run(self.net, slug, fn['name'], minutes, variants, function_log) + # Also records test backends and older search providers that do not publish history. + # The actual single-function search uses the same completion gate. + self.completed(slug, fn, minutes, variants, result, build_scope) + visited += 1 + tested += result['tested'] + matched += int(result['matched']) + except NetworkError as error: + # Claim races are temporary. A bad unchanged candidate stays visible as an error, + # not completed, and a changed source/header or an explicit repeat retries it. + busy = self.net.store.one('SELECT 1 FROM claims WHERE function_id=? AND expires_at>?', fn['id'], time.time()) + self.record(fn, scope, 'pending' if busy or 'claim' in str(error).lower() or str(error).startswith('This function is working') else 'error', minutes, variants, error=str(error)) + log(f'{fn["name"]}: {error}') + except BaseException: + self.record(fn, scope, 'pending', minutes, variants) + raise + progress({'phase': 'moving to next function', 'tested': tested, 'functions_tested': visited, 'matches': matched}) + if not pending and not waiting: + self.save(slug, self.config(slug) | {'enabled': False}) + log('Deep pass finished. Completed bounded passes are saved; this is not an exhaustive search of all C.') + return {'functions_tested': visited, 'tested': tested, 'matched': matched} + log.progress({'phase': 'waiting for unclaimed functions or scored C', 'functions_tested': visited, + 'matches': matched, 'waiting': waiting, 'total_unmatched': len(rows)}) + # Cancellation remains responsive while another worker owns the remaining functions. + for _ in range(POLL_SECONDS): + log.check_cancel() + time.sleep(1) diff --git a/src/decompnet/app/engine/gui.py b/src/decompnet/app/engine/gui.py index 25ab15ca..68b52abd 100644 --- a/src/decompnet/app/engine/gui.py +++ b/src/decompnet/app/engine/gui.py @@ -256,8 +256,10 @@ def function_diff(self, slug: str, name: str, which: str = "latest") -> dict: def function_detail(self, slug: str, name: str) -> dict: from ..function_names import state as name_state + sweep = getattr(self, 'deep_sweeps', None) + deep_pass = sweep.history(slug, self._function(name, slug)) if sweep else {'state': 'not tried', 'completed': False} if (fork := self.fork_slug(slug)): - return self.forks.function_detail(fork, name) | name_state(self._function(name, slug)) + return self.forks.function_detail(fork, name) | name_state(self._function(name, slug)) | {'deep_pass': deep_pass} fn = self._function(name, slug) proj = self.store.one("SELECT * FROM projects WHERE id=?", fn["project_id"]) attempts = self.store.all( @@ -275,6 +277,7 @@ def function_detail(self, slug: str, name: str) -> dict: return { **name_state(fn), "name": fn["name"], "vram": fn["vram"], "rom_offset": fn["rom_offset"], "size": fn["size"], "insns": fn["n_insns"], "status": fn["status"], "best": fn["best_score"], **set_aside(fn), + "deep_pass": deep_pass, "compiler_test": self.compiler_test_result(proj, fn), "asm": self._asm_text(fn, proj), "source": fn["matched_source"] or fn["best_source"] or (latest["source"] if latest else None) or fn["seed"], diff --git a/src/decompnet/app/routes/projects.py b/src/decompnet/app/routes/projects.py index 9493aed0..b9302ee4 100644 --- a/src/decompnet/app/routes/projects.py +++ b/src/decompnet/app/routes/projects.py @@ -237,6 +237,19 @@ def work(log): return {'job': jobs.start('source-preparation', work)} return source_preparation.configure(net, proj, data) + def p_deep_sweep(slug, request, data): + sweeps = net.deep_sweeps + if request.method == 'GET': + return sweeps.status(slug) + action = data.get('action', 'start') + if action == 'stop': + return sweeps.stop(slug) + if action != 'start': + raise ValueError('Choose start or stop for the deep pass.') + if not isinstance(data.get('repeat', False), bool): + raise ValueError('Repeat must be true or false.') + return sweeps.start(slug, data.get('minutes', 5), data.get('max_variants', 10000), data.get('repeat', False)) + def p_deep_permuter(slug, request, data): from .. import deep_permuter name = str(data.get('function') or '') @@ -332,6 +345,7 @@ def p_join_rom(slug, request, data): "/api/try": (p_try, ["POST"]), "/api/release": (p_release, ["POST"]), "/api/reopen": (p_reopen, ["POST"]), "/api/setaside": (p_set_aside, ["POST"]), "/api/recheck": (p_recheck, ["POST"]), "/api/permuter": (p_permuter, ["POST"]), + "/api/permuter/pass": (p_deep_sweep, ["GET", "POST"]), "/api/permuter/function": (p_deep_permuter, ["POST"]), "/api/source-preparation": (p_source_preparation, ["GET", "POST"]), "/api/autopilot": (p_autopilot, ["POST"]), "/api/instructions": (p_instructions, ["POST"]), "/api/title": (p_title, ["POST"]), diff --git a/src/decompnet/app/server.py b/src/decompnet/app/server.py index 376fbfaa..30102802 100644 --- a/src/decompnet/app/server.py +++ b/src/decompnet/app/server.py @@ -36,6 +36,8 @@ def build(net: RomNetwork, home: Path, port: int, secret: str): build_id = (updates.installed().get("commit") or "")[:12] # a branch install: the commit it came from manager = agents.Manager(net, home, mcp_url) jobs = Jobs(home / "logs" / "decomp.log") + from .deep_sweep import Sweeps + net.deep_sweeps = Sweeps(net, jobs) def busy() -> bool: return bool(manager.running()) or any(j["state"] == "running" for j in jobs.jobs.values()) @@ -52,6 +54,7 @@ def window_ended(): def quit_app() -> None: """Quit means quit: the window, every helper with everything it started, then the server.""" window.close_app() + net.deep_sweeps.shutdown() manager.shutdown() os._exit(0) window.WINDOW = window.Window(home, f"http://127.0.0.1:{port}/", secret, window_ended) @@ -110,6 +113,7 @@ def options_now(kind: str, options: dict) -> dict | None: return None return options | (session_options(saved) if saved is not None else {}) manager.options_now = options_now + net.deep_sweeps.resume() manager.resume() # helpers that were working when the app last stopped carry on (their records say which) mcp = tools.build_mcp(net, agent_of, observe=manager.record_agent) @@ -157,6 +161,7 @@ def fail(e: Exception, code: int = 400) -> JSONResponse: return JSONResponse({"error": str(e)}, status_code=code) def before_restart() -> None: # helpers stop now and resume in the new version; the window closes with the process + net.deep_sweeps.shutdown() manager.shutdown() # the app's parts add their routes (routes/__init__.py says which is where); forks come after state and projects, diff --git a/src/decompnet/app/static/index.html b/src/decompnet/app/static/index.html index 7cfa2b90..4f011f98 100644 --- a/src/decompnet/app/static/index.html +++ b/src/decompnet/app/static/index.html @@ -385,6 +385,19 @@
Give each function a longer turn through local repairs and random permutations. Completed passes are saved, so you can stop, close the app and resume later. Changed source, headers or compiler inputs make a function eligible again.
+ + + +One function at a time. Busy functions and those without scored C wait for their agent or draft. Only the ordinary byte judge can save a match. A completed pass means its bounded turn finished, not that every possible rewrite was tried.
+ + +Tries the supported local repairs, then searches random rewrites of this function’s saved C. No AI tokens. This is a bounded search, not every possible C program. Exact matches and better candidates are saved immediately.
One function at a time. Busy functions and those without scored C wait for their agent or draft. Only the ordinary byte judge can save a match. A completed pass means its bounded turn finished, not that every possible rewrite was tried.
+${status.improved || 0} functions improved by deep permutation
` + + (rows.length ? `| Function | Starting score | Starting match | New score | New match |
|---|---|---|---|---|
| ${esc(r.starting_score)} | ${esc(r.starting_percent_text)} | ${esc(r.new_score)} | ${esc(r.new_percent_text)} |
Lower scores and higher match percentages are better. Saved across restarts and repeat passes; each function appears once, from its first recorded improvement to its best deep-pass result.
` : ""); + $("deepSweepResults").querySelectorAll("[data-deep-result]").forEach(button => { + button.onclick = () => openFn(rows[Number(button.dataset.deepResult)].function); + }); +} async function renderDeepSweep() { const slug = S.slug; if (!slug) return; @@ -823,6 +831,7 @@ async function renderDeepSweep() { $("deepSweepVariants").value = status.max_variants; $("deepSweepRepeat").checked = false; } + renderDeepSweepResults(status); $("deepSweepStart").disabled = status.state === "running"; $("deepSweepStop").hidden = status.state !== "running"; $("deepSweepStatus").textContent = `${status.completed} saved completed passes · ${status.errors} need attention · ${status.ready} of ${status.total} unmatched functions have scored C`; @@ -836,10 +845,17 @@ async function followDeepSweep(slug, job) { host.textContent = ""; host.append(log); $("deepSweepStart").disabled = true; const done = beginWork(log, "Deep permutation pass", [], "Deep pass finished"); + let lastResults = 0, refreshing = false; try { await followJob(job, log, (_, state) => { const progress = state.progress || {}; if (S.slug !== slug) return; + if (!refreshing && Date.now() - lastResults >= 2000) { + refreshing = true; lastResults = Date.now(); + api(`/api/permuter/pass?slug=${encodeURIComponent(slug)}`).then(status => { + if (S.slug === slug) renderDeepSweepResults(status); + }).catch(() => {}).finally(() => { refreshing = false; }); + } $("deepSweepStatus").textContent = `${progress.function || "Queue"} · ${progress.phase || "working"} · ${progress.functions_tested || 0} functions tested · ${progress.matches || 0} matches`; }, () => S.slug === slug); } catch (error) { if (S.slug === slug) showJobError(log, error.message); } diff --git a/tests/gui_progress.js b/tests/gui_progress.js index 40befc92..aeef7eeb 100644 --- a/tests/gui_progress.js +++ b/tests/gui_progress.js @@ -287,3 +287,20 @@ main().catch((error) => { console.error(error); process.exitCode = 1; }); if (state === "running") assert.equal(bar.firstElementChild.style.width, ""); } } + +// Durable deep-pass results show every improvement and keep the function link usable. +{ + const source = read("steering"), start = source.indexOf("function renderDeepSweepResults(status) {"); + const end = source.indexOf("async function renderDeepSweep()", start); + const result = {innerHTML:"", querySelectorAll:()=>[button]}, button = {dataset:{deepResult:"0"}}; + let opened; + const ctx = {$:()=>result, esc:s=>String(s).replaceAll("<", "<"), openFn:name=>{opened=name;}}; + vm.createContext(ctx); vm.runInContext(source.slice(start,end), ctx); + ctx.renderDeepSweepResults({improved:1, improvements:[{function:"f<0", starting_score:50, + new_score:25, starting_percent_text:"95.0%", new_percent_text:"97.5%"}]}); + for (const text of ["1 functions improved", "f<0", "50", "25", "95.0%", "97.5%"]) + assert.ok(result.innerHTML.includes(text)); + button.onclick(); assert.equal(opened,"f<0"); + ctx.renderDeepSweepResults({improved:0, improvements:[]}); + assert.ok(!result.innerHTML.includes("