knosky 0.6.3 → 0.7.0
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.
- package/CHANGELOG.md +149 -93
- package/CREDITS.md +14 -14
- package/LICENSE.md +76 -76
- package/LIMITATIONS.md +33 -23
- package/PRIVACY.md +30 -30
- package/README.md +170 -117
- package/SECURITY.md +78 -46
- package/action/post-comment.mjs +94 -89
- package/action.yml +62 -62
- package/bin/knosky.mjs +279 -105
- package/core/CONTRACT.md +70 -70
- package/core/append-only-checkpoint.mjs +215 -0
- package/core/audit-writer.mjs +317 -0
- package/core/benchmark-results.mjs +225 -225
- package/core/bundle.mjs +178 -178
- package/core/churn.mjs +23 -23
- package/core/ci.mjs +268 -268
- package/core/comparison.mjs +189 -189
- package/core/config.mjs +189 -189
- package/core/constants.mjs +13 -13
- package/core/contract.mjs +123 -123
- package/core/cross-repo.mjs +111 -111
- package/core/decision-codes.mjs +92 -0
- package/core/destination.mjs +161 -161
- package/core/district-classification.mjs +111 -0
- package/core/doctor-scorecard.mjs +369 -0
- package/core/domain-store.mjs +347 -0
- package/core/edges.mjs +43 -43
- package/core/escalate.mjs +68 -68
- package/core/freshness.mjs +198 -194
- package/core/fs-indexer.mjs +218 -218
- package/core/key-store.mjs +348 -348
- package/core/layout.mjs +46 -46
- package/core/ledger.mjs +176 -141
- package/core/local-ipc-identity.mjs +500 -0
- package/core/lod.mjs +155 -155
- package/core/mode-b.mjs +410 -0
- package/core/multi-model-benchmark.mjs +405 -405
- package/core/net-lockdown.mjs +421 -0
- package/core/onboarding.mjs +223 -223
- package/core/operator-auth.mjs +317 -0
- package/core/overlays.mjs +45 -45
- package/core/policy-lattice.mjs +142 -0
- package/core/pr-comment.mjs +198 -198
- package/core/protocol-spec.mjs +460 -460
- package/core/provenance.mjs +320 -0
- package/core/retrieve.mjs +63 -63
- package/core/route.mjs +304 -304
- package/core/schema.mjs +275 -275
- package/core/signing-tiers.mjs +1265 -0
- package/core/swarm-bench.mjs +106 -0
- package/core/swarm-coordinator.mjs +867 -0
- package/core/trust-root-rekey.mjs +410 -0
- package/mcp/server.mjs +264 -108
- package/package.json +56 -46
- package/renderer/art/kenney/buildingTiles_sheet.xml +130 -130
- package/renderer/art/kenney/cityDetails_sheet.xml +12 -12
- package/renderer/art/kenney/landscapeTiles_sheet.xml +129 -129
- package/renderer/art/kenney/sheet_allCars.xml +545 -545
- package/renderer/build-rich.mjs +43 -43
- package/renderer/city.template.html +808 -808
- package/ssot/decision-codes.json +133 -0
- package/ssot/ladder-l0-l3.md +232 -0
- package/ssot/tool-menu.json +130 -0
|
@@ -1,405 +1,405 @@
|
|
|
1
|
-
// KnoSky simultaneous multi-model benchmark harness (SAT-459).
|
|
2
|
-
//
|
|
3
|
-
// Defines the artifact schema and validators for a multi-model benchmark run:
|
|
4
|
-
// the same benchmark tasks from the comparison-run protocol (core/comparison.mjs)
|
|
5
|
-
// executed simultaneously across all supported model families in parallel — not
|
|
6
|
-
// sequentially — and collected into a single artifact for analysis.
|
|
7
|
-
//
|
|
8
|
-
// Paul's explicit instruction: "run simultaneous tests" -- every model family
|
|
9
|
-
// (Claude, GPT, Gemini, DeepSeek, Grok) receives the same tasks at the same
|
|
10
|
-
// time via Promise.all. This module owns the protocol shape only; it makes no
|
|
11
|
-
// network calls itself. A separate, not-yet-built tools/-layer helper (following
|
|
12
|
-
// the CI-only HTTP-dispatch pattern already used elsewhere in this repo's build
|
|
13
|
-
// tooling) is expected to supply the real dispatchFn at call time -- see D-176.
|
|
14
|
-
//
|
|
15
|
-
// Pure Node stdlib, ESM — no new deps.
|
|
16
|
-
|
|
17
|
-
import { PROTOCOL_VERSION } from './schema.mjs';
|
|
18
|
-
// NOTE (D-176, post-PR#50 review): this file does NOT import validateComparisonRun from
|
|
19
|
-
// core/comparison.mjs -- it defines and validates its own multi-model artifact shape and
|
|
20
|
-
// never delegates to the single-model comparison-run validator. An earlier version had an
|
|
21
|
-
// unused import of it (dead code, flagged by review); removed rather than left in.
|
|
22
|
-
|
|
23
|
-
// ---------------------------------------------------------------------------
|
|
24
|
-
// Artifact type constant
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
|
|
27
|
-
/** @type {string} */
|
|
28
|
-
export const MULTI_MODEL_ARTIFACT_TYPE = 'multi-model-benchmark';
|
|
29
|
-
|
|
30
|
-
// ---------------------------------------------------------------------------
|
|
31
|
-
// Supported model families — the canonical list for simultaneous dispatch.
|
|
32
|
-
// Names must be stable identifiers (used as keys in per-model result maps).
|
|
33
|
-
// ---------------------------------------------------------------------------
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* The five model families that the simultaneous harness fans out to.
|
|
37
|
-
* Each entry is a { family, modelId } pair:
|
|
38
|
-
* family — short name used as a key in multi-model result artifacts
|
|
39
|
-
* modelId — the LiteLLM / routing model string for the dispatch layer
|
|
40
|
-
*
|
|
41
|
-
* @type {Array<{ family: string, modelId: string }>}
|
|
42
|
-
*/
|
|
43
|
-
export const MODEL_FAMILIES = [
|
|
44
|
-
{ family: 'claude', modelId: 'sathia-standard' },
|
|
45
|
-
{ family: 'gpt', modelId: 'sathia-gpt' },
|
|
46
|
-
{ family: 'gemini', modelId: 'sathia-gemini' },
|
|
47
|
-
{ family: 'deepseek', modelId: 'sathia-deepseek' },
|
|
48
|
-
{ family: 'grok', modelId: 'sathia-grok' },
|
|
49
|
-
];
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* The set of valid family names — used in validation.
|
|
53
|
-
* @type {Set<string>}
|
|
54
|
-
*/
|
|
55
|
-
const KNOWN_FAMILIES = new Set(MODEL_FAMILIES.map(m => m.family));
|
|
56
|
-
|
|
57
|
-
// ---------------------------------------------------------------------------
|
|
58
|
-
// Internal helpers
|
|
59
|
-
// ---------------------------------------------------------------------------
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Return true when `p` is a relative-safe file path:
|
|
63
|
-
* — non-empty string
|
|
64
|
-
* — does not start with `/` (Unix absolute) or Windows drive letter
|
|
65
|
-
* — contains no `..` path segment
|
|
66
|
-
* @param {string} p
|
|
67
|
-
* @returns {boolean}
|
|
68
|
-
*/
|
|
69
|
-
function isRelativeSafePath(p) {
|
|
70
|
-
if (!p || typeof p !== 'string') return false;
|
|
71
|
-
if (p.startsWith('/')) return false;
|
|
72
|
-
if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
|
|
73
|
-
if (p.split(/[/\\]/).some(s => s === '..')) return false;
|
|
74
|
-
return true;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Validate one per-model result object.
|
|
79
|
-
* Returns an array of violation strings; empty means valid.
|
|
80
|
-
*
|
|
81
|
-
* @param {unknown} result
|
|
82
|
-
* @param {string} label — e.g. "results[claude]"
|
|
83
|
-
* @returns {string[]}
|
|
84
|
-
*/
|
|
85
|
-
function validateModelResult(result, label) {
|
|
86
|
-
const errors = [];
|
|
87
|
-
|
|
88
|
-
if (!result || typeof result !== 'object') {
|
|
89
|
-
errors.push(`${label} must be an object`);
|
|
90
|
-
return errors;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// family: known non-empty string
|
|
94
|
-
if (typeof result.family !== 'string' || !KNOWN_FAMILIES.has(result.family)) {
|
|
95
|
-
errors.push(
|
|
96
|
-
`${label}.family must be one of [${[...KNOWN_FAMILIES].join(', ')}], ` +
|
|
97
|
-
`got: ${JSON.stringify(result.family)}`,
|
|
98
|
-
);
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// model_id: non-empty string
|
|
102
|
-
if (typeof result.model_id !== 'string' || result.model_id.length === 0) {
|
|
103
|
-
errors.push(`${label}.model_id must be a non-empty string, got: ${JSON.stringify(result.model_id)}`);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// status: 'ok' | 'failed'
|
|
107
|
-
if (result.status !== 'ok' && result.status !== 'failed') {
|
|
108
|
-
errors.push(`${label}.status must be "ok" or "failed", got: ${JSON.stringify(result.status)}`);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// error: string or null
|
|
112
|
-
if (result.error !== null && typeof result.error !== 'string') {
|
|
113
|
-
errors.push(`${label}.error must be null or a string, got: ${JSON.stringify(result.error)}`);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// metrics: required when status === 'ok', must pass comparison-run side validation
|
|
117
|
-
if (result.status === 'ok') {
|
|
118
|
-
if (!result.metrics || typeof result.metrics !== 'object') {
|
|
119
|
-
errors.push(`${label}.metrics must be an object when status is "ok"`);
|
|
120
|
-
} else {
|
|
121
|
-
// metrics carries tokens_in / tokens_out / tool_calls / time_to_relevant_file_ms / correct
|
|
122
|
-
const m = result.metrics;
|
|
123
|
-
if (!Number.isInteger(m.tokens_in) || m.tokens_in < 0) {
|
|
124
|
-
errors.push(`${label}.metrics.tokens_in must be a non-negative integer, got: ${JSON.stringify(m.tokens_in)}`);
|
|
125
|
-
}
|
|
126
|
-
if (!Number.isInteger(m.tokens_out) || m.tokens_out < 0) {
|
|
127
|
-
errors.push(`${label}.metrics.tokens_out must be a non-negative integer, got: ${JSON.stringify(m.tokens_out)}`);
|
|
128
|
-
}
|
|
129
|
-
if (!Number.isInteger(m.tool_calls) || m.tool_calls < 0) {
|
|
130
|
-
errors.push(`${label}.metrics.tool_calls must be a non-negative integer, got: ${JSON.stringify(m.tool_calls)}`);
|
|
131
|
-
}
|
|
132
|
-
const ttrf = m.time_to_relevant_file_ms;
|
|
133
|
-
if (ttrf !== null && !(typeof ttrf === 'number' && ttrf >= 0)) {
|
|
134
|
-
errors.push(
|
|
135
|
-
`${label}.metrics.time_to_relevant_file_ms must be null or a non-negative number, ` +
|
|
136
|
-
`got: ${JSON.stringify(ttrf)}`,
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
if (typeof m.correct !== 'boolean') {
|
|
140
|
-
errors.push(`${label}.metrics.correct must be a boolean, got: ${JSON.stringify(m.correct)}`);
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return errors;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
// makeMultiModelRun
|
|
150
|
-
// ---------------------------------------------------------------------------
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Construct a KnoSky `multi-model-benchmark` artifact envelope.
|
|
154
|
-
*
|
|
155
|
-
* The artifact records:
|
|
156
|
-
* - the task that was run (same fields as comparison-run: id, description, target_files)
|
|
157
|
-
* - per-model results collected simultaneously via Promise.all dispatch
|
|
158
|
-
* - a summary: which models succeeded, total latency, fastest model
|
|
159
|
-
*
|
|
160
|
-
* @param {object} opts
|
|
161
|
-
* @param {string} opts.task_id Short identifier for this task.
|
|
162
|
-
* @param {string} opts.task_description Human-readable description of the task.
|
|
163
|
-
* @param {string[]} opts.target_files Relative paths to the "relevant" files.
|
|
164
|
-
* @param {object[]} opts.results Per-model result objects (one per family).
|
|
165
|
-
* @param {object} [opts.dispatch] Dispatch metadata: how models were invoked.
|
|
166
|
-
* @param {string} [opts.dispatch.mode] Always "parallel" — simultaneous Promise.all.
|
|
167
|
-
* @param {number} [opts.dispatch.started_at_ms] Wall-clock ms when the fan-out started.
|
|
168
|
-
* @param {number} [opts.dispatch.settled_at_ms] Wall-clock ms when all settled.
|
|
169
|
-
* @returns {object}
|
|
170
|
-
*/
|
|
171
|
-
export function makeMultiModelRun({
|
|
172
|
-
task_id,
|
|
173
|
-
task_description,
|
|
174
|
-
target_files = [],
|
|
175
|
-
results = [],
|
|
176
|
-
dispatch = null,
|
|
177
|
-
} = {}) {
|
|
178
|
-
// Derive summary from results
|
|
179
|
-
const succeeded = results.filter(r => r && r.status === 'ok').map(r => r.family);
|
|
180
|
-
const failed = results.filter(r => r && r.status === 'failed').map(r => r.family);
|
|
181
|
-
|
|
182
|
-
// Fastest model = min time_to_relevant_file_ms among successful runs that found the file
|
|
183
|
-
let fastest_family = null;
|
|
184
|
-
let minTtrf = Infinity;
|
|
185
|
-
for (const r of results) {
|
|
186
|
-
if (r && r.status === 'ok' && r.metrics) {
|
|
187
|
-
const t = r.metrics.time_to_relevant_file_ms;
|
|
188
|
-
if (typeof t === 'number' && t >= 0 && t < minTtrf) {
|
|
189
|
-
minTtrf = t;
|
|
190
|
-
fastest_family = r.family;
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
return {
|
|
196
|
-
knosky_protocol: PROTOCOL_VERSION,
|
|
197
|
-
artifact_type: MULTI_MODEL_ARTIFACT_TYPE,
|
|
198
|
-
advisory: true,
|
|
199
|
-
generated_at: new Date().toISOString(),
|
|
200
|
-
task_id,
|
|
201
|
-
task_description,
|
|
202
|
-
target_files,
|
|
203
|
-
dispatch: dispatch || { mode: 'parallel' },
|
|
204
|
-
results,
|
|
205
|
-
summary: {
|
|
206
|
-
total: results.length,
|
|
207
|
-
succeeded: succeeded.length,
|
|
208
|
-
failed: failed.length,
|
|
209
|
-
succeeded_families: succeeded,
|
|
210
|
-
failed_families: failed,
|
|
211
|
-
fastest_family,
|
|
212
|
-
},
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// ---------------------------------------------------------------------------
|
|
217
|
-
// validateMultiModelRun
|
|
218
|
-
// ---------------------------------------------------------------------------
|
|
219
|
-
|
|
220
|
-
/**
|
|
221
|
-
* Validate a KnoSky `multi-model-benchmark` document.
|
|
222
|
-
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
223
|
-
*
|
|
224
|
-
* @param {object} doc
|
|
225
|
-
* @returns {{ ok: boolean, errors: string[] }}
|
|
226
|
-
*/
|
|
227
|
-
export function validateMultiModelRun(doc) {
|
|
228
|
-
const errors = [];
|
|
229
|
-
|
|
230
|
-
if (!doc || typeof doc !== 'object') {
|
|
231
|
-
return { ok: false, errors: ['doc must be an object'] };
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
if (doc.knosky_protocol !== PROTOCOL_VERSION) {
|
|
235
|
-
errors.push(
|
|
236
|
-
`knosky_protocol must be "${PROTOCOL_VERSION}", got: ${JSON.stringify(doc.knosky_protocol)}`,
|
|
237
|
-
);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
if (doc.artifact_type !== MULTI_MODEL_ARTIFACT_TYPE) {
|
|
241
|
-
errors.push(
|
|
242
|
-
`artifact_type must be "${MULTI_MODEL_ARTIFACT_TYPE}", ` +
|
|
243
|
-
`got: ${JSON.stringify(doc.artifact_type)}`,
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
if (doc.advisory !== true) {
|
|
248
|
-
errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// task_id: non-empty string
|
|
252
|
-
if (typeof doc.task_id !== 'string' || doc.task_id.length === 0) {
|
|
253
|
-
errors.push(`task_id must be a non-empty string, got: ${JSON.stringify(doc.task_id)}`);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// task_description: non-empty string
|
|
257
|
-
if (typeof doc.task_description !== 'string' || doc.task_description.length === 0) {
|
|
258
|
-
errors.push(
|
|
259
|
-
`task_description must be a non-empty string, got: ${JSON.stringify(doc.task_description)}`,
|
|
260
|
-
);
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// target_files: non-empty array of relative-safe path strings
|
|
264
|
-
if (!Array.isArray(doc.target_files) || doc.target_files.length === 0) {
|
|
265
|
-
errors.push('target_files must be a non-empty array');
|
|
266
|
-
} else {
|
|
267
|
-
for (let i = 0; i < doc.target_files.length; i++) {
|
|
268
|
-
const p = doc.target_files[i];
|
|
269
|
-
if (typeof p !== 'string' || p.length === 0) {
|
|
270
|
-
errors.push(`target_files[${i}] must be a non-empty string`);
|
|
271
|
-
} else if (!isRelativeSafePath(p)) {
|
|
272
|
-
errors.push(
|
|
273
|
-
`target_files[${i}] must be a relative path with no ".." segments: ${JSON.stringify(p)}`,
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// dispatch: object with mode === 'parallel'
|
|
280
|
-
if (!doc.dispatch || typeof doc.dispatch !== 'object') {
|
|
281
|
-
errors.push('dispatch must be an object');
|
|
282
|
-
} else if (doc.dispatch.mode !== 'parallel') {
|
|
283
|
-
errors.push(
|
|
284
|
-
`dispatch.mode must be "parallel" (simultaneous fan-out), ` +
|
|
285
|
-
`got: ${JSON.stringify(doc.dispatch.mode)}`,
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// results: non-empty array
|
|
290
|
-
if (!Array.isArray(doc.results) || doc.results.length === 0) {
|
|
291
|
-
errors.push('results must be a non-empty array');
|
|
292
|
-
} else {
|
|
293
|
-
for (let i = 0; i < doc.results.length; i++) {
|
|
294
|
-
errors.push(...validateModelResult(doc.results[i], `results[${i}]`));
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// summary: structural check
|
|
299
|
-
if (!doc.summary || typeof doc.summary !== 'object') {
|
|
300
|
-
errors.push('summary must be an object');
|
|
301
|
-
} else {
|
|
302
|
-
const s = doc.summary;
|
|
303
|
-
if (!Number.isInteger(s.total) || s.total < 0) {
|
|
304
|
-
errors.push(`summary.total must be a non-negative integer, got: ${JSON.stringify(s.total)}`);
|
|
305
|
-
}
|
|
306
|
-
if (!Number.isInteger(s.succeeded) || s.succeeded < 0) {
|
|
307
|
-
errors.push(`summary.succeeded must be a non-negative integer, got: ${JSON.stringify(s.succeeded)}`);
|
|
308
|
-
}
|
|
309
|
-
if (!Number.isInteger(s.failed) || s.failed < 0) {
|
|
310
|
-
errors.push(`summary.failed must be a non-negative integer, got: ${JSON.stringify(s.failed)}`);
|
|
311
|
-
}
|
|
312
|
-
if (!Array.isArray(s.succeeded_families)) {
|
|
313
|
-
errors.push('summary.succeeded_families must be an array');
|
|
314
|
-
}
|
|
315
|
-
if (!Array.isArray(s.failed_families)) {
|
|
316
|
-
errors.push('summary.failed_families must be an array');
|
|
317
|
-
}
|
|
318
|
-
// fastest_family: null or a known family string
|
|
319
|
-
if (s.fastest_family !== null && typeof s.fastest_family !== 'string') {
|
|
320
|
-
errors.push(
|
|
321
|
-
`summary.fastest_family must be null or a string, got: ${JSON.stringify(s.fastest_family)}`,
|
|
322
|
-
);
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
return { ok: errors.length === 0, errors };
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// ---------------------------------------------------------------------------
|
|
330
|
-
// runMultiModelBenchmark
|
|
331
|
-
// ---------------------------------------------------------------------------
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Execute one benchmark task simultaneously across all model families.
|
|
335
|
-
*
|
|
336
|
-
* This is the harness entry point that implements Paul's "simultaneous tests"
|
|
337
|
-
* requirement: all models are dispatched in a single Promise.all fan-out so
|
|
338
|
-
* no model waits for another to finish.
|
|
339
|
-
*
|
|
340
|
-
* `dispatch` is an async function provided by the caller (typically the tools/
|
|
341
|
-
* layer, which owns the HTTPS + LiteLLM wiring). It receives a { family,
|
|
342
|
-
* modelId, task } descriptor and must resolve to a per-model result object:
|
|
343
|
-
*
|
|
344
|
-
* {
|
|
345
|
-
* family: string, // must match MODEL_FAMILIES[n].family
|
|
346
|
-
* model_id: string,
|
|
347
|
-
* status: 'ok' | 'failed',
|
|
348
|
-
* error: string | null, // non-null on failure
|
|
349
|
-
* metrics: { // present when status === 'ok'
|
|
350
|
-
* tokens_in: number,
|
|
351
|
-
* tokens_out: number,
|
|
352
|
-
* tool_calls: number,
|
|
353
|
-
* time_to_relevant_file_ms: number | null,
|
|
354
|
-
* correct: boolean,
|
|
355
|
-
* } | null,
|
|
356
|
-
* }
|
|
357
|
-
*
|
|
358
|
-
* Failed dispatches (rejected promise, uncaught throw) are caught and stored
|
|
359
|
-
* as `{ status: 'failed', error: <message> }` — one model failing never
|
|
360
|
-
* prevents the others from completing (Promise.allSettled semantics on top
|
|
361
|
-
* of the individual error handling).
|
|
362
|
-
*
|
|
363
|
-
* @param {object} task
|
|
364
|
-
* @param {string} task.task_id
|
|
365
|
-
* @param {string} task.task_description
|
|
366
|
-
* @param {string[]} task.target_files
|
|
367
|
-
* @param {Function} dispatchFn async (descriptor) => modelResult
|
|
368
|
-
* @param {Array<{ family: string, modelId: string }>} [models] Defaults to MODEL_FAMILIES.
|
|
369
|
-
* @returns {Promise<object>} A validated multi-model-benchmark artifact.
|
|
370
|
-
*/
|
|
371
|
-
export async function runMultiModelBenchmark(task, dispatchFn, models = MODEL_FAMILIES) {
|
|
372
|
-
const startedAtMs = Date.now();
|
|
373
|
-
|
|
374
|
-
// Fan out simultaneously — Promise.all so all models start at the same time.
|
|
375
|
-
// Individual dispatch errors are caught per-slot so one failure cannot abort others.
|
|
376
|
-
const results = await Promise.all(
|
|
377
|
-
models.map(async ({ family, modelId }) => {
|
|
378
|
-
try {
|
|
379
|
-
return await dispatchFn({ family, modelId, task });
|
|
380
|
-
} catch (err) {
|
|
381
|
-
return {
|
|
382
|
-
family,
|
|
383
|
-
model_id: modelId,
|
|
384
|
-
status: 'failed',
|
|
385
|
-
error: err && err.message ? err.message : String(err),
|
|
386
|
-
metrics: null,
|
|
387
|
-
};
|
|
388
|
-
}
|
|
389
|
-
}),
|
|
390
|
-
);
|
|
391
|
-
|
|
392
|
-
const settledAtMs = Date.now();
|
|
393
|
-
|
|
394
|
-
return makeMultiModelRun({
|
|
395
|
-
task_id: task.task_id,
|
|
396
|
-
task_description: task.task_description,
|
|
397
|
-
target_files: task.target_files,
|
|
398
|
-
results,
|
|
399
|
-
dispatch: {
|
|
400
|
-
mode: 'parallel',
|
|
401
|
-
started_at_ms: startedAtMs,
|
|
402
|
-
settled_at_ms: settledAtMs,
|
|
403
|
-
},
|
|
404
|
-
});
|
|
405
|
-
}
|
|
1
|
+
// KnoSky simultaneous multi-model benchmark harness (SAT-459).
|
|
2
|
+
//
|
|
3
|
+
// Defines the artifact schema and validators for a multi-model benchmark run:
|
|
4
|
+
// the same benchmark tasks from the comparison-run protocol (core/comparison.mjs)
|
|
5
|
+
// executed simultaneously across all supported model families in parallel — not
|
|
6
|
+
// sequentially — and collected into a single artifact for analysis.
|
|
7
|
+
//
|
|
8
|
+
// Paul's explicit instruction: "run simultaneous tests" -- every model family
|
|
9
|
+
// (Claude, GPT, Gemini, DeepSeek, Grok) receives the same tasks at the same
|
|
10
|
+
// time via Promise.all. This module owns the protocol shape only; it makes no
|
|
11
|
+
// network calls itself. A separate, not-yet-built tools/-layer helper (following
|
|
12
|
+
// the CI-only HTTP-dispatch pattern already used elsewhere in this repo's build
|
|
13
|
+
// tooling) is expected to supply the real dispatchFn at call time -- see D-176.
|
|
14
|
+
//
|
|
15
|
+
// Pure Node stdlib, ESM — no new deps.
|
|
16
|
+
|
|
17
|
+
import { PROTOCOL_VERSION } from './schema.mjs';
|
|
18
|
+
// NOTE (D-176, post-PR#50 review): this file does NOT import validateComparisonRun from
|
|
19
|
+
// core/comparison.mjs -- it defines and validates its own multi-model artifact shape and
|
|
20
|
+
// never delegates to the single-model comparison-run validator. An earlier version had an
|
|
21
|
+
// unused import of it (dead code, flagged by review); removed rather than left in.
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Artifact type constant
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
/** @type {string} */
|
|
28
|
+
export const MULTI_MODEL_ARTIFACT_TYPE = 'multi-model-benchmark';
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Supported model families — the canonical list for simultaneous dispatch.
|
|
32
|
+
// Names must be stable identifiers (used as keys in per-model result maps).
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The five model families that the simultaneous harness fans out to.
|
|
37
|
+
* Each entry is a { family, modelId } pair:
|
|
38
|
+
* family — short name used as a key in multi-model result artifacts
|
|
39
|
+
* modelId — the LiteLLM / routing model string for the dispatch layer
|
|
40
|
+
*
|
|
41
|
+
* @type {Array<{ family: string, modelId: string }>}
|
|
42
|
+
*/
|
|
43
|
+
export const MODEL_FAMILIES = [
|
|
44
|
+
{ family: 'claude', modelId: 'sathia-standard' },
|
|
45
|
+
{ family: 'gpt', modelId: 'sathia-gpt' },
|
|
46
|
+
{ family: 'gemini', modelId: 'sathia-gemini' },
|
|
47
|
+
{ family: 'deepseek', modelId: 'sathia-deepseek' },
|
|
48
|
+
{ family: 'grok', modelId: 'sathia-grok' },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The set of valid family names — used in validation.
|
|
53
|
+
* @type {Set<string>}
|
|
54
|
+
*/
|
|
55
|
+
const KNOWN_FAMILIES = new Set(MODEL_FAMILIES.map(m => m.family));
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Internal helpers
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return true when `p` is a relative-safe file path:
|
|
63
|
+
* — non-empty string
|
|
64
|
+
* — does not start with `/` (Unix absolute) or Windows drive letter
|
|
65
|
+
* — contains no `..` path segment
|
|
66
|
+
* @param {string} p
|
|
67
|
+
* @returns {boolean}
|
|
68
|
+
*/
|
|
69
|
+
function isRelativeSafePath(p) {
|
|
70
|
+
if (!p || typeof p !== 'string') return false;
|
|
71
|
+
if (p.startsWith('/')) return false;
|
|
72
|
+
if (/^[A-Za-z]:[\\\/]/.test(p)) return false;
|
|
73
|
+
if (p.split(/[/\\]/).some(s => s === '..')) return false;
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Validate one per-model result object.
|
|
79
|
+
* Returns an array of violation strings; empty means valid.
|
|
80
|
+
*
|
|
81
|
+
* @param {unknown} result
|
|
82
|
+
* @param {string} label — e.g. "results[claude]"
|
|
83
|
+
* @returns {string[]}
|
|
84
|
+
*/
|
|
85
|
+
function validateModelResult(result, label) {
|
|
86
|
+
const errors = [];
|
|
87
|
+
|
|
88
|
+
if (!result || typeof result !== 'object') {
|
|
89
|
+
errors.push(`${label} must be an object`);
|
|
90
|
+
return errors;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// family: known non-empty string
|
|
94
|
+
if (typeof result.family !== 'string' || !KNOWN_FAMILIES.has(result.family)) {
|
|
95
|
+
errors.push(
|
|
96
|
+
`${label}.family must be one of [${[...KNOWN_FAMILIES].join(', ')}], ` +
|
|
97
|
+
`got: ${JSON.stringify(result.family)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// model_id: non-empty string
|
|
102
|
+
if (typeof result.model_id !== 'string' || result.model_id.length === 0) {
|
|
103
|
+
errors.push(`${label}.model_id must be a non-empty string, got: ${JSON.stringify(result.model_id)}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// status: 'ok' | 'failed'
|
|
107
|
+
if (result.status !== 'ok' && result.status !== 'failed') {
|
|
108
|
+
errors.push(`${label}.status must be "ok" or "failed", got: ${JSON.stringify(result.status)}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// error: string or null
|
|
112
|
+
if (result.error !== null && typeof result.error !== 'string') {
|
|
113
|
+
errors.push(`${label}.error must be null or a string, got: ${JSON.stringify(result.error)}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// metrics: required when status === 'ok', must pass comparison-run side validation
|
|
117
|
+
if (result.status === 'ok') {
|
|
118
|
+
if (!result.metrics || typeof result.metrics !== 'object') {
|
|
119
|
+
errors.push(`${label}.metrics must be an object when status is "ok"`);
|
|
120
|
+
} else {
|
|
121
|
+
// metrics carries tokens_in / tokens_out / tool_calls / time_to_relevant_file_ms / correct
|
|
122
|
+
const m = result.metrics;
|
|
123
|
+
if (!Number.isInteger(m.tokens_in) || m.tokens_in < 0) {
|
|
124
|
+
errors.push(`${label}.metrics.tokens_in must be a non-negative integer, got: ${JSON.stringify(m.tokens_in)}`);
|
|
125
|
+
}
|
|
126
|
+
if (!Number.isInteger(m.tokens_out) || m.tokens_out < 0) {
|
|
127
|
+
errors.push(`${label}.metrics.tokens_out must be a non-negative integer, got: ${JSON.stringify(m.tokens_out)}`);
|
|
128
|
+
}
|
|
129
|
+
if (!Number.isInteger(m.tool_calls) || m.tool_calls < 0) {
|
|
130
|
+
errors.push(`${label}.metrics.tool_calls must be a non-negative integer, got: ${JSON.stringify(m.tool_calls)}`);
|
|
131
|
+
}
|
|
132
|
+
const ttrf = m.time_to_relevant_file_ms;
|
|
133
|
+
if (ttrf !== null && !(typeof ttrf === 'number' && ttrf >= 0)) {
|
|
134
|
+
errors.push(
|
|
135
|
+
`${label}.metrics.time_to_relevant_file_ms must be null or a non-negative number, ` +
|
|
136
|
+
`got: ${JSON.stringify(ttrf)}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (typeof m.correct !== 'boolean') {
|
|
140
|
+
errors.push(`${label}.metrics.correct must be a boolean, got: ${JSON.stringify(m.correct)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return errors;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// makeMultiModelRun
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Construct a KnoSky `multi-model-benchmark` artifact envelope.
|
|
154
|
+
*
|
|
155
|
+
* The artifact records:
|
|
156
|
+
* - the task that was run (same fields as comparison-run: id, description, target_files)
|
|
157
|
+
* - per-model results collected simultaneously via Promise.all dispatch
|
|
158
|
+
* - a summary: which models succeeded, total latency, fastest model
|
|
159
|
+
*
|
|
160
|
+
* @param {object} opts
|
|
161
|
+
* @param {string} opts.task_id Short identifier for this task.
|
|
162
|
+
* @param {string} opts.task_description Human-readable description of the task.
|
|
163
|
+
* @param {string[]} opts.target_files Relative paths to the "relevant" files.
|
|
164
|
+
* @param {object[]} opts.results Per-model result objects (one per family).
|
|
165
|
+
* @param {object} [opts.dispatch] Dispatch metadata: how models were invoked.
|
|
166
|
+
* @param {string} [opts.dispatch.mode] Always "parallel" — simultaneous Promise.all.
|
|
167
|
+
* @param {number} [opts.dispatch.started_at_ms] Wall-clock ms when the fan-out started.
|
|
168
|
+
* @param {number} [opts.dispatch.settled_at_ms] Wall-clock ms when all settled.
|
|
169
|
+
* @returns {object}
|
|
170
|
+
*/
|
|
171
|
+
export function makeMultiModelRun({
|
|
172
|
+
task_id,
|
|
173
|
+
task_description,
|
|
174
|
+
target_files = [],
|
|
175
|
+
results = [],
|
|
176
|
+
dispatch = null,
|
|
177
|
+
} = {}) {
|
|
178
|
+
// Derive summary from results
|
|
179
|
+
const succeeded = results.filter(r => r && r.status === 'ok').map(r => r.family);
|
|
180
|
+
const failed = results.filter(r => r && r.status === 'failed').map(r => r.family);
|
|
181
|
+
|
|
182
|
+
// Fastest model = min time_to_relevant_file_ms among successful runs that found the file
|
|
183
|
+
let fastest_family = null;
|
|
184
|
+
let minTtrf = Infinity;
|
|
185
|
+
for (const r of results) {
|
|
186
|
+
if (r && r.status === 'ok' && r.metrics) {
|
|
187
|
+
const t = r.metrics.time_to_relevant_file_ms;
|
|
188
|
+
if (typeof t === 'number' && t >= 0 && t < minTtrf) {
|
|
189
|
+
minTtrf = t;
|
|
190
|
+
fastest_family = r.family;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
knosky_protocol: PROTOCOL_VERSION,
|
|
197
|
+
artifact_type: MULTI_MODEL_ARTIFACT_TYPE,
|
|
198
|
+
advisory: true,
|
|
199
|
+
generated_at: new Date().toISOString(),
|
|
200
|
+
task_id,
|
|
201
|
+
task_description,
|
|
202
|
+
target_files,
|
|
203
|
+
dispatch: dispatch || { mode: 'parallel' },
|
|
204
|
+
results,
|
|
205
|
+
summary: {
|
|
206
|
+
total: results.length,
|
|
207
|
+
succeeded: succeeded.length,
|
|
208
|
+
failed: failed.length,
|
|
209
|
+
succeeded_families: succeeded,
|
|
210
|
+
failed_families: failed,
|
|
211
|
+
fastest_family,
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// validateMultiModelRun
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Validate a KnoSky `multi-model-benchmark` document.
|
|
222
|
+
* Collects every violation; `ok` is true only when `errors` is empty.
|
|
223
|
+
*
|
|
224
|
+
* @param {object} doc
|
|
225
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
226
|
+
*/
|
|
227
|
+
export function validateMultiModelRun(doc) {
|
|
228
|
+
const errors = [];
|
|
229
|
+
|
|
230
|
+
if (!doc || typeof doc !== 'object') {
|
|
231
|
+
return { ok: false, errors: ['doc must be an object'] };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (doc.knosky_protocol !== PROTOCOL_VERSION) {
|
|
235
|
+
errors.push(
|
|
236
|
+
`knosky_protocol must be "${PROTOCOL_VERSION}", got: ${JSON.stringify(doc.knosky_protocol)}`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (doc.artifact_type !== MULTI_MODEL_ARTIFACT_TYPE) {
|
|
241
|
+
errors.push(
|
|
242
|
+
`artifact_type must be "${MULTI_MODEL_ARTIFACT_TYPE}", ` +
|
|
243
|
+
`got: ${JSON.stringify(doc.artifact_type)}`,
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (doc.advisory !== true) {
|
|
248
|
+
errors.push(`advisory must be true, got: ${JSON.stringify(doc.advisory)}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// task_id: non-empty string
|
|
252
|
+
if (typeof doc.task_id !== 'string' || doc.task_id.length === 0) {
|
|
253
|
+
errors.push(`task_id must be a non-empty string, got: ${JSON.stringify(doc.task_id)}`);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// task_description: non-empty string
|
|
257
|
+
if (typeof doc.task_description !== 'string' || doc.task_description.length === 0) {
|
|
258
|
+
errors.push(
|
|
259
|
+
`task_description must be a non-empty string, got: ${JSON.stringify(doc.task_description)}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// target_files: non-empty array of relative-safe path strings
|
|
264
|
+
if (!Array.isArray(doc.target_files) || doc.target_files.length === 0) {
|
|
265
|
+
errors.push('target_files must be a non-empty array');
|
|
266
|
+
} else {
|
|
267
|
+
for (let i = 0; i < doc.target_files.length; i++) {
|
|
268
|
+
const p = doc.target_files[i];
|
|
269
|
+
if (typeof p !== 'string' || p.length === 0) {
|
|
270
|
+
errors.push(`target_files[${i}] must be a non-empty string`);
|
|
271
|
+
} else if (!isRelativeSafePath(p)) {
|
|
272
|
+
errors.push(
|
|
273
|
+
`target_files[${i}] must be a relative path with no ".." segments: ${JSON.stringify(p)}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// dispatch: object with mode === 'parallel'
|
|
280
|
+
if (!doc.dispatch || typeof doc.dispatch !== 'object') {
|
|
281
|
+
errors.push('dispatch must be an object');
|
|
282
|
+
} else if (doc.dispatch.mode !== 'parallel') {
|
|
283
|
+
errors.push(
|
|
284
|
+
`dispatch.mode must be "parallel" (simultaneous fan-out), ` +
|
|
285
|
+
`got: ${JSON.stringify(doc.dispatch.mode)}`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// results: non-empty array
|
|
290
|
+
if (!Array.isArray(doc.results) || doc.results.length === 0) {
|
|
291
|
+
errors.push('results must be a non-empty array');
|
|
292
|
+
} else {
|
|
293
|
+
for (let i = 0; i < doc.results.length; i++) {
|
|
294
|
+
errors.push(...validateModelResult(doc.results[i], `results[${i}]`));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// summary: structural check
|
|
299
|
+
if (!doc.summary || typeof doc.summary !== 'object') {
|
|
300
|
+
errors.push('summary must be an object');
|
|
301
|
+
} else {
|
|
302
|
+
const s = doc.summary;
|
|
303
|
+
if (!Number.isInteger(s.total) || s.total < 0) {
|
|
304
|
+
errors.push(`summary.total must be a non-negative integer, got: ${JSON.stringify(s.total)}`);
|
|
305
|
+
}
|
|
306
|
+
if (!Number.isInteger(s.succeeded) || s.succeeded < 0) {
|
|
307
|
+
errors.push(`summary.succeeded must be a non-negative integer, got: ${JSON.stringify(s.succeeded)}`);
|
|
308
|
+
}
|
|
309
|
+
if (!Number.isInteger(s.failed) || s.failed < 0) {
|
|
310
|
+
errors.push(`summary.failed must be a non-negative integer, got: ${JSON.stringify(s.failed)}`);
|
|
311
|
+
}
|
|
312
|
+
if (!Array.isArray(s.succeeded_families)) {
|
|
313
|
+
errors.push('summary.succeeded_families must be an array');
|
|
314
|
+
}
|
|
315
|
+
if (!Array.isArray(s.failed_families)) {
|
|
316
|
+
errors.push('summary.failed_families must be an array');
|
|
317
|
+
}
|
|
318
|
+
// fastest_family: null or a known family string
|
|
319
|
+
if (s.fastest_family !== null && typeof s.fastest_family !== 'string') {
|
|
320
|
+
errors.push(
|
|
321
|
+
`summary.fastest_family must be null or a string, got: ${JSON.stringify(s.fastest_family)}`,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return { ok: errors.length === 0, errors };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
// runMultiModelBenchmark
|
|
331
|
+
// ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Execute one benchmark task simultaneously across all model families.
|
|
335
|
+
*
|
|
336
|
+
* This is the harness entry point that implements Paul's "simultaneous tests"
|
|
337
|
+
* requirement: all models are dispatched in a single Promise.all fan-out so
|
|
338
|
+
* no model waits for another to finish.
|
|
339
|
+
*
|
|
340
|
+
* `dispatch` is an async function provided by the caller (typically the tools/
|
|
341
|
+
* layer, which owns the HTTPS + LiteLLM wiring). It receives a { family,
|
|
342
|
+
* modelId, task } descriptor and must resolve to a per-model result object:
|
|
343
|
+
*
|
|
344
|
+
* {
|
|
345
|
+
* family: string, // must match MODEL_FAMILIES[n].family
|
|
346
|
+
* model_id: string,
|
|
347
|
+
* status: 'ok' | 'failed',
|
|
348
|
+
* error: string | null, // non-null on failure
|
|
349
|
+
* metrics: { // present when status === 'ok'
|
|
350
|
+
* tokens_in: number,
|
|
351
|
+
* tokens_out: number,
|
|
352
|
+
* tool_calls: number,
|
|
353
|
+
* time_to_relevant_file_ms: number | null,
|
|
354
|
+
* correct: boolean,
|
|
355
|
+
* } | null,
|
|
356
|
+
* }
|
|
357
|
+
*
|
|
358
|
+
* Failed dispatches (rejected promise, uncaught throw) are caught and stored
|
|
359
|
+
* as `{ status: 'failed', error: <message> }` — one model failing never
|
|
360
|
+
* prevents the others from completing (Promise.allSettled semantics on top
|
|
361
|
+
* of the individual error handling).
|
|
362
|
+
*
|
|
363
|
+
* @param {object} task
|
|
364
|
+
* @param {string} task.task_id
|
|
365
|
+
* @param {string} task.task_description
|
|
366
|
+
* @param {string[]} task.target_files
|
|
367
|
+
* @param {Function} dispatchFn async (descriptor) => modelResult
|
|
368
|
+
* @param {Array<{ family: string, modelId: string }>} [models] Defaults to MODEL_FAMILIES.
|
|
369
|
+
* @returns {Promise<object>} A validated multi-model-benchmark artifact.
|
|
370
|
+
*/
|
|
371
|
+
export async function runMultiModelBenchmark(task, dispatchFn, models = MODEL_FAMILIES) {
|
|
372
|
+
const startedAtMs = Date.now();
|
|
373
|
+
|
|
374
|
+
// Fan out simultaneously — Promise.all so all models start at the same time.
|
|
375
|
+
// Individual dispatch errors are caught per-slot so one failure cannot abort others.
|
|
376
|
+
const results = await Promise.all(
|
|
377
|
+
models.map(async ({ family, modelId }) => {
|
|
378
|
+
try {
|
|
379
|
+
return await dispatchFn({ family, modelId, task });
|
|
380
|
+
} catch (err) {
|
|
381
|
+
return {
|
|
382
|
+
family,
|
|
383
|
+
model_id: modelId,
|
|
384
|
+
status: 'failed',
|
|
385
|
+
error: err && err.message ? err.message : String(err),
|
|
386
|
+
metrics: null,
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
}),
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
const settledAtMs = Date.now();
|
|
393
|
+
|
|
394
|
+
return makeMultiModelRun({
|
|
395
|
+
task_id: task.task_id,
|
|
396
|
+
task_description: task.task_description,
|
|
397
|
+
target_files: task.target_files,
|
|
398
|
+
results,
|
|
399
|
+
dispatch: {
|
|
400
|
+
mode: 'parallel',
|
|
401
|
+
started_at_ms: startedAtMs,
|
|
402
|
+
settled_at_ms: settledAtMs,
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
}
|