@kontourai/survey 3.0.0 → 5.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.
Files changed (46) hide show
  1. package/README.md +4 -0
  2. package/dist/examples/calibrated-auto-accept.d.ts +22 -15
  3. package/dist/examples/calibrated-auto-accept.js +40 -36
  4. package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
  5. package/dist/src/calibration.d.ts +48 -21
  6. package/dist/src/calibration.js +72 -33
  7. package/dist/src/canonical-reviewed-trust-input.js +73 -34
  8. package/dist/src/console/review-console-server.d.ts +3 -1
  9. package/dist/src/console/review-console-server.js +203 -50
  10. package/dist/src/extraction-envelope.d.ts +81 -3
  11. package/dist/src/extraction-envelope.js +183 -24
  12. package/dist/src/index.d.ts +9 -8
  13. package/dist/src/index.js +3 -3
  14. package/dist/src/inquiry-mapping.d.ts +15 -1
  15. package/dist/src/inquiry-mapping.js +10 -2
  16. package/dist/src/mcp/review-mcp.js +112 -90
  17. package/dist/src/producer-profile.d.ts +41 -2
  18. package/dist/src/producer-profile.js +29 -2
  19. package/dist/src/review-session-file.d.ts +64 -0
  20. package/dist/src/review-session-file.js +320 -0
  21. package/dist/src/review-workbench/edited-value.d.ts +70 -0
  22. package/dist/src/review-workbench/edited-value.js +147 -0
  23. package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
  24. package/dist/src/review-workbench/extraction-inspector.js +55 -19
  25. package/dist/src/review-workbench/queue-binding.js +1 -1
  26. package/dist/src/review-workbench/review-presentation.d.ts +46 -1
  27. package/dist/src/review-workbench/review-presentation.js +72 -1
  28. package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
  29. package/dist/src/review-workbench/review-queue-session.js +54 -14
  30. package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
  31. package/dist/src/review-workbench/review-session-replay.js +82 -3
  32. package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
  33. package/dist/src/review-workbench/review-workbench.css +2 -0
  34. package/dist/src/review-workbench/review-workbench.d.ts +22 -11
  35. package/dist/src/review-workbench/review-workbench.js +91 -26
  36. package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
  37. package/dist/src/review-workbench/server-review-session.d.ts +3 -1
  38. package/dist/src/review-workbench/server-review-session.js +1 -0
  39. package/dist/src/reviewed-candidate-resolution.js +13 -7
  40. package/dist/src/schema-mapping.d.ts +23 -0
  41. package/dist/src/schema-mapping.js +30 -20
  42. package/dist/src/surface-reviewed-extraction.js +4 -0
  43. package/dist/src/to-surface.d.ts +30 -6
  44. package/dist/src/to-surface.js +312 -18
  45. package/dist/src/types.d.ts +44 -1
  46. package/package.json +5 -4
@@ -20,7 +20,7 @@
20
20
  * so that resolveInquiry can resolve across systems with weakest-link
21
21
  * capping.
22
22
  */
23
- import { evaluateAutoAccept, getProducerProposal, projectProposalsToCandidateSet, } from "./producer-profile.js";
23
+ import { assertValidAutoAcceptThreshold, evaluateAutoAccept, getProducerProposal, projectProposalsToCandidateSet, } from "./producer-profile.js";
24
24
  import { buildSurveyTrustBundle } from "./to-surface.js";
25
25
  // ---------------------------------------------------------------------------
26
26
  // Canonical pair key
@@ -35,6 +35,14 @@ function fieldPairKey(a, b) {
35
35
  function mappingSubjectId(a, b) {
36
36
  return fieldPairKey(a, b);
37
37
  }
38
+ function mappingValue(proposal) {
39
+ return {
40
+ relation: proposal.relation,
41
+ sourceField: proposal.sourceField,
42
+ targetField: proposal.targetField,
43
+ conversion: proposal.conversion,
44
+ };
45
+ }
38
46
  // ---------------------------------------------------------------------------
39
47
  // surveySchemaMapping
40
48
  // ---------------------------------------------------------------------------
@@ -56,9 +64,12 @@ function mappingSubjectId(a, b) {
56
64
  * storage) or for mappingReviewToSurface after human review.
57
65
  */
58
66
  export async function surveySchemaMapping(context, extractor, options = {}) {
67
+ if (options.autoAcceptMinConfidence !== undefined)
68
+ assertValidAutoAcceptThreshold(options.autoAcceptMinConfidence);
59
69
  const generatedAt = options.generatedAt ?? new Date().toISOString();
60
70
  const source = options.source ?? `schema-mapping:${extractor.name}`;
61
71
  const proposals = await Promise.resolve(extractor.extract(context));
72
+ const autoAcceptWarnings = [];
62
73
  // One RawSource per system schema
63
74
  const rawSources = context.systems.map((s) => ({
64
75
  id: `schema-mapping.source.${s.system}`,
@@ -93,11 +104,7 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
93
104
  id: extractionId,
94
105
  sourceId: rawSource.id,
95
106
  target: `${proposal.sourceField.entity}.${proposal.sourceField.field}:maps-to:${proposal.targetField.entity}.${proposal.targetField.field}`,
96
- value: {
97
- relation: proposal.relation,
98
- targetField: proposal.targetField,
99
- conversion: proposal.conversion,
100
- },
107
+ value: mappingValue(proposal),
101
108
  confidence: proposal.confidence,
102
109
  locator: proposal.sourceField.locator ?? `structured-field:${proposal.sourceField.entity}.${proposal.sourceField.field}`,
103
110
  excerpt: proposal.evidence.map((e) => `[${e.system}] ${e.excerpt}`).join(" | "),
@@ -120,11 +127,7 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
120
127
  return {
121
128
  candidateId: `schema-mapping.candidate.${proposal.id}`,
122
129
  extractionId,
123
- value: {
124
- relation: proposal.relation,
125
- targetField: proposal.targetField,
126
- conversion: proposal.conversion,
127
- },
130
+ value: mappingValue(proposal),
128
131
  confidence: proposal.confidence,
129
132
  equivalenceKey: proposal.relation,
130
133
  metadata: {
@@ -179,6 +182,13 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
179
182
  rationale: selectedProposal?.rationale,
180
183
  proposedAt: selectedProposal?.proposedAt,
181
184
  }, hasConflict, { minConfidence: options.autoAcceptMinConfidence }, generatedAt);
185
+ if (decision.warning) {
186
+ autoAcceptWarnings.push({
187
+ code: decision.warning,
188
+ proposalId: selectedProposal?.proposalId ?? selectedCandidate.id,
189
+ confidence: decision.confidence,
190
+ });
191
+ }
182
192
  if (decision.accepted) {
183
193
  const reviewId = `schema-mapping.review.${pairKey}`;
184
194
  reviewOutcomes.push({
@@ -208,12 +218,8 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
208
218
  facet: "schema-mapping.profile",
209
219
  claimType: "schema-mapping.field-link",
210
220
  fieldOrBehavior: "maps-to",
211
- value: {
212
- relation: first.relation,
213
- sourceField: first.sourceField,
214
- targetField: first.targetField,
215
- conversion: first.conversion,
216
- },
221
+ // No value override: the claim carries the selected candidate's value,
222
+ // which is the whole mapping a reviewer (or auto-accept) decided on.
217
223
  ...(claimStatus ? { status: claimStatus } : {}),
218
224
  impactLevel: "medium",
219
225
  collectedBy: extractor.name,
@@ -241,7 +247,12 @@ export async function surveySchemaMapping(context, extractor, options = {}) {
241
247
  reviewOutcomes,
242
248
  claims,
243
249
  };
244
- return { surveyInput, proposals, candidateSets };
250
+ return {
251
+ surveyInput,
252
+ proposals,
253
+ candidateSets,
254
+ ...(autoAcceptWarnings.length > 0 ? { autoAcceptWarnings } : {}),
255
+ };
245
256
  }
246
257
  // ---------------------------------------------------------------------------
247
258
  // mappingReviewToSurface
@@ -315,7 +326,7 @@ export function mappingReviewToSurface(reviewedMappings, options = {}) {
315
326
  id: extractionId,
316
327
  sourceId: rawSourceId,
317
328
  target: `${sourceField.entity}.${sourceField.field}:maps-to:${targetField.entity}.${targetField.field}`,
318
- value: { relation, targetField, conversion },
329
+ value: { relation, sourceField, targetField, conversion },
319
330
  confidence,
320
331
  locator: sourceField.locator ?? `structured-field:${sourceField.entity}.${sourceField.field}`,
321
332
  excerpt: evidence.map((e) => `[${e.system}] ${e.excerpt}`).join(" | "),
@@ -352,7 +363,6 @@ export function mappingReviewToSurface(reviewedMappings, options = {}) {
352
363
  facet: "schema-mapping.profile",
353
364
  claimType: "schema-mapping.field-link",
354
365
  fieldOrBehavior: "maps-to",
355
- value: { relation, sourceField, targetField, conversion },
356
366
  status: rm.reviewOutcome.status,
357
367
  impactLevel: "medium",
358
368
  collectedBy: proposedBy,
@@ -1,4 +1,8 @@
1
1
  export function toSurfaceReviewedExtractionImport(record) {
2
+ const unreported = record.spec.envelope.result.proposals.findIndex((proposal) => proposal.confidence === undefined);
3
+ if (unreported !== -1) {
4
+ throw new Error(`Surface's reviewed-extraction profile requires a proposer confidence on every proposal; proposal ${unreported} of ${record.metadata.name} reports none.`);
5
+ }
2
6
  return record;
3
7
  }
4
8
  export function toSurfaceReviewedExtractionItem(item) {
@@ -2,6 +2,14 @@ import type { TrustBundle } from "@kontourai/surface";
2
2
  import { type CalibrationMetrics } from "./calibration.js";
3
3
  import type { SurveyInput } from "./types.js";
4
4
  export interface SurveyCalibrationOptions {
5
+ /**
6
+ * EXPERIMENTAL opt-in, required for any `conclusionConfidence.value` to be
7
+ * set (#279). The value is the affirmation rate of the claim's whole
8
+ * extractor/field GROUP — a base rate every affirmed claim in the group
9
+ * shares — not a per-claim probability. Without this flag the `calibration`
10
+ * option sets no value.
11
+ */
12
+ experimentalConclusionValue?: boolean;
5
13
  /**
6
14
  * Precomputed calibration to source the value from — typically derived over a
7
15
  * LONGER history than the current batch (a better-grounded curve, and it avoids
@@ -30,16 +38,32 @@ export interface BuildSurveyTrustBundleOptions {
30
38
  */
31
39
  projectionContextId?: string;
32
40
  /**
33
- * Populate `conclusionConfidence.value` from empirical review calibration —
34
- * "how often this extractor's proposals at this confidence were affirmed by a
35
- * human reviewer" (the produce side of the confidence loop; see #114/#137).
36
- * `true` derives calibration from this batch; an object supplies precomputed
37
- * metrics and/or a `minSamples` floor. Absent → `value` stays unset and only
38
- * the comfort-zone signal is carried (unchanged behavior).
41
+ * EXPERIMENTAL. Populate `conclusionConfidence.value` from empirical review
42
+ * calibration — the affirmation rate of the claim's extractor/field group
43
+ * (a group base rate, not a per-claim probability; see #114/#137/#279).
44
+ * A value is set ONLY with `{ experimentalConclusionValue: true }`; `true` or
45
+ * an object without that flag sets no value. The object may also supply
46
+ * precomputed `metrics` and/or a `minSamples` floor. Absent → `value` stays
47
+ * unset and only the comfort-zone signal is carried.
39
48
  *
40
49
  * ADVISORY (ADR 0003 §4): this only enriches the emitted conclusion confidence;
41
50
  * it never changes a claim's `status`.
42
51
  */
43
52
  calibration?: boolean | SurveyCalibrationOptions;
44
53
  }
54
+ /**
55
+ * Thrown when a claim's trusted status or value does not agree with the review
56
+ * outcome it cites, or when the governing review cannot be chosen.
57
+ * - `status-mismatch`: a `verified`/`assumed` claim cites a review whose status differs.
58
+ * - `value-mismatch`: a `verified`/`assumed` claim carries a value other than the reviewed value.
59
+ * - `ambiguous-review-order`: several reviews apply to one candidate and the latest
60
+ * cannot be determined from `reviewedAt` (missing, unparseable, or tied).
61
+ */
62
+ export declare class ReviewAgreementError extends Error {
63
+ readonly name = "ReviewAgreementError";
64
+ readonly code: "status-mismatch" | "value-mismatch" | "ambiguous-review-order";
65
+ constructor(code: ReviewAgreementError["code"], message: string);
66
+ }
45
67
  export declare function buildSurveyTrustBundle(input: SurveyInput, options?: BuildSurveyTrustBundleOptions): TrustBundle;
68
+ /** Test-only: re-arm the once-per-process calibration opt-in warning. Not exported from the package index. */
69
+ export declare function resetCalibrationOptInWarningForTests(): void;
@@ -2,15 +2,34 @@ import { createHash } from "node:crypto";
2
2
  import { buildReviewProofAnchor } from "./review-proof.js";
3
3
  import { assertReviewOutcomeDiscipline } from "./producer-discipline.js";
4
4
  import { deriveCalibration } from "./calibration.js";
5
+ import { AUTO_ACCEPT_ACTOR } from "./producer-profile.js";
6
+ import { canonicalJson } from "./review-workbench/canonical.js";
5
7
  /** Minimum labeled samples a calibration group needs before its empirical
6
8
  * accuracy is emitted as a `conclusionConfidence.value`. */
7
9
  const DEFAULT_CALIBRATION_MIN_SAMPLES = 20;
10
+ /**
11
+ * Thrown when a claim's trusted status or value does not agree with the review
12
+ * outcome it cites, or when the governing review cannot be chosen.
13
+ * - `status-mismatch`: a `verified`/`assumed` claim cites a review whose status differs.
14
+ * - `value-mismatch`: a `verified`/`assumed` claim carries a value other than the reviewed value.
15
+ * - `ambiguous-review-order`: several reviews apply to one candidate and the latest
16
+ * cannot be determined from `reviewedAt` (missing, unparseable, or tied).
17
+ */
18
+ export class ReviewAgreementError extends Error {
19
+ name = "ReviewAgreementError";
20
+ code;
21
+ constructor(code, message) {
22
+ super(message);
23
+ this.code = code;
24
+ }
25
+ }
8
26
  export function buildSurveyTrustBundle(input, options = {}) {
9
27
  const projectionContextId = validateProjectionContextId(options.projectionContextId);
10
28
  const rawSources = indexById(input.rawSources, "raw source");
11
29
  const extractions = indexById(input.extractions, "extraction");
12
30
  const candidateSets = indexById(input.candidateSets, "candidate set");
13
31
  const reviewsByCandidateSet = groupBy(input.reviewOutcomes, (review) => review.candidateSetId);
32
+ // Only the explicit experimental opt-in produces a value (#279).
14
33
  const calibrationOptions = normalizeCalibrationOptions(options.calibration);
15
34
  const calibrationMetrics = calibrationOptions
16
35
  ? (calibrationOptions.metrics ?? deriveCalibration({
@@ -25,21 +44,26 @@ export function buildSurveyTrustBundle(input, options = {}) {
25
44
  const events = [];
26
45
  for (const projection of input.claims) {
27
46
  const candidateSet = requireMapValue(candidateSets, projection.candidateSetId, "candidate set");
47
+ if (projection.candidateId === undefined && candidateSet.selectedCandidateId === undefined && candidateSet.candidates.length > 1) {
48
+ projectUnselectedSetClaim({ input, projection, candidateSet, rawSources, extractions, reviewsByCandidateSet, projectionContextId, claims, evidence, events });
49
+ continue;
50
+ }
28
51
  const candidate = selectCandidate(candidateSet, projection.candidateId);
29
52
  const extraction = requireMapValue(extractions, candidate.extractionId, "extraction");
30
53
  const rawSource = requireMapValue(rawSources, extraction.sourceId, "raw source");
31
- const review = selectReview(reviewsByCandidateSet.get(candidateSet.id) ?? [], candidate.id);
54
+ const review = selectReview(reviewsByCandidateSet.get(candidateSet.id) ?? [], candidate.id, projection.id);
32
55
  const projectionReview = review?.resolution === "could_not_confirm" ? undefined : review;
33
56
  const unreviewedStatus = statusFor({ candidateSet, candidate });
34
57
  const status = projection.status
35
58
  ?? (review?.resolution === "could_not_confirm"
36
59
  ? (unreviewedStatus === "disputed" ? unreviewedStatus : review.status)
37
60
  : statusFor({ candidateSet, candidate, review }));
38
- assertProducerDiscipline({ status, review, candidateSet, extraction, rawSource, projection });
39
61
  const claimValue = projection.value ?? candidate.value;
62
+ assertProducerDiscipline({ status, review, candidateSet, candidate, extraction, rawSource, projection, claimValue });
40
63
  const createdAt = projection.createdAt ?? extraction.extractedAt;
41
64
  const updatedAt = projection.updatedAt ?? projectionReview?.reviewedAt ?? input.generatedAt;
42
65
  const evidenceId = projectionRecordId(projection.id, projectionContextId, "claim-evidence", "evidence.source");
66
+ const autoAccepted = projectionReview?.actor === AUTO_ACCEPT_ACTOR;
43
67
  const claim = {
44
68
  id: projection.id,
45
69
  subjectType: projection.subjectType,
@@ -57,8 +81,13 @@ export function buildSurveyTrustBundle(input, options = {}) {
57
81
  confidenceBasis: {
58
82
  sourceQuality: "moderate",
59
83
  extractionConfidence: candidate.confidence ?? extraction.confidence,
60
- reviewerAuthority: status === "verified" || status === "assumed" ? "operator" : "none",
61
- evidenceStrength: status === "verified" || status === "assumed" ? "moderate" : "weak",
84
+ // An auto-accept policy checked nothing but the proposer's own
85
+ // confidence, so its claims carry system authority and weak evidence;
86
+ // only a human review earns operator authority.
87
+ reviewerAuthority: status === "verified" || status === "assumed"
88
+ ? (autoAccepted ? "system" : "operator")
89
+ : "none",
90
+ evidenceStrength: (status === "verified" || status === "assumed") && !autoAccepted ? "moderate" : "weak",
62
91
  impactLevel: projection.impactLevel,
63
92
  ...projection.confidenceBasis,
64
93
  },
@@ -72,18 +101,21 @@ export function buildSurveyTrustBundle(input, options = {}) {
72
101
  candidate,
73
102
  review: projectionReview,
74
103
  comfortZoneReview: review,
104
+ valueEdit: (status === "verified" || status === "assumed") && review ? acceptedEdit(review) : undefined,
75
105
  }),
76
106
  },
77
107
  };
78
- // Promote the review's comfort-zone signal — and, when calibration is
79
- // enabled, an empirically-calibrated conclusion probability — into the
108
+ // Promote the review's comfort-zone signal — and, under the experimental
109
+ // calibration opt-in, the group affirmation rate — into the
80
110
  // first-class conclusionConfidence field (Surface 2.9 / Hachure 0.14) so the
81
111
  // signal is portable and comparable, not buried in producer metadata.
82
112
  //
83
- // comfortZone is CARRIED from the review. `value` is PRODUCED from empirical
84
- // review calibration (#114/#137): the affirmation rate of this extractor's
85
- // proposals at this confidence — a calibrated conclusion probability, distinct
86
- // from the extraction-confidence ingredient in confidenceBasis.
113
+ // comfortZone is CARRIED from the review. `value` is PRODUCED, only under the
114
+ // experimental opt-in, from empirical review calibration (#114/#137/#279):
115
+ // the affirmation rate of this claim's extractor/field GROUP. It is a group
116
+ // base rate shared by every affirmed claim in the group, not a per-claim
117
+ // probability, and distinct from the extraction-confidence ingredient in
118
+ // confidenceBasis.
87
119
  //
88
120
  // A value is produced only for an AFFIRMED conclusion (status verified/assumed)
89
121
  // that clears the sample floor. conclusionConfidence.value is "probability the
@@ -192,6 +224,116 @@ export function buildSurveyTrustBundle(input, options = {}) {
192
224
  events,
193
225
  };
194
226
  }
227
+ /**
228
+ * A claim over a candidate set that selects none of its candidates (a review
229
+ * rejected every conflicting value, or could not confirm any). No single value
230
+ * is presented: the claim value is the projection's own (null from the
231
+ * canonical path), every candidate is listed in `metadata.survey.candidates`
232
+ * and backed by its own evidence record, and the status can never be trusted.
233
+ * A review that names one candidate cannot apply to such a claim and is
234
+ * refused rather than dropped. With `reviewProofs`, no integrity anchor is
235
+ * attached: the anchor commits one reviewed candidate, and this claim has none
236
+ * (it is never `verified` or `assumed`).
237
+ */
238
+ function projectUnselectedSetClaim(context) {
239
+ const { input, projection, candidateSet, projectionContextId } = context;
240
+ const setReviews = context.reviewsByCandidateSet.get(candidateSet.id) ?? [];
241
+ const candidateReviews = setReviews.filter((review) => review.candidateId);
242
+ if (candidateReviews.length) {
243
+ throw new Error(`Claim ${projection.id} names no candidate of set ${candidateSet.id}, but review ${candidateReviews.map((review) => review.id).join(", ")} is about candidate ${candidateReviews.map((review) => review.candidateId).join(", ")}: a candidate-level review needs a selectedCandidateId on the set or a candidateId on the claim.`);
244
+ }
245
+ const reviews = setReviews;
246
+ const review = latestReview(reviews, projection.id);
247
+ const setStatus = statusFor({ candidateSet, candidate: { id: "", extractionId: "", value: null } });
248
+ const status = projection.status
249
+ ?? (review?.resolution === "could_not_confirm"
250
+ ? (setStatus === "disputed" ? setStatus : review.status)
251
+ : review?.status ?? setStatus);
252
+ if (status === "verified" || status === "assumed") {
253
+ throw new ReviewAgreementError("status-mismatch", `Claim ${projection.id} selects no candidate of set ${candidateSet.id}, so it cannot be ${status}`);
254
+ }
255
+ assertReviewOutcomeDiscipline({ subject: `Claim ${projection.id}`, status, review, candidateSetStatus: candidateSet.status });
256
+ const sources = candidateSet.candidates.map((candidate) => {
257
+ const extraction = requireMapValue(context.extractions, candidate.extractionId, "extraction");
258
+ const rawSource = requireMapValue(context.rawSources, extraction.sourceId, "raw source");
259
+ return { candidate, extraction, rawSource };
260
+ });
261
+ const evidenceIds = sources.map(({ candidate, extraction, rawSource }) => {
262
+ const id = projectionRecordId(projection.id, projectionContextId, "claim-evidence", `evidence.source.${candidate.id}`);
263
+ const policyStandard = policyStandardFields(rawSource);
264
+ context.evidence.push({
265
+ id,
266
+ claimId: projection.id,
267
+ evidenceType: projection.evidenceType ?? (rawSource.resolution ? evidenceTypeForResolution(rawSource, rawSource.resolution) : evidenceTypeFor(rawSource)),
268
+ method: projection.evidenceMethod ?? "extraction",
269
+ sourceRef: rawSource.sourceRef,
270
+ sourceLocator: extraction.locator,
271
+ excerptOrSummary: evidenceExcerptOrSummary({ rawSource, extraction, projection, policyStandard }),
272
+ observedAt: rawSource.observedAt,
273
+ collectedBy: projection.collectedBy,
274
+ integrityRef: rawSource.checksum,
275
+ metadata: {
276
+ ...rawSource.metadata,
277
+ ...extraction.metadata,
278
+ ...candidate.metadata,
279
+ ...(policyStandard ? { policyStandard } : {}),
280
+ rawSourceKind: rawSource.kind,
281
+ locatorScheme: rawSource.locatorScheme,
282
+ candidateId: candidate.id,
283
+ ...(candidate.confidence ?? extraction.confidence) !== undefined ? { confidence: candidate.confidence ?? extraction.confidence } : {},
284
+ },
285
+ });
286
+ return id;
287
+ });
288
+ const producerSurvey = isRecord(projection.metadata?.survey) ? projection.metadata.survey : {};
289
+ context.claims.push({
290
+ id: projection.id,
291
+ subjectType: projection.subjectType,
292
+ subjectId: projection.subjectId,
293
+ facet: projection.facet,
294
+ claimType: projection.claimType,
295
+ fieldOrBehavior: projection.fieldOrBehavior,
296
+ value: "value" in projection ? projection.value : null,
297
+ status,
298
+ createdAt: projection.createdAt ?? sources.map(({ extraction }) => extraction.extractedAt).sort()[0],
299
+ updatedAt: projection.updatedAt ?? review?.reviewedAt ?? input.generatedAt,
300
+ impactLevel: projection.impactLevel,
301
+ derivedFrom: projection.derivedFrom,
302
+ derivationEdges: projection.derivationEdges,
303
+ confidenceBasis: {
304
+ sourceQuality: "moderate",
305
+ reviewerAuthority: "none",
306
+ evidenceStrength: "weak",
307
+ impactLevel: projection.impactLevel,
308
+ ...projection.confidenceBasis,
309
+ },
310
+ metadata: {
311
+ ...projection.metadata,
312
+ survey: {
313
+ ...producerSurvey,
314
+ candidateSetId: candidateSet.id,
315
+ candidateSetStatus: candidateSet.status,
316
+ // No candidate is selected; every value the set held, in set order.
317
+ candidates: candidateSet.candidates.map((candidate) => ({
318
+ candidateId: candidate.id,
319
+ value: candidate.value,
320
+ ...(candidate.rejectionReason !== undefined ? { rejectionReason: candidate.rejectionReason } : {}),
321
+ })),
322
+ reviewOutcomeId: review?.id,
323
+ },
324
+ },
325
+ });
326
+ context.events.push({
327
+ id: projectionRecordId(projection.id, projectionContextId, "claim-event", `event.${status}`),
328
+ claimId: projection.id,
329
+ status,
330
+ actor: projection.actor ?? (review?.resolution === "could_not_confirm" ? undefined : review?.actor) ?? projection.collectedBy,
331
+ method: projection.eventMethod ?? eventMethodFor(status, candidateSet),
332
+ evidenceIds,
333
+ createdAt: review?.reviewedAt ?? input.generatedAt,
334
+ notes: review?.rationale ?? candidateSet.rationale,
335
+ });
336
+ }
195
337
  function validateProjectionContextId(contextId) {
196
338
  if (contextId === undefined)
197
339
  return undefined;
@@ -235,7 +377,8 @@ function projectInterpretations(input) {
235
377
  }
236
378
  }
237
379
  function buildInterpretationProjection(input) {
238
- const anchorSource = requirePolicyStandardAnchor(input.interpretation, input.rawSources);
380
+ assertInterpretationReadingShape(input.interpretation);
381
+ const anchorSource = requireInterpretationAnchor(input.interpretation, input.rawSources);
239
382
  const claim = resolveInterpretationClaim(input.interpretation, input.claims, input.input);
240
383
  if (!input.claimsById.has(claim.id)) {
241
384
  throw new Error(`Interpretation ${input.interpretation.id} resolved unknown claim ${claim.id}`);
@@ -260,6 +403,48 @@ function buildInterpretationProjection(input) {
260
403
  }),
261
404
  };
262
405
  }
406
+ const INTERPRETATION_READING_KINDS = ["policy-standard", "gleaned", "answerImpact"];
407
+ const INTERPRETATION_ANSWER_IMPACTS = ["supported", "narrowed", "accepted-risk"];
408
+ /** Absent readingKind means the original policy-standard reading (pre-#259 records). */
409
+ function interpretationReadingKind(interpretation) {
410
+ return interpretation.readingKind ?? "policy-standard";
411
+ }
412
+ /**
413
+ * Fail-closed shape validation for the reading dimension (#259). Records come
414
+ * from JSON as often as from typed callers, so unknown vocabulary throws
415
+ * instead of silently projecting under a label nothing derived.
416
+ */
417
+ function assertInterpretationReadingShape(interpretation) {
418
+ if (interpretation.readingKind !== undefined && !INTERPRETATION_READING_KINDS.includes(interpretation.readingKind)) {
419
+ throw new Error(`Interpretation ${interpretation.id} has unknown readingKind ${String(interpretation.readingKind)}; expected one of ${INTERPRETATION_READING_KINDS.join(", ")}`);
420
+ }
421
+ const kind = interpretationReadingKind(interpretation);
422
+ if (kind === "answerImpact") {
423
+ if (interpretation.answerImpact === undefined) {
424
+ throw new Error(`Interpretation ${interpretation.id} readingKind answerImpact requires an answerImpact value`);
425
+ }
426
+ if (!INTERPRETATION_ANSWER_IMPACTS.includes(interpretation.answerImpact)) {
427
+ throw new Error(`Interpretation ${interpretation.id} has unknown answerImpact ${String(interpretation.answerImpact)}; expected one of ${INTERPRETATION_ANSWER_IMPACTS.join(", ")}`);
428
+ }
429
+ return;
430
+ }
431
+ if (interpretation.answerImpact !== undefined) {
432
+ throw new Error(`Interpretation ${interpretation.id} sets answerImpact but readingKind is ${kind}; answerImpact is only valid on answerImpact readings`);
433
+ }
434
+ }
435
+ /**
436
+ * Resolves an interpretation's anchor raw source. Every reading kind requires
437
+ * a KNOWN raw source (referential integrity per #16 R3); only the original
438
+ * policy-standard reading additionally requires the source kind to be
439
+ * `policy-standard`. The gleaned / answerImpact readings (#259) anchor to the
440
+ * result source the producer read, whatever its kind.
441
+ */
442
+ function requireInterpretationAnchor(interpretation, rawSources) {
443
+ if (interpretationReadingKind(interpretation) === "policy-standard") {
444
+ return requirePolicyStandardAnchor(interpretation, rawSources);
445
+ }
446
+ return requireMapValue(rawSources, interpretation.anchorsToSourceId, "interpretation anchor raw source");
447
+ }
263
448
  function requirePolicyStandardAnchor(interpretation, rawSources) {
264
449
  const anchorSource = requireMapValue(rawSources, interpretation.anchorsToSourceId, "interpretation anchor raw source");
265
450
  if (anchorSource.kind !== "policy-standard") {
@@ -268,14 +453,20 @@ function requirePolicyStandardAnchor(interpretation, rawSources) {
268
453
  return anchorSource;
269
454
  }
270
455
  function createInterpretationAnchorEvidence(input) {
456
+ const readingKind = interpretationReadingKind(input.interpretation);
271
457
  return {
272
458
  id: projectionRecordId(input.interpretation.id, input.projectionContextId, "interpretation-evidence", "evidence.anchor"),
273
459
  claimId: input.claim.id,
274
- evidenceType: "policy_rule",
460
+ // The policy-standard reading keeps its exact prior projection; the new
461
+ // reading kinds (#259) derive the evidence type from the anchor source
462
+ // they actually read, via the same mapping extraction evidence uses.
463
+ evidenceType: readingKind === "policy-standard" ? "policy_rule" : evidenceTypeFor(input.anchorSource),
275
464
  method: "anchoring",
276
465
  sourceRef: input.anchorSource.sourceRef,
277
466
  sourceLocator: input.interpretation.ruleLocator,
278
- excerptOrSummary: input.policyStandard?.inlineText ?? `Anchored policy-standard reading at ${input.interpretation.ruleLocator}.`,
467
+ excerptOrSummary: readingKind === "policy-standard"
468
+ ? input.policyStandard?.inlineText ?? `Anchored policy-standard reading at ${input.interpretation.ruleLocator}.`
469
+ : input.anchorSource.inlineText ?? `Anchored ${readingKind} reading at ${input.interpretation.ruleLocator}.`,
279
470
  observedAt: input.anchorSource.observedAt,
280
471
  collectedBy: input.interpretation.actor,
281
472
  integrityRef: input.anchorSource.checksum,
@@ -287,6 +478,10 @@ function createInterpretationAnchorEvidence(input) {
287
478
  locatorScheme: input.anchorSource.locatorScheme,
288
479
  anchorsToSourceId: input.anchorSource.id,
289
480
  ruleLocator: input.interpretation.ruleLocator,
481
+ // Only explicitly-set reading kinds project, so legacy batches stay
482
+ // byte-identical. Authored-judgment provenance markers, not machine facts.
483
+ ...(input.interpretation.readingKind ? { readingKind: input.interpretation.readingKind } : {}),
484
+ ...(input.interpretation.answerImpact ? { answerImpact: input.interpretation.answerImpact } : {}),
290
485
  },
291
486
  };
292
487
  }
@@ -319,6 +514,10 @@ function attachInterpretationClaimMetadata(input) {
319
514
  reading: input.interpretation.reading,
320
515
  actor: input.interpretation.actor,
321
516
  recordedAt: input.interpretation.recordedAt,
517
+ // Explicit reading dimension only (#259): legacy entries keep their
518
+ // exact prior bytes; absent readingKind means policy-standard.
519
+ ...(input.interpretation.readingKind ? { readingKind: input.interpretation.readingKind } : {}),
520
+ ...(input.interpretation.answerImpact ? { answerImpact: input.interpretation.answerImpact } : {}),
322
521
  ...(input.interpretation.metadata ? { metadata: input.interpretation.metadata } : {}),
323
522
  edges: [
324
523
  {
@@ -394,6 +593,11 @@ function buildSurveyMetadata(input) {
394
593
  },
395
594
  }
396
595
  : {}),
596
+ // A trusted claim whose value is a reviewer's edit says so, and keeps the
597
+ // value the reviewer was shown.
598
+ ...(input.valueEdit
599
+ ? { valueEdit: { edited: true, originalValue: input.candidate.value, reviewOutcomeId: input.comfortZoneReview?.id } }
600
+ : {}),
397
601
  ...(input.comfortZoneReview?.withinComfortZone === false
398
602
  ? {
399
603
  comfortZone: {
@@ -414,6 +618,8 @@ function statusFor(input) {
414
618
  return "disputed";
415
619
  if (input.candidateSet.status === "escalated")
416
620
  return "disputed";
621
+ if (input.candidateSet.status === "rejected")
622
+ return "rejected";
417
623
  return "proposed";
418
624
  }
419
625
  function assertProducerDiscipline(input) {
@@ -423,6 +629,17 @@ function assertProducerDiscipline(input) {
423
629
  review: input.review,
424
630
  candidateSetStatus: input.candidateSet.status,
425
631
  });
632
+ if ((input.status === "verified" || input.status === "assumed") && input.review) {
633
+ // A trusted claim may only restate its review: same status, and the value
634
+ // the reviewer saw (the candidate value, or the edit the review records).
635
+ if (input.review.status !== input.status) {
636
+ throw new ReviewAgreementError("status-mismatch", `Claim ${input.projection.id} status ${input.status} disagrees with review outcome ${input.review.id} status ${input.review.status}`);
637
+ }
638
+ const reviewedValue = reviewedValueFor(input.review, input.candidate);
639
+ if (canonicalJson(input.claimValue) !== canonicalJson(reviewedValue)) {
640
+ throw new ReviewAgreementError("value-mismatch", `Claim ${input.projection.id} is ${input.status} but its value differs from the value reviewed in ${input.review.id}`);
641
+ }
642
+ }
426
643
  if (input.rawSource.kind !== "manual-entry" && !input.extraction.locator) {
427
644
  throw new Error(`Claim ${input.projection.id} needs a source locator for ${input.rawSource.kind}`);
428
645
  }
@@ -435,15 +652,92 @@ function selectCandidate(candidateSet, candidateId) {
435
652
  }
436
653
  return candidate;
437
654
  }
438
- function selectReview(reviews, candidateId) {
439
- return reviews.find((review) => review.candidateId === candidateId) ?? reviews.find((review) => !review.candidateId);
655
+ /** An accepted edit the review records. `buildCanonicalReviewedTrustInput`
656
+ * writes `metadata.editedValue` next to `metadata.workbenchDecision`; only an
657
+ * edit on an `accept-proposed` decision replaces the reviewed value. Any other
658
+ * `editedValue` (a bare one, or one on a reject or keep decision) is not an
659
+ * accepted edit and is ignored. */
660
+ function acceptedEdit(review) {
661
+ const metadata = review.metadata;
662
+ return metadata?.workbenchDecision === "accept-proposed" && Object.hasOwn(metadata, "editedValue")
663
+ ? { value: metadata.editedValue }
664
+ : undefined;
665
+ }
666
+ /** The value a review outcome attests: its accepted edit, else the candidate value that was reviewed. */
667
+ function reviewedValueFor(review, candidate) {
668
+ const edit = acceptedEdit(review);
669
+ return edit ? edit.value : candidate.value;
670
+ }
671
+ /** The latest applicable review governs. Exact candidate bindings take
672
+ * precedence over unbound (set-wide) reviews; within a tier, several reviews
673
+ * are ordered by `reviewedAt`, and an order that cannot be established is
674
+ * refused rather than guessed. */
675
+ function selectReview(reviews, candidateId, claimId) {
676
+ const exact = reviews.filter((review) => review.candidateId === candidateId);
677
+ return latestReview(exact.length ? exact : reviews.filter((review) => !review.candidateId), claimId);
678
+ }
679
+ function latestReview(reviews, claimId) {
680
+ if (reviews.length <= 1)
681
+ return reviews[0];
682
+ const timed = reviews.map((review) => ({ review, at: review.reviewedAt ? Date.parse(review.reviewedAt) : Number.NaN }));
683
+ const untimed = timed.find((entry) => Number.isNaN(entry.at));
684
+ if (untimed) {
685
+ throw new ReviewAgreementError("ambiguous-review-order", `Claim ${claimId} has ${reviews.length} applicable review outcomes but ${untimed.review.id} has no parseable reviewedAt`);
686
+ }
687
+ const latestAt = Math.max(...timed.map((entry) => entry.at));
688
+ const latest = timed.filter((entry) => entry.at === latestAt).map((entry) => entry.review);
689
+ // Reviews at the same instant (however the timestamp is spelled) that record
690
+ // the same decision are one review recorded twice; conflicting ones are refused.
691
+ if (new Set(latest.map(reviewDecisionKey)).size > 1) {
692
+ throw new ReviewAgreementError("ambiguous-review-order", `Claim ${claimId} has conflicting review outcomes ${latest.map((review) => review.id).join(", ")} at the latest reviewedAt`);
693
+ }
694
+ return [...latest].sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))[0];
695
+ }
696
+ function reviewDecisionKey(review) {
697
+ const edit = acceptedEdit(review);
698
+ return canonicalJson({
699
+ candidateId: review.candidateId ?? null,
700
+ status: review.status,
701
+ resolution: review.resolution ?? null,
702
+ edit: edit ? { value: edit.value } : null,
703
+ // These reach the claim (comfort zone, testimony provenance), so reviews
704
+ // that differ in them are different decisions, not one recorded twice.
705
+ withinComfortZone: review.withinComfortZone ?? null,
706
+ comfortZoneNote: review.comfortZoneNote ?? null,
707
+ authorizing: review.authorizing ?? null,
708
+ });
440
709
  }
441
710
  function normalizeCalibrationOptions(calibration) {
442
711
  if (calibration === undefined || calibration === false)
443
712
  return undefined;
444
- if (calibration === true)
445
- return {};
446
- return calibration;
713
+ if (calibration === true || calibration.experimentalConclusionValue === undefined) {
714
+ // Callers written before #279 asked for a value this way; tell them it is
715
+ // now ignored instead of silently dropping it. An explicit `false` is a
716
+ // deliberate opt-out and stays quiet.
717
+ warnCalibrationOptInMissingOnce();
718
+ return undefined;
719
+ }
720
+ return calibration.experimentalConclusionValue === true ? calibration : undefined;
721
+ }
722
+ let warnedCalibrationOptInMissing = false;
723
+ /**
724
+ * Warn once per process, matching the `defineProductVocabulary` deprecation
725
+ * notice: `buildSurveyTrustBundle` has no diagnostics channel in its return
726
+ * value (it returns a TrustBundle), and a per-call warning would flood batch
727
+ * projections.
728
+ */
729
+ function warnCalibrationOptInMissingOnce() {
730
+ if (warnedCalibrationOptInMissing)
731
+ return;
732
+ warnedCalibrationOptInMissing = true;
733
+ console.warn("[@kontourai/survey] buildSurveyTrustBundle: the `calibration` option no longer sets " +
734
+ "conclusionConfidence.value without `calibration: { experimentalConclusionValue: true }` (#279). " +
735
+ "The value is an experimental extractor/field group base rate, not a per-claim probability. " +
736
+ "Pass the flag to keep it, or `experimentalConclusionValue: false` / omit `calibration` to silence this warning.");
737
+ }
738
+ /** Test-only: re-arm the once-per-process calibration opt-in warning. Not exported from the package index. */
739
+ export function resetCalibrationOptInWarningForTests() {
740
+ warnedCalibrationOptInMissing = false;
447
741
  }
448
742
  /**
449
743
  * Looks up the empirical affirmation rate for an extractor/field, preferring the