@kontourai/survey 7.0.0 → 8.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/dist/example-data/public-directory-review-resource.d.ts +3 -3
- package/dist/src/calibration.d.ts +15 -0
- package/dist/src/calibration.js +11 -0
- package/dist/src/canonical-reviewed-trust-input.d.ts +10 -0
- package/dist/src/canonical-reviewed-trust-input.js +15 -0
- package/dist/src/decision-carry-forward.d.ts +157 -0
- package/dist/src/decision-carry-forward.js +295 -0
- package/dist/src/extraction-envelope.d.ts +4 -3
- package/dist/src/extraction-envelope.js +3 -3
- package/dist/src/field-states.d.ts +100 -0
- package/dist/src/field-states.js +169 -0
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +2 -0
- package/dist/src/mcp/review-mcp.js +35 -10
- package/dist/src/review-resource.d.ts +29 -0
- package/dist/src/review-workbench/audit-sample.d.ts +32 -0
- package/dist/src/review-workbench/audit-sample.js +53 -0
- package/dist/src/review-workbench/extraction-inspector.d.ts +1 -1
- package/dist/src/review-workbench/extraction-inspector.js +1 -1
- package/dist/src/review-workbench/field-state-view.d.ts +15 -0
- package/dist/src/review-workbench/field-state-view.js +84 -0
- package/dist/src/review-workbench/review-queue-session.d.ts +46 -1
- package/dist/src/review-workbench/review-queue-session.js +74 -3
- package/dist/src/review-workbench/review-session-replay.d.ts +1 -1
- package/dist/src/review-workbench/review-session-replay.js +15 -1
- package/dist/src/review-workbench/review-workbench-css.generated.js +32 -0
- package/dist/src/review-workbench/review-workbench.css +32 -0
- package/dist/src/review-workbench/review-workbench.d.ts +13 -1
- package/dist/src/review-workbench/review-workbench.js +41 -15
- package/dist/src/review-workbench/review-workbench.standalone.css +32 -0
- package/package.json +1 -1
|
@@ -35,12 +35,12 @@ export declare const publicDirectoryReviewItemExample: {
|
|
|
35
35
|
excerpt: string;
|
|
36
36
|
};
|
|
37
37
|
extraction: {
|
|
38
|
+
model?: undefined;
|
|
38
39
|
extractionId: string;
|
|
39
40
|
target: string;
|
|
40
41
|
confidence: number;
|
|
41
42
|
extractor: string;
|
|
42
43
|
extractedAt: string;
|
|
43
|
-
model?: undefined;
|
|
44
44
|
};
|
|
45
45
|
claimTarget: {
|
|
46
46
|
claimId: string;
|
|
@@ -61,13 +61,13 @@ export declare const publicDirectoryReviewItemExample: {
|
|
|
61
61
|
claimId: string;
|
|
62
62
|
};
|
|
63
63
|
producer: {
|
|
64
|
+
proposalId?: undefined;
|
|
65
|
+
oldValue?: undefined;
|
|
64
66
|
sourceAuthority: {
|
|
65
67
|
authorityClass: string;
|
|
66
68
|
declaredBy: string;
|
|
67
69
|
scope: string;
|
|
68
70
|
};
|
|
69
|
-
proposalId?: undefined;
|
|
70
|
-
oldValue?: undefined;
|
|
71
71
|
};
|
|
72
72
|
} | {
|
|
73
73
|
id: string;
|
|
@@ -75,6 +75,21 @@ export interface DeriveCalibrationOptions {
|
|
|
75
75
|
* labeled samples. Default false — see the module note on circularity.
|
|
76
76
|
*/
|
|
77
77
|
readonly includeAutoAccepted?: boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Count only audit labels: outcomes whose metadata records a score-blind
|
|
80
|
+
* decision (`presentation.scoreBlind === true`) on a random audit sample
|
|
81
|
+
* (`sampling.kind === "random-audit"`), as the review workbench writes them
|
|
82
|
+
* for a session opened with `openRandomAuditSession`. Default false (every
|
|
83
|
+
* human outcome counts, as before). Other outcomes are skipped and counted
|
|
84
|
+
* in `skippedCount`, as windowed-out outcomes are.
|
|
85
|
+
*
|
|
86
|
+
* Calibration reads these fields as recorded on the outcome. The review
|
|
87
|
+
* session validates them (a `random-audit` rate in (0, 1] and a stable seed)
|
|
88
|
+
* and replay refuses events recorded under other conditions than the
|
|
89
|
+
* snapshot's, but an outcome assembled outside Survey's apply path is taken
|
|
90
|
+
* at its word: only feed outcomes from a store you trust.
|
|
91
|
+
*/
|
|
92
|
+
readonly auditSamplesOnly?: boolean;
|
|
78
93
|
}
|
|
79
94
|
/** One labeled calibration sample: a prediction paired with the human label. */
|
|
80
95
|
export interface CalibrationSample {
|
package/dist/src/calibration.js
CHANGED
|
@@ -80,6 +80,10 @@ export function deriveCalibration(input, options = {}) {
|
|
|
80
80
|
const samples = [];
|
|
81
81
|
let skippedCount = 0;
|
|
82
82
|
for (const outcome of input.reviewOutcomes) {
|
|
83
|
+
if (options.auditSamplesOnly && !isScoreBlindAuditOutcome(outcome)) {
|
|
84
|
+
skippedCount++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
83
87
|
if (outcome.resolution === "could_not_confirm") {
|
|
84
88
|
skippedCount++;
|
|
85
89
|
continue;
|
|
@@ -142,6 +146,13 @@ export function deriveCalibration(input, options = {}) {
|
|
|
142
146
|
// ---------------------------------------------------------------------------
|
|
143
147
|
// Internal: sample construction
|
|
144
148
|
// ---------------------------------------------------------------------------
|
|
149
|
+
/** A decision recorded score-blind on a random audit sample (see `openRandomAuditSession`). */
|
|
150
|
+
function isScoreBlindAuditOutcome(outcome) {
|
|
151
|
+
const presentation = outcome.metadata?.presentation;
|
|
152
|
+
const sampling = outcome.metadata?.sampling;
|
|
153
|
+
return typeof presentation === "object" && presentation !== null && presentation.scoreBlind === true
|
|
154
|
+
&& typeof sampling === "object" && sampling !== null && sampling.kind === "random-audit";
|
|
155
|
+
}
|
|
145
156
|
function toSample(outcome, candidateSetById, extractionById, includeAutoAccepted) {
|
|
146
157
|
// No human label yet.
|
|
147
158
|
if (outcome.status === "proposed")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SurveyInput } from "./types.js";
|
|
2
2
|
import type { ReviewItem } from "./review-resource.js";
|
|
3
3
|
import type { ReviewWorkbenchResult } from "./review-workbench/review-workbench.js";
|
|
4
|
+
import { type DeriveFieldStatesInput } from "./field-states.js";
|
|
4
5
|
export interface BuildCanonicalReviewedTrustInputOptions {
|
|
5
6
|
/** Producer identity for the resulting SurveyInput batch. */
|
|
6
7
|
readonly source: string;
|
|
@@ -12,6 +13,15 @@ export interface BuildCanonicalReviewedTrustInputOptions {
|
|
|
12
13
|
readonly items: readonly ReviewItem[];
|
|
13
14
|
/** Results derived by Survey's server apply boundary from snapshot + events. */
|
|
14
15
|
readonly results: readonly ReviewWorkbenchResult[];
|
|
16
|
+
/**
|
|
17
|
+
* The extraction imports the items came from (and optionally verifier
|
|
18
|
+
* records and supersessions). When present, each claim whose item one of
|
|
19
|
+
* these imports produced carries its derived content and lifecycle states
|
|
20
|
+
* under `metadata["survey.kontourai.io/field-state"]` (see
|
|
21
|
+
* `deriveFieldStates`). The states are derived here from the imports and the
|
|
22
|
+
* results' own decisions, never supplied as labels. Claim status is unchanged.
|
|
23
|
+
*/
|
|
24
|
+
readonly fieldStates?: Omit<DeriveFieldStatesInput, "decisions" | "carryForwards">;
|
|
15
25
|
}
|
|
16
26
|
export interface CanonicalReviewedTrustInput {
|
|
17
27
|
readonly surveyInput: SurveyInput;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { SURVEY_INPUT_CONTRACT_VERSION } from "./types.js";
|
|
2
2
|
import { canonicalJson } from "./review-workbench/canonical.js";
|
|
3
3
|
import { decisionSelectsNoCandidate, workbenchDecisionDefinitions } from "./review-workbench/review-queue-session.js";
|
|
4
|
+
import { deriveFieldStates, fieldStateClaimMetadata, FIELD_STATE_METADATA_KEY } from "./field-states.js";
|
|
4
5
|
/**
|
|
5
6
|
* Projects server-applied review records into the complete SurveyInput consumed
|
|
6
7
|
* by buildSurveyTrustBundle. The ReviewItem and ReviewWorkbenchResult are the
|
|
@@ -22,6 +23,13 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
22
23
|
if (itemsByName.size !== resultsByName.size) {
|
|
23
24
|
throw new Error("Canonical review projection requires exactly one resolved result for every ReviewItem.");
|
|
24
25
|
}
|
|
26
|
+
const fieldStatesByItem = new Map();
|
|
27
|
+
if (options.fieldStates) {
|
|
28
|
+
const states = deriveFieldStates({ ...options.fieldStates, decisions: options.results.map((result) => result.reviewDecision) });
|
|
29
|
+
for (const state of states)
|
|
30
|
+
if (state.reviewItemName !== undefined)
|
|
31
|
+
fieldStatesByItem.set(state.reviewItemName, state);
|
|
32
|
+
}
|
|
25
33
|
const rawSources = new Map();
|
|
26
34
|
const extractions = new Map();
|
|
27
35
|
const candidateSets = new Map();
|
|
@@ -106,6 +114,10 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
106
114
|
...(decision.unselectedCandidateIds?.length
|
|
107
115
|
? { unselectedCandidateIds: decision.unselectedCandidateIds.map((id) => recordIdFor(item, id)) }
|
|
108
116
|
: {}),
|
|
117
|
+
// How the decision was made: whether scores were hidden, and how the
|
|
118
|
+
// item was sampled. Calibration reads these to select audit labels.
|
|
119
|
+
...(decision.presentation !== undefined ? { presentation: { scoreBlind: decision.presentation.scoreBlind } } : {}),
|
|
120
|
+
...(decision.sampling !== undefined ? { sampling: { ...decision.sampling } } : {}),
|
|
109
121
|
},
|
|
110
122
|
};
|
|
111
123
|
addConsistent(reviewOutcomes, reviewOutcome, "review outcome");
|
|
@@ -146,6 +158,9 @@ export function buildCanonicalReviewedTrustInput(options) {
|
|
|
146
158
|
...(hint.derivedFrom ? { derivedFrom: [...hint.derivedFrom] } : {}),
|
|
147
159
|
collectedBy: hint.collectedBy ?? sharedExtractor(selected ? [selected] : item.spec.candidates) ?? options.source,
|
|
148
160
|
...(decision.actor?.id ? { actor: decision.actor.id } : {}),
|
|
161
|
+
...(fieldStatesByItem.has(item.metadata.name)
|
|
162
|
+
? { metadata: { [FIELD_STATE_METADATA_KEY]: fieldStateClaimMetadata(fieldStatesByItem.get(item.metadata.name)) } }
|
|
163
|
+
: {}),
|
|
149
164
|
};
|
|
150
165
|
addConsistent(claims, claim, "claim target");
|
|
151
166
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { ReviewDecision, ReviewItem } from "./review-resource.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decisions across review rounds (#295).
|
|
4
|
+
*
|
|
5
|
+
* A new extraction of the same document gives every review item a new identity,
|
|
6
|
+
* so on its own every re-extraction sends every field back to review. Given the
|
|
7
|
+
* producer's stable slot identity and per-candidate version identity, a
|
|
8
|
+
* decision on a slot whose candidates did not change is carried forward to the
|
|
9
|
+
* new round, and a decision that a later one replaces is marked superseded.
|
|
10
|
+
*
|
|
11
|
+
* Vocabulary follows the round receipts producers already keep: a decision is
|
|
12
|
+
* `carried-forward` (made in an earlier round and still holding because its
|
|
13
|
+
* candidates are unchanged) or `affirmed` (made against this round's item).
|
|
14
|
+
*
|
|
15
|
+
* Survey does not invent slot or version identities: they come from the
|
|
16
|
+
* producer, and every item and candidate must carry one. Survey additionally
|
|
17
|
+
* compares each candidate's value, full locator, source evidence and full
|
|
18
|
+
* claim target, so a
|
|
19
|
+
* decision is never carried across a candidate whose content changed, even if a
|
|
20
|
+
* producer reused a version identity.
|
|
21
|
+
*
|
|
22
|
+
* Both records are content-addressed (`id` is a digest of every other field)
|
|
23
|
+
* and immutable: a record that changes is a new record.
|
|
24
|
+
*/
|
|
25
|
+
export declare const decisionCarryForwardKind: "survey.decision-carry-forward";
|
|
26
|
+
export declare const decisionSupersessionKind: "survey.decision-supersession";
|
|
27
|
+
/** How a decision reads in a round. */
|
|
28
|
+
export type DecisionBasis = "carried-forward" | "affirmed";
|
|
29
|
+
/** The terminal resolution a decision reached, as carry-forward reads it. */
|
|
30
|
+
export type CarriedResolution = "accepted" | "rejected" | "could_not_confirm";
|
|
31
|
+
/** One review item in a round, with the producer's slot and version identities. */
|
|
32
|
+
export interface RoundReviewItem {
|
|
33
|
+
readonly item: ReviewItem;
|
|
34
|
+
/** Producer slot identity: the same field of the same source lineage across rounds. */
|
|
35
|
+
readonly slotId: string;
|
|
36
|
+
/** Producer version identity of every candidate of `item`, keyed by candidate id. */
|
|
37
|
+
readonly candidateVersionIds: Readonly<Record<string, string>>;
|
|
38
|
+
}
|
|
39
|
+
/** A decision made in an earlier round, with the round item it was made on. */
|
|
40
|
+
export interface PriorRoundDecision extends RoundReviewItem {
|
|
41
|
+
readonly decision: ReviewDecision;
|
|
42
|
+
}
|
|
43
|
+
/** A decision named by content: its resource name and the digest of its canonical bytes. */
|
|
44
|
+
export interface DecisionReference {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly digest: string;
|
|
47
|
+
}
|
|
48
|
+
export interface DecisionCarryForward {
|
|
49
|
+
/** `sha256:` digest of every other field; recomputed on validation. */
|
|
50
|
+
readonly id: string;
|
|
51
|
+
readonly kind: typeof decisionCarryForwardKind;
|
|
52
|
+
readonly schemaVersion: 1;
|
|
53
|
+
readonly priorDecision: DecisionReference;
|
|
54
|
+
readonly slotId: string;
|
|
55
|
+
/** Sorted, unique version ids of the slot's candidates, identical in both rounds. */
|
|
56
|
+
readonly versionIds: readonly string[];
|
|
57
|
+
readonly roundId: string;
|
|
58
|
+
/** The new round's review item the decision now applies to. */
|
|
59
|
+
readonly reviewItemName: string;
|
|
60
|
+
/** The new round's candidate the prior decision selected, when it selected one. */
|
|
61
|
+
readonly candidateId?: string;
|
|
62
|
+
readonly resolution: CarriedResolution;
|
|
63
|
+
readonly basis: "carried-forward";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Why a decision was superseded: its slot's candidates changed and the new
|
|
67
|
+
* version was decided (`candidate-changed`), or the unchanged slot was
|
|
68
|
+
* reviewed again and decided anew (`re-reviewed`).
|
|
69
|
+
*/
|
|
70
|
+
export type SupersessionReason = "candidate-changed" | "re-reviewed";
|
|
71
|
+
export interface DecisionSupersession {
|
|
72
|
+
/** `sha256:` digest of every other field; recomputed on validation. */
|
|
73
|
+
readonly id: string;
|
|
74
|
+
readonly kind: typeof decisionSupersessionKind;
|
|
75
|
+
readonly schemaVersion: 1;
|
|
76
|
+
readonly priorDecision: DecisionReference;
|
|
77
|
+
readonly newDecision: DecisionReference;
|
|
78
|
+
readonly slotId: string;
|
|
79
|
+
readonly reason: SupersessionReason;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Which prior resolutions may be carried forward. Only `accepted` is carried
|
|
83
|
+
* unless a policy names others explicitly: a rejection or a could-not-confirm
|
|
84
|
+
* is a judgment about evidence a new round may have changed around it.
|
|
85
|
+
*/
|
|
86
|
+
export interface CarryForwardPolicy {
|
|
87
|
+
readonly carry: readonly CarriedResolution[];
|
|
88
|
+
}
|
|
89
|
+
export declare const defaultCarryForwardPolicy: CarryForwardPolicy;
|
|
90
|
+
export type NeedsReviewReason =
|
|
91
|
+
/** No prior decision was made on this slot. */
|
|
92
|
+
"no-prior-decision"
|
|
93
|
+
/** The slot's candidate versions differ from the ones the prior decision saw. */
|
|
94
|
+
| "candidate-changed"
|
|
95
|
+
/** A version id matched, but the candidate's value, locator, source or claim target did not. */
|
|
96
|
+
| "candidate-content-changed"
|
|
97
|
+
/** The prior decision's resolution is not one the policy carries. */
|
|
98
|
+
| "resolution-not-carried"
|
|
99
|
+
/** More than one live prior decision names this slot. */
|
|
100
|
+
| "ambiguous-prior-decisions";
|
|
101
|
+
export interface CarriedForwardItem {
|
|
102
|
+
readonly item: RoundReviewItem;
|
|
103
|
+
readonly carryForward: DecisionCarryForward;
|
|
104
|
+
}
|
|
105
|
+
export interface NeedsReviewItem {
|
|
106
|
+
readonly item: RoundReviewItem;
|
|
107
|
+
readonly reason: NeedsReviewReason;
|
|
108
|
+
/** The prior decision the item could not inherit, when there was one. */
|
|
109
|
+
readonly priorDecision?: DecisionReference;
|
|
110
|
+
}
|
|
111
|
+
export interface SplitRoundForCarryForwardInput {
|
|
112
|
+
/** Identity of the new round. */
|
|
113
|
+
readonly roundId: string;
|
|
114
|
+
readonly items: readonly RoundReviewItem[];
|
|
115
|
+
readonly priorDecisions: readonly PriorRoundDecision[];
|
|
116
|
+
/** Supersessions already recorded: a prior decision they name is no longer live. */
|
|
117
|
+
readonly supersessions?: readonly DecisionSupersession[];
|
|
118
|
+
readonly policy?: CarryForwardPolicy;
|
|
119
|
+
}
|
|
120
|
+
export interface SplitRoundForCarryForwardResult {
|
|
121
|
+
readonly carriedForward: readonly CarriedForwardItem[];
|
|
122
|
+
readonly needsReview: readonly NeedsReviewItem[];
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Splits a new round's items into those that inherit a prior decision
|
|
126
|
+
* (with a {@link DecisionCarryForward} record naming it) and those that need
|
|
127
|
+
* review. Pure; performs no I/O. An item carries forward only when exactly one
|
|
128
|
+
* live prior decision names its slot, the slot's candidates have the same
|
|
129
|
+
* version ids in both rounds, each candidate's value, locator, excerpt and
|
|
130
|
+
* source are unchanged, and the policy carries the prior resolution.
|
|
131
|
+
*/
|
|
132
|
+
export declare function splitRoundForCarryForward(input: SplitRoundForCarryForwardInput): SplitRoundForCarryForwardResult;
|
|
133
|
+
export interface BuildDecisionSupersessionInput {
|
|
134
|
+
readonly prior: PriorRoundDecision;
|
|
135
|
+
/** The new round's item the new decision was made on. */
|
|
136
|
+
readonly item: RoundReviewItem;
|
|
137
|
+
readonly decision: ReviewDecision;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Records that `decision`, made on the new round's item for the same slot,
|
|
141
|
+
* replaces `prior`. The reason is derived: `candidate-changed` when the slot's
|
|
142
|
+
* candidate versions or content differ, `re-reviewed` when they do not.
|
|
143
|
+
*/
|
|
144
|
+
export declare function buildDecisionSupersession(input: BuildDecisionSupersessionInput): DecisionSupersession;
|
|
145
|
+
/** The name and canonical digest of a decision. */
|
|
146
|
+
export declare function decisionReference(decision: ReviewDecision): DecisionReference;
|
|
147
|
+
/**
|
|
148
|
+
* The terminal resolution a decision reached: `could_not_confirm`, `rejected`
|
|
149
|
+
* for a rejected status, `accepted` for a verified or assumed one. Undefined
|
|
150
|
+
* for a decision that reached none (a `proposed` status without
|
|
151
|
+
* could-not-confirm).
|
|
152
|
+
*/
|
|
153
|
+
export declare function decisionResolution(decision: ReviewDecision): CarriedResolution | undefined;
|
|
154
|
+
/** Validates a stored carry-forward record: its shape and its content-addressed id. */
|
|
155
|
+
export declare function validateDecisionCarryForward(value: unknown): DecisionCarryForward;
|
|
156
|
+
/** Validates a stored supersession record: its shape and its content-addressed id. */
|
|
157
|
+
export declare function validateDecisionSupersession(value: unknown): DecisionSupersession;
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import { sha256Hex } from "./sha256.js";
|
|
2
|
+
import { canonicalJson } from "./review-workbench/canonical.js";
|
|
3
|
+
/**
|
|
4
|
+
* Decisions across review rounds (#295).
|
|
5
|
+
*
|
|
6
|
+
* A new extraction of the same document gives every review item a new identity,
|
|
7
|
+
* so on its own every re-extraction sends every field back to review. Given the
|
|
8
|
+
* producer's stable slot identity and per-candidate version identity, a
|
|
9
|
+
* decision on a slot whose candidates did not change is carried forward to the
|
|
10
|
+
* new round, and a decision that a later one replaces is marked superseded.
|
|
11
|
+
*
|
|
12
|
+
* Vocabulary follows the round receipts producers already keep: a decision is
|
|
13
|
+
* `carried-forward` (made in an earlier round and still holding because its
|
|
14
|
+
* candidates are unchanged) or `affirmed` (made against this round's item).
|
|
15
|
+
*
|
|
16
|
+
* Survey does not invent slot or version identities: they come from the
|
|
17
|
+
* producer, and every item and candidate must carry one. Survey additionally
|
|
18
|
+
* compares each candidate's value, full locator, source evidence and full
|
|
19
|
+
* claim target, so a
|
|
20
|
+
* decision is never carried across a candidate whose content changed, even if a
|
|
21
|
+
* producer reused a version identity.
|
|
22
|
+
*
|
|
23
|
+
* Both records are content-addressed (`id` is a digest of every other field)
|
|
24
|
+
* and immutable: a record that changes is a new record.
|
|
25
|
+
*/
|
|
26
|
+
export const decisionCarryForwardKind = "survey.decision-carry-forward";
|
|
27
|
+
export const decisionSupersessionKind = "survey.decision-supersession";
|
|
28
|
+
export const defaultCarryForwardPolicy = Object.freeze({ carry: Object.freeze(["accepted"]) });
|
|
29
|
+
/**
|
|
30
|
+
* Splits a new round's items into those that inherit a prior decision
|
|
31
|
+
* (with a {@link DecisionCarryForward} record naming it) and those that need
|
|
32
|
+
* review. Pure; performs no I/O. An item carries forward only when exactly one
|
|
33
|
+
* live prior decision names its slot, the slot's candidates have the same
|
|
34
|
+
* version ids in both rounds, each candidate's value, locator, excerpt and
|
|
35
|
+
* source are unchanged, and the policy carries the prior resolution.
|
|
36
|
+
*/
|
|
37
|
+
export function splitRoundForCarryForward(input) {
|
|
38
|
+
const roundId = nonEmpty(input.roundId, "roundId");
|
|
39
|
+
const policy = normalizePolicy(input.policy ?? defaultCarryForwardPolicy);
|
|
40
|
+
const superseded = new Set((input.supersessions ?? []).map((record) => validateDecisionSupersession(record).priorDecision.digest));
|
|
41
|
+
const priorBySlot = new Map();
|
|
42
|
+
for (const prior of input.priorDecisions) {
|
|
43
|
+
assertRoundItem(prior, "prior decision");
|
|
44
|
+
assertDecisionNamesItem(prior.decision, prior.item);
|
|
45
|
+
if (superseded.has(decisionReference(prior.decision).digest))
|
|
46
|
+
continue;
|
|
47
|
+
priorBySlot.set(prior.slotId, [...(priorBySlot.get(prior.slotId) ?? []), prior]);
|
|
48
|
+
}
|
|
49
|
+
const seenSlots = new Set();
|
|
50
|
+
const carriedForward = [];
|
|
51
|
+
const needsReview = [];
|
|
52
|
+
for (const item of input.items) {
|
|
53
|
+
assertRoundItem(item, "round item");
|
|
54
|
+
if (seenSlots.has(item.slotId))
|
|
55
|
+
throw new Error(`Round ${roundId} has two items for slot ${item.slotId}.`);
|
|
56
|
+
seenSlots.add(item.slotId);
|
|
57
|
+
const priors = priorBySlot.get(item.slotId) ?? [];
|
|
58
|
+
if (priors.length === 0) {
|
|
59
|
+
needsReview.push({ item, reason: "no-prior-decision" });
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (priors.length > 1) {
|
|
63
|
+
needsReview.push({ item, reason: "ambiguous-prior-decisions" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const prior = priors[0];
|
|
67
|
+
const priorDecision = decisionReference(prior.decision);
|
|
68
|
+
const priorVersions = sortedVersions(prior);
|
|
69
|
+
const versions = sortedVersions(item);
|
|
70
|
+
if (canonicalJson(priorVersions) !== canonicalJson(versions)) {
|
|
71
|
+
needsReview.push({ item, reason: "candidate-changed", priorDecision });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!sameCandidateContent(prior, item)) {
|
|
75
|
+
needsReview.push({ item, reason: "candidate-content-changed", priorDecision });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const resolution = decisionResolution(prior.decision);
|
|
79
|
+
if (resolution === undefined || !policy.carry.includes(resolution)) {
|
|
80
|
+
needsReview.push({ item, reason: "resolution-not-carried", priorDecision });
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const priorCandidateId = prior.decision.spec.candidateId;
|
|
84
|
+
const candidateId = priorCandidateId === undefined ? undefined : candidateWithVersion(item, prior.candidateVersionIds[priorCandidateId]).id;
|
|
85
|
+
carriedForward.push({
|
|
86
|
+
item,
|
|
87
|
+
carryForward: sealCarryForward({
|
|
88
|
+
kind: decisionCarryForwardKind,
|
|
89
|
+
schemaVersion: 1,
|
|
90
|
+
priorDecision,
|
|
91
|
+
slotId: item.slotId,
|
|
92
|
+
versionIds: versions,
|
|
93
|
+
roundId,
|
|
94
|
+
reviewItemName: item.item.metadata.name,
|
|
95
|
+
...(candidateId !== undefined ? { candidateId } : {}),
|
|
96
|
+
resolution,
|
|
97
|
+
basis: "carried-forward",
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return Object.freeze({ carriedForward: Object.freeze(carriedForward), needsReview: Object.freeze(needsReview) });
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Records that `decision`, made on the new round's item for the same slot,
|
|
105
|
+
* replaces `prior`. The reason is derived: `candidate-changed` when the slot's
|
|
106
|
+
* candidate versions or content differ, `re-reviewed` when they do not.
|
|
107
|
+
*/
|
|
108
|
+
export function buildDecisionSupersession(input) {
|
|
109
|
+
assertRoundItem(input.prior, "prior decision");
|
|
110
|
+
assertRoundItem(input.item, "round item");
|
|
111
|
+
assertDecisionNamesItem(input.prior.decision, input.prior.item);
|
|
112
|
+
assertDecisionNamesItem(input.decision, input.item.item);
|
|
113
|
+
if (input.prior.slotId !== input.item.slotId)
|
|
114
|
+
throw new Error(`A supersession needs one slot; the prior decision is on ${input.prior.slotId} and the new one on ${input.item.slotId}.`);
|
|
115
|
+
const priorDecision = decisionReference(input.prior.decision);
|
|
116
|
+
const newDecision = decisionReference(input.decision);
|
|
117
|
+
if (priorDecision.digest === newDecision.digest)
|
|
118
|
+
throw new Error("A decision cannot supersede itself.");
|
|
119
|
+
const unchanged = canonicalJson(sortedVersions(input.prior)) === canonicalJson(sortedVersions(input.item)) && sameCandidateContent(input.prior, input.item);
|
|
120
|
+
return sealSupersession({
|
|
121
|
+
kind: decisionSupersessionKind,
|
|
122
|
+
schemaVersion: 1,
|
|
123
|
+
priorDecision,
|
|
124
|
+
newDecision,
|
|
125
|
+
slotId: input.item.slotId,
|
|
126
|
+
reason: unchanged ? "re-reviewed" : "candidate-changed",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/** The name and canonical digest of a decision. */
|
|
130
|
+
export function decisionReference(decision) {
|
|
131
|
+
if (decision?.kind !== "ReviewDecision")
|
|
132
|
+
throw new Error("A decision reference needs a ReviewDecision.");
|
|
133
|
+
return { name: nonEmpty(decision.metadata?.name, "decision name"), digest: `sha256:${sha256Hex(canonicalJson(decision))}` };
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The terminal resolution a decision reached: `could_not_confirm`, `rejected`
|
|
137
|
+
* for a rejected status, `accepted` for a verified or assumed one. Undefined
|
|
138
|
+
* for a decision that reached none (a `proposed` status without
|
|
139
|
+
* could-not-confirm).
|
|
140
|
+
*/
|
|
141
|
+
export function decisionResolution(decision) {
|
|
142
|
+
if (decision.spec.resolution === "could_not_confirm")
|
|
143
|
+
return "could_not_confirm";
|
|
144
|
+
if (decision.spec.status === "rejected")
|
|
145
|
+
return "rejected";
|
|
146
|
+
if (decision.spec.status === "verified" || decision.spec.status === "assumed")
|
|
147
|
+
return "accepted";
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
/** Validates a stored carry-forward record: its shape and its content-addressed id. */
|
|
151
|
+
export function validateDecisionCarryForward(value) {
|
|
152
|
+
const record = object(value, "DecisionCarryForward");
|
|
153
|
+
exactKeys(record, ["id", "kind", "schemaVersion", "priorDecision", "slotId", "versionIds", "roundId", "reviewItemName", "resolution", "basis"], ["candidateId"], "DecisionCarryForward");
|
|
154
|
+
if (record.kind !== decisionCarryForwardKind || record.schemaVersion !== 1 || record.basis !== "carried-forward")
|
|
155
|
+
throw new Error("DecisionCarryForward kind, schemaVersion or basis is invalid.");
|
|
156
|
+
validateReference(record.priorDecision, "priorDecision");
|
|
157
|
+
nonEmpty(record.slotId, "slotId");
|
|
158
|
+
nonEmpty(record.roundId, "roundId");
|
|
159
|
+
nonEmpty(record.reviewItemName, "reviewItemName");
|
|
160
|
+
if (record.candidateId !== undefined)
|
|
161
|
+
nonEmpty(record.candidateId, "candidateId");
|
|
162
|
+
if (!Array.isArray(record.versionIds) || record.versionIds.length === 0)
|
|
163
|
+
throw new Error("DecisionCarryForward versionIds must be a non-empty array.");
|
|
164
|
+
record.versionIds.forEach((entry, index) => {
|
|
165
|
+
nonEmpty(entry, "versionId");
|
|
166
|
+
if (index > 0 && !(record.versionIds[index - 1] < entry))
|
|
167
|
+
throw new Error("DecisionCarryForward versionIds must be sorted and unique.");
|
|
168
|
+
});
|
|
169
|
+
if (!["accepted", "rejected", "could_not_confirm"].includes(record.resolution))
|
|
170
|
+
throw new Error("DecisionCarryForward resolution is invalid.");
|
|
171
|
+
const { id, ...payload } = record;
|
|
172
|
+
if (id !== recordDigest(decisionCarryForwardKind, payload))
|
|
173
|
+
throw new Error("DecisionCarryForward id is not the digest of its fields.");
|
|
174
|
+
return deepFreeze(structuredClone(record));
|
|
175
|
+
}
|
|
176
|
+
/** Validates a stored supersession record: its shape and its content-addressed id. */
|
|
177
|
+
export function validateDecisionSupersession(value) {
|
|
178
|
+
const record = object(value, "DecisionSupersession");
|
|
179
|
+
exactKeys(record, ["id", "kind", "schemaVersion", "priorDecision", "newDecision", "slotId", "reason"], [], "DecisionSupersession");
|
|
180
|
+
if (record.kind !== decisionSupersessionKind || record.schemaVersion !== 1)
|
|
181
|
+
throw new Error("DecisionSupersession kind or schemaVersion is invalid.");
|
|
182
|
+
validateReference(record.priorDecision, "priorDecision");
|
|
183
|
+
validateReference(record.newDecision, "newDecision");
|
|
184
|
+
nonEmpty(record.slotId, "slotId");
|
|
185
|
+
if (record.reason !== "candidate-changed" && record.reason !== "re-reviewed")
|
|
186
|
+
throw new Error("DecisionSupersession reason is invalid.");
|
|
187
|
+
if (record.priorDecision.digest === record.newDecision.digest)
|
|
188
|
+
throw new Error("A decision cannot supersede itself.");
|
|
189
|
+
const { id, ...payload } = record;
|
|
190
|
+
if (id !== recordDigest(decisionSupersessionKind, payload))
|
|
191
|
+
throw new Error("DecisionSupersession id is not the digest of its fields.");
|
|
192
|
+
return deepFreeze(structuredClone(record));
|
|
193
|
+
}
|
|
194
|
+
function sealCarryForward(payload) {
|
|
195
|
+
return deepFreeze({ id: recordDigest(decisionCarryForwardKind, payload), ...payload });
|
|
196
|
+
}
|
|
197
|
+
function sealSupersession(payload) {
|
|
198
|
+
return deepFreeze({ id: recordDigest(decisionSupersessionKind, payload), ...payload });
|
|
199
|
+
}
|
|
200
|
+
function recordDigest(kind, payload) {
|
|
201
|
+
return `sha256:${sha256Hex(canonicalJson({ kind: `${kind}/v1`, payload }))}`;
|
|
202
|
+
}
|
|
203
|
+
/** Every candidate of the item has exactly one non-empty version id, and ids are unique within the item. */
|
|
204
|
+
function assertRoundItem(entry, label) {
|
|
205
|
+
nonEmpty(entry.slotId, `${label} slotId`);
|
|
206
|
+
const candidateIds = entry.item.spec.candidates.map((candidate) => candidate.id);
|
|
207
|
+
if (new Set(candidateIds).size !== candidateIds.length)
|
|
208
|
+
throw new Error(`ReviewItem ${entry.item.metadata.name} has duplicate candidate ids.`);
|
|
209
|
+
const versionKeys = Object.keys(entry.candidateVersionIds ?? {}).sort();
|
|
210
|
+
if (canonicalJson(versionKeys) !== canonicalJson([...candidateIds].sort())) {
|
|
211
|
+
throw new Error(`ReviewItem ${entry.item.metadata.name} (${label}) must carry a producer version id for exactly its candidates.`);
|
|
212
|
+
}
|
|
213
|
+
const versions = Object.values(entry.candidateVersionIds);
|
|
214
|
+
versions.forEach((version) => nonEmpty(version, `${label} versionId`));
|
|
215
|
+
if (new Set(versions).size !== versions.length)
|
|
216
|
+
throw new Error(`ReviewItem ${entry.item.metadata.name} (${label}) has two candidates with one version id.`);
|
|
217
|
+
}
|
|
218
|
+
function assertDecisionNamesItem(decision, item) {
|
|
219
|
+
if (decision.spec.reviewItemName !== item.metadata.name)
|
|
220
|
+
throw new Error(`ReviewDecision ${decision.metadata.name} names ${decision.spec.reviewItemName}, not ${item.metadata.name}.`);
|
|
221
|
+
if (decision.spec.candidateId !== undefined && !item.spec.candidates.some((candidate) => candidate.id === decision.spec.candidateId)) {
|
|
222
|
+
throw new Error(`ReviewDecision ${decision.metadata.name} selects candidate ${decision.spec.candidateId}, which ReviewItem ${item.metadata.name} does not hold.`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
function sortedVersions(entry) {
|
|
226
|
+
return Object.values(entry.candidateVersionIds).sort();
|
|
227
|
+
}
|
|
228
|
+
function candidateWithVersion(entry, versionId) {
|
|
229
|
+
const id = Object.keys(entry.candidateVersionIds).find((candidateId) => entry.candidateVersionIds[candidateId] === versionId);
|
|
230
|
+
return entry.item.spec.candidates.find((candidate) => candidate.id === id);
|
|
231
|
+
}
|
|
232
|
+
/** For each version id, the prior and new candidates carry the same value and evidence. */
|
|
233
|
+
function sameCandidateContent(prior, next) {
|
|
234
|
+
return Object.entries(prior.candidateVersionIds).every(([candidateId, versionId]) => {
|
|
235
|
+
const before = prior.item.spec.candidates.find((candidate) => candidate.id === candidateId);
|
|
236
|
+
const after = candidateWithVersion(next, versionId);
|
|
237
|
+
return canonicalJson(candidateContent(before)) === canonicalJson(candidateContent(after));
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
/** What a decision was made about: the value, the evidence behind it, and the claim it is for. */
|
|
241
|
+
function candidateContent(candidate) {
|
|
242
|
+
return {
|
|
243
|
+
role: candidate.role ?? null,
|
|
244
|
+
value: candidate.value,
|
|
245
|
+
// The whole locator (scheme, locator, excerpt) and the whole claim target
|
|
246
|
+
// (subject, field, impact and the rest): a decision is about this value,
|
|
247
|
+
// at this place in the source, for this claim.
|
|
248
|
+
locator: candidate.locator ?? null,
|
|
249
|
+
claimTarget: candidate.claimTarget,
|
|
250
|
+
sourceRef: candidate.source.sourceRef,
|
|
251
|
+
sourceChecksum: candidate.source.checksum ?? null,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
function normalizePolicy(policy) {
|
|
255
|
+
if (!policy || !Array.isArray(policy.carry))
|
|
256
|
+
throw new Error("A carry-forward policy must list the resolutions it carries.");
|
|
257
|
+
for (const resolution of policy.carry) {
|
|
258
|
+
if (!["accepted", "rejected", "could_not_confirm"].includes(resolution))
|
|
259
|
+
throw new Error(`Carry-forward policy names an unknown resolution: ${String(resolution)}.`);
|
|
260
|
+
}
|
|
261
|
+
return policy;
|
|
262
|
+
}
|
|
263
|
+
function validateReference(value, label) {
|
|
264
|
+
const reference = object(value, label);
|
|
265
|
+
exactKeys(reference, ["name", "digest"], [], label);
|
|
266
|
+
nonEmpty(reference.name, `${label}.name`);
|
|
267
|
+
if (typeof reference.digest !== "string" || !/^sha256:[0-9a-f]{64}$/.test(reference.digest))
|
|
268
|
+
throw new Error(`${label}.digest must be a sha256:<64 hex> digest.`);
|
|
269
|
+
}
|
|
270
|
+
function object(value, label) {
|
|
271
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
272
|
+
throw new Error(`${label} must be an object.`);
|
|
273
|
+
return value;
|
|
274
|
+
}
|
|
275
|
+
function exactKeys(value, required, optional, label) {
|
|
276
|
+
for (const key of required)
|
|
277
|
+
if (!Object.hasOwn(value, key))
|
|
278
|
+
throw new Error(`${label}.${key} is required.`);
|
|
279
|
+
for (const key of Object.keys(value))
|
|
280
|
+
if (!required.includes(key) && !optional.includes(key))
|
|
281
|
+
throw new Error(`${label}.${key} is unexpected.`);
|
|
282
|
+
}
|
|
283
|
+
function nonEmpty(value, label) {
|
|
284
|
+
if (typeof value !== "string" || value.trim() === "")
|
|
285
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
286
|
+
return value;
|
|
287
|
+
}
|
|
288
|
+
function deepFreeze(value) {
|
|
289
|
+
if (value && typeof value === "object") {
|
|
290
|
+
for (const nested of Object.values(value))
|
|
291
|
+
deepFreeze(nested);
|
|
292
|
+
Object.freeze(value);
|
|
293
|
+
}
|
|
294
|
+
return value;
|
|
295
|
+
}
|
|
@@ -197,8 +197,9 @@ export type ResolvedExtractionArtifact = {
|
|
|
197
197
|
* artifact text. `verified` means the supplied text matched the envelope's
|
|
198
198
|
* digest and length and every proposal that produced a ReviewItem matched its
|
|
199
199
|
* span; `unverified` means no text was supplied, or the text did not verify
|
|
200
|
-
* (a diagnostic then says why).
|
|
201
|
-
*
|
|
200
|
+
* (a diagnostic then says why). Required: a record without it, or with any
|
|
201
|
+
* other value, is refused, so a record whose provenance was stripped cannot
|
|
202
|
+
* pass as the one-item-per-proposal shape Survey wrote before 6.0.0.
|
|
202
203
|
*/
|
|
203
204
|
export type ExtractionEnvelopeImportProvenance = "verified" | "unverified";
|
|
204
205
|
/**
|
|
@@ -253,7 +254,7 @@ export interface ExtractionEnvelopeImport {
|
|
|
253
254
|
status: {
|
|
254
255
|
state: "grounded" | "unresolved";
|
|
255
256
|
diagnostics: ExtractionEnvelopeImportDiagnostic[];
|
|
256
|
-
provenance
|
|
257
|
+
provenance: ExtractionEnvelopeImportProvenance;
|
|
257
258
|
};
|
|
258
259
|
}
|
|
259
260
|
export interface ExtractionEnvelopeImportResult {
|
|
@@ -333,8 +333,6 @@ function excerptMismatchDiagnostic(index, proposal) {
|
|
|
333
333
|
* written: each diagnostic is rebuilt from its own fields and must match.
|
|
334
334
|
*/
|
|
335
335
|
function verificationDiagnosticsAreCoherent(envelope, provenance, diagnostics) {
|
|
336
|
-
if (provenance === undefined)
|
|
337
|
-
return diagnostics.length === 0;
|
|
338
336
|
const prepared = envelope.result.preparedArtifact;
|
|
339
337
|
const rebuild = (value) => {
|
|
340
338
|
if (!value || typeof value !== "object" || Array.isArray(value) || !prepared)
|
|
@@ -422,7 +420,9 @@ function validateImport(value) {
|
|
|
422
420
|
if (targets.length !== envelope.result.proposals.length)
|
|
423
421
|
throw new Error("spec.claimTargets must align with proposals.");
|
|
424
422
|
const status = obj(record.status, "status");
|
|
425
|
-
exact(status, ["state", "diagnostics"
|
|
423
|
+
exact(status, ["state", "diagnostics", "provenance"], "status");
|
|
424
|
+
if (status.provenance !== "verified" && status.provenance !== "unverified")
|
|
425
|
+
throw new Error("status.provenance must be \"verified\" or \"unverified\".");
|
|
426
426
|
const diagnostics = array(status.diagnostics, "status.diagnostics");
|
|
427
427
|
const expected = diagnosticsFor(envelope);
|
|
428
428
|
if (canonicalJson(diagnostics.slice(0, expected.length)) !== canonicalJson(expected)
|