@aws/ml-container-creator 0.9.1 → 0.10.3

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 (90) hide show
  1. package/LICENSE-THIRD-PARTY +9304 -0
  2. package/bin/cli.js +2 -0
  3. package/config/bootstrap-e2e-stack.json +341 -0
  4. package/config/bootstrap-stack.json +40 -3
  5. package/config/parameter-schema-v2.json +2049 -0
  6. package/config/tune-catalog.json +1781 -0
  7. package/infra/ci-harness/buildspec.yml +1 -0
  8. package/infra/ci-harness/lambda/path-prover/brain.ts +306 -0
  9. package/infra/ci-harness/lambda/path-prover/write-results.ts +152 -0
  10. package/infra/ci-harness/lib/ci-harness-stack.ts +837 -7
  11. package/infra/ci-harness/state-machines/path-prover.asl.json +496 -0
  12. package/package.json +53 -68
  13. package/servers/base-image-picker/index.js +121 -121
  14. package/servers/e2e-status/index.js +297 -0
  15. package/servers/e2e-status/manifest.json +14 -0
  16. package/servers/e2e-status/package.json +15 -0
  17. package/servers/endpoint-picker/LICENSE +202 -0
  18. package/servers/endpoint-picker/index.js +536 -0
  19. package/servers/endpoint-picker/manifest.json +14 -0
  20. package/servers/endpoint-picker/package.json +18 -0
  21. package/servers/hyperpod-cluster-picker/index.js +125 -125
  22. package/servers/instance-sizer/index.js +138 -138
  23. package/servers/instance-sizer/lib/instance-ranker.js +76 -76
  24. package/servers/instance-sizer/lib/model-resolver.js +61 -61
  25. package/servers/instance-sizer/lib/quota-resolver.js +113 -113
  26. package/servers/instance-sizer/lib/vram-estimator.js +31 -31
  27. package/servers/lib/bedrock-client.js +38 -38
  28. package/servers/lib/catalogs/jumpstart-public.json +101 -16
  29. package/servers/lib/catalogs/model-servers.json +201 -3
  30. package/servers/lib/catalogs/models.json +182 -26
  31. package/servers/lib/custom-validators.js +13 -13
  32. package/servers/lib/dynamic-resolver.js +4 -4
  33. package/servers/marketplace-picker/index.js +342 -0
  34. package/servers/marketplace-picker/manifest.json +14 -0
  35. package/servers/marketplace-picker/package.json +18 -0
  36. package/servers/model-picker/index.js +382 -382
  37. package/servers/region-picker/index.js +56 -56
  38. package/servers/workload-picker/LICENSE +202 -0
  39. package/servers/workload-picker/catalogs/workload-profiles.json +67 -0
  40. package/servers/workload-picker/index.js +171 -0
  41. package/servers/workload-picker/manifest.json +16 -0
  42. package/servers/workload-picker/package.json +16 -0
  43. package/src/app.js +4 -390
  44. package/src/lib/bootstrap-command-handler.js +710 -1148
  45. package/src/lib/bootstrap-config.js +36 -0
  46. package/src/lib/bootstrap-profile-manager.js +641 -0
  47. package/src/lib/bootstrap-provisioners.js +421 -0
  48. package/src/lib/ci-register-helpers.js +74 -0
  49. package/src/lib/config-loader.js +408 -0
  50. package/src/lib/config-manager.js +66 -1685
  51. package/src/lib/config-mcp-client.js +118 -0
  52. package/src/lib/config-validator.js +634 -0
  53. package/src/lib/cuda-resolver.js +149 -0
  54. package/src/lib/e2e-catalog-validator.js +251 -3
  55. package/src/lib/e2e-ci-recorder.js +103 -0
  56. package/src/lib/generated/cli-options.js +315 -311
  57. package/src/lib/generated/parameter-matrix.js +671 -0
  58. package/src/lib/generated/validation-rules.js +71 -71
  59. package/src/lib/marketplace-flow.js +276 -0
  60. package/src/lib/mcp-query-runner.js +768 -0
  61. package/src/lib/parameter-schema-validator.js +62 -18
  62. package/src/lib/path-prover-brain.js +607 -0
  63. package/src/lib/prompt-runner.js +41 -1504
  64. package/src/lib/prompts/feature-prompts.js +172 -0
  65. package/src/lib/prompts/index.js +48 -0
  66. package/src/lib/prompts/infrastructure-prompts.js +690 -0
  67. package/src/lib/prompts/model-prompts.js +552 -0
  68. package/src/lib/prompts/project-prompts.js +82 -0
  69. package/src/lib/prompts.js +2 -1446
  70. package/src/lib/registry-command-handler.js +135 -3
  71. package/src/lib/secrets-prompt-runner.js +251 -0
  72. package/src/lib/template-variable-resolver.js +422 -0
  73. package/src/lib/tune-catalog-validator.js +37 -4
  74. package/templates/Dockerfile +9 -0
  75. package/templates/code/adapter_sidecar.py +444 -0
  76. package/templates/code/serve +6 -0
  77. package/templates/code/serve.d/vllm.ejs +1 -1
  78. package/templates/do/.benchmark_writer.py +1476 -0
  79. package/templates/do/.tune_helper.py +982 -57
  80. package/templates/do/__pycache__/.benchmark_writer.cpython-312.pyc +0 -0
  81. package/templates/do/adapter +149 -0
  82. package/templates/do/benchmark +639 -85
  83. package/templates/do/config +108 -5
  84. package/templates/do/deploy.d/managed-inference.ejs +192 -11
  85. package/templates/do/optimize +106 -37
  86. package/templates/do/register +89 -0
  87. package/templates/do/test +13 -0
  88. package/templates/do/tune +378 -59
  89. package/templates/do/validate +44 -4
  90. package/config/parameter-schema.json +0 -88
@@ -0,0 +1,607 @@
1
+ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ import { readFileSync } from 'node:fs';
5
+ import { resolve } from 'node:path';
6
+
7
+ /**
8
+ * Path Prover Brain
9
+ *
10
+ * Implements the intelligence layer for the Path Prover agent mode.
11
+ * This module identifies coverage gaps, finds nearest substitutions,
12
+ * classifies failures, gates tune/adapter stages, and builds
13
+ * Athena-compatible records with run_type='path_prove'.
14
+ *
15
+ * Feature: ci-benchmark-pipeline
16
+ * Requirements: 8.1–8.12
17
+ */
18
+
19
+ // ── Configuration Dimensions ─────────────────────────────────────────────────
20
+
21
+ /**
22
+ * The ordered vector of config dimensions used for Hamming distance calculation.
23
+ */
24
+ export const CONFIG_DIMENSIONS = [
25
+ 'deployment_config',
26
+ 'model_family',
27
+ 'instance_family',
28
+ 'quantization',
29
+ 'tp_degree',
30
+ 'deployment_target'
31
+ ];
32
+
33
+ // ── Failure Classification ───────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Valid failure categories for Path Prover classification.
37
+ */
38
+ export const FAILURE_CATEGORIES = [
39
+ 'capacity',
40
+ 'timeout',
41
+ 'oom',
42
+ 'code_bug',
43
+ 'model_incompatibility',
44
+ 'service_limitation'
45
+ ];
46
+
47
+ /**
48
+ * Error pattern matchers for failure classification.
49
+ * Each entry maps a regex pattern to a category and retryable flag.
50
+ */
51
+ const ERROR_PATTERNS = [
52
+ { pattern: /InsufficientInstanceCapacity/i, category: 'capacity', retryable: true },
53
+ { pattern: /CapacityError/i, category: 'capacity', retryable: true },
54
+ { pattern: /no capacity/i, category: 'capacity', retryable: true },
55
+ { pattern: /timed?\s*out/i, category: 'timeout', retryable: true },
56
+ { pattern: /timeout/i, category: 'timeout', retryable: true },
57
+ { pattern: /deadline exceeded/i, category: 'timeout', retryable: true },
58
+ { pattern: /OutOfMemory/i, category: 'oom', retryable: false },
59
+ { pattern: /OOM/i, category: 'oom', retryable: false },
60
+ { pattern: /CUDA out of memory/i, category: 'oom', retryable: false },
61
+ { pattern: /Cannot allocate memory/i, category: 'oom', retryable: false },
62
+ { pattern: /killed.*memory/i, category: 'oom', retryable: false },
63
+ { pattern: /template.*error/i, category: 'code_bug', retryable: false },
64
+ { pattern: /SyntaxError/i, category: 'code_bug', retryable: false },
65
+ { pattern: /ReferenceError/i, category: 'code_bug', retryable: false },
66
+ { pattern: /TypeError/i, category: 'code_bug', retryable: false },
67
+ { pattern: /script crash/i, category: 'code_bug', retryable: false },
68
+ { pattern: /rendering failed/i, category: 'code_bug', retryable: false },
69
+ { pattern: /not supported.*model/i, category: 'model_incompatibility', retryable: false },
70
+ { pattern: /model.*incompatible/i, category: 'model_incompatibility', retryable: false },
71
+ { pattern: /unsupported.*architecture/i, category: 'model_incompatibility', retryable: false },
72
+ { pattern: /LoRA.*not supported/i, category: 'model_incompatibility', retryable: false },
73
+ { pattern: /adapter.*not compatible/i, category: 'model_incompatibility', retryable: false },
74
+ { pattern: /not available.*region/i, category: 'service_limitation', retryable: false },
75
+ { pattern: /service.*not supported/i, category: 'service_limitation', retryable: false },
76
+ { pattern: /API.*not available/i, category: 'service_limitation', retryable: false },
77
+ { pattern: /feature.*not.*region/i, category: 'service_limitation', retryable: false },
78
+ { pattern: /ValidationException/i, category: 'service_limitation', retryable: false }
79
+ ];
80
+
81
+ // ── Gap Identification (Task 5.1) ────────────────────────────────────────────
82
+
83
+ /**
84
+ * Identify coverage gaps given a set of proven configurations.
85
+ *
86
+ * A "gap" is a config dimension combination that has no records in Athena.
87
+ * This function compares the known dimension space (all unique values seen
88
+ * across proven configs) against what is actually proven, and returns
89
+ * combinations that are missing.
90
+ *
91
+ * @param {object[]} provenConfigs - Array of proven config objects from Athena
92
+ * Each object must have keys matching CONFIG_DIMENSIONS plus `status`
93
+ * @returns {object[]} Ordered list of gap configs to prove, sorted by
94
+ * coverage priority (more neighbors proven = higher priority)
95
+ */
96
+ export function identifyGaps(provenConfigs) {
97
+ if (!provenConfigs || provenConfigs.length === 0) {
98
+ return [];
99
+ }
100
+
101
+ // Extract unique values for each dimension from proven configs
102
+ const dimensionValues = {};
103
+ for (const dim of CONFIG_DIMENSIONS) {
104
+ const values = new Set();
105
+ for (const config of provenConfigs) {
106
+ if (config[dim] !== undefined && config[dim] !== null) {
107
+ values.add(String(config[dim]));
108
+ }
109
+ }
110
+ dimensionValues[dim] = [...values];
111
+ }
112
+
113
+ // Build a set of proven config signatures for fast lookup
114
+ const provenSignatures = new Set();
115
+ for (const config of provenConfigs) {
116
+ if (config.status === 'completed') {
117
+ const sig = CONFIG_DIMENSIONS.map(d => String(config[d] ?? '')).join('|');
118
+ provenSignatures.add(sig);
119
+ }
120
+ }
121
+
122
+ // Generate all combinations from observed values and find gaps
123
+ const gaps = [];
124
+ const combinations = cartesianProduct(dimensionValues);
125
+
126
+ for (const combo of combinations) {
127
+ const sig = CONFIG_DIMENSIONS.map(d => String(combo[d] ?? '')).join('|');
128
+ if (!provenSignatures.has(sig)) {
129
+ // Count how many neighbors (distance=1) are proven — higher = more valuable
130
+ let neighborCount = 0;
131
+ for (const provenSig of provenSignatures) {
132
+ const provenParts = provenSig.split('|');
133
+ const comboParts = sig.split('|');
134
+ let diff = 0;
135
+ for (let i = 0; i < provenParts.length; i++) {
136
+ if (provenParts[i] !== comboParts[i]) diff++;
137
+ }
138
+ if (diff === 1) neighborCount++;
139
+ }
140
+ gaps.push({ ...combo, _neighborCount: neighborCount });
141
+ }
142
+ }
143
+
144
+ // Sort by neighbor count descending (most surrounded gaps first)
145
+ gaps.sort((a, b) => b._neighborCount - a._neighborCount);
146
+
147
+ // Remove internal sorting field before returning
148
+ return gaps.map(({ _neighborCount, ...config }) => config);
149
+ }
150
+
151
+ /**
152
+ * Generate cartesian product of dimension value arrays.
153
+ * @param {object} dimensionValues - Map of dimension name to array of values
154
+ * @returns {object[]} Array of config objects representing all combinations
155
+ */
156
+ function cartesianProduct(dimensionValues) {
157
+ const dims = CONFIG_DIMENSIONS;
158
+ const results = [];
159
+
160
+ function generate(index, current) {
161
+ if (index === dims.length) {
162
+ results.push({ ...current });
163
+ return;
164
+ }
165
+ const dim = dims[index];
166
+ const values = dimensionValues[dim] || [];
167
+ if (values.length === 0) {
168
+ generate(index + 1, current);
169
+ return;
170
+ }
171
+ for (const val of values) {
172
+ current[dim] = val;
173
+ generate(index + 1, current);
174
+ }
175
+ }
176
+
177
+ generate(0, {});
178
+ return results;
179
+ }
180
+
181
+ // ── Substitution Algorithm (Task 5.2) ────────────────────────────────────────
182
+
183
+ /**
184
+ * Find the nearest proven substitution for a requested configuration.
185
+ *
186
+ * Uses Hamming distance on the config dimension vector. Only considers
187
+ * configs with status='completed'. Never crosses the model_family boundary.
188
+ *
189
+ * @param {object} requestedConfig - The requested config with dimension fields
190
+ * @param {object[]} provenConfigs - Array of proven configs from Athena
191
+ * @returns {object} Result object:
192
+ * - If matches found: { substitutions: [{config, distance, explanation}...] } (top 3)
193
+ * - If no matches: { noMatch: true, message: string }
194
+ */
195
+ export function findNearestSubstitution(requestedConfig, provenConfigs) {
196
+ if (!requestedConfig || !provenConfigs || provenConfigs.length === 0) {
197
+ return { noMatch: true, message: 'no coverage — no proven configs available' };
198
+ }
199
+
200
+ const requestedFamily = requestedConfig.model_family;
201
+
202
+ // Filter to only completed configs in the same model_family
203
+ const candidates = provenConfigs.filter(c =>
204
+ c.status === 'completed' && c.model_family === requestedFamily
205
+ );
206
+
207
+ if (candidates.length === 0) {
208
+ // Find nearest across families for the message
209
+ const allCompleted = provenConfigs.filter(c => c.status === 'completed');
210
+ if (allCompleted.length === 0) {
211
+ return { noMatch: true, message: 'no coverage — no proven configs available' };
212
+ }
213
+ const minDistance = Math.min(
214
+ ...allCompleted.map(c => hammingDistance(requestedConfig, c))
215
+ );
216
+ return {
217
+ noMatch: true,
218
+ message: `no coverage — nearest proven config is ${minDistance} dimensions away`
219
+ };
220
+ }
221
+
222
+ // Compute distances and sort
223
+ const scored = candidates.map(config => {
224
+ const distance = hammingDistance(requestedConfig, config);
225
+ const explanation = buildExplanation(requestedConfig, config);
226
+ return { config, distance, explanation };
227
+ });
228
+
229
+ // Sort by distance ascending, then by recency (if run_timestamp available)
230
+ scored.sort((a, b) => {
231
+ if (a.distance !== b.distance) return a.distance - b.distance;
232
+ // Secondary sort: prefer more recent configs
233
+ const aTime = a.config.run_timestamp || '';
234
+ const bTime = b.config.run_timestamp || '';
235
+ return bTime.localeCompare(aTime);
236
+ });
237
+
238
+ // Return top 3
239
+ const substitutions = scored.slice(0, 3).map(({ config, distance, explanation }) => ({
240
+ config,
241
+ distance,
242
+ explanation
243
+ }));
244
+
245
+ return { substitutions };
246
+ }
247
+
248
+ /**
249
+ * Compute Hamming distance between two config vectors.
250
+ * Counts the number of dimensions that differ.
251
+ *
252
+ * @param {object} configA - First config
253
+ * @param {object} configB - Second config
254
+ * @returns {number} Number of dimensions that differ
255
+ */
256
+ export function hammingDistance(configA, configB) {
257
+ let distance = 0;
258
+ for (const dim of CONFIG_DIMENSIONS) {
259
+ const valA = String(configA[dim] ?? '');
260
+ const valB = String(configB[dim] ?? '');
261
+ if (valA !== valB) {
262
+ distance++;
263
+ }
264
+ }
265
+ return distance;
266
+ }
267
+
268
+ /**
269
+ * Build a human-readable explanation of which dimensions differ.
270
+ *
271
+ * @param {object} requested - The requested config
272
+ * @param {object} suggested - The suggested substitution
273
+ * @returns {string[]} Array of dimension difference explanations
274
+ */
275
+ function buildExplanation(requested, suggested) {
276
+ const diffs = [];
277
+ for (const dim of CONFIG_DIMENSIONS) {
278
+ const reqVal = String(requested[dim] ?? '');
279
+ const sugVal = String(suggested[dim] ?? '');
280
+ if (reqVal !== sugVal) {
281
+ diffs.push(`${dim}: '${reqVal}' → '${sugVal}'`);
282
+ }
283
+ }
284
+ return diffs;
285
+ }
286
+
287
+ // ── Tune/Adapter Stage Gating (Task 5.3) ─────────────────────────────────────
288
+
289
+ /**
290
+ * Determine whether tune/adapter stages should execute for a prove request.
291
+ *
292
+ * Tune stages only execute when the prove request explicitly includes
293
+ * fine-tuning (e.g., the gap involves a tune technique or the user
294
+ * requested adapter serving).
295
+ *
296
+ * @param {object} proveRequest - The prove request object
297
+ * @param {boolean} [proveRequest.include_tuning] - Explicitly request tuning
298
+ * @param {boolean} [proveRequest.enable_lora] - Whether LoRA is enabled
299
+ * @param {string} [proveRequest.tune_technique] - Tune technique (sft, dpo, etc.)
300
+ * @returns {boolean} True if tune stages should execute
301
+ */
302
+ export function shouldExecuteTuneStages(proveRequest) {
303
+ if (!proveRequest) return false;
304
+
305
+ // Explicit tuning request
306
+ if (proveRequest.include_tuning === true) return true;
307
+
308
+ // LoRA adapter serving requested
309
+ if (proveRequest.enable_lora === true) return true;
310
+
311
+ // Tune technique specified
312
+ if (proveRequest.tune_technique && proveRequest.tune_technique !== 'none') return true;
313
+
314
+ return false;
315
+ }
316
+
317
+ // ── Failure Classification (Task 5.4) ────────────────────────────────────────
318
+
319
+ /**
320
+ * Classify a failure from error output.
321
+ *
322
+ * Parses error output for known patterns and returns a structured
323
+ * classification with stage, category, and retryable flag.
324
+ *
325
+ * @param {string|object} errorOutput - Error output (string or structured object)
326
+ * @param {string} [errorOutput.error] - Error message (if object)
327
+ * @param {string} [errorOutput.stage] - Stage that failed (if object)
328
+ * @returns {object} Classification: { stage, category, retryable }
329
+ */
330
+ export function classifyFailure(errorOutput) {
331
+ if (!errorOutput) {
332
+ return { stage: 'unknown', category: 'code_bug', retryable: false };
333
+ }
334
+
335
+ // Extract error message and stage
336
+ let errorMsg = '';
337
+ let stage = 'unknown';
338
+
339
+ if (typeof errorOutput === 'string') {
340
+ errorMsg = errorOutput;
341
+ stage = detectStage(errorOutput);
342
+ } else if (typeof errorOutput === 'object') {
343
+ errorMsg = errorOutput.error || errorOutput.message || JSON.stringify(errorOutput);
344
+ stage = errorOutput.stage || detectStage(errorMsg);
345
+ }
346
+
347
+ // Match against known patterns
348
+ for (const { pattern, category, retryable } of ERROR_PATTERNS) {
349
+ if (pattern.test(errorMsg)) {
350
+ return { stage, category, retryable };
351
+ }
352
+ }
353
+
354
+ // Default: unrecognized errors are classified as code_bug (non-retryable)
355
+ return { stage, category: 'code_bug', retryable: false };
356
+ }
357
+
358
+ /**
359
+ * Detect which lifecycle stage produced an error from the error message.
360
+ *
361
+ * @param {string} errorMsg - The error message
362
+ * @returns {string} The detected stage name
363
+ */
364
+ function detectStage(errorMsg) {
365
+ const stagePatterns = [
366
+ { pattern: /\b(generate|generation)\b/i, stage: 'generate' },
367
+ { pattern: /\b(build|docker)\b/i, stage: 'build' },
368
+ { pattern: /\b(push|ecr|registry)\b/i, stage: 'push' },
369
+ { pattern: /\b(deploy|endpoint|CreateEndpoint|InferenceComponent)\b/i, stage: 'deploy' },
370
+ { pattern: /\b(test|invoke|invocation|inference)\b/i, stage: 'test' },
371
+ { pattern: /\b(tune|fine-?tun|customization)\b/i, stage: 'tune' },
372
+ { pattern: /\b(adapter|lora)\b/i, stage: 'adapter' },
373
+ { pattern: /\b(benchmark|bench)\b/i, stage: 'benchmark' },
374
+ { pattern: /\b(register|dynamo)\b/i, stage: 'register' },
375
+ { pattern: /\b(clean|delete)\b/i, stage: 'clean' }
376
+ ];
377
+
378
+ for (const { pattern, stage } of stagePatterns) {
379
+ if (pattern.test(errorMsg)) {
380
+ return stage;
381
+ }
382
+ }
383
+
384
+ return 'unknown';
385
+ }
386
+
387
+ // ── Result Writing (Task 5.5) ────────────────────────────────────────────────
388
+
389
+ /**
390
+ * Build a Path Prover Athena record from execution result and classification.
391
+ *
392
+ * All records have run_type='path_prove'. On success, status='completed'.
393
+ * On non-retryable failure, status='unfeasible' with failure_reason populated.
394
+ * On retryable failure, status='failed' with failure_reason populated.
395
+ *
396
+ * @param {object} result - The execution result
397
+ * @param {boolean} result.success - Whether the prove run succeeded
398
+ * @param {object} [result.metrics] - Benchmark metrics (on success)
399
+ * @param {object} [result.config] - The config that was proven
400
+ * @param {string} [result.error] - Error message (on failure)
401
+ * @param {object|null} [classification] - Failure classification (from classifyFailure)
402
+ * @param {string} [classification.stage] - Stage that failed
403
+ * @param {string} [classification.category] - Error category
404
+ * @param {boolean} [classification.retryable] - Whether failure is retryable
405
+ * @returns {object} Athena-compatible record with run_type='path_prove'
406
+ */
407
+ export function buildPathProverRecord(result, classification) {
408
+ const record = {
409
+ run_type: 'path_prove',
410
+ run_timestamp: new Date().toISOString()
411
+ };
412
+
413
+ // Merge config dimensions if provided
414
+ if (result.config) {
415
+ for (const dim of CONFIG_DIMENSIONS) {
416
+ if (result.config[dim] !== undefined) {
417
+ record[dim] = result.config[dim];
418
+ }
419
+ }
420
+ // Also copy non-dimension config fields
421
+ if (result.config.config_id) record.config_id = result.config.config_id;
422
+ if (result.config.model_name) record.model_name = result.config.model_name;
423
+ if (result.config.instance_type) record.instance_type = result.config.instance_type;
424
+ }
425
+
426
+ if (result.success) {
427
+ record.status = 'completed';
428
+ // Merge metrics if available
429
+ if (result.metrics) {
430
+ Object.assign(record, result.metrics);
431
+ }
432
+ } else {
433
+ // Failure case
434
+ if (classification && classification.retryable === false) {
435
+ record.status = 'unfeasible';
436
+ } else {
437
+ record.status = 'failed';
438
+ }
439
+
440
+ // Populate failure details
441
+ record.failure_reason = result.error || 'Unknown failure';
442
+
443
+ if (classification) {
444
+ record.failure_stage = classification.stage;
445
+ record.failure_category = classification.category;
446
+ record.failure_retryable = classification.retryable;
447
+ }
448
+ }
449
+
450
+ return record;
451
+ }
452
+
453
+ /**
454
+ * Check if a config is known to be unfeasible (prevents repeated attempts).
455
+ *
456
+ * @param {object} config - The config to check
457
+ * @param {object[]} existingRecords - Existing Athena records
458
+ * @returns {object|null} The unfeasible record if found, null otherwise
459
+ */
460
+ export function findUnfeasibleRecord(config, existingRecords) {
461
+ if (!config || !existingRecords || existingRecords.length === 0) {
462
+ return null;
463
+ }
464
+
465
+ for (const record of existingRecords) {
466
+ if (record.status !== 'unfeasible') continue;
467
+ if (record.run_type !== 'path_prove') continue;
468
+
469
+ // Check if all dimensions match
470
+ const allMatch = CONFIG_DIMENSIONS.every(dim =>
471
+ String(record[dim] ?? '') === String(config[dim] ?? '')
472
+ );
473
+
474
+ if (allMatch) return record;
475
+ }
476
+
477
+ return null;
478
+ }
479
+
480
+ // ── Priority Queue (v1 Validation Mode) ──────────────────────────────────────
481
+
482
+ /**
483
+ * Get the next unproven config from the priority queue.
484
+ *
485
+ * Checks the priority targets list and returns the first target whose
486
+ * status is 'pending' and which hasn't been proven in existing records.
487
+ * If all priority targets are proven/completed, returns null to fall
488
+ * through to gap-finding mode.
489
+ *
490
+ * @param {object} event - The Step Functions event object
491
+ * @param {string} [event.priorityConfigPath] - Path to priority targets JSON
492
+ * @param {object[]} [event.previousResults] - Previously proven configs in this run
493
+ * @param {object|null} priorityData - Pre-loaded priority data (for Lambda/testing).
494
+ * If null, attempts to load from event.priorityConfigPath.
495
+ * @returns {object|null} Next config to prove, or null if priority queue exhausted
496
+ */
497
+ export function getNextPriorityConfig(event, priorityData = null) {
498
+ // Resolve priority data: explicit param > event._priorityData > load from file
499
+ const data = priorityData || event._priorityData || (
500
+ event.priorityConfigPath ? loadPriorityTargets(event.priorityConfigPath) : null
501
+ );
502
+
503
+ if (!data || !data.targets || !Array.isArray(data.targets)) {
504
+ return null;
505
+ }
506
+
507
+ const defaults = data.defaults || {};
508
+ const provenNames = new Set((data.proven || []).map(p => p.model_name));
509
+
510
+ // Also consider previousResults from this run as proven
511
+ const previousResults = event.previousResults || [];
512
+ for (const result of previousResults) {
513
+ if (result.success && result.config && result.config.model_name) {
514
+ provenNames.add(result.config.model_name);
515
+ }
516
+ }
517
+
518
+ // Find first pending target not yet proven
519
+ for (const target of data.targets) {
520
+ if (target.status !== 'pending') continue;
521
+ if (provenNames.has(target.model_name)) continue;
522
+
523
+ // Build full config from defaults + target overrides
524
+ const config = { ...defaults, ...target };
525
+ delete config.status; // status is metadata, not a config field
526
+
527
+ return config;
528
+ }
529
+
530
+ // All priority targets are proven or non-pending
531
+ return null;
532
+ }
533
+
534
+ /**
535
+ * Update a priority target's status after a prove attempt.
536
+ *
537
+ * @param {object} priorityData - The loaded priority targets data (mutated in place)
538
+ * @param {string} modelName - The model_name to update
539
+ * @param {string} newStatus - New status: 'proven', 'failed', or 'unfeasible'
540
+ * @param {object} [details] - Additional details (error_category, error_message)
541
+ * @returns {object} Updated priority data (same reference, mutated)
542
+ */
543
+ export function updatePriorityStatus(priorityData, modelName, newStatus, details = {}) {
544
+ if (!priorityData || !priorityData.targets) return priorityData;
545
+
546
+ const targetIndex = priorityData.targets.findIndex(t => t.model_name === modelName);
547
+ if (targetIndex === -1) return priorityData;
548
+
549
+ if (newStatus === 'proven') {
550
+ // Move from targets to proven list
551
+ priorityData.targets.splice(targetIndex, 1);
552
+ priorityData.proven = priorityData.proven || [];
553
+ priorityData.proven.push({
554
+ model_name: modelName,
555
+ proven_date: new Date().toISOString().split('T')[0],
556
+ ...details
557
+ });
558
+ } else {
559
+ // Update status in place (failed, unfeasible)
560
+ const target = priorityData.targets[targetIndex];
561
+ target.status = newStatus;
562
+ if (details.error_category) target.error_category = details.error_category;
563
+ if (details.error_message) target.error_message = details.error_message;
564
+ target.last_attempt = new Date().toISOString();
565
+ }
566
+
567
+ return priorityData;
568
+ }
569
+
570
+ /**
571
+ * Get a summary of priority queue status.
572
+ *
573
+ * @param {object} priorityData - The loaded priority targets data
574
+ * @returns {object} Summary with counts: { total, pending, proven, failed, unfeasible }
575
+ */
576
+ export function getPriorityQueueStatus(priorityData) {
577
+ if (!priorityData) {
578
+ return { total: 0, pending: 0, proven: 0, failed: 0, unfeasible: 0 };
579
+ }
580
+
581
+ const targets = priorityData.targets || [];
582
+ const proven = priorityData.proven || [];
583
+
584
+ return {
585
+ total: targets.length + proven.length,
586
+ pending: targets.filter(t => t.status === 'pending').length,
587
+ proven: proven.length,
588
+ failed: targets.filter(t => t.status === 'failed').length,
589
+ unfeasible: targets.filter(t => t.status === 'unfeasible').length
590
+ };
591
+ }
592
+
593
+ /**
594
+ * Load priority targets from a JSON file path (synchronous).
595
+ *
596
+ * @param {string} configPath - Absolute or relative path to the JSON file
597
+ * @returns {object|null} Parsed priority data, or null if not found/invalid
598
+ */
599
+ export function loadPriorityTargets(configPath) {
600
+ try {
601
+ const resolvedPath = resolve(configPath);
602
+ const raw = readFileSync(resolvedPath, 'utf8');
603
+ return JSON.parse(raw);
604
+ } catch {
605
+ return null;
606
+ }
607
+ }