@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.
- package/README.md +4 -0
- package/dist/examples/calibrated-auto-accept.d.ts +22 -15
- package/dist/examples/calibrated-auto-accept.js +40 -36
- package/dist/examples/review-workbench/server-apply-consumer.js +5 -0
- package/dist/src/calibration.d.ts +48 -21
- package/dist/src/calibration.js +72 -33
- package/dist/src/canonical-reviewed-trust-input.js +73 -34
- package/dist/src/console/review-console-server.d.ts +3 -1
- package/dist/src/console/review-console-server.js +203 -50
- package/dist/src/extraction-envelope.d.ts +81 -3
- package/dist/src/extraction-envelope.js +183 -24
- package/dist/src/index.d.ts +9 -8
- package/dist/src/index.js +3 -3
- package/dist/src/inquiry-mapping.d.ts +15 -1
- package/dist/src/inquiry-mapping.js +10 -2
- package/dist/src/mcp/review-mcp.js +112 -90
- package/dist/src/producer-profile.d.ts +41 -2
- package/dist/src/producer-profile.js +29 -2
- package/dist/src/review-session-file.d.ts +64 -0
- package/dist/src/review-session-file.js +320 -0
- package/dist/src/review-workbench/edited-value.d.ts +70 -0
- package/dist/src/review-workbench/edited-value.js +147 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +15 -1
- package/dist/src/review-workbench/extraction-inspector.js +55 -19
- package/dist/src/review-workbench/queue-binding.js +1 -1
- package/dist/src/review-workbench/review-presentation.d.ts +46 -1
- package/dist/src/review-workbench/review-presentation.js +72 -1
- package/dist/src/review-workbench/review-queue-session.d.ts +19 -5
- package/dist/src/review-workbench/review-queue-session.js +54 -14
- package/dist/src/review-workbench/review-session-replay.d.ts +35 -1
- package/dist/src/review-workbench/review-session-replay.js +82 -3
- package/dist/src/review-workbench/review-workbench-css.generated.js +2 -0
- package/dist/src/review-workbench/review-workbench.css +2 -0
- package/dist/src/review-workbench/review-workbench.d.ts +22 -11
- package/dist/src/review-workbench/review-workbench.js +91 -26
- package/dist/src/review-workbench/review-workbench.standalone.css +2 -0
- package/dist/src/review-workbench/server-review-session.d.ts +3 -1
- package/dist/src/review-workbench/server-review-session.js +1 -0
- package/dist/src/reviewed-candidate-resolution.js +13 -7
- package/dist/src/schema-mapping.d.ts +23 -0
- package/dist/src/schema-mapping.js +30 -20
- package/dist/src/surface-reviewed-extraction.js +4 -0
- package/dist/src/to-surface.d.ts +30 -6
- package/dist/src/to-surface.js +312 -18
- package/dist/src/types.d.ts +44 -1
- 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
|
-
|
|
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 {
|
|
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) {
|
package/dist/src/to-surface.d.ts
CHANGED
|
@@ -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
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
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;
|
package/dist/src/to-surface.js
CHANGED
|
@@ -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
|
-
|
|
61
|
-
|
|
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,
|
|
79
|
-
//
|
|
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
|
|
84
|
-
// review calibration (#114/#137):
|
|
85
|
-
//
|
|
86
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
446
|
-
|
|
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
|