@orangekame3/pi-qcaleval 0.0.2-beta.10 → 0.0.2-beta.12

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,40 +4,74 @@ 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?, task_name?, knowledge?, 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, or not with all of it, `details.raw` holds its text and
21
- `details.missing_keys` the keys it lacks.
22
-
23
- On a chat-completions API (vLLM, SGLang, llama.cpp) the request carries a
24
- `response_format` with the diagnosis schema, so decoding is constrained to the
25
- six keys; a server that refuses it is asked plainly. An answer that still
26
- lacks keys is asked for once more and the more complete one kept. The details
27
- record `attempts` (1 or 2), `structured` (whether decoding was constrained),
28
- and `stop_reason`.
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. `dry_run` builds
41
+ the prompts and returns them (`details.prompts` by question, `details.prompt`
42
+ as one text) without asking the model, so a host can show what a task's
43
+ reference and examples turn into; the figure may be omitted then.
44
+
45
+ `knowledge` is the experiment background the benchmark prepends to Q2–Q6: a few
46
+ sentences on what the experiment sweeps and what a successful result looks like.
47
+ `criteria` fills the family-specific parts of the templates: `guidance` (the
48
+ points Q3 should explain), `parameters` (the Q5 schema, name to type),
49
+ `statuses` (the Q6 decision criteria, code to criterion, `SUCCESS` first) and
50
+ `range_format`. A host that keeps such knowledge per task fills both in from
51
+ `task_name`, so the planner only names the task. Without them the templates use
52
+ generic guidance, ask for whatever parameters the plot determines, and offer
53
+ `SUCCESS` / `SUBOPTIMAL` / `NO_SIGNAL` / `ANOMALOUS`.
54
+
55
+ `context` is what the background cannot know (the task, the target, a swept
56
+ range); the model reads values off the plot, so do not pass fitted results. A
57
+ `question` is added to Q3 as one more point to address.
29
58
 
30
59
  `images` are base64 image blocks. A host may fill them in from the most recent
31
60
  figures of the conversation, so the planner does not have to pass image bytes;
32
61
  `max_images` (default 1, up to 4) says how many of the newest figures to supply,
33
62
  for example 2 when an expected reference figure precedes the measured result.
63
+ The last figure is the one judged; earlier ones are shown as references.
64
+
65
+ ### Labeled examples (in-context)
34
66
 
35
- `knowledge` is the host's reference for the experiment: what a good result
36
- looks like, the questions a reviewer asks, and the common failure patterns. A
37
- host that keeps such knowledge per task fills it in from `task_name`, so the
38
- planner only names the task; the text is sent before the context. The exact
39
- user turn the model received (reference, context, question) is stored in
40
- `details.prompt`, so a host can show what the model saw.
67
+ With `use_examples`, the host supplies `examples`: each is one or more figures
68
+ with the answers an expert validated (`scenario`, `analysis`, `parameters`,
69
+ `status`, `suggested_range`). They are laid out as the benchmark's few-shot
70
+ split lays them out, examples first and the query figure last, and only for the
71
+ three questions the benchmark uses them for: Q3 shows each example's
72
+ `analysis` under `Scenario "<scenario>":`, Q5 shows one example's
73
+ `parameters`, Q6 shows each example's `Status:` and `Suggested range:`. Up to
74
+ six examples and six example figures are shown; the rest are dropped in order.
41
75
 
42
76
  ## Configuration
43
77
 
@@ -49,7 +83,9 @@ No setting is required. The evaluation model is resolved in this order:
49
83
  marked `fallback: true` in `details`, so a host without an Ising deployment
50
84
  still gets a diagnosis from the vision model it already runs.
51
85
 
52
- The output budget follows the model's `maxTokens` (at most 4096).
86
+ The output budget per answer follows the model's `maxTokens` (at most 4096). The
87
+ benchmark decodes greedily; set the model's temperature to 0 for reproducible
88
+ answers.
53
89
 
54
90
  The provider must be declared in pi's `models.json` with its base URL and
55
91
  credential, for example:
@@ -67,8 +103,9 @@ credential, for example:
67
103
  }
68
104
  ```
69
105
 
70
- Only the plot, a short system prompt, and the `context` text are sent, so the
71
- evaluation fits comfortably in a 16k-token deployment.
106
+ Each request carries one plot, the background and one question, so an
107
+ evaluation fits comfortably in a 16k-token deployment; the six requests run at
108
+ once.
72
109
 
73
110
  ## Installation
74
111
 
@@ -2,11 +2,12 @@
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 the host's reference knowledge for
6
- * the task when the host supplies one) and asks the evaluation model, which is
7
- * configured separately from the conversation's model, for a diagnosis. The
8
- * planner decides what to do with it; whether a calibration loop continues is
9
- * 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.
10
11
  */
11
12
 
12
13
  import type { Api, AssistantMessage, ImageContent, Model, Usage } from "@earendil-works/pi-ai";
@@ -21,13 +22,13 @@ import { Type, type Static } from "typebox";
21
22
  export const MAX_IMAGES = 4;
22
23
 
23
24
  /** Labeled example plots shown before the query, and the figures they may hold in total. */
24
- export const MAX_EXAMPLES = 4;
25
- export const MAX_EXAMPLE_IMAGES = 4;
25
+ export const MAX_EXAMPLES = 6;
26
+ export const MAX_EXAMPLE_IMAGES = 6;
26
27
 
27
28
  /** Model ids of models fine-tuned for calibration plots; preferred when configured. */
28
29
  export const CALIBRATION_MODEL_PATTERN = /ising-calibration/i;
29
30
 
30
- /** Output budget when the model declares none; a diagnosis is a few hundred tokens. */
31
+ /** Output budget when the model declares none; one answer is a few hundred tokens. */
31
32
  const DEFAULT_MAX_TOKENS = 2048;
32
33
  const MAX_OUTPUT_TOKENS = 4096;
33
34
 
@@ -90,152 +91,139 @@ export function resolveEvaluationModel(
90
91
  };
91
92
  }
92
93
 
93
- /** Output budget for one evaluation, within what the model allows. */
94
+ /** Output budget for one answer, within what the model allows. */
94
95
  export function outputBudget(model: Pick<Model<Api>, "maxTokens">): number {
95
96
  return model.maxTokens > 0 ? Math.min(model.maxTokens, MAX_OUTPUT_TOKENS) : DEFAULT_MAX_TOKENS;
96
97
  }
97
98
 
98
- /** Categories the Ising-Calibration model card describes; asked for in this order. */
99
- export const DIAGNOSIS_SECTIONS = [
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 = [
100
108
  "technical_description",
101
109
  "experimental_conclusion",
102
110
  "experimental_significance",
103
- "fit_quality",
111
+ "fit_reliability",
104
112
  "parameter_extraction",
105
- "success",
113
+ "calibration_diagnosis",
106
114
  ] as const;
115
+ export type Question = (typeof QUESTIONS)[number];
107
116
 
108
- /**
109
- * The diagnosis as a JSON schema: the six keys, all required. Sent as the
110
- * response format to OpenAI-compatible servers that constrain decoding to a
111
- * schema (vLLM, SGLang, llama.cpp). Asked for JSON in the prompt alone, the
112
- * calibration model now and then closes the object after the first key or
113
- * nests quotes that break it; constrained decoding rules both out.
114
- */
115
- export const DIAGNOSIS_SCHEMA = {
116
- type: "object",
117
- properties: {
118
- technical_description: { type: "string" },
119
- experimental_conclusion: { type: "string" },
120
- experimental_significance: { type: "string" },
121
- fit_quality: { type: "string" },
122
- parameter_extraction: {
123
- anyOf: [{ type: "object", additionalProperties: true }, { type: "string" }, { type: "null" }],
124
- },
125
- success: { type: "boolean" },
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",
126
131
  },
127
- required: [...DIAGNOSIS_SECTIONS],
128
- additionalProperties: false,
129
- } as const;
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"];
130
140
 
131
- /** `response_format` of a chat completion constrained to the diagnosis schema. */
132
- export const RESPONSE_FORMAT = {
133
- type: "json_schema",
134
- json_schema: { name: "calibration_diagnosis", schema: DIAGNOSIS_SCHEMA },
135
- } as const;
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" },
146
+ ] as const;
147
+ export type FitAssessment = (typeof FIT_ASSESSMENTS)[number]["key"];
136
148
 
137
- /**
138
- * Whether the model's API takes `response_format` in the request body. Pi
139
- * merges `samplingParams` into the body of chat completions only; the
140
- * responses API spells the format differently and other APIs ignore it.
141
- */
142
- export function canConstrain(model: Pick<Model<Api>, "api">): boolean {
143
- return model.api === "openai-completions";
144
- }
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;
145
155
 
146
- /** The diagnosis keys an answer lacks; all six when there is no diagnosis. */
147
- export function missingKeys(diagnosis: Record<string, unknown> | undefined): string[] {
148
- if (!diagnosis) return [...DIAGNOSIS_SECTIONS];
149
- return DIAGNOSIS_SECTIONS.filter((key) => !(key in diagnosis));
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;
150
175
  }
151
176
 
152
- export const SYSTEM_PROMPT = [
153
- "You evaluate superconducting qubit calibration experiment plots.",
154
- "Answer with one JSON object and nothing else, with these keys:",
155
- ' "technical_description": what the plot shows (axes, traces, fit);',
156
- ' "experimental_conclusion": what the data says about the experiment;',
157
- ' "experimental_significance": what it means for the calibration;',
158
- ' "fit_quality": "good" | "marginal" | "poor" and why;',
159
- ' "parameter_extraction": extracted values with units, or null;',
160
- ' "success": true | false, whether the experiment achieved its goal.',
161
- "Base every statement on the plot, the reference, and the given context only.",
162
- ].join("\n");
177
+ export interface Figure {
178
+ data: string;
179
+ mimeType: string;
180
+ }
163
181
 
164
- /** One labeled example: its figure(s) followed by the expert's diagnosis. */
182
+ /** One labeled example plot: its figure(s) and the answers an expert validated. */
165
183
  export interface LabeledExample {
166
- label: string;
167
- images: ReadonlyArray<{ data: string; mimeType: string }>;
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;
168
195
  }
169
196
 
170
197
  export type UserContentBlock =
171
198
  | { type: "text"; text: string }
172
199
  | { type: "image"; data: string; mimeType: string };
173
200
 
174
- /**
175
- * The single user turn sent to the evaluation model, as the QCalEval papers
176
- * lay it out: the reference for the experiment, then each labeled example as
177
- * its figure followed by its diagnosis, then the context and question, then
178
- * the figure to judge. Everything is one ordered content array; images are
179
- * native blocks, never placeholders in the text.
180
- *
181
- * `prompt` is the same turn as text, with `[figure]` where an image was, for
182
- * the record of what the model saw.
183
- */
184
- export function buildUserContent(
185
- context: string,
186
- question: string | undefined,
187
- images: ReadonlyArray<{ data: string; mimeType: string }>,
188
- knowledge?: string,
189
- examples: ReadonlyArray<LabeledExample> = [],
190
- ): { content: UserContentBlock[]; prompt: string; examplesUsed: number } {
191
- const content: UserContentBlock[] = [];
192
- const shown = fitExamples(examples);
193
- const text = (lines: string[]) => {
194
- content.push({ type: "text", text: lines.join("\n") });
195
- };
196
- const image = (figure: { data: string; mimeType: string }) => {
197
- content.push({ type: "image", data: figure.data, mimeType: figure.mimeType });
198
- };
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
+ }
199
213
 
200
- const opening: string[] = [];
201
- if (knowledge?.trim()) {
202
- opening.push("Reference for this experiment (expected plot, outcomes, parameters):", knowledge.trim(), "");
203
- }
204
- if (shown.length > 0) {
205
- opening.push(
206
- `${shown.length} labeled example${shown.length === 1 ? "" : "s"} of this experiment follow, each figure followed by the diagnosis an expert validated. Use them as the standard for the figure to judge, which comes last.`,
207
- );
208
- }
209
- if (opening.length > 0) text(opening);
214
+ /** The marker the templates use for the figure to judge, as the benchmark's prompts do. */
215
+ const IMAGE = "<image>";
210
216
 
211
- shown.forEach((example, index) => {
212
- text(["", `Example ${index + 1} of ${shown.length}:`]);
213
- for (const figure of example.images) image(figure);
214
- text([`Diagnosis of example ${index + 1}:`, example.label.trim()]);
215
- });
217
+ function paragraphs(...parts: Array<string | undefined>): string {
218
+ return parts.filter((part): part is string => Boolean(part && part.trim())).join("\n\n");
219
+ }
216
220
 
217
- const closing: string[] = [];
218
- if (shown.length > 0) closing.push("", "Now the figure to judge, evaluated independently of the examples.");
219
- closing.push("Experiment context:", context.trim() || "(none)");
220
- if (images.length > 1) {
221
- closing.push(
222
- "",
223
- `${images.length} figures follow, in the order the context describes; the last one is the measured result to judge.`,
224
- );
225
- }
226
- if (question?.trim()) closing.push("", "Specific question:", question.trim());
227
- // Repeated here because a long reference ahead of the plot pulls the model
228
- // towards prose; the last instruction it reads is the output format.
229
- closing.push("", "Answer with the JSON object described in the system prompt, and nothing else.");
230
- text(closing);
231
- for (const figure of images) image(figure);
232
-
233
- const prompt = content
234
- .map((block) => (block.type === "text" ? block.text : "[figure]"))
235
- .join("\n")
236
- .replace(/\n{3,}/g, "\n\n")
237
- .trim();
238
- return { content, prompt, examplesUsed: shown.length };
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
+ );
239
227
  }
240
228
 
241
229
  /** Keep examples in order within the example and figure budgets; later ones are dropped. */
@@ -244,7 +232,8 @@ export function fitExamples(examples: ReadonlyArray<LabeledExample>): LabeledExa
244
232
  let figures = 0;
245
233
  for (const example of examples) {
246
234
  if (kept.length >= MAX_EXAMPLES) break;
247
- if (!example.label?.trim() || !example.images?.length) continue;
235
+ const labeled = Boolean(example.analysis?.trim() || example.status?.trim() || example.parameters);
236
+ if (!labeled || !example.images?.length) continue;
248
237
  if (figures + example.images.length > MAX_EXAMPLE_IMAGES) break;
249
238
  figures += example.images.length;
250
239
  kept.push(example);
@@ -252,22 +241,190 @@ export function fitExamples(examples: ReadonlyArray<LabeledExample>): LabeledExa
252
241
  return kept;
253
242
  }
254
243
 
255
- /** The user turn as text only (no examples), kept for callers that render the prompt. */
256
- export function buildUserPrompt(
257
- context: string,
258
- question: string | undefined,
259
- imageCount = 1,
260
- knowledge?: string,
261
- ): string {
262
- const placeholder = { data: "", mimeType: "image/png" };
263
- return buildUserContent(context, question, Array(imageCount).fill(placeholder), knowledge)
264
- .content.filter((block) => block.type === "text")
265
- .map((block) => (block as { text: string }).text)
266
- .join("\n")
267
- .replace(/\n{3,}/g, "\n\n")
268
- .trim();
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");
269
254
  }
270
255
 
256
+ /**
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.
260
+ */
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,
393
+ );
394
+ }
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 };
417
+ }
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
+
271
428
  /** Pull the text blocks of the assistant answer, dropping thinking. */
272
429
  export function answerText(message: AssistantMessage): string {
273
430
  return message.content
@@ -277,10 +434,11 @@ export function answerText(message: AssistantMessage): string {
277
434
  }
278
435
 
279
436
  /**
280
- * Parse the model's JSON object, tolerating a code fence, a "thought" preamble
281
- * 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.
282
440
  */
283
- export function parseDiagnosis(text: string): Record<string, unknown> | undefined {
441
+ export function parseJsonObject(text: string): Record<string, unknown> | undefined {
284
442
  const start = text.indexOf("{");
285
443
  if (start < 0) return undefined;
286
444
  let depth = 0;
@@ -321,32 +479,117 @@ function parseObject(json: string): Record<string, unknown> | undefined {
321
479
  return undefined;
322
480
  }
323
481
 
324
- /** One answer of the evaluation model, read for the record. */
325
- export interface Answer {
326
- text: string;
327
- diagnosis?: Record<string, unknown>;
328
- /** Diagnosis keys the answer lacks; empty when it is complete. */
329
- missing: string[];
330
- /** Model calls made for this answer: 1, or 2 after an incomplete or refused first one. */
331
- attempts: number;
332
- /** Whether decoding was constrained to the diagnosis schema. */
333
- structured: boolean;
334
- stopReason: AssistantMessage["stopReason"];
335
- usage?: Usage;
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;
336
486
  }
337
487
 
338
- function readAnswer(message: AssistantMessage, structured: boolean): Answer {
339
- const text = answerText(message);
340
- const diagnosis = parseDiagnosis(text);
341
- return {
342
- text,
343
- diagnosis,
344
- missing: missingKeys(diagnosis),
345
- attempts: 1,
346
- structured,
347
- stopReason: message.stopReason,
348
- usage: message.usage,
349
- };
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>>;
350
593
  }
351
594
 
352
595
  /** Token usage of two calls together, so a retried answer is billed as both. */
@@ -374,66 +617,125 @@ export function addUsage(a: Usage | undefined, b: Usage | undefined): Usage | un
374
617
  }
375
618
 
376
619
  /**
377
- * Ask the model for the diagnosis, in at most two calls. The first call is
378
- * constrained to the schema when the API allows it; a server that refuses the
379
- * response format (an older vLLM, a gateway) gets the plain request instead.
380
- * An answer that lacks diagnosis keys is asked for once more, since the model
381
- * usually completes the object on a second sample, and the more complete of
382
- * the two answers is kept. Usage counts every call made.
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.
383
625
  */
384
- export async function askForDiagnosis(
385
- call: (structured: boolean) => Promise<AssistantMessage>,
386
- constrain: boolean,
387
- ): Promise<Answer | { error: string }> {
388
- let structured = constrain;
389
- let first = await call(structured);
390
- let attempts = 1;
391
- let usage: Usage | undefined = first.usage;
392
- if (first.stopReason === "error" && structured) {
393
- structured = false;
394
- first = await call(structured);
395
- attempts += 1;
396
- usage = addUsage(usage, first.usage);
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);
397
665
  }
398
- if (first.stopReason === "error") return { error: first.errorMessage ?? "model error" };
399
- const answer = { ...readAnswer(first, structured), attempts, usage };
400
- if (answer.missing.length === 0 || attempts === 2) return answer;
401
- const second = await call(structured);
402
- const retried = second.stopReason === "error" ? undefined : readAnswer(second, structured);
403
- const kept = retried && retried.missing.length <= answer.missing.length ? retried : answer;
404
- return { ...kept, attempts: 2, usage: addUsage(first.usage, second.usage) };
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
+ };
405
681
  }
406
682
 
683
+ // ---------------------------------------------------------------------------
684
+ // The tool
685
+ // ---------------------------------------------------------------------------
686
+
407
687
  const imageSchema = Type.Object({
408
688
  data: Type.String({ description: "Base64 image bytes (no data: prefix)" }),
409
689
  mimeType: Type.String({ description: "image/png or image/jpeg" }),
410
690
  });
411
691
 
412
692
  const exampleSchema = Type.Object({
413
- label: Type.String({ description: "The expert's diagnosis of the example figure(s)" }),
414
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" })),
415
714
  });
416
715
 
716
+ const questionSchema = Type.Union(QUESTIONS.map((question) => Type.Literal(question)));
717
+
417
718
  const parameters = Type.Object({
418
719
  context: Type.String({
419
720
  description:
420
- "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",
421
722
  }),
422
723
  question: Type.Optional(
423
- 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" }),
424
725
  ),
425
726
  task_name: Type.Optional(
426
727
  Type.String({
427
728
  description:
428
- "The calibration task's name as the host knows it (e.g. RandomizedBenchmarking). Lets the host supply its reference knowledge for the experiment.",
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.",
429
730
  }),
430
731
  ),
431
732
  knowledge: Type.Optional(
432
733
  Type.String({
433
734
  description:
434
- "Normally omitted: the host supplies the reference for `task_name` (expected result, review questions, failure patterns). Fill only to override it with text you were given.",
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.",
435
736
  }),
436
737
  ),
738
+ criteria: Type.Optional(criteriaSchema),
437
739
  images: Type.Optional(
438
740
  Type.Array(imageSchema, {
439
741
  description:
@@ -458,7 +760,19 @@ const parameters = Type.Object({
458
760
  Type.Array(exampleSchema, {
459
761
  maxItems: MAX_EXAMPLES,
460
762
  description:
461
- "Normally omitted: the host supplies labeled examples when `use_examples` is true. Each is one or more real figures followed by the expert's diagnosis.",
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
+ ),
772
+ dry_run: Type.Optional(
773
+ Type.Boolean({
774
+ description:
775
+ "Build the prompts and return them without asking the model, to show what it would be sent. The figure may be omitted.",
462
776
  }),
463
777
  ),
464
778
  });
@@ -469,23 +783,28 @@ export interface EvaluationDetails {
469
783
  model?: ModelSpec;
470
784
  /** True when no calibration VLM was configured and the conversation's model judged instead. */
471
785
  fallback?: boolean;
786
+ /** The parsed answers by question, plus `success`. */
472
787
  diagnosis?: Record<string, unknown> | null;
473
- /** The model's answer when it was not the requested JSON, or not all of it. */
474
- raw?: string;
475
- /** Diagnosis keys the parsed answer lacks, when it is incomplete. */
476
- missing_keys?: string[];
477
- /** Model calls made: 1, or 2 after an incomplete or refused first answer. */
788
+ /** Every answered question's text, as returned. */
789
+ answers?: Partial<Record<Question, string>>;
790
+ /** The questions asked, in order. */
791
+ questions?: Question[];
792
+ /** Questions asked that got no answer. */
793
+ missing_keys?: Question[];
794
+ /** Model calls made, retries included. */
478
795
  attempts?: number;
479
- /** Whether decoding was constrained to the diagnosis schema. */
480
- structured?: boolean;
481
- /** Why the model stopped, as pi reports it. */
796
+ /** "length" when a call hit the output budget; otherwise how the calls ended. */
482
797
  stop_reason?: string;
483
- /** The user turn the model was sent (reference, examples, context, question), so the host can show what it saw. */
798
+ /** Every model call made, by question: duration, stop reason, output tokens. */
799
+ calls?: CallRecord[];
800
+ /** The user turns the model was sent, one per question, so the host can show what it saw. */
484
801
  prompt?: string;
485
- /** How many labeled examples were shown before the figure. */
802
+ /** The same turns by question. */
803
+ prompts?: Partial<Record<Question, string>>;
804
+ /** How many labeled examples were shown. */
486
805
  examples_used?: number;
487
- /** The system prompt the model was sent, so the host can show the whole request. */
488
- system_prompt?: string;
806
+ /** True when the prompts were built but the model was not asked. */
807
+ dry_run?: boolean;
489
808
  }
490
809
 
491
810
  function failure(spec: ModelSpec | undefined, text: string): AgentToolResult<EvaluationDetails> {
@@ -501,12 +820,12 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
501
820
  name: "qcal_evaluate",
502
821
  label: "Evaluate Calibration Plot",
503
822
  description:
504
- "Send a calibration experiment plot to the calibration-evaluation VLM and return its diagnosis: technical description, conclusion, significance, fit quality, extracted parameters, and success.",
823
+ "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.",
505
824
  promptSnippet: "Diagnose a calibration plot with the dedicated calibration VLM",
506
825
  promptGuidelines: [
507
826
  "Use qcal_evaluate for every calibration figure you need to judge, instead of judging it yourself.",
508
- "Pass the task name, target, and expected result as context so the diagnosis is specific.",
509
- "Pass task_name when you know it: the host then supplies its reference knowledge for that task.",
827
+ "Pass the task name and target as context so the diagnosis is specific; do not pass fitted values, the model reads the plot.",
828
+ "Pass task_name when you know it: the host then supplies the background and criteria for that task.",
510
829
  "Set use_examples true only when the user asked for a judgement against labeled reference examples.",
511
830
  "Treat the returned diagnosis as the evaluation of record; add your own reasoning only on top of it.",
512
831
  ],
@@ -519,35 +838,63 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
519
838
  ctx: ExtensionToolContext,
520
839
  ): Promise<AgentToolResult<EvaluationDetails>> {
521
840
  const resolved = resolveEvaluationModel(ctx.modelRegistry, ctx.model, process.env.QCALEVAL_MODEL);
522
- if (!resolved.model) return failure(undefined, resolved.error);
523
- const { model, fallback } = resolved;
524
- const spec: ModelSpec = { provider: model.provider, modelId: model.id };
841
+ const dryRun = params.dry_run === true;
842
+ if (!resolved.model && !dryRun) return failure(undefined, resolved.error);
843
+ const spec: ModelSpec | undefined = resolved.model
844
+ ? { provider: resolved.model.provider, modelId: resolved.model.id }
845
+ : undefined;
525
846
  const images: ImageContent[] = (params.images ?? []).map((image) => ({
526
847
  type: "image",
527
848
  data: image.data,
528
849
  mimeType: image.mimeType,
529
850
  }));
530
851
  if (images.length === 0) {
531
- return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
852
+ if (!dryRun) return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
853
+ // The prompts are the same whatever the figure; a placeholder stands in.
854
+ images.push({ type: "image", data: "", mimeType: "image/png" });
532
855
  }
533
856
 
534
- const { content, prompt, examplesUsed } = buildUserContent(
535
- params.context,
536
- params.question,
537
- images.map((image) => ({ data: image.data, mimeType: image.mimeType })),
538
- params.knowledge,
539
- params.examples ?? [],
540
- );
541
- const answer = await askForDiagnosis(
542
- (structured) =>
857
+ const requested = new Set<string>(params.questions ?? []);
858
+ const questions: Question[] = requested.size > 0 ? QUESTIONS.filter((question) => requested.has(question)) : [...QUESTIONS];
859
+ const input: PromptInput = {
860
+ background: params.knowledge,
861
+ context: params.context,
862
+ question: params.question,
863
+ criteria: params.criteria,
864
+ images: images.map((image) => ({ data: image.data, mimeType: image.mimeType })),
865
+ examples: params.examples ?? [],
866
+ };
867
+ const turns = new Map(questions.map((question) => [question, buildQuestionContent(question, input)]));
868
+ const examplesUsed = fitExamples(input.examples ?? []).length;
869
+ const prompts = Object.fromEntries(questions.map((question) => [question, turns.get(question)!.prompt])) as Partial<
870
+ Record<Question, string>
871
+ >;
872
+ const prompt = promptRecord(questions.map((question) => ({ question, prompt: turns.get(question)!.prompt })));
873
+ if (dryRun || !resolved.model) {
874
+ return {
875
+ content: [{ type: "text", text: prompt }],
876
+ details: {
877
+ ...(spec ? { model: spec } : {}),
878
+ ...(resolved.model && resolved.fallback ? { fallback: true } : {}),
879
+ dry_run: true,
880
+ questions,
881
+ prompt,
882
+ prompts,
883
+ ...(examplesUsed ? { examples_used: examplesUsed } : {}),
884
+ },
885
+ };
886
+ }
887
+ const { model, fallback } = resolved;
888
+ const answers = await askQuestions(
889
+ questions,
890
+ (question) =>
543
891
  ctx.modelRegistry.complete(
544
892
  model,
545
893
  {
546
- systemPrompt: SYSTEM_PROMPT,
547
894
  messages: [
548
895
  {
549
896
  role: "user",
550
- content: content.map((block) =>
897
+ content: turns.get(question)!.content.map((block) =>
551
898
  block.type === "text"
552
899
  ? { type: "text" as const, text: block.text }
553
900
  : { type: "image" as const, data: block.data, mimeType: block.mimeType },
@@ -556,33 +903,30 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
556
903
  },
557
904
  ],
558
905
  },
559
- {
560
- signal,
561
- maxTokens: outputBudget(model),
562
- ...(structured ? { samplingParams: { response_format: RESPONSE_FORMAT } } : {}),
563
- },
906
+ { signal, maxTokens: outputBudget(model) },
564
907
  ),
565
- canConstrain(model),
566
908
  );
567
- if ("error" in answer) return failure(spec, answer.error);
568
- const { text, diagnosis, missing } = answer;
569
- const complete = diagnosis !== undefined && missing.length === 0;
909
+ if (answers.missing.length === questions.length) {
910
+ const error = Object.values(answers.errors)[0];
911
+ return failure(spec, error ?? "the model answered none of the questions");
912
+ }
570
913
  return {
571
- content: [{ type: "text", text: diagnosis ? JSON.stringify(diagnosis, null, 2) : text }],
914
+ content: [{ type: "text", text: JSON.stringify(answers.diagnosis, null, 2) }],
572
915
  details: {
573
916
  model: spec,
574
917
  ...(fallback ? { fallback: true } : {}),
575
- diagnosis: diagnosis ?? null,
576
- ...(complete ? {} : { raw: text }),
577
- ...(diagnosis && !complete ? { missing_keys: missing } : {}),
578
- attempts: answer.attempts,
579
- structured: answer.structured,
580
- stop_reason: answer.stopReason,
918
+ diagnosis: answers.diagnosis,
919
+ answers: answers.answers,
920
+ questions: answers.questions,
921
+ ...(answers.missing.length > 0 ? { missing_keys: answers.missing } : {}),
922
+ attempts: answers.attempts,
923
+ stop_reason: answers.stopReason,
924
+ calls: answers.calls,
581
925
  prompt,
582
- system_prompt: SYSTEM_PROMPT,
926
+ prompts,
583
927
  ...(examplesUsed ? { examples_used: examplesUsed } : {}),
584
928
  },
585
- ...(answer.usage ? { usage: answer.usage } : {}),
929
+ ...(answers.usage ? { usage: answers.usage } : {}),
586
930
  };
587
931
  },
588
932
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orangekame3/pi-qcaleval",
3
- "version": "0.0.2-beta.10",
3
+ "version": "0.0.2-beta.12",
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,40 +8,53 @@ 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.
21
26
  - Pass `task_name` whenever you know the task (for example `CheckRabi`): the
22
- host then supplies its own reference knowledge for that experiment, which is
23
- more reliable than a summary you write yourself. Keep `context` for what the
24
- reference cannot know: the target, the swept range, and previous values.
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.
25
30
  - When the conversation holds an expected reference figure followed by the
26
31
  measured result (a task review opens this way), pass `max_images: 2` and say
27
32
  in `context` that the first figure is the reference and the last the
28
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
+ - Pass `dry_run: true` to see the prompts the model would get (per question)
37
+ without asking it, for example when the user wants to check a task's
38
+ reference before judging.
29
39
 
30
40
  ## Labeled examples (in-context)
31
41
 
32
42
  - When the user asks to judge a plot against the labeled reference examples
33
43
  (in-context examples, few-shot), pass `use_examples: true` with `task_name`.
34
44
  The host supplies its expert-labeled example plots for that task, and the
35
- model sees each example figure followed by its diagnosis before the figure
36
- to judge, as in the QCalEval papers. Leave it off otherwise.
45
+ model sees them before the figure to judge, in the layout of the QCalEval
46
+ few-shot benchmark: each example's status for the status question, each
47
+ example's analysis for the significance question, one example's values for
48
+ parameter extraction. Leave it off otherwise.
37
49
 
38
50
  ## How to use the result
39
51
 
40
52
  - Treat the diagnosis as the evaluation of record; reason on top of it instead
41
53
  of re-judging the plot yourself.
42
- - Report `fit_quality` and `success` verbatim, then the extracted parameters.
43
- - When `success` is false, say what the diagnosis identified as the cause before
44
- proposing a next step.
45
- - The model only sees the plot and the context you pass. If a conclusion needs
46
- data that is not in the plot (previous values, device limits), fetch it and
47
- include it in `context`.
54
+ - Report the status, the fit reliability and the outcome verbatim, then the
55
+ extracted parameters and the suggested range when there is one.
56
+ - When the status is not SUCCESS, say what the notes and the outcome reason
57
+ identified as the cause before proposing a next step.
58
+ - The model only sees the plot, the background and the context you pass. If a
59
+ conclusion needs data that is not in the plot (previous values, device
60
+ limits), fetch it and include it in `context`.