feat(phase-22): eval_lora held-out scorer (validated) + train_lora --max-steps

eval_lora.py: feeds a fine-tuned model the LEAN asm of held-out BANKED test fns, compiles its C,
compares to the corpus target reloc-masked (banked fns have no .s — corpus carries the asm). Validated:
known-good corpus C scores MATCH vs its own asm. train_lora --max-steps for smoke runs. GPU confirmed
(3080 Ti, torch 2.10+cu128); install + corpus-filter (638/75 clean pairs) done; smoke-train running.
This commit is contained in:
Drew T
2026-06-29 16:49:11 -06:00
parent dfd97226cc
commit 803be6341c
2 changed files with 134 additions and 2 deletions
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""eval_lora.py — gate-true eval of a fine-tuned model on HELD-OUT banked functions.
The held-out test pairs (datasets/match_pairs/test.jsonl) are banked, so they have no .s — but the
corpus carries each one's target asm (with reloc markers). So: feed the model the LEAN asm prompt
(same shape as training), compile its C with the pinned toolchain, objdump it, and compare to the
corpus target (relocation-masked, exactly like match_one). Same format on both sides (objdump) → no
train/eval format confound; functions the model never trained on → real generalization signal.
Reports match / near / fail vs the stock-local floor (~0). Optional --iters feeds the diff back.
LEAN=1 API_BASE=http://<host>:1234/v1 MODEL=bfm-match-7b \
.venv/bin/python tools/eval_lora.py --test datasets/match_pairs/test.jsonl --iters 3
"""
import argparse, json, os, re, struct, subprocess, sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import api_draft # call_api, build_user_lean, extract_code, LEAN_SYS (module-level argparse-free)
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CPP = 'mipsel-linux-gnu-cpp'; CC1 = 'tools/bin/gcc-2.7.2-psx/cc1'; MASPSX = 'tools/maspsx/maspsx.py'
AS = 'mipsel-linux-gnu-as'; OBJDUMP = 'mipsel-linux-gnu-objdump'; PYV = '.venv/bin/python'
CPPF = '-lang-c -Iinclude -undef -Wall -fno-builtin -Dmips -D__GNUC__=2 -D__OPTIMIZE__ -Dpsx -D_PSYQ -D_MIPSEL -D_LANGUAGE_C'.split()
CC1F = '-quiet -O2 -G0 -mips1 -mcpu=3000 -mgas -msoft-float -fgnu-linker'.split()
ASF = '-Iinclude -march=r3000 -mtune=r3000 -no-pad-sections -O1 -G0'.split()
_TD = re.compile(r'^[ \t]*typedef\b.*\b(u8|u16|u32|u64|s8|s16|s32|s64|f32|f64)[ \t]*;[ \t]*\n', re.M)
def compile_obj(c, wd):
"""pinned pipeline -> object path, or None on compile failure."""
os.makedirs(wd, exist_ok=True)
open(os.path.join(wd, 't.c'), 'w').write('#include "common.h"\n' + _TD.sub('', c))
p = subprocess.run([CPP] + CPPF + [os.path.join(wd, 't.c')], capture_output=True, cwd=REPO)
if p.returncode: return None
p = subprocess.run([CC1] + CC1F, input=p.stdout, capture_output=True, cwd=REPO)
if p.returncode: return None
p = subprocess.run([PYV, MASPSX, '--aspsx-version=2.56', '--expand-div'], input=p.stdout, capture_output=True, cwd=REPO)
if p.returncode: return None
p = subprocess.run([AS] + ASF + ['-o', os.path.join(wd, 't.o')], input=p.stdout, capture_output=True, cwd=REPO)
return os.path.join(wd, 't.o') if p.returncode == 0 else None
def my_insns(obj, fn):
d = subprocess.run([OBJDUMP, '-dr', '-j', '.text', obj], capture_output=True, text=True, cwd=REPO).stdout
words, masks, infn = [], [], False
for line in d.splitlines():
h = re.match(r'^[0-9a-f]+ <([^>]+)>:', line)
if h:
infn = (h.group(1) == fn); continue
if not infn: continue
mi = re.match(r'\s+[0-9a-f]+:\s+([0-9a-f]{8})\s+', line)
if mi:
w = int(mi.group(1), 16); words.append(w)
masks.append(0 if (w >> 26) in (2, 3) else 0xFFFFFFFF)
elif 'R_MIPS' in line and words:
masks[-1] = 0 if '_26' in line else 0xFFFF0000
return words, masks
def parse_target(asm):
"""corpus asm (/* vaddr LEWORD */ mnem ; R_MIPS_*) -> (words, masks)."""
words, masks = [], []
for line in asm.splitlines():
m = re.match(r'\s*/\*\s*[0-9A-Fa-f]+\s+([0-9A-Fa-f]{8})\s*\*/\s*(.*)', line)
if not m: continue
words.append(struct.unpack('<I', bytes.fromhex(m.group(1)))[0])
masks.append(0 if 'R_MIPS' in m.group(2) and '_26' in m.group(2)
else (0xFFFF0000 if 'R_MIPS' in m.group(2) else 0xFFFFFFFF))
return words, masks
def diff(mw, mmask, tw, tmask):
n = max(len(mw), len(tw)); ds = []
for i in range(n):
me = (mw[i] & mmask[i]) if i < len(mw) else None
tg = (tw[i] & tmask[i]) if i < len(tw) else None
if me != tg:
ds.append('idx %d: mine=%s target=%s' % (i, '%08x' % mw[i] if i < len(mw) else '--',
'%08x' % tw[i] if i < len(tw) else '--'))
return ds
def eval_one(r, iters, wd):
fn, asm = r['fn'], r['asm']
tw, tmask = parse_target(asm)
msgs = [{'role': 'system', 'content': api_draft.LEAN_SYS},
{'role': 'user', 'content': api_draft.build_user_lean(r, asm, '')}]
best = 10 ** 9
for i in range(max(1, iters)):
code = api_draft.extract_code(api_draft.call_api(msgs))
if not code.strip(): break
obj = compile_obj(code, wd)
if not obj:
ds = ['compile-fail']; close = 10 ** 8
else:
mw, mmask = my_insns(obj, fn)
mismatch = diff(mw, mmask, tw, tmask)
close = 0 if (not mismatch and len(mw) == len(tw)) else (len(mismatch) or 1)
ds = mismatch
best = min(best, close)
if close == 0: return 0
if i < iters - 1:
msgs += [{'role': 'assistant', 'content': '```c\n' + code + '```'},
{'role': 'user', 'content': 'Not byte-identical. Diff:\n' + '\n'.join(ds[:40])
+ '\nFix and reply with the corrected full function in ONE ```c block.'}]
return best
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--test', default='datasets/match_pairs/test.jsonl')
ap.add_argument('--iters', type=int, default=3)
ap.add_argument('--limit', type=int, default=0)
a = ap.parse_args()
rows = [json.loads(l) for l in open(os.path.join(REPO, a.test))]
if a.limit: rows = rows[:a.limit]
print('eval_lora: %s @ %s -> %d held-out fns, %d iters\n' % (api_draft.MODEL, api_draft.API_BASE, len(rows), a.iters))
match = near = fail = 0
for r in rows:
c = eval_one(r, a.iters, os.path.join(REPO, '.run/_eval_lora'))
tag = 'MATCH' if c == 0 else ('compile/near-fail' if c >= 10 ** 8 else 'near %d' % c)
if c == 0: match += 1
elif c >= 10 ** 8: fail += 1
else: near += 1
print(' %-16s %s' % (r['fn'], tag))
n = len(rows)
print('\n=== eval: MATCH %d/%d near %d fail %d (stock-local floor: ~0) ===' % (match, n, near, fail))
if __name__ == '__main__':
main()
+4 -2
View File
@@ -31,6 +31,7 @@ def main():
ap.add_argument('--out', default='models/bfm-match-7b')
ap.add_argument('--rank', type=int, default=16)
ap.add_argument('--epochs', type=float, default=3.0)
ap.add_argument('--max-steps', type=int, default=0, help='>0 overrides epochs (use for a smoke run)')
ap.add_argument('--lr', type=float, default=2e-4)
ap.add_argument('--maxlen', type=int, default=4096, help='token cap; longest pairs are ~giant asm')
ap.add_argument('--no-gguf', action='store_true', help='skip the merged-GGUF export step')
@@ -63,8 +64,9 @@ def main():
args=SFTConfig(
dataset_text_field='text', max_seq_length=a.maxlen,
per_device_train_batch_size=2, gradient_accumulation_steps=8,
warmup_ratio=0.03, num_train_epochs=a.epochs, learning_rate=a.lr,
logging_steps=10, optim='adamw_8bit', weight_decay=0.01,
warmup_ratio=0.03, num_train_epochs=a.epochs,
max_steps=(a.max_steps if a.max_steps > 0 else -1), learning_rate=a.lr,
logging_steps=1 if a.max_steps else 10, optim='adamw_8bit', weight_decay=0.01,
lr_scheduler_type='cosine', seed=3407,
output_dir=os.path.join(REPO, a.out, 'ckpt')))
trainer.train()