modelrig 0.5.0 → 0.6.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.
Files changed (159) hide show
  1. package/CHANGELOG.md +109 -0
  2. package/README.md +52 -3
  3. package/dist/adapters/anthropic.d.ts.map +1 -1
  4. package/dist/adapters/anthropic.js +36 -7
  5. package/dist/adapters/attempt-meta-map.d.ts +63 -0
  6. package/dist/adapters/attempt-meta-map.d.ts.map +1 -0
  7. package/dist/adapters/attempt-meta-map.js +154 -0
  8. package/dist/adapters/deepseek.d.ts.map +1 -1
  9. package/dist/adapters/deepseek.js +25 -2
  10. package/dist/adapters/gemini.d.ts.map +1 -1
  11. package/dist/adapters/gemini.js +29 -2
  12. package/dist/adapters/grok.d.ts.map +1 -1
  13. package/dist/adapters/grok.js +25 -2
  14. package/dist/adapters/openai-compat.d.ts.map +1 -1
  15. package/dist/adapters/openai-compat.js +25 -2
  16. package/dist/adapters/openai-stream.d.ts +2 -0
  17. package/dist/adapters/openai-stream.d.ts.map +1 -1
  18. package/dist/adapters/openai-stream.js +23 -3
  19. package/dist/adapters/openai.d.ts.map +1 -1
  20. package/dist/adapters/openai.js +36 -6
  21. package/dist/artifacts/artifact.d.ts.map +1 -1
  22. package/dist/artifacts/artifact.js +24 -5
  23. package/dist/artifacts/classification.d.ts +27 -0
  24. package/dist/artifacts/classification.d.ts.map +1 -0
  25. package/dist/artifacts/classification.js +38 -0
  26. package/dist/artifacts/index.d.ts +3 -1
  27. package/dist/artifacts/index.d.ts.map +1 -1
  28. package/dist/artifacts/index.js +10 -1
  29. package/dist/artifacts/run-context.d.ts +15 -3
  30. package/dist/artifacts/run-context.d.ts.map +1 -1
  31. package/dist/artifacts/run-context.js +49 -0
  32. package/dist/artifacts/scrub.d.ts +26 -0
  33. package/dist/artifacts/scrub.d.ts.map +1 -1
  34. package/dist/artifacts/scrub.js +53 -0
  35. package/dist/artifacts/step.d.ts +9 -0
  36. package/dist/artifacts/step.d.ts.map +1 -1
  37. package/dist/artifacts/step.js +9 -0
  38. package/dist/bakeoff/engine.d.ts +46 -4
  39. package/dist/bakeoff/engine.d.ts.map +1 -1
  40. package/dist/bakeoff/engine.js +269 -19
  41. package/dist/bakeoff/eval-cases.d.ts +5 -0
  42. package/dist/bakeoff/eval-cases.d.ts.map +1 -1
  43. package/dist/bakeoff/eval-cases.js +3 -0
  44. package/dist/bakeoff/judge.d.ts +39 -0
  45. package/dist/bakeoff/judge.d.ts.map +1 -0
  46. package/dist/bakeoff/judge.js +102 -0
  47. package/dist/bakeoff/quality.d.ts +44 -0
  48. package/dist/bakeoff/quality.d.ts.map +1 -0
  49. package/dist/bakeoff/quality.js +126 -0
  50. package/dist/bakeoff/reference.d.ts +34 -0
  51. package/dist/bakeoff/reference.d.ts.map +1 -0
  52. package/dist/bakeoff/reference.js +68 -0
  53. package/dist/bakeoff/replay.d.ts +25 -0
  54. package/dist/bakeoff/replay.d.ts.map +1 -1
  55. package/dist/bakeoff/replay.js +48 -1
  56. package/dist/bakeoff/report.d.ts +8 -1
  57. package/dist/bakeoff/report.d.ts.map +1 -1
  58. package/dist/bakeoff/report.js +83 -12
  59. package/dist/bakeoff/samples.d.ts +35 -2
  60. package/dist/bakeoff/samples.d.ts.map +1 -1
  61. package/dist/bakeoff/samples.js +67 -6
  62. package/dist/bakeoff/stats.d.ts +17 -1
  63. package/dist/bakeoff/stats.d.ts.map +1 -1
  64. package/dist/bakeoff/stats.js +67 -0
  65. package/dist/bakeoff/types.d.ts +76 -5
  66. package/dist/bakeoff/types.d.ts.map +1 -1
  67. package/dist/bakeoff/types.js +4 -3
  68. package/dist/cli-query.d.ts +14 -0
  69. package/dist/cli-query.d.ts.map +1 -0
  70. package/dist/cli-query.js +59 -0
  71. package/dist/cli.d.ts +2 -1
  72. package/dist/cli.d.ts.map +1 -1
  73. package/dist/cli.js +26 -23
  74. package/dist/config.d.ts +6 -0
  75. package/dist/config.d.ts.map +1 -1
  76. package/dist/config.js +14 -0
  77. package/dist/detect/detector.d.ts +82 -0
  78. package/dist/detect/detector.d.ts.map +1 -0
  79. package/dist/detect/detector.js +23 -0
  80. package/dist/index.d.ts +19 -14
  81. package/dist/index.d.ts.map +1 -1
  82. package/dist/index.js +38 -11
  83. package/dist/query/analytics.d.ts +84 -0
  84. package/dist/query/analytics.d.ts.map +1 -0
  85. package/dist/query/analytics.js +280 -0
  86. package/dist/query/columns.d.ts +59 -0
  87. package/dist/query/columns.d.ts.map +1 -0
  88. package/dist/query/columns.js +89 -0
  89. package/dist/query/grammar.d.ts +74 -7
  90. package/dist/query/grammar.d.ts.map +1 -1
  91. package/dist/query/grammar.js +179 -71
  92. package/dist/query/saved.d.ts +26 -0
  93. package/dist/query/saved.d.ts.map +1 -0
  94. package/dist/query/saved.js +51 -0
  95. package/dist/routes/loader.js +15 -10
  96. package/dist/routes/objective-loader.d.ts +35 -0
  97. package/dist/routes/objective-loader.d.ts.map +1 -0
  98. package/dist/routes/objective-loader.js +286 -0
  99. package/dist/run/accept.d.ts +43 -0
  100. package/dist/run/accept.d.ts.map +1 -0
  101. package/dist/run/accept.js +65 -0
  102. package/dist/run/cost.d.ts +13 -0
  103. package/dist/run/cost.d.ts.map +1 -1
  104. package/dist/run/cost.js +27 -0
  105. package/dist/run/dispatch.d.ts +24 -1
  106. package/dist/run/dispatch.d.ts.map +1 -1
  107. package/dist/run/dispatch.js +19 -0
  108. package/dist/run/ladder-key.d.ts +4 -0
  109. package/dist/run/ladder-key.d.ts.map +1 -1
  110. package/dist/run/ladder-key.js +3 -0
  111. package/dist/run/quality-gate.d.ts +39 -0
  112. package/dist/run/quality-gate.d.ts.map +1 -0
  113. package/dist/run/quality-gate.js +70 -0
  114. package/dist/run/raw.d.ts.map +1 -1
  115. package/dist/run/raw.js +17 -0
  116. package/dist/run/record-attempt.d.ts.map +1 -1
  117. package/dist/run/record-attempt.js +22 -1
  118. package/dist/run/routing-docs.d.ts.map +1 -1
  119. package/dist/run/routing-docs.js +11 -4
  120. package/dist/run/run.d.ts.map +1 -1
  121. package/dist/run/run.js +40 -36
  122. package/dist/settings/index.d.ts +1 -1
  123. package/dist/settings/index.d.ts.map +1 -1
  124. package/dist/settings/index.js +2 -1
  125. package/dist/settings/task-settings.d.ts +10 -0
  126. package/dist/settings/task-settings.d.ts.map +1 -1
  127. package/dist/settings/task-settings.js +17 -0
  128. package/dist/telemetry/attempt-meta.d.ts +178 -0
  129. package/dist/telemetry/attempt-meta.d.ts.map +1 -0
  130. package/dist/telemetry/attempt-meta.js +319 -0
  131. package/dist/telemetry/exporter.d.ts.map +1 -1
  132. package/dist/telemetry/exporter.js +6 -0
  133. package/dist/telemetry/ingest-protocol.d.ts +11 -0
  134. package/dist/telemetry/ingest-protocol.d.ts.map +1 -1
  135. package/dist/telemetry/ingest-protocol.js +3 -0
  136. package/dist/telemetry/otel-map.d.ts +10 -0
  137. package/dist/telemetry/otel-map.d.ts.map +1 -1
  138. package/dist/telemetry/otel-map.js +64 -0
  139. package/dist/telemetry/store-migrations.d.ts.map +1 -1
  140. package/dist/telemetry/store-migrations.js +15 -0
  141. package/dist/telemetry/store.d.ts.map +1 -1
  142. package/dist/telemetry/store.js +25 -7
  143. package/dist/types/failures-teaching.d.ts.map +1 -1
  144. package/dist/types/failures-teaching.js +20 -0
  145. package/dist/types/failures.d.ts +1 -1
  146. package/dist/types/failures.d.ts.map +1 -1
  147. package/dist/types/failures.js +1 -0
  148. package/dist/types/provider.d.ts +9 -0
  149. package/dist/types/provider.d.ts.map +1 -1
  150. package/dist/types/quality.d.ts +41 -0
  151. package/dist/types/quality.d.ts.map +1 -0
  152. package/dist/types/quality.js +12 -0
  153. package/dist/types/route.d.ts +89 -0
  154. package/dist/types/route.d.ts.map +1 -1
  155. package/dist/types/telemetry.d.ts +13 -0
  156. package/dist/types/telemetry.d.ts.map +1 -1
  157. package/dist/validate-cli.d.ts.map +1 -1
  158. package/dist/validate-cli.js +10 -0
  159. package/package.json +1 -1
@@ -1,25 +1,57 @@
1
1
  "use strict";
2
2
  /**
3
- * The telemetry query grammar — Phase 4 WS5 ("your exhaust is your data").
3
+ * The telemetry query grammar — Phase 4 WS5 ("your exhaust is your data"),
4
+ * extended for WI-1b (the attempt-envelope analytics surface).
5
+ *
4
6
  * A hand-rolled recursive-descent parser over a DELIBERATELY tiny SQL-ish
5
7
  * language:
6
8
  *
7
9
  * SELECT item (, item)* FROM inferences
8
10
  * [WHERE cond (AND cond)*] [GROUP BY col (, col)*] [LIMIT n]
9
11
  * item := col | count(*) | sum(m)|avg(m)|min(m)|max(m)
10
- * col := route|provider|model|ts|failure_class|served_variant|tag.<name>
12
+ * col := route|provider|model|ts|failure_class|served_variant
13
+ * | subject|feature|user|session|cost_center (reserved dims)
14
+ * | meta.<field> (attempt envelope)
15
+ * | tag.<name>
11
16
  * m := cost|tokens_in|tokens_out|tokens_cached|latency_ms|ttfb_ms
17
+ * | meta.http_status|meta.reasoning_tokens|meta.response_chars
18
+ * | meta.cost.in|meta.cost.out|meta.cost.cache_read|meta.cost.cache_write|meta.cost.other
12
19
  * cond := col op literal op := = != > < >= <=
13
20
  *
14
- * Security model = the allowlist: unknown tokens throw, string literals
15
- * only ever travel as bind parameters, the output is a single read-only
16
- * SELECT. There is no raw-SQL path — by construction, not by filtering.
17
- * Row scoping: a token's scope is ANDed into WHERE as one more bind
18
- * parameter; the caller cannot opt out of it.
21
+ * ── TWO EXECUTION TARGETS, ONE PARSER (S1: extend, don't twin) ────────────
22
+ * `parseQuery` produces a {@link QueryAst}. `compileQuery` renders that AST to a
23
+ * single read-only SQLite SELECT (the local `/query` surface, `runTelemetryQuery`).
24
+ * The server analytics surface (`/v1/analytics/query`) evaluates the SAME AST
25
+ * over PostgREST-read rows in JS (query/analytics.ts) — so the grammar, its
26
+ * SQLite semantics, and its multi-tenant semantics can never disagree.
27
+ *
28
+ * Security model = the allowlist (columns.ts): unknown tokens throw, string
29
+ * literals only ever travel as bind parameters, the SQLite output is a single
30
+ * read-only SELECT. There is no raw-SQL path — by construction, not by filtering.
31
+ * The metadata-safe invariant is structural: every selectable column is an
32
+ * enum/number/boolean/hash/identifier/timestamp (columns.ts), never content.
19
33
  */
20
34
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.QueryGrammarError = void 0;
35
+ exports.QueryGrammarError = exports.RESERVED_DIMENSION_NAMES = exports.METRIC_COLUMNS = exports.DIMENSION_COLUMNS = exports.sinceIsoFor = exports.savedQueryById = exports.SAVED_QUERIES = exports.selectableColumnsAreMetaSafe = exports.ANALYTICS_SCAN_CAP = exports.planAnalyticsRead = exports.evaluateAnalyticsQuery = exports.describeAnalyticsSchema = void 0;
36
+ exports.parseQuery = parseQuery;
22
37
  exports.compileQuery = compileQuery;
38
+ const columns_1 = require("./columns");
39
+ // Re-surface the analytics + saved-query symbols through this module so the
40
+ // package barrel (index.ts) can name them without a new export line (LOC cap).
41
+ var analytics_1 = require("./analytics");
42
+ Object.defineProperty(exports, "describeAnalyticsSchema", { enumerable: true, get: function () { return analytics_1.describeAnalyticsSchema; } });
43
+ Object.defineProperty(exports, "evaluateAnalyticsQuery", { enumerable: true, get: function () { return analytics_1.evaluateAnalyticsQuery; } });
44
+ Object.defineProperty(exports, "planAnalyticsRead", { enumerable: true, get: function () { return analytics_1.planAnalyticsRead; } });
45
+ Object.defineProperty(exports, "ANALYTICS_SCAN_CAP", { enumerable: true, get: function () { return analytics_1.ANALYTICS_SCAN_CAP; } });
46
+ Object.defineProperty(exports, "selectableColumnsAreMetaSafe", { enumerable: true, get: function () { return analytics_1.selectableColumnsAreMetaSafe; } });
47
+ var saved_1 = require("./saved");
48
+ Object.defineProperty(exports, "SAVED_QUERIES", { enumerable: true, get: function () { return saved_1.SAVED_QUERIES; } });
49
+ Object.defineProperty(exports, "savedQueryById", { enumerable: true, get: function () { return saved_1.savedQueryById; } });
50
+ Object.defineProperty(exports, "sinceIsoFor", { enumerable: true, get: function () { return saved_1.sinceIsoFor; } });
51
+ var columns_2 = require("./columns");
52
+ Object.defineProperty(exports, "DIMENSION_COLUMNS", { enumerable: true, get: function () { return columns_2.DIMENSION_COLUMNS; } });
53
+ Object.defineProperty(exports, "METRIC_COLUMNS", { enumerable: true, get: function () { return columns_2.METRIC_COLUMNS; } });
54
+ Object.defineProperty(exports, "RESERVED_DIMENSION_NAMES", { enumerable: true, get: function () { return columns_2.RESERVED_DIMENSION_NAMES; } });
23
55
  class QueryGrammarError extends Error {
24
56
  constructor(message) {
25
57
  super(message);
@@ -27,22 +59,6 @@ class QueryGrammarError extends Error {
27
59
  }
28
60
  }
29
61
  exports.QueryGrammarError = QueryGrammarError;
30
- const PLAIN_COLUMNS = {
31
- route: "route",
32
- provider: "provider",
33
- model: "model",
34
- ts: "ts",
35
- failure_class: "failure_class",
36
- served_variant: "served_variant",
37
- };
38
- const METRICS = {
39
- cost: "cost_estimate_usd",
40
- tokens_in: "tokens_in",
41
- tokens_out: "tokens_out",
42
- tokens_cached: "tokens_cached",
43
- latency_ms: "latency_ms",
44
- ttfb_ms: "ttfb_ms",
45
- };
46
62
  const FUNCTIONS = new Set(["sum", "avg", "min", "max", "count"]);
47
63
  const OPERATORS = new Set(["=", "!=", ">", "<", ">=", "<="]);
48
64
  const TAG_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
@@ -121,23 +137,69 @@ function tokenize(input) {
121
137
  }
122
138
  return tokens;
123
139
  }
124
- /** Column reference → SQL expression. tag.<name> compiles to a
125
- * json_extract over tags_json with a VALIDATED identifier (never
126
- * interpolated user text beyond the identifier alphabet). */
127
- function columnSql(word) {
140
+ /** The alias a column reads out under — `tag.client`/`meta.finish_reason` lose
141
+ * their prefix so result keys read naturally (`client`, `finish_reason`). */
142
+ function aliasFor(word) {
143
+ if (word.startsWith("tag."))
144
+ return word.slice(4);
145
+ if (word.startsWith("meta."))
146
+ return word.slice(5);
147
+ return word;
148
+ }
149
+ /** Resolve a grammar word to a DIMENSION reference (group/filter), or throw. A
150
+ * `tag.<name>` compiles to a validated tags key; every other name must be a
151
+ * registered dimension (columns.ts). */
152
+ function resolveDimension(word) {
128
153
  if (word.startsWith("tag.")) {
129
154
  const tag = word.slice(4);
130
155
  if (!TAG_NAME.test(tag))
131
156
  throw new QueryGrammarError(`bad tag name: ${tag}`);
132
- return `json_extract(tags_json, '$.${tag}')`;
157
+ return { name: word, source: "tag", key: tag, classification: "identifier", numeric: false, alias: tag };
133
158
  }
134
- const column = PLAIN_COLUMNS[word];
135
- if (column === undefined) {
136
- throw new QueryGrammarError(`unknown column "${word}" — allowed: ${Object.keys(PLAIN_COLUMNS).join(", ")}, tag.<name>`);
159
+ const def = columns_1.DIMENSIONS_BY_NAME.get(word);
160
+ if (def === undefined) {
161
+ throw new QueryGrammarError(`unknown column "${word}" — allowed: ${[...columns_1.DIMENSIONS_BY_NAME.keys()].join(", ")}, tag.<name>`);
137
162
  }
138
- return column;
163
+ return refOf(def);
139
164
  }
140
- function compileQuery(input, scope) {
165
+ /** Resolve a grammar word to a METRIC reference (aggregate target), or throw. */
166
+ function resolveMetric(word) {
167
+ const def = columns_1.METRICS_BY_NAME.get(word);
168
+ if (def === undefined) {
169
+ throw new QueryGrammarError(`unknown metric "${word}" — allowed: ${[...columns_1.METRICS_BY_NAME.keys()].join(", ")}`);
170
+ }
171
+ return refOf(def);
172
+ }
173
+ /**
174
+ * Resolve the argument of an aggregate. `sum`/`avg` require a NUMERIC metric.
175
+ * `min`/`max` also accept a dimension (e.g. `min(ts)` for first-seen) — MIN/MAX
176
+ * over an ordered non-numeric column is well-defined (SQL + the JS fold agree).
177
+ */
178
+ function resolveAggArg(fn, word) {
179
+ if (fn === "sum" || fn === "avg")
180
+ return resolveMetric(word);
181
+ const metric = columns_1.METRICS_BY_NAME.get(word);
182
+ if (metric !== undefined)
183
+ return refOf(metric);
184
+ return resolveDimension(word);
185
+ }
186
+ function refOf(def) {
187
+ return {
188
+ name: def.name,
189
+ source: def.source,
190
+ key: def.key,
191
+ classification: def.classification,
192
+ numeric: def.numeric,
193
+ alias: aliasFor(def.name),
194
+ };
195
+ }
196
+ /**
197
+ * Parse a query string into a {@link QueryAst}. Pure and total: every path
198
+ * either returns a validated AST or throws {@link QueryGrammarError}. This is
199
+ * the single parser — both the SQLite compiler (below) and the PostgREST
200
+ * evaluator (analytics.ts) consume its output.
201
+ */
202
+ function parseQuery(input) {
141
203
  const tokens = tokenize(input);
142
204
  let pos = 0;
143
205
  const peek = () => tokens[pos];
@@ -161,7 +223,7 @@ function compileQuery(input, scope) {
161
223
  }
162
224
  }
163
225
  expectWord("select");
164
- const selectSql = [];
226
+ const select = [];
165
227
  for (;;) {
166
228
  const token = next();
167
229
  if (token.kind !== "word")
@@ -175,22 +237,19 @@ function compileQuery(input, scope) {
175
237
  if (arg.kind !== "punct" || arg.text !== "*") {
176
238
  throw new QueryGrammarError("count takes exactly count(*)");
177
239
  }
178
- selectSql.push(`COUNT(*) AS "count"`);
240
+ select.push({ kind: "count", alias: "count" });
179
241
  }
180
242
  else {
181
243
  if (arg.kind !== "word")
182
244
  throw new QueryGrammarError(`bad metric in ${lower}()`);
183
- const metric = METRICS[arg.text.toLowerCase()];
184
- if (metric === undefined) {
185
- throw new QueryGrammarError(`unknown metric "${arg.text}" — allowed: ${Object.keys(METRICS).join(", ")}`);
186
- }
187
- selectSql.push(`${lower.toUpperCase()}(${metric}) AS "${lower}_${arg.text.toLowerCase()}"`);
245
+ const fn = lower;
246
+ const metric = resolveAggArg(fn, arg.text.toLowerCase());
247
+ const aliasBase = metric.alias.replace(/\./g, "_");
248
+ select.push({ kind: "aggregate", fn, metric, alias: `${fn}_${aliasBase}` });
188
249
  }
189
250
  }
190
251
  else {
191
- // tag.client aliases to "client" — result keys read naturally.
192
- const alias = token.text.startsWith("tag.") ? token.text.slice(4) : token.text;
193
- selectSql.push(`${columnSql(token.text)} AS "${alias}"`);
252
+ select.push({ kind: "column", column: resolveDimension(token.text) });
194
253
  }
195
254
  if (peek()?.kind === "punct" && peek()?.text === ",") {
196
255
  pos += 1;
@@ -203,32 +262,35 @@ function compileQuery(input, scope) {
203
262
  if (table.kind !== "word" || table.text.toLowerCase() !== "inferences") {
204
263
  throw new QueryGrammarError(`only "inferences" is queryable, got "${table.text}"`);
205
264
  }
206
- const whereSql = [];
207
- const params = [];
265
+ const where = [];
208
266
  if (peek()?.kind === "word" && peek()?.text.toLowerCase() === "where") {
209
267
  pos += 1;
210
268
  for (;;) {
211
- const column = next();
212
- if (column.kind !== "word")
269
+ const columnTok = next();
270
+ if (columnTok.kind !== "word")
213
271
  throw new QueryGrammarError(`bad condition column`);
214
- const columnExpr = METRICS[column.text.toLowerCase()] !== undefined
215
- ? METRICS[column.text.toLowerCase()]
216
- : columnSql(column.text);
272
+ // A condition may target a metric (cost > 0.01) or a dimension (route = 'x').
273
+ const column = columns_1.METRICS_BY_NAME.has(columnTok.text.toLowerCase())
274
+ ? resolveMetric(columnTok.text.toLowerCase())
275
+ : resolveDimension(columnTok.text);
217
276
  const op = next();
218
277
  if (op.kind !== "punct" || !OPERATORS.has(op.text)) {
219
278
  throw new QueryGrammarError(`bad operator "${op.text}" — allowed: ${[...OPERATORS].join(" ")}`);
220
279
  }
221
280
  const value = next();
222
- if (value.kind === "string") {
223
- params.push(value.text);
224
- }
225
- else if (value.kind === "number") {
226
- params.push(Number(value.text));
227
- }
228
- else {
281
+ let literal;
282
+ if (value.kind === "string")
283
+ literal = value.text;
284
+ else if (value.kind === "number")
285
+ literal = Number(value.text);
286
+ else
229
287
  throw new QueryGrammarError(`condition value must be a 'string' or number, got "${value.text}"`);
288
+ // A `ts` bound is pushed down to PostgREST by the hosted read: validate it
289
+ // here so a bad literal is a 422 teaching error, never an upstream 400.
290
+ if (column.source === "plain" && column.key === "ts" && typeof literal === "string" && Number.isNaN(Date.parse(literal))) {
291
+ throw new QueryGrammarError(`ts bound must be an ISO-8601 timestamp (got '${literal}')`);
230
292
  }
231
- whereSql.push(`${columnExpr} ${op.text} ?`);
293
+ where.push({ column, op: op.text, value: literal });
232
294
  if (peek()?.kind === "word" && peek()?.text.toLowerCase() === "and") {
233
295
  pos += 1;
234
296
  continue;
@@ -236,22 +298,15 @@ function compileQuery(input, scope) {
236
298
  break;
237
299
  }
238
300
  }
239
- // Row scoping — ANDed in unconditionally; the caller cannot remove it.
240
- if (scope.kind === "tag") {
241
- if (!TAG_NAME.test(scope.tag))
242
- throw new QueryGrammarError(`bad scope tag: ${scope.tag}`);
243
- whereSql.push(`json_extract(tags_json, '$.${scope.tag}') = ?`);
244
- params.push(scope.value);
245
- }
246
- const groupSql = [];
301
+ const groupBy = [];
247
302
  if (peek()?.kind === "word" && peek()?.text.toLowerCase() === "group") {
248
303
  pos += 1;
249
304
  expectWord("by");
250
305
  for (;;) {
251
- const column = next();
252
- if (column.kind !== "word")
306
+ const columnTok = next();
307
+ if (columnTok.kind !== "word")
253
308
  throw new QueryGrammarError("bad GROUP BY column");
254
- groupSql.push(columnSql(column.text));
309
+ groupBy.push(resolveDimension(columnTok.text));
255
310
  if (peek()?.kind === "punct" && peek()?.text === ",") {
256
311
  pos += 1;
257
312
  continue;
@@ -271,9 +326,62 @@ function compileQuery(input, scope) {
271
326
  if (pos !== tokens.length) {
272
327
  throw new QueryGrammarError(`unexpected trailing input: "${tokens[pos].text}"`);
273
328
  }
329
+ return { select, where, groupBy, limit };
330
+ }
331
+ // ── SQLite rendering (the local `/query` surface) ────────────────────────────
332
+ /** A column reference → SQLite expression. `meta.<field>` and reserved dims read
333
+ * out of `meta_json` / `tags_json` via json_extract with a VALIDATED path. */
334
+ function sqliteColumnSql(ref) {
335
+ if (ref.source === "tag")
336
+ return `json_extract(tags_json, '$.${ref.key}')`;
337
+ if (ref.source === "meta")
338
+ return `json_extract(meta_json, '$.${ref.key}')`;
339
+ return ref.key;
340
+ }
341
+ /** An aggregate's SQLite argument. A `meta.*` value under a NUMERIC aggregate
342
+ * (`sum`/`avg`) is cast to REAL so the fold is numeric on the json_extract text;
343
+ * `min`/`max` over an ordered column (ts, a snapshot id) keep the raw expression
344
+ * so the comparison stays lexicographic — matching the JS fold. */
345
+ function sqliteAggArgSql(fn, ref) {
346
+ if ((fn === "sum" || fn === "avg") && ref.source === "meta") {
347
+ return `CAST(json_extract(meta_json, '$.${ref.key}') AS REAL)`;
348
+ }
349
+ return sqliteColumnSql(ref);
350
+ }
351
+ /**
352
+ * Compile a query string to a single read-only SQLite SELECT (the local
353
+ * telemetry store). Row scoping is ANDed into WHERE unconditionally — the caller
354
+ * cannot opt out of it.
355
+ */
356
+ function compileQuery(input, scope) {
357
+ const ast = parseQuery(input);
358
+ const params = [];
359
+ const selectSql = ast.select.map((item) => {
360
+ if (item.kind === "count")
361
+ return `COUNT(*) AS "count"`;
362
+ if (item.kind === "aggregate") {
363
+ return `${item.fn.toUpperCase()}(${sqliteAggArgSql(item.fn, item.metric)}) AS "${item.alias}"`;
364
+ }
365
+ return `${sqliteColumnSql(item.column)} AS "${item.column.alias}"`;
366
+ });
367
+ const whereSql = ast.where.map((cond) => {
368
+ params.push(cond.value);
369
+ return `${sqliteColumnSql(cond.column)} ${cond.op} ?`;
370
+ });
371
+ // Live rows only — bake-off replay rows (`tags.replay`) never move a route's
372
+ // numbers; mirrored in the JS fold (evaluateAnalyticsQuery).
373
+ whereSql.push("json_extract(tags_json, '$.replay') IS NULL");
374
+ // Row scoping — ANDed in unconditionally; the caller cannot remove it.
375
+ if (scope.kind === "tag") {
376
+ if (!TAG_NAME.test(scope.tag))
377
+ throw new QueryGrammarError(`bad scope tag: ${scope.tag}`);
378
+ whereSql.push(`json_extract(tags_json, '$.${scope.tag}') = ?`);
379
+ params.push(scope.value);
380
+ }
381
+ const groupSql = ast.groupBy.map((ref) => sqliteColumnSql(ref));
274
382
  const sql = `SELECT ${selectSql.join(", ")} FROM inferences` +
275
383
  (whereSql.length > 0 ? ` WHERE ${whereSql.join(" AND ")}` : "") +
276
384
  (groupSql.length > 0 ? ` GROUP BY ${groupSql.join(", ")}` : "") +
277
- ` LIMIT ${limit}`;
385
+ ` LIMIT ${ast.limit}`;
278
386
  return { sql, params };
279
387
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The three saved analytics questions — WI-1b (spec §5). ONE shared module the
3
+ * server, console, and CLI import, so the console chip, the CLI `--saved <id>`,
4
+ * and any docs answer the SAME grammar. Each question is a trailing-window
5
+ * query; the ts bound is filled at call time (`grammar(sinceIso)`) so the module
6
+ * stays pure and testable — the caller computes `sinceIso = now − windowDays`.
7
+ *
8
+ * The questions return COUNTS and components; a RATE (truncation %, refusal %)
9
+ * is derived by the surface from those counts — the grammar measures, it never
10
+ * editorializes (F5: measured-only).
11
+ */
12
+ export interface SavedQuery {
13
+ readonly id: string;
14
+ readonly title: string;
15
+ readonly description: string;
16
+ /** The trailing window the question asks over, in days. */
17
+ readonly windowDays: number;
18
+ /** The grammar string with the `ts` lower bound filled in. */
19
+ grammar(sinceIso: string): string;
20
+ }
21
+ export declare const SAVED_QUERIES: readonly SavedQuery[];
22
+ /** Look up a saved question by id, or null. */
23
+ export declare function savedQueryById(id: string): SavedQuery | null;
24
+ /** The ISO lower bound for a trailing window of `windowDays` ending at `now`. */
25
+ export declare function sinceIsoFor(windowDays: number, now: Date): string;
26
+ //# sourceMappingURL=saved.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"saved.d.ts","sourceRoot":"","sources":["../../src/query/saved.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,MAAM,WAAW,UAAU;IACzB,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,2DAA2D;IAC3D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,8DAA8D;IAC9D,OAAO,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC;CACnC;AAED,eAAO,MAAM,aAAa,EAAE,SAAS,UAAU,EA8B9C,CAAC;AAEF,+CAA+C;AAC/C,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,UAAU,GAAG,IAAI,CAE5D;AAED,iFAAiF;AACjF,wBAAgB,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,GAAG,EAAE,IAAI,GAAG,MAAM,CAEjE"}
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ /**
3
+ * The three saved analytics questions — WI-1b (spec §5). ONE shared module the
4
+ * server, console, and CLI import, so the console chip, the CLI `--saved <id>`,
5
+ * and any docs answer the SAME grammar. Each question is a trailing-window
6
+ * query; the ts bound is filled at call time (`grammar(sinceIso)`) so the module
7
+ * stays pure and testable — the caller computes `sinceIso = now − windowDays`.
8
+ *
9
+ * The questions return COUNTS and components; a RATE (truncation %, refusal %)
10
+ * is derived by the surface from those counts — the grammar measures, it never
11
+ * editorializes (F5: measured-only).
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.SAVED_QUERIES = void 0;
15
+ exports.savedQueryById = savedQueryById;
16
+ exports.sinceIsoFor = sinceIsoFor;
17
+ exports.SAVED_QUERIES = [
18
+ {
19
+ id: "cost-per-subject",
20
+ title: "Cost per subject this week",
21
+ description: "Total estimated spend grouped by the reserved `subject` dimension over the last 7 days.",
22
+ windowDays: 7,
23
+ grammar: (since) => `SELECT subject, sum(cost), count(*) FROM inferences WHERE ts >= '${since}' GROUP BY subject`,
24
+ },
25
+ {
26
+ id: "truncation-refusal",
27
+ title: "Truncation & refusal rate by task × model",
28
+ description: "Attempt counts grouped by route (task), model, and finish reason over the last 7 days — the surface " +
29
+ "derives truncation (finish_reason=length) and refusal rates from the counts.",
30
+ windowDays: 7,
31
+ grammar: (since) => `SELECT route, model, meta.finish_reason, count(*) FROM inferences ` +
32
+ `WHERE ts >= '${since}' GROUP BY route, model, meta.finish_reason`,
33
+ },
34
+ {
35
+ id: "snapshots-seen",
36
+ title: "Model snapshots seen per route (28 d)",
37
+ description: "Distinct provider-reported model snapshots per route over the last 28 days, with first-seen (min ts) and " +
38
+ "last-seen (max ts) and the attempt count — a drift signal, not proof (spec §7).",
39
+ windowDays: 28,
40
+ grammar: (since) => `SELECT route, meta.model_snapshot, min(ts), max(ts), count(*) FROM inferences ` +
41
+ `WHERE ts >= '${since}' GROUP BY route, meta.model_snapshot`,
42
+ },
43
+ ];
44
+ /** Look up a saved question by id, or null. */
45
+ function savedQueryById(id) {
46
+ return exports.SAVED_QUERIES.find((q) => q.id === id) ?? null;
47
+ }
48
+ /** The ISO lower bound for a trailing window of `windowDays` ending at `now`. */
49
+ function sinceIsoFor(windowDays, now) {
50
+ return new Date(now.getTime() - windowDays * 24 * 60 * 60 * 1000).toISOString();
51
+ }
@@ -19,9 +19,9 @@ const template_1 = require("./template");
19
19
  const cache_lint_1 = require("./cache-lint");
20
20
  const sampling_config_1 = require("./sampling-config");
21
21
  const loader_warnings_1 = require("./loader-warnings");
22
- // Re-exported so existing importers of "./loader" keep resolving these pure
23
- // advisories (they now live in ./loader-warnings to hold loader.ts under the
24
- // 700-LOC cap).
22
+ const objective_loader_1 = require("./objective-loader");
23
+ // Re-exported so existing importers of "./loader" keep resolving these advisories
24
+ // (they live in ./loader-warnings to hold loader.ts under the 700-LOC cap).
25
25
  var loader_warnings_2 = require("./loader-warnings");
26
26
  Object.defineProperty(exports, "arrayRootSchemaWarning", { enumerable: true, get: function () { return loader_warnings_2.arrayRootSchemaWarning; } });
27
27
  Object.defineProperty(exports, "unreferencedTrialCandidatesWarning", { enumerable: true, get: function () { return loader_warnings_2.unreferencedTrialCandidatesWarning; } });
@@ -65,7 +65,7 @@ const RETRYABLE_CLASSES = new Set([
65
65
  "network",
66
66
  "refusal",
67
67
  "cache_invalid",
68
- "timeout",
68
+ "timeout", "quality_rejected",
69
69
  ].filter((c) => !failures_1.TERMINAL_CLASSES.has(c)));
70
70
  function isRecord(v) {
71
71
  return typeof v === "object" && v !== null && !Array.isArray(v);
@@ -258,6 +258,10 @@ function parseVariants(raw, routeCandidates, problems) {
258
258
  problems.push(`${field}.name "default" is reserved (the route's base config)`);
259
259
  continue;
260
260
  }
261
+ if (v.name.startsWith("__")) {
262
+ problems.push(`${field}.name "${v.name}" is reserved ("__" names are synthetic bake-off arms, e.g. "__reference__")`);
263
+ continue;
264
+ }
261
265
  if (seen.has(v.name)) {
262
266
  problems.push(`duplicate variant name "${v.name}"`);
263
267
  continue;
@@ -393,6 +397,9 @@ function loadBundleFile(filePath) {
393
397
  groundingQuery = doc.grounding.query;
394
398
  }
395
399
  }
400
+ // RB-1 (near-optimal spec §3): objective knob, reference arm + judge, value
401
+ // checks — all additive; candidate-set invariant enforced in parseRb1Fields.
402
+ const rb1 = (0, objective_loader_1.parseRb1Fields)(doc, route, candidates, problems);
396
403
  let systemTemplate = "";
397
404
  let variables = [];
398
405
  if (!isRecord(doc.prompt) || typeof doc.prompt.system !== "string") {
@@ -446,22 +453,19 @@ function loadBundleFile(filePath) {
446
453
  // and is sent as declared, but Google recommends 1.0 for the family — warn so
447
454
  // the choice is deliberate, never silent (spec §3/§6).
448
455
  const geminiTempWarning = (0, sampling_config_1.gemini3LowTemperatureWarning)(route, policy, candidates);
449
- if (geminiTempWarning !== null) {
456
+ if (geminiTempWarning !== null)
450
457
  console.warn(`modelrig: ${geminiTempWarning}`);
451
- }
452
458
  // CE-2 A5 co-item: an array-root schema cannot use OpenAI strict enforcement —
453
459
  // warn once at load so the incompatibility is deliberate, never silent.
454
460
  const arrayRootWarning = (0, loader_warnings_1.arrayRootSchemaWarning)(route, outputSchema);
455
- if (arrayRootWarning !== null) {
461
+ if (arrayRootWarning !== null)
456
462
  console.warn(`modelrig: ${arrayRootWarning}`);
457
- }
458
463
  // Declare-first dead-declaration warning (spec §4.4): a trial candidate no
459
464
  // variant references is unreachable — warn so the dangling declaration is
460
465
  // deliberate, never silent.
461
466
  const trialWarning = (0, loader_warnings_1.unreferencedTrialCandidatesWarning)(route, candidates, variants);
462
- if (trialWarning !== null) {
467
+ if (trialWarning !== null)
463
468
  console.warn(`modelrig: ${trialWarning}`);
464
- }
465
469
  return Object.freeze({
466
470
  route: route,
467
471
  version: version,
@@ -480,6 +484,7 @@ function loadBundleFile(filePath) {
480
484
  // Network-effect instrumentation (spec WS6): computed once at load,
481
485
  // stamped on every telemetry row for this route.
482
486
  fingerprintCell: (0, fingerprint_1.fingerprintSchema)(outputSchema)?.cellKey ?? null,
487
+ ...rb1, // RB-1 regret-bounded objective (objective/bakeoff/valueChecks, additive)
483
488
  });
484
489
  }
485
490
  /**
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Route-bundle parsing for the RB-1 regret-bounded objective (near-optimal spec
3
+ * §3). Extracted from loader.ts (kept it under the 700-LOC cap). Each parser
4
+ * pushes teaching problems into the shared `problems` array — a non-empty array
5
+ * fails the bundle load, exactly like every other loader field. Pure functions,
6
+ * unit-tested directly.
7
+ */
8
+ import type { BakeoffConfig, CandidateSpec, RouteObjective, ValueCheck } from "../types/route";
9
+ /** All three RB-1 route fields parsed in one call (keeps loader.ts under the
10
+ * 700-LOC cap): objective, bakeoff (reference arm + judge), value_checks. Pushes
11
+ * any problems into the shared array; emits the missing-bound advisory. */
12
+ export declare function parseRb1Fields(doc: Record<string, unknown>, route: string, candidates: readonly CandidateSpec[], problems: string[]): {
13
+ objective?: RouteObjective;
14
+ bakeoff?: BakeoffConfig;
15
+ valueChecks?: readonly ValueCheck[];
16
+ };
17
+ /** Parse the `objective:` block. Absent ⇒ undefined (the engine defaults to
18
+ * quality-first with no bound: regret shown, nothing proposed). */
19
+ export declare function parseObjective(raw: unknown, problems: string[]): RouteObjective | undefined;
20
+ /** Parse the `bakeoff:` block (reference arm + judge). `candidates` is the
21
+ * route's declared set — the candidate-set invariant is enforced here (#223 F3
22
+ * for the judge; the reference must be declared unless allow-external). */
23
+ export declare function parseBakeoffConfig(raw: unknown, candidates: readonly CandidateSpec[], problems: string[]): BakeoffConfig | undefined;
24
+ /** Parse `value_checks:` — a list of {path, expect}. `path` is a JSON pointer;
25
+ * `expect` is a literal or {regex}. */
26
+ export declare function parseValueChecks(raw: unknown, problems: string[]): readonly ValueCheck[] | undefined;
27
+ /**
28
+ * Non-fatal advisory (CE-2 teaching-hint style): a route declares an objective
29
+ * with NEITHER a regret bound NOR a price ceiling, so a bake-off will REPORT
30
+ * regret but never propose anything. Warn so the missing bound is deliberate.
31
+ * Returns the warning line, or null. Only meaningful for quality-first/balanced
32
+ * (cost-first has its own gate and needs no bound).
33
+ */
34
+ export declare function missingBoundWarning(route: string, objective: RouteObjective | undefined): string | null;
35
+ //# sourceMappingURL=objective-loader.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"objective-loader.d.ts","sourceRoot":"","sources":["../../src/routes/objective-loader.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,OAAO,KAAK,EACV,aAAa,EACb,aAAa,EAGb,cAAc,EACd,UAAU,EACX,MAAM,gBAAgB,CAAC;AAExB;;2EAE2E;AAC3E,wBAAgB,cAAc,CAC5B,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC5B,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,SAAS,aAAa,EAAE,EACpC,QAAQ,EAAE,MAAM,EAAE,GACjB;IAAE,SAAS,CAAC,EAAE,cAAc,CAAC;IAAC,OAAO,CAAC,EAAE,aAAa,CAAC;IAAC,WAAW,CAAC,EAAE,SAAS,UAAU,EAAE,CAAA;CAAE,CAe9F;AAoCD;mEACmE;AACnE,wBAAgB,cAAc,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,cAAc,GAAG,SAAS,CAwC3F;AAqCD;;2EAE2E;AAC3E,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,OAAO,EACZ,UAAU,EAAE,SAAS,aAAa,EAAE,EACpC,QAAQ,EAAE,MAAM,EAAE,GACjB,aAAa,GAAG,SAAS,CA+E3B;AAED;uCACuC;AACvC,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,EAAE,QAAQ,EAAE,MAAM,EAAE,GAAG,SAAS,UAAU,EAAE,GAAG,SAAS,CAqCpG;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,MAAM,EAAE,SAAS,EAAE,cAAc,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAUvG"}