skillfid 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waldek Mastykarz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,303 @@
1
+ # skillfid
2
+
3
+ `skillfid` is a CLI for developers who build agent skills from documentation. It tests whether a skill helps an agent answer questions grounded in that source, revealing missing or imprecise guidance that reviewing the skill alone can miss. Use it to compare skill revisions against a reusable closed-book baseline and identify source-backed improvements.
4
+
5
+ ![Canopy skill evaluation report showing an 83.91% skill-assisted score and actionable diagnoses](examples/canopy/assets/evaluation-report.png)
6
+
7
+ ## Explore the Canopy result
8
+
9
+ > Canopy, is a synthetic distributed build cache used to demonstrate the complete workflow.
10
+
11
+ Inspect the included evaluation and trace its findings to the recorded data without
12
+ making a model call:
13
+
14
+ - [Open the self-contained HTML report](examples/canopy/runs/run_1689576d3f9343ce/report.html)
15
+ - Inspect the run's [summary](examples/canopy/runs/run_1689576d3f9343ce/summary.json), [diagnoses](examples/canopy/runs/run_1689576d3f9343ce/diagnoses.jsonl), and [recorded answers](examples/canopy/runs/run_1689576d3f9343ce/answers.jsonl)
16
+ - Trace findings through the [generated questions](examples/canopy/dataset/ds_7f1bc4dc92fc4b51/questions.jsonl), [evidence](examples/canopy/dataset/ds_7f1bc4dc92fc4b51/evidence.jsonl), and [source corpus](examples/canopy/corpus/cache-operations.md)
17
+ - Compare the intentionally compressed [original skill](examples/canopy/skill/SKILL.md) with the source-faithful [v2 skill](examples/canopy/skill-v2/SKILL.md)
18
+
19
+ The original skill scored **83.91%**, compared with **0%** closed book. After
20
+ source-backed improvements, v2 scored **100%** against the same dataset and
21
+ baseline. Both results used `gpt-5.6-sol` as subject and judge with three trials per
22
+ question. Scores are specific to the dataset and evaluation configuration.
23
+
24
+ ## Run the complete Canopy flow
25
+
26
+ Complete the [requirements](#requirements) and [installation](#installation) first.
27
+ Then build an immutable dataset from the Canopy corpus:
28
+
29
+ ```sh
30
+ skillfid --progress human dataset build \
31
+ --corpus ./examples/canopy/corpus \
32
+ --output-dir ./.work/canopy/datasets \
33
+ --work-dir ./.work/canopy/dataset-work
34
+ ```
35
+
36
+ Set `DATASET` to the published path printed by the command, then verify it locally:
37
+
38
+ ```sh
39
+ DATASET=./.work/canopy/datasets/<dataset-id>
40
+
41
+ skillfid dataset verify --dataset "$DATASET"
42
+ ```
43
+
44
+ Create the reusable closed-book baseline:
45
+
46
+ ```sh
47
+ skillfid --progress human eval baseline \
48
+ --dataset "$DATASET" \
49
+ --output-dir ./.work/canopy/baselines \
50
+ --work-dir ./.work/canopy/baseline-work
51
+ ```
52
+
53
+ Evaluate the original skill with explicit invocation. The run reuses the compatible
54
+ baseline automatically:
55
+
56
+ ```sh
57
+ skillfid --progress human eval run \
58
+ --dataset "$DATASET" \
59
+ --skill ./examples/canopy/skill \
60
+ --baseline-dir ./.work/canopy/baselines \
61
+ --skill-invocation explicit \
62
+ --output-dir ./.work/canopy/runs \
63
+ --work-dir ./.work/canopy/eval-work
64
+ ```
65
+
66
+ Set `RUN` to the output path, then generate the report locally:
67
+
68
+ ```sh
69
+ RUN=./.work/canopy/runs/<run-id>
70
+
71
+ skillfid eval report \
72
+ --run "$RUN" \
73
+ --dataset "$DATASET" \
74
+ --title "Canopy cache operations"
75
+ ```
76
+
77
+ To evaluate the improved skill, rerun `eval run` with
78
+ `--skill ./examples/canopy/skill-v2`. See the
79
+ [Canopy walkthrough](examples/canopy/README.md) for the checked-in artifacts and
80
+ reproduction paths. If a model-backed command is interrupted, use the resume command
81
+ printed by the CLI.
82
+
83
+ ## Requirements
84
+
85
+ Before running `skillfid`, make sure that you have:
86
+
87
+ - Node.js 24 or later
88
+ - GitHub Copilot CLI authenticated for Copilot access
89
+
90
+ Check them with `node --version` and `copilot --version`. The default model for both
91
+ subject and judge is `gpt-5.6-sol`; you can override either per command. Evaluation
92
+ runs use an isolated repository-local profile and do not expose authentication
93
+ tokens to agent tools.
94
+
95
+ ## Installation
96
+
97
+ Install `skillfid` from npm:
98
+
99
+ ```sh
100
+ npm install --global skillfid
101
+ ```
102
+
103
+ For a minimal model-backed smoke test, use the one-fact
104
+ [Arbor example](examples/arbor/README.md).
105
+
106
+ ## Command reference
107
+
108
+ Build an immutable, oracle-calibrated dataset from a Markdown corpus:
109
+
110
+ ```sh
111
+ skillfid dataset build --corpus ./corpus --json
112
+ ```
113
+
114
+ Every generated question must produce an oracle answer that receives a stable,
115
+ perfect criterion-level judgment before publication. The same answer is judged
116
+ independently three times. Unanimous results are accepted; disagreement triggers
117
+ two more judgments, and only a 4/5 result is accepted. A 3/2 split is unstable and
118
+ blocks publication.
119
+
120
+ The dataset stores every judgment and its consensus in `calibrations.jsonl`; the
121
+ corpus remains the sole ground truth. Datasets older than schema v6 lack the combined
122
+ structural and integrity proof and must be rebuilt.
123
+
124
+ After changing the Copilot runtime, model, judge, or harness, recalibrate without
125
+ repeating corpus inventory or question extraction:
126
+
127
+ ```sh
128
+ skillfid dataset recalibrate \
129
+ --dataset ./datasets/<dataset-id> \
130
+ --output-dir ./datasets \
131
+ --json
132
+ ```
133
+
134
+ Recalibration copies documents, knowledge, evidence, questions, verification,
135
+ coverage, and audit records unchanged. It reruns only the oracle answer and
136
+ independent consensus judgments for each question. The oracle answer is generated
137
+ once and held fixed across all judge repeats. Every question must still receive a
138
+ stable, perfect calibration before publication.
139
+
140
+ The result is a new immutable dataset whose manifest records `sourceDatasetId`.
141
+ Continue interrupted recalibration with the same command and `--resume`.
142
+
143
+ Verify a dataset locally without rerunning extraction:
144
+
145
+ ```sh
146
+ skillfid dataset verify --dataset ./datasets/<dataset-id> --json
147
+ ```
148
+
149
+ Measure the reusable closed-book baseline:
150
+
151
+ ```sh
152
+ skillfid eval baseline \
153
+ --dataset ./datasets/<dataset-id> \
154
+ --json
155
+ ```
156
+
157
+ Evaluate a skill using the latest exactly compatible baseline:
158
+
159
+ ```sh
160
+ skillfid eval run \
161
+ --dataset ./datasets/<dataset-id> \
162
+ --skill ./path/to/skill \
163
+ --json
164
+ ```
165
+
166
+ Skill activation is automatic by default. To invoke the discovered project skill as
167
+ `/skill-name` in every skill-condition prompt, use explicit invocation:
168
+
169
+ ```sh
170
+ skillfid eval run \
171
+ --dataset ./datasets/<dataset-id> \
172
+ --skill ./path/to/skill \
173
+ --skill-invocation explicit \
174
+ --json
175
+ ```
176
+
177
+ `--skill-invocation` accepts `auto` or `explicit`. It changes only the skill
178
+ condition. The resolved mode is recorded in the run manifest; the baseline does not
179
+ change.
180
+
181
+ Oracle calibration is a dataset publication gate, not an evaluation condition or
182
+ model-specific ceiling. Baseline and skill model settings inherit from the dataset
183
+ by default, but both commands may select another model configuration.
184
+
185
+ A compatible baseline must match the dataset ID, subject model, judge model,
186
+ reasoning effort, trial count, evaluator version, and Copilot CLI version exactly.
187
+ Evaluation fails with an actionable message when none exists.
188
+
189
+ Both commands default to three trials per question. Use `--trials <count>`
190
+ consistently to override that default. When storing baselines outside
191
+ `./baselines`, use matching `--output-dir` on `eval baseline` and `--baseline-dir`
192
+ on `eval run`.
193
+
194
+ Generate a self-contained HTML report:
195
+
196
+ ```sh
197
+ skillfid eval report \
198
+ --run ./runs/<run-id> \
199
+ --dataset ./datasets/<dataset-id> \
200
+ --title "My skill"
201
+ ```
202
+
203
+ The report defaults to `<run>/report.html`. Use `--output <file>` to choose another
204
+ location. Report generation is local and makes no Copilot calls.
205
+
206
+ ## Execution and recovery
207
+
208
+ Run `skillfid --help` for the complete command reference, including JSON
209
+ schemas, prerequisites, and exit codes. Primary output goes to stdout, while
210
+ progress and errors go to stderr.
211
+
212
+ Copilot calls have a 600-second timeout and one fresh-session retry by default.
213
+ Configure them with `--timeout <seconds>` and `--timeout-retries <count>`.
214
+
215
+ Dataset builds and evaluations run independent work concurrently. The default
216
+ is 10; set `--concurrency <count>` to any positive integer. Answers and judgments
217
+ are separate recovery checkpoints, so interruption after answering does not
218
+ require generating that answer again.
219
+
220
+ Matching operations reuse completed work by default. Resume selects the latest
221
+ matching incomplete operation, including one originally started with `--fresh`.
222
+ Validated jobs are stored in
223
+ `<work-dir>/operations.sqlite` using SQLite WAL and retained after completion.
224
+
225
+ Use `--fresh` to start from scratch without deleting earlier state. When an
226
+ interactive operation is interrupted, the CLI prints the exact resume command, so
227
+ you do not need to reconstruct it.
228
+
229
+ Inspect retained operations without modifying the journal:
230
+
231
+ ```sh
232
+ skillfid operation status --work-dir .work/eval --json
233
+ skillfid operation status --work-dir .work/eval --operation-id <id> --json
234
+ ```
235
+
236
+ All commands support `--progress auto|human|agent|json|quiet`. Auto selects an
237
+ in-place display on a TTY and bounded agent snapshots otherwise. Human mode shows
238
+ current work and elapsed time, followed by an estimate and a compact result. JSON
239
+ progress is emitted as JSON Lines on stderr without changing final stdout.
240
+
241
+ ## Artifacts and scoring
242
+
243
+ Each baseline stores its closed-book answers and judgments with an exact
244
+ compatibility manifest. Each skill run embeds those baseline records alongside
245
+ fresh skill answers. You will find the answers in `answers.jsonl`, criterion-level
246
+ judgments in `judgments.jsonl`, failure analysis in `diagnoses.jsonl`, and aggregate
247
+ scores in `summary.json`.
248
+
249
+ Embedding baseline records keeps reports self-contained without repeating closed-book
250
+ inference. The summary retains build-time calibration metadata for compatibility;
251
+ calibration is not an evaluation condition or model call.
252
+
253
+ HTML reports preserve that separation. Scores come from all recorded trials, and
254
+ uplift is shown in percentage points. Only diagnoses with concrete file targets
255
+ appear as recommended work; the evidence view retains every trial answer and its
256
+ failed-criterion rationale.
257
+
258
+ ## Isolation and safety
259
+
260
+ Every question, trial, and condition runs in a fresh non-resumed Copilot SDK session
261
+ with its own filesystem workspace. Subject sessions share one client, and judge
262
+ sessions share another. Conversation and workspace state are not reused.
263
+
264
+ Evaluation calls deny shell execution, file writes, and URL access. This stops the
265
+ closed-book baseline from searching external sources while preserving local read
266
+ access for skill files. Service, authentication, and validation failures surface
267
+ immediately while completed journal checkpoints remain resumable.
268
+
269
+ See [DESIGN.md](DESIGN.md) for the architecture, scheduler behavior, and scoring
270
+ model.
271
+
272
+ ## Development
273
+
274
+ Run the tests:
275
+
276
+ ```sh
277
+ npm test
278
+ ```
279
+
280
+ Ferryline is the synthetic distributed build-cache scenario from GitHub Next's
281
+ Knowledge Compressor article. Reproduce its calibration from a repository checkout;
282
+ the extraction script requires internet access to fetch the article:
283
+
284
+ ```sh
285
+ npm run calibration:extract
286
+ npm start -- dataset build \
287
+ --corpus .work/js-calibration/article/corpus \
288
+ --output-dir .work/js-calibration/datasets \
289
+ --json
290
+ npm run calibration:compare -- \
291
+ .work/js-calibration/datasets/<dataset-id> \
292
+ .work/js-calibration/article/reference-questions.json
293
+ ```
294
+
295
+ ## Support and contributing
296
+
297
+ Report defects and request features in
298
+ [GitHub Issues](https://github.com/waldekmastykarz/knowledge-eval/issues). Read
299
+ [CONTRIBUTING.md](CONTRIBUTING.md) before opening a pull request.
300
+
301
+ ## License
302
+
303
+ Licensed under the [MIT License](LICENSE).
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "skillfid",
3
+ "version": "0.1.0",
4
+ "description": "Evaluate how faithfully agent skills apply their source documentation",
5
+ "keywords": [
6
+ "agent-skills",
7
+ "copilot",
8
+ "evaluation",
9
+ "grounding"
10
+ ],
11
+ "homepage": "https://github.com/waldekmastykarz/skillfid#readme",
12
+ "bugs": "https://github.com/waldekmastykarz/skillfid/issues",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/waldekmastykarz/skillfid.git"
16
+ },
17
+ "license": "MIT",
18
+ "author": "Waldek Mastykarz <waldek@mastykarz.nl>",
19
+ "type": "module",
20
+ "files": [
21
+ "src/",
22
+ "templates/evaluation-report.html"
23
+ ],
24
+ "bin": {
25
+ "skillfid": "./src/cli.js"
26
+ },
27
+ "scripts": {
28
+ "calibration:extract": "node scripts/extract-ferryline.js",
29
+ "calibration:compare": "node scripts/compare-questions.js",
30
+ "test": "node --test",
31
+ "start": "node src/cli.js"
32
+ },
33
+ "engines": {
34
+ "node": ">=24"
35
+ },
36
+ "dependencies": {
37
+ "@github/copilot-sdk": "^1.0.11"
38
+ }
39
+ }
@@ -0,0 +1,85 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { readJson, readJsonl, writeJson, writeJsonl } from './files.js';
6
+ import { stableStringify } from './json.js';
7
+
8
+ export class BaselineError extends Error {}
9
+
10
+ export function baselineCompatibilityKey(compatibility) {
11
+ return createHash('sha256').update(stableStringify(compatibility), 'utf8').digest('hex');
12
+ }
13
+
14
+ export async function writeBaseline({ outputRoot, compatibility, answers, judgments }) {
15
+ assertBaselineRecords(answers, judgments);
16
+ const baselineId = `baseline_${randomUUID().replaceAll('-', '').slice(0, 16)}`;
17
+ const root = path.resolve(outputRoot);
18
+ await mkdir(root, { recursive: true });
19
+ const destination = path.join(root, baselineId);
20
+ await mkdir(destination, { recursive: false });
21
+ await writeJsonl(path.join(destination, 'answers.jsonl'), answers);
22
+ await writeJsonl(path.join(destination, 'judgments.jsonl'), judgments);
23
+ await writeJson(path.join(destination, 'manifest.json'), {
24
+ baselineId,
25
+ compatibilityKey: baselineCompatibilityKey(compatibility),
26
+ createdAt: new Date().toISOString(),
27
+ ...compatibility,
28
+ });
29
+ return destination;
30
+ }
31
+
32
+ export async function findCompatibleBaseline({ outputRoot, compatibility }) {
33
+ const root = path.resolve(outputRoot);
34
+ let entries;
35
+ try { entries = await readdir(root, { withFileTypes: true }); }
36
+ catch (error) {
37
+ if (error.code === 'ENOENT') return undefined;
38
+ throw error;
39
+ }
40
+ const compatibilityKey = baselineCompatibilityKey(compatibility);
41
+ const candidates = [];
42
+ for (const entry of entries) {
43
+ if (!entry.isDirectory() || !entry.name.startsWith('baseline_')) continue;
44
+ const baselinePath = path.join(root, entry.name);
45
+ const manifest = await readJson(path.join(baselinePath, 'manifest.json'));
46
+ if (manifest.compatibilityKey === compatibilityKey) candidates.push({ baselinePath, manifest });
47
+ }
48
+ candidates.sort((left, right) => right.manifest.createdAt.localeCompare(left.manifest.createdAt) || right.manifest.baselineId.localeCompare(left.manifest.baselineId));
49
+ if (!candidates.length) return undefined;
50
+ return loadBaseline(candidates[0].baselinePath, compatibility);
51
+ }
52
+
53
+ export async function loadBaseline(baselinePath, compatibility) {
54
+ const root = path.resolve(baselinePath);
55
+ const [manifest, answers, judgments] = await Promise.all([
56
+ readJson(path.join(root, 'manifest.json')),
57
+ readJsonl(path.join(root, 'answers.jsonl')),
58
+ readJsonl(path.join(root, 'judgments.jsonl')),
59
+ ]);
60
+ const expectedKey = baselineCompatibilityKey(compatibility);
61
+ if (manifest.compatibilityKey !== expectedKey) throw new BaselineError(`Baseline ${manifest.baselineId ?? root} is not compatible with this evaluation configuration`);
62
+ assertBaselineRecords(answers, judgments);
63
+ return { baselinePath: root, manifest, answers, judgments };
64
+ }
65
+
66
+ export function assertBaselineCoverage({ answers, judgments }, questions, trialsPerQuestion) {
67
+ assertBaselineRecords(answers, judgments);
68
+ const expectedKeys = new Set(questions.flatMap((question) => Array.from({ length: trialsPerQuestion }, (_, trial) => `${question.testId}:${trial}`)));
69
+ const answerKeys = new Set(answers.map(recordKey));
70
+ const judgmentKeys = new Set(judgments.map(recordKey));
71
+ if (answerKeys.size !== expectedKeys.size || [...expectedKeys].some((key) => !answerKeys.has(key) || !judgmentKeys.has(key))) {
72
+ throw new BaselineError(`Baseline coverage does not match ${questions.length} questions with ${trialsPerQuestion} trials each`);
73
+ }
74
+ }
75
+
76
+ function assertBaselineRecords(answers, judgments) {
77
+ if (!answers.length || answers.some((answer) => answer.condition !== 'closedBook')) throw new BaselineError('Baseline answers must contain only closed-book records');
78
+ if (judgments.length !== answers.length || judgments.some((judgment) => judgment.condition !== 'closedBook')) throw new BaselineError('Baseline judgments must match the closed-book answers');
79
+ const answerKeys = new Set(answers.map(recordKey));
80
+ if (answerKeys.size !== answers.length || judgments.some((judgment) => !answerKeys.has(recordKey(judgment)))) throw new BaselineError('Baseline answers and judgments must have matching unique test and trial records');
81
+ }
82
+
83
+ function recordKey(record) {
84
+ return `${record.testId}:${record.trial}`;
85
+ }
@@ -0,0 +1,32 @@
1
+ import { stableStringify } from './json.js';
2
+ import { InvalidStructuredResponse, parseJsonObject } from './structured.js';
3
+ import { parseJudgeResponse } from './judge.js';
4
+
5
+ const INTEGRITY_FIELDS = ['directlyEntailed', 'contradictionChecked', 'qualificationsIncluded', 'authorityResolved', 'proxyAnswer'];
6
+ const VERIFICATION_FIELDS = ['answerable', 'referenceSupported', 'criteriaSupported', 'mappedItemsRequired', 'requiresCorpusKnowledge', 'unambiguous', 'answerLeakage'];
7
+
8
+ export function buildCalibrationPrompt({ question, source, candidateAnswer, rubric }) {
9
+ return [
10
+ 'Calibrate this question and oracle answer against the complete supplied source documents. Return only valid JSON with this shape:',
11
+ '{"criterionResults":[{"criterionIndex":0,"score":1,"rationale":"..."}],"unsupportedClaims":[],"verification":{"answerable":true,"referenceSupported":true,"criteriaSupported":true,"mappedItemsRequired":true,"requiresCorpusKnowledge":true,"unambiguous":true,"answerLeakage":false,"reason":"..."},"integrity":{"directlyEntailed":true,"contradictionChecked":true,"qualificationsIncluded":true,"authorityResolved":true,"proxyAnswer":false,"rationale":"..."}}',
12
+ 'Score every rubric criterion from 0 to 1. directlyEntailed means every answer claim follows from the complete source. contradictionChecked means the complete source was checked for conflicting passages. qualificationsIncluded means applicable exceptions and prerequisites are represented. authorityResolved means version and authority precedence are correctly handled. proxyAnswer is true when the answer merely resembles local evidence without establishing the source-supported answer. Set each field independently and explain the integrity verdict.',
13
+ 'INPUT:',
14
+ stableStringify({ question, source, candidateAnswer, rubric }),
15
+ ].join('\n');
16
+ }
17
+
18
+ export function parseCalibrationResponse(response, criterionCount) {
19
+ const data = parseJsonObject(response, 'Calibration response');
20
+ const judgment = parseJudgeResponse(response, criterionCount);
21
+ if (data.verification === null || Array.isArray(data.verification) || typeof data.verification !== 'object') throw new InvalidStructuredResponse('verification must be an object');
22
+ for (const field of VERIFICATION_FIELDS) if (typeof data.verification[field] !== 'boolean') throw new InvalidStructuredResponse(`verification.${field} must be a boolean`);
23
+ if (typeof data.verification.reason !== 'string' || !data.verification.reason.trim()) throw new InvalidStructuredResponse('verification.reason must be a non-empty string');
24
+ const verification = { ...Object.fromEntries(VERIFICATION_FIELDS.map((field) => [field, data.verification[field]])), reason: data.verification.reason.trim() };
25
+ verification.passed = verification.answerable && verification.referenceSupported && verification.criteriaSupported && verification.mappedItemsRequired && verification.requiresCorpusKnowledge && verification.unambiguous && !verification.answerLeakage;
26
+ if (data.integrity === null || Array.isArray(data.integrity) || typeof data.integrity !== 'object') throw new InvalidStructuredResponse('integrity must be an object');
27
+ for (const field of INTEGRITY_FIELDS) if (typeof data.integrity[field] !== 'boolean') throw new InvalidStructuredResponse(`integrity.${field} must be a boolean`);
28
+ if (typeof data.integrity.rationale !== 'string' || !data.integrity.rationale.trim()) throw new InvalidStructuredResponse('integrity.rationale must be a non-empty string');
29
+ const integrity = { ...Object.fromEntries(INTEGRITY_FIELDS.map((field) => [field, data.integrity[field]])), rationale: data.integrity.rationale.trim() };
30
+ integrity.passed = integrity.directlyEntailed && integrity.contradictionChecked && integrity.qualificationsIncluded && integrity.authorityResolved && !integrity.proxyAnswer;
31
+ return { ...judgment, verification, integrity };
32
+ }