@orangekame3/pi-qcaleval 0.0.1 → 0.0.2-beta.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,32 +4,85 @@ A [pi](https://github.com/badlogic/pi-mono) extension that sends superconducting
4
4
  qubit calibration plots to a dedicated calibration-evaluation VLM and returns a
5
5
  structured diagnosis. The default model is
6
6
  [NVIDIA Ising-Calibration](https://build.nvidia.com/nvidia/ising-calibration-1.5-31b/modelcard)
7
- served by vLLM.
7
+ served by vLLM, and the questions are the ones it was trained and benchmarked
8
+ on: the six questions of
9
+ [QCalEval](https://arxiv.org/abs/2604.25884), asked the way the benchmark asks
10
+ them.
8
11
 
9
12
  The planner model keeps the conversation; `qcal_evaluate` is the tool it calls
10
13
  whenever a calibration figure needs a fit-quality judgement, parameter
11
- extraction, or a success verdict. The extension has no QDash dependency, so it
14
+ extraction, or a status verdict. The extension has no QDash dependency, so it
12
15
  works from the pi CLI and from the QDash Copilot runtime alike.
13
16
 
14
17
  ## Tool
15
18
 
16
- `qcal_evaluate(context, question?, images?, max_images?)` returns a JSON object with
17
- `technical_description`, `experimental_conclusion`, `experimental_significance`,
18
- `fit_quality`, `parameter_extraction`, and `success`. The parsed object is also
19
- stored in the tool result's `details.diagnosis`; when the model did not answer
20
- with JSON, `details.raw` holds its text instead.
19
+ `qcal_evaluate(context, question?, task_name?, knowledge?, criteria?, images?, max_images?, use_examples?, examples?, questions?)`
20
+ asks the evaluation model six questions, each in its own single-turn request
21
+ (no system prompt), with the figure at the position the benchmark's templates
22
+ put it, and returns the parsed answers as `details.diagnosis`:
23
+
24
+ | key | the benchmark's question | parsed answer |
25
+ | --- | --- | --- |
26
+ | `technical_description` | Q1 Describe the figure in JSON | `{plot_type, x_axis, y_axis, main_features}`, or the text when not JSON |
27
+ | `experimental_conclusion` | Q2 Classify the outcome | `{classification: expected \| suboptimal \| anomalous \| apparatus, reason}` |
28
+ | `experimental_significance` | Q3 What does this result imply | text |
29
+ | `fit_reliability` | Q4 Is the fit reliable for extraction | `{assessment: reliable \| unreliable \| no_fit, reason}` |
30
+ | `parameter_extraction` | Q5 Extract parameters in JSON | the object, or the text when not JSON |
31
+ | `calibration_diagnosis` | Q6 Determine the status | `{status: CODE, suggested_range, notes}` |
32
+
33
+ plus `success`: whether the status is `SUCCESS` (or, when the status question
34
+ was not asked, whether the outcome is `expected`). `details.answers` keeps every
35
+ answer as returned, `details.questions` the questions asked, `details.prompt`
36
+ every user turn the model received (with `[figure]` where an image was), and
37
+ `details.calls` each question's call with its duration, stop reason and output
38
+ tokens. A call that fails is retried once, as the benchmark retries API
39
+ failures; nothing is re-asked for its content. Questions that got no answer are
40
+ listed in `details.missing_keys`. `questions` asks a subset.
41
+
42
+ `knowledge` is the experiment background the benchmark prepends to Q2–Q6: a few
43
+ sentences on what the experiment sweeps and what a successful result looks like.
44
+ `criteria` fills the family-specific parts of the templates: `guidance` (the
45
+ points Q3 should explain), `parameters` (the Q5 schema, name to type),
46
+ `statuses` (the Q6 decision criteria, code to criterion, `SUCCESS` first) and
47
+ `range_format`. A host that keeps such knowledge per task fills both in from
48
+ `task_name`, so the planner only names the task. Without them the templates use
49
+ generic guidance, ask for whatever parameters the plot determines, and offer
50
+ `SUCCESS` / `SUBOPTIMAL` / `NO_SIGNAL` / `ANOMALOUS`.
51
+
52
+ `context` is what the background cannot know (the task, the target, a swept
53
+ range); the model reads values off the plot, so do not pass fitted results. A
54
+ `question` is added to Q3 as one more point to address.
21
55
 
22
56
  `images` are base64 image blocks. A host may fill them in from the most recent
23
57
  figures of the conversation, so the planner does not have to pass image bytes;
24
58
  `max_images` (default 1, up to 4) says how many of the newest figures to supply,
25
59
  for example 2 when an expected reference figure precedes the measured result.
60
+ The last figure is the one judged; earlier ones are shown as references.
61
+
62
+ ### Labeled examples (in-context)
63
+
64
+ With `use_examples`, the host supplies `examples`: each is one or more figures
65
+ with the answers an expert validated (`scenario`, `analysis`, `parameters`,
66
+ `status`, `suggested_range`). They are laid out as the benchmark's few-shot
67
+ split lays them out, examples first and the query figure last, and only for the
68
+ three questions the benchmark uses them for: Q3 shows each example's
69
+ `analysis` under `Scenario "<scenario>":`, Q5 shows one example's
70
+ `parameters`, Q6 shows each example's `Status:` and `Suggested range:`. Up to
71
+ six examples and six example figures are shown; the rest are dropped in order.
26
72
 
27
73
  ## Configuration
28
74
 
29
- | Variable | Purpose |
30
- |----------|---------|
31
- | `QCALEVAL_MODEL` | `provider/modelId` of the evaluation model in pi's `models.json` (default: `vllm/nvidia/Ising-Calibration-1.5-31B-NVFP4`) |
32
- | `QCALEVAL_MAX_TOKENS` | Output budget for one evaluation (default: `2048`) |
75
+ No setting is required. The evaluation model is resolved in this order:
76
+
77
+ 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for pi CLI use;
78
+ 2. a configured calibration VLM, recognised by its id (`Ising-Calibration`);
79
+ 3. the conversation's own model, when it accepts images. The result is then
80
+ marked `fallback: true` in `details`, so a host without an Ising deployment
81
+ still gets a diagnosis from the vision model it already runs.
82
+
83
+ The output budget per answer follows the model's `maxTokens` (at most 4096). The
84
+ benchmark decodes greedily; set the model's temperature to 0 for reproducible
85
+ answers.
33
86
 
34
87
  The provider must be declared in pi's `models.json` with its base URL and
35
88
  credential, for example:
@@ -47,8 +100,9 @@ credential, for example:
47
100
  }
48
101
  ```
49
102
 
50
- Only the plot, a short system prompt, and the `context` text are sent, so the
51
- evaluation fits comfortably in a 16k-token deployment.
103
+ Each request carries one plot, the background and one question, so an
104
+ evaluation fits comfortably in a 16k-token deployment; the six requests run at
105
+ once.
52
106
 
53
107
  ## Installation
54
108
 
@@ -2,13 +2,15 @@
2
2
  * pi-qcaleval: evaluate calibration plots with a dedicated calibration VLM.
3
3
  *
4
4
  * The extension has no QDash dependency. It takes a plot (plus the experiment
5
- * context the planner already knows) and asks the evaluation model, which is
6
- * configured separately from the conversation's model, for a diagnosis. The
7
- * planner decides what to do with it; whether a calibration loop continues is
8
- * the host's policy, not this tool's.
5
+ * background and criteria the host supplies for the task, and the context the
6
+ * planner already knows) and asks the evaluation model the six QCalEval
7
+ * questions, each as its own single-turn request the way the benchmark and
8
+ * the Ising-Calibration training data lay them out. The planner decides what
9
+ * to do with the diagnosis; whether a calibration loop continues is the
10
+ * host's policy, not this tool's.
9
11
  */
10
12
 
11
- import type { Api, AssistantMessage, ImageContent, Model } from "@earendil-works/pi-ai";
13
+ import type { Api, AssistantMessage, ImageContent, Model, Usage } from "@earendil-works/pi-ai";
12
14
  import type {
13
15
  AgentToolResult,
14
16
  ExtensionAPI,
@@ -16,20 +18,29 @@ import type {
16
18
  } from "@earendil-works/pi-coding-agent";
17
19
  import { Type, type Static } from "typebox";
18
20
 
19
- /** `provider/modelId`; the model id itself may contain slashes (nvidia/...). */
20
- export const DEFAULT_MODEL = "vllm/nvidia/Ising-Calibration-1.5-31B-NVFP4";
21
-
22
21
  /** Figures per evaluation; a 16k-token deployment holds a few plots comfortably. */
23
22
  export const MAX_IMAGES = 4;
24
23
 
24
+ /** Labeled example plots shown before the query, and the figures they may hold in total. */
25
+ export const MAX_EXAMPLES = 6;
26
+ export const MAX_EXAMPLE_IMAGES = 6;
27
+
28
+ /** Model ids of models fine-tuned for calibration plots; preferred when configured. */
29
+ export const CALIBRATION_MODEL_PATTERN = /ising-calibration/i;
30
+
31
+ /** Output budget when the model declares none; one answer is a few hundred tokens. */
32
+ const DEFAULT_MAX_TOKENS = 2048;
33
+ const MAX_OUTPUT_TOKENS = 4096;
34
+
25
35
  export interface ModelSpec {
26
36
  provider: string;
27
37
  modelId: string;
28
38
  }
29
39
 
30
- /** Parse `provider/modelId`, splitting at the first slash only. */
31
- export function parseModelSpec(value: string | undefined): ModelSpec {
32
- const spec = (value ?? "").trim() || DEFAULT_MODEL;
40
+ /** Parse an explicit `provider/modelId` override; the model id may contain slashes (nvidia/...). */
41
+ export function parseModelSpec(value: string | undefined): ModelSpec | undefined {
42
+ const spec = (value ?? "").trim();
43
+ if (!spec) return undefined;
33
44
  const slash = spec.indexOf("/");
34
45
  if (slash <= 0 || slash === spec.length - 1) {
35
46
  throw new Error(`QCALEVAL_MODEL must be provider/modelId, got "${spec}"`);
@@ -37,49 +48,383 @@ export function parseModelSpec(value: string | undefined): ModelSpec {
37
48
  return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
38
49
  }
39
50
 
40
- /** Categories the Ising-Calibration model card describes; asked for in this order. */
41
- export const DIAGNOSIS_SECTIONS = [
51
+ /** The slice of pi's model registry the resolution needs. */
52
+ export interface ModelLookup {
53
+ find(provider: string, modelId: string): Model<Api> | undefined;
54
+ getAvailable(): Model<Api>[];
55
+ }
56
+
57
+ export type EvaluationModel =
58
+ | { model: Model<Api>; fallback: false }
59
+ | { model: Model<Api>; fallback: true }
60
+ | { model?: undefined; error: string };
61
+
62
+ /**
63
+ * Pick the model that judges the plot, without any host-specific setting:
64
+ *
65
+ * 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for the pi CLI;
66
+ * 2. a configured calibration VLM, recognised by its id (Ising-Calibration);
67
+ * 3. the conversation's own model, when it accepts images.
68
+ *
69
+ * A host that declares the calibration model in its models.json gets it
70
+ * everywhere; one that does not (no Ising deployment) still gets a diagnosis
71
+ * from the vision model it already runs, marked as a fallback.
72
+ */
73
+ export function resolveEvaluationModel(
74
+ registry: ModelLookup,
75
+ current: Model<Api> | undefined,
76
+ override: string | undefined,
77
+ ): EvaluationModel {
78
+ const spec = parseModelSpec(override);
79
+ if (spec) {
80
+ const model = registry.find(spec.provider, spec.modelId);
81
+ return model
82
+ ? { model, fallback: false }
83
+ : { error: `model ${spec.provider}/${spec.modelId} is not configured (QCALEVAL_MODEL)` };
84
+ }
85
+ const calibration = registry.getAvailable().find((model) => CALIBRATION_MODEL_PATTERN.test(model.id));
86
+ if (calibration) return { model: calibration, fallback: false };
87
+ if (current?.input.includes("image")) return { model: current, fallback: true };
88
+ return {
89
+ error:
90
+ "no evaluation model: declare a calibration VLM (Ising-Calibration) in models.json, or run with a vision model",
91
+ };
92
+ }
93
+
94
+ /** Output budget for one answer, within what the model allows. */
95
+ export function outputBudget(model: Pick<Model<Api>, "maxTokens">): number {
96
+ return model.maxTokens > 0 ? Math.min(model.maxTokens, MAX_OUTPUT_TOKENS) : DEFAULT_MAX_TOKENS;
97
+ }
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // The six questions
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * The QCalEval questions, keyed as the benchmark keys its responses, in the
105
+ * order the paper numbers them (Q1–Q6). Each is asked in its own request.
106
+ */
107
+ export const QUESTIONS = [
42
108
  "technical_description",
43
109
  "experimental_conclusion",
44
110
  "experimental_significance",
45
- "fit_quality",
111
+ "fit_reliability",
46
112
  "parameter_extraction",
47
- "success",
113
+ "calibration_diagnosis",
114
+ ] as const;
115
+ export type Question = (typeof QUESTIONS)[number];
116
+
117
+ /** Questions that take labeled demonstrations (the benchmark's ICL mode). */
118
+ export const ICL_QUESTIONS: ReadonlySet<Question> = new Set<Question>([
119
+ "experimental_significance",
120
+ "parameter_extraction",
121
+ "calibration_diagnosis",
122
+ ]);
123
+
124
+ /** The four outcomes of Q2, as the benchmark words them, with the key the diagnosis uses. */
125
+ export const OUTCOMES = [
126
+ { key: "expected", option: "Expected behavior", meaning: "Experiment produced usable calibration data" },
127
+ {
128
+ key: "suboptimal",
129
+ option: "Suboptimal parameters",
130
+ meaning: "Working but needs parameter adjustment within this experiment",
131
+ },
132
+ {
133
+ key: "anomalous",
134
+ option: "Anomalous behavior",
135
+ meaning: "Requires upstream recalibration or shows uncontrollable quantum effects",
136
+ },
137
+ { key: "apparatus", option: "Apparatus issue", meaning: "No meaningful signal — measurement system misconfigured" },
138
+ ] as const;
139
+ export type Outcome = (typeof OUTCOMES)[number]["key"];
140
+
141
+ /** The three answers of Q4, with the key the diagnosis uses. */
142
+ export const FIT_ASSESSMENTS = [
143
+ { key: "reliable", option: "Reliable" },
144
+ { key: "unreliable", option: "Unreliable" },
145
+ { key: "no_fit", option: "No fit" },
48
146
  ] as const;
147
+ export type FitAssessment = (typeof FIT_ASSESSMENTS)[number]["key"];
148
+
149
+ /** What Q3 asks the model to explain when the host gives no task-specific points. */
150
+ export const DEFAULT_GUIDANCE = [
151
+ "What the observed behavior indicates about the calibration",
152
+ "Whether the measurement is valid for its purpose (sweep range, resolution, signal quality)",
153
+ "What calibration step follows",
154
+ ] as const;
155
+
156
+ /** Q6 statuses when the host gives none; a task's own criteria replace them. */
157
+ export const DEFAULT_STATUSES: Readonly<Record<string, string>> = {
158
+ SUCCESS: "The plot shows the expected behavior described above and the result is usable",
159
+ SUBOPTIMAL: "Usable data, but a parameter of this experiment (range, amplitude, resolution) needs adjustment",
160
+ NO_SIGNAL: "Flat or random data with no usable signal",
161
+ ANOMALOUS: "Behavior that calls for upstream recalibration or shows uncontrolled effects",
162
+ };
163
+ export const DEFAULT_RANGE_FORMAT = "(<min>, <max>) of the swept parameter";
164
+
165
+ /** Task-specific pieces of the question templates, supplied by the host. */
166
+ export interface Criteria {
167
+ /** Q3 "Explain:" bullets. */
168
+ guidance?: ReadonlyArray<string>;
169
+ /** Q5 schema: parameter name to its type or description. */
170
+ parameters?: Readonly<Record<string, string>>;
171
+ /** Q6 decision criteria: status code to the criterion; SUCCESS first. */
172
+ statuses?: Readonly<Record<string, string>>;
173
+ /** Q6 suggested-range format, e.g. "(<min 1/alpha>, <max 1/alpha>)". */
174
+ range_format?: string;
175
+ }
176
+
177
+ export interface Figure {
178
+ data: string;
179
+ mimeType: string;
180
+ }
181
+
182
+ /** One labeled example plot: its figure(s) and the answers an expert validated. */
183
+ export interface LabeledExample {
184
+ images: ReadonlyArray<Figure>;
185
+ /** What the example shows, e.g. "drag_failure_no_signal" or a short title. */
186
+ scenario?: string;
187
+ /** Q3 answer for the example. */
188
+ analysis?: string;
189
+ /** Q5 answer for the example: JSON text or an object. */
190
+ parameters?: string | Readonly<Record<string, unknown>>;
191
+ /** Q6 status code of the example. */
192
+ status?: string;
193
+ /** Q6 suggested range of the example; "N/A" or empty for a success. */
194
+ suggested_range?: string;
195
+ }
196
+
197
+ export type UserContentBlock =
198
+ | { type: "text"; text: string }
199
+ | { type: "image"; data: string; mimeType: string };
49
200
 
50
- export const SYSTEM_PROMPT = [
51
- "You evaluate superconducting qubit calibration experiment plots.",
52
- "Answer with one JSON object and nothing else, with these keys:",
53
- ' "technical_description": what the plot shows (axes, traces, fit);',
54
- ' "experimental_conclusion": what the data says about the experiment;',
55
- ' "experimental_significance": what it means for the calibration;',
56
- ' "fit_quality": "good" | "marginal" | "poor" and why;',
57
- ' "parameter_extraction": extracted values with units, or null;',
58
- ' "success": true | false, whether the experiment achieved its goal.',
59
- "Base every statement on the plot and the given context only.",
60
- ].join("\n");
201
+ export interface PromptInput {
202
+ /** The experiment background: what is swept and what a successful result looks like. */
203
+ background?: string;
204
+ /** The host's context: task, target, anything the background cannot know. */
205
+ context: string;
206
+ /** A specific point to address in the analysis (Q3). */
207
+ question?: string;
208
+ criteria?: Criteria;
209
+ /** The figures; the last one is the measured result to judge. */
210
+ images: ReadonlyArray<Figure>;
211
+ examples?: ReadonlyArray<LabeledExample>;
212
+ }
213
+
214
+ /** The marker the templates use for the figure to judge, as the benchmark's prompts do. */
215
+ const IMAGE = "<image>";
216
+
217
+ function paragraphs(...parts: Array<string | undefined>): string {
218
+ return parts.filter((part): part is string => Boolean(part && part.trim())).join("\n\n");
219
+ }
220
+
221
+ function opening(input: PromptInput, withBackground = true): string {
222
+ const context = input.context.trim();
223
+ return paragraphs(
224
+ withBackground ? input.background?.trim() : undefined,
225
+ context ? `Experiment context: ${context}` : undefined,
226
+ );
227
+ }
228
+
229
+ /** Keep examples in order within the example and figure budgets; later ones are dropped. */
230
+ export function fitExamples(examples: ReadonlyArray<LabeledExample>): LabeledExample[] {
231
+ const kept: LabeledExample[] = [];
232
+ let figures = 0;
233
+ for (const example of examples) {
234
+ if (kept.length >= MAX_EXAMPLES) break;
235
+ const labeled = Boolean(example.analysis?.trim() || example.status?.trim() || example.parameters);
236
+ if (!labeled || !example.images?.length) continue;
237
+ if (figures + example.images.length > MAX_EXAMPLE_IMAGES) break;
238
+ figures += example.images.length;
239
+ kept.push(example);
240
+ }
241
+ return kept;
242
+ }
243
+
244
+ function parametersText(value: LabeledExample["parameters"]): string | undefined {
245
+ if (value === undefined || value === null) return undefined;
246
+ if (typeof value === "string") return value.trim() || undefined;
247
+ return JSON.stringify(value);
248
+ }
249
+
250
+ function statusList(statuses: Readonly<Record<string, string>>): string {
251
+ return Object.entries(statuses)
252
+ .map(([code, criterion]) => `- ${code}: ${criterion}`)
253
+ .join("\n");
254
+ }
61
255
 
62
256
  /**
63
- * Build the single user turn sent to the evaluation model. With several
64
- * figures, the model is told how many follow so it reads them in the order
65
- * the context describes (expected reference first, measured result last).
257
+ * The text of one question's prompt, with `<image>` where each figure goes:
258
+ * the example figures in order, then the figure to judge. Templates follow the
259
+ * QCalEval dataset's prompts; the host's criteria fill the family-specific parts.
66
260
  */
67
- export function buildUserPrompt(
68
- context: string,
69
- question: string | undefined,
70
- imageCount = 1,
71
- ): string {
72
- const lines = ["Experiment context:", context.trim() || "(none)"];
73
- if (imageCount > 1) {
74
- lines.push(
75
- "",
76
- `${imageCount} figures follow, in the order the context describes; the last one is the measured result to judge.`,
261
+ export function questionTemplate(question: Question, input: PromptInput): { text: string; images: Figure[] } {
262
+ const criteria = input.criteria ?? {};
263
+ const examples = fitExamples(input.examples ?? []);
264
+ const exampleImages: Figure[] = [];
265
+ const demo = (example: LabeledExample): string => {
266
+ exampleImages.push(...example.images);
267
+ return example.images.map(() => IMAGE).join(" ");
268
+ };
269
+ const specific = input.question?.trim();
270
+ let text: string;
271
+
272
+ switch (question) {
273
+ case "technical_description": {
274
+ text = [
275
+ `Describe the figure ${IMAGE} in JSON format.`,
276
+ "",
277
+ "Required fields:",
278
+ "{",
279
+ ' "plot_type": "scatter" | "line" | "heatmap" | "histogram",',
280
+ ' "x_axis": {"label": string, "scale": "linear" | "log", "range": [min, max]},',
281
+ ' "y_axis": {"label": string, "scale": "linear" | "log", "range": [min, max]},',
282
+ ' "main_features": string',
283
+ "}",
284
+ ].join("\n");
285
+ break;
286
+ }
287
+ case "experimental_conclusion": {
288
+ text = paragraphs(
289
+ opening(input),
290
+ `Based on what you observe in the data ${IMAGE}, classify the experimental outcome.`,
291
+ ["Options:", ...OUTCOMES.map((o) => `- ${o.option}: ${o.meaning}`)].join("\n"),
292
+ "Provide your answer as:\nClassification: <your choice>\nReason: <brief explanation>",
293
+ );
294
+ break;
295
+ }
296
+ case "experimental_significance": {
297
+ const shown = examples.filter((example) => example.analysis?.trim());
298
+ if (shown.length > 0) {
299
+ text = paragraphs(
300
+ opening(input),
301
+ "Here are example analyses for each scenario type in this experiment family:",
302
+ ...shown.map(
303
+ (example, index) =>
304
+ `${demo(example)} Scenario "${example.scenario?.trim() || `example ${index + 1}`}":\n${example.analysis!.trim()}`,
305
+ ),
306
+ specific ? `Also address: ${specific}` : undefined,
307
+ `Now analyze this chart:\n\n${IMAGE}`,
308
+ );
309
+ } else {
310
+ const guidance = criteria.guidance?.filter((line) => line.trim()).length
311
+ ? criteria.guidance.filter((line) => line.trim())
312
+ : DEFAULT_GUIDANCE;
313
+ text = paragraphs(
314
+ opening(input),
315
+ `What does this result ${IMAGE} imply?`,
316
+ ["Explain:", ...guidance.map((line) => `- ${line.trim()}`), ...(specific ? [`- ${specific}`] : [])].join(
317
+ "\n",
318
+ ),
319
+ "Provide your assessment.",
320
+ );
321
+ }
322
+ break;
323
+ }
324
+ case "fit_reliability": {
325
+ text = paragraphs(
326
+ opening(input),
327
+ `Assess whether the fit to the data in this plot ${IMAGE} is reliable for parameter extraction.`,
328
+ ["Options:", ...FIT_ASSESSMENTS.map((f) => `- ${f.option}`)].join("\n"),
329
+ "Provide your answer as:\nAssessment: <your choice>\nReason: <brief explanation>",
330
+ );
331
+ break;
332
+ }
333
+ case "parameter_extraction": {
334
+ const shown = examples.find((example) => parametersText(example.parameters));
335
+ const schema = Object.entries(criteria.parameters ?? {});
336
+ if (shown) {
337
+ text = paragraphs(
338
+ opening(input),
339
+ "Here is an example of parameter extraction from a similar chart:",
340
+ `${demo(shown)} Extracted parameters: ${parametersText(shown.parameters)}`,
341
+ `Now extract parameters from this chart:\n\n${IMAGE}`,
342
+ );
343
+ } else if (schema.length > 0) {
344
+ text = paragraphs(
345
+ opening(input),
346
+ `Extract the following parameters from this plot ${IMAGE}.`,
347
+ `Report in JSON format:\n{${schema.map(([name, type]) => `"${name}": ${type}`).join(", ")}}\nUse "Unreliable" for a value the plot does not determine.`,
348
+ );
349
+ } else {
350
+ text = paragraphs(
351
+ opening(input),
352
+ `Extract the physical parameters this plot ${IMAGE} determines.`,
353
+ 'Report in JSON format, one key per parameter with its value and unit, e.g. {"<parameter>": <value>, "<parameter>_unit": "<unit>"}. Use "Unreliable" for a value the plot does not determine.',
354
+ );
355
+ }
356
+ break;
357
+ }
358
+ case "calibration_diagnosis": {
359
+ const statuses = criteria.statuses && Object.keys(criteria.statuses).length > 0 ? criteria.statuses : DEFAULT_STATUSES;
360
+ const range = criteria.range_format?.trim() || DEFAULT_RANGE_FORMAT;
361
+ const shown = examples.filter((example) => example.status?.trim());
362
+ if (shown.length > 0) {
363
+ text = paragraphs(
364
+ opening(input),
365
+ `Classify this chart given the following labeled examples.\n\nDECISION CRITERIA\n${statusList(statuses)}`,
366
+ ...shown.map(
367
+ (example) =>
368
+ `${demo(example)} Status: ${example.status!.trim().toUpperCase()}\nSuggested range: ${example.suggested_range?.trim() || "N/A"}`,
369
+ ),
370
+ `Now classify this chart:\n\n${IMAGE}`,
371
+ );
372
+ } else {
373
+ text = paragraphs(
374
+ opening(input),
375
+ `Evaluate the image ${IMAGE} and determine the experiment status.`,
376
+ `DECISION CRITERIA\n${statusList(statuses)}`,
377
+ `When the status is not SUCCESS, provide a SPECIFIC suggested ${range}.`,
378
+ `The response MUST follow this exact format:\n\nStatus: <one of the listed statuses>\nSuggested range: ${range} (or "N/A" if SUCCESS)\nNotes: <1-3 sentences explaining your reasoning>`,
379
+ );
380
+ }
381
+ break;
382
+ }
383
+ }
384
+
385
+ // A reference figure ahead of the measured one (a task review opens with
386
+ // the expected plot) is shown first, as the benchmark shows its examples.
387
+ const query = input.images[input.images.length - 1];
388
+ const references = input.images.slice(0, -1);
389
+ if (references.length > 0) {
390
+ text = paragraphs(
391
+ `Reference figure${references.length === 1 ? "" : "s"} for comparison (expected result); the last figure is the measured result to judge:\n${references.map(() => IMAGE).join(" ")}`,
392
+ text,
77
393
  );
78
394
  }
79
- if (question?.trim()) lines.push("", "Specific question:", question.trim());
80
- return lines.join("\n");
395
+ return { text, images: [...references, ...exampleImages, ...(query ? [query] : [])] };
396
+ }
397
+
398
+ /**
399
+ * One question's user turn as content blocks: the template text with each
400
+ * `<image>` replaced by the next figure, images as native blocks. `prompt` is
401
+ * the same turn as text with `[figure]` where an image was, for the record.
402
+ */
403
+ export function buildQuestionContent(
404
+ question: Question,
405
+ input: PromptInput,
406
+ ): { content: UserContentBlock[]; prompt: string } {
407
+ const { text, images } = questionTemplate(question, input);
408
+ const content: UserContentBlock[] = [];
409
+ const parts = text.split(IMAGE);
410
+ parts.forEach((part, index) => {
411
+ if (part.trim()) content.push({ type: "text", text: part.trim() });
412
+ const figure = images[index];
413
+ if (index < parts.length - 1 && figure) content.push({ type: "image", data: figure.data, mimeType: figure.mimeType });
414
+ });
415
+ const prompt = text.replace(/<image>/g, "[figure]").replace(/\n{3,}/g, "\n\n").trim();
416
+ return { content, prompt };
81
417
  }
82
418
 
419
+ /** The prompts of several questions as one text, headed by the question key, for the record. */
420
+ export function promptRecord(prompts: ReadonlyArray<{ question: Question; prompt: string }>): string {
421
+ return prompts.map(({ question, prompt }) => `## ${question}\n${prompt}`).join("\n\n");
422
+ }
423
+
424
+ // ---------------------------------------------------------------------------
425
+ // Answers
426
+ // ---------------------------------------------------------------------------
427
+
83
428
  /** Pull the text blocks of the assistant answer, dropping thinking. */
84
429
  export function answerText(message: AssistantMessage): string {
85
430
  return message.content
@@ -89,10 +434,11 @@ export function answerText(message: AssistantMessage): string {
89
434
  }
90
435
 
91
436
  /**
92
- * Parse the model's JSON object, tolerating a code fence, a "thought" preamble
93
- * or trailing commentary around it: the first balanced `{...}` is taken.
437
+ * Parse the first JSON object in an answer, tolerating a code fence, a
438
+ * "thought" preamble or trailing commentary around it: the first balanced
439
+ * `{...}` is taken.
94
440
  */
95
- export function parseDiagnosis(text: string): Record<string, unknown> | undefined {
441
+ export function parseJsonObject(text: string): Record<string, unknown> | undefined {
96
442
  const start = text.indexOf("{");
97
443
  if (start < 0) return undefined;
98
444
  let depth = 0;
@@ -133,19 +479,263 @@ function parseObject(json: string): Record<string, unknown> | undefined {
133
479
  return undefined;
134
480
  }
135
481
 
482
+ /** The value after `Label:` on its own line (markdown emphasis tolerated), or undefined. */
483
+ export function labeledLine(text: string, label: string): string | undefined {
484
+ const match = text.match(new RegExp(`^[ \\t]*[*_#]*${label}[*_]*[ \\t]*:[*_ \\t]*(.+?)[ \\t]*$`, "im"));
485
+ return match?.[1]?.replace(/[*_]+$/, "").trim() || undefined;
486
+ }
487
+
488
+ /** Everything after `Label:` to the end of the text, across lines. */
489
+ function labeledRest(text: string, label: string): string | undefined {
490
+ const match = text.match(new RegExp(`^[ \\t]*[*_#]*${label}[*_]*[ \\t]*:[*_ \\t]*([\\s\\S]*)$`, "im"));
491
+ return match?.[1]?.trim() || undefined;
492
+ }
493
+
494
+ export function parseOutcome(text: string): Outcome | null {
495
+ const line = (labeledLine(text, "Classification") ?? text).toLowerCase();
496
+ for (const outcome of OUTCOMES) {
497
+ if (line.includes(outcome.key) || line.includes(outcome.option.toLowerCase())) return outcome.key;
498
+ }
499
+ return null;
500
+ }
501
+
502
+ export function parseFitAssessment(text: string): FitAssessment | null {
503
+ const line = (labeledLine(text, "Assessment") ?? text).toLowerCase();
504
+ if (/\bno fit\b|\bno_fit\b/.test(line)) return "no_fit";
505
+ if (/\bunreliable\b/.test(line)) return "unreliable";
506
+ if (/\breliable\b/.test(line)) return "reliable";
507
+ return null;
508
+ }
509
+
510
+ /** A status code as the benchmark normalises it: trimmed, upper-cased, spaces as underscores. */
511
+ export function normalizeStatus(value: string | undefined): string | null {
512
+ const code = (value ?? "")
513
+ .replace(/[`*]+/g, "")
514
+ .trim()
515
+ .split(/\s*\(|\s+[—–-]\s+/)[0]
516
+ ?.trim()
517
+ .toUpperCase()
518
+ .replace(/[\s-]+/g, "_");
519
+ return code && /^[A-Z][A-Z0-9_]*$/.test(code) ? code : null;
520
+ }
521
+
522
+ /** The parsed value of one answer, in the shape the diagnosis stores for that question. */
523
+ export function parseAnswer(question: Question, text: string): unknown {
524
+ switch (question) {
525
+ case "technical_description":
526
+ return parseJsonObject(text) ?? text;
527
+ case "experimental_conclusion":
528
+ return { classification: parseOutcome(text), reason: labeledRest(text, "Reason") ?? "" };
529
+ case "experimental_significance":
530
+ return text;
531
+ case "fit_reliability":
532
+ return { assessment: parseFitAssessment(text), reason: labeledRest(text, "Reason") ?? "" };
533
+ case "parameter_extraction": {
534
+ const object = parseJsonObject(text);
535
+ if (object) return object;
536
+ return text.trim().toLowerCase() === "null" ? null : text;
537
+ }
538
+ case "calibration_diagnosis": {
539
+ const range = labeledLine(text, "Suggested range");
540
+ return {
541
+ status: normalizeStatus(labeledLine(text, "Status")),
542
+ suggested_range: range && !/^n\/?a$/i.test(range) ? range : null,
543
+ notes: labeledRest(text, "Notes") ?? "",
544
+ };
545
+ }
546
+ }
547
+ }
548
+
549
+ /**
550
+ * The diagnosis from the parsed answers, plus `success`: the Q6 status is
551
+ * SUCCESS; without a Q6 answer, the Q2 outcome is expected; null when neither
552
+ * was answered.
553
+ */
554
+ export function assembleDiagnosis(answers: Partial<Record<Question, unknown>>): Record<string, unknown> {
555
+ const diagnosis: Record<string, unknown> = {};
556
+ for (const question of QUESTIONS) {
557
+ if (question in answers) diagnosis[question] = answers[question];
558
+ }
559
+ const status = (answers.calibration_diagnosis as { status?: unknown } | undefined)?.status;
560
+ const outcome = (answers.experimental_conclusion as { classification?: unknown } | undefined)?.classification;
561
+ diagnosis.success =
562
+ typeof status === "string"
563
+ ? status === "SUCCESS"
564
+ : answers.calibration_diagnosis === undefined && typeof outcome === "string"
565
+ ? outcome === "expected"
566
+ : null;
567
+ return diagnosis;
568
+ }
569
+
570
+ /** One model call made for an answer, for the record: how long it took and how it ended. */
571
+ export interface CallRecord {
572
+ question: Question;
573
+ duration_ms: number;
574
+ stop_reason: string;
575
+ output_tokens?: number;
576
+ }
577
+
578
+ /** The answers of one evaluation, read for the record. */
579
+ export interface Answers {
580
+ diagnosis: Record<string, unknown>;
581
+ /** Every answered question's text, as returned. */
582
+ answers: Partial<Record<Question, string>>;
583
+ questions: Question[];
584
+ /** Questions asked that got no answer (a model error, or an empty reply). */
585
+ missing: Question[];
586
+ /** Model calls made, retries included. */
587
+ attempts: number;
588
+ stopReason: string;
589
+ usage?: Usage;
590
+ calls: CallRecord[];
591
+ /** The error of the last failed call, when every call for some question failed. */
592
+ errors: Partial<Record<Question, string>>;
593
+ }
594
+
595
+ /** Token usage of two calls together, so a retried answer is billed as both. */
596
+ export function addUsage(a: Usage | undefined, b: Usage | undefined): Usage | undefined {
597
+ if (!a || !b) return a ?? b;
598
+ const sum = (x: number | undefined, y: number | undefined) => (x ?? 0) + (y ?? 0);
599
+ return {
600
+ ...a,
601
+ input: sum(a.input, b.input),
602
+ output: sum(a.output, b.output),
603
+ cacheRead: sum(a.cacheRead, b.cacheRead),
604
+ cacheWrite: sum(a.cacheWrite, b.cacheWrite),
605
+ ...(a.reasoning === undefined && b.reasoning === undefined
606
+ ? {}
607
+ : { reasoning: sum(a.reasoning, b.reasoning) }),
608
+ totalTokens: sum(a.totalTokens, b.totalTokens),
609
+ cost: {
610
+ input: sum(a.cost?.input, b.cost?.input),
611
+ output: sum(a.cost?.output, b.cost?.output),
612
+ cacheRead: sum(a.cost?.cacheRead, b.cost?.cacheRead),
613
+ cacheWrite: sum(a.cost?.cacheWrite, b.cost?.cacheWrite),
614
+ total: sum(a.cost?.total, b.cost?.total),
615
+ },
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Ask every question, each in its own request, all at once. A call that
621
+ * fails (a transport or server error) is retried once, as the benchmark
622
+ * retries API failures; an answer is never re-asked for its content. Each
623
+ * call's duration and stop reason are kept, so a slow evaluation can be
624
+ * traced to the question that ran long.
625
+ */
626
+ export async function askQuestions(
627
+ questions: ReadonlyArray<Question>,
628
+ call: (question: Question) => Promise<AssistantMessage>,
629
+ clock: () => number = Date.now,
630
+ ): Promise<Answers> {
631
+ const calls: CallRecord[] = [];
632
+ const usages: Usage[] = [];
633
+ const ask = async (question: Question): Promise<AssistantMessage> => {
634
+ const started = clock();
635
+ const message = await call(question);
636
+ calls.push({
637
+ question,
638
+ duration_ms: Math.max(0, clock() - started),
639
+ stop_reason: message.stopReason,
640
+ ...(message.usage ? { output_tokens: message.usage.output } : {}),
641
+ });
642
+ if (message.usage) usages.push(message.usage);
643
+ return message;
644
+ };
645
+ const results = await Promise.all(
646
+ questions.map(async (question) => {
647
+ let message = await ask(question);
648
+ if (message.stopReason === "error") message = await ask(question);
649
+ return { question, message };
650
+ }),
651
+ );
652
+ const answers: Partial<Record<Question, string>> = {};
653
+ const parsed: Partial<Record<Question, unknown>> = {};
654
+ const errors: Partial<Record<Question, string>> = {};
655
+ const missing: Question[] = [];
656
+ for (const { question, message } of results) {
657
+ const text = message.stopReason === "error" ? "" : answerText(message);
658
+ if (!text) {
659
+ missing.push(question);
660
+ if (message.stopReason === "error") errors[question] = message.errorMessage ?? "model error";
661
+ continue;
662
+ }
663
+ answers[question] = text;
664
+ parsed[question] = parseAnswer(question, text);
665
+ }
666
+ // Calls finish in any order; the record lists them by question.
667
+ calls.sort((a, b) => QUESTIONS.indexOf(a.question) - QUESTIONS.indexOf(b.question));
668
+ const stops = calls.map((c) => c.stop_reason);
669
+ const stopReason = stops.includes("length") ? "length" : missing.length === questions.length && stops.length ? stops[stops.length - 1]! : "stop";
670
+ return {
671
+ diagnosis: assembleDiagnosis(parsed),
672
+ answers,
673
+ questions: [...questions],
674
+ missing,
675
+ attempts: calls.length,
676
+ stopReason,
677
+ usage: usages.reduce<Usage | undefined>((sum, usage) => addUsage(sum, usage), undefined),
678
+ calls,
679
+ errors,
680
+ };
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // The tool
685
+ // ---------------------------------------------------------------------------
686
+
136
687
  const imageSchema = Type.Object({
137
688
  data: Type.String({ description: "Base64 image bytes (no data: prefix)" }),
138
689
  mimeType: Type.String({ description: "image/png or image/jpeg" }),
139
690
  });
140
691
 
692
+ const exampleSchema = Type.Object({
693
+ images: Type.Array(imageSchema, { minItems: 1 }),
694
+ scenario: Type.Optional(Type.String({ description: "What the example shows, in a few words" })),
695
+ analysis: Type.Optional(Type.String({ description: "The expert's analysis of the example (its experimental_significance answer)" })),
696
+ parameters: Type.Optional(
697
+ Type.Union([Type.String(), Type.Record(Type.String(), Type.Unknown())], {
698
+ description: "The parameters read from the example plot, as JSON",
699
+ }),
700
+ ),
701
+ status: Type.Optional(Type.String({ description: "The example's status code (its calibration_diagnosis answer)" })),
702
+ suggested_range: Type.Optional(Type.String({ description: "The example's suggested range, or N/A" })),
703
+ });
704
+
705
+ const criteriaSchema = Type.Object({
706
+ guidance: Type.Optional(Type.Array(Type.String(), { description: "Points the analysis should explain" })),
707
+ parameters: Type.Optional(
708
+ Type.Record(Type.String(), Type.String(), { description: "Parameters to extract: name to type, e.g. float" }),
709
+ ),
710
+ statuses: Type.Optional(
711
+ Type.Record(Type.String(), Type.String(), { description: "Decision criteria: status code to criterion, SUCCESS first" }),
712
+ ),
713
+ range_format: Type.Optional(Type.String({ description: "Format of the suggested range for a failed status" })),
714
+ });
715
+
716
+ const questionSchema = Type.Union(QUESTIONS.map((question) => Type.Literal(question)));
717
+
141
718
  const parameters = Type.Object({
142
719
  context: Type.String({
143
720
  description:
144
- "Experiment context: task name, target qubit/coupling, parameters, and what a successful result looks like",
721
+ "Experiment context the background cannot know: task name, target qubit/coupling, swept range, previous values",
145
722
  }),
146
723
  question: Type.Optional(
147
- Type.String({ description: "A specific question to answer about the plot" }),
724
+ Type.String({ description: "A specific point the analysis should address, on top of the standard questions" }),
148
725
  ),
726
+ task_name: Type.Optional(
727
+ Type.String({
728
+ description:
729
+ "The calibration task's name as the host knows it (e.g. CheckRabi). Lets the host supply the background and criteria for the experiment.",
730
+ }),
731
+ ),
732
+ knowledge: Type.Optional(
733
+ Type.String({
734
+ description:
735
+ "Normally omitted: the host supplies the experiment background for `task_name` (what is swept, what a successful result looks like). Fill only to override it with text you were given.",
736
+ }),
737
+ ),
738
+ criteria: Type.Optional(criteriaSchema),
149
739
  images: Type.Optional(
150
740
  Type.Array(imageSchema, {
151
741
  description:
@@ -160,18 +750,59 @@ const parameters = Type.Object({
160
750
  "How many of the newest figures the host should supply when `images` is omitted (default 1). Use 2 when an expected reference figure precedes the measured one, and describe their order in `context`.",
161
751
  }),
162
752
  ),
753
+ use_examples: Type.Optional(
754
+ Type.Boolean({
755
+ description:
756
+ "Set true only when the user asked to judge against labeled reference examples: the host then supplies its expert-labeled example plots for `task_name` (in-context examples). Default false.",
757
+ }),
758
+ ),
759
+ examples: Type.Optional(
760
+ Type.Array(exampleSchema, {
761
+ maxItems: MAX_EXAMPLES,
762
+ description:
763
+ "Normally omitted: the host supplies labeled examples when `use_examples` is true. Each is one or more real figures with the expert's answers.",
764
+ }),
765
+ ),
766
+ questions: Type.Optional(
767
+ Type.Array(questionSchema, {
768
+ minItems: 1,
769
+ description: "The questions to ask (default: all six, in order).",
770
+ }),
771
+ ),
163
772
  });
164
773
 
165
774
  /** Stored on the tool result; the host reads `diagnosis` without reparsing text. */
166
775
  export interface EvaluationDetails {
167
- model: ModelSpec;
776
+ /** The model that judged the plot; absent when none could be resolved. */
777
+ model?: ModelSpec;
778
+ /** True when no calibration VLM was configured and the conversation's model judged instead. */
779
+ fallback?: boolean;
780
+ /** The parsed answers by question, plus `success`. */
168
781
  diagnosis?: Record<string, unknown> | null;
169
- /** The model's answer when it was not the requested JSON. */
170
- raw?: string;
782
+ /** Every answered question's text, as returned. */
783
+ answers?: Partial<Record<Question, string>>;
784
+ /** The questions asked, in order. */
785
+ questions?: Question[];
786
+ /** Questions asked that got no answer. */
787
+ missing_keys?: Question[];
788
+ /** Model calls made, retries included. */
789
+ attempts?: number;
790
+ /** "length" when a call hit the output budget; otherwise how the calls ended. */
791
+ stop_reason?: string;
792
+ /** Every model call made, by question: duration, stop reason, output tokens. */
793
+ calls?: CallRecord[];
794
+ /** The user turns the model was sent, one per question, so the host can show what it saw. */
795
+ prompt?: string;
796
+ /** How many labeled examples were shown. */
797
+ examples_used?: number;
171
798
  }
172
799
 
173
- function failure(spec: ModelSpec, text: string): AgentToolResult<EvaluationDetails> {
174
- return { content: [{ type: "text", text: `qcal_evaluate: ${text}` }], details: { model: spec }, isError: true };
800
+ function failure(spec: ModelSpec | undefined, text: string): AgentToolResult<EvaluationDetails> {
801
+ return {
802
+ content: [{ type: "text", text: `qcal_evaluate: ${text}` }],
803
+ details: spec ? { model: spec } : {},
804
+ isError: true,
805
+ };
175
806
  }
176
807
 
177
808
  export default function qcalevalExtension(pi: ExtensionAPI) {
@@ -179,11 +810,13 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
179
810
  name: "qcal_evaluate",
180
811
  label: "Evaluate Calibration Plot",
181
812
  description:
182
- "Send a calibration experiment plot to the calibration-evaluation VLM and return its diagnosis: technical description, conclusion, significance, fit quality, extracted parameters, and success.",
813
+ "Send a calibration experiment plot to the calibration-evaluation VLM and return its diagnosis: technical description, outcome classification, significance, fit reliability, extracted parameters, and calibration status.",
183
814
  promptSnippet: "Diagnose a calibration plot with the dedicated calibration VLM",
184
815
  promptGuidelines: [
185
816
  "Use qcal_evaluate for every calibration figure you need to judge, instead of judging it yourself.",
186
- "Pass the task name, target, and expected result as context so the diagnosis is specific.",
817
+ "Pass the task name and target as context so the diagnosis is specific; do not pass fitted values, the model reads the plot.",
818
+ "Pass task_name when you know it: the host then supplies the background and criteria for that task.",
819
+ "Set use_examples true only when the user asked for a judgement against labeled reference examples.",
187
820
  "Treat the returned diagnosis as the evaluation of record; add your own reasoning only on top of it.",
188
821
  ],
189
822
  parameters,
@@ -194,11 +827,10 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
194
827
  _onUpdate: unknown,
195
828
  ctx: ExtensionToolContext,
196
829
  ): Promise<AgentToolResult<EvaluationDetails>> {
197
- const spec = parseModelSpec(process.env.QCALEVAL_MODEL);
198
- const model: Model<Api> | undefined = ctx.modelRegistry.find(spec.provider, spec.modelId);
199
- if (!model) {
200
- return failure(spec, `model ${spec.provider}/${spec.modelId} is not configured (QCALEVAL_MODEL)`);
201
- }
830
+ const resolved = resolveEvaluationModel(ctx.modelRegistry, ctx.model, process.env.QCALEVAL_MODEL);
831
+ if (!resolved.model) return failure(undefined, resolved.error);
832
+ const { model, fallback } = resolved;
833
+ const spec: ModelSpec = { provider: model.provider, modelId: model.id };
202
834
  const images: ImageContent[] = (params.images ?? []).map((image) => ({
203
835
  type: "image",
204
836
  data: image.data,
@@ -208,39 +840,59 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
208
840
  return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
209
841
  }
210
842
 
211
- const message = await ctx.modelRegistry.complete(
212
- model,
213
- {
214
- systemPrompt: SYSTEM_PROMPT,
215
- messages: [
843
+ const requested = new Set<string>(params.questions ?? []);
844
+ const questions: Question[] = requested.size > 0 ? QUESTIONS.filter((question) => requested.has(question)) : [...QUESTIONS];
845
+ const input: PromptInput = {
846
+ background: params.knowledge,
847
+ context: params.context,
848
+ question: params.question,
849
+ criteria: params.criteria,
850
+ images: images.map((image) => ({ data: image.data, mimeType: image.mimeType })),
851
+ examples: params.examples ?? [],
852
+ };
853
+ const turns = new Map(questions.map((question) => [question, buildQuestionContent(question, input)]));
854
+ const examplesUsed = fitExamples(input.examples ?? []).length;
855
+ const answers = await askQuestions(
856
+ questions,
857
+ (question) =>
858
+ ctx.modelRegistry.complete(
859
+ model,
216
860
  {
217
- role: "user",
218
- content: [
861
+ messages: [
219
862
  {
220
- type: "text",
221
- text: buildUserPrompt(params.context, params.question, images.length),
863
+ role: "user",
864
+ content: turns.get(question)!.content.map((block) =>
865
+ block.type === "text"
866
+ ? { type: "text" as const, text: block.text }
867
+ : { type: "image" as const, data: block.data, mimeType: block.mimeType },
868
+ ),
869
+ timestamp: Date.now(),
222
870
  },
223
- ...images,
224
871
  ],
225
- timestamp: Date.now(),
226
872
  },
227
- ],
228
- },
229
- { signal, maxTokens: Number(process.env.QCALEVAL_MAX_TOKENS ?? 2048) },
873
+ { signal, maxTokens: outputBudget(model) },
874
+ ),
230
875
  );
231
- if (message.stopReason === "error") {
232
- return failure(spec, message.errorMessage ?? "model error");
876
+ if (answers.missing.length === questions.length) {
877
+ const error = Object.values(answers.errors)[0];
878
+ return failure(spec, error ?? "the model answered none of the questions");
233
879
  }
234
- const text = answerText(message);
235
- const diagnosis = parseDiagnosis(text);
236
880
  return {
237
- content: [{ type: "text", text: diagnosis ? JSON.stringify(diagnosis, null, 2) : text }],
881
+ content: [{ type: "text", text: JSON.stringify(answers.diagnosis, null, 2) }],
238
882
  details: {
239
883
  model: spec,
240
- diagnosis: diagnosis ?? null,
241
- ...(diagnosis ? {} : { raw: text }),
884
+ ...(fallback ? { fallback: true } : {}),
885
+ diagnosis: answers.diagnosis,
886
+ answers: answers.answers,
887
+ questions: answers.questions,
888
+ ...(answers.missing.length > 0 ? { missing_keys: answers.missing } : {}),
889
+ attempts: answers.attempts,
890
+ stop_reason: answers.stopReason,
891
+ calls: answers.calls,
892
+ prompt: promptRecord(questions.map((question) => ({ question, prompt: turns.get(question)!.prompt }))),
893
+ ...(examplesUsed ? { examples_used: examplesUsed } : {}),
242
894
  },
243
- ...(message.usage ? { usage: message.usage } : {}),
895
+ ...(answers.usage ? { usage: answers.usage } : {}),
244
896
  };
245
897
  },
246
898
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangekame3/pi-qcaleval",
3
- "version": "0.0.1",
3
+ "version": "0.0.2-beta.11",
4
4
  "description": "pi extension that sends calibration plots to a calibration-evaluation VLM such as NVIDIA Ising-Calibration and returns a structured diagnosis",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: qcaleval
3
- description: Evaluate superconducting qubit calibration plots with the dedicated calibration VLM through the pi-qcaleval extension. Use when a calibration figure (Rabi, Ramsey, chevron, T1/T2, readout, spectroscopy, two-qubit tune-up) needs a fit-quality judgement, parameter extraction, or a success verdict.
3
+ description: Evaluate superconducting qubit calibration plots with the dedicated calibration VLM through the pi-qcaleval extension. Use when a calibration figure (Rabi, Ramsey, chevron, T1/T2, readout, spectroscopy, two-qubit tune-up) needs an outcome classification, a fit-reliability judgement, parameter extraction, or a status verdict.
4
4
  ---
5
5
 
6
6
  <!-- Whether a calibration loop continues after an evaluation is the host's policy. This skill describes the tool, not when to stop. -->
@@ -8,28 +8,50 @@ description: Evaluate superconducting qubit calibration plots with the dedicated
8
8
  # Calibration plot evaluation
9
9
 
10
10
  `qcal_evaluate` sends a plot to a calibration-evaluation model (NVIDIA
11
- Ising-Calibration by default) and returns a JSON diagnosis with
12
- `technical_description`, `experimental_conclusion`, `experimental_significance`,
13
- `fit_quality`, `parameter_extraction`, and `success`.
11
+ Ising-Calibration by default) and asks it the six QCalEval questions, each in
12
+ its own request. The diagnosis it returns has one entry per question:
13
+ `technical_description`, `experimental_conclusion` (outcome: expected /
14
+ suboptimal / anomalous / apparatus), `experimental_significance`,
15
+ `fit_reliability` (reliable / unreliable / no_fit), `parameter_extraction`,
16
+ and `calibration_diagnosis` (a status code, a suggested range, notes), plus
17
+ `success` (the status is SUCCESS).
14
18
 
15
19
  ## Preferred tools
16
20
 
17
- - `qcal_evaluate`: judge one or more calibration plots. Always give `context`:
18
- the task name, the target (qubit or coupling), the swept parameter, and what a
19
- successful result looks like. Add `question` when a specific point matters
20
- (for example "is the chevron centred on the current frequency?").
21
+ - `qcal_evaluate`: judge one or more calibration plots. Give `context` for what
22
+ the background cannot know: the task name, the target (qubit or coupling),
23
+ the swept range. Do not put fitted values in it; the model reads the plot.
24
+ Add `question` when a specific point matters (for example "is the chevron
25
+ centred on the current frequency?"); it is added to the significance question.
26
+ - Pass `task_name` whenever you know the task (for example `CheckRabi`): the
27
+ host then supplies the experiment background and the task's criteria (the
28
+ parameters to extract, the status codes), which is more reliable than a
29
+ summary you write yourself.
21
30
  - When the conversation holds an expected reference figure followed by the
22
31
  measured result (a task review opens this way), pass `max_images: 2` and say
23
32
  in `context` that the first figure is the reference and the last the
24
33
  measurement. The host then supplies both; the default is the newest figure only.
34
+ - Pass `questions` to ask a subset, for example `["calibration_diagnosis"]`
35
+ when only the status matters.
36
+
37
+ ## Labeled examples (in-context)
38
+
39
+ - When the user asks to judge a plot against the labeled reference examples
40
+ (in-context examples, few-shot), pass `use_examples: true` with `task_name`.
41
+ The host supplies its expert-labeled example plots for that task, and the
42
+ model sees them before the figure to judge, in the layout of the QCalEval
43
+ few-shot benchmark: each example's status for the status question, each
44
+ example's analysis for the significance question, one example's values for
45
+ parameter extraction. Leave it off otherwise.
25
46
 
26
47
  ## How to use the result
27
48
 
28
49
  - Treat the diagnosis as the evaluation of record; reason on top of it instead
29
50
  of re-judging the plot yourself.
30
- - Report `fit_quality` and `success` verbatim, then the extracted parameters.
31
- - When `success` is false, say what the diagnosis identified as the cause before
32
- proposing a next step.
33
- - The model only sees the plot and the context you pass. If a conclusion needs
34
- data that is not in the plot (previous values, device limits), fetch it and
35
- include it in `context`.
51
+ - Report the status, the fit reliability and the outcome verbatim, then the
52
+ extracted parameters and the suggested range when there is one.
53
+ - When the status is not SUCCESS, say what the notes and the outcome reason
54
+ identified as the cause before proposing a next step.
55
+ - The model only sees the plot, the background and the context you pass. If a
56
+ conclusion needs data that is not in the plot (previous values, device
57
+ limits), fetch it and include it in `context`.