@caveman-ai/cloud 0.0.1 → 0.1.0-rc.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/README.md +75 -3
  2. package/bin/ci.mjs +236 -0
  3. package/bin/cvm.mjs +261 -27
  4. package/bin/evals.mjs +621 -0
  5. package/dist/cloud-client.mjs +34 -33
  6. package/dist/cloud-mcp.mjs +32 -32
  7. package/dist/criterion-registry.json +1370 -0
  8. package/dist/skills/caveman-cloud/SKILL.md +129 -0
  9. package/dist/skills/caveman-cloud/references/caveman-cloud-compare.md +12 -0
  10. package/dist/skills/caveman-cloud/references/caveman-cloud-connect.md +54 -0
  11. package/dist/skills/caveman-cloud/references/caveman-cloud-dashboards.md +72 -0
  12. package/dist/skills/caveman-cloud/references/caveman-cloud-evals.md +202 -0
  13. package/dist/skills/caveman-cloud/references/caveman-cloud-project.md +97 -0
  14. package/dist/skills/caveman-cloud/references/caveman-cloud-scenarios.md +51 -0
  15. package/dist/skills/caveman-cloud/references/caveman-cloud-setup.md +174 -0
  16. package/dist/skills/caveman-cloud/references/caveman-cloud-strict-evals.md +52 -0
  17. package/dist/skills/caveman-cloud/references/caveman-cloud-traces.md +16 -0
  18. package/dist/skills/caveman-cloud/references/caveman-discover.md +143 -0
  19. package/dist/skills/caveman-cloud/references/caveman-evidence-review.md +64 -0
  20. package/dist/skills/caveman-cloud/references/caveman-manage.md +59 -0
  21. package/dist/skills/caveman-cloud/references/caveman-onboard.md +100 -0
  22. package/dist/skills/caveman-cloud/references/caveman-optimize.md +113 -0
  23. package/dist/skills/caveman-cloud/references/caveman-setup.md +282 -0
  24. package/dist/skills/caveman-cloud/references/caveman-sql.md +92 -0
  25. package/package.json +7 -4
package/bin/evals.mjs ADDED
@@ -0,0 +1,621 @@
1
+ // `cvm evals init|validate|run` and the eval files of `cvm evals ci`: one eval
2
+ // as a repository file (*.eval.json; its schema is eval-file.schema.json in
3
+ // public/shared/contracts/schemas). The file's name is the experiment, found
4
+ // or created by that name, so every run lines up with the last. A command
5
+ // task runs here and its outputs go up with the run; Caveman grades them and
6
+ // reads the run against the experiment's saved baseline. The verdict and its
7
+ // numbers are the server's: nothing here computes a comparison.
8
+ import {globSync,readFileSync,writeFileSync} from 'node:fs';
9
+ import {spawn} from 'node:child_process';
10
+ import {randomUUID} from 'node:crypto';
11
+ import {parseArgs} from 'node:util';
12
+ import {CloudError,inertJSON} from '../dist/cloud-client.mjs';
13
+ import {connect,detectRun,seconds,xml} from './ci.mjs';
14
+
15
+ const KEY=/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,95}$/,UUID=/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/i;
16
+ const MAX_OUTPUT=1024*1024; // characters of one task output; a run's upload is capped at 16 MiB
17
+ const isObject=value=>value!==null&&typeof value==='object'&&!Array.isArray(value);
18
+ // The fixed checks and the options each takes: the criterion registry
19
+ // generated from Go at this release (cloud/internal/evalworkbench/registry.go),
20
+ // copied beside the client when cvm is built. A trajectory rule or code is a
21
+ // library criterion, named in a file, never a fixed check.
22
+ let checks;
23
+ const fixedChecks=()=>checks??=new Map(JSON.parse(readFileSync(new URL('../dist/criterion-registry.json',import.meta.url),'utf8')).kinds.filter(kind=>kind.kind==='output_grader'||kind.kind==='question').map(kind=>[kind.id,kind]));
24
+
25
+ // validateEvalFile checks a parsed file against eval-file.schema.json, plus
26
+ // what that schema cannot say (unique case ids, a recorded task's outputs, a
27
+ // fixed check's type and options). Every error names the JSON path it is about.
28
+ export function validateEvalFile(doc){
29
+ const errors=[],bad=(path,message)=>{errors.push({path,message});};
30
+ const fields=(value,path,required,optional=[])=>{
31
+ if(!isObject(value)){bad(path,'must be an object');return false;}
32
+ for(const key of Object.keys(value))if(!required.includes(key)&&!optional.includes(key))bad(`${path}.${key}`,`is not a field here; the fields are ${[...required,...optional].join(', ')}`);
33
+ for(const key of required)if(value[key]===undefined)bad(`${path}.${key}`,'is required');
34
+ return true;
35
+ };
36
+ const text=(value,path,max)=>{if(value!==undefined&&(typeof value!=='string'||!value.length||value.length>max))bad(path,`must be text of 1 to ${max} characters`);};
37
+ const number=(value,path,min,max,whole=true)=>{if(value!==undefined&&(typeof value!=='number'||whole&&!Number.isInteger(value)||!(value>=min&&value<=max)))bad(path,`must be a ${whole?'whole ':''}number from ${min} to ${max}`);};
38
+ const key=(value,path)=>{if(value!==undefined&&(typeof value!=='string'||!KEY.test(value)))bad(path,'must be 1 to 96 letters, digits, _ or -, starting with a letter or digit');};
39
+ const list=(value,path,min,max)=>{
40
+ if(value===undefined)return [];
41
+ if(!Array.isArray(value)||value.length<min||value.length>max){bad(path,max===Infinity?`must be a list of at least ${min} item`:`must be a list of ${min} to ${max} items`);return [];}
42
+ return value;
43
+ };
44
+
45
+ if(!fields(doc,'$',['name','dataset','task','criteria'],['$schema','trials','gate','budget_usd']))return errors;
46
+ if(doc.$schema!==undefined&&typeof doc.$schema!=='string')bad('$.$schema','must be text');
47
+ text(doc.name,'$.name',200);
48
+ number(doc.trials,'$.trials',1,10);
49
+ number(doc.budget_usd,'$.budget_usd',0,1000,false);
50
+
51
+ const {dataset,task,gate}=doc;
52
+ const inline=isObject(dataset)&&dataset.cases!==undefined;
53
+ if(inline){
54
+ fields(dataset,'$.dataset',['cases']);
55
+ const seen=new Map();
56
+ for(const [i,item] of list(dataset.cases,'$.dataset.cases',1,10000).entries()){
57
+ const path=`$.dataset.cases[${i}]`;
58
+ if(!fields(item,path,['id','input'],['expected','output','tags']))continue;
59
+ key(item.id,`${path}.id`);
60
+ if(seen.has(item.id))bad(`${path}.id`,`repeats the id of $.dataset.cases[${seen.get(item.id)}]`);else seen.set(item.id,i);
61
+ const tags=list(item.tags,`${path}.tags`,0,32);
62
+ for(const [j,tag] of tags.entries())text(tag,`${path}.tags[${j}]`,100);
63
+ if(new Set(tags).size!==tags.length)bad(`${path}.tags`,'names a tag twice');
64
+ }
65
+ }else if(isObject(dataset)&&dataset.name!==undefined){
66
+ fields(dataset,'$.dataset',['name'],['version']);
67
+ text(dataset.name,'$.dataset.name',200);
68
+ number(dataset.version,'$.dataset.version',1,Number.MAX_SAFE_INTEGER);
69
+ }else if(dataset!==undefined)bad('$.dataset','needs cases (written here) or name (a dataset of the project)');
70
+
71
+ if(isObject(task)&&task.command!==undefined){
72
+ fields(task,'$.task',['command'],['concurrency','timeout_seconds','retries']);
73
+ for(const [i,arg] of list(task.command,'$.task.command',1,Infinity).entries())if(typeof arg!=='string'||!arg)bad(`$.task.command[${i}]`,'must be text');
74
+ number(task.concurrency,'$.task.concurrency',1,64);
75
+ number(task.retries,'$.task.retries',0,10);
76
+ number(task.timeout_seconds,'$.task.timeout_seconds',0,3600,false);
77
+ if(task.timeout_seconds===0)bad('$.task.timeout_seconds','must be over 0');
78
+ }else if(isObject(task)&&task.recorded!==undefined){
79
+ fields(task,'$.task',['recorded']);
80
+ key(task.recorded,'$.task.recorded');
81
+ if(inline&&Array.isArray(dataset.cases)){
82
+ if(task.recorded!=='output')bad('$.task.recorded','must be "output": a case written in the file carries its recorded output as output');
83
+ for(const [i,item] of dataset.cases.entries())if(isObject(item)&&item.output===undefined)bad(`$.dataset.cases[${i}].output`,'is required: the task reads recorded outputs');
84
+ }
85
+ }else if(isObject(task)&&task.kind!==undefined){
86
+ fields(task,'$.task',['kind','config'],['input_mapping']);
87
+ if(!['model','prompt','http'].includes(task.kind))bad('$.task.kind','must be model, prompt or http');
88
+ if(task.config!==undefined&&!isObject(task.config))bad('$.task.config','must be an object');
89
+ if(task.input_mapping!==undefined){
90
+ if(!isObject(task.input_mapping))bad('$.task.input_mapping','must be an object');
91
+ else for(const [name,column] of Object.entries(task.input_mapping))key(column,`$.task.input_mapping.${name}`);
92
+ }
93
+ }else if(task!==undefined)bad('$.task','needs command (run here), recorded (a dataset column) or kind (a target Caveman runs)');
94
+
95
+ for(const [i,criterion] of list(doc.criteria,'$.criteria',1,24).entries()){
96
+ const path=`$.criteria[${i}]`;
97
+ if(typeof criterion==='string')text(criterion,path,200);
98
+ else if(isObject(criterion)&&criterion.judge!==undefined){
99
+ fields(criterion,path,['judge'],['name']);
100
+ text(criterion.name,`${path}.name`,200);
101
+ if(fields(criterion.judge,`${path}.judge`,['question'],['pass','fail'])){
102
+ text(criterion.judge.question,`${path}.judge.question`,500);
103
+ text(criterion.judge.pass,`${path}.judge.pass`,1000);
104
+ text(criterion.judge.fail,`${path}.judge.fail`,1000);
105
+ }
106
+ }else if(isObject(criterion)&&criterion.type!==undefined){
107
+ text(criterion.type,`${path}.type`,100);
108
+ text(criterion.name,`${path}.name`,200);
109
+ const check=fixedChecks().get(criterion.type);
110
+ if(!check){if(typeof criterion.type==='string'&&criterion.type)bad(`${path}.type`,`"${criterion.type}" is no fixed check; cvm criteria catalog lists them`);continue;}
111
+ const known=check.options.map(option=>option.name);
112
+ for(const option of check.options)if(option.required&&criterion[option.name]===undefined)bad(`${path}.${option.name}`,`is required by ${criterion.type}`);
113
+ if(!check.extra_options)for(const option of Object.keys(criterion))if(option!=='type'&&option!=='name'&&!known.includes(option))bad(`${path}.${option}`,`is not an option of ${criterion.type}; ${known.length?`its options are ${known.join(', ')}`:'it takes none'}`);
114
+ }else bad(path,'must be a library criterion name, a fixed check ({"type": ...}) or a judge question ({"judge": ...})');
115
+ }
116
+
117
+ if(gate!==undefined&&fields(gate,'$.gate',[],['baseline','policy'])){
118
+ if(gate.baseline!==undefined&&gate.baseline!=='saved'&&!(typeof gate.baseline==='string'&&UUID.test(gate.baseline)))bad('$.gate.baseline','must be "saved" or the id of a run of this experiment');
119
+ if(gate.policy!==undefined&&!['statistical','strict'].includes(gate.policy))bad('$.gate.policy','must be statistical or strict');
120
+ }
121
+ return errors;
122
+ }
123
+
124
+ // readEvalFile reads and validates one file. A file that cannot be read or
125
+ // parsed is one error at the root.
126
+ function readEvalFile(file){
127
+ let doc;
128
+ try{doc=JSON.parse(readFileSync(file,'utf8'));}
129
+ catch(error){return {file,errors:[{path:'$',message:error instanceof SyntaxError?`is not JSON: ${error.message}`:`cannot be read: ${error.code??error.message}`}]};}
130
+ return {file,doc,errors:validateEvalFile(doc)};
131
+ }
132
+
133
+ // loadCases is the file's cases as dataset columns and rows: written in the
134
+ // file, or read from the project's dataset of that name. Rows are read only
135
+ // for a command task, which runs here on them; for a task Caveman runs, the
136
+ // dataset's columns and the version's identity are enough, and those any
137
+ // sign-in that may run an eval can read.
138
+ async function loadCases(doc,cloud){
139
+ const {dataset,task}=doc;
140
+ if(dataset.cases){
141
+ const has=key=>dataset.cases.some(item=>item[key]!==undefined);
142
+ return {errors:[],columns:[{key:'input',name:'Input',type:'json',role:'input'},...(has('expected')?[{key:'expected',name:'Expected',type:'json',role:'expected'}]:[]),...(has('output')?[{key:'output',name:'Output',type:'json',role:'output'}]:[])],
143
+ rows:dataset.cases.map(item=>({row_key:item.id,values:{input:item.input,...(item.expected!==undefined?{expected:item.expected}:{}),...(item.output!==undefined?{output:item.output}:{})},...(item.tags?.length?{tags:item.tags}:{})}))};
144
+ }
145
+ const client=await cloud();
146
+ // ponytail: datasets.list takes no cursor, so only its first page is searched (the SDKs' ceiling too); page here when it does.
147
+ const found=((await client.execute('datasets.list',{})).data.data??[]).find(item=>item.name.toLowerCase()===dataset.name.toLowerCase());
148
+ if(!found)return {errors:[{path:'$.dataset.name',message:`no dataset named "${dataset.name}" in this project`}]};
149
+ const reference={key:'cases',dataset_id:found.id},noVersion={errors:[{path:'$.dataset.version',message:`dataset "${found.name}" has no version ${dataset.version}`}]};
150
+ let columns,rows=[];
151
+ if(!task.command){
152
+ // ponytail: a pinned version is checked against the columns of the dataset's head; the server checks the version's own when the experiment is saved.
153
+ columns=found.columns??[];
154
+ if(dataset.version!==undefined){
155
+ const version=((await client.execute('datasets.versions',{dataset_id:found.id})).data.data??[]).find(item=>item.version===dataset.version);
156
+ if(!version)return noVersion;
157
+ Object.assign(reference,{version:version.version,document_sha256:version.document_sha256});
158
+ }
159
+ }else{
160
+ let detail;
161
+ try{detail=(await client.execute('datasets.get',{dataset_id:found.id})).data;}
162
+ catch(error){
163
+ // Rows are captured content: a project key and a member below admin read none.
164
+ if(!['cave_api_key_route_forbidden','cave_operation_forbidden','cave_forbidden'].includes(error.code))throw error;
165
+ return {errors:[{path:'$.dataset.name',message:'this sign-in reads no dataset rows, and a command task runs here on them: write the cases in the file, or run it as an owner or admin'}]};
166
+ }
167
+ columns=detail.dataset.columns;rows=[...(detail.rows??[])].sort((a,b)=>a.position-b.position).map(row=>({row_key:row.row_key,values:row.values}));
168
+ if(dataset.version!==undefined){
169
+ const version=(detail.versions??[]).find(item=>item.version===dataset.version);
170
+ if(!version)return noVersion;
171
+ columns=version.document.columns;rows=version.document.rows;
172
+ Object.assign(reference,{version:version.version,document_sha256:version.document_sha256});
173
+ }
174
+ }
175
+ if(task.recorded&&!columns.some(column=>column.key===task.recorded))return {errors:[{path:'$.task.recorded',message:`dataset "${found.name}" has no column "${task.recorded}"`}]};
176
+ return {errors:[],columns,rows,reference};
177
+ }
178
+
179
+ const slug=value=>value.toLowerCase().replace(/[^a-z0-9]+/g,'_').replace(/^_+|_+$/g,'').slice(0,64)||'criterion';
180
+
181
+ // resolveCriteria turns the file's criteria into the experiment's evaluators:
182
+ // a library criterion at its current version, a fixed check or a judge question on
183
+ // the task's output. Keys are slugs of the names, sorted, as the SDKs write
184
+ // them: two runs compare when their criteria are the same, in any order.
185
+ async function resolveCriteria(doc,columns,cloud){
186
+ const errors=[],drafts=[];
187
+ const expected=columns.find(column=>column.role==='expected')?.key;
188
+ const input_mapping={candidate:{source:'target',key:'$target'},...(expected?{reference:{source:'dataset',key:expected}}:{})};
189
+ for(const [i,criterion] of doc.criteria.entries()){
190
+ const path=`$.criteria[${i}]`;
191
+ if(typeof criterion!=='string'){
192
+ if(criterion.judge){
193
+ const {question,pass,fail}=criterion.judge;
194
+ drafts.push({name:criterion.name??question.slice(0,200),source:'inline',grader_type:'llm_judge',options:{rubric:[question,pass&&`Pass: ${pass}`,fail&&`Fail: ${fail}`].filter(Boolean).join('\n')},input_mapping});
195
+ }else{
196
+ const {type,name,...options}=criterion;
197
+ drafts.push({name:name??type,source:'inline',grader_type:type,options,input_mapping});
198
+ }
199
+ continue;
200
+ }
201
+ const client=await cloud();
202
+ let found;
203
+ if(UUID.test(criterion)){
204
+ try{found=[(await client.execute('criteria.get',{criterion_id:criterion})).data];}
205
+ catch(error){if(error.code!=='cave_not_found')throw error;found=[];}
206
+ }else{
207
+ // Every state is read so a criterion that exists but is not on is named as such.
208
+ const named=((await client.execute('criteria.list',{q:criterion,state:'all',limit:100})).data.data??[]).filter(item=>item.name.toLowerCase()===criterion.toLowerCase());
209
+ found=named.filter(item=>(item.state??'active')==='active');
210
+ if(!found.length&&named.length){errors.push({path,message:`"${criterion}" is ${named[0].state} in this project's library, not on: a person turns it on (console, Criteria) before a run can use it`});continue;}
211
+ }
212
+ if(found.length!==1)errors.push({path,message:found.length?`"${criterion}" names ${found.length} criteria of this project's library; use the id of one`:`no criterion named "${criterion}" in this project's library`});
213
+ else drafts.push({name:found[0].name,source:'criterion',criterion_id:found[0].id,criterion_version:found[0].version});
214
+ }
215
+ const taken=new Set();
216
+ const evaluators=drafts.map(draft=>{
217
+ let key=slug(draft.name);
218
+ for(let count=2;taken.has(key);count++)key=`${slug(draft.name)}_${count}`;
219
+ taken.add(key);
220
+ return {key,...draft};
221
+ }).sort((a,b)=>a.key<b.key?-1:a.key>b.key?1:0);
222
+ return {errors,evaluators};
223
+ }
224
+
225
+ function target(task){
226
+ if(task.command)return {key:'local_task',name:'Local task (run by cvm)',kind:'external',input_mapping:{},config:{}};
227
+ if(task.recorded)return {key:'recorded',name:'Recorded output',kind:'recorded',input_mapping:{},config:{output_column:task.recorded}};
228
+ return {key:'task',name:`${task.kind} task`,kind:task.kind,input_mapping:task.input_mapping??{},config:task.config};
229
+ }
230
+
231
+ // One input column is the task's input; several are an object by column key.
232
+ function taskInput(columns,values){
233
+ const keys=columns.filter(column=>column.role==='input').map(column=>column.key);
234
+ return keys.length===1?values[keys[0]]:Object.fromEntries(keys.map(key=>[key,values[key]]));
235
+ }
236
+
237
+ // pick narrows the cases for --first or --sample. A sample is a seeded
238
+ // shuffle (mulberry32), so the same seed takes the same cases again.
239
+ export function pick(rows,{first,sample,seed=0}){
240
+ if(first!==undefined)return rows.slice(0,first);
241
+ if(sample===undefined)return rows;
242
+ let state=seed>>>0;
243
+ const random=()=>{state=state+0x6D2B79F5|0;let t=Math.imul(state^state>>>15,1|state);t=t+Math.imul(t^t>>>7,61|t)^t;return ((t^t>>>14)>>>0)/4294967296;};
244
+ const pool=[...rows];
245
+ for(let i=pool.length-1;i>0;i--){const j=Math.floor(random()*(i+1));[pool[i],pool[j]]=[pool[j],pool[i]];}
246
+ return pool.slice(0,sample);
247
+ }
248
+
249
+ // runCommand runs the task once: the input as JSON on stdin, the output from
250
+ // stdout (JSON when it parses, else the text). A non-zero exit, a timeout or
251
+ // an output over the limit is a task error. The command is run directly,
252
+ // never through a shell.
253
+ function runCommand(command,input,timeoutMs){
254
+ return new Promise(resolve=>{
255
+ const started=Date.now(),child=spawn(command[0],command.slice(1),{stdio:['pipe','pipe','pipe']});
256
+ let out='',err='',done=false;
257
+ child.stdout.setEncoding('utf8');child.stderr.setEncoding('utf8');
258
+ const finish=result=>{if(done)return;done=true;clearTimeout(timer);resolve({...result,latency_ms:Date.now()-started});};
259
+ const stop=error=>{child.kill('SIGKILL');finish({error});};
260
+ const timer=setTimeout(()=>stop(`the task took longer than ${timeoutMs/1000} s`),timeoutMs);
261
+ child.stdout.on('data',chunk=>{if((out+=chunk).length>MAX_OUTPUT)stop('the task wrote more than 1 MiB to stdout');});
262
+ child.stderr.on('data',chunk=>{err=(err+chunk).slice(-1500);});
263
+ child.on('error',error=>finish({error:`could not run ${command[0]}: ${error.message}`}));
264
+ child.on('close',(code,signal)=>{
265
+ if(code!==0)return finish({error:`exit ${code??signal}${err.trim()?': '+err.trim():''}`});
266
+ let output=out.replace(/\r?\n$/,'');
267
+ try{output=JSON.parse(out);}catch{}
268
+ finish({output});
269
+ });
270
+ child.stdin.on('error',()=>{}); // a task that exits without reading its input
271
+ child.stdin.end(JSON.stringify(input)??'null');
272
+ });
273
+ }
274
+
275
+ // runTask runs the command over every case and trial, a bounded number at a
276
+ // time. Only a task error is tried again; the attempts are kept.
277
+ async function runTask(task,units,runOne,onDone){
278
+ const {command,concurrency=4,retries=2,timeout_seconds=120}=task,results=new Array(units.length);
279
+ let next=0,finished=0;
280
+ await Promise.all(Array.from({length:Math.min(concurrency,units.length)},async()=>{
281
+ for(let i;(i=next++)<units.length;){
282
+ let result,attempts=0;
283
+ do{result=await runOne(command,units[i].input,timeout_seconds*1000);attempts++;}while(result.error!==undefined&&attempts<=retries);
284
+ const {row_key,trial}=units[i],{error,output,latency_ms}=result;
285
+ results[i]={row_key,trial,attempts,...(latency_ms===undefined?{}:{latency_ms}),...(error===undefined?{output}:{error:String(error).slice(0,2000)})};
286
+ onDone(++finished,units.length);
287
+ }
288
+ }));
289
+ return results;
290
+ }
291
+
292
+ // tally reads marks (one per case, trial and criterion) into the report's
293
+ // rows: how many trials passed, failed or errored, and why those that did.
294
+ function tally(caseIds,criteria,marks){
295
+ const zero=()=>Object.fromEntries(criteria.map(criterion=>[criterion.key,{passed:0,failed:0,errors:0}]));
296
+ const cases=new Map(caseIds.map(id=>[id,zero()])),totals=zero(),rationales=[];
297
+ for(const mark of marks){
298
+ if(!totals[mark.key])continue;
299
+ if(!cases.has(mark.case))cases.set(mark.case,zero());
300
+ cases.get(mark.case)[mark.key][mark.result]++;totals[mark.key][mark.result]++;
301
+ if(mark.result!=='passed')rationales.push({case:mark.case,criterion:criteria.find(criterion=>criterion.key===mark.key).name,trial:mark.trial,result:mark.result==='errors'?'error':'fail',reason:mark.reason||null,
302
+ // The judge's own words and how it produced them; reason is Caveman's sentence about the verdict.
303
+ ...(mark.rationale?{rationale:mark.rationale,rationale_kind:mark.rationale_kind??null}:{})});
304
+ }
305
+ return {cases:[...cases].map(([id,counts])=>({id,criteria:counts})),rationales,
306
+ criteria:criteria.map(({key,name})=>{const count=totals[key],graded=count.passed+count.failed;return {key,name,...count,pass_rate:graded?count.passed/graded:null};})};
307
+ }
308
+
309
+ // A cell whose task did not finish is an error on every criterion.
310
+ function cellMarks(cells,criteria){
311
+ const marks=[];
312
+ for(const cell of cells){
313
+ const at={case:cell.dataset_row_key,trial:cell.trial??1};
314
+ if(cell.status!=='completed'){for(const {key} of criteria)marks.push({...at,key,result:'errors',reason:cell.error_message??`the task did not finish (${cell.status})`});continue;}
315
+ for(const graded of Array.isArray(cell.evaluator_results)?cell.evaluator_results:[])
316
+ marks.push({...at,key:graded.key,result:graded.status!=='completed'||typeof graded.passed!=='boolean'?'errors':graded.passed?'passed':'failed',reason:graded.reason??graded.error_code,rationale:graded.rationale,rationale_kind:graded.rationale_kind});
317
+ }
318
+ return marks;
319
+ }
320
+
321
+ // exitFor is the exit code of a file's status: 0 passed or nothing to gate,
322
+ // 5 the gate failed, 6 no verdict (more data needed, not comparable, outputs
323
+ // that could not all be graded), 2 an invalid file, 1 anything that crashed.
324
+ export const exitFor=status=>({passed:0,not_gated:0,no_baseline:0,partial:0,dry_run:0,failed:5,need_more_data:6,incomplete:6,not_comparable:6,invalid:2})[status]??1;
325
+
326
+ // The eval file's word for a gate policy, and the API's.
327
+ const POLICY_ON_WIRE={statistical:'kernel',strict:'strict'};
328
+ const STATUS_WORDS={valid:'Valid',passed:'Passed',failed:'Failed',need_more_data:'Need more data',incomplete:'No verdict',not_comparable:'Not compared',no_baseline:'No baseline yet',not_gated:'Not gated',partial:'Partial run',dry_run:'Dry run',invalid:'Invalid',error:'Error'};
329
+ const BASELINE_WORDS={experiment:'saved for this experiment',project:'the project default',query:'named in the file'};
330
+ const RATIONALE_WORDS={reasoned_before:'reasoned first',explained_after:'explained afterwards'};
331
+ const judgeLine=why=>`Judge${RATIONALE_WORDS[why.rationale_kind]?` (${RATIONALE_WORDS[why.rationale_kind]})`:''}: ${why.rationale}`;
332
+
333
+ // commitScope is a CI run (detectRun) as a run's scope carries it: the commit,
334
+ // ref, base and pull request, each only in the shape the server takes.
335
+ // Undefined when none is.
336
+ export function commitScope(run={}){
337
+ const sha=value=>/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/.test(value??'')&&!/^0+$/.test(value),ref=value=>typeof value==='string'&&/^\S{1,255}$/.test(value)&&value!=='refs/heads/HEAD';
338
+ const commit={...(sha(run.sha)?{sha:run.sha}:{}),...(ref(run.ref)?{ref:run.ref}:{}),...(sha(run.base_sha)?{base_sha:run.base_sha}:{}),...(ref(run.base_ref)?{base_ref:run.base_ref}:{}),
339
+ ...(Number.isInteger(run.pr_number)&&run.pr_number>0?{pr_number:run.pr_number}:{})};
340
+ return Object.keys(commit).length?commit:undefined;
341
+ }
342
+
343
+ // runEvalFile takes one valid file from nothing to a verdict. flags:
344
+ // {dryRun, trials, first, sample, seed, setBaseline, timeoutMs, projectKey, commit}.
345
+ // projectKey: the sign-in is a project API key, whose runs need a budget and
346
+ // whose reads carry no reasons. commit: commitScope of a run in CI. cloud()
347
+ // gives the signed-in client and is
348
+ // only called when the server is needed.
349
+ export async function runEvalFile(file,doc,flags,deps){
350
+ const report={schema_version:1,ok:false,file,name:doc.name,status:'error',exit_code:1,sentence:'',partial:flags.first!==undefined||flags.sample!==undefined,trials:flags.trials??doc.trials??1,...(flags.sample!==undefined?{seed:flags.seed}:{})};
351
+ try{return await runInto(report,doc,flags,deps);}
352
+ catch(error){
353
+ // What was reached stays on the report: a run that outlives the wait keeps its id.
354
+ const e=error instanceof CloudError?error:new CloudError('cave_eval_crashed',error.message??'The eval crashed.',1);
355
+ return Object.assign(report,{ok:false,status:'error',exit_code:e.exitCode,sentence:e.message,error:{code:e.code,message:e.message}});
356
+ }
357
+ }
358
+ async function runInto(report,doc,flags,{cloud,runOne=runCommand,progress=()=>{},consoleURL}){
359
+ const {trials,partial}=report;
360
+ const end=(status,sentence,more={})=>Object.assign(report,more,{status,sentence,exit_code:exitFor(status),ok:exitFor(status)===0});
361
+ if(partial&&!doc.task.command)return end('invalid','',{errors:[{path:'$.task',message:'--first and --sample need a command task: Caveman runs every case of a recorded, model, prompt or http task'}]});
362
+ // The server refuses such a run; said here, before the task runs or anything is written.
363
+ if(flags.projectKey&&!flags.dryRun&&doc.budget_usd===undefined)return end('invalid','',{errors:[{path:'$.budget_usd',message:'is required when a project key runs the file: every run a key starts is given a budget, held against the key\'s limit for the day'}]});
364
+
365
+ const loaded=await loadCases(doc,cloud);
366
+ if(loaded.errors.length)return end('invalid','',{errors:loaded.errors});
367
+ const rows=pick(loaded.rows,flags);
368
+ const units=rows.flatMap(row=>Array.from({length:trials},(_,i)=>({row_key:row.row_key,trial:i+1,input:taskInput(loaded.columns,row.values)})));
369
+ const caseIds=rows.map(row=>row.row_key);
370
+ const runLocally=()=>runTask(doc.task,units,runOne,(done,total)=>progress(`${doc.name}: task ${done}/${total}`));
371
+
372
+ if(flags.dryRun){
373
+ const outputs=doc.task.command?await runLocally():[],ran=[{key:'task',name:'Task ran'}];
374
+ Object.assign(report,tally(caseIds,ran,outputs.map(output=>({case:output.row_key,trial:output.trial,key:'task',result:output.error===undefined?'passed':'errors',reason:output.error}))),{outputs});
375
+ const failed=outputs.filter(output=>output.error!==undefined).length;
376
+ if(failed)return end('incomplete',`Dry run: the task failed on ${failed} of ${outputs.length} runs. Nothing was sent to Caveman.`);
377
+ return end('dry_run',doc.task.command?'Dry run: the task ran here and nothing was sent to Caveman, so nothing was graded.':'Dry run: Caveman runs this task, so nothing ran here and nothing was sent.');
378
+ }
379
+
380
+ const resolved=await resolveCriteria(doc,loaded.columns,cloud);
381
+ if(resolved.errors.length)return end('invalid','',{errors:resolved.errors});
382
+ const client=await cloud(),request=randomUUID(),criteria=resolved.evaluators.map(({key,name})=>({key,name}));
383
+ const description=`Eval file "${doc.name}", run by cvm.`;
384
+ progress(`${doc.name}: saving the dataset and the experiment`);
385
+ const reference=loaded.reference??{key:'cases',dataset_id:(await client.execute('datasets.create',{name:doc.name,description,upsert:true,document:{schema_version:1,columns:loaded.columns,rows:loaded.rows}},{idempotencyKey:`${request}:dataset`})).data.id};
386
+ const workbenchId=(await client.execute('evals.create',{name:doc.name,description,upsert:true,document:{schema_version:1,datasets:[reference],targets:[target(doc.task)],evaluators:resolved.evaluators}},{idempotencyKey:`${request}:experiment`})).data.id;
387
+ // The file's gate policy is stored on the experiment, so the console, the
388
+ // SDKs and this run all read a comparison by the same rule.
389
+ if(doc.gate?.policy)await client.execute('evals.set_baseline',{workbench_id:workbenchId,gate_policy:POLICY_ON_WIRE[doc.gate.policy]});
390
+ Object.assign(report,{dataset_id:reference.dataset_id,experiment_id:workbenchId,url:consoleURL?`${consoleURL.replace(/\/+$/,'')}/experiments/workbenches/${workbenchId}`:null});
391
+
392
+ const outputs=doc.task.command?await runLocally():undefined;
393
+ // The run says it is partial and which commit it is for: the server refuses a partial run as a baseline and in a comparison.
394
+ const scope={kind:'all',...(trials>1?{trials}:{}),...(doc.budget_usd!==undefined?{budget_usd:doc.budget_usd}:{}),...(partial?{mode:'partial'}:{}),...(flags.commit?{commit:flags.commit}:{})};
395
+ progress(`${doc.name}: starting the run`);
396
+ const runId=(await client.execute('evals.run',{workbench_id:workbenchId,scope,...(outputs?{outputs}:{})},{idempotencyKey:`${request}:run`})).data.run.id;
397
+ report.run_id=runId;
398
+ const {run,cells,quality}=(await client.waitEvaluation(workbenchId,runId,{deadline:Date.now()+flags.timeoutMs,onProgress:p=>progress(`${doc.name}: grading ${p.completed}/${p.total}`)})).data;
399
+ Object.assign(report,tally(caseIds,criteria,cellMarks(cells??[],criteria)),{cost_usd:run.summary?.cost_available?run.summary.cost_usd:null,
400
+ // What Caveman's own judging cost, known even when the task's cost is not (the caller ran it).
401
+ judge_cost_usd:run.summary?.judge_cost_available?run.summary.judge_cost_usd:null,
402
+ // What the caller said its own task cost: its word, never added to the cost Caveman measured.
403
+ reported_cost_usd:run.summary?.reported_cost_cells>0?run.summary.reported_cost_usd:null,gate:null});
404
+ // A project key's read of a run carries no judge reason and no error text.
405
+ if(flags.projectKey&&report.rationales.length)report.reasons_withheld=true;
406
+
407
+ if(run.status!=='completed')return end('error',run.error_message??`The run ended ${run.status}.`);
408
+ // A task error or a criterion that could not grade leaves no verdict to read.
409
+ if(quality?.status==='incomplete')return end('incomplete',`Not every output was graded (${quality.reason||'run incomplete'}), so there is no verdict.`);
410
+ if(partial)return end('partial','Partial run (--first or --sample): it is not compared and cannot become a baseline.');
411
+ if(!doc.gate)return end('not_gated','This eval file sets no gate, so the run is not compared.');
412
+
413
+ // No policy is sent: the experiment's stored one decides, and the answer says which it was.
414
+ const baseline=doc.gate.baseline??'saved';
415
+ let status,sentence;
416
+ try{
417
+ const compared=(await client.execute('evals.compare',{workbench_id:workbenchId,candidate_run_id:runId,...(baseline==='saved'?{}:{baseline_run_id:baseline})})).data;
418
+ status=['passed','failed','need_more_data'].includes(compared.quality)?compared.quality:'incomplete';sentence=compared.summary??'';
419
+ report.gate={baseline_run_id:compared.baseline_run_id,baseline_source:compared.baseline_source??null,policy:compared.policy==='strict'?'strict':'statistical',policy_source:compared.policy_source??null,quality:compared.quality,paired_cases:compared.paired_cells,regressions:compared.regressions,improvements:compared.improvements,
420
+ unchanged:compared.unchanged??null,unpaired:compared.unpaired??null,regressed_cases:(compared.changed_cases??[]).filter(row=>row.regression_pts>0).map(row=>row.row_key)};
421
+ }catch(error){
422
+ // Only no baseline and a refusal to compare are answers. A project's
423
+ // default baseline that is another experiment's run is no baseline here.
424
+ if(error.code==='cave_eval_no_baseline'){status='no_baseline';sentence='No baseline is saved for this experiment, so there is nothing to compare with.';}
425
+ else if(error.code==='cave_eval_comparison_incompatible'){
426
+ status='not_comparable';
427
+ sentence=`${error.message} If the cases or criteria changed on purpose, save this run as the new baseline: cvm evals set_baseline --workbench-id ${workbenchId} --run-id ${runId}`;
428
+ }else throw error;
429
+ }
430
+ if(flags.setBaseline&&['passed','no_baseline'].includes(status)){
431
+ await client.execute('evals.set_baseline',{workbench_id:workbenchId,run_id:runId});
432
+ report.baseline_saved=true;sentence+=' This run is now the saved baseline.';
433
+ }else if(status==='no_baseline')sentence+=' Save one with --set-baseline-on-pass.';
434
+ return end(status,sentence);
435
+ }
436
+
437
+ // renderReport is a file's result for a terminal: a row per case, a column
438
+ // per criterion, why the failing ones failed, then pass rates, the server's
439
+ // sentence, the baseline it was read against, cost and where to open it.
440
+ // Meaning is in words, never colour; ascii also rewrites the server's text.
441
+ export function renderReport(report,{ascii=false}={}){
442
+ const plain=value=>ascii?String(value).replaceAll('−','-').replaceAll('…','...').replace(/[^\x20-\x7e\n]/g,'?'):String(value);
443
+ const lines=[`${report.name??report.file}: ${STATUS_WORDS[report.status]??report.status}`];
444
+ for(const error of report.errors??[])lines.push(` ${report.file}: ${error.path}: ${error.message}`);
445
+ if(report.cases?.length){
446
+ const trials=report.trials,short=text=>text.length>24?text.slice(0,23)+'~':text;
447
+ const cell=count=>{const total=count.passed+count.failed+count.errors,word=!total?'-':count.errors?'error':count.failed?'fail':'pass';return total>1?`${word} ${count.passed}/${total}`:word;};
448
+ const table=[['case',...report.criteria.map(criterion=>short(criterion.name))],...report.cases.map(item=>[item.id,...report.criteria.map(criterion=>cell(item.criteria[criterion.key]))])];
449
+ const widths=table[0].map((_,column)=>Math.max(...table.map(row=>row[column].length)));
450
+ lines.push('',`${report.cases.length} cases${trials>1?`, ${trials} trials each`:''}${report.partial?' (partial)':''}`);
451
+ for(const [i,row] of table.entries()){
452
+ lines.push(row.map((text,column)=>text.padEnd(widths[column])).join(' ').trimEnd());
453
+ if(i===0)lines.push(widths.map(width=>'-'.repeat(width)).join(' '));
454
+ }
455
+ if(report.rationales.length){
456
+ lines.push('','Failing:');
457
+ for(const why of report.rationales.slice(0,20))lines.push(` ${why.case} / ${why.criterion}${trials>1?` (trial ${why.trial})`:''}: ${why.result}${why.reason?' - '+why.reason:''}`,...(why.rationale?[' '+judgeLine(why)]:[]));
458
+ if(report.rationales.length>20)lines.push(` and ${report.rationales.length-20} more`);
459
+ if(report.reasons_withheld)lines.push(' Reasons are not in a project key\'s read of a run: open the run in the console to see them.');
460
+ }
461
+ lines.push('','Pass rate: '+report.criteria.map(criterion=>`${criterion.name} ${criterion.pass_rate===null?'not graded':`${Math.round(criterion.pass_rate*100)}% (${criterion.passed}/${criterion.passed+criterion.failed})`}${criterion.errors?`, ${criterion.errors} errors`:''}`).join('; '));
462
+ }
463
+ // Only a comparison's sentence is a verdict.
464
+ if(report.sentence)lines.push(`${report.status==='error'?'Error: ':report.gate?'Verdict: ':''}${report.sentence}`);
465
+ if(report.gate)lines.push(`Baseline: run ${report.gate.baseline_run_id} (${BASELINE_WORDS[report.gate.baseline_source]??'baseline'}), ${report.gate.policy} policy${report.gate.policy_source==='experiment'?' (stored on the experiment)':''}`);
466
+ // The whole measured cost when Caveman has it; else its own judging alone,
467
+ // which it always measured, apart from what the caller says its task cost.
468
+ if(report.run_id)lines.push(report.cost_usd!=null?`Cost: $${report.cost_usd.toFixed(4)}`
469
+ :report.judge_cost_usd!=null?`Judging cost (measured by Caveman): $${report.judge_cost_usd.toFixed(4)}${report.reported_cost_usd==null?'; the task\'s own cost is not available':''}`:'Cost: not available',
470
+ ...(report.reported_cost_usd==null?[]:[`Task cost reported by the caller (not measured by Caveman): $${report.reported_cost_usd.toFixed(4)}`]),report.url??`Experiment ${report.experiment_id}, run ${report.run_id}`);
471
+ return plain(lines.join('\n'))+'\n';
472
+ }
473
+
474
+ // evalJUnit is the reports as JUnit: a suite per file, a test case per case
475
+ // and criterion, and one for the gate (failed only when the gate failed).
476
+ export function evalJUnit(reports){
477
+ const suites=reports.map(report=>{
478
+ const why=new Map((report.rationales??[]).map(item=>[`${item.case}\0${item.criterion}`,[item.reason,item.rationale&&judgeLine(item)].filter(Boolean).join('\n')]).reverse());
479
+ const tests=(report.cases??[]).flatMap(item=>(report.criteria??[]).map(criterion=>{
480
+ const count=item.criteria[criterion.key],total=count.passed+count.failed+count.errors,reason=xml(why.get(`${item.id}\0${criterion.name}`)??'');
481
+ const body=!total?'<skipped/>':count.errors?`<error message="${count.errors} of ${total} could not be graded">${reason}</error>`:count.failed?`<failure message="${count.passed} of ${total} passed">${reason}</failure>`:'';
482
+ return {body,xml:` <testcase classname="${xml(report.name??report.file)}" name="${xml(item.id+' / '+criterion.name)}">${body}</testcase>`};
483
+ }));
484
+ const sentence=xml(report.sentence||(report.errors??[]).map(error=>`${error.path}: ${error.message}`).join('; ')||report.error?.message||report.status);
485
+ const gate=report.exit_code===5?`<failure message="${sentence}"/>`:report.status==='passed'?'':[1,2,3,4,7].includes(report.exit_code)?`<error message="${sentence}"/>`:`<skipped message="${sentence}"/>`;
486
+ tests.push({body:gate,xml:` <testcase classname="${xml(report.name??report.file)}" name="Gate">${gate}</testcase>`});
487
+ const count=tag=>tests.filter(test=>test.body.startsWith('<'+tag)).length;
488
+ return ` <testsuite name="${xml('Caveman / '+(report.name??report.file))}" tests="${tests.length}" failures="${count('failure')}" errors="${count('error')}" skipped="${count('skipped')}">\n${tests.map(test=>test.xml).join('\n')}\n </testsuite>`;
489
+ });
490
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<testsuites>\n${suites.join('\n')}\n</testsuites>\n`;
491
+ }
492
+
493
+ const text={type:'string'},flag={type:'boolean'};
494
+ const EXIT_CODES='0 passed or nothing to gate; 5 the gate failed; 6 no verdict (need more data, not comparable, or outputs that could not all be graded); 2 an invalid file or usage; 1 the run crashed; 3 signed out or forbidden; 4 the server refused the run (a project key\'s daily budget is held, or a conflict)';
495
+ const usage={
496
+ init:'cvm evals init [PATH] writes a starter eval file (default example.eval.json) and prints the next three commands',
497
+ validate:'cvm evals validate FILE... [--offline] [--project UUID] [--format json|table] (an experiment manifest: cvm evals validate --file MANIFEST)',
498
+ run:'cvm evals run FILE... [--dry-run] [--trials N] [--first N] [--sample N --seed S] [--junit-out PATH|-] [--set-baseline-on-pass] [--timeout SECONDS] [--project UUID] [--format json|table] (the operation: cvm evals run EXPERIMENT_ID)',
499
+ ci:'cvm evals ci FILE... [--junit-out PATH|-] [--set-baseline-on-pass] [--advisory] [--trials N] [--timeout SECONDS] [--sha SHA] [--ref REF] [--base-sha SHA] [--base-ref REF] [--pr N] [--job NAME] [--project UUID] [--format json|table]',
500
+ };
501
+ function whole(raw,name,min,max){
502
+ if(raw===undefined)return undefined;
503
+ const value=Number(raw);
504
+ if(!Number.isInteger(value)||value<min||value>max)throw new CloudError('cave_usage',`--${name} must be a whole number from ${min} to ${max}.`,2);
505
+ return value;
506
+ }
507
+ function outputFormat(raw){
508
+ const format=raw??(process.stdout.isTTY?'table':'json');
509
+ if(!['json','table'].includes(format))throw new CloudError('cave_usage','--format must be json or table.',2);
510
+ return format;
511
+ }
512
+ function evalFiles(positionals,name){
513
+ const files=positionals.flatMap(path=>/[*?[]/.test(path)?globSync(path).sort():[path]);
514
+ if(!files.length)throw new CloudError('cave_usage',usage[name],2);
515
+ return files;
516
+ }
517
+ // One JSON object per file, or the readable form on a terminal.
518
+ const print=(reports,format)=>{for(const report of reports)process.stdout.write(format==='json'?inertJSON(report)+'\n':renderReport(report,{ascii:!/utf-?8/i.test(process.env.LC_ALL||process.env.LC_CTYPE||process.env.LANG||'')})+'\n');};
519
+ const invalid=({file,doc,errors})=>({schema_version:1,ok:false,file,...(typeof doc?.name==='string'?{name:doc.name}:{}),status:'invalid',exit_code:2,sentence:'',errors});
520
+ function failure(error){
521
+ const e=error instanceof CloudError?error:new CloudError('cave_usage',error.message??'Command failed.',2);
522
+ // The shape of a file's result: failed is a gate's word, never a usage error's.
523
+ process.stdout.write(inertJSON({schema_version:1,ok:false,status:e.exitCode===2?'invalid':'error',exit_code:e.exitCode,sentence:e.message,error:{code:e.code,message:e.message}})+'\n');return e.exitCode;
524
+ }
525
+
526
+ // evalsValidate checks files against the schema and, unless --offline, their
527
+ // criterion and dataset names against the project. Exit 2 when any is invalid.
528
+ export async function evalsValidate(argv,{context}){
529
+ try{
530
+ const {values,positionals}=parseArgs({args:argv,strict:true,allowPositionals:true,options:{offline:flag,project:text,format:text,help:flag}});
531
+ if(values.help){process.stdout.write(inertJSON({usage:usage.validate,exit_codes:'0 every file is valid; 2 a file is invalid (each error names its JSON path); 3 signed out (--offline checks the schema alone)'},2)+'\n');return 0;}
532
+ const format=outputFormat(values.format);
533
+ let client;const cloud=async()=>client??=await connect(context,values.project);
534
+ const reports=[];
535
+ for(const read of evalFiles(positionals,'validate').map(readEvalFile)){
536
+ if(!read.errors.length&&!values.offline){
537
+ const loaded=await loadCases(read.doc,cloud);
538
+ read.errors=loaded.errors.length?loaded.errors:(await resolveCriteria(read.doc,loaded.columns,cloud)).errors;
539
+ }
540
+ reports.push(read.errors.length?invalid(read):{schema_version:1,ok:true,file:read.file,name:read.doc.name,status:'valid',exit_code:0,sentence:values.offline?'Matches the schema and its fixed checks take their options; names were not checked (--offline).':'Matches the schema, its fixed checks take their options, and its dataset and criteria names resolve.',checked:values.offline?'schema':'schema_and_names'});
541
+ }
542
+ print(reports,format);
543
+ return reports.some(report=>!report.ok)?2:0;
544
+ }catch(error){return failure(error);}
545
+ }
546
+
547
+ // evalsRun runs each file and exits by the worst of them. Nothing runs when
548
+ // any file is invalid. With ci it is `cvm evals ci FILE...`: the same path,
549
+ // with the commit, branch and pull request on each result.
550
+ export async function evalsRun(argv,{context},{ci=false}={}){
551
+ const name=ci?'ci':'run';
552
+ try{
553
+ const {values,positionals}=parseArgs({args:argv,strict:true,allowPositionals:true,options:{'dry-run':flag,trials:text,first:text,sample:text,seed:text,'junit-out':text,'set-baseline-on-pass':flag,timeout:text,project:text,format:text,help:flag,
554
+ ...(ci?{advisory:flag,sha:text,ref:text,'base-sha':text,'base-ref':text,pr:text,event:text,job:text}:{})}});
555
+ if(values.help){process.stdout.write(inertJSON({usage:usage[name],exit_codes:EXIT_CODES},2)+'\n');return 0;}
556
+ const format=outputFormat(values.format);
557
+ const flags={dryRun:!!values['dry-run'],trials:whole(values.trials,'trials',1,10),first:whole(values.first,'first',1,10000),sample:whole(values.sample,'sample',1,10000),setBaseline:!!values['set-baseline-on-pass'],timeoutMs:seconds(values.timeout,1800),projectKey:/^cave_live_/.test(process.env.CAVE_TOKEN??'')};
558
+ if(flags.first!==undefined&&flags.sample!==undefined)throw new CloudError('cave_usage','Use --first or --sample, not both.',2);
559
+ if(values.seed!==undefined&&flags.sample===undefined)throw new CloudError('cave_usage','--seed needs --sample.',2);
560
+ // Without a seed one is drawn and reported, so the sample can be taken again.
561
+ if(flags.sample!==undefined)flags.seed=whole(values.seed,'seed',0,0xffffffff)??Math.floor(Math.random()*0x100000000);
562
+ if(flags.setBaseline&&(flags.first!==undefined||flags.sample!==undefined||flags.dryRun))throw new CloudError('cave_usage','--set-baseline-on-pass needs a full run: a --first, --sample or --dry-run run cannot become a baseline.',2);
563
+ const read=evalFiles(positionals,name).map(readEvalFile);
564
+ let reports;
565
+ if(read.some(file=>file.errors.length))reports=read.filter(file=>file.errors.length).map(invalid);
566
+ else{
567
+ let client;const cloud=async()=>client??=await connect(context,values.project);
568
+ const live=process.stderr.isTTY,progress=live?line=>process.stderr.write(`\r\x1b[K${line}`):()=>{};
569
+ const run=ci?detectRun(process.env,values):undefined;
570
+ const commit=run&&Object.fromEntries(['sha','ref','base_sha','base_ref','pr_number','event','job'].filter(key=>run[key]!==undefined&&run[key]!=='').map(key=>[key,run[key]]));
571
+ if(run)flags.commit=commitScope(run);
572
+ reports=[];
573
+ for(const {file,doc} of read){
574
+ const report=await runEvalFile(file,doc,flags,{cloud,progress,consoleURL:process.env.CAVE_CONSOLE_URL});
575
+ reports.push(commit?{...report,ci:commit}:report);
576
+ }
577
+ if(live)process.stderr.write('\r\x1b[K');
578
+ }
579
+ const junit=values['junit-out'];
580
+ if(junit&&junit!=='-')writeFileSync(junit,evalJUnit(reports));
581
+ if(junit==='-')process.stdout.write(evalJUnit(reports));else print(reports,format);
582
+ // --advisory spares the job a verdict, never an invalid file or a crash.
583
+ const codes=reports.map(report=>values.advisory&&[5,6].includes(report.exit_code)?0:report.exit_code);
584
+ return [2,3,4,7,1,5,6].find(code=>codes.includes(code))??0;
585
+ }catch(error){return failure(error);}
586
+ }
587
+
588
+ const starter={
589
+ $schema:'https://caveman.so/schemas/eval-file.schema.json',
590
+ name:'example',
591
+ dataset:{cases:[
592
+ {id:'refund',input:{question:'How do I get a refund?'},tags:['billing']},
593
+ {id:'password',input:{question:'I forgot my password.'},tags:['account']},
594
+ {id:'shipping',input:{question:'Where is my order?'},tags:['orders']},
595
+ ]},
596
+ task:{command:['node','-e','let s="";process.stdin.on("data",d=>s+=d).on("end",()=>console.log("You asked: "+JSON.parse(s).question))']},
597
+ criteria:[{type:'contains',name:'Repeats the question',fragments:['You asked:']},{type:'not_contains',name:'Does not apologise',fragments:['sorry']}],
598
+ // strict decides on three cases; the default, statistical, reads a pass on about 30 and more.
599
+ gate:{policy:'strict'},
600
+ // A run a project key starts (CI) must name one; these fixed checks spend none of it.
601
+ budget_usd:0.25,
602
+ };
603
+
604
+ // evalsInit writes the starter file: three cases, a task that runs with no
605
+ // setup, two criteria that need no judge, a gate that can decide on three
606
+ // cases, and a budget.
607
+ export async function evalsInit(argv){
608
+ try{
609
+ const {values,positionals}=parseArgs({args:argv,strict:true,allowPositionals:true,options:{format:text,help:flag}});
610
+ if(values.help){process.stdout.write(inertJSON({usage:usage.init},2)+'\n');return 0;}
611
+ const format=outputFormat(values.format);
612
+ if(positionals.length>1)throw new CloudError('cave_usage',usage.init,2);
613
+ const file=positionals[0]??'example.eval.json';
614
+ try{writeFileSync(file,JSON.stringify(starter,null,2)+'\n',{flag:'wx'});}
615
+ catch(error){throw error.code==='EEXIST'?new CloudError('cave_file_exists',`${file} already exists; name another path.`,4):new CloudError('cave_usage',`Could not write ${file}: ${error.code??error.message}.`,2);}
616
+ const next=[`cvm evals validate ${file}`,`cvm evals run ${file} --set-baseline-on-pass`,`cvm evals ci ${file}`];
617
+ process.stdout.write(format==='json'?inertJSON({schema_version:1,ok:true,file,next})+'\n'
618
+ :`Wrote ${file}. Replace its task command with your own, then:\n ${next[0]} check the file\n ${next[1]} run it and save the baseline\n ${next[2]} gate a change against that baseline\nIts gate is strict: it fails when a criterion got worse on any case. The statistical gate ("policy": "statistical") reads a pass on about 30 cases and more, and need more data under that.\n`);
619
+ return 0;
620
+ }catch(error){return failure(error);}
621
+ }