@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.
@@ -44,6 +44,10 @@ export function buildSurveyTrustBundle(input, options = {}) {
44
44
  const events = [];
45
45
  for (const projection of input.claims) {
46
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
+ }
47
51
  const candidate = selectCandidate(candidateSet, projection.candidateId);
48
52
  const extraction = requireMapValue(extractions, candidate.extractionId, "extraction");
49
53
  const rawSource = requireMapValue(rawSources, extraction.sourceId, "raw source");
@@ -220,6 +224,116 @@ export function buildSurveyTrustBundle(input, options = {}) {
220
224
  events,
221
225
  };
222
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
+ }
223
337
  function validateProjectionContextId(contextId) {
224
338
  if (contextId === undefined)
225
339
  return undefined;
@@ -504,6 +618,8 @@ function statusFor(input) {
504
618
  return "disputed";
505
619
  if (input.candidateSet.status === "escalated")
506
620
  return "disputed";
621
+ if (input.candidateSet.status === "rejected")
622
+ return "rejected";
507
623
  return "proposed";
508
624
  }
509
625
  function assertProducerDiscipline(input) {
@@ -28,7 +28,11 @@ export interface Extraction {
28
28
  extractedAt: string;
29
29
  metadata?: Record<string, unknown>;
30
30
  }
31
- export type CandidateSetStatus = "resolved" | "needs-review" | "conflict" | "escalated";
31
+ /**
32
+ * `rejected`: a review rejected every candidate in the set and selected none
33
+ * (for example rejecting all conflicting values for one claim).
34
+ */
35
+ export type CandidateSetStatus = "resolved" | "needs-review" | "conflict" | "escalated" | "rejected";
32
36
  export type EscalationDimension = "framing" | "completeness" | "conclusion" | "citation";
33
37
  export interface Candidate {
34
38
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontourai/survey",
3
- "version": "4.0.0",
3
+ "version": "6.0.0",
4
4
  "description": "Producer-side source, extraction, candidate, and review contracts for projecting verified claims into Surface.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",