MiniWeb-demo / src /miniweb /scripts /audit_task_verifiers.py
sparklabuofu's picture
Update the demo
22de727 verified
Raw History Blame Contribute Delete
8.32 kB
"""Offline verifier audit. Uses saved evidence; never fabricates successful walks.
Judge-dependent comparisons are marked unresolved in the deterministic pass.
Reports record every task, including exclusions and missing verifiers.
"""
import argparse
import copy
import json
from pathlib import Path
import sys
from contextlib import nullcontext
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[3] # the repository
sys.path.insert(0, str(ROOT / "src"))
from miniweb.annotation.macros import canon, all_canonical
from miniweb.annotation.quality import effective_macros
from miniweb.evaluation.verifiers import verify_task, CHECKS
from miniweb.evaluation.trajectory import merge_server_log
def nodes(tree, path=()):
if not isinstance(tree, dict):
yield path, tree
return
yield path, tree
for field in ('checks', 'steps'):
for i, child in enumerate(tree.get(field, [])):
yield from nodes(child, path + (field, i))
for field in ('request', 'match'):
if isinstance(tree.get(field), dict):
yield from nodes(tree[field], path + (field,))
def load_evidence(directory, task):
walk = directory / 'verification_walk.json'
# Excluded tasks may have a failed exploratory walk; preserve that distinction.
runs = directory / 'verifier_runs.json'
prior = json.loads(runs.read_text()).get('runs', {}) if runs.exists() else {}
if walk.exists() and prior.get('walk', {}).get('passed'):
data = json.loads(walk.read_text())
return data.get('trajectory', []), data.get('answer', ''), 'walk'
path = directory / 'trajectory.json'
traj = json.loads(path.read_text()) if path.exists() else []
traj = merge_server_log(traj, directory / 'server_log.json')
return traj, task.get('answer') or task.get('expected_answer') or '', 'gold'
def audit_one(key, task, spec):
row = {'key': key, 'recommendation': task.get('ai_review', {}).get('recommendation', 'unreviewed'),
'instruction': task.get('instruction', ''), 'findings': [], 'probes': []}
if not spec or not spec.get('macros'):
row['findings'].append('No saved verifier')
return row
spec = copy.deepcopy(spec)
spec['macros'] = effective_macros(task, spec['macros'])
want = {canon(m) for m in task.get('macros', [])}
if want != set(spec['macros']):
row['findings'].append('Task and verifier macro keys differ')
for macro, tree in spec['macros'].items():
for path, node in nodes(tree):
if not isinstance(node, dict):
row['findings'].append('Malformed node: ' + macro + str(path));continue
if 'op' in node and (not node.get('checks') or node['op'] not in ('AND', 'OR')):
row['findings'].append('Empty/invalid group: ' + macro + str(path))
if 'type' in node and node['type'] not in CHECKS:
row['findings'].append('Unknown check: ' + str(node['type']))
if any(isinstance(v, dict) and v.get('open') for v in node.values()) and not node.get('advisory'):
row['findings'].append('Unfilled gating check: ' + macro + str(path))
traj, answer, source = load_evidence(ROOT / 'data/annotations' / key, task)
row['source'] = source
def probe(name, events, output, expected=None):
report = verify_task(spec, events, output, question=task.get('instruction', ''))
errors=[]
def failed(n):
if n.get('reason') and ('unavailable' in n['reason'].lower() or 'audit: judge' in n['reason'] or 'raised ' in n['reason']):errors.append(n['reason'])
for child in n.get('checks',[]):failed(child)
for n in report['macros'].values():failed(n)
row['probes'].append({'name': name, 'passed': report['passed'], 'expected': expected,
'by_macro': report['by_macro'], 'unresolved': errors})
return report['passed']
passed=probe('saved_'+source, traj, answer, True)
probe('empty', [], '', False)
probe('answer_only', [], answer, False)
probe('unrelated_activity', [{'type':'action','action':'click','target':'unrelated'}, {'type':'network','method':'GET','url':'/unrelated','status':200}, {'type':'observation','url':'/unrelated','snapshot':'Unrelated page'}], answer, False)
all_nodes=[n for tree in spec['macros'].values() for _,n in nodes(tree) if isinstance(n,dict)]
answers=[n for n in all_nodes if (n.get('type') in ('answer_matches','qa_answer') and n.get('leaf', True) is not False)
or (n.get('type') == 'request_sequence' and 'answer' in n)]
row['answer_gated']=bool(answers)
if answers:
probe('missing_answer',traj,'',False)
probe('denies_expected_answer',traj,'The answer is not '+str(answer)+'.',False)
probe('uncertain_answer',traj,'I do not know whether the answer is '+str(answer)+'.',False)
probe('wrong_answer',traj,'Unrelated answer 987654321',False)
# Diagnostic ablations require semantic review: outcome-only tasks may
# legitimately pass without action events or an unnecessary request.
for kind in ('network','action','observation'):
probe('without_'+kind,[e for e in traj if e.get('type')!=kind],answer)
requests=[e for e in traj if e.get('type')=='network' and e.get('method') in ('POST','PUT','PATCH','DELETE')]
if requests:
altered=[dict(e,status=500) if e in requests else e for e in traj]
probe('mutations_return_500',altered,answer)
from miniweb.evaluation.verifiers import RequestMade, _parse_body
# Counterfactual copies are diagnostic probes, never stored as walkthroughs.
for ni,n in enumerate(all_nodes):
if n.get('type') != 'request_made' or not n.get('last_for_resource') or '{{' in n.get('url',''):
continue
matches=[e for e in traj if RequestMade(n).run([e], '')[0]]
if not matches:continue
e=copy.deepcopy(matches[-1]);resp=_parse_body(e.get('responseBody'))
inverse={'liked':'unliked','saved':'unsaved','starred':'unstarred','followed':'unfollowed',
'joined':'left','blocked':'unblocked','subscribed':'unsubscribed','up':'down'}
changed=False
for field,w in n.get('response_fields',{}).items():
if field not in resp:continue
value=resp[field]
if isinstance(value,bool):resp[field]=not value;changed=True
elif isinstance(value,str) and value in inverse:resp[field]=inverse[value];changed=True
if changed:
e['responseBody']=resp;e.pop('timestamp',None);e.pop('_source',None)
probe('undo_final_state_'+str(ni),traj+[e],answer,False)
if passed:
probe('harmless_extra_action',traj+[{'type':'action','action':'scroll','target':'page'}],answer,True)
row['unexpected']=[p['name'] for p in row['probes'] if p['expected'] is not None and p['passed']!=p['expected'] and not p['unresolved']]
return row
def main():
parser=argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output',required=True);parser.add_argument('--only',default='')
parser.add_argument('--judge',action='store_true',help='Run configured semantic judge instead of leaving those comparisons unresolved')
args=parser.parse_args();out=Path(args.output);out.mkdir(parents=True,exist_ok=True)
rows=[]
with nullcontext() if args.judge else patch('miniweb.evaluation.verifiers._judge_alignment', return_value=(False,'audit: judge-dependent comparison not executed')):
for i,path in enumerate(sorted((ROOT/'data/annotations').glob('*/*/task.json')),1):
key=str(path.parent.relative_to(ROOT/'data/annotations'))
if args.only and args.only not in key:continue
task=json.loads(path.read_text());vf=path.parent/'verifier.json';spec=json.loads(vf.read_text()) if vf.exists() else None
rows.append(audit_one(key,task,spec))
(out/'automated.json').write_text(json.dumps(rows,indent=2,ensure_ascii=False))
if i%25==0:print('Audited',i,flush=True)
print('Complete:',len(rows),'tasks;',sum(len(r.get('probes',[])) for r in rows),'probes;',sum(bool(r.get('unexpected') or r['findings']) for r in rows),'tasks flagged',flush=True)
if __name__=='__main__':main()