@orangekame3/pi-qcaleval 0.0.0-stage → 0.0.2-beta.10

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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright yyyy OQTOPUS Team
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -1,3 +1,98 @@
1
- # Temporary Holding Version
1
+ # pi-qcaleval
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A [pi](https://github.com/badlogic/pi-mono) extension that sends superconducting
4
+ qubit calibration plots to a dedicated calibration-evaluation VLM and returns a
5
+ structured diagnosis. The default model is
6
+ [NVIDIA Ising-Calibration](https://build.nvidia.com/nvidia/ising-calibration-1.5-31b/modelcard)
7
+ served by vLLM.
8
+
9
+ The planner model keeps the conversation; `qcal_evaluate` is the tool it calls
10
+ whenever a calibration figure needs a fit-quality judgement, parameter
11
+ extraction, or a success verdict. The extension has no QDash dependency, so it
12
+ works from the pi CLI and from the QDash Copilot runtime alike.
13
+
14
+ ## Tool
15
+
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`.
29
+
30
+ `images` are base64 image blocks. A host may fill them in from the most recent
31
+ figures of the conversation, so the planner does not have to pass image bytes;
32
+ `max_images` (default 1, up to 4) says how many of the newest figures to supply,
33
+ for example 2 when an expected reference figure precedes the measured result.
34
+
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.
41
+
42
+ ## Configuration
43
+
44
+ No setting is required. The evaluation model is resolved in this order:
45
+
46
+ 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for pi CLI use;
47
+ 2. a configured calibration VLM, recognised by its id (`Ising-Calibration`);
48
+ 3. the conversation's own model, when it accepts images. The result is then
49
+ marked `fallback: true` in `details`, so a host without an Ising deployment
50
+ still gets a diagnosis from the vision model it already runs.
51
+
52
+ The output budget follows the model's `maxTokens` (at most 4096).
53
+
54
+ The provider must be declared in pi's `models.json` with its base URL and
55
+ credential, for example:
56
+
57
+ ```json
58
+ {
59
+ "providers": {
60
+ "vllm": {
61
+ "baseUrl": "https://llm.example/v1",
62
+ "api": "openai-completions",
63
+ "apiKey": "$VLLM_API_KEY",
64
+ "models": [{ "id": "nvidia/Ising-Calibration-1.5-31B-NVFP4", "contextWindow": 16384, "maxTokens": 2048, "input": ["text", "image"] }]
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
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.
72
+
73
+ ## Installation
74
+
75
+ ```bash
76
+ pi install npm:@orangekame3/pi-qcaleval
77
+ ```
78
+
79
+ Releases are tagged `vX.Y.Z` and published to npm as `latest`; every push to
80
+ `main` also publishes a `beta` prerelease (`npm:@orangekame3/pi-qcaleval@beta`)
81
+ for hosts that want an unreleased build. Installing from git
82
+ (`pi install git:github.com/orangekame3/pi-qcaleval`) also works.
83
+
84
+ For development against the QDash Agent Runtime, clone this repository under
85
+ `agent-runtime/extensions/` in the QDash checkout and start the runtime with
86
+ `compose.extensions.yaml`; see the QDash Copilot architecture docs.
87
+
88
+ ## Development
89
+
90
+ ```bash
91
+ bun install
92
+ bun run check # tsc --noEmit
93
+ bun run test # node --test
94
+ ```
95
+
96
+ ## License
97
+
98
+ Apache-2.0
@@ -0,0 +1,589 @@
1
+ /**
2
+ * pi-qcaleval: evaluate calibration plots with a dedicated calibration VLM.
3
+ *
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.
10
+ */
11
+
12
+ import type { Api, AssistantMessage, ImageContent, Model, Usage } from "@earendil-works/pi-ai";
13
+ import type {
14
+ AgentToolResult,
15
+ ExtensionAPI,
16
+ ExtensionToolContext,
17
+ } from "@earendil-works/pi-coding-agent";
18
+ import { Type, type Static } from "typebox";
19
+
20
+ /** Figures per evaluation; a 16k-token deployment holds a few plots comfortably. */
21
+ export const MAX_IMAGES = 4;
22
+
23
+ /** 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;
26
+
27
+ /** Model ids of models fine-tuned for calibration plots; preferred when configured. */
28
+ export const CALIBRATION_MODEL_PATTERN = /ising-calibration/i;
29
+
30
+ /** Output budget when the model declares none; a diagnosis is a few hundred tokens. */
31
+ const DEFAULT_MAX_TOKENS = 2048;
32
+ const MAX_OUTPUT_TOKENS = 4096;
33
+
34
+ export interface ModelSpec {
35
+ provider: string;
36
+ modelId: string;
37
+ }
38
+
39
+ /** Parse an explicit `provider/modelId` override; the model id may contain slashes (nvidia/...). */
40
+ export function parseModelSpec(value: string | undefined): ModelSpec | undefined {
41
+ const spec = (value ?? "").trim();
42
+ if (!spec) return undefined;
43
+ const slash = spec.indexOf("/");
44
+ if (slash <= 0 || slash === spec.length - 1) {
45
+ throw new Error(`QCALEVAL_MODEL must be provider/modelId, got "${spec}"`);
46
+ }
47
+ return { provider: spec.slice(0, slash), modelId: spec.slice(slash + 1) };
48
+ }
49
+
50
+ /** The slice of pi's model registry the resolution needs. */
51
+ export interface ModelLookup {
52
+ find(provider: string, modelId: string): Model<Api> | undefined;
53
+ getAvailable(): Model<Api>[];
54
+ }
55
+
56
+ export type EvaluationModel =
57
+ | { model: Model<Api>; fallback: false }
58
+ | { model: Model<Api>; fallback: true }
59
+ | { model?: undefined; error: string };
60
+
61
+ /**
62
+ * Pick the model that judges the plot, without any host-specific setting:
63
+ *
64
+ * 1. `QCALEVAL_MODEL` (`provider/modelId`), an explicit override for the pi CLI;
65
+ * 2. a configured calibration VLM, recognised by its id (Ising-Calibration);
66
+ * 3. the conversation's own model, when it accepts images.
67
+ *
68
+ * A host that declares the calibration model in its models.json gets it
69
+ * everywhere; one that does not (no Ising deployment) still gets a diagnosis
70
+ * from the vision model it already runs, marked as a fallback.
71
+ */
72
+ export function resolveEvaluationModel(
73
+ registry: ModelLookup,
74
+ current: Model<Api> | undefined,
75
+ override: string | undefined,
76
+ ): EvaluationModel {
77
+ const spec = parseModelSpec(override);
78
+ if (spec) {
79
+ const model = registry.find(spec.provider, spec.modelId);
80
+ return model
81
+ ? { model, fallback: false }
82
+ : { error: `model ${spec.provider}/${spec.modelId} is not configured (QCALEVAL_MODEL)` };
83
+ }
84
+ const calibration = registry.getAvailable().find((model) => CALIBRATION_MODEL_PATTERN.test(model.id));
85
+ if (calibration) return { model: calibration, fallback: false };
86
+ if (current?.input.includes("image")) return { model: current, fallback: true };
87
+ return {
88
+ error:
89
+ "no evaluation model: declare a calibration VLM (Ising-Calibration) in models.json, or run with a vision model",
90
+ };
91
+ }
92
+
93
+ /** Output budget for one evaluation, within what the model allows. */
94
+ export function outputBudget(model: Pick<Model<Api>, "maxTokens">): number {
95
+ return model.maxTokens > 0 ? Math.min(model.maxTokens, MAX_OUTPUT_TOKENS) : DEFAULT_MAX_TOKENS;
96
+ }
97
+
98
+ /** Categories the Ising-Calibration model card describes; asked for in this order. */
99
+ export const DIAGNOSIS_SECTIONS = [
100
+ "technical_description",
101
+ "experimental_conclusion",
102
+ "experimental_significance",
103
+ "fit_quality",
104
+ "parameter_extraction",
105
+ "success",
106
+ ] as const;
107
+
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" },
126
+ },
127
+ required: [...DIAGNOSIS_SECTIONS],
128
+ additionalProperties: false,
129
+ } as const;
130
+
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;
136
+
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
+ }
145
+
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));
150
+ }
151
+
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");
163
+
164
+ /** One labeled example: its figure(s) followed by the expert's diagnosis. */
165
+ export interface LabeledExample {
166
+ label: string;
167
+ images: ReadonlyArray<{ data: string; mimeType: string }>;
168
+ }
169
+
170
+ export type UserContentBlock =
171
+ | { type: "text"; text: string }
172
+ | { type: "image"; data: string; mimeType: string };
173
+
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
+ };
199
+
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);
210
+
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
+ });
216
+
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 };
239
+ }
240
+
241
+ /** Keep examples in order within the example and figure budgets; later ones are dropped. */
242
+ export function fitExamples(examples: ReadonlyArray<LabeledExample>): LabeledExample[] {
243
+ const kept: LabeledExample[] = [];
244
+ let figures = 0;
245
+ for (const example of examples) {
246
+ if (kept.length >= MAX_EXAMPLES) break;
247
+ if (!example.label?.trim() || !example.images?.length) continue;
248
+ if (figures + example.images.length > MAX_EXAMPLE_IMAGES) break;
249
+ figures += example.images.length;
250
+ kept.push(example);
251
+ }
252
+ return kept;
253
+ }
254
+
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();
269
+ }
270
+
271
+ /** Pull the text blocks of the assistant answer, dropping thinking. */
272
+ export function answerText(message: AssistantMessage): string {
273
+ return message.content
274
+ .map((block) => (block.type === "text" ? block.text : ""))
275
+ .join("")
276
+ .trim();
277
+ }
278
+
279
+ /**
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.
282
+ */
283
+ export function parseDiagnosis(text: string): Record<string, unknown> | undefined {
284
+ const start = text.indexOf("{");
285
+ if (start < 0) return undefined;
286
+ let depth = 0;
287
+ let inString = false;
288
+ for (let i = start; i < text.length; i++) {
289
+ const ch = text[i];
290
+ if (inString) {
291
+ if (ch === "\\") i++;
292
+ else if (ch === '"') inString = false;
293
+ continue;
294
+ }
295
+ if (ch === '"') inString = true;
296
+ else if (ch === "{") depth++;
297
+ else if (ch === "}" && --depth === 0) {
298
+ return parseObject(text.slice(start, i + 1));
299
+ }
300
+ }
301
+ return undefined;
302
+ }
303
+
304
+ /**
305
+ * Parse one JSON object. The model writes LaTeX such as `\pi` inside strings,
306
+ * which is an invalid JSON escape, so a failed strict parse is retried with
307
+ * every backslash that does not start a JSON escape doubled.
308
+ */
309
+ function parseObject(json: string): Record<string, unknown> | undefined {
310
+ for (const candidate of [json, json.replace(/\\(?!["\\/bfnrtu])/g, "\\\\")]) {
311
+ try {
312
+ const parsed: unknown = JSON.parse(candidate);
313
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
314
+ return parsed as Record<string, unknown>;
315
+ }
316
+ return undefined;
317
+ } catch {
318
+ // Try the lenient candidate next.
319
+ }
320
+ }
321
+ return undefined;
322
+ }
323
+
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;
336
+ }
337
+
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
+ };
350
+ }
351
+
352
+ /** Token usage of two calls together, so a retried answer is billed as both. */
353
+ export function addUsage(a: Usage | undefined, b: Usage | undefined): Usage | undefined {
354
+ if (!a || !b) return a ?? b;
355
+ const sum = (x: number | undefined, y: number | undefined) => (x ?? 0) + (y ?? 0);
356
+ return {
357
+ ...a,
358
+ input: sum(a.input, b.input),
359
+ output: sum(a.output, b.output),
360
+ cacheRead: sum(a.cacheRead, b.cacheRead),
361
+ cacheWrite: sum(a.cacheWrite, b.cacheWrite),
362
+ ...(a.reasoning === undefined && b.reasoning === undefined
363
+ ? {}
364
+ : { reasoning: sum(a.reasoning, b.reasoning) }),
365
+ totalTokens: sum(a.totalTokens, b.totalTokens),
366
+ cost: {
367
+ input: sum(a.cost?.input, b.cost?.input),
368
+ output: sum(a.cost?.output, b.cost?.output),
369
+ cacheRead: sum(a.cost?.cacheRead, b.cost?.cacheRead),
370
+ cacheWrite: sum(a.cost?.cacheWrite, b.cost?.cacheWrite),
371
+ total: sum(a.cost?.total, b.cost?.total),
372
+ },
373
+ };
374
+ }
375
+
376
+ /**
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.
383
+ */
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);
397
+ }
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) };
405
+ }
406
+
407
+ const imageSchema = Type.Object({
408
+ data: Type.String({ description: "Base64 image bytes (no data: prefix)" }),
409
+ mimeType: Type.String({ description: "image/png or image/jpeg" }),
410
+ });
411
+
412
+ const exampleSchema = Type.Object({
413
+ label: Type.String({ description: "The expert's diagnosis of the example figure(s)" }),
414
+ images: Type.Array(imageSchema, { minItems: 1 }),
415
+ });
416
+
417
+ const parameters = Type.Object({
418
+ context: Type.String({
419
+ description:
420
+ "Experiment context: task name, target qubit/coupling, parameters, and what a successful result looks like",
421
+ }),
422
+ question: Type.Optional(
423
+ Type.String({ description: "A specific question to answer about the plot" }),
424
+ ),
425
+ task_name: Type.Optional(
426
+ Type.String({
427
+ 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.",
429
+ }),
430
+ ),
431
+ knowledge: Type.Optional(
432
+ Type.String({
433
+ 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.",
435
+ }),
436
+ ),
437
+ images: Type.Optional(
438
+ Type.Array(imageSchema, {
439
+ description:
440
+ "Normally omitted: the host supplies the newest figures of the conversation. Fill only with real base64 image bytes you were given; never with placeholders or descriptions.",
441
+ }),
442
+ ),
443
+ max_images: Type.Optional(
444
+ Type.Integer({
445
+ minimum: 1,
446
+ maximum: MAX_IMAGES,
447
+ description:
448
+ "How many of the newest figures the host should supply when `images` is omitted (default 1). Use 2 when an expected reference figure precedes the measured one, and describe their order in `context`.",
449
+ }),
450
+ ),
451
+ use_examples: Type.Optional(
452
+ Type.Boolean({
453
+ description:
454
+ "Set true only when the user asked to judge against labeled reference examples: the host then supplies its expert-labeled example plots for `task_name` (in-context examples). Default false.",
455
+ }),
456
+ ),
457
+ examples: Type.Optional(
458
+ Type.Array(exampleSchema, {
459
+ maxItems: MAX_EXAMPLES,
460
+ 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.",
462
+ }),
463
+ ),
464
+ });
465
+
466
+ /** Stored on the tool result; the host reads `diagnosis` without reparsing text. */
467
+ export interface EvaluationDetails {
468
+ /** The model that judged the plot; absent when none could be resolved. */
469
+ model?: ModelSpec;
470
+ /** True when no calibration VLM was configured and the conversation's model judged instead. */
471
+ fallback?: boolean;
472
+ 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. */
478
+ attempts?: number;
479
+ /** Whether decoding was constrained to the diagnosis schema. */
480
+ structured?: boolean;
481
+ /** Why the model stopped, as pi reports it. */
482
+ stop_reason?: string;
483
+ /** The user turn the model was sent (reference, examples, context, question), so the host can show what it saw. */
484
+ prompt?: string;
485
+ /** How many labeled examples were shown before the figure. */
486
+ examples_used?: number;
487
+ /** The system prompt the model was sent, so the host can show the whole request. */
488
+ system_prompt?: string;
489
+ }
490
+
491
+ function failure(spec: ModelSpec | undefined, text: string): AgentToolResult<EvaluationDetails> {
492
+ return {
493
+ content: [{ type: "text", text: `qcal_evaluate: ${text}` }],
494
+ details: spec ? { model: spec } : {},
495
+ isError: true,
496
+ };
497
+ }
498
+
499
+ export default function qcalevalExtension(pi: ExtensionAPI) {
500
+ pi.registerTool<typeof parameters, EvaluationDetails>({
501
+ name: "qcal_evaluate",
502
+ label: "Evaluate Calibration Plot",
503
+ 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.",
505
+ promptSnippet: "Diagnose a calibration plot with the dedicated calibration VLM",
506
+ promptGuidelines: [
507
+ "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.",
510
+ "Set use_examples true only when the user asked for a judgement against labeled reference examples.",
511
+ "Treat the returned diagnosis as the evaluation of record; add your own reasoning only on top of it.",
512
+ ],
513
+ parameters,
514
+ async execute(
515
+ _toolCallId: string,
516
+ params: Static<typeof parameters>,
517
+ signal: AbortSignal | undefined,
518
+ _onUpdate: unknown,
519
+ ctx: ExtensionToolContext,
520
+ ): Promise<AgentToolResult<EvaluationDetails>> {
521
+ 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 };
525
+ const images: ImageContent[] = (params.images ?? []).map((image) => ({
526
+ type: "image",
527
+ data: image.data,
528
+ mimeType: image.mimeType,
529
+ }));
530
+ if (images.length === 0) {
531
+ return failure(spec, "no plot was given. Pass the figure in `images` or fetch it first.");
532
+ }
533
+
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) =>
543
+ ctx.modelRegistry.complete(
544
+ model,
545
+ {
546
+ systemPrompt: SYSTEM_PROMPT,
547
+ messages: [
548
+ {
549
+ role: "user",
550
+ content: content.map((block) =>
551
+ block.type === "text"
552
+ ? { type: "text" as const, text: block.text }
553
+ : { type: "image" as const, data: block.data, mimeType: block.mimeType },
554
+ ),
555
+ timestamp: Date.now(),
556
+ },
557
+ ],
558
+ },
559
+ {
560
+ signal,
561
+ maxTokens: outputBudget(model),
562
+ ...(structured ? { samplingParams: { response_format: RESPONSE_FORMAT } } : {}),
563
+ },
564
+ ),
565
+ canConstrain(model),
566
+ );
567
+ if ("error" in answer) return failure(spec, answer.error);
568
+ const { text, diagnosis, missing } = answer;
569
+ const complete = diagnosis !== undefined && missing.length === 0;
570
+ return {
571
+ content: [{ type: "text", text: diagnosis ? JSON.stringify(diagnosis, null, 2) : text }],
572
+ details: {
573
+ model: spec,
574
+ ...(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,
581
+ prompt,
582
+ system_prompt: SYSTEM_PROMPT,
583
+ ...(examplesUsed ? { examples_used: examplesUsed } : {}),
584
+ },
585
+ ...(answer.usage ? { usage: answer.usage } : {}),
586
+ };
587
+ },
588
+ });
589
+ }
package/package.json CHANGED
@@ -1,6 +1,52 @@
1
1
  {
2
2
  "name": "@orangekame3/pi-qcaleval",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.2-beta.10",
4
+ "description": "pi extension that sends calibration plots to a calibration-evaluation VLM such as NVIDIA Ising-Calibration and returns a structured diagnosis",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/orangekame3/pi-qcaleval.git"
9
+ },
10
+ "homepage": "https://github.com/orangekame3/pi-qcaleval#readme",
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi-extension",
14
+ "calibration",
15
+ "qdash"
16
+ ],
17
+ "publishConfig": {
18
+ "access": "public"
19
+ },
20
+ "engines": {
21
+ "node": ">=20.0.0"
22
+ },
23
+ "type": "module",
24
+ "files": [
25
+ "extensions",
26
+ "skills"
27
+ ],
28
+ "pi": {
29
+ "extensions": [
30
+ "./extensions/qcaleval.ts"
31
+ ],
32
+ "skills": [
33
+ "./skills"
34
+ ]
35
+ },
36
+ "scripts": {
37
+ "check": "tsc --noEmit",
38
+ "test": "rm -rf .test-dist && tsc --noEmit false --outDir .test-dist && node --test tests/*.test.mjs; status=$?; rm -rf .test-dist; exit $status"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "typebox": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-ai": "1.0.3",
47
+ "@earendil-works/pi-coding-agent": "1.0.3",
48
+ "@types/node": "^22.0.0",
49
+ "typebox": "^1.0.55",
50
+ "typescript": "^5.8.0"
51
+ }
52
+ }
@@ -0,0 +1,47 @@
1
+ ---
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.
4
+ ---
5
+
6
+ <!-- Whether a calibration loop continues after an evaluation is the host's policy. This skill describes the tool, not when to stop. -->
7
+
8
+ # Calibration plot evaluation
9
+
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`.
14
+
15
+ ## Preferred tools
16
+
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
+ - 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.
25
+ - When the conversation holds an expected reference figure followed by the
26
+ measured result (a task review opens this way), pass `max_images: 2` and say
27
+ in `context` that the first figure is the reference and the last the
28
+ measurement. The host then supplies both; the default is the newest figure only.
29
+
30
+ ## Labeled examples (in-context)
31
+
32
+ - When the user asks to judge a plot against the labeled reference examples
33
+ (in-context examples, few-shot), pass `use_examples: true` with `task_name`.
34
+ 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.
37
+
38
+ ## How to use the result
39
+
40
+ - Treat the diagnosis as the evaluation of record; reason on top of it instead
41
+ 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`.