@orangekame3/pi-qcaleval 0.0.2-beta.10 → 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 +58 -24
- package/extensions/qcaleval.ts +556 -246
- package/package.json +1 -1
- package/skills/qcaleval/SKILL.md +29 -19
package/README.md
CHANGED
|
@@ -4,40 +4,71 @@ 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
|
|
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?)`
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
`
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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.
|
|
29
55
|
|
|
30
56
|
`images` are base64 image blocks. A host may fill them in from the most recent
|
|
31
57
|
figures of the conversation, so the planner does not have to pass image bytes;
|
|
32
58
|
`max_images` (default 1, up to 4) says how many of the newest figures to supply,
|
|
33
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)
|
|
34
63
|
|
|
35
|
-
`
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
`
|
|
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.
|
|
41
72
|
|
|
42
73
|
## Configuration
|
|
43
74
|
|
|
@@ -49,7 +80,9 @@ No setting is required. The evaluation model is resolved in this order:
|
|
|
49
80
|
marked `fallback: true` in `details`, so a host without an Ising deployment
|
|
50
81
|
still gets a diagnosis from the vision model it already runs.
|
|
51
82
|
|
|
52
|
-
The output budget follows the model's `maxTokens` (at most 4096).
|
|
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.
|
|
53
86
|
|
|
54
87
|
The provider must be declared in pi's `models.json` with its base URL and
|
|
55
88
|
credential, for example:
|
|
@@ -67,8 +100,9 @@ credential, for example:
|
|
|
67
100
|
}
|
|
68
101
|
```
|
|
69
102
|
|
|
70
|
-
|
|
71
|
-
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.
|
|
72
106
|
|
|
73
107
|
## Installation
|
|
74
108
|
|
package/extensions/qcaleval.ts
CHANGED
|
@@ -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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* the
|
|
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 =
|
|
25
|
-
export const MAX_EXAMPLE_IMAGES =
|
|
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;
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
"
|
|
111
|
+
"fit_reliability",
|
|
104
112
|
"parameter_extraction",
|
|
105
|
-
"
|
|
113
|
+
"calibration_diagnosis",
|
|
106
114
|
] as const;
|
|
115
|
+
export type Question = (typeof QUESTIONS)[number];
|
|
107
116
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
/**
|
|
132
|
-
export const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
/**
|
|
147
|
-
export
|
|
148
|
-
|
|
149
|
-
|
|
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
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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)
|
|
182
|
+
/** One labeled example plot: its figure(s) and the answers an expert validated. */
|
|
165
183
|
export interface LabeledExample {
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
201
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
): string {
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
.
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
+
}
|
|
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");
|
|
269
422
|
}
|
|
270
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
|
|
281
|
-
* or trailing commentary around it: the first balanced
|
|
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
|
|
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
|
-
/**
|
|
325
|
-
export
|
|
326
|
-
text
|
|
327
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
return
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
|
378
|
-
*
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
const
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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" })),
|
|
415
703
|
});
|
|
416
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
|
+
|
|
417
718
|
const parameters = Type.Object({
|
|
418
719
|
context: Type.String({
|
|
419
720
|
description:
|
|
420
|
-
"Experiment context: task name, target qubit/coupling,
|
|
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
|
|
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.
|
|
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
|
|
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,13 @@ 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
|
|
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).",
|
|
462
770
|
}),
|
|
463
771
|
),
|
|
464
772
|
});
|
|
@@ -469,23 +777,24 @@ export interface EvaluationDetails {
|
|
|
469
777
|
model?: ModelSpec;
|
|
470
778
|
/** True when no calibration VLM was configured and the conversation's model judged instead. */
|
|
471
779
|
fallback?: boolean;
|
|
780
|
+
/** The parsed answers by question, plus `success`. */
|
|
472
781
|
diagnosis?: Record<string, unknown> | null;
|
|
473
|
-
/**
|
|
474
|
-
|
|
475
|
-
/**
|
|
476
|
-
|
|
477
|
-
/**
|
|
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. */
|
|
478
789
|
attempts?: number;
|
|
479
|
-
/**
|
|
480
|
-
structured?: boolean;
|
|
481
|
-
/** Why the model stopped, as pi reports it. */
|
|
790
|
+
/** "length" when a call hit the output budget; otherwise how the calls ended. */
|
|
482
791
|
stop_reason?: string;
|
|
483
|
-
/**
|
|
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. */
|
|
484
795
|
prompt?: string;
|
|
485
|
-
/** How many labeled examples were shown
|
|
796
|
+
/** How many labeled examples were shown. */
|
|
486
797
|
examples_used?: number;
|
|
487
|
-
/** The system prompt the model was sent, so the host can show the whole request. */
|
|
488
|
-
system_prompt?: string;
|
|
489
798
|
}
|
|
490
799
|
|
|
491
800
|
function failure(spec: ModelSpec | undefined, text: string): AgentToolResult<EvaluationDetails> {
|
|
@@ -501,12 +810,12 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
|
|
|
501
810
|
name: "qcal_evaluate",
|
|
502
811
|
label: "Evaluate Calibration Plot",
|
|
503
812
|
description:
|
|
504
|
-
"Send a calibration experiment plot to the calibration-evaluation VLM and return its diagnosis: technical description,
|
|
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.",
|
|
505
814
|
promptSnippet: "Diagnose a calibration plot with the dedicated calibration VLM",
|
|
506
815
|
promptGuidelines: [
|
|
507
816
|
"Use qcal_evaluate for every calibration figure you need to judge, instead of judging it yourself.",
|
|
508
|
-
"Pass the task name
|
|
509
|
-
"Pass task_name when you know it: the host then supplies
|
|
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.",
|
|
510
819
|
"Set use_examples true only when the user asked for a judgement against labeled reference examples.",
|
|
511
820
|
"Treat the returned diagnosis as the evaluation of record; add your own reasoning only on top of it.",
|
|
512
821
|
],
|
|
@@ -531,23 +840,28 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
|
|
|
531
840
|
return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
|
|
532
841
|
}
|
|
533
842
|
|
|
534
|
-
const
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
params.
|
|
539
|
-
params.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
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) =>
|
|
543
858
|
ctx.modelRegistry.complete(
|
|
544
859
|
model,
|
|
545
860
|
{
|
|
546
|
-
systemPrompt: SYSTEM_PROMPT,
|
|
547
861
|
messages: [
|
|
548
862
|
{
|
|
549
863
|
role: "user",
|
|
550
|
-
content: content.map((block) =>
|
|
864
|
+
content: turns.get(question)!.content.map((block) =>
|
|
551
865
|
block.type === "text"
|
|
552
866
|
? { type: "text" as const, text: block.text }
|
|
553
867
|
: { type: "image" as const, data: block.data, mimeType: block.mimeType },
|
|
@@ -556,33 +870,29 @@ export default function qcalevalExtension(pi: ExtensionAPI) {
|
|
|
556
870
|
},
|
|
557
871
|
],
|
|
558
872
|
},
|
|
559
|
-
{
|
|
560
|
-
signal,
|
|
561
|
-
maxTokens: outputBudget(model),
|
|
562
|
-
...(structured ? { samplingParams: { response_format: RESPONSE_FORMAT } } : {}),
|
|
563
|
-
},
|
|
873
|
+
{ signal, maxTokens: outputBudget(model) },
|
|
564
874
|
),
|
|
565
|
-
canConstrain(model),
|
|
566
875
|
);
|
|
567
|
-
if (
|
|
568
|
-
|
|
569
|
-
|
|
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");
|
|
879
|
+
}
|
|
570
880
|
return {
|
|
571
|
-
content: [{ type: "text", text:
|
|
881
|
+
content: [{ type: "text", text: JSON.stringify(answers.diagnosis, null, 2) }],
|
|
572
882
|
details: {
|
|
573
883
|
model: spec,
|
|
574
884
|
...(fallback ? { fallback: true } : {}),
|
|
575
|
-
diagnosis: diagnosis
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
stop_reason:
|
|
581
|
-
|
|
582
|
-
|
|
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 }))),
|
|
583
893
|
...(examplesUsed ? { examples_used: examplesUsed } : {}),
|
|
584
894
|
},
|
|
585
|
-
...(
|
|
895
|
+
...(answers.usage ? { usage: answers.usage } : {}),
|
|
586
896
|
};
|
|
587
897
|
},
|
|
588
898
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@orangekame3/pi-qcaleval",
|
|
3
|
-
"version": "0.0.2-beta.
|
|
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": {
|
package/skills/qcaleval/SKILL.md
CHANGED
|
@@ -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-
|
|
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,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
|
|
12
|
-
|
|
13
|
-
`
|
|
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.
|
|
18
|
-
the task name, the target (qubit or coupling),
|
|
19
|
-
|
|
20
|
-
(for example "is the chevron
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
29
36
|
|
|
30
37
|
## Labeled examples (in-context)
|
|
31
38
|
|
|
32
39
|
- When the user asks to judge a plot against the labeled reference examples
|
|
33
40
|
(in-context examples, few-shot), pass `use_examples: true` with `task_name`.
|
|
34
41
|
The host supplies its expert-labeled example plots for that task, and the
|
|
35
|
-
model sees
|
|
36
|
-
|
|
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.
|
|
37
46
|
|
|
38
47
|
## How to use the result
|
|
39
48
|
|
|
40
49
|
- Treat the diagnosis as the evaluation of record; reason on top of it instead
|
|
41
50
|
of re-judging the plot yourself.
|
|
42
|
-
- Report
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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`.
|