@kontourai/survey 4.0.0 → 6.0.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.
@@ -26,21 +26,76 @@ export function importExtractionEnvelope(serialized, options) {
26
26
  const producerNamespace = options.producerNamespace ?? envelope.result.provider;
27
27
  nonEmpty(name, "Extraction envelope import name");
28
28
  nonEmpty(producerNamespace, "Extraction envelope producer namespace");
29
- const diagnostics = diagnosticsFor(envelope);
29
+ const verification = options.artifact === undefined
30
+ ? { provenance: "unverified", diagnostics: [] }
31
+ : verifyAgainstArtifact(envelope, options.artifact);
32
+ const diagnostics = [...diagnosticsFor(envelope), ...verification.diagnostics];
30
33
  const record = {
31
34
  apiVersion: extractionEnvelopeImportApiVersion,
32
35
  kind: "ExtractionEnvelopeImport",
33
36
  metadata: { name, producerNamespace },
34
37
  spec: { envelope, sourceKind: options.sourceKind, claimTargets: cloneJson(claimTargets) },
35
- status: { state: diagnostics.length ? "unresolved" : "grounded", diagnostics },
38
+ status: { state: stateFor(diagnostics, envelope.result.proposals.length), diagnostics, provenance: verification.provenance },
36
39
  };
37
40
  return { record, reviewItems: buildReviewItemsFromExtractionEnvelopeImport(record) };
38
41
  }
42
+ /**
43
+ * One ReviewItem per claim slot: every proposal whose claim target names the
44
+ * same claim (subject, facet, claim type, field or behavior, and claim id when
45
+ * one is set) at the same `pathIndices` is one candidate set, so two values for
46
+ * one claim can never be accepted as two separate verified claims. The slot is
47
+ * the claim the proposal would project to, not the upstream `fieldPath` (two
48
+ * field paths mapped to one claim are one slot) and not the value.
49
+ *
50
+ * Within a slot there is one candidate per distinct canonical value; further
51
+ * proposals of an already-seen value are recorded on that candidate as
52
+ * `sameValueProposals`. Two or more distinct values make the set `conflict`.
53
+ * Distinct `pathIndices` (array items) or distinct claim ids are separate slots,
54
+ * which is how a producer declares a multi-valued field.
55
+ */
39
56
  export function buildReviewItemsFromExtractionEnvelopeImport(record) {
40
57
  validateImport(record);
41
58
  if (record.status.state !== "grounded")
42
59
  return [];
43
- return record.spec.envelope.result.proposals.map((proposal, index) => buildReviewItem(record, proposal, index));
60
+ return claimSlotGroups(record).map((group) => buildReviewItem(record, group));
61
+ }
62
+ function claimSlotGroups(record) {
63
+ const groups = new Map();
64
+ const excluded = excerptMismatchProposalIndices(record);
65
+ const slotOf = (proposal, index) => {
66
+ const target = record.spec.claimTargets[index];
67
+ return {
68
+ subjectType: target.subjectType, subjectId: target.subjectId, facet: target.facet, claimType: target.claimType,
69
+ fieldOrBehavior: target.fieldOrBehavior, claimId: target.claimId ?? null, pathIndices: proposal.pathIndices ?? null,
70
+ };
71
+ };
72
+ record.spec.envelope.result.proposals.forEach((proposal, index) => {
73
+ if (excluded.has(index))
74
+ return;
75
+ const target = record.spec.claimTargets[index];
76
+ const slot = slotOf(proposal, index);
77
+ const key = canonicalJson(slot);
78
+ const group = groups.get(key);
79
+ if (!group) {
80
+ groups.set(key, { slot, members: [{ proposal, index }], excluded: [] });
81
+ return;
82
+ }
83
+ const first = group.members[0].index;
84
+ // One claim cannot carry two impact levels or evidence descriptions.
85
+ if (canonicalJson(record.spec.claimTargets[first]) !== canonicalJson(target)) {
86
+ throw new Error(`Proposals ${first} and ${index} map to the same claim with different claim targets.`);
87
+ }
88
+ group.members.push({ proposal, index });
89
+ });
90
+ // An excluded proposal is unverifiable, not disproven: the slot it would have
91
+ // joined keeps a record of it, so a rival value cannot vanish without trace.
92
+ // A slot whose every proposal was excluded has no item; the import
93
+ // diagnostics are then the only record, as for any excluded proposal.
94
+ record.spec.envelope.result.proposals.forEach((proposal, index) => {
95
+ if (excluded.has(index))
96
+ groups.get(canonicalJson(slotOf(proposal, index)))?.excluded.push({ proposal, index });
97
+ });
98
+ return [...groups.values()];
44
99
  }
45
100
  export function exportExtractionEnvelopeImport(record) {
46
101
  validateImport(record);
@@ -66,15 +121,57 @@ export function createExtractionEnvelopeResolutionIdentity(record, proposalIndex
66
121
  const nonce = globalThis.crypto.randomUUID();
67
122
  return { evidenceId: `survey.extraction.${base}.resolution-evidence.${nonce}`, eventId: `survey.extraction.${base}.resolution-event.${nonce}` };
68
123
  }
69
- function buildReviewItem(record, proposal, index) {
124
+ function buildReviewItem(record, group) {
125
+ const envelope = record.spec.envelope;
126
+ const byValue = new Map();
127
+ for (const member of group.members) {
128
+ const key = canonicalJson(member.proposal.candidateValue);
129
+ byValue.set(key, [...(byValue.get(key) ?? []), member]);
130
+ }
131
+ const candidates = [...byValue.values()].map((members) => buildCandidate(record, members));
132
+ const lead = group.members[0].proposal;
133
+ const valueType = lead.valueType ?? inferValueType(lead.candidateValue);
134
+ const identity = identityHash({ producerNamespace: record.metadata.producerNamespace, importName: record.metadata.name, source: envelope.source,
135
+ preparedArtifact: envelope.result.preparedArtifact, pdfLayout: envelope.result.pdfLayout, runId: envelope.result.runId, claimSlot: group.slot });
136
+ return {
137
+ apiVersion: reviewResourceApiVersion, kind: "ReviewItem",
138
+ metadata: { name: `extraction-envelope.${identity}`, producer: { "survey.kontourai.io/extraction-envelope": {
139
+ importName: record.metadata.name,
140
+ evidenceId: `survey.extraction.${identityHash(evidenceInputs(record, lead))}.source-evidence`,
141
+ proposalIndices: group.members.map((member) => member.index),
142
+ source: envelope.source,
143
+ ...(envelope.result.preparedArtifact ? { preparedArtifact: envelope.result.preparedArtifact } : {}),
144
+ // Present only on verified imports, so an unverified import's items stay
145
+ // byte-identical to the ones earlier releases produced.
146
+ ...(record.status.provenance === "verified" ? { excerptVerification: "verified" } : {}),
147
+ ...(group.excluded.length ? { excludedProposals: group.excluded.map(({ proposal, index }) => ({
148
+ proposalIndex: index, value: proposal.candidateValue, locator: proposal.provenance.locator,
149
+ excerpt: proposal.provenance.excerpt, reason: "excerpt-mismatch",
150
+ })) } : {}),
151
+ } } },
152
+ spec: {
153
+ target: lead.fieldPath, candidates,
154
+ candidateSetStatus: candidates.length > 1 ? "conflict" : "needs-review",
155
+ valueDescriptor: { type: valueType }, editable: false,
156
+ },
157
+ status: { observedCandidateCount: candidates.length },
158
+ };
159
+ }
160
+ /** One candidate for one distinct value; `members` are that value's proposals in envelope order. */
161
+ function buildCandidate(record, members) {
70
162
  const envelope = record.spec.envelope;
71
- const identity = identityHash(identityInputs(record, proposal, index));
163
+ const { proposal, index } = members[0];
164
+ const others = members.slice(1);
165
+ // A candidate commits to every proposal it stands for; a single-proposal
166
+ // candidate keeps exactly the identity it had before grouping.
167
+ const identity = identityHash(others.length === 0 ? identityInputs(record, proposal, index)
168
+ : { ...identityInputs(record, proposal, index), sameValueProposals: others.map((other) => ({ proposalIndex: other.index, proposal: other.proposal })) });
72
169
  const evidence = identityHash(evidenceInputs(record, proposal));
73
170
  const target = record.spec.claimTargets[index];
74
171
  const valueType = proposal.valueType ?? inferValueType(proposal.candidateValue);
75
- const candidate = {
172
+ return {
76
173
  id: `extraction-envelope.${identity}.proposed`, role: "proposed", value: proposal.candidateValue,
77
- confidence: proposal.confidence,
174
+ ...(proposal.confidence !== undefined ? { confidence: proposal.confidence } : {}),
78
175
  source: {
79
176
  sourceRef: envelope.source.ref,
80
177
  sourceId: envelope.source.snapshotRef ?? envelope.source.ref,
@@ -87,8 +184,9 @@ function buildReviewItem(record, proposal, index) {
87
184
  extraction: {
88
185
  extractionId: `extraction-envelope.${identity}`,
89
186
  target: proposal.fieldPath,
90
- confidence: proposal.confidence,
187
+ ...(proposal.confidence !== undefined ? { confidence: proposal.confidence } : {}),
91
188
  extractor: proposal.extractor,
189
+ extractedAt: envelope.result.extractedAt,
92
190
  // The proposal's own served model when recorded; in a multi-chunk run
93
191
  // `result.model` names only the last chunk's model.
94
192
  ...(proposal.producedBy ? { model: proposal.producedBy.model } : envelope.result.model ? { model: envelope.result.model } : {}),
@@ -105,22 +203,21 @@ function buildReviewItem(record, proposal, index) {
105
203
  ...(proposal.producedBy ? { producedBy: proposal.producedBy } : {}),
106
204
  ...(proposal.evidenceMatch ? { evidenceMatch: proposal.evidenceMatch } : {}),
107
205
  occurrence: proposal.provenance.occurrence,
206
+ ...(others.length ? { sameValueProposals: others.map((other) => ({
207
+ proposalIndex: other.index,
208
+ evidenceId: `survey.extraction.${identityHash(evidenceInputs(record, other.proposal))}.source-evidence`,
209
+ locator: other.proposal.provenance.locator,
210
+ excerpt: other.proposal.provenance.excerpt,
211
+ })) } : {}),
108
212
  attempt: { id: envelope.result.runId, providerCalls: envelope.result.providerCalls },
109
213
  ...(envelope.result.warningClassifications ? { warnings: envelope.result.warningClassifications } : {}),
110
214
  outcome: envelope.result.outcome,
215
+ // A partial run may have left this field's other values unread: the
216
+ // reason and the per-chunk coverage travel with every candidate.
217
+ ...(envelope.result.partial ? { partial: envelope.result.partial } : {}),
218
+ ...(envelope.result.coverage ? { coverage: envelope.result.coverage } : {}),
111
219
  } },
112
220
  };
113
- return {
114
- apiVersion: reviewResourceApiVersion, kind: "ReviewItem",
115
- metadata: { name: `extraction-envelope.${identity}`, producer: { "survey.kontourai.io/extraction-envelope": {
116
- importName: record.metadata.name,
117
- evidenceId: `survey.extraction.${evidence}.source-evidence`,
118
- source: envelope.source,
119
- ...(envelope.result.preparedArtifact ? { preparedArtifact: envelope.result.preparedArtifact } : {}),
120
- } } },
121
- spec: { target: proposal.fieldPath, candidates: [candidate], candidateSetStatus: "needs-review", valueDescriptor: { type: valueType }, editable: false },
122
- status: { observedCandidateCount: 1 },
123
- };
124
221
  }
125
222
  function identityInputs(record, proposal, index) {
126
223
  return { producerNamespace: record.metadata.producerNamespace, importName: record.metadata.name, source: record.spec.envelope.source,
@@ -135,7 +232,150 @@ function evidenceInputs(record, proposal) {
135
232
  extractedAt: record.spec.envelope.result.extractedAt,
136
233
  provenance: proposal.provenance };
137
234
  }
235
+ /** Proposals the import excluded because their span did not match their excerpt. */
236
+ export function excerptMismatchProposalIndices(record) {
237
+ return new Set(record.status.diagnostics.flatMap((diagnostic) => diagnostic.kind === "excerpt-mismatch" ? [diagnostic.proposalIndex] : []));
238
+ }
239
+ /**
240
+ * An import is grounded when nothing but excerpt mismatches was diagnosed and
241
+ * at least one proposal survived them. Every proposal excluded must not read as
242
+ * a complete run that found nothing.
243
+ */
244
+ function stateFor(diagnostics, proposalCount) {
245
+ const mismatches = diagnostics.filter((diagnostic) => diagnostic.kind === "excerpt-mismatch").length;
246
+ if (mismatches !== diagnostics.length)
247
+ return "unresolved";
248
+ return mismatches > 0 && mismatches === proposalCount ? "unresolved" : "grounded";
249
+ }
250
+ /** Validate the caller-resolved artifact shape; resolver detail beyond the code is refused. */
251
+ export function assertResolvedExtractionArtifact(artifact) {
252
+ if (!artifact || typeof artifact !== "object" || Array.isArray(artifact))
253
+ throw new Error("Invalid resolved extraction artifact.");
254
+ const a = artifact;
255
+ const keys = Object.keys(a).sort();
256
+ if (a.status === "available") {
257
+ if (keys.join(",") !== "actualDigest,status,text" || typeof a.text !== "string" || typeof a.actualDigest !== "string" || !/^[a-f0-9]{64}$/.test(a.actualDigest))
258
+ throw new Error("Invalid available extraction artifact.");
259
+ }
260
+ else if (a.status === "digest-mismatch") {
261
+ if (keys.join(",") !== "actualDigest,status" || typeof a.actualDigest !== "string" || !/^[a-f0-9]{64}$/.test(a.actualDigest))
262
+ throw new Error("Invalid digest-mismatch extraction artifact.");
263
+ }
264
+ else if (a.status === "unavailable") {
265
+ if (keys.join(",") !== "code,status" || !ARTIFACT_UNAVAILABLE_CODES.has(a.code))
266
+ throw new Error("Invalid unavailable extraction artifact.");
267
+ }
268
+ else
269
+ throw new Error("Invalid resolved extraction artifact status.");
270
+ }
271
+ /**
272
+ * Checks resolved text against the envelope's prepared-artifact identity: the
273
+ * caller's reported digest, the digest of the text itself, and the declared
274
+ * length. Shared by import-time verification and the source inspector.
275
+ */
276
+ export function checkPreparedArtifact(prepared, artifact) {
277
+ if (artifact.status === "unavailable")
278
+ return { status: "unavailable", code: artifact.code };
279
+ if (artifact.status === "digest-mismatch")
280
+ return { status: "digest-mismatch", actualDigest: artifact.actualDigest };
281
+ const textDigest = sha256Hex(artifact.text);
282
+ if (!prepared || artifact.actualDigest !== prepared.digest || textDigest !== artifact.actualDigest)
283
+ return { status: "digest-mismatch", actualDigest: textDigest };
284
+ if (artifact.text.length !== prepared.contentLength)
285
+ return { status: "wrong-length" };
286
+ return { status: "aligned", text: artifact.text };
287
+ }
288
+ /** Whether the verified prepared text at a proposal's `chars:` span is exactly its excerpt. */
289
+ export function proposalSpanMatches(text, proposal) {
290
+ const match = /^chars:(\d+)-(\d+)$/.exec(proposal.provenance.locator);
291
+ if (!match)
292
+ return false;
293
+ return text.slice(Number(match[1]), Number(match[2])) === proposal.provenance.excerpt;
294
+ }
295
+ function verifyAgainstArtifact(envelope, artifact) {
296
+ assertResolvedExtractionArtifact(artifact);
297
+ const prepared = envelope.result.preparedArtifact;
298
+ if (!prepared)
299
+ throw new Error("The artifact option requires result.preparedArtifact to verify against.");
300
+ const check = checkPreparedArtifact(prepared, artifact);
301
+ if (check.status === "unavailable")
302
+ return { provenance: "unverified", diagnostics: [unreadableArtifactDiagnostic(prepared.ref, check.code)] };
303
+ if (check.status === "wrong-length")
304
+ return { provenance: "unverified", diagnostics: [wrongLengthDiagnostic(prepared.ref)] };
305
+ if (check.status === "digest-mismatch")
306
+ return { provenance: "unverified", diagnostics: [suppliedDigestMismatchDiagnostic(prepared, check.actualDigest)] };
307
+ const diagnostics = [];
308
+ envelope.result.proposals.forEach((proposal, index) => {
309
+ if (!proposalSpanMatches(check.text, proposal))
310
+ diagnostics.push(excerptMismatchDiagnostic(index, proposal));
311
+ });
312
+ return { provenance: "verified", diagnostics };
313
+ }
314
+ function unreadableArtifactDiagnostic(artifactRef, code) {
315
+ const status = code === "storage-error" ? "storage-error" : code === "invalid-artifact" ? "invalid-artifact" : "unavailable";
316
+ return { kind: "artifact-unavailable", status, artifactRef, code, message: `Prepared artifact could not be read to verify excerpts (${code}); no candidate is grounded.` };
317
+ }
318
+ function wrongLengthDiagnostic(artifactRef) {
319
+ return { kind: "artifact-unavailable", status: "invalid-artifact", artifactRef, message: "Prepared artifact text does not have the declared content length; no candidate is grounded." };
320
+ }
321
+ function suppliedDigestMismatchDiagnostic(prepared, actualDigest) {
322
+ return { kind: "digest-mismatch", artifactRef: prepared.ref, expectedDigest: prepared.digest, actualDigest,
323
+ message: "Prepared artifact text supplied at import does not match the extraction artifact digest; no candidate is grounded." };
324
+ }
325
+ function excerptMismatchDiagnostic(index, proposal) {
326
+ return { kind: "excerpt-mismatch", proposalIndex: index, locator: proposal.provenance.locator,
327
+ message: `Proposal ${index} (${proposal.fieldPath}): the prepared text at ${proposal.provenance.locator} is not its excerpt, so it produced no review item.` };
328
+ }
329
+ /**
330
+ * The import-time verification diagnostics a record may carry after the
331
+ * envelope-derived ones. They cannot be recomputed without the artifact text,
332
+ * so a reloaded record is checked for being one the import could have
333
+ * written: each diagnostic is rebuilt from its own fields and must match.
334
+ */
335
+ function verificationDiagnosticsAreCoherent(envelope, provenance, diagnostics) {
336
+ if (provenance === undefined)
337
+ return diagnostics.length === 0;
338
+ const prepared = envelope.result.preparedArtifact;
339
+ const rebuild = (value) => {
340
+ if (!value || typeof value !== "object" || Array.isArray(value) || !prepared)
341
+ return undefined;
342
+ const d = value;
343
+ if (provenance === "verified") {
344
+ const proposal = Number.isSafeInteger(d.proposalIndex) ? envelope.result.proposals[d.proposalIndex] : undefined;
345
+ return d.kind === "excerpt-mismatch" && proposal ? excerptMismatchDiagnostic(d.proposalIndex, proposal) : undefined;
346
+ }
347
+ if (d.kind === "artifact-unavailable") {
348
+ if (d.code === undefined)
349
+ return wrongLengthDiagnostic(prepared.ref);
350
+ return ARTIFACT_UNAVAILABLE_CODES.has(d.code) ? unreadableArtifactDiagnostic(prepared.ref, d.code) : undefined;
351
+ }
352
+ if (d.kind === "digest-mismatch" && typeof d.actualDigest === "string" && /^[a-f0-9]{64}$/.test(d.actualDigest))
353
+ return suppliedDigestMismatchDiagnostic(prepared, d.actualDigest);
354
+ return undefined;
355
+ };
356
+ if (provenance !== "verified" && provenance !== "unverified")
357
+ return false;
358
+ if (provenance === "verified" && !prepared)
359
+ return false;
360
+ if (provenance === "unverified" && diagnostics.length > 1)
361
+ return false;
362
+ let previous = -1;
363
+ for (const diagnostic of diagnostics) {
364
+ const expected = rebuild(diagnostic);
365
+ if (!expected || canonicalJson(expected) !== canonicalJson(diagnostic))
366
+ return false;
367
+ if (expected.kind === "excerpt-mismatch") {
368
+ if (expected.proposalIndex <= previous)
369
+ return false;
370
+ previous = expected.proposalIndex;
371
+ }
372
+ }
373
+ return true;
374
+ }
138
375
  function diagnosticsFor(envelope) {
376
+ return [...artifactDiagnostics(envelope), ...outcomeDiagnostics(envelope)];
377
+ }
378
+ function artifactDiagnostics(envelope) {
139
379
  const state = envelope.result.preparedArtifactState;
140
380
  if (!state || state.status === "available")
141
381
  return [];
@@ -147,6 +387,21 @@ function diagnosticsFor(envelope) {
147
387
  ...(state.status === "invalid-artifact" ? { artifactRef: state.canonicalRef } : { artifactRef: state.requestedRef }),
148
388
  message: `Prepared artifact resolution is ${state.status}.` }];
149
389
  }
390
+ /**
391
+ * A failed run, or a partial run with no proposal, must not look like a
392
+ * complete run that found nothing. A partial run with proposals stays
393
+ * grounded: its reason and coverage travel on every candidate.
394
+ */
395
+ function outcomeDiagnostics(envelope) {
396
+ const outcome = envelope.result.outcome;
397
+ if (outcome.status === "failure")
398
+ return [{ kind: "extraction-failed", category: outcome.category, code: outcome.code,
399
+ message: `Extraction failed (${outcome.category}/${outcome.code}); no usable answer was recorded for this source, so the import has no candidates.` }];
400
+ if (outcome.status === "partial" && envelope.result.proposals.length === 0)
401
+ return [{ kind: "extraction-incomplete", reason: outcome.reason,
402
+ message: `Extraction stopped short (${outcome.reason}) without proposing any value; unread text may hold values.` }];
403
+ return [];
404
+ }
150
405
  function validateImport(value) {
151
406
  jsonSafe(value, "Extraction envelope import");
152
407
  const record = obj(value, "Extraction envelope import");
@@ -167,9 +422,12 @@ function validateImport(value) {
167
422
  if (targets.length !== envelope.result.proposals.length)
168
423
  throw new Error("spec.claimTargets must align with proposals.");
169
424
  const status = obj(record.status, "status");
170
- exact(status, ["state", "diagnostics"], "status");
425
+ exact(status, ["state", "diagnostics"], "status", ["provenance"]);
426
+ const diagnostics = array(status.diagnostics, "status.diagnostics");
171
427
  const expected = diagnosticsFor(envelope);
172
- if (canonicalJson(status.diagnostics) !== canonicalJson(expected) || status.state !== (expected.length ? "unresolved" : "grounded"))
428
+ if (canonicalJson(diagnostics.slice(0, expected.length)) !== canonicalJson(expected)
429
+ || !verificationDiagnosticsAreCoherent(envelope, status.provenance, diagnostics.slice(expected.length))
430
+ || status.state !== stateFor(diagnostics, envelope.result.proposals.length))
173
431
  throw new Error("Import status does not match envelope state.");
174
432
  }
175
433
  /** Validate and defensively clone a persisted or browser-delivered import. */
@@ -189,7 +447,7 @@ function validateEnvelope(input) {
189
447
  if (source.snapshotRef !== undefined)
190
448
  safeReference(source.snapshotRef, "source.snapshotRef");
191
449
  const r = obj(e.result, "result");
192
- exact(r, ["proposals", "provider", "runId", "raw", "outcome", "extractedAt", "providerCalls", "totalTokensUsed"], "result", ["model", "warningClassifications", "partial", "providerFailures", "taskDigest", "exampleDigests", "pdfPageOffsets", "pdfLayout", "ocrDerived", "preparedArtifact", "preparedArtifactState"]);
450
+ exact(r, ["proposals", "provider", "runId", "raw", "outcome", "extractedAt", "providerCalls", "totalTokensUsed"], "result", ["model", "warningClassifications", "partial", "coverage", "providerFailures", "taskDigest", "exampleDigests", "pdfPageOffsets", "pdfLayout", "ocrDerived", "preparedArtifact", "preparedArtifactState"]);
193
451
  stableIdentity(r.provider, "result.provider");
194
452
  if (r.model !== undefined)
195
453
  stableIdentity(r.model, "result.model");
@@ -224,6 +482,9 @@ function validateEnvelope(input) {
224
482
  : artifact === undefined
225
483
  ? (() => { throw new Error("result.pdfLayout requires result.preparedArtifact."); })()
226
484
  : validatePortablePdfLayout(r.pdfLayout, artifact.contentLength);
485
+ if (r.coverage !== undefined)
486
+ validateCoverage(r.coverage, artifact);
487
+ validateCoverageAgreement(r.outcome, r.coverage);
227
488
  const state = r.preparedArtifactState === undefined ? undefined : validateArtifactState(r.preparedArtifactState, artifact);
228
489
  if (source.snapshotRef !== undefined && artifact?.sourceSnapshotRef !== undefined && source.snapshotRef !== artifact.sourceSnapshotRef)
229
490
  throw new Error("Source snapshot identity mismatch.");
@@ -232,10 +493,12 @@ function validateEnvelope(input) {
232
493
  }
233
494
  function validateProposal(input, index, contentLength) {
234
495
  const p = obj(input, `proposal[${index}]`);
235
- exact(p, ["fieldPath", "candidateValue", "confidence", "provenance", "extractor"], `proposal[${index}]`, ["pathIndices", "inferenceType", "valueType", "enumValues", "producedBy", "evidenceMatch"]);
496
+ exact(p, ["fieldPath", "candidateValue", "provenance", "extractor"], `proposal[${index}]`, ["confidence", "pathIndices", "inferenceType", "valueType", "enumValues", "producedBy", "evidenceMatch"]);
236
497
  wireNonEmpty(p.fieldPath, "proposal.fieldPath");
237
498
  stableIdentity(p.extractor, "proposal.extractor");
238
- finite(p.confidence, "proposal.confidence", 0, 1);
499
+ // Absent means the proposer reported none; `null` is not absent and is rejected.
500
+ if (Object.hasOwn(p, "confidence"))
501
+ finite(p.confidence, "proposal.confidence", 0, 1);
239
502
  const provenance = obj(p.provenance, "proposal.provenance");
240
503
  exact(provenance, ["excerpt", "locator", "occurrence"], "proposal.provenance");
241
504
  wireNonEmpty(provenance.excerpt, "proposal.provenance.excerpt");
@@ -323,6 +586,45 @@ else if (o.status === "failure") {
323
586
  else
324
587
  throw new Error("outcome status is invalid."); if (o.status !== "partial" && partial !== undefined)
325
588
  throw new Error("partial requires partial outcome."); }
589
+ function validateCoverage(input, artifact) {
590
+ if (!artifact)
591
+ throw new Error("result.coverage requires result.preparedArtifact.");
592
+ const entries = array(input, "result.coverage");
593
+ entries.forEach((value, index) => {
594
+ const subject = `result.coverage[${index}]`;
595
+ const c = obj(value, subject);
596
+ exact(c, ["chunk", "start", "end", "status"], subject, ["reason"]);
597
+ integer(c.chunk, `${subject}.chunk`);
598
+ if (c.chunk === 0)
599
+ throw new Error(`${subject}.chunk must be positive.`);
600
+ integer(c.start, `${subject}.start`);
601
+ integer(c.end, `${subject}.end`);
602
+ if (c.end <= c.start)
603
+ throw new Error(`${subject} must have start < end.`);
604
+ if (c.end > artifact.contentLength)
605
+ throw new Error(`${subject}.end exceeds the prepared artifact contentLength.`);
606
+ if (!COVERAGE_STATUSES.has(c.status))
607
+ throw new Error(`${subject}.status is invalid.`);
608
+ if ((c.status === "unread") !== (c.reason !== undefined))
609
+ throw new Error(`${subject}.reason is required exactly when status is unread.`);
610
+ if (c.reason !== undefined && !COVERAGE_REASONS.has(c.reason))
611
+ throw new Error(`${subject}.reason is invalid.`);
612
+ // Chunks overlap by design, so ranges may overlap; only the order is fixed.
613
+ if (index > 0 && c.start < (entries[index - 1].start))
614
+ throw new Error("result.coverage entries must be ordered by start.");
615
+ });
616
+ }
617
+ /** The outcome and the coverage must tell the same story about unread text. */
618
+ function validateCoverageAgreement(outcome, coverage) {
619
+ const lost = coverage?.some((entry) => entry.status !== "complete") ?? false;
620
+ if (outcome.status === "success" && lost)
621
+ throw new Error("result.coverage names unread or unanswered text, but the outcome is success.");
622
+ // A loss reason means a dispatched chunk lost text; a never-dispatched range
623
+ // is an early stop, not that loss.
624
+ const dispatchedLoss = coverage?.some((entry) => entry.status !== "complete" && entry.reason !== "not-dispatched") ?? false;
625
+ if (outcome.status === "partial" && LOSS_PARTIAL.has(outcome.reason) && !dispatchedLoss)
626
+ throw new Error(`partial reason ${outcome.reason} requires a result.coverage entry for a dispatched chunk that was not read or answered.`);
627
+ }
326
628
  function validateWarning(v) { const w = obj(v, "warning"); exact(w, ["category", "code"], "warning"); if (!WARNING_CATEGORIES.has(w.category))
327
629
  throw new Error("warning category invalid."); stableIdentity(w.code, "warning.code"); }
328
630
  function validateFailure(v) { const f = obj(v, "providerFailure"); exact(f, ["provider", "kind", "retryable"], "providerFailure", ["code"]); stableIdentity(f.provider, "failure.provider"); if (!FAILURE_KINDS.has(f.kind) || typeof f.retryable !== "boolean")
@@ -447,13 +749,18 @@ function isWellFormedUnicode(value) { for (let index = 0; index < value.length;
447
749
  } return true; }
448
750
  const RAW_SOURCE_KINDS = new Set(["uploaded-document", "web-page", "api-record", "manual-entry", "policy-standard", "inquiry-question", "agent-utterance", "system-schema"]);
449
751
  const VALUE_TYPES = new Set(["string", "number", "boolean", "date", "enum", "array", "object"]);
450
- const PARTIAL = new Set(["cancelled", "max-provider-calls", "max-total-tokens", "max-chunks"]);
752
+ /** Partial reasons meaning a dispatched chunk was not (fully) read or answered. */
753
+ const LOSS_PARTIAL = new Set(["provider-failure", "content-truncated", "output-truncated"]);
754
+ const PARTIAL = new Set(["cancelled", "max-provider-calls", "max-total-tokens", "max-chunks", ...LOSS_PARTIAL]);
755
+ const COVERAGE_STATUSES = new Set(["complete", "unread", "output-truncated"]);
756
+ const COVERAGE_REASONS = new Set(["provider-failure", "content-truncated", "missing-tool-call", "not-dispatched"]);
451
757
  const FAILURE_CATEGORIES = new Set(["invalid-config", "invalid-task", "preparation", "provider", "unexpected"]);
452
758
  const WARNING_CATEGORIES = new Set(["provider", "normalization", "preparation", "limit", "storage", "content", "other"]);
453
759
  const FAILURE_KINDS = new Set(["authentication", "rate-limit", "timeout", "invalid-request", "unavailable", "unknown"]);
454
760
  const MODEL_SOURCES = new Set(["provider-reported", "configured"]);
455
761
  const SCHEMA_MATCHES = new Set(["ok", "type-mismatch", "enum-mismatch", "format-invalid"]);
456
762
  const VALUE_IN_EXCERPT_MATCHES = new Set(["match", "mismatch", "not-evaluated", "not-applicable"]);
763
+ const ARTIFACT_UNAVAILABLE_CODES = new Set(["not-found", "storage-error", "access-denied", "invalid-artifact", "unknown"]);
457
764
  const PREPARATION_MODES = new Set(["text", "markdown", "transcript", "pdf-text", "image-ocr"]);
458
765
  const ARTIFACT_INVALID_REASONS = new Set(["not-an-object", "invalid-format", "invalid-version", "invalid-digest", "invalid-ref", "invalid-preparation-mode", "invalid-preparation-version", "invalid-content-length", "invalid-source-snapshot-ref", "ill-formed-unicode", "invalid-resolved-text"]);
459
766
  const STABLE_IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:@/+~-]{0,255}$/;
@@ -2,13 +2,13 @@ export type { CandidateSetStatus, Candidate, CandidateSet, ClaimTarget, Escalati
2
2
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
3
3
  export { reviewResourceApiVersion } from "./review-resource.js";
4
4
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
5
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
+ export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
6
6
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
7
7
  export type { BindReviewQueueOptions, ReviewQueueBinding, ReviewQueueBindingIssue, ReviewQueueBindingIssueCode, ReviewQueueExtractionIssue, ReviewQueueExtractionIssueCode, ValidateReviewQueueBindingOptions, } from "./review-workbench/queue-binding.js";
8
8
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
9
9
  export type { PortablePdfBoundingBox, PortablePdfLayout, PortablePdfPageGeometry, PortablePdfRegionContext, PortablePdfTable, PortablePdfTableCell, PortablePdfTextElement, PortablePdfTextRange, } from "./pdf-layout.js";
10
10
  export type { ExtractionAlignmentState, ArtifactUnavailableCode, BuiltExtractionInspectorCandidate, BuiltExtractionInspectorModel, ExtractionInspectorCandidate, ExtractionInspectorEntry, ExtractionInspectorExportOptions, ExtractionInspectorFilters, ExtractionInspectorInput, ExtractionInspectorModel, ExtractionInspectorSource, ResolvedExtractionArtifact, } from "./review-workbench/extraction-inspector.js";
11
- export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionEvidenceMatch, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
11
+ export type { ExtractionEnvelopeImport, ExtractionEnvelopeImportDiagnostic, ExtractionEnvelopeImportOptions, ExtractionEnvelopeImportProvenance, ExtractionEnvelopeImportResult, ExtractionEnvelopeResolutionIdentity, PortableExtractionCoverageEntry, PortableExtractionEvidenceMatch, PortableExtractionPartialReason, PortableExtractionOccurrence, PortableExtractionProducedBy, PortableExtractionProposal, PortableExtractionResultEnvelope, PortablePreparedArtifactState, } from "./extraction-envelope.js";
12
12
  export type { CandidateRole, ClaimTargetHint, ProducerPolicy, ExtractionReference, ResourceEnvelope, ResourceMetadata, ReviewActor, ReviewCandidate, ReviewDecision, ReviewDecisionMode, ReviewDecisionSpec, ReviewDecisionStatus, ReviewItem, ReviewItemSpec, ReviewItemStatus, ReviewLocator, ReviewResource, ReviewResourceApiVersion, ReviewResourceKind, ReviewSession, ReviewSessionEvent, ReviewSessionEventSpec, ReviewSessionEventStatus, ReviewSessionEventType, ReviewSessionSpec, ReviewSessionStatus, ReviewValueDescriptor, ReviewValueType, SourceReference, SurveyRecordProjectionHint, } from "./review-resource.js";
13
13
  export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";
14
14
  export { candidateReviewRecord, candidateSetStatusFor, SurveyInputBuilder } from "./builder.js";
package/dist/src/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
2
2
  export { reviewResourceApiVersion } from "./review-resource.js";
3
3
  export { buildReviewItemsFromExtractionEnvelopeImport, createExtractionEnvelopeResolutionIdentity, exportExtractionEnvelopeImport, extractionEnvelopeImportApiVersion, importExtractionEnvelope, portableExtractionResultFormat, portableExtractionResultVersion, reimportExtractionEnvelope, validateExtractionEnvelopeImport, } from "./extraction-envelope.js";
4
- export { buildExtractionInspectorModel, exportExtractionInspector, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
4
+ export { buildExtractionInspectorModel, exportExtractionInspector, inspectorSourcePosture, filterExtractionInspectorCandidates, } from "./review-workbench/extraction-inspector.js";
5
5
  export { assertReviewQueueAgainstExtractionImport, assertReviewQueueBinding, bindReviewQueue, hashReviewQueueSnapshot, UnattestedExtractionQueueError, UnattestedReviewQueueError, validateReviewQueueAgainstExtractionImport, validateReviewQueueBinding, } from "./review-workbench/queue-binding.js";
6
6
  export { resolvePortablePdfRegion } from "./pdf-layout.js";
7
7
  export { toSurfaceReviewedExtractionDecision, toSurfaceReviewedExtractionImport, toSurfaceReviewedExtractionItem, } from "./surface-reviewed-extraction.js";