incremnt 0.7.1 → 0.8.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.
@@ -3,13 +3,16 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import {
5
5
  askContext,
6
- askRoutedContext,
6
+ canonicalExerciseName,
7
7
  checkpointContext,
8
8
  cycleSummaryContext,
9
+ executeCoachReadTool,
9
10
  normalizeExerciseName,
10
11
  workoutSummaryContext,
11
12
  vitalsSummaryContext
12
13
  } from './queries.js';
14
+ import { askRoutedContext, buildAskStructuredResponse } from './ask-coach.js';
15
+ import { formatIncrementScorePrelude, isScoreQuestion } from './score-prelude.js';
13
16
  import {
14
17
  AI_PROMPT_VERSIONS,
15
18
  generateAskAnswer,
@@ -19,6 +22,8 @@ import {
19
22
  generateWorkoutCoachingSummary
20
23
  } from './openrouter.js';
21
24
  import { computeScoreBand } from './score-context.js';
25
+ import { stripXMLTagBlocks } from './prompt-security.js';
26
+ import { extractAskProgramDraft, hasProgramDraftBlock } from './program-draft.js';
22
27
 
23
28
  const __filename = fileURLToPath(import.meta.url);
24
29
  const __dirname = path.dirname(__filename);
@@ -29,6 +34,14 @@ export function defaultCaseSetName() {
29
34
  return process.env.SUMMARY_EVAL_CASE_SET || 'synthetic';
30
35
  }
31
36
 
37
+ function envFlag(name, env = process.env) {
38
+ return ['1', 'true', 'yes'].includes(String(env[name] ?? '').toLowerCase());
39
+ }
40
+
41
+ export function summaryEvalsLiveGenerationEnabled(env = process.env) {
42
+ return envFlag('SUMMARY_EVALS_LIVE', env) || envFlag('PROMPTFOO_LIVE', env);
43
+ }
44
+
32
45
  function stableSortByDateDesc(items, selector) {
33
46
  return [...items].sort((lhs, rhs) => String(selector(rhs)).localeCompare(String(selector(lhs))));
34
47
  }
@@ -86,12 +99,22 @@ export function buildSummaryEvalContext(snapshot, testCase) {
86
99
  return vitalsSummaryContext(snapshot, { exclude: new Set(testCase.exclude ?? []) });
87
100
  case 'ask': {
88
101
  const question = testCase.context?.question ?? testCase.question ?? '';
102
+ const today = testCase.context?.today ?? testCase.today ?? null;
103
+ const history = Array.isArray(testCase.context?.history) ? testCase.context.history : [];
89
104
  const routed = question
90
- ? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []) })
105
+ ? askRoutedContext(snapshot, question, { exclude: new Set(testCase.exclude ?? []), history, today: today ?? new Date() })
91
106
  : null;
107
+ // Mirror production: the live /cli/ask path prepends the Increment Score
108
+ // prelude to the routed context. Including it here means a live eval feeds
109
+ // the model the same dump-prone material, so evaluateAskScoreVoice actually
110
+ // guards the prompt, not just the checker.
111
+ const prelude = formatIncrementScorePrelude(scoreHistoryFromSnapshot(snapshot), { question });
112
+ const routedContext = routed?.context ?? null;
113
+ const trainingData = testCase.context?.trainingData
114
+ ?? (prelude && routedContext ? `${prelude}\n\n${routedContext}` : (routedContext ?? prelude));
92
115
  return {
93
116
  ...(testCase.context ?? {}),
94
- trainingData: testCase.context?.trainingData ?? routed?.context ?? null,
117
+ trainingData,
95
118
  routedMetadata: routed?.metadata ?? null
96
119
  };
97
120
  }
@@ -115,11 +138,38 @@ function summaryEvalGenerationMetadata(result) {
115
138
  );
116
139
  }
117
140
 
141
+ function buildAskEvalStructuredMetadata(testCase, context, output) {
142
+ if (testCase.surface !== 'ask') return {};
143
+ const parsedAsk = extractAskProgramDraft(output, {
144
+ canonicalizeExerciseName: canonicalExerciseName
145
+ });
146
+ const answer = stripXMLTagBlocks(parsedAsk.answerText);
147
+ const question = context?.question ?? testCase.context?.question ?? testCase.question ?? '';
148
+ const routingMetadata = context?.routedMetadata ?? null;
149
+ return {
150
+ routingMetadata,
151
+ structured: buildAskStructuredResponse(answer, routingMetadata ?? {}, {
152
+ programDraft: askStructuredProgramDraft(parsedAsk, routingMetadata),
153
+ question
154
+ })
155
+ };
156
+ }
157
+
158
+ function summaryEvalProviderMetadata(testCase, context, output, result = null) {
159
+ return {
160
+ ...summaryEvalGenerationMetadata(result),
161
+ ...buildAskEvalStructuredMetadata(testCase, context, output)
162
+ };
163
+ }
164
+
118
165
  export async function generateSummaryEvalOutputWithMetadata(testCase, context, snapshot = null) {
119
- const liveGenerationEnabled = process.env.SUMMARY_EVALS_LIVE === '1';
166
+ const liveGenerationEnabled = summaryEvalsLiveGenerationEnabled();
120
167
  const apiKey = process.env.OPENROUTER_API_KEY;
121
168
  if (!liveGenerationEnabled || !apiKey || testCase.shouldPass === false) {
122
- return { output: testCase.output, metadata: {} };
169
+ return {
170
+ output: testCase.output,
171
+ metadata: summaryEvalProviderMetadata(testCase, context, testCase.output)
172
+ };
123
173
  }
124
174
 
125
175
  let result;
@@ -149,7 +199,8 @@ export async function generateSummaryEvalOutputWithMetadata(testCase, context, s
149
199
  apiKey,
150
200
  history: context.history ?? [],
151
201
  tone: context.tone,
152
- model: context.model
202
+ model: context.model,
203
+ routingMetadata: context.routedMetadata ?? undefined
153
204
  });
154
205
  break;
155
206
  }
@@ -159,7 +210,7 @@ export async function generateSummaryEvalOutputWithMetadata(testCase, context, s
159
210
 
160
211
  return {
161
212
  output: result.text,
162
- metadata: summaryEvalGenerationMetadata(result)
213
+ metadata: summaryEvalProviderMetadata(testCase, context, result.text, result)
163
214
  };
164
215
  }
165
216
 
@@ -258,8 +309,44 @@ function isSingleParagraph(text) {
258
309
  return !normalizeText(text).includes('\n\n');
259
310
  }
260
311
 
261
- function lowerIncludes(text, snippet) {
262
- return normalizeText(text).toLowerCase().includes(String(snippet).toLowerCase());
312
+ // Canonicalizes free-form coach text and required-mention snippets to the same
313
+ // surface form before substring matching. The goal is to keep grounding checks
314
+ // (does the answer cite this real number?) while tolerating the formatting an
315
+ // LLM legitimately varies: unicode × vs ASCII x, set-token unit placement,
316
+ // signed deltas, and rep-sequence separators (8/8/7 vs "8, 8, and 7").
317
+ // It only adds equivalences; it never strips the digits a check is grounded on,
318
+ // so a genuinely absent number still fails.
319
+ function normalizeForMention(value) {
320
+ let s = normalizeText(value).toLowerCase();
321
+ // Unicode multiplication / bullet / asterisk between digits -> ASCII x.
322
+ s = s.replace(/(\d)\s*[×✕╳·∗*]\s*(\d)/g, '$1x$2');
323
+ // Drop weight units that sit inside set tokens: "80kg x 7" / "40 kg" -> "80 x 7" / "40".
324
+ s = s.replace(/(\d(?:\.\d+)?)\s*(?:kgs?|lbs?|pounds)\b/g, '$1');
325
+ // Collapse spaces around an x that joins two numbers: "80 x 7" -> "80x7".
326
+ s = s.replace(/(\d)\s*x\s*(?=\d)/g, '$1x');
327
+ // Unify rep-sequence separators: "8/8/7" and "8, 8, and 7" -> "8,8,7".
328
+ // Lookahead keeps the trailing digit so chained separators all collapse.
329
+ s = s.replace(/(\d)\s*\/\s*(?=\d)/g, '$1,');
330
+ s = s.replace(/(\d)\s*,\s*and\s+(?=\d)/g, '$1,');
331
+ s = s.replace(/(\d)\s*,\s*(?=\d)/g, '$1,');
332
+ return s.replace(/\s+/g, ' ').trim();
333
+ }
334
+
335
+ // A required/any-of mention may be a string or an array of acceptable
336
+ // alternatives (matches if any alternative is present). Arrays express
337
+ // AND-of-ORs at the fixture level: every top-level entry must match, and an
338
+ // array entry matches when any of its phrasings appears.
339
+ function mentionMatches(output, mention) {
340
+ const normalizedOutput = normalizeForMention(output);
341
+ const alternatives = Array.isArray(mention) ? mention : [mention];
342
+ return alternatives
343
+ .map((alternative) => normalizeForMention(alternative))
344
+ .filter(Boolean)
345
+ .some((alternative) => normalizedOutput.includes(alternative));
346
+ }
347
+
348
+ function describeMention(mention) {
349
+ return Array.isArray(mention) ? `one of [${mention.join(' | ')}]` : String(mention);
263
350
  }
264
351
 
265
352
  function phraseIncludes(text, snippet) {
@@ -305,7 +392,6 @@ const historicalExerciseModifiers = new Set([
305
392
  'leg',
306
393
  'weighted',
307
394
  'romanian',
308
- 'hack',
309
395
  'full',
310
396
  'grip'
311
397
  ]);
@@ -358,6 +444,18 @@ function collectAllowedExerciseNames(surface, context) {
358
444
  for (const sc of context.planComparison?.setsComparison ?? []) {
359
445
  if (sc.exercise) names.add(sc.exercise);
360
446
  }
447
+ // Planned-but-skipped and unplanned-but-added exercises are legitimate
448
+ // planned-vs-actual subjects: the model is handed "skipped X, added Y" in
449
+ // its context, so a correct "you skipped X" note must not be flagged as an
450
+ // unauthorized mention. `setsComparison` only carries *performed* planned
451
+ // exercises (queries.js builds it with a performedNames filter), so skipped
452
+ // lifts would otherwise never be authorized.
453
+ for (const exerciseName of context.planComparison?.skipped ?? []) {
454
+ if (exerciseName) names.add(exerciseName);
455
+ }
456
+ for (const exerciseName of context.planComparison?.added ?? []) {
457
+ if (exerciseName) names.add(exerciseName);
458
+ }
361
459
  }
362
460
 
363
461
  if (surface === 'cycle' && context && typeof context === 'object') {
@@ -399,6 +497,49 @@ function collectAllowedExerciseNames(surface, context) {
399
497
  return [...names];
400
498
  }
401
499
 
500
+ // Project the allow-set from the actual context object the model was handed.
501
+ // Any known exercise name (from the snapshot's vocabulary) that appears anywhere
502
+ // in the serialized context — structured fields, plan comparison, prior-session
503
+ // comparisons, nearby cardio, or free-text session/exercise notes — is something
504
+ // the model could legitimately reference. Deriving authorization this way means
505
+ // the allow-set can never drift behind a newly added context field: the failure
506
+ // mode that flagged a correct "you skipped Hack Squat" note and a note-echoed
507
+ // lift. A genuine invention is a known exercise present in the output but absent
508
+ // from the context entirely.
509
+ function collectContextExerciseNames(context, knownNames) {
510
+ if (!context || typeof context !== 'object') return [];
511
+ let serialized;
512
+ try {
513
+ serialized = JSON.stringify(context);
514
+ } catch {
515
+ return [];
516
+ }
517
+ const contextText = normalizeExerciseName(serialized);
518
+ if (!contextText) return [];
519
+ const matches = [];
520
+ for (const name of knownNames) {
521
+ const normalized = normalizeExerciseName(name);
522
+ if (!normalized) continue;
523
+ const pattern = new RegExp(`(?<!\\S)${escapeRegex(normalized)}(?!\\S)`, 'g');
524
+ for (const match of contextText.matchAll(pattern)) {
525
+ matches.push({
526
+ name,
527
+ normalized,
528
+ start: match.index,
529
+ end: (match.index ?? 0) + normalized.length
530
+ });
531
+ }
532
+ }
533
+ return uniqueStrings(matches
534
+ .filter((match) => !matches.some((candidate) =>
535
+ candidate !== match &&
536
+ candidate.normalized.length > match.normalized.length &&
537
+ candidate.start <= match.start &&
538
+ candidate.end >= match.end
539
+ ))
540
+ .map((match) => match.name));
541
+ }
542
+
402
543
  function historicalExerciseVariants(name) {
403
544
  const normalized = normalizeExerciseName(name);
404
545
  if (!normalized) return [];
@@ -431,9 +572,15 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
431
572
 
432
573
  const outputText = surface === 'scoreCommentary' ? scoreCommentaryText(output) : output;
433
574
  const isStored = testCase.source === 'stored';
575
+ const allNames = collectAllExerciseNames(snapshot);
576
+
577
+ // Union the hand-built field list with a projection of the actual context, so
578
+ // this can only ever *reduce* false positives. testCase.allowedExerciseMentions
579
+ // stays as an explicit override for cases that need it.
434
580
  const allowed = new Set();
435
581
  for (const name of [
436
582
  ...collectAllowedExerciseNames(surface, context),
583
+ ...collectContextExerciseNames(context, allNames),
437
584
  ...(testCase.allowedExerciseMentions ?? [])
438
585
  ]) {
439
586
  const variants = isStored ? historicalExerciseVariants(name) : [normalizeExerciseName(name)];
@@ -441,7 +588,6 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
441
588
  allowed.add(variant);
442
589
  }
443
590
  }
444
- const allNames = collectAllExerciseNames(snapshot);
445
591
  const normalizedOutput = normalizeExerciseName(outputText);
446
592
  const mentions = [];
447
593
 
@@ -463,8 +609,14 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
463
609
 
464
610
  const unauthorized = mentions
465
611
  .filter((mention) => !mention.allowed)
612
+ // Collapse a shorter mention into any longer mention that spans the same
613
+ // text — regardless of whether the covering mention is itself allowed.
614
+ // "Squat" matches inside "Hack Squat" (whitespace word boundary), so without
615
+ // this an unauthorized "Hack Squat" was double-counted as both "Hack Squat"
616
+ // and "Squat". The covering mention carries the real verdict; the substring
617
+ // is never a distinct mention.
466
618
  .filter((mention) => !mentions.some((candidate) =>
467
- candidate.allowed &&
619
+ candidate !== mention &&
468
620
  candidate.normalizedName.length > mention.normalizedName.length &&
469
621
  candidate.start <= mention.start &&
470
622
  candidate.end >= mention.end
@@ -481,11 +633,14 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
481
633
  }
482
634
 
483
635
  function evaluateRequiredMentions(output, testCase) {
484
- const missing = uniqueStrings(testCase.requiredMentions).filter((mention) => !lowerIncludes(output, mention));
636
+ const mentions = Array.isArray(testCase.requiredMentions) ? testCase.requiredMentions : [];
637
+ const missing = mentions.filter((mention) => !mentionMatches(output, mention));
485
638
  return {
486
639
  key: 'required_mentions',
487
640
  passed: missing.length === 0,
488
- reason: missing.length === 0 ? 'All required mentions present.' : `Missing required mention(s): ${missing.join(', ')}`
641
+ reason: missing.length === 0
642
+ ? 'All required mentions present.'
643
+ : `Missing required mention(s): ${missing.map(describeMention).join(', ')}`
489
644
  };
490
645
  }
491
646
 
@@ -499,7 +654,7 @@ function evaluateAnyOfMentions(output, testCase) {
499
654
  };
500
655
  }
501
656
 
502
- const matched = candidates.some((mention) => lowerIncludes(output, mention));
657
+ const matched = candidates.some((mention) => mentionMatches(output, mention));
503
658
  return {
504
659
  key: 'required_any_of_mentions',
505
660
  passed: matched,
@@ -1095,19 +1250,48 @@ function hasAskFatigueSupport(snapshot, lookbackDays = 7) {
1095
1250
  return false;
1096
1251
  }
1097
1252
 
1253
+ function parseWeightNumber(raw) {
1254
+ return Number(String(raw).replace(/,/g, ''));
1255
+ }
1256
+
1098
1257
  function extractAskWeightClaims(text) {
1099
1258
  const claims = [];
1100
- const pattern = /\b(\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\b/gi;
1259
+ // Accept comma-grouped thousands ("40,500 kg") as a single number so volume
1260
+ // figures are not shredded into bogus "500 kg" / "000 kg" claims. Volume/total
1261
+ // figures are excluded by isVolumeWeightClaim at the call sites, not by a
1262
+ // magnitude cap — heavy machine work (leg press, sled) legitimately exceeds
1263
+ // 1000 kg, and a fabricated heavy load must still be graded.
1264
+ const pattern = /\b(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\b/gi;
1101
1265
  for (const match of text.matchAll(pattern)) {
1102
1266
  claims.push({
1103
1267
  text: match[0],
1104
- value: Number(match[1]),
1268
+ value: parseWeightNumber(match[1]),
1105
1269
  index: match.index ?? -1
1106
1270
  });
1107
1271
  }
1108
1272
  return claims;
1109
1273
  }
1110
1274
 
1275
+ function extractAskWeightedSetClaims(text) {
1276
+ const claims = [];
1277
+ // A weight×reps pair is only unambiguous with "x"/"×" (e.g. "70 kg x 5"), or
1278
+ // an explicit "for N rep(s)". Bare "X kg for N" is NOT a rep claim — N is
1279
+ // almost always a SET count ("70 kg for 4 working sets") or a duration, and
1280
+ // treating it as reps flags real data as a fabricated pair. So match only the
1281
+ // unambiguous forms; the plain-weight loop still grounds the weight itself.
1282
+ const pattern = /\b(\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?)\s*(?:kg|kilograms?)\s*(?:(?:x|×)\s*(\d+)|for\s+(\d+)\s*reps?)\b/gi;
1283
+ for (const match of text.matchAll(pattern)) {
1284
+ claims.push({
1285
+ text: match[0],
1286
+ weight: parseWeightNumber(match[1]),
1287
+ reps: Number(match[2] ?? match[3]),
1288
+ index: match.index ?? -1,
1289
+ end: (match.index ?? -1) + match[0].length
1290
+ });
1291
+ }
1292
+ return claims;
1293
+ }
1294
+
1111
1295
  function allowedWeightsForExercise(snapshot, normalizedExerciseName) {
1112
1296
  const weights = [];
1113
1297
  for (const session of snapshot?.sessions ?? []) {
@@ -1143,10 +1327,359 @@ function isEstimatedOneRepMaxWeightClaim(text, claim) {
1143
1327
  }
1144
1328
 
1145
1329
  function isVolumeWeightClaim(text, claim) {
1146
- const start = Math.max(0, claim.index - 30);
1147
- const end = Math.min(text.length, claim.index + claim.text.length + 30);
1148
- const window = text.slice(start, end);
1149
- return /\bvolume\b/i.test(window);
1330
+ // A kg figure in a clause about volume/tonnage/total load is a workload total
1331
+ // (e.g. "weekly strength volume fell from 44,000 kg to 40,500 kg"), not an
1332
+ // exercise load. Scope to the claim's clause so a fabricated exercise load
1333
+ // earlier in the same sentence is still graded.
1334
+ return /\b(?:volume|tonnage|total\s+(?:load|work|volume|tonnage))\b/i.test(claimClause(text, claim));
1335
+ }
1336
+
1337
+ function claimClause(text, claim) {
1338
+ const boundaries = [
1339
+ '\n',
1340
+ '. ',
1341
+ ';',
1342
+ ', while',
1343
+ ', whereas',
1344
+ ', but',
1345
+ ' while ',
1346
+ ' whereas ',
1347
+ ' but '
1348
+ ];
1349
+ let start = 0;
1350
+ for (const boundary of boundaries) {
1351
+ const index = text.lastIndexOf(boundary, claim.index);
1352
+ if (index >= 0) start = Math.max(start, index + boundary.length);
1353
+ }
1354
+
1355
+ let end = text.length;
1356
+ for (const boundary of boundaries) {
1357
+ const index = text.indexOf(boundary, claim.index + claim.text.length);
1358
+ if (index >= 0) end = Math.min(end, index);
1359
+ }
1360
+ return text.slice(start, end);
1361
+ }
1362
+
1363
+ // Returns the sentence containing the claim, so context guards can look at the
1364
+ // whole clause rather than a fixed-width window (body-weight phrasing can put
1365
+ // the "body weight" anchor well before the kg figure).
1366
+ function claimSentence(text, claim) {
1367
+ const before = text.slice(0, claim.index);
1368
+ const startBreak = Math.max(before.lastIndexOf('. '), before.lastIndexOf('\n'));
1369
+ const start = startBreak >= 0 ? startBreak + 1 : 0;
1370
+ const after = text.slice(claim.index);
1371
+ const breaks = [after.indexOf('. '), after.indexOf('\n')].filter((i) => i >= 0);
1372
+ const end = breaks.length ? claim.index + Math.min(...breaks) : text.length;
1373
+ return text.slice(start, end);
1374
+ }
1375
+
1376
+ // Body-weight figures ("body weight is up 0.6 kg", "80.0 kg latest") are not
1377
+ // exercise-load claims. findNearestMentionedExercise would otherwise attribute
1378
+ // them to the previously named lift and flag a correct answer as a
1379
+ // hallucination, so skip any kg figure stated in a body-weight clause.
1380
+ function isBodyWeightClaim(text, claim) {
1381
+ return /\bbody\s*weight\b|\bbodyweight\b|\bweight\s+trend\b/i.test(claimSentence(text, claim));
1382
+ }
1383
+
1384
+ function askWorkingTopSetRows(snapshot) {
1385
+ const rows = [];
1386
+ for (const session of stableSortByDateDesc(snapshot?.sessions ?? [], (session) => session.completedAt ?? session.date)) {
1387
+ const completedAt = session.completedAt ?? session.date;
1388
+ for (const exercise of session.exercises ?? []) {
1389
+ const workingSets = (exercise.sets ?? [])
1390
+ .filter((set) => set?.isComplete && !set?.isWarmup)
1391
+ .map((set) => ({
1392
+ weight: Number(set.weight) || 0,
1393
+ reps: Number(set.reps) || 0
1394
+ }));
1395
+ if (workingSets.length === 0) continue;
1396
+ const topSet = workingSets.sort((a, b) => b.weight - a.weight || b.reps - a.reps)[0];
1397
+ rows.push({
1398
+ sessionId: session.id ?? null,
1399
+ date: String(completedAt ?? '').slice(0, 10),
1400
+ exerciseName: exercise.name,
1401
+ normalizedName: normalizeExerciseName(exercise.name),
1402
+ ...topSet
1403
+ });
1404
+ }
1405
+ }
1406
+ return rows;
1407
+ }
1408
+
1409
+ function daysAgoForEval(date, testCase) {
1410
+ const today = testCase.context?.today ?? testCase.today;
1411
+ if (!today) return null;
1412
+ const dateMs = Date.parse(`${String(date ?? '').slice(0, 10)}T00:00:00.000Z`);
1413
+ const todayMs = Date.parse(`${String(today).slice(0, 10)}T00:00:00.000Z`);
1414
+ if (!Number.isFinite(dateMs) || !Number.isFinite(todayMs)) return null;
1415
+ return Math.max(0, Math.round((todayMs - dateMs) / (24 * 60 * 60 * 1000)));
1416
+ }
1417
+
1418
+ function hasUnqualifiedDeclineLanguage(window) {
1419
+ const text = normalizeText(window);
1420
+ const decline = /\b(drop(?:ped|ping|s)?(?: off)?|drop-off|declin(?:e|ed|ing)|regress(?:ed|ion|ing)?|fell|fall(?:ing)?|decreas(?:e|ed|ing)|lower|worse|slid|slipped)\b/i;
1421
+ if (!decline.test(text)) return false;
1422
+ if (/\b(?:no|not|isn'?t|wasn'?t|without|rather than)\b.{0,45}\b(drop(?:ped|ping|s)?(?: off)?|drop-off|declin(?:e|ed|ing)?|decreas(?:e|ed|ing)?|regress(?:ed|ion|ing)?|fall(?:ing)?|fell|lower|worse|slid|slipped)\b/i.test(text)) return false;
1423
+ if (/\b(?:rep|reps)\b.{0,20}\b(drop(?:ped|ping|s)?(?: off)?|drop-off|slip(?:ped|ping)?|fell|fall(?:ing)?|lower|declin(?:e|ed|ing)?|decreas(?:e|ed|ing)?|worse)\b/i.test(text)) return false;
1424
+ if (/\b(drop(?:ped|ping|s)?(?: off)?|drop-off|slip(?:ped|ping)?|fell|fall(?:ing)?|lower|declin(?:e|ed|ing)?|decreas(?:e|ed|ing)?|worse)\b.{0,20}\b(?:rep|reps)\b/i.test(text)) return false;
1425
+ return true;
1426
+ }
1427
+
1428
+ function hasUnqualifiedImprovementLanguage(window) {
1429
+ const text = normalizeText(window);
1430
+ const improvement = /\b(improv(?:e|ed|ing|ement)|progress(?:ed|ing)?|stronger|increas(?:e|ed|ing)|moving up|went up|up from|load jump|jumped)\b/i;
1431
+ if (!improvement.test(text)) return false;
1432
+ if (/\b(?:no|not|isn'?t|wasn'?t|without|rather than)\b.{0,35}\b(improv(?:e|ed|ing|ement)?|progress(?:ed|ing)?|stronger|increas(?:e|ed|ing)?|moving up|went up|up from|load jump|jump(?:ed|ing)?)\b/i.test(text)) return false;
1433
+ if (/\b(?:rep|reps)\b.{0,20}\b(improv(?:e|ed|ing|ement)?|increas(?:e|ed|ing)?|better|moving up|went up|up from|load jump|jump(?:ed|ing)?)\b/i.test(text)) return false;
1434
+ if (/\b(improv(?:e|ed|ing|ement)?|increas(?:e|ed|ing)?|better|moving up|went up|up from|load jump|jump(?:ed|ing)?)\b.{0,20}\b(?:rep|reps)\b/i.test(text)) return false;
1435
+ return true;
1436
+ }
1437
+
1438
+ function isReferentialDirectionContinuation(sentence) {
1439
+ return /^(?:that|this|it|there|still|the\s+(?:latest|top)|top\s+set|same\s+load)\b/i.test(sentence);
1440
+ }
1441
+
1442
+ function directionEvaluationWindows(outputText, exerciseName, exerciseNames = []) {
1443
+ const normalizedExercise = normalizeExerciseName(exerciseName);
1444
+ const otherExercises = [...new Set(exerciseNames.map(normalizeExerciseName))]
1445
+ .filter((name) => name && name !== normalizedExercise);
1446
+ const sentences = outputText
1447
+ .split(/(?<=[.!?])\s+/)
1448
+ .map((sentence) => sentence.trim())
1449
+ .filter(Boolean);
1450
+ if (!normalizedExercise) return sentences;
1451
+ const windows = [];
1452
+ for (let index = 0; index < sentences.length; index++) {
1453
+ if (!normalizeExerciseName(sentences[index]).includes(normalizedExercise)) continue;
1454
+ windows.push(sentences[index]);
1455
+ for (let nextIndex = index + 1; nextIndex < sentences.length; nextIndex++) {
1456
+ const normalizedNext = normalizeExerciseName(sentences[nextIndex]);
1457
+ if (otherExercises.some((name) => normalizedNext.includes(name))) break;
1458
+ if (!isReferentialDirectionContinuation(sentences[nextIndex])) break;
1459
+ windows.push(sentences[nextIndex]);
1460
+ }
1461
+ }
1462
+ return windows.length > 0 ? [...new Set(windows)] : [outputText];
1463
+ }
1464
+
1465
+ function evaluateAskDirectionalConsistency(output, snapshot, testCase) {
1466
+ if (testCase.surface !== 'ask') {
1467
+ return { key: 'ask_directional_consistency', passed: true, reason: 'Not an ask answer.' };
1468
+ }
1469
+
1470
+ const required = Array.isArray(testCase.directionalConsistency)
1471
+ ? testCase.directionalConsistency
1472
+ : [];
1473
+ if (required.length === 0) {
1474
+ return { key: 'ask_directional_consistency', passed: true, reason: 'No directional assertions configured.' };
1475
+ }
1476
+
1477
+ const rows = askWorkingTopSetRows(snapshot);
1478
+ const outputText = normalizeText(output);
1479
+ const failures = [];
1480
+
1481
+ for (const expectation of required) {
1482
+ const normalizedName = normalizeExerciseName(expectation.exercise ?? expectation.exerciseName);
1483
+ const history = rows.filter((row) => row.normalizedName === normalizedName);
1484
+ if (history.length < 2) continue;
1485
+ const latest = history[0];
1486
+ const previous = history[1];
1487
+ const loadDelta = latest.weight - previous.weight;
1488
+ const actualDirection = loadDelta > 0 ? 'up' : loadDelta < 0 ? 'down' : 'flat';
1489
+ const expectedDirection = expectation.loadDirection ?? actualDirection;
1490
+ if (expectedDirection !== actualDirection) {
1491
+ failures.push(`Configured expected direction for ${latest.exerciseName} is ${expectedDirection}, but snapshot top-load direction is ${actualDirection}.`);
1492
+ continue;
1493
+ }
1494
+
1495
+ const windows = directionEvaluationWindows(
1496
+ outputText,
1497
+ expectation.exercise ?? expectation.exerciseName,
1498
+ rows.map((row) => row.exerciseName)
1499
+ );
1500
+ if (actualDirection === 'up' && windows.some(hasUnqualifiedDeclineLanguage)) {
1501
+ failures.push(`Ask answer frames ${latest.exerciseName} as declining/drop-off even though top load increased from ${previous.weight} kg to ${latest.weight} kg.`);
1502
+ }
1503
+ if (actualDirection === 'down' && windows.some(hasUnqualifiedImprovementLanguage)) {
1504
+ failures.push(`Ask answer frames ${latest.exerciseName} as improving even though top load decreased from ${previous.weight} kg to ${latest.weight} kg.`);
1505
+ }
1506
+ if (actualDirection === 'flat' && windows.some((window) => hasUnqualifiedDeclineLanguage(window) || hasUnqualifiedImprovementLanguage(window))) {
1507
+ failures.push(`Ask answer invents a load direction for ${latest.exerciseName}, but top load was flat at ${latest.weight} kg.`);
1508
+ }
1509
+ }
1510
+
1511
+ return {
1512
+ key: 'ask_directional_consistency',
1513
+ passed: failures.length === 0,
1514
+ reason: failures.length === 0
1515
+ ? 'Ask answer does not invert configured load directions.'
1516
+ : failures.join(' ')
1517
+ };
1518
+ }
1519
+
1520
+ // Increment Score component names. Recited with a number, these are the raw
1521
+ // sub-scores the coach-observation-voice spec marks Tier 1 — never surface.
1522
+ const SCORE_COMPONENT_NAMES = ['coverage', 'stimulus', 'execution', 'progression', 'recovery'];
1523
+
1524
+ // A score-like magnitude: 1-3 digits, optional one decimal place.
1525
+ const SCORE_NUMBER = '\\d{1,3}(?:\\.\\d+)?';
1526
+
1527
+ // Contexts that mean the number is real-world data — reps, load, time, counts,
1528
+ // ratios, percentages, the /100 headline — not a raw component sub-score. A
1529
+ // number directly followed by one of these is left alone.
1530
+ const NON_SCORE_UNIT =
1531
+ '(?:kg|kilo|lbs?|pounds?|reps?|sets?|%|percent|pct|x\\b|for\\s+\\d|sessions?|days?|nights?|weeks?|months?|' +
1532
+ 'years?|yrs?|hrs?|hours?|mins?|minutes?|secs?|seconds?|rpe|rir|am|pm|out\\s+of|of\\b|/\\s*\\d)';
1533
+
1534
+ // Heuristic, not a parser. Flags a component name followed — within a short,
1535
+ // period/newline-free gap (one clause) — by a score-like number that is not a
1536
+ // real-world unit. The bounded gap (excludes digits, so it can't skip a unit'd
1537
+ // number) catches the natural phrasings an LLM actually emits — "recovery 35",
1538
+ // "recovery is 35", "recovery is sitting at 35", "recovery came in at 35",
1539
+ // "recovery (35)", "recovery is much lower at 42.8", "coverage 100" — while the
1540
+ // unit lookahead keeps clean prose ("recovery over the last 3 sessions",
1541
+ // "recovery after 3 hours of sleep", "execution at 9/10 RPE") from tripping.
1542
+ const SCORE_COMPONENT_DUMP_PATTERN = new RegExp(
1543
+ // `(?!\\.\\d)` rejects a number that is really the integer part of a decimal —
1544
+ // without it, backtracking matches "2" in "progression of 2.5 kg" (the unit
1545
+ // guard only sees the ".5 kg" tail) and false-flags real load/time data.
1546
+ `\\b(${SCORE_COMPONENT_NAMES.join('|')})\\b[^.\\d\\n]{0,25}?(${SCORE_NUMBER})\\b(?!\\.\\d)(?!\\s*${NON_SCORE_UNIT})`,
1547
+ 'gi'
1548
+ );
1549
+
1550
+ // The other dump the prelude used to emit and the model parroted: an explicit
1551
+ // day-over-day delta number ("-13 day-over-day delta", "down 11 points day over
1552
+ // day"). A bare "down day-over-day" with no number is fine.
1553
+ const SCORE_DELTA_DUMP_PATTERN =
1554
+ /[+-]\d+(?:\.\d+)?[^.\n]{0,16}?day[- ]over[- ]day|(?:\d+(?:\.\d+)?\s*points?)[^.\n]{0,16}?day[- ]over[- ]day|day[- ]over[- ]day[^.\n]{0,16}?(?:[+-]\d+(?:\.\d+)?|\d+(?:\.\d+)?\s*points?)/i;
1555
+
1556
+ export function evaluateAskScoreVoice(output, testCase) {
1557
+ if (testCase.surface !== 'ask') {
1558
+ return { key: 'ask_score_voice', passed: true, reason: 'Not an ask answer.' };
1559
+ }
1560
+ // Escape hatch for cases that legitimately need raw component values
1561
+ // (e.g. an ask case paired with the numbers-only tone).
1562
+ if (testCase.allowScoreComponents === true) {
1563
+ return { key: 'ask_score_voice', passed: true, reason: 'Score-component voice check opted out for this case.' };
1564
+ }
1565
+
1566
+ const text = normalizeText(output);
1567
+ const hits = new Set();
1568
+ for (const match of text.matchAll(SCORE_COMPONENT_DUMP_PATTERN)) {
1569
+ hits.add(`${match[1]} ${match[2]}`);
1570
+ }
1571
+ if (SCORE_DELTA_DUMP_PATTERN.test(text)) {
1572
+ hits.add('day-over-day delta number');
1573
+ }
1574
+
1575
+ return {
1576
+ key: 'ask_score_voice',
1577
+ passed: hits.size === 0,
1578
+ reason: hits.size === 0
1579
+ ? 'Ask answer does not recite raw Increment Score component sub-scores.'
1580
+ : `Ask answer recites raw score internals: ${[...hits].join(', ')}. Speak in training reality, not raw sub-scores.`
1581
+ };
1582
+ }
1583
+
1584
+ function relevantSessionsForStaleness(snapshot, testCase) {
1585
+ const configuredExercise = testCase.staleness?.exercise ?? testCase.staleness?.exerciseName
1586
+ ?? testCase.directionalConsistency?.[0]?.exercise
1587
+ ?? testCase.directionalConsistency?.[0]?.exerciseName
1588
+ ?? null;
1589
+ if (!configuredExercise) return snapshot?.sessions ?? [];
1590
+ const normalized = normalizeExerciseName(configuredExercise);
1591
+ return (snapshot?.sessions ?? []).filter((session) => (
1592
+ (session.exercises ?? []).some((exercise) => normalizeExerciseName(exercise.name) === normalized)
1593
+ ));
1594
+ }
1595
+
1596
+ // The coach IS the coach — it must speak in the first person and never refer to
1597
+ // itself or its own outputs as a third party ("the coach observation says…",
1598
+ // "the system shows…"). Own the observation instead ("I flagged…").
1599
+ const ASK_SELF_REFERENCE_PATTERNS = [
1600
+ /\bthe coach observations?\b/i,
1601
+ /\bthe coach\b/i,
1602
+ /\bthe ai coach\b/i,
1603
+ /\byour coach\b/i,
1604
+ /\bthis coach\b/i,
1605
+ /\bthe system\b/i,
1606
+ /\bthe assistant\b/i
1607
+ ];
1608
+
1609
+ function evaluateAskSelfReference(output, testCase) {
1610
+ if (testCase.surface !== 'ask') {
1611
+ return { key: 'ask_self_reference', passed: true, reason: 'Not an ask answer.' };
1612
+ }
1613
+ const text = normalizeText(output);
1614
+ if (text === 'NO_INSIGHT' || !text) {
1615
+ return { key: 'ask_self_reference', passed: true, reason: 'No answer text.' };
1616
+ }
1617
+ const hits = [];
1618
+ for (const pattern of ASK_SELF_REFERENCE_PATTERNS) {
1619
+ const match = text.match(pattern);
1620
+ if (match) hits.push(match[0]);
1621
+ }
1622
+ const unique = uniqueStrings(hits);
1623
+ return {
1624
+ key: 'ask_self_reference',
1625
+ passed: unique.length === 0,
1626
+ reason: unique.length === 0
1627
+ ? 'Ask answer speaks in the first person.'
1628
+ : `Ask answer refers to itself in the third person: ${unique.join(', ')}. You ARE the coach — own it ("I flagged…", "your data shows…").`
1629
+ };
1630
+ }
1631
+
1632
+ // On a question that is not about the Increment Score, the coach must not
1633
+ // volunteer the bare overall score number (e.g. "your score is 92/100"). The
1634
+ // prelude withholds the number for non-score questions; this guards the answer.
1635
+ function evaluateAskVolunteeredScore(output, testCase) {
1636
+ if (testCase.surface !== 'ask') {
1637
+ return { key: 'ask_volunteered_score', passed: true, reason: 'Not an ask answer.' };
1638
+ }
1639
+ const question = testCase.context?.question ?? testCase.question ?? '';
1640
+ if (isScoreQuestion(question)) {
1641
+ return { key: 'ask_volunteered_score', passed: true, reason: 'Question is about the score; naming it is allowed.' };
1642
+ }
1643
+ const text = normalizeText(output);
1644
+ const volunteered = /\b\d{2,3}\s*\/\s*100\b/.test(text)
1645
+ || /\b(?:increment\s+)?score\s+(?:is|of|at|sits at|currently|was)\b[^.\n]*\b\d{2,3}\b/i.test(text);
1646
+ return {
1647
+ key: 'ask_volunteered_score',
1648
+ passed: !volunteered,
1649
+ reason: volunteered
1650
+ ? 'Ask answer volunteers the overall Increment Score number on a question that was not about the score. Translate it to the limiter instead.'
1651
+ : 'Ask answer does not volunteer the score number unprompted.'
1652
+ };
1653
+ }
1654
+
1655
+ function evaluateAskStaleness(output, snapshot, testCase) {
1656
+ if (testCase.surface !== 'ask') {
1657
+ return { key: 'ask_staleness', passed: true, reason: 'Not an ask answer.' };
1658
+ }
1659
+ const maxRecentDays = testCase.staleness?.maxRecentDays;
1660
+ if (!Number.isFinite(Number(maxRecentDays))) {
1661
+ return { key: 'ask_staleness', passed: true, reason: 'No staleness assertion configured.' };
1662
+ }
1663
+
1664
+ const latestSession = stableSortByDateDesc(relevantSessionsForStaleness(snapshot, testCase), (session) => session.completedAt ?? session.date)[0] ?? null;
1665
+ const daysAgo = daysAgoForEval(latestSession?.completedAt ?? latestSession?.date, testCase);
1666
+ if (daysAgo == null || daysAgo <= Number(maxRecentDays)) {
1667
+ return { key: 'ask_staleness', passed: true, reason: 'Latest session is inside the configured recency window.' };
1668
+ }
1669
+
1670
+ const normalized = normalizeText(output);
1671
+ const claimsRecent = /\brecent(?:ly)?\b/i.test(normalized);
1672
+ const explicitlyNotRecent = /\b(?:not|isn'?t|wasn'?t|no longer)\s+(?:a\s+)?recent\b/i.test(normalized)
1673
+ || /\brecent\b.{0,20}\b(?:not|isn'?t|wasn'?t)\b/i.test(normalized);
1674
+ const includesAge = new RegExp(`\\b${daysAgo}\\s+days?\\s+ago\\b`, 'i').test(normalized);
1675
+ const passed = !claimsRecent || explicitlyNotRecent || includesAge;
1676
+ return {
1677
+ key: 'ask_staleness',
1678
+ passed,
1679
+ reason: passed
1680
+ ? 'Ask answer does not present stale sessions as simply recent.'
1681
+ : `Ask answer calls a ${daysAgo}-day-old session recent without the days-ago label.`
1682
+ };
1150
1683
  }
1151
1684
 
1152
1685
  function evaluateAskClaims(output, snapshot, testCase) {
@@ -1212,6 +1745,7 @@ function evaluateAskClaims(output, snapshot, testCase) {
1212
1745
  for (const claim of extractAskWeightClaims(normalized)) {
1213
1746
  if (isEstimatedOneRepMaxWeightClaim(normalized, claim)) continue;
1214
1747
  if (isVolumeWeightClaim(normalized, claim)) continue;
1748
+ if (isBodyWeightClaim(normalized, claim)) continue;
1215
1749
  const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
1216
1750
  if (!referencedExercise) continue;
1217
1751
  const allowedWeights = allowedWeightsForExercise(snapshot, referencedExercise.normalizedName);
@@ -1234,14 +1768,195 @@ function evaluateAskClaims(output, snapshot, testCase) {
1234
1768
  };
1235
1769
  }
1236
1770
 
1237
- function evaluateAskToolProvenance(output, context, testCase) {
1771
+ function routedToolResultsForEval(snapshot, context) {
1772
+ const routedMetadata = context?.routedMetadata ?? {};
1773
+ const toolParams = routedMetadata.toolParams ?? {};
1774
+ const toolResults = [];
1775
+ const replayFailures = [];
1776
+ for (const toolName of uniqueStrings(routedMetadata.toolsUsed ?? [])) {
1777
+ try {
1778
+ toolResults.push(executeCoachReadTool(snapshot, toolName, toolParams[toolName] ?? {}));
1779
+ } catch (error) {
1780
+ replayFailures.push(`Could not replay routed tool ${toolName}: ${error?.message ?? String(error)}`);
1781
+ }
1782
+ }
1783
+ return { toolResults, replayFailures };
1784
+ }
1785
+
1786
+ function addAskToolEvidenceRow(rows, toolName, row, inherited = {}) {
1787
+ const exerciseName = row?.exerciseName ?? row?.name ?? inherited.exerciseName ?? null;
1788
+ const normalizedName = normalizeExerciseName(exerciseName);
1789
+ if (!normalizedName) return;
1790
+ rows.push({
1791
+ toolName,
1792
+ exerciseName,
1793
+ normalizedName,
1794
+ date: row?.date ?? inherited.date ?? null,
1795
+ daysAgo: row?.daysAgo ?? inherited.daysAgo ?? null,
1796
+ recencyLabel: row?.recencyLabel ?? inherited.recencyLabel ?? null,
1797
+ isStale: row?.isStale ?? inherited.isStale ?? false,
1798
+ recencyCutoffDays: row?.recencyCutoffDays ?? inherited.recencyCutoffDays ?? null,
1799
+ warmupSetCount: row?.warmupSetCount ?? 0,
1800
+ workingSetCount: row?.workingSetCount ?? null,
1801
+ topSet: row?.topSet ?? null,
1802
+ comparedToPreviousSession: row?.comparedToPreviousSession ?? null,
1803
+ sets: Array.isArray(row?.sets) ? row.sets : []
1804
+ });
1805
+ }
1806
+
1807
+ function askToolEvidenceRows(toolResults = []) {
1808
+ const rows = [];
1809
+ for (const toolResult of toolResults) {
1810
+ for (const row of toolResult?.rows ?? []) {
1811
+ if (Array.isArray(row?.exercises)) {
1812
+ for (const exercise of row.exercises) {
1813
+ addAskToolEvidenceRow(rows, toolResult.toolName, exercise, {
1814
+ date: row.date,
1815
+ daysAgo: row.daysAgo,
1816
+ recencyLabel: row.recencyLabel,
1817
+ isStale: row.isStale,
1818
+ recencyCutoffDays: row.recencyCutoffDays
1819
+ });
1820
+ }
1821
+ } else {
1822
+ addAskToolEvidenceRow(rows, toolResult.toolName, row);
1823
+ }
1824
+ }
1825
+ }
1826
+ return rows;
1827
+ }
1828
+
1829
+ function askToolEvidenceWeights(rows = []) {
1830
+ const weights = [];
1831
+ for (const row of rows) {
1832
+ for (const set of row.sets ?? []) {
1833
+ const weight = Number(set.weight);
1834
+ if (Number.isFinite(weight)) weights.push(weight);
1835
+ }
1836
+ const topWeight = Number(row.topSet?.weight);
1837
+ if (Number.isFinite(topWeight)) weights.push(topWeight);
1838
+ const previousTopWeight = Number(row.comparedToPreviousSession?.previousTopSet?.weight);
1839
+ if (Number.isFinite(previousTopWeight)) weights.push(previousTopWeight);
1840
+ }
1841
+ return weights;
1842
+ }
1843
+
1844
+ function askToolEvidenceSetPairs(rows = []) {
1845
+ const pairs = [];
1846
+ for (const row of rows) {
1847
+ for (const set of row.sets ?? []) {
1848
+ const weight = Number(set.weight);
1849
+ const reps = Number(set.reps);
1850
+ if (Number.isFinite(weight) && Number.isFinite(reps)) pairs.push({ weight, reps });
1851
+ }
1852
+ const topWeight = Number(row.topSet?.weight);
1853
+ const topReps = Number(row.topSet?.reps);
1854
+ if (Number.isFinite(topWeight) && Number.isFinite(topReps)) pairs.push({ weight: topWeight, reps: topReps });
1855
+ const previousTopWeight = Number(row.comparedToPreviousSession?.previousTopSet?.weight);
1856
+ const previousTopReps = Number(row.comparedToPreviousSession?.previousTopSet?.reps);
1857
+ if (Number.isFinite(previousTopWeight) && Number.isFinite(previousTopReps)) {
1858
+ pairs.push({ weight: previousTopWeight, reps: previousTopReps });
1859
+ }
1860
+ }
1861
+ return pairs;
1862
+ }
1863
+
1864
+ function toolEvidenceSupportsWeightClaim(claim, rows) {
1865
+ if (weightClaimSupported(claim, askToolEvidenceWeights(rows))) return true;
1866
+ return false;
1867
+ }
1868
+
1869
+ function toolEvidenceSupportsWeightedSetClaim(claim, rows) {
1870
+ if (askToolEvidenceSetPairs(rows).some((pair) => (
1871
+ Math.abs(pair.weight - claim.weight) < 0.01 && pair.reps === claim.reps
1872
+ ))) {
1873
+ return true;
1874
+ }
1875
+ return false;
1876
+ }
1877
+
1878
+ function compareToolEvidenceRecency(lhs, rhs) {
1879
+ const lhsDaysAgo = Number(lhs?.daysAgo);
1880
+ const rhsDaysAgo = Number(rhs?.daysAgo);
1881
+ if (Number.isFinite(lhsDaysAgo) && Number.isFinite(rhsDaysAgo)) return lhsDaysAgo - rhsDaysAgo;
1882
+ if (Number.isFinite(lhsDaysAgo)) return -1;
1883
+ if (Number.isFinite(rhsDaysAgo)) return 1;
1884
+ return String(rhs?.date ?? '').localeCompare(String(lhs?.date ?? ''));
1885
+ }
1886
+
1887
+ function newestToolEvidenceRow(rows = [], predicate = () => true) {
1888
+ return rows
1889
+ .filter(predicate)
1890
+ .sort(compareToolEvidenceRecency)[0] ?? null;
1891
+ }
1892
+
1893
+ function latestComparableToolRow(rows = []) {
1894
+ return newestToolEvidenceRow(rows, (row) => row.comparedToPreviousSession?.loadDirection) ?? null;
1895
+ }
1896
+
1897
+ function isWithinWeightedSetClaim(claim, weightedSetClaims) {
1898
+ return weightedSetClaims.some((setClaim) => claim.index >= setClaim.index && claim.index < setClaim.end);
1899
+ }
1900
+
1901
+ function rowIsStaleForEval(row, testCase) {
1902
+ const daysAgo = Number(row?.daysAgo);
1903
+ const cutoff = Number(testCase.staleness?.maxRecentDays ?? row?.recencyCutoffDays);
1904
+ if (!Number.isFinite(daysAgo) || !Number.isFinite(cutoff)) return Boolean(row?.isStale);
1905
+ return daysAgo > cutoff;
1906
+ }
1907
+
1908
+ function outputCallsStaleEvidenceRecent(outputText, row) {
1909
+ const normalized = normalizeText(outputText);
1910
+ const claimsRecent = /\brecent(?:ly)?\b/i.test(normalized);
1911
+ if (!claimsRecent) return false;
1912
+ const explicitlyNotRecent = /\b(?:not|isn'?t|wasn'?t|no longer)\s+(?:a\s+)?recent\b/i.test(normalized)
1913
+ || /\brecent\b.{0,20}\b(?:not|isn'?t|wasn'?t)\b/i.test(normalized);
1914
+ if (explicitlyNotRecent) return false;
1915
+ const daysAgo = Number(row?.daysAgo);
1916
+ return !Number.isFinite(daysAgo) || !new RegExp(`\\b${daysAgo}\\s+days?\\s+ago\\b`, 'i').test(normalized);
1917
+ }
1918
+
1919
+ function recencyEvaluationWindows(outputText, exerciseName, exerciseNames = []) {
1920
+ const normalizedExercise = normalizeExerciseName(exerciseName);
1921
+ const otherExercises = [...new Set(exerciseNames.map(normalizeExerciseName))]
1922
+ .filter((name) => name && name !== normalizedExercise);
1923
+ const windows = directionEvaluationWindows(outputText, exerciseName, exerciseNames);
1924
+ if (!normalizedExercise) return windows;
1925
+ const scoped = [];
1926
+ for (const window of windows) {
1927
+ const clauses = window
1928
+ .split(/\s*(?:[.;:]|,\s+|\b(?:while|whereas|but|and)\b)\s*/i)
1929
+ .map((clause) => clause.trim())
1930
+ .filter(Boolean);
1931
+ let matched = false;
1932
+ for (let index = 0; index < clauses.length; index++) {
1933
+ if (!normalizeExerciseName(clauses[index]).includes(normalizedExercise)) continue;
1934
+ matched = true;
1935
+ let scopedWindow = clauses[index];
1936
+ for (let nextIndex = index + 1; nextIndex < clauses.length; nextIndex++) {
1937
+ const normalizedNext = normalizeExerciseName(clauses[nextIndex]);
1938
+ if (otherExercises.some((name) => normalizedNext.includes(name))) break;
1939
+ scopedWindow += ` ${clauses[nextIndex]}`;
1940
+ }
1941
+ scoped.push(scopedWindow);
1942
+ }
1943
+ if (!matched) scoped.push(window);
1944
+ }
1945
+ return scoped.length > 0 ? [...new Set(scoped)] : windows;
1946
+ }
1947
+
1948
+ function evaluateAskToolProvenance(output, context, testCase, snapshot) {
1238
1949
  if (testCase.surface !== 'ask') {
1239
1950
  return { key: 'ask_tool_provenance', passed: true, reason: 'Not an ask answer.' };
1240
1951
  }
1241
1952
 
1242
1953
  const routedMetadata = context?.routedMetadata ?? {};
1243
1954
  const toolsUsed = new Set(routedMetadata.toolsUsed ?? []);
1244
- const failures = [];
1955
+ const { toolResults, replayFailures } = routedToolResultsForEval(snapshot, context);
1956
+ const evidenceRows = askToolEvidenceRows(toolResults);
1957
+ const mentionedExercises = findMentionedExercises(output, snapshot);
1958
+ const unroutedMentionNames = new Set();
1959
+ const failures = [...replayFailures];
1245
1960
  for (const toolName of uniqueStrings(testCase.requiredTools)) {
1246
1961
  if (!toolsUsed.has(toolName)) {
1247
1962
  failures.push(`Expected routed Ask Coach context to use ${toolName}.`);
@@ -1252,6 +1967,75 @@ function evaluateAskToolProvenance(output, context, testCase) {
1252
1967
  failures.push('Ask answer mentions e1RM/1RM, but routed context did not use get_records.');
1253
1968
  }
1254
1969
 
1970
+ const weightedSetClaims = extractAskWeightedSetClaims(output);
1971
+ for (const claim of weightedSetClaims) {
1972
+ if (isEstimatedOneRepMaxWeightClaim(output, claim)) continue;
1973
+ const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
1974
+ if (!referencedExercise) continue;
1975
+ const rows = evidenceRows.filter((row) => row.normalizedName === referencedExercise.normalizedName);
1976
+ if (rows.length === 0) {
1977
+ unroutedMentionNames.add(referencedExercise.normalizedName);
1978
+ failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but ${referencedExercise.name} was not present in routed tool outputs.`);
1979
+ continue;
1980
+ }
1981
+ if (!toolEvidenceSupportsWeightedSetClaim(claim, rows)) {
1982
+ failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but routed tool outputs for ${referencedExercise.name} did not include that weight/reps pair.`);
1983
+ }
1984
+ }
1985
+
1986
+ for (const claim of extractAskWeightClaims(output)) {
1987
+ if (isWithinWeightedSetClaim(claim, weightedSetClaims)) continue;
1988
+ if (isEstimatedOneRepMaxWeightClaim(output, claim)) continue;
1989
+ if (isVolumeWeightClaim(output, claim)) continue;
1990
+ if (isBodyWeightClaim(output, claim)) continue;
1991
+ const referencedExercise = findNearestMentionedExercise(mentionedExercises, claim.index);
1992
+ if (!referencedExercise) continue;
1993
+ const rows = evidenceRows.filter((row) => row.normalizedName === referencedExercise.normalizedName);
1994
+ if (rows.length === 0) {
1995
+ unroutedMentionNames.add(referencedExercise.normalizedName);
1996
+ failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but ${referencedExercise.name} was not present in routed tool outputs.`);
1997
+ continue;
1998
+ }
1999
+ if (!toolEvidenceSupportsWeightClaim(claim, rows)) {
2000
+ failures.push(`Ask answer asserts ${claim.text} for ${referencedExercise.name}, but routed tool outputs for ${referencedExercise.name} did not include that weight.`);
2001
+ }
2002
+ }
2003
+
2004
+ const exerciseNames = evidenceRows.map((row) => row.exerciseName);
2005
+ for (const mention of mentionedExercises) {
2006
+ const rows = evidenceRows.filter((row) => row.normalizedName === mention.normalizedName);
2007
+ if (rows.length === 0) {
2008
+ if (toolResults.length > 0 && !unroutedMentionNames.has(mention.normalizedName)) {
2009
+ unroutedMentionNames.add(mention.normalizedName);
2010
+ failures.push(`Ask answer mentions ${mention.name}, but ${mention.name} was not present in routed tool outputs.`);
2011
+ }
2012
+ continue;
2013
+ }
2014
+ const comparable = latestComparableToolRow(rows);
2015
+ if (comparable) {
2016
+ const direction = comparable.comparedToPreviousSession.loadDirection;
2017
+ const previous = comparable.comparedToPreviousSession.previousTopSet;
2018
+ const windows = directionEvaluationWindows(output, mention.name, exerciseNames);
2019
+ if (direction === 'up' && windows.some(hasUnqualifiedDeclineLanguage)) {
2020
+ failures.push(`Ask answer frames ${mention.name} as declining/drop-off, but routed ${comparable.toolName} evidence says top load increased from ${previous.weight} kg to ${comparable.topSet.weight} kg.`);
2021
+ }
2022
+ if (direction === 'down' && windows.some(hasUnqualifiedImprovementLanguage)) {
2023
+ failures.push(`Ask answer frames ${mention.name} as improving, but routed ${comparable.toolName} evidence says top load decreased from ${previous.weight} kg to ${comparable.topSet.weight} kg.`);
2024
+ }
2025
+ if (direction === 'flat' && windows.some((window) => hasUnqualifiedDeclineLanguage(window) || hasUnqualifiedImprovementLanguage(window))) {
2026
+ failures.push(`Ask answer invents a load direction for ${mention.name}, but routed ${comparable.toolName} evidence says top load was flat at ${comparable.topSet.weight} kg.`);
2027
+ }
2028
+ }
2029
+
2030
+ const latestDatedRow = newestToolEvidenceRow(rows, (row) => row.daysAgo != null);
2031
+ if (latestDatedRow && rowIsStaleForEval(latestDatedRow, testCase)) {
2032
+ const windows = recencyEvaluationWindows(output, mention.name, exerciseNames);
2033
+ if (windows.some((window) => outputCallsStaleEvidenceRecent(window, latestDatedRow))) {
2034
+ failures.push(`Ask answer calls ${mention.name} recent, but routed tool evidence says the latest relevant session was ${latestDatedRow.daysAgo} days ago.`);
2035
+ }
2036
+ }
2037
+ }
2038
+
1255
2039
  return {
1256
2040
  key: 'ask_tool_provenance',
1257
2041
  passed: failures.length === 0,
@@ -1261,6 +2045,309 @@ function evaluateAskToolProvenance(output, context, testCase) {
1261
2045
  };
1262
2046
  }
1263
2047
 
2048
+ function scoreFormulaEntries(snapshot) {
2049
+ const seen = new Set();
2050
+ return scoreHistoryFromSnapshot(snapshot).filter((entry) => {
2051
+ if (!entry) return false;
2052
+ const key = entry.id ?? entry.snapshotAt;
2053
+ if (key == null) return true;
2054
+ if (seen.has(key)) return false;
2055
+ seen.add(key);
2056
+ return true;
2057
+ });
2058
+ }
2059
+
2060
+ function evaluateFormulaVersion(_output, snapshot, testCase) {
2061
+ const expected = testCase.expectedFormulaVersion ?? testCase.formulaVersion ?? null;
2062
+ if (!expected) {
2063
+ return { key: 'formula_version', passed: true, reason: 'No formula version pin configured.' };
2064
+ }
2065
+
2066
+ const entries = scoreFormulaEntries(snapshot);
2067
+ const missingCount = entries.filter((entry) => !entry?.formulaVersion).length;
2068
+ const versions = uniqueStrings(entries.map((entry) => entry?.formulaVersion));
2069
+ const passed = entries.length > 0 && missingCount === 0 && versions.every((version) => version === expected);
2070
+ return {
2071
+ key: 'formula_version',
2072
+ passed,
2073
+ reason: passed
2074
+ ? `Formula version is pinned to ${expected}.`
2075
+ : missingCount > 0
2076
+ ? `Expected formula version ${expected}, but ${missingCount} score snapshot(s) have no formula version.`
2077
+ : versions.length > 0
2078
+ ? `Expected formula version ${expected}, got ${versions.join(', ')}.`
2079
+ : `Expected formula version ${expected}, but snapshot has no increment score formula version.`
2080
+ };
2081
+ }
2082
+
2083
+ function arrayContainsAll(actual = [], expected = []) {
2084
+ const actualSet = new Set(actual ?? []);
2085
+ return (expected ?? []).every((item) => actualSet.has(item));
2086
+ }
2087
+
2088
+ function arrayEquals(actual = [], expected = []) {
2089
+ if (!Array.isArray(actual) || !Array.isArray(expected) || actual.length !== expected.length) {
2090
+ return false;
2091
+ }
2092
+ const sortedActual = [...actual].sort();
2093
+ const sortedExpected = [...expected].sort();
2094
+ return sortedActual.every((item, index) => item === sortedExpected[index]);
2095
+ }
2096
+
2097
+ function askObservationCheckMatches(actualCheck, expectedCheck) {
2098
+ return Object.entries(expectedCheck ?? {}).every(([key, value]) => {
2099
+ if (Array.isArray(value)) return arrayContainsAll(actualCheck?.[key], value);
2100
+ return actualCheck?.[key] === value;
2101
+ });
2102
+ }
2103
+
2104
+ function evaluateAskEvidencePlan(_output, context, testCase) {
2105
+ if (testCase.surface !== 'ask') {
2106
+ return { key: 'ask_evidence_plan', passed: true, reason: 'Not an ask answer.' };
2107
+ }
2108
+
2109
+ const expected = testCase.expectedEvidencePlan ?? null;
2110
+ if (!expected) {
2111
+ return { key: 'ask_evidence_plan', passed: true, reason: 'No evidence plan assertion configured.' };
2112
+ }
2113
+
2114
+ const plan = context?.routedMetadata?.evidencePlan ?? null;
2115
+ const failures = [];
2116
+ if (!plan) {
2117
+ failures.push('Routed Ask context did not expose metadata.evidencePlan.');
2118
+ } else {
2119
+ for (const key of ['route', 'effectiveRoute', 'fallbackRoute']) {
2120
+ if (key in expected && plan[key] !== expected[key]) {
2121
+ failures.push(`Expected evidencePlan.${key}=${expected[key] ?? 'null'}, got ${plan[key] ?? 'null'}.`);
2122
+ }
2123
+ }
2124
+
2125
+ for (const key of ['requiredTools', 'optionalTools', 'executedTools', 'evidenceGaps']) {
2126
+ if (Array.isArray(expected[key]) && !arrayEquals(plan[key], expected[key])) {
2127
+ failures.push(`Expected evidencePlan.${key} to equal ${expected[key].join(', ')}; got ${(plan[key] ?? []).join(', ')}.`);
2128
+ }
2129
+ }
2130
+
2131
+ if (Array.isArray(expected.excludedExecutedTools)) {
2132
+ const executed = new Set(plan.executedTools ?? []);
2133
+ const hits = expected.excludedExecutedTools.filter((toolName) => executed.has(toolName));
2134
+ if (hits.length > 0) {
2135
+ failures.push(`Expected evidencePlan.executedTools to exclude ${hits.join(', ')}.`);
2136
+ }
2137
+ }
2138
+
2139
+ for (const expectedCheck of expected.observationChecks ?? []) {
2140
+ const matched = (plan.observationChecks ?? []).some((actualCheck) => askObservationCheckMatches(actualCheck, expectedCheck));
2141
+ if (!matched) {
2142
+ failures.push(`Expected observation check ${JSON.stringify(expectedCheck)}; got ${JSON.stringify(plan.observationChecks ?? [])}.`);
2143
+ }
2144
+ }
2145
+ if (Array.isArray(expected.observationChecks) && (plan.observationChecks ?? []).length !== expected.observationChecks.length) {
2146
+ failures.push(`Expected ${expected.observationChecks.length} observation check(s), got ${(plan.observationChecks ?? []).length}.`);
2147
+ }
2148
+ }
2149
+
2150
+ return {
2151
+ key: 'ask_evidence_plan',
2152
+ passed: failures.length === 0,
2153
+ reason: failures.length === 0
2154
+ ? 'Ask evidence plan matches configured assertions.'
2155
+ : failures.join(' ')
2156
+ };
2157
+ }
2158
+
2159
+ function askMetadataObservationReferences(metadata) {
2160
+ const references = new Set([
2161
+ ...(metadata?.includedCoachObservationIds ?? []),
2162
+ ...(metadata?.coachObservationIds ?? [])
2163
+ ]);
2164
+ for (const comparison of metadata?.sessionObservationComparisons ?? []) {
2165
+ if (comparison?.observationId) references.add(comparison.observationId);
2166
+ }
2167
+ for (const item of metadata?.provenance ?? []) {
2168
+ for (const sourceId of item?.sourceIds ?? []) {
2169
+ references.add(sourceId);
2170
+ }
2171
+ }
2172
+ return references;
2173
+ }
2174
+
2175
+ function evaluateAskMetadata(output, context, testCase) {
2176
+ if (testCase.surface !== 'ask') {
2177
+ return { key: 'ask_metadata', passed: true, reason: 'Not an ask answer.' };
2178
+ }
2179
+
2180
+ const expected = testCase.expectedMetadata ?? null;
2181
+ if (!expected) {
2182
+ return { key: 'ask_metadata', passed: true, reason: 'No Ask metadata assertion configured.' };
2183
+ }
2184
+
2185
+ const metadata = context?.routedMetadata ?? {};
2186
+ const failures = [];
2187
+
2188
+ if (Array.isArray(expected.includedCoachObservationIds)) {
2189
+ const included = new Set(metadata.includedCoachObservationIds ?? []);
2190
+ const missing = expected.includedCoachObservationIds.filter((id) => !included.has(id));
2191
+ if (missing.length > 0) {
2192
+ failures.push(`Expected included coach observation id(s): ${missing.join(', ')}.`);
2193
+ }
2194
+ }
2195
+
2196
+ if (Array.isArray(expected.excludedCoachObservationIds)) {
2197
+ const references = askMetadataObservationReferences(metadata);
2198
+ const hits = expected.excludedCoachObservationIds.filter((id) => references.has(id));
2199
+ if (hits.length > 0) {
2200
+ failures.push(`Expected coach observation id(s) to be excluded from rendered metadata: ${hits.join(', ')}.`);
2201
+ }
2202
+ }
2203
+
2204
+ if (Array.isArray(expected.forbiddenObservationPhrases)) {
2205
+ const hits = uniqueStrings(expected.forbiddenObservationPhrases).filter((phrase) => phraseIncludes(output, phrase));
2206
+ if (hits.length > 0) {
2207
+ failures.push(`Dismissed or excluded observation phrase(s) leaked into Ask answer: ${hits.join(', ')}.`);
2208
+ }
2209
+ }
2210
+
2211
+ return {
2212
+ key: 'ask_metadata',
2213
+ passed: failures.length === 0,
2214
+ reason: failures.length === 0
2215
+ ? 'Ask metadata matches configured assertions.'
2216
+ : failures.join(' ')
2217
+ };
2218
+ }
2219
+
2220
+ function normalizedStructuredText(value) {
2221
+ return String(value ?? '')
2222
+ .toLowerCase()
2223
+ .replace(/[^a-z0-9]+/g, ' ')
2224
+ .replace(/\b(my|the|a|an)\b/g, ' ')
2225
+ .replace(/\s+/g, ' ')
2226
+ .trim();
2227
+ }
2228
+
2229
+ function structuredStringArray(value) {
2230
+ return Array.isArray(value)
2231
+ ? value.map((item) => String(item ?? '').trim()).filter(Boolean)
2232
+ : [];
2233
+ }
2234
+
2235
+ function structuredObjectStringArray(items, key) {
2236
+ return Array.isArray(items)
2237
+ ? items.map((item) => String(item?.[key] ?? '').trim()).filter(Boolean)
2238
+ : [];
2239
+ }
2240
+
2241
+ function requireStructuredStrings(actual, expected, label, failures) {
2242
+ if (!Array.isArray(expected)) return;
2243
+ const actualSet = new Set(structuredStringArray(actual));
2244
+ const missing = expected.filter((item) => !actualSet.has(item));
2245
+ if (missing.length > 0) {
2246
+ failures.push(`Expected structured ${label}: ${missing.join(', ')}.`);
2247
+ }
2248
+ }
2249
+
2250
+ function forbidStructuredSuggestions(actual, forbidden, failures) {
2251
+ if (!Array.isArray(forbidden)) return;
2252
+ const normalizedActual = new Set(structuredStringArray(actual).map(normalizedStructuredText).filter(Boolean));
2253
+ const hits = forbidden.filter((item) => normalizedActual.has(normalizedStructuredText(item)));
2254
+ if (hits.length > 0) {
2255
+ failures.push(`Forbidden follow-up suggestion(s) present: ${hits.join(', ')}.`);
2256
+ }
2257
+ }
2258
+
2259
+ function evaluateAskStructuredResponse(_output, context, testCase, structured) {
2260
+ if (testCase.surface !== 'ask') {
2261
+ return { key: 'ask_structured_response', passed: true, reason: 'Not an ask answer.' };
2262
+ }
2263
+
2264
+ const expected = testCase.expectedStructuredResponse ?? null;
2265
+ if (!expected) {
2266
+ return { key: 'ask_structured_response', passed: true, reason: 'No structured response assertion configured.' };
2267
+ }
2268
+
2269
+ const failures = [];
2270
+ if (!structured || typeof structured !== 'object' || Array.isArray(structured)) {
2271
+ failures.push('Ask structured response was not generated.');
2272
+ } else {
2273
+ if (expected.confidence && structured.confidence !== expected.confidence) {
2274
+ failures.push(`Expected structured confidence ${expected.confidence}, got ${structured.confidence ?? 'null'}.`);
2275
+ }
2276
+
2277
+ requireStructuredStrings(
2278
+ structuredObjectStringArray(structured.evidenceUsed, 'toolName'),
2279
+ expected.requiredEvidenceTools,
2280
+ 'evidence tool(s)',
2281
+ failures
2282
+ );
2283
+ requireStructuredStrings(
2284
+ structuredObjectStringArray(structured.evidenceUsed, 'label'),
2285
+ expected.requiredEvidenceLabels,
2286
+ 'evidence label(s)',
2287
+ failures
2288
+ );
2289
+ requireStructuredStrings(
2290
+ structuredObjectStringArray(structured.recommendedActions, 'label'),
2291
+ expected.requiredRecommendedActionLabels,
2292
+ 'recommended action label(s)',
2293
+ failures
2294
+ );
2295
+ requireStructuredStrings(
2296
+ structured.followUpSuggestions,
2297
+ expected.requiredFollowUpSuggestions,
2298
+ 'follow-up suggestion(s)',
2299
+ failures
2300
+ );
2301
+ requireStructuredStrings(
2302
+ structured.limitations,
2303
+ expected.requiredLimitations,
2304
+ 'limitation(s)',
2305
+ failures
2306
+ );
2307
+ forbidStructuredSuggestions(structured.followUpSuggestions, expected.forbiddenFollowUpSuggestions, failures);
2308
+
2309
+ const followUps = structuredStringArray(structured.followUpSuggestions);
2310
+ const normalizedFollowUps = followUps.map(normalizedStructuredText).filter(Boolean);
2311
+ const duplicateCount = normalizedFollowUps.length - new Set(normalizedFollowUps).size;
2312
+ if (duplicateCount > 0) {
2313
+ failures.push('Structured follow-up suggestions must be unique.');
2314
+ }
2315
+
2316
+ const normalizedQuestion = normalizedStructuredText(context?.question ?? testCase.context?.question ?? testCase.question ?? '');
2317
+ if (normalizedQuestion && normalizedFollowUps.includes(normalizedQuestion)) {
2318
+ failures.push('Structured follow-up suggestions must not repeat the current user question.');
2319
+ }
2320
+
2321
+ if (Number.isFinite(expected.maxFollowUpSuggestions) && followUps.length > expected.maxFollowUpSuggestions) {
2322
+ failures.push(`Expected at most ${expected.maxFollowUpSuggestions} follow-up suggestion(s), got ${followUps.length}.`);
2323
+ }
2324
+ if (Number.isFinite(expected.minFollowUpSuggestions) && followUps.length < expected.minFollowUpSuggestions) {
2325
+ failures.push(`Expected at least ${expected.minFollowUpSuggestions} follow-up suggestion(s), got ${followUps.length}.`);
2326
+ }
2327
+
2328
+ if (typeof expected.programDraftPresent === 'boolean') {
2329
+ const hasProgramDraft = structured.programDraft != null;
2330
+ if (hasProgramDraft !== expected.programDraftPresent) {
2331
+ failures.push(`Expected programDraft present=${expected.programDraftPresent}, got ${hasProgramDraft}.`);
2332
+ }
2333
+ }
2334
+ }
2335
+
2336
+ return {
2337
+ key: 'ask_structured_response',
2338
+ passed: failures.length === 0,
2339
+ reason: failures.length === 0
2340
+ ? 'Ask structured response matches configured assertions.'
2341
+ : failures.join(' ')
2342
+ };
2343
+ }
2344
+
2345
+ function askStructuredProgramDraft(parsedAsk, routingMetadata) {
2346
+ const shouldSuppressDraft = routingMetadata?.requestedCoachObservationIntent === 'successor_plan'
2347
+ && routingMetadata?.coachObservationFollowUpMissing === true;
2348
+ return shouldSuppressDraft ? undefined : parsedAsk?.programDraft;
2349
+ }
2350
+
1264
2351
  function firstAction(payload) {
1265
2352
  const actions = Array.isArray(payload?.recommendedNextActions) ? payload.recommendedNextActions : [];
1266
2353
  return actions.find((action) => typeof action?.action === 'string' && action.action.trim());
@@ -1489,17 +2576,20 @@ function evaluatePersonaMotivation(output, context, testCase) {
1489
2576
  failures.push('Feedback acknowledges a PR/positive result but frames later-set dropoff in a demotivating way.');
1490
2577
  }
1491
2578
 
2579
+ const dataLimitationCaveat = /\bnot enough\s+(?:\w+\s+){0,5}?(?:data|details?|context|evidence|information|info|signals?|metrics?)\b/i.test(normalized)
2580
+ || /\bnot enough\s+(?:\w+\s+){0,8}?to\s+(?:separate|infer|tie|connect|attribute|blame|claim|say|show|prove|know|call)\b/i.test(normalized);
2581
+
1492
2582
  const discouragingPatterns = [
1493
2583
  /\bdisappointing\b/i,
1494
2584
  /\bunderwhelming\b/i,
1495
2585
  /\bunderperformed\b/i,
1496
2586
  /\bpoor\b/i,
1497
- /\bnot enough\b/i,
2587
+ /\bnot enough\s+(?:effort|work|volume|intensity|reps?|sets?|weight|load|progress|consistency)\b/i,
1498
2588
  /\bfailed to\b/i,
1499
2589
  /\bstruggled\b/i
1500
2590
  ];
1501
2591
 
1502
- if (discouragingPatterns.some((pattern) => pattern.test(normalized))) {
2592
+ if (!dataLimitationCaveat && discouragingPatterns.some((pattern) => pattern.test(normalized))) {
1503
2593
  failures.push('Feedback uses discouraging language that is likely to reduce motivation.');
1504
2594
  }
1505
2595
 
@@ -1517,6 +2607,33 @@ export async function runSummaryEvalCase(testCase) {
1517
2607
  return runSummaryEvalCaseFromSnapshot(testCase, snapshot);
1518
2608
  }
1519
2609
 
2610
+ // When an ask answer emits a <program_draft> block, it must be valid JSON in the
2611
+ // exact Program shape (enums, limits, no forbidden keys) — validated by the same
2612
+ // normalizer the runtime uses to accept/drop drafts. Catches malformed drafts in
2613
+ // CI instead of silently dropping them in prod. No block = nothing to check.
2614
+ function evaluateProgramDraft(output, testCase, parsedAsk = null) {
2615
+ if (testCase.surface !== 'ask') {
2616
+ return { key: 'program_draft', passed: true, reason: 'Not an ask answer.' };
2617
+ }
2618
+ if (!hasProgramDraftBlock(output)) {
2619
+ return { key: 'program_draft', passed: true, reason: 'No program draft block.' };
2620
+ }
2621
+ // Validate against the EXACT runtime rules — the runtime passes
2622
+ // canonicalExerciseName, which strips non-alphanumerics; without it the eval
2623
+ // would green-light drafts (e.g. punctuation-only names) that prod silently drops.
2624
+ const { programDraft } = parsedAsk ?? extractAskProgramDraft(output, {
2625
+ canonicalizeExerciseName: canonicalExerciseName,
2626
+ strict: true
2627
+ });
2628
+ return {
2629
+ key: 'program_draft',
2630
+ passed: programDraft != null,
2631
+ reason: programDraft != null
2632
+ ? 'Program draft is valid JSON matching the required shape.'
2633
+ : 'Program draft block is malformed (invalid JSON, or fails shape/enum/limit validation).'
2634
+ };
2635
+ }
2636
+
1520
2637
  export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
1521
2638
  const context = buildSummaryEvalContext(snapshot, testCase);
1522
2639
  if (context == null) {
@@ -1527,24 +2644,53 @@ export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
1527
2644
  throw new Error(`Eval case ${testCase.id} produced an empty output`);
1528
2645
  }
1529
2646
 
2647
+ // strict: eval rejects a draft with any malformed nested item (the runtime
2648
+ // salvages it, but partial malformation is a regression signal). The parsed
2649
+ // result also feeds <program_draft> stripping for the other checks.
2650
+ const parsedAsk = testCase.surface === 'ask'
2651
+ ? extractAskProgramDraft(output, { canonicalizeExerciseName: canonicalExerciseName, strict: true })
2652
+ : null;
2653
+ const structuredParsedAsk = testCase.surface === 'ask'
2654
+ ? extractAskProgramDraft(output, { canonicalizeExerciseName: canonicalExerciseName })
2655
+ : null;
2656
+ const visibleOutput = parsedAsk
2657
+ ? stripXMLTagBlocks(parsedAsk.answerText)
2658
+ : output;
2659
+ const structuredAsk = testCase.surface === 'ask'
2660
+ ? buildAskStructuredResponse(visibleOutput, context.routedMetadata ?? {}, {
2661
+ programDraft: askStructuredProgramDraft(structuredParsedAsk, context.routedMetadata),
2662
+ question: context.question ?? testCase.context?.question ?? testCase.question ?? ''
2663
+ })
2664
+ : null;
2665
+
1530
2666
  const checks = [
1531
- evaluateNoInsight(output, testCase),
1532
- evaluateShape(output, testCase),
1533
- evaluateRequiredMentions(output, testCase),
1534
- evaluateAnyOfMentions(output, testCase),
1535
- evaluateForbiddenPhrases(output, testCase),
1536
- evaluateForbiddenMentions(output, testCase),
1537
- evaluateExerciseMentions(output, snapshot, context, testCase.surface, testCase),
1538
- evaluateWorkoutClaims(output, context, testCase),
1539
- evaluateAskClaims(output, snapshot, testCase),
1540
- evaluateAskToolProvenance(output, context, testCase),
1541
- evaluateScoreCommentaryAction(output, context, testCase),
1542
- evaluateScoreCommentarySynthesis(output, context, testCase),
1543
- evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase),
1544
- evaluateScoreCommentaryBand(output, context, testCase),
1545
- evaluateScoreCommentaryTone(output, testCase),
1546
- evaluateScoreCommentaryLength(output, testCase),
1547
- evaluatePersonaMotivation(output, context, testCase)
2667
+ evaluateNoInsight(visibleOutput, testCase),
2668
+ evaluateShape(visibleOutput, testCase),
2669
+ evaluateRequiredMentions(visibleOutput, testCase),
2670
+ evaluateAnyOfMentions(visibleOutput, testCase),
2671
+ evaluateForbiddenPhrases(visibleOutput, testCase),
2672
+ evaluateForbiddenMentions(visibleOutput, testCase),
2673
+ evaluateExerciseMentions(visibleOutput, snapshot, context, testCase.surface, testCase),
2674
+ evaluateWorkoutClaims(visibleOutput, context, testCase),
2675
+ evaluateAskClaims(visibleOutput, snapshot, testCase),
2676
+ evaluateAskDirectionalConsistency(visibleOutput, snapshot, testCase),
2677
+ evaluateAskScoreVoice(visibleOutput, testCase),
2678
+ evaluateAskSelfReference(visibleOutput, testCase),
2679
+ evaluateAskVolunteeredScore(visibleOutput, testCase),
2680
+ evaluateAskStaleness(visibleOutput, snapshot, testCase),
2681
+ evaluateAskToolProvenance(visibleOutput, context, testCase, snapshot),
2682
+ evaluateFormulaVersion(visibleOutput, snapshot, testCase),
2683
+ evaluateAskEvidencePlan(visibleOutput, context, testCase),
2684
+ evaluateAskMetadata(visibleOutput, context, testCase),
2685
+ evaluateAskStructuredResponse(visibleOutput, context, testCase, structuredAsk),
2686
+ evaluateScoreCommentaryAction(visibleOutput, context, testCase),
2687
+ evaluateScoreCommentarySynthesis(visibleOutput, context, testCase),
2688
+ evaluateScoreCommentaryExerciseInvention(visibleOutput, snapshot, context, testCase),
2689
+ evaluateScoreCommentaryBand(visibleOutput, context, testCase),
2690
+ evaluateScoreCommentaryTone(visibleOutput, testCase),
2691
+ evaluateScoreCommentaryLength(visibleOutput, testCase),
2692
+ evaluatePersonaMotivation(visibleOutput, context, testCase),
2693
+ evaluateProgramDraft(output, testCase, parsedAsk)
1548
2694
  ];
1549
2695
 
1550
2696
  return {
@@ -1567,12 +2713,14 @@ export async function runSummaryEvalCaseFromSnapshot(testCase, snapshot) {
1567
2713
  return evaluateSummaryOutputFromSnapshot(testCase, snapshot, output);
1568
2714
  }
1569
2715
 
1570
- function genericForbiddenPhrasesForSurface(surface) {
2716
+ export function genericForbiddenPhrasesForSurface(surface) {
1571
2717
  switch (surface) {
1572
2718
  case 'workout':
1573
2719
  return ['solid progress', 'trust the process', 'keep it up', 'quality work', 'in a great place', 'continue progressive overload', 'as fatigue accumulates'];
1574
2720
  case 'cycle':
1575
- return ['solid progress', 'trust the process', 'in a great place', 'continue progressive overload', 'as fatigue accumulates', 'solid session', 'quality work'];
2721
+ // 'solid first week' enforces the FIRST_WEEK_CYCLE_PROMPT's "do not say
2722
+ // solid first week" rule, which was previously prompt-only (unguarded).
2723
+ return ['solid progress', 'trust the process', 'in a great place', 'continue progressive overload', 'as fatigue accumulates', 'solid session', 'quality work', 'solid first week'];
1576
2724
  case 'checkpoint':
1577
2725
  return ['solid progress', 'quality work', 'trust the process', 'in a great place'];
1578
2726
  case 'vitals':