@kontourai/survey 1.7.0 → 1.8.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/dist/src/index.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ export { buildSurveyTrustBundle } from "./to-surface.js";
|
|
|
12
12
|
export type { BuildSurveyTrustBundleOptions } from "./to-surface.js";
|
|
13
13
|
export { buildSurveyLearningProjections } from "./learning-projections.js";
|
|
14
14
|
export type { LearningProjection, LearningProjectionKind, LearningProjectionSeverity, LearningProjectionSignal, } from "./learning-projections.js";
|
|
15
|
+
export { buildReviewedLearningUpdateProposal } from "./learning-update-proposal.js";
|
|
16
|
+
export type { LearningUpdateEvidenceReference, LearningUpdateProposal, OpaqueEvidenceReference, ProvenanceReference, ReviewedLearningUpdateProposalInput, ReviewProofReference, } from "./learning-update-proposal.js";
|
|
15
17
|
export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalReviewProofJson, hashCanonicalReviewProofPayload, verifyCanonicalReviewProofPayload, REVIEW_PROOF_CONTRACT_VERSION, REVIEW_PROOF_PACKAGE_NAME, REVIEW_PROOF_SCHEMA, REVIEW_PROOF_SCHEMA_VERSION, } from "./review-proof.js";
|
|
16
18
|
export type { CanonicalReviewProofPayload, CanonicalReviewProofPayloadV1, CanonicalReviewProofPayloadV2, ReviewProofInput, } from "./review-proof.js";
|
|
17
19
|
export { fieldObservation } from "./field-observation.js";
|
package/dist/src/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { reviewedCandidateResolution } from "./reviewed-candidate-resolution.js"
|
|
|
5
5
|
export { reviewedCurrentProposedResolution } from "./reviewed-current-proposed-resolution.js";
|
|
6
6
|
export { buildSurveyTrustBundle } from "./to-surface.js";
|
|
7
7
|
export { buildSurveyLearningProjections } from "./learning-projections.js";
|
|
8
|
+
export { buildReviewedLearningUpdateProposal } from "./learning-update-proposal.js";
|
|
8
9
|
export { buildCanonicalReviewProofPayload, buildReviewProofAnchor, canonicalReviewProofJson, hashCanonicalReviewProofPayload, verifyCanonicalReviewProofPayload, REVIEW_PROOF_CONTRACT_VERSION, REVIEW_PROOF_PACKAGE_NAME, REVIEW_PROOF_SCHEMA, REVIEW_PROOF_SCHEMA_VERSION, } from "./review-proof.js";
|
|
9
10
|
export { fieldObservation } from "./field-observation.js";
|
|
10
11
|
export { repeatedObservation } from "./repeated-observation.js";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ClaimTarget, ProvenanceResolution, RawSourceKind, SurveyInput } from "./types.js";
|
|
2
|
+
export interface ReviewProofReference {
|
|
3
|
+
kind: "review-proof";
|
|
4
|
+
algorithm: "sha256";
|
|
5
|
+
value: string;
|
|
6
|
+
proofSchemaVersion: 2;
|
|
7
|
+
}
|
|
8
|
+
export interface ProvenanceReference {
|
|
9
|
+
kind: "provenance";
|
|
10
|
+
rawSourceId: string;
|
|
11
|
+
origin: RawSourceKind;
|
|
12
|
+
resolution: ProvenanceResolution;
|
|
13
|
+
reviewOutcomeId: string;
|
|
14
|
+
}
|
|
15
|
+
export interface OpaqueEvidenceReference {
|
|
16
|
+
kind: "evidence";
|
|
17
|
+
id: string;
|
|
18
|
+
}
|
|
19
|
+
export type LearningUpdateEvidenceReference = ReviewProofReference | ProvenanceReference | OpaqueEvidenceReference;
|
|
20
|
+
export interface LearningUpdateProposal {
|
|
21
|
+
id: string;
|
|
22
|
+
kind: "learning.update-proposal";
|
|
23
|
+
source: string;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
subject: Pick<ClaimTarget, "subjectType" | "subjectId" | "facet" | "claimType" | "fieldOrBehavior">;
|
|
26
|
+
applicability: {
|
|
27
|
+
target: string;
|
|
28
|
+
};
|
|
29
|
+
proposedDelta: {
|
|
30
|
+
previousValue: unknown;
|
|
31
|
+
proposedValue: unknown;
|
|
32
|
+
};
|
|
33
|
+
evidenceRefs: LearningUpdateEvidenceReference[];
|
|
34
|
+
authorizationRef: {
|
|
35
|
+
reviewOutcomeId: string;
|
|
36
|
+
reviewProofHash: string;
|
|
37
|
+
};
|
|
38
|
+
reviewLineage: {
|
|
39
|
+
candidateSetId: string;
|
|
40
|
+
selectedCandidateId: string;
|
|
41
|
+
unselectedCandidateIds: string[];
|
|
42
|
+
reviewOutcomeId: string;
|
|
43
|
+
selectedClaimId: string;
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
export interface ReviewedLearningUpdateProposalInput {
|
|
47
|
+
survey: SurveyInput;
|
|
48
|
+
candidateSetId: string;
|
|
49
|
+
reviewOutcomeId: string;
|
|
50
|
+
selectedClaimId: string;
|
|
51
|
+
proof: ReviewProofReference;
|
|
52
|
+
}
|
|
53
|
+
/** Builds data for a producer-owned application decision. It performs no I/O or domain validation. */
|
|
54
|
+
export declare function buildReviewedLearningUpdateProposal(input: ReviewedLearningUpdateProposalInput): LearningUpdateProposal;
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalJson } from "./review-workbench/canonical.js";
|
|
3
|
+
/** Builds data for a producer-owned application decision. It performs no I/O or domain validation. */
|
|
4
|
+
export function buildReviewedLearningUpdateProposal(input) {
|
|
5
|
+
const { survey } = input;
|
|
6
|
+
const candidateSet = exactlyOne(survey.candidateSets, ({ id }) => id === input.candidateSetId, "candidate set");
|
|
7
|
+
if (!candidateSet || candidateSet.status !== "resolved")
|
|
8
|
+
throw new Error("learning update requires a resolved candidate set");
|
|
9
|
+
const roleCandidates = candidateSet.candidates.filter((candidate) => {
|
|
10
|
+
const role = candidate.metadata?.candidateRole;
|
|
11
|
+
return role === "current" || role === "proposed";
|
|
12
|
+
});
|
|
13
|
+
const current = roleCandidates.filter(({ metadata }) => metadata?.candidateRole === "current");
|
|
14
|
+
const proposed = roleCandidates.filter(({ metadata }) => metadata?.candidateRole === "proposed");
|
|
15
|
+
assertUniqueIds(candidateSet.candidates, "candidate");
|
|
16
|
+
if (current.length !== 1 || proposed.length !== 1) {
|
|
17
|
+
throw new Error("learning update requires exactly one current and one proposed candidate role");
|
|
18
|
+
}
|
|
19
|
+
const currentCandidate = current[0];
|
|
20
|
+
const proposedCandidate = proposed[0];
|
|
21
|
+
if (candidateSet.selectedCandidateId !== proposedCandidate.id)
|
|
22
|
+
throw new Error("learning update requires the proposed candidate to be selected");
|
|
23
|
+
const review = exactlyOne(survey.reviewOutcomes, ({ id }) => id === input.reviewOutcomeId, "review outcome");
|
|
24
|
+
if (!review || review.candidateSetId !== candidateSet.id || review.candidateId !== candidateSet.selectedCandidateId) {
|
|
25
|
+
throw new Error("review outcome must identify the selected candidate and candidate set");
|
|
26
|
+
}
|
|
27
|
+
if (review.status !== "verified" && review.status !== "assumed")
|
|
28
|
+
throw new Error("learning update requires an accepted review status");
|
|
29
|
+
if (!review.reviewedAt)
|
|
30
|
+
throw new Error("learning update requires reviewedAt");
|
|
31
|
+
if (!review.authorizing)
|
|
32
|
+
throw new Error("learning update requires authorizing review provenance");
|
|
33
|
+
assertCanonicalProofReference(input.proof);
|
|
34
|
+
const selectedClaim = exactlyOne(survey.claims, ({ id }) => id === input.selectedClaimId, "selected claim");
|
|
35
|
+
if (!selectedClaim || selectedClaim.candidateSetId !== candidateSet.id || selectedClaim.candidateId !== proposedCandidate.id) {
|
|
36
|
+
throw new Error("selected claim must identify the selected proposed candidate and candidate set");
|
|
37
|
+
}
|
|
38
|
+
if (selectedClaim.status !== "verified" && selectedClaim.status !== "assumed") {
|
|
39
|
+
throw new Error("learning update requires an accepted selected claim status");
|
|
40
|
+
}
|
|
41
|
+
if (selectedClaim.status !== review.status)
|
|
42
|
+
throw new Error("selected claim status must match the accepted review status");
|
|
43
|
+
const currentClaim = exactlyOne(survey.claims, ({ candidateSetId, candidateId }) => candidateSetId === candidateSet.id && candidateId === currentCandidate.id, "current claim");
|
|
44
|
+
if (!currentClaim || !sameSubject(currentClaim, selectedClaim))
|
|
45
|
+
throw new Error("current and selected claim lineage must share a subject");
|
|
46
|
+
const unselectedCandidates = candidateSet.candidates.filter(({ id }) => id !== proposedCandidate.id);
|
|
47
|
+
for (const candidate of unselectedCandidates) {
|
|
48
|
+
const claim = exactlyOne(survey.claims, ({ candidateSetId, candidateId }) => candidateSetId === candidateSet.id && candidateId === candidate.id, "unselected claim");
|
|
49
|
+
if (claim.status !== "superseded")
|
|
50
|
+
throw new Error("unselected claim status must be superseded");
|
|
51
|
+
}
|
|
52
|
+
assertCanonicalJsonValue(currentCandidate.value);
|
|
53
|
+
assertCanonicalJsonValue(proposedCandidate.value);
|
|
54
|
+
const currentSource = sourceForCandidate(survey, candidateSet.target, currentCandidate.id, currentCandidate.extractionId, currentCandidate.value);
|
|
55
|
+
const proposedSource = sourceForCandidate(survey, candidateSet.target, proposedCandidate.id, proposedCandidate.extractionId, proposedCandidate.value);
|
|
56
|
+
const provenanceRefs = [currentSource, proposedSource].map((source) => {
|
|
57
|
+
if (source.resolution !== "supersession")
|
|
58
|
+
throw new Error(`candidate ${source.id} requires explicit supersession provenance`);
|
|
59
|
+
return { kind: "provenance", rawSourceId: source.id, origin: source.kind, resolution: source.resolution, reviewOutcomeId: review.id };
|
|
60
|
+
});
|
|
61
|
+
const evidenceCandidates = [
|
|
62
|
+
...(review.evidenceIds ?? []).map((id) => ({ kind: "evidence", id })),
|
|
63
|
+
...provenanceRefs,
|
|
64
|
+
{ ...input.proof },
|
|
65
|
+
];
|
|
66
|
+
const evidenceRefs = [...new Map(evidenceCandidates.map((reference) => [canonicalJson(reference), reference])).values()]
|
|
67
|
+
.sort((left, right) => evidenceRank(left) - evidenceRank(right) || canonicalJson(left).localeCompare(canonicalJson(right)));
|
|
68
|
+
const subject = pickSubject(selectedClaim);
|
|
69
|
+
const reviewLineage = {
|
|
70
|
+
candidateSetId: candidateSet.id,
|
|
71
|
+
selectedCandidateId: proposedCandidate.id,
|
|
72
|
+
unselectedCandidateIds: unselectedCandidates.map(({ id }) => id).sort(),
|
|
73
|
+
reviewOutcomeId: review.id,
|
|
74
|
+
selectedClaimId: selectedClaim.id,
|
|
75
|
+
};
|
|
76
|
+
const authorizationRef = { reviewOutcomeId: review.id, reviewProofHash: input.proof.value };
|
|
77
|
+
const proposalWithoutId = {
|
|
78
|
+
kind: "learning.update-proposal",
|
|
79
|
+
source: survey.source,
|
|
80
|
+
createdAt: review.reviewedAt,
|
|
81
|
+
subject,
|
|
82
|
+
applicability: { target: candidateSet.target },
|
|
83
|
+
proposedDelta: { previousValue: currentCandidate.value, proposedValue: proposedCandidate.value },
|
|
84
|
+
evidenceRefs,
|
|
85
|
+
authorizationRef,
|
|
86
|
+
reviewLineage,
|
|
87
|
+
};
|
|
88
|
+
const identityPayload = { identitySchemaVersion: 1, ...proposalWithoutId };
|
|
89
|
+
return { id: createHash("sha256").update(canonicalJson(identityPayload)).digest("hex"), ...proposalWithoutId };
|
|
90
|
+
}
|
|
91
|
+
function assertCanonicalProofReference(proof) {
|
|
92
|
+
if (proof?.kind !== "review-proof" || proof.algorithm !== "sha256" || proof.proofSchemaVersion !== 2) {
|
|
93
|
+
throw new Error("learning update requires an identified canonical v2 review proof");
|
|
94
|
+
}
|
|
95
|
+
if (!/^[a-f0-9]{64}$/.test(proof.value))
|
|
96
|
+
throw new Error("canonical review proof must be a lowercase SHA-256 hash");
|
|
97
|
+
}
|
|
98
|
+
function sourceForCandidate(survey, target, candidateId, extractionId, value) {
|
|
99
|
+
const extraction = exactlyOne(survey.extractions, ({ id }) => id === extractionId, "extraction");
|
|
100
|
+
assertCanonicalJsonValue(extraction.value);
|
|
101
|
+
if (!extraction || extraction.target !== target || canonicalJson(extraction.value) !== canonicalJson(value)) {
|
|
102
|
+
throw new Error(`candidate ${candidateId} has inconsistent extraction lineage`);
|
|
103
|
+
}
|
|
104
|
+
const source = exactlyOne(survey.rawSources, ({ id }) => id === extraction.sourceId, "raw source");
|
|
105
|
+
if (!source)
|
|
106
|
+
throw new Error(`candidate ${candidateId} has missing source lineage`);
|
|
107
|
+
return source;
|
|
108
|
+
}
|
|
109
|
+
function pickSubject(claim) {
|
|
110
|
+
return { subjectType: claim.subjectType, subjectId: claim.subjectId, facet: claim.facet, claimType: claim.claimType, fieldOrBehavior: claim.fieldOrBehavior };
|
|
111
|
+
}
|
|
112
|
+
function sameSubject(left, right) {
|
|
113
|
+
return canonicalJson(pickSubject(left)) === canonicalJson(pickSubject(right));
|
|
114
|
+
}
|
|
115
|
+
function evidenceRank(reference) {
|
|
116
|
+
if (reference.kind === "evidence")
|
|
117
|
+
return 0;
|
|
118
|
+
if (reference.kind === "provenance")
|
|
119
|
+
return 1;
|
|
120
|
+
return 2;
|
|
121
|
+
}
|
|
122
|
+
function exactlyOne(records, predicate, label) {
|
|
123
|
+
const matches = records.filter(predicate);
|
|
124
|
+
if (matches.length !== 1)
|
|
125
|
+
throw new Error(`learning update requires exactly one ${label}; found ${matches.length}`);
|
|
126
|
+
return matches[0];
|
|
127
|
+
}
|
|
128
|
+
function assertUniqueIds(records, label) {
|
|
129
|
+
const ids = new Set();
|
|
130
|
+
for (const { id } of records) {
|
|
131
|
+
if (ids.has(id))
|
|
132
|
+
throw new Error(`learning update rejects duplicate ${label} id ${id}`);
|
|
133
|
+
ids.add(id);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** Values are limited to JSON primitives, arrays, and plain string-keyed objects. */
|
|
137
|
+
function assertCanonicalJsonValue(value, ancestors = new Set()) {
|
|
138
|
+
if (value === null || typeof value === "string" || typeof value === "boolean")
|
|
139
|
+
return;
|
|
140
|
+
if (typeof value === "number") {
|
|
141
|
+
if (Number.isFinite(value) && !Object.is(value, -0))
|
|
142
|
+
return;
|
|
143
|
+
throw new Error("invalid canonical JSON value: number is not collision-free");
|
|
144
|
+
}
|
|
145
|
+
if (typeof value !== "object")
|
|
146
|
+
throw new Error("invalid canonical JSON value: unsupported primitive");
|
|
147
|
+
if (ancestors.has(value))
|
|
148
|
+
throw new Error("invalid canonical JSON value: cycle");
|
|
149
|
+
const prototype = Object.getPrototypeOf(value);
|
|
150
|
+
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
|
151
|
+
throw new Error("invalid canonical JSON value: unsupported prototype");
|
|
152
|
+
}
|
|
153
|
+
ancestors.add(value);
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
156
|
+
throw new Error("invalid canonical JSON value: symbol-keyed array property");
|
|
157
|
+
const descriptors = Object.getOwnPropertyDescriptors(value);
|
|
158
|
+
const expectedKeys = Array.from({ length: value.length }, (_, index) => String(index));
|
|
159
|
+
const actualKeys = Object.keys(descriptors).filter((key) => key !== "length");
|
|
160
|
+
if (actualKeys.length !== expectedKeys.length || actualKeys.some((key, index) => key !== expectedKeys[index])) {
|
|
161
|
+
throw new Error("invalid canonical JSON value: sparse or extra array property");
|
|
162
|
+
}
|
|
163
|
+
for (const key of expectedKeys) {
|
|
164
|
+
const descriptor = descriptors[key];
|
|
165
|
+
if (!descriptor.enumerable || !("value" in descriptor))
|
|
166
|
+
throw new Error("invalid canonical JSON value: array accessor or hidden property");
|
|
167
|
+
assertCanonicalJsonValue(descriptor.value, ancestors);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
if (Object.getOwnPropertySymbols(value).length > 0)
|
|
172
|
+
throw new Error("invalid canonical JSON value: symbol-keyed property");
|
|
173
|
+
for (const descriptor of Object.values(Object.getOwnPropertyDescriptors(value))) {
|
|
174
|
+
if (!descriptor.enumerable || !("value" in descriptor))
|
|
175
|
+
throw new Error("invalid canonical JSON value: property is not enumerable data");
|
|
176
|
+
assertCanonicalJsonValue(descriptor.value, ancestors);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
ancestors.delete(value);
|
|
180
|
+
}
|
package/package.json
CHANGED