File size: 9,364 Bytes
b296ad4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
"""Raw greedy tool-calling evaluation; SQL equivalence is explicitly SQLite-based."""
import argparse
from collections import Counter,defaultdict
import contextlib
import hashlib
import json
import time
from pathlib import Path
import torch
import jsonschema
from tokenizers import Tokenizer
from tinyquery.model import TinyQuery,Config
from tinyquery.data import validate_sql,validate_context_sql,SQL_OPS


def parse_action(text):
    def unique(pairs):
        result={}
        for key,value in pairs:
            if key in result:raise ValueError('Duplicate JSON key: '+key)
            result[key]=value
        return result
    return json.loads(text,object_pairs_hook=unique)


def check_action(action,context):
    if not isinstance(action,dict): raise ValueError('Action must be an object')
    kind=action.get('action')
    if kind=='call':
        if set(action)!={'action','name','arguments'}: raise ValueError('Unexpected action keys')
        tool=next((t for t in context['tools'] if t['name']==action.get('name')),None)
        if tool is None: raise ValueError('Unknown tool')
        jsonschema.validate(action.get('arguments'),tool['inputSchema'])
    elif kind in ('clarify','answer'):
        key='question' if kind=='clarify' else 'text'
        if set(action)!={'action',key} or not isinstance(action[key],str) or not action[key].strip():
            raise ValueError('Invalid textual action')
    else: raise ValueError('Unknown action')
    return action


def mcp_request(action,context,request_id=1):
    check_action(action,context)
    if action['action']!='call': raise ValueError('Only tool actions map to tools/call')
    # Scope is an inference input, not a string the model may freely substitute.
    if 'project_id' in action['arguments'] and action['arguments']['project_id']!=context.get('project_id'):
        raise ValueError('Predicted project_id differs from the supplied project scope')
    tool=next(t for t in context['tools'] if t['name']==action['name'])
    description=tool.get('description','')
    for key,value in action['arguments'].items():
        if key=='sql' or (key=='query' and 'SELECT query' in description):
            import sqlglot
            from sqlglot import exp
            trees=sqlglot.parse(value,read='mysql' if context['backend']=='mysql' else 'postgres')
            if len(trees)!=1 or not isinstance(trees[0],exp.Select):raise ValueError('Expected one read-only SELECT')
            tree=trees[0]
            if tree.find(exp.Into) or any(isinstance(n,(exp.DML,exp.DDL)) for n in tree.walk()):
                raise ValueError('Data-changing SQL is not supported')
            known={sqlglot.parse_one(ddl).this.this.name for ddl in context.get('schema',[])}
            if any(table.name not in known for table in tree.find_all(exp.Table)):
                raise ValueError('SQL references a table absent from the supplied schema')
    return {'jsonrpc':'2.0','id':request_id,'method':'tools/call',
            'params':{'name':action['name'],'arguments':action['arguments']}}


def score(row,text):
    result={'json_valid':False,'schema_valid':False,'action_correct':False,'tool_correct':False,
            'arguments_exact':False,'sql_equivalent':None,'success':False}
    if row['target']['action']!='call':
        result['tool_correct']=None; result['arguments_exact']=None
    if row['operation'] in SQL_OPS: result['sql_equivalent']=False
    try:
        parsed=parse_action(text); result['json_valid']=True
        if isinstance(parsed,dict): result['action_correct']=parsed.get('action')==row['target']['action']
        check_action(parsed,row['context']); result['schema_valid']=True
        gold=row['target']; result['action_correct']=parsed['action']==gold['action']
        if not result['action_correct']: return result
        if gold['action']!='call':
            key='question' if gold['action']=='clarify' else 'text'
            # Action accuracy and wording exact-match are separate; generic clarification is not semantic proof.
            result['text_exact']=parsed[key]==gold[key]
            result['success']=result['text_exact']
            return result
        result['tool_correct']=parsed['name']==gold['name']
        result['arguments_exact']=parsed['arguments']==gold['arguments']
        sqlkey=next((k for k in ('sql','query') if k in gold['arguments'] and str(gold['arguments'][k]).startswith('SELECT ')),None)
        if sqlkey:
            # Backend/project arguments must also match; equal SQL against a wrong project is not a pass.
            other_correct={k:v for k,v in parsed['arguments'].items() if k!=sqlkey}=={k:v for k,v in gold['arguments'].items() if k!=sqlkey}
            validate_context_sql(parsed['arguments'][sqlkey],row['backend'],row['context']['schema'])
            actual=validate_sql(parsed['arguments'][sqlkey],row['backend'],row['slots'])
            expected=validate_sql(gold['arguments'][sqlkey],row['backend'],row['slots'])
            ordered=row['operation'] in ['sort_asc','sort_desc','top','bottom']
            normalize=lambda a:a if ordered else sorted(a,key=repr)
            equivalent=all(normalize(a)==normalize(b) for a,b in zip(actual,expected))
            result['sql_equivalent']=equivalent
            result['success']=result['tool_correct'] and other_correct and equivalent
        else: result['success']=result['tool_correct'] and result['arguments_exact']
    except Exception as exc: result['error']=str(exc)[:350]
    return result


def load_model(checkpoint,device):
    path=Path(checkpoint)
    if path.suffix=='.safetensors':
        from safetensors.torch import load
        c=Config(**json.loads((path.parent/'config.json').read_text()))
        raw=path.read_bytes(); state=load(raw)
        model=TinyQuery(c); model.load_state_dict(state)
        model.checkpoint_sha256=hashlib.sha256(raw).hexdigest();del raw,state
    else:
        state=torch.load(path,map_location='cpu',weights_only=False)
        model=TinyQuery(Config(**state['config'])); model.load_state_dict(state['model']); del state
    model=model.to(device)
    if device=='cuda': model=model.to(torch.bfloat16)
    return model.eval()


def aggregate(results):
    metrics={}
    for key in ['json_valid','schema_valid','action_correct','tool_correct','arguments_exact','sql_equivalent','success']:
        entries=[r[key] for r in results if r.get(key) is not None]
        metrics[key]={'correct':sum(entries),'total':len(entries),'rate':sum(entries)/max(1,len(entries))}
    return metrics


def main():
    p=argparse.ArgumentParser(); p.add_argument('--checkpoint',required=True); p.add_argument('--tokenizer',required=True)
    p.add_argument('--data',required=True); p.add_argument('--out',required=True); p.add_argument('--limit',type=int,default=0)
    p.add_argument('--batch',type=int,default=16); p.add_argument('--tokens',type=int,default=160)
    args=p.parse_args(); device='cuda' if torch.cuda.is_available() else ('mps' if torch.backends.mps.is_available() else 'cpu')
    torch.set_num_threads(8); model=load_model(args.checkpoint,device); tokenizer=Tokenizer.from_file(args.tokenizer)
    rows=[json.loads(l) for l in Path(args.data).read_text().splitlines()]
    if args.limit:
        import random
        random.Random(777).shuffle(rows); rows=rows[:args.limit]
    # Sort only for efficient padding, never alter which examples are evaluated.
    encoded=[(r,tokenizer.encode(r['prompt']).ids) for r in rows]; encoded.sort(key=lambda item:len(item[1]))
    out=Path(args.out); out.parent.mkdir(parents=True,exist_ok=True); results=[]; start=time.time()
    groups=defaultdict(list)
    with out.open('w') as stream:
        for i in range(0,len(encoded),args.batch):
            batch=encoded[i:i+args.batch]
            with torch.inference_mode():
                outputs=model.generate_batch([ids for _,ids in batch],tokenizer.token_to_id('<|end|>'),max_new_tokens=args.tokens)
            for (row,_),ids in zip(batch,outputs):
                text=tokenizer.decode(ids,skip_special_tokens=True)
                metrics=score(row,text); results.append(metrics)
                for field in ['language','backend','operation']: groups[field+':'+row[field]].append(metrics)
                entry={'id':row['id'],'language':row['language'],'backend':row['backend'],'operation':row['operation'],
                       'question':row['question'],'expected':row['target'],'output':text,'metrics':metrics}
                stream.write(json.dumps(entry,ensure_ascii=False)+'\n')
            stream.flush()
            print(json.dumps({'evaluated':len(results),'seconds':time.time()-start,'success':sum(r['success'] for r in results)/len(results)}),flush=True)
    summary={'examples':len(results),'seconds':time.time()-start,'decoding':'raw greedy, no repair, no teacher fallback',
             'checkpoint_sha256':getattr(model,'checkpoint_sha256',None),
             'sql_metric':'Compile against the supplied schema, then compare results on two generated SQLite fixtures after dialect adaptation; not native MySQL/PostgreSQL execution.',
             'metrics':aggregate(results),'groups':{k:aggregate(v) for k,v in groups.items()}}
    out.with_suffix('.summary.json').write_text(json.dumps(summary,indent=2)); print(json.dumps(summary),flush=True)


if __name__=='__main__': main()