From 280bbe3faf8bb956ea70e052af4ff61a6a2ccb1a Mon Sep 17 00:00:00 2001 From: RetroGameDeveloper <40120498+RetroGameDeveloper@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:15:50 +0000 Subject: [PATCH] Add cancellable deep permuter searches for a selected function --- README.md | 8 ++ ROADMAP.md | 6 + src/decompnet/app/deep_permuter.py | 150 ++++++++++++++++++++++ src/decompnet/app/draft_repairs.py | 4 +- src/decompnet/app/routes/projects.py | 9 ++ src/decompnet/app/static/index.html | 12 ++ src/decompnet/app/static/js/function.js | 42 ++++++- tests/gui_progress.js | 35 +++++- tests/test_app_server.py | 24 ++++ tests/test_deep_permuter.py | 159 ++++++++++++++++++++++++ 10 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 src/decompnet/app/deep_permuter.py create mode 100644 tests/test_deep_permuter.py diff --git a/README.md b/README.md index bbcf9821..32978b6f 100644 --- a/README.md +++ b/README.md @@ -623,6 +623,14 @@ explicit coverage notice identifies their exclusion from function progress. ### Permuter budgets +For a longer search of one function, open its drawer and expand **Deep permuter · this function**. +Choose a time limit and maximum candidate compiles, then select **Run deep permuter**. **Stop** ends the job +after its current compiler/generator finishes. Better candidates and exact matches are saved immediately. +This bypasses the background queue’s instruction and turn limits for the selected function only. It visits the +current local candidate list, then uses random rewrites; it cannot exhaust every possible C program. No AI tokens +are used. Cached misses are skipped on later deep runs when their build inputs still agree. + + In **Settings → Permuter limits**, raise the maximum instructions per function to include larger near misses. Defaults are 400 instructions, 60 seconds per turn and four turns per function. All three limits are global, saved across restarts and diff --git a/ROADMAP.md b/ROADMAP.md index 64ffdbce..7a525450 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -875,3 +875,9 @@ Measured MIPS stack-only diffs can also propose two small missing-padding hypoth epilogue and every changed saved-register slot agree on the same positive frame delta. The optional iterative phase uses its existing budget; agent local trials require measured evidence for their current source. Other processors still use padding removal/shrinking until they have a corresponding frame reader. + +A function drawer can launch a bounded deep permuter job for one saved, scored candidate in ROM or imported +projects. It bypasses the background queue's size and turn filters, visits the generated local repair list, +then uses ordinary random rewrites. Time/compile limits, progress, cancellation, normal judges and immediate +saving remain mandatory. Scoped misses survive another deep run; failed compiles stay retryable and cached +improvements must be rejudged. This does not claim to exhaust all possible C programs or compiler options. diff --git a/src/decompnet/app/deep_permuter.py b/src/decompnet/app/deep_permuter.py new file mode 100644 index 00000000..4a6af690 --- /dev/null +++ b/src/decompnet/app/deep_permuter.py @@ -0,0 +1,150 @@ +"""User-requested, bounded searches of one saved candidate, using the ordinary permuter judges. + +The finite local candidate list can be visited completely. Random C rewriting has no finite exhaustive +search space, so time/variant limits and cancellation always remain explicit. +""" +import hashlib +import json +import time + +from ..network import NetworkError +from ..project import compiler_for +from . import agent_tokens, draft_repairs, permuter_local, stack_padding_repairs +from .permute import Permuter, State + + +class SearchLimit(Exception): + pass + + +def validate(net, slug, name, minutes, max_variants): + if isinstance(minutes, bool) or not isinstance(minutes, int) or not 1 <= minutes <= 120: + raise NetworkError('Choose a time limit from 1 to 120 minutes.') + if isinstance(max_variants, bool) or not isinstance(max_variants, int) or not 1 <= max_variants <= 1000000: + raise NetworkError('Choose a variant limit from 1 to 1,000,000.') + proj = net.project_row(slug) + fn = net._function(name, slug) + if slug in net.links: + raise NetworkError('Run deep permuter on the room host’s project.') + if fn['status'] != 'open' or not fn['best_source'] or fn['best_score'] is None: + raise NetworkError('Choose an unmatched function with saved, scored C first.') + if net.store.one('SELECT 1 FROM claims WHERE function_id=? AND expires_at>?', fn['id'], time.time()): + raise NetworkError('This function is working; stop its agent or wait for its claim to finish.') + return proj, fn + + +class Search(Permuter): + def __init__(self, net, minutes, max_variants, log): + super().__init__(net) + self.deadline = time.monotonic()+minutes*60 + self.seconds = minutes*60 + self.limit = max_variants + self.log = log + self.tested = self.skipped = self.visited = 0 + self.best = None + self.phase = 'local repairs' + self.measuring_baseline = False + + def check(self): + self.log.check_cancel() + if time.monotonic() >= self.deadline: + raise SearchLimit('Time limit reached') + if self.tested >= self.limit or self.visited >= self.limit*20: + raise SearchLimit('Variant limit reached') + + def report(self): + self.log.progress({'phase': self.phase, 'tested': self.tested, 'skipped': self.skipped, + 'best_score': self.best, 'variant_limit': self.limit, + 'seconds_left': max(0, round(self.deadline-time.monotonic()))}) + + 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. + self.measuring_baseline = True + try: + return super()._baseline(*args) + finally: + self.measuring_baseline = False + + def _score(self, agent, proj, fn, source, best, fork): + self.check() + self.visited += 1 + # Scope includes the actual compiler/header/target and imported build dependencies. A previous + # miss is reusable only in that scope. Better cached results are rejudged before being kept. + scope = agent_tokens.target_scope(self.net, {**dict(fn), 'best_source': source}, '') + key = 'deep-v1:'+hashlib.sha256((scope+source).encode()).hexdigest() + cached = self.net.store.one('SELECT feedback FROM reuse_trials WHERE function_id=? AND signature=?', fn['id'], key) + score = json.loads(cached['feedback']) if cached and cached['feedback'] else None + if not self.measuring_baseline and isinstance(score, int) and score >= best: + self.skipped += 1 + else: + self.tested += 1 + score = super()._score(agent, proj, fn, source, best, fork) + if score is not None and score > 0: + with self.net.store.tx() as db: + 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: + self.best = score if self.best is None else min(self.best, score) + self.report() + return score + + +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) + search = Search(net, minutes, max_variants, log) + state = State() + agent, _ = net.local_agent('permuter') + reason = 'Current candidate families finished' + log('Testing supported local repairs, then bounded random rewrites. This cannot exhaust every possible C program.') + try: + if not search._hold(agent, fn, count_turn=False): + 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. + 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.') + if baseline == 0: + reason = 'Matched' + else: + best = baseline + source = fn['best_source'] + rows = getattr(net, '_last_diffs', {}).get((agent.id, fn['id']), ([], ''))[0] + choices = draft_repairs.variants(source, fn['name'], proj['arch'], fn['asm'] or '', flags, 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): + score = search._score(agent, proj, fn, candidate, best, fork) + if score is not None and score < best: + best = score + if score == 0: + reason = 'Matched' + break + finally: + search._let_go(agent, fn) + if reason != 'Matched': + search.check() + search.phase = 'random rewrites' + search.seconds = max(1, search.deadline-time.monotonic()) + current = net._function(name, slug) + if current['status'] == 'matched': + reason = 'Matched' + else: + # _permute ignores the normal size/turn queue filters; the user selected this function. + search._permute(agent, proj, current, state) + if state.tried == 0: + raise NetworkError('This function was claimed meanwhile; try again after it is released.') + if net._function(name, slug)['status'] == 'matched': + reason = 'Matched' + elif time.monotonic() >= search.deadline: + reason = 'Time limit reached' + except SearchLimit as error: + reason = str(error) + search.report() + log(f'{reason}: {search.tested} candidates tested, {search.skipped} cached misses skipped. Verified improvements are saved immediately.') + return {'function': name, 'reason': reason, 'tested': search.tested, 'skipped': search.skipped, + 'matched': net._function(name, slug)['status'] == 'matched', 'best_score': search.best} diff --git a/src/decompnet/app/draft_repairs.py b/src/decompnet/app/draft_repairs.py index b399914c..41e5694c 100644 --- a/src/decompnet/app/draft_repairs.py +++ b/src/decompnet/app/draft_repairs.py @@ -31,7 +31,7 @@ _MEMBER = rf'{_IDENT}(?:\s*(?:->|\.)\s*{_IDENT})*' -def variants(source: str, name: str, arch: str, asm: str = '', flags=(), labels=None) -> list[str]: +def variants(source: str, name: str, arch: str, asm: str = '', flags=(), labels=None, *, limit=LIMIT) -> list[str]: span = find(source, name) if span is None: return [] @@ -155,4 +155,4 @@ def add(a, b, text): for items in groups.values(): if items: ordered.append(items.pop(0)) - return ordered[:LIMIT] + return ordered if limit is None else ordered[:limit] diff --git a/src/decompnet/app/routes/projects.py b/src/decompnet/app/routes/projects.py index 809f29bc..9493aed0 100644 --- a/src/decompnet/app/routes/projects.py +++ b/src/decompnet/app/routes/projects.py @@ -237,6 +237,14 @@ def work(log): return {'job': jobs.start('source-preparation', work)} return source_preparation.configure(net, proj, data) + def p_deep_permuter(slug, request, data): + from .. import deep_permuter + name = str(data.get('function') or '') + minutes, variants = data.get('minutes', 30), data.get('max_variants', 100000) + deep_permuter.validate(net, slug, name, minutes, variants) + return {'job': jobs.start('deep-permuter', lambda log: deep_permuter.run(net, slug, name, minutes, variants, log), + cancel_message='Deep permuter stopped. Verified improvements are saved.')} + def p_permuter(slug, request, data): """Run free local permutations without starting a paid helper or the full autopilot.""" net.project_row(slug) @@ -324,6 +332,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/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"]), "/api/cross-game": (lambda s, r, d: {"results": net.twins.cross_game_results(s), diff --git a/src/decompnet/app/static/index.html b/src/decompnet/app/static/index.html index ee06ca69..7cfa2b90 100644 --- a/src/decompnet/app/static/index.html +++ b/src/decompnet/app/static/index.html @@ -572,6 +572,18 @@

Comparison details
+
diff --git a/src/decompnet/app/static/js/function.js b/src/decompnet/app/static/js/function.js index 53804690..bc887e8a 100644 --- a/src/decompnet/app/static/js/function.js +++ b/src/decompnet/app/static/js/function.js @@ -96,6 +96,7 @@ document.querySelectorAll("#diffWhich [data-which]").forEach((b) => { let fnBusy = false; let compilerComparisonBusy = false; +let deepPermuterBusy = false, deepPermuterJob = null; async function refreshFn(first) { if (fnBusy || nameChangeBusy || !S.fn) return; fnBusy = true; @@ -124,6 +125,7 @@ async function refreshFn(first) { ? `Name inferred from ${d.naming.source_project} · exact code match` : d.renamed ? `Renamed from ${d.naming.original_name}` : d.named ? "Named function" : "Generated name"; fnBadge(d); + $("fnDeepPanel").hidden = !deepPermuterBusy && (d.status !== "open" || d.best == null); $("fnCompilerTest").hidden = S.ov.kind === "fork" || d.status !== "open" || !S.ov.compilers?.length; if (!compilerComparisonBusy && S.ov.kind !== "fork") { const history = d.compiler_test; @@ -523,4 +525,42 @@ $("fnTestCompilers").onclick = async () => { renderFunctionCompilerComparison(result.comparison, slug, name); } catch (e) { summary.textContent = e.message; showJobError(log, e.message); } finally { compilerComparisonBusy = false; finish(); await refreshFn(false); } -}; \ No newline at end of file +}; + +$("fnDeepRun").onclick = async () => { + const slug = S.slug, name = S.fn; + const log = $("fnDeepLog"), status = $("fnDeepStatus"), stop = $("fnDeepStop"); + const controls = [$("fnDeepRun"), $("fnDeepMinutes"), $("fnDeepVariants")]; + const minutes = Number($("fnDeepMinutes").value), maxVariants = Number($("fnDeepVariants").value); + deepPermuterBusy = true; + log.hidden = false; stop.hidden = false; stop.disabled = true; + showJobError(log, ""); + status.textContent = `Starting deep search for ${name}…`; + const done = beginWork(log, "Searching this function locally", controls, "Deep search finished."); + try { + const { job } = await api("/api/permuter/function", { method: "POST", body: { slug, function: name, minutes, max_variants: maxVariants } }); + deepPermuterJob = job; stop.disabled = false; + const result = await followJob(job, log, (_, state) => { + const p = state.progress; + if (p) status.textContent = `${name} · ${p.phase} · ${p.tested.toLocaleString()} tested · ${p.skipped.toLocaleString()} cached misses skipped · best score ${p.best_score ?? "—"} · ${p.seconds_left}s left`; + }); + status.textContent = `${name} · ${result.reason} · ${result.tested.toLocaleString()} tested. Improvements saved.`; + if (S.slug === slug && S.fn === name) { await refreshMap(true); await refreshFn(false); } + } catch (error) { + if (error.job?.state === "cancelled") { + status.textContent = `${name} · Stopped. Verified improvements are saved.`; + log._workStatus.dataset.state = "cancelled"; log._workStatus.textContent = "Stopped"; + } + else showJobError(log, error.message); + } finally { + deepPermuterBusy = false; deepPermuterJob = null; stop.hidden = true; done(); + } +}; +$("fnDeepStop").onclick = async () => { + if (!deepPermuterJob) return; + const button = $("fnDeepStop"); button.disabled = true; + try { + await api("/api/job/cancel", { method: "POST", body: { id: deepPermuterJob } }); + $("fnDeepStatus").textContent = "Stopping after the current compiler or generator finishes. Improvements are saved."; + } catch (error) { showJobError($("fnDeepLog"), error.message); button.disabled = false; } +}; diff --git a/tests/gui_progress.js b/tests/gui_progress.js index 5cc7d425..b2c20c5f 100644 --- a/tests/gui_progress.js +++ b/tests/gui_progress.js @@ -36,6 +36,7 @@ const cases = [ ["project", '$("localScan").onclick = async () => {', '\n};', "localScanLog", "localScan"], ["project", 'go.onclick = async () => {', '\n };', "localImportLog", "localImport"], ["steering", '$("sourcePrepRun").onclick = async () => {', '\n};', "sourcePrepLog", "sourcePrepRun"], + ["function", '$("fnDeepRun").onclick = async () => {', '\n};', "fnDeepLog", "fnDeepRun"], ]; function setup(file, marker, end, logId, buttonId, ranges = "") { const elements = new Map(); @@ -45,13 +46,15 @@ function setup(file, marker, end, logId, buttonId, ranges = "") { get("agList").querySelector = () => log; const input = element(); const box = { querySelector: (selector) => ["[data-log]", "[data-local-log]"].includes(selector) ? log : get(selector.replace(/^#/, "")), querySelectorAll: (selector) => selector === "input, button, select" ? [input, button] : [input] }; get("srcState").querySelectorAll = () => [button]; + get("fnDeepMinutes").value = "30"; get("fnDeepVariants").value = "100"; get("setRanges").value = ranges; get("rLink").value = "https://room.test"; get("jPath").value = "game"; const start = deferred(), poll = deferred(); let polls = 0; const ctx = { $: get, document: { createElement: element }, - S: { slug: "test", ov: { kind: "fork" } }, info: {}, box, body: box, n: get("compilerNotice"), log, inst: button, + deepPermuterBusy: false, deepPermuterJob: null, refreshFn: async () => {}, + S: { slug: "test", fn: "f", ov: { kind: "fork" } }, info: {}, box, body: box, n: get("compilerNotice"), log, inst: button, b: Object.assign(button, { dataset: { inst: "test" } }), a: { id: "test", name: "Test" }, ROOM_PORT: 6481, results: get("localResults"), scan: { folder: "/synthetic/collection", projects: [] }, picked: new Set(), renderList() {}, selected: () => [{ path: "/synthetic/collection/project" }], renderLocalProjects() {}, update() {}, @@ -100,20 +103,22 @@ async function main() { assert.equal(log.bar.removed, undefined, marker); poll.resolve({ state: outcome === "done" ? "done" : outcome === "cancelled" ? "cancelled" : "error", log: ["Building"], error: outcome === "cancelled" ? "Cancelled." : "Build failed", - result: { slug: "test", identical: true, switched: 1, stayed: [], functions: 1, imported: [], errors: [] } }); + result: {reason: "Time limit reached", tested: 2, slug: "test", identical: true, switched: 1, stayed: [], functions: 1, imported: [], errors: [] } }); } await running; assert.equal(log.bar.removed, true, marker); if (buttonId) assert.equal(button.disabled, false, marker); - if (outcome !== "done") assert.equal(log.error, outcome === "start-error" ? "Cannot start" : outcome === "cancelled" ? "Cancelled." : "Build failed", marker); - if (outcome === "job-error" || outcome === "cancelled") assert.equal(log.textContent, (outcome === "cancelled" ? "Cancelled." : "Build failed") + "\n\nBuilding", marker); + const deepCancelled = file === "function" && outcome === "cancelled"; + if (deepCancelled) assert.ok(test.get("fnDeepStatus").textContent.includes("Stopped")); + if (outcome !== "done" && !deepCancelled) assert.equal(log.error, outcome === "start-error" ? "Cannot start" : outcome === "cancelled" ? "Cancelled." : "Build failed", marker); + if (outcome === "job-error" || (outcome === "cancelled" && !deepCancelled)) assert.equal(log.textContent, (outcome === "cancelled" ? "Cancelled." : "Build failed") + "\n\nBuilding", marker); if (outcome === "done") { assert.equal(log.error || "", "", marker); assert.equal(log._workStatus.dataset.state, "done"); assert.ok(log._workStatus.textContent.endsWith("."), marker); assert.notEqual(log._workStatus.textContent, "Finished.", marker); } else { - assert.equal(log._workStatus.dataset.state, "error"); + assert.equal(log._workStatus.dataset.state, deepCancelled ? "cancelled" : "error"); assert.equal(log._workStatus.textContent, "Stopped"); } } @@ -132,6 +137,26 @@ async function main() { if (fail) assert.equal(test.log.error, "Invalid source folder"); else assert.match(test.log.textContent, /12 signatures and 4 C definitions/); } + // Stopping a deep search cancels its existing job, rather than launching another search. + for (const fail of [false, true]) { + const test = setup(...cases[cases.length - 1]); + test.ctx.deepPermuterJob = "deep-job"; + const request = deferred(); let called; + test.ctx.api = (url, options) => { called = { url, options }; return request.promise; }; + const source = read("function"), marker = '$("fnDeepStop").onclick = async () => {'; + const offset = source.indexOf(marker) + marker.length; + vm.runInContext('async function stopDeep() {' + source.slice(offset, source.indexOf('\n};', offset)) + '\n}', test.ctx); + const stopping = test.ctx.stopDeep(); + assert.equal(test.get("fnDeepStop").disabled, true); + assert.equal(called.url, "/api/job/cancel"); + assert.equal(called.options.body.id, "deep-job"); + if (fail) request.reject(new Error("Could not stop")); else request.resolve({ cancelled: true }); + await stopping; + if (fail) { + assert.equal(test.log.error, "Could not stop"); + assert.equal(test.get("fnDeepStop").disabled, false); + } else assert.ok(test.get("fnDeepStatus").textContent.includes("Stopping")); + } // Passive overview refreshes keep a pending check's panel and indicator intact. for (const caseIndex of [0, 6]) { const test = setup(...cases[caseIndex]); diff --git a/tests/test_app_server.py b/tests/test_app_server.py index 67dcfe60..53d21b63 100644 --- a/tests/test_app_server.py +++ b/tests/test_app_server.py @@ -922,3 +922,27 @@ def test_source_preparation_settings_and_bounded_job(app, tmp_path, monkeypatch) time.sleep(.01) assert result['state'] == 'done' and calls assert result['result']['improved'] == 1 + + +def test_deep_permuter_route_starts_a_cancellable_job_for_one_function(app, monkeypatch): + from decompnet.app import deep_permuter + client, _, _ = app + checked = [] + monkeypatch.setattr(deep_permuter, 'validate', lambda *args: checked.append(args[1:])) + def run(net, slug, name, minutes, variants, log): + log('Testing only '+name) + log.progress({'tested':1, 'skipped':0, 'phase':'local repairs'}) + return {'function':name, 'matched':False, 'tested':1} + monkeypatch.setattr(deep_permuter, 'run', run) + payload = {'slug':'selected-game','function':'selected_function','minutes':10,'max_variants':99} + response = client.post('/api/permuter/function', json=payload, headers={'X-Decomp':'1'}) + assert response.status_code == 200 + job_id = response.json()['job'] + for _ in range(100): + result = client.get('/api/job?id='+job_id).json() + if result['state'] != 'running': + break + time.sleep(.01) + assert result['state'] == 'done' + assert result['result']['function'] == 'selected_function' + assert checked == [('selected-game','selected_function',10,99)] diff --git a/tests/test_deep_permuter.py b/tests/test_deep_permuter.py new file mode 100644 index 00000000..02565a92 --- /dev/null +++ b/tests/test_deep_permuter.py @@ -0,0 +1,159 @@ +import pytest + +from decompnet.app import deep_permuter, draft_repairs, permuter_local +from decompnet.app.permute import Permuter +from decompnet.network import NetworkError +from tests.test_permute import AGENT, FakeNet, ProjectStub, batches + + +class Log: + def __init__(self): + self.lines, self.updates = [], [] + self.cancelled = False + def __call__(self, line): + self.check_cancel(); self.lines.append(line) + def check_cancel(self): + if self.cancelled: + raise RuntimeError('cancelled') + def progress(self, value): + self.check_cancel(); self.updates.append(value) + + +def setup(monkeypatch, imported=False): + net = FakeNet() + net._function = lambda name, slug: net.function() + net.local_agent = lambda name: (AGENT, None) + net.fork_slug = lambda slug: 'fork' if imported else None + class Fork(ProjectStub): + def permuter_header(self, slug, name): + return '' + net.forks = Fork() + with net.store.tx() as db: + db.execute("UPDATE functions SET status='open'") + monkeypatch.setattr(deep_permuter.Search, '_baseline', lambda *args: (40, 0)) + monkeypatch.setattr(draft_repairs, 'variants', lambda *args, **kwargs: ['local']) + monkeypatch.setattr(permuter_local, 'variants', lambda *args: []) + return net + + +@pytest.mark.parametrize('imported', [False, True]) +def test_one_function_deep_search_uses_normal_judge_and_keeps_improvements(monkeypatch, imported): + net = setup(monkeypatch, imported) + seen = [] + def judge(self, agent, proj, fn, source, best, fork): + seen.append((source, fork)) + score = {'local':20, 'random':0}.get(source, 100) + 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'])) + return score + monkeypatch.setattr(Permuter, '_score', judge) + batches(monkeypatch, ['local', 'random', 'never tested']) + log = Log() + result = deep_permuter.run(net, 'game', 'f', 30, 10, log) + assert result['matched'] and result['tested'] == 2 and result['skipped'] == 1 + assert seen == [('local','fork' if imported else None), ('random','fork' if imported else None)] + assert net.function()['status'] == 'matched' + assert net.store.one('SELECT COUNT(*) n FROM claims')['n'] == 0 + assert log.updates[-1]['best_score'] == 0 + + +def test_variant_limit_cancellation_and_duplicate_misses_release_the_lease(monkeypatch): + net = setup(monkeypatch) + monkeypatch.setattr(Permuter, '_score', lambda *args: 40) + batches(monkeypatch, ['different']) + result = deep_permuter.run(net, 'game', 'f', 1, 1, Log()) + assert result['reason'] == 'Variant limit reached' and result['tested'] == 1 + assert net.store.one('SELECT COUNT(*) n FROM claims')['n'] == 0 + log = Log() + def cancel(*args): + log.cancelled = True + return 40 + monkeypatch.setattr(Permuter, '_score', cancel) + # Change the build inputs so the first local candidate is not a reusable old miss. + net._context = lambda *args: {'header':'changed', 'version':2} + with pytest.raises(RuntimeError, match='cancelled'): + deep_permuter.run(net, 'game', 'f', 1, 10, log) + assert net.store.one('SELECT COUNT(*) n FROM claims')['n'] == 0 + + +def test_failed_compiles_remain_retryable_and_better_cached_results_are_rejudged(monkeypatch): + net = setup(monkeypatch) + calls = [] + monkeypatch.setattr(Permuter, '_score', lambda *args: calls.append(1)) + log = Log(); search = deep_permuter.Search(net, 1, 10, log) + proj, fn = net.project_row('game'), net.function() + search._score(AGENT, proj, fn, 'bad', 40, None) + search._score(AGENT, proj, fn, 'bad', 40, None) + assert len(calls) == 2 + monkeypatch.setattr(Permuter, '_score', lambda *args: calls.append(1) or 20) + search._score(AGENT, proj, fn, 'ok', 40, None) + search._score(AGENT, proj, fn, 'ok', 40, None) + assert len(calls) == 4 # a cache hit cannot substitute for saving/judging an improvement + + +@pytest.mark.parametrize('minutes,limit', [(0,1), (121,1), (True,1), (1,0), (1,1000001), (1,True)]) +def test_invalid_limits_do_not_start_a_search(monkeypatch, minutes, limit): + net = setup(monkeypatch) + with pytest.raises(NetworkError): + deep_permuter.validate(net, 'game', 'f', minutes, limit) + + +def test_busy_matched_and_unscored_functions_are_rejected(monkeypatch): + net = setup(monkeypatch) + for values in ["status='matched'", "best_source=NULL", "best_score=NULL"]: + with net.store.tx() as db: db.execute('UPDATE functions SET '+values) + with pytest.raises(NetworkError): deep_permuter.validate(net, 'game', 'f', 1, 10) + with net.store.tx() as db: + db.execute("UPDATE functions SET status='open',best_source='int f(){return 1;}',best_score=1") + db.execute('INSERT INTO claims VALUES(1,1,0,9999999999)') + with pytest.raises(NetworkError, match='working'): + deep_permuter.validate(net, 'game', 'f', 1, 10) + + +def test_time_limit_ends_before_a_compile_and_keeps_the_lease_free(monkeypatch): + net = setup(monkeypatch) + initialize = deep_permuter.Search.__init__ + def expired(self, *args): + initialize(self, *args) + self.deadline = 0 + monkeypatch.setattr(deep_permuter.Search, '__init__', expired) + result = deep_permuter.run(net, 'game', 'f', 1, 100, Log()) + assert result['reason'] == 'Time limit reached' and result['tested'] == 0 + assert net.store.one('SELECT COUNT(*) n FROM claims')['n'] == 0 + + +def test_cancelling_random_search_closes_generator_and_releases_function(monkeypatch): + net = setup(monkeypatch) + monkeypatch.setattr(draft_repairs, 'variants', lambda *args, **kwargs: []) + log = Log(); closed = [] + def judge(*args): + log.cancelled = True + return 40 + monkeypatch.setattr(Permuter, '_score', judge) + from decompnet.app import permute + class Generator: + def batch(self, job): return ['one'] + def close(self): closed.append(True) + monkeypatch.setattr(permute, 'available', lambda: True) + monkeypatch.setattr(permute, 'Generator', Generator) + with pytest.raises(RuntimeError, match='cancelled'): + deep_permuter.run(net, 'game', 'f', 1, 100, log) + assert closed and net.store.one('SELECT COUNT(*) n FROM claims')['n'] == 0 + + +def test_a_scalar_cache_does_not_replace_baseline_instruction_evidence(monkeypatch): + measure = deep_permuter.Search._baseline + net = setup(monkeypatch) + monkeypatch.setattr(deep_permuter.Search, '_baseline', measure) + seen = [] + monkeypatch.setattr(Permuter, '_score', lambda *args: seen.append(1) or 40) + def baseline(self, agent, proj, fn, state, fork): + return self._score(agent, proj, fn, 'cached', 40, fork), 1 + monkeypatch.setattr(Permuter, '_baseline', baseline) + search = deep_permuter.Search(net, 1, 10, Log()) + proj, fn = net.project_row('game'), net.function() + search._score(AGENT, proj, fn, 'cached', 40, None) + search._baseline(AGENT, proj, fn, None, None) + assert len(seen) == 2