@unson/brainbase-mcp 0.6.0 → 0.7.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/canonical-task-contract.d.ts +9 -0
- package/dist/canonical-task-contract.js +39 -0
- package/dist/canonical-task-principal.d.ts +16 -0
- package/dist/canonical-task-principal.js +36 -0
- package/dist/canonical-task-service.d.ts +251 -0
- package/dist/canonical-task-service.js +809 -0
- package/dist/company-os-evaluation.d.ts +193 -0
- package/dist/company-os-evaluation.js +1150 -0
- package/dist/company-os-impact-review.d.ts +248 -0
- package/dist/company-os-impact-review.js +663 -0
- package/dist/company-os-learning-adoption.d.ts +288 -0
- package/dist/company-os-learning-adoption.js +944 -0
- package/dist/company-os-objectives.d.ts +52 -0
- package/dist/company-os-objectives.js +197 -0
- package/dist/company-os-receipt-adapter.d.ts +109 -0
- package/dist/company-os-receipt-adapter.js +636 -0
- package/dist/constraint-exception-store.d.ts +23 -0
- package/dist/constraint-exception-store.js +108 -0
- package/dist/constraint-resolution.d.ts +175 -0
- package/dist/constraint-resolution.js +922 -0
- package/dist/decision-adapter.d.ts +145 -0
- package/dist/decision-adapter.js +586 -0
- package/dist/durable-wait-http.d.ts +51 -0
- package/dist/durable-wait-http.js +497 -0
- package/dist/durable-waits.d.ts +234 -0
- package/dist/durable-waits.js +944 -0
- package/dist/execution-authority-http.d.ts +28 -0
- package/dist/execution-authority-http.js +359 -0
- package/dist/execution-authority.d.ts +201 -0
- package/dist/execution-authority.js +634 -0
- package/dist/foundation-catalog.d.ts +26 -0
- package/dist/foundation-catalog.js +206 -0
- package/dist/foundation-constraint-store.d.ts +50 -0
- package/dist/foundation-constraint-store.js +215 -0
- package/dist/foundation-http.d.ts +90 -0
- package/dist/foundation-http.js +439 -0
- package/dist/foundation-store.d.ts +132 -0
- package/dist/foundation-store.js +654 -0
- package/dist/judgment-dag-composition.d.ts +224 -0
- package/dist/judgment-dag-composition.js +731 -0
- package/dist/judgment-dag.d.ts +2 -0
- package/dist/judgment-dag.js +1 -0
- package/dist/judgment-problem-snapshot.d.ts +185 -0
- package/dist/judgment-problem-snapshot.js +777 -0
- package/dist/judgment-view-http.d.ts +22 -0
- package/dist/judgment-view-http.js +128 -0
- package/dist/judgment-view.d.ts +222 -0
- package/dist/judgment-view.js +627 -0
- package/dist/knowledge-adapter.d.ts +112 -0
- package/dist/knowledge-adapter.js +691 -0
- package/dist/ontology-foundation.d.ts +209 -0
- package/dist/ontology-foundation.js +1000 -0
- package/dist/ontology.d.ts +13 -0
- package/dist/ontology.js +14 -0
- package/dist/organization-connection.d.ts +164 -0
- package/dist/organization-connection.js +428 -0
- package/dist/personal-knowledge.d.ts +120 -0
- package/dist/personal-knowledge.js +750 -0
- package/dist/problem-candidates.d.ts +149 -0
- package/dist/problem-candidates.js +898 -0
- package/dist/problem-selection.d.ts +238 -0
- package/dist/problem-selection.js +995 -0
- package/dist/resource-reservation-http.d.ts +31 -0
- package/dist/resource-reservation-http.js +371 -0
- package/dist/resource-reservations.d.ts +221 -0
- package/dist/resource-reservations.js +577 -0
- package/dist/server.d.ts +97 -0
- package/dist/server.js +180 -1
- package/dist/ssot.d.ts +6 -1
- package/dist/ssot.js +63 -5
- package/dist/streamable-http.d.ts +42 -0
- package/dist/streamable-http.js +288 -0
- package/dist/types.d.ts +20 -0
- package/dist/world-model.d.ts +181 -0
- package/dist/world-model.js +873 -0
- package/package.json +139 -3
- package/ui/judgment-view.css +238 -0
- package/ui/judgment-view.js +508 -0
- package/ui/objective-editor.css +180 -0
- package/ui/objective-editor.js +1139 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
import { validateEvaluationCompatibility, validateFoundationDefinition } from './ontology-foundation.js';
|
|
2
|
+
import { findFoundationRecord, findLatestFoundationRecord, foundationRef, foundationCatalogOrEmpty } from './foundation-catalog.js';
|
|
3
|
+
import { computeJudgmentProblemSnapshotId, JudgmentProblemSnapshotError, loadJudgmentProblemSnapshot } from './judgment-problem-snapshot.js';
|
|
4
|
+
import { loadPersonalOs, mutatePersonalOsWithSidecar, readPersonalOsSidecar } from './ssot.js';
|
|
5
|
+
/**
|
|
6
|
+
* Evaluation is an evidence record over a pinned Problem. It does not own
|
|
7
|
+
* Objective or Variable definitions and it does not close an OutcomeCase.
|
|
8
|
+
*/
|
|
9
|
+
export const COMPANY_OS_EVALUATION_CONTRACT_VERSION = '0.1.0';
|
|
10
|
+
export const COMPANY_OS_EVALUATION_RECORD_CATALOG_VERSION = 1;
|
|
11
|
+
export const COMPANY_OS_EVALUATION_SIDECAR = 'evidence/company-os-evaluation.json';
|
|
12
|
+
export function createFoundationDefinitionLoadPort(store) {
|
|
13
|
+
if (!store || typeof store.read !== 'function') {
|
|
14
|
+
throw new TypeError('A foundation store with read is required');
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
async readExact(reference, context) {
|
|
18
|
+
const record = await store.read({
|
|
19
|
+
id: reference.id,
|
|
20
|
+
type: reference.type,
|
|
21
|
+
revision: reference.revision
|
|
22
|
+
}, context);
|
|
23
|
+
if (!record)
|
|
24
|
+
return null;
|
|
25
|
+
if (record.digest !== reference.digest) {
|
|
26
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Foundation ${reference.type}/${reference.id}@${reference.revision} has a different digest`);
|
|
27
|
+
}
|
|
28
|
+
return record;
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export class CompanyOsEvaluationError extends Error {
|
|
33
|
+
code;
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = 'CompanyOsEvaluationError';
|
|
37
|
+
this.code = code;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function createCompanyOsEvaluationStore(options) {
|
|
41
|
+
if (!options || !isNonEmptyString(options.dataDir)) {
|
|
42
|
+
throw new CompanyOsEvaluationError('invalid_input', 'dataDir is required');
|
|
43
|
+
}
|
|
44
|
+
if (!options.foundation || typeof options.foundation.readExact !== 'function') {
|
|
45
|
+
throw new CompanyOsEvaluationError('invalid_input', 'foundation.readExact is required');
|
|
46
|
+
}
|
|
47
|
+
if (!options.outcomeCase || typeof options.outcomeCase.read !== 'function') {
|
|
48
|
+
throw new CompanyOsEvaluationError('invalid_input', 'outcomeCase.read is required');
|
|
49
|
+
}
|
|
50
|
+
if (!options.snapshotReferenceProvider || typeof options.snapshotReferenceProvider.resolve !== 'function') {
|
|
51
|
+
throw new CompanyOsEvaluationError('invalid_input', 'snapshotReferenceProvider.resolve is required');
|
|
52
|
+
}
|
|
53
|
+
return new GraphCompanyOsEvaluationStore(options);
|
|
54
|
+
}
|
|
55
|
+
class GraphCompanyOsEvaluationStore {
|
|
56
|
+
options;
|
|
57
|
+
constructor(options) {
|
|
58
|
+
this.options = options;
|
|
59
|
+
}
|
|
60
|
+
async evaluate(request) {
|
|
61
|
+
const access = assertAccessContext(request?.access);
|
|
62
|
+
assertEvaluationRequest(request);
|
|
63
|
+
const resolved = await resolveDefinitions(this.options.foundation, request.snapshot, access);
|
|
64
|
+
const outcomeCase = await readCanonicalOutcomeCase(this.options.outcomeCase, request.outcomeCase, access);
|
|
65
|
+
const predictions = await resolveMeasurements(request.predictions, resolved.variables, this.options.foundation, access, 'prediction', resolved.objective.evaluationPeriod);
|
|
66
|
+
const actuals = await resolveMeasurements(request.actuals, resolved.variables, this.options.foundation, access, 'actual', resolved.objective.evaluationPeriod);
|
|
67
|
+
const validity = normalizeJudgmentValidity(request.judgmentAtTimeValidity);
|
|
68
|
+
const record = buildEvaluationRecord({
|
|
69
|
+
id: request.id,
|
|
70
|
+
resolved,
|
|
71
|
+
outcomeCase,
|
|
72
|
+
predictions,
|
|
73
|
+
actuals,
|
|
74
|
+
validity,
|
|
75
|
+
evaluatedAt: request.evaluatedAt
|
|
76
|
+
});
|
|
77
|
+
await this.mutateCatalog((catalog, current) => {
|
|
78
|
+
if (catalog.records.some((existing) => existing.id === record.id)) {
|
|
79
|
+
throw new CompanyOsEvaluationError('revision_conflict', `Evaluation ${record.id} already exists`);
|
|
80
|
+
}
|
|
81
|
+
// The Foundation read above is repeated against the aggregate held by
|
|
82
|
+
// the canonical lock. This prevents a concurrent revision/ACL update
|
|
83
|
+
// from silently changing what the immutable evaluation points at.
|
|
84
|
+
assertCurrentFoundation(current, record.objectiveRef, resolved.objectiveRecord, access);
|
|
85
|
+
for (const variable of resolved.variables) {
|
|
86
|
+
assertCurrentFoundation(current, variable.reference, variable.record, access);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...catalog,
|
|
90
|
+
records: [...catalog.records, record]
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const readback = await this.read(record.id, access);
|
|
94
|
+
if (!readback) {
|
|
95
|
+
throw new CompanyOsEvaluationError('readback_mismatch', `Evaluation ${record.id} was not readable after commit`);
|
|
96
|
+
}
|
|
97
|
+
return readback;
|
|
98
|
+
}
|
|
99
|
+
async read(id, access) {
|
|
100
|
+
const context = assertAccessContext(access);
|
|
101
|
+
if (!isNonEmptyString(id))
|
|
102
|
+
throw new CompanyOsEvaluationError('invalid_input', 'evaluation id is required');
|
|
103
|
+
const catalog = await this.loadCatalog();
|
|
104
|
+
const record = catalog.records.find((item) => item.id === id);
|
|
105
|
+
if (!record)
|
|
106
|
+
return null;
|
|
107
|
+
const snapshot = await loadEvaluationSnapshot(this.options, record, context);
|
|
108
|
+
assertSnapshotMatchesEvaluation(record, snapshot);
|
|
109
|
+
const resolved = await this.options.foundation.readExact(record.objectiveRef, context);
|
|
110
|
+
if (!resolved || resolved.definition.type !== 'objective') {
|
|
111
|
+
throw new CompanyOsEvaluationError('corrupt_record', `Objective for evaluation ${id} is missing or has the wrong type`);
|
|
112
|
+
}
|
|
113
|
+
await assertEvaluationRecordConsistency(record, resolved, this.options.foundation, context);
|
|
114
|
+
const outcomeCase = await readCanonicalOutcomeCase(this.options.outcomeCase, record.outcomeCaseRef, context);
|
|
115
|
+
if (!sameCaseReference(outcomeCase.reference, record.outcomeCaseRef)) {
|
|
116
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `OutcomeCase reference for evaluation ${id} changed`);
|
|
117
|
+
}
|
|
118
|
+
return cloneEvaluationRecord(record);
|
|
119
|
+
}
|
|
120
|
+
async list(access) {
|
|
121
|
+
const context = assertAccessContext(access);
|
|
122
|
+
const catalog = await this.loadCatalog();
|
|
123
|
+
const records = [];
|
|
124
|
+
for (const record of catalog.records) {
|
|
125
|
+
const visible = await this.read(record.id, context);
|
|
126
|
+
if (visible)
|
|
127
|
+
records.push(visible);
|
|
128
|
+
}
|
|
129
|
+
return Object.freeze(records);
|
|
130
|
+
}
|
|
131
|
+
async loadCatalog() {
|
|
132
|
+
try {
|
|
133
|
+
const os = await loadPersonalOs(this.options.dataDir);
|
|
134
|
+
if (os.graph.version !== 2) {
|
|
135
|
+
throw new CompanyOsEvaluationError('unsupported_graph', 'Evaluation records require canonical Graph v2');
|
|
136
|
+
}
|
|
137
|
+
return parseEvaluationCatalog(await readPersonalOsSidecar(this.options.dataDir, COMPANY_OS_EVALUATION_SIDECAR));
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
if (error instanceof CompanyOsEvaluationError)
|
|
141
|
+
throw error;
|
|
142
|
+
throw new CompanyOsEvaluationError('corrupt_record', formatError(error));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
async mutateCatalog(mutator) {
|
|
146
|
+
try {
|
|
147
|
+
await mutatePersonalOsWithSidecar(this.options.dataDir, COMPANY_OS_EVALUATION_SIDECAR, async (current, existingContent) => {
|
|
148
|
+
if (current.graph.version !== 2) {
|
|
149
|
+
throw new CompanyOsEvaluationError('unsupported_graph', 'Evaluation records require canonical Graph v2');
|
|
150
|
+
}
|
|
151
|
+
const catalog = parseEvaluationCatalog(existingContent);
|
|
152
|
+
const next = await mutator(catalog, current);
|
|
153
|
+
assertEvaluationCatalog(next);
|
|
154
|
+
return {
|
|
155
|
+
next: current,
|
|
156
|
+
sidecarContent: serializeEvaluationCatalog(next),
|
|
157
|
+
result: undefined
|
|
158
|
+
};
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
if (error instanceof CompanyOsEvaluationError)
|
|
163
|
+
throw error;
|
|
164
|
+
throw new CompanyOsEvaluationError('corrupt_record', formatError(error));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
async function resolveDefinitions(foundation, snapshotInput, access) {
|
|
169
|
+
const snapshot = await loadJudgmentProblemSnapshot({
|
|
170
|
+
root: snapshotInput.root,
|
|
171
|
+
...(snapshotInput.snapshotId === undefined ? {} : { snapshot_id: snapshotInput.snapshotId }),
|
|
172
|
+
...(snapshotInput.problemId === undefined ? {} : { problem_id: snapshotInput.problemId }),
|
|
173
|
+
...(snapshotInput.revision === undefined ? {} : { revision: snapshotInput.revision }),
|
|
174
|
+
access,
|
|
175
|
+
...(snapshotInput.accessProvider === undefined ? {} : { accessProvider: snapshotInput.accessProvider }),
|
|
176
|
+
reference_resolution: 'historical',
|
|
177
|
+
referenceProvider: snapshotInput.referenceProvider
|
|
178
|
+
});
|
|
179
|
+
const snapshotId = computeJudgmentProblemSnapshotId(snapshot);
|
|
180
|
+
const objectiveReferences = snapshot.references.filter((reference) => reference.kind === 'objective');
|
|
181
|
+
if (objectiveReferences.length !== 1) {
|
|
182
|
+
throw new CompanyOsEvaluationError('invalid_input', 'A Problem must pin exactly one Objective reference');
|
|
183
|
+
}
|
|
184
|
+
const objectiveRef = foundationReference(objectiveReferences[0]);
|
|
185
|
+
const objectiveRecord = await foundation.readExact(objectiveRef, access);
|
|
186
|
+
if (!objectiveRecord) {
|
|
187
|
+
throw new CompanyOsEvaluationError('not_found', `Objective ${objectiveRef.id}@${objectiveRef.revision} was not found`);
|
|
188
|
+
}
|
|
189
|
+
if (objectiveRecord.definition.type !== 'objective') {
|
|
190
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Problem Objective reference resolved to ${objectiveRecord.definition.type}`);
|
|
191
|
+
}
|
|
192
|
+
const objective = objectiveRecord.definition;
|
|
193
|
+
const validation = validateFoundationDefinition(objective, { use: 'evaluation' });
|
|
194
|
+
if (!validation.valid) {
|
|
195
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Objective cannot be evaluated: ${validation.issues.map((issue) => issue.message).join('; ')}`);
|
|
196
|
+
}
|
|
197
|
+
const criteriaReferences = snapshot.references.filter((reference) => reference.kind === 'criterion');
|
|
198
|
+
if (criteriaReferences.length !== objective.criteria.length || criteriaReferences.length === 0) {
|
|
199
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Problem criterion references do not match the pinned Objective criteria');
|
|
200
|
+
}
|
|
201
|
+
assertUniqueReferences(criteriaReferences, 'criterion');
|
|
202
|
+
const variableReferences = snapshot.references.filter((reference) => reference.kind === 'variable');
|
|
203
|
+
const variables = [];
|
|
204
|
+
for (const [criterionIndex, criterion] of objective.criteria.entries()) {
|
|
205
|
+
const candidates = variableReferences.filter((reference) => (reference.id === criterion.variableRef.id
|
|
206
|
+
&& reference.revision === criterion.variableRef.revision));
|
|
207
|
+
if (candidates.length !== 1) {
|
|
208
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Problem must pin exactly one Variable reference for criterion ${criterionIndex}`);
|
|
209
|
+
}
|
|
210
|
+
const reference = foundationReference(candidates[0]);
|
|
211
|
+
const record = await foundation.readExact(reference, access);
|
|
212
|
+
if (!record)
|
|
213
|
+
throw new CompanyOsEvaluationError('not_found', `Variable ${reference.id}@${reference.revision} was not found`);
|
|
214
|
+
if (record.definition.type !== 'variable') {
|
|
215
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Criterion ${criterionIndex} resolved to ${record.definition.type}`);
|
|
216
|
+
}
|
|
217
|
+
const variableValidation = validateFoundationDefinition(record.definition, { use: 'evaluation' });
|
|
218
|
+
if (!variableValidation.valid) {
|
|
219
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Variable ${reference.id} cannot be evaluated: ${variableValidation.issues.map((issue) => issue.message).join('; ')}`);
|
|
220
|
+
}
|
|
221
|
+
assertCriterionCompatibility(criterion, record.definition, criterionIndex);
|
|
222
|
+
variables.push({ criterionIndex, criterion, reference, definition: record.definition, record });
|
|
223
|
+
}
|
|
224
|
+
return { snapshot, snapshotId, objectiveRef, objective, objectiveRecord, variables };
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* A stored evaluation is only meaningful with the exact Problem snapshot that
|
|
228
|
+
* produced it. Re-resolve the canonical locator on every read instead of
|
|
229
|
+
* trusting the four fields copied into the evaluation sidecar. The resolver
|
|
230
|
+
* also rechecks current ACLs and exact reference digests for historical reads.
|
|
231
|
+
*/
|
|
232
|
+
async function loadEvaluationSnapshot(options, record, access) {
|
|
233
|
+
let snapshot;
|
|
234
|
+
try {
|
|
235
|
+
snapshot = await loadJudgmentProblemSnapshot({
|
|
236
|
+
root: options.dataDir,
|
|
237
|
+
snapshot_id: record.snapshot.snapshotId,
|
|
238
|
+
problem_id: record.snapshot.problemId,
|
|
239
|
+
revision: record.snapshot.revision,
|
|
240
|
+
access,
|
|
241
|
+
reference_resolution: 'historical',
|
|
242
|
+
referenceProvider: options.snapshotReferenceProvider
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch (error) {
|
|
246
|
+
throw mapEvaluationSnapshotLoadError(error);
|
|
247
|
+
}
|
|
248
|
+
const canonicalDigest = computeJudgmentProblemSnapshotId(snapshot);
|
|
249
|
+
if (snapshot.problem_id !== record.snapshot.problemId
|
|
250
|
+
|| snapshot.revision !== record.snapshot.revision
|
|
251
|
+
|| canonicalDigest !== record.snapshot.snapshotId
|
|
252
|
+
|| canonicalDigest !== record.snapshot.digest) {
|
|
253
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Evaluation snapshot locator or digest does not match the canonical Problem snapshot');
|
|
254
|
+
}
|
|
255
|
+
return snapshot;
|
|
256
|
+
}
|
|
257
|
+
function mapEvaluationSnapshotLoadError(error) {
|
|
258
|
+
if (error instanceof CompanyOsEvaluationError)
|
|
259
|
+
return error;
|
|
260
|
+
if (error instanceof JudgmentProblemSnapshotError) {
|
|
261
|
+
if (error.code === 'unauthorized') {
|
|
262
|
+
return new CompanyOsEvaluationError('authorization_denied', error.message);
|
|
263
|
+
}
|
|
264
|
+
if (error.code === 'not_found' || error.code === 'integrity_mismatch' || error.code === 'conflict') {
|
|
265
|
+
return new CompanyOsEvaluationError('integrity_mismatch', error.message);
|
|
266
|
+
}
|
|
267
|
+
return new CompanyOsEvaluationError('corrupt_record', error.message);
|
|
268
|
+
}
|
|
269
|
+
return new CompanyOsEvaluationError('corrupt_record', formatError(error));
|
|
270
|
+
}
|
|
271
|
+
function assertSnapshotMatchesEvaluation(record, snapshot) {
|
|
272
|
+
const objectiveReferences = snapshot.references.filter((reference) => reference.kind === 'objective');
|
|
273
|
+
if (objectiveReferences.length !== 1) {
|
|
274
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Evaluation snapshot must contain exactly one Objective reference');
|
|
275
|
+
}
|
|
276
|
+
if (!sameFoundationRef(record.objectiveRef, foundationReference(objectiveReferences[0]))) {
|
|
277
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Evaluation Objective reference does not match its Problem snapshot');
|
|
278
|
+
}
|
|
279
|
+
const variableKeys = new Set(snapshot.references
|
|
280
|
+
.filter((reference) => reference.kind === 'variable')
|
|
281
|
+
.map((reference) => foundationReferenceKey(foundationReference(reference))));
|
|
282
|
+
for (const criterion of record.criteria) {
|
|
283
|
+
if (!variableKeys.has(foundationReferenceKey(criterion.variableRef))) {
|
|
284
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Evaluation criterion ${criterion.criterionIndex} is not pinned by its Problem snapshot`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async function resolveMeasurements(inputs, variables, foundation, access, phase, expectedPeriod) {
|
|
289
|
+
if (!Array.isArray(inputs))
|
|
290
|
+
throw new CompanyOsEvaluationError('invalid_input', `${phase} measurements must be an array`);
|
|
291
|
+
const byVariable = new Map();
|
|
292
|
+
const normalized = [];
|
|
293
|
+
for (const input of inputs) {
|
|
294
|
+
const variable = await resolveMeasurementVariable(input, variables, foundation, access);
|
|
295
|
+
const measurement = normalizeMeasurement(input, variable, expectedPeriod);
|
|
296
|
+
const key = foundationReferenceKey(measurement.effectiveVariableRef);
|
|
297
|
+
if (byVariable.has(key)) {
|
|
298
|
+
throw new CompanyOsEvaluationError('invalid_input', `${phase} contains duplicate measurements for ${key}`);
|
|
299
|
+
}
|
|
300
|
+
byVariable.set(key, measurement);
|
|
301
|
+
normalized.push(measurement);
|
|
302
|
+
}
|
|
303
|
+
return { measurements: Object.freeze(normalized.map(cloneEvaluationMeasurement)), byVariable };
|
|
304
|
+
}
|
|
305
|
+
async function resolveMeasurementVariable(input, variables, foundation, access) {
|
|
306
|
+
if (!input || typeof input !== 'object') {
|
|
307
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Measurement must be an object');
|
|
308
|
+
}
|
|
309
|
+
assertFoundationRef(input?.variableRef, 'measurement.variableRef');
|
|
310
|
+
const conversion = input.conversion;
|
|
311
|
+
const source = conversion?.sourceRef ?? input.variableRef;
|
|
312
|
+
if (!sameFoundationRef(source, input.variableRef)) {
|
|
313
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Conversion sourceRef must equal measurement.variableRef');
|
|
314
|
+
}
|
|
315
|
+
if (conversion !== undefined) {
|
|
316
|
+
assertFoundationRef(conversion.targetRef, 'measurement.conversion.targetRef');
|
|
317
|
+
if (!isNonEmptyString(conversion.provenance.id) || !isNonEmptyString(conversion.provenance.description)) {
|
|
318
|
+
throw new CompanyOsEvaluationError('invalid_input', 'A conversion requires explicit provenance id and description');
|
|
319
|
+
}
|
|
320
|
+
const sourceRecord = await foundation.readExact(source, access);
|
|
321
|
+
if (!sourceRecord)
|
|
322
|
+
throw new CompanyOsEvaluationError('not_found', `Conversion source ${source.id}@${source.revision} was not found`);
|
|
323
|
+
if (sourceRecord.definition.type !== 'variable') {
|
|
324
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Conversion source ${source.id}@${source.revision} is not a Variable`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const target = conversion?.targetRef ?? input.variableRef;
|
|
328
|
+
const matches = variables.filter((candidate) => sameFoundationRef(candidate.reference, target));
|
|
329
|
+
if (matches.length !== 1) {
|
|
330
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Measurement ${input.variableRef.id}@${input.variableRef.revision} does not match a pinned criterion definition; an explicit conversion is required`);
|
|
331
|
+
}
|
|
332
|
+
if (conversion !== undefined && !sameFoundationRef(conversion.targetRef, matches[0].reference)) {
|
|
333
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Conversion target does not match the pinned criterion definition');
|
|
334
|
+
}
|
|
335
|
+
return matches[0];
|
|
336
|
+
}
|
|
337
|
+
function normalizeMeasurement(input, variable, expectedPeriod) {
|
|
338
|
+
if (!input || typeof input !== 'object')
|
|
339
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Measurement must be an object');
|
|
340
|
+
if (!input.descriptor || typeof input.descriptor !== 'object' || Array.isArray(input.descriptor)
|
|
341
|
+
|| Object.prototype.hasOwnProperty.call(input.descriptor, 'variableRef')) {
|
|
342
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Measurement descriptor must not carry a variableRef');
|
|
343
|
+
}
|
|
344
|
+
if (!['observed', 'missing', 'not_arrived'].includes(input.status)) {
|
|
345
|
+
throw new CompanyOsEvaluationError('invalid_input', `Unknown measurement status ${String(input.status)}`);
|
|
346
|
+
}
|
|
347
|
+
if (!parseIsoTimestamp(input.recordedAt)) {
|
|
348
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Measurement recordedAt must be an RFC3339 timestamp');
|
|
349
|
+
}
|
|
350
|
+
if (input.status === 'observed' && input.value === undefined) {
|
|
351
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Observed measurements require a value');
|
|
352
|
+
}
|
|
353
|
+
if (input.status !== 'observed' && input.value !== undefined) {
|
|
354
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Missing or not-arrived measurements cannot carry a value');
|
|
355
|
+
}
|
|
356
|
+
const descriptor = {
|
|
357
|
+
variableRef: {
|
|
358
|
+
id: variable.reference.id,
|
|
359
|
+
type: 'variable',
|
|
360
|
+
revision: variable.reference.revision
|
|
361
|
+
},
|
|
362
|
+
...input.descriptor
|
|
363
|
+
};
|
|
364
|
+
const compatibility = validateEvaluationCompatibility(variable.definition, descriptor, { period: expectedPeriod });
|
|
365
|
+
// A measurement may not silently use a different period or definition. The
|
|
366
|
+
// caller can only use a different definition through an explicit conversion
|
|
367
|
+
// whose effective descriptor still matches the pinned criterion.
|
|
368
|
+
if (compatibility.issues.length > 0) {
|
|
369
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', compatibility.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; '));
|
|
370
|
+
}
|
|
371
|
+
assertValueKind(input.value, variable.definition.valueKind, input.status);
|
|
372
|
+
return deepFreeze({
|
|
373
|
+
variableRef: cloneFoundationRef(input.variableRef),
|
|
374
|
+
effectiveVariableRef: cloneFoundationRef(variable.reference),
|
|
375
|
+
descriptor: cloneJson(input.descriptor),
|
|
376
|
+
status: input.status,
|
|
377
|
+
...(input.value === undefined ? {} : { value: input.value }),
|
|
378
|
+
recordedAt: input.recordedAt,
|
|
379
|
+
...(input.evidenceRef === undefined ? {} : { evidenceRef: input.evidenceRef }),
|
|
380
|
+
...(input.conversion === undefined ? {} : { conversion: cloneJson(input.conversion) })
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
function buildEvaluationRecord(input) {
|
|
384
|
+
if (!isNonEmptyString(input.id))
|
|
385
|
+
throw new CompanyOsEvaluationError('invalid_input', 'evaluation id is required');
|
|
386
|
+
if (!parseIsoTimestamp(input.evaluatedAt))
|
|
387
|
+
throw new CompanyOsEvaluationError('invalid_input', 'evaluatedAt must be an RFC3339 timestamp');
|
|
388
|
+
const criteria = [];
|
|
389
|
+
const predictionComparisons = [];
|
|
390
|
+
for (const variable of input.resolved.variables) {
|
|
391
|
+
const actual = input.actuals.byVariable.get(foundationReferenceKey(variable.reference));
|
|
392
|
+
const prediction = input.predictions.byVariable.get(foundationReferenceKey(variable.reference));
|
|
393
|
+
criteria.push(evaluateCriterion(variable, actual, input.evaluatedAt));
|
|
394
|
+
predictionComparisons.push(comparePrediction(variable, prediction, actual, input.evaluatedAt));
|
|
395
|
+
}
|
|
396
|
+
const achievement = aggregateAchievement(criteria);
|
|
397
|
+
return deepFreeze({
|
|
398
|
+
version: COMPANY_OS_EVALUATION_RECORD_CATALOG_VERSION,
|
|
399
|
+
id: input.id,
|
|
400
|
+
snapshot: {
|
|
401
|
+
snapshotId: input.resolved.snapshotId,
|
|
402
|
+
problemId: input.resolved.snapshot.problem_id,
|
|
403
|
+
revision: input.resolved.snapshot.revision,
|
|
404
|
+
digest: input.resolved.snapshotId
|
|
405
|
+
},
|
|
406
|
+
objectiveRef: cloneFoundationRef(input.resolved.objectiveRef),
|
|
407
|
+
outcomeCaseRef: cloneCaseReference(input.outcomeCase.reference),
|
|
408
|
+
predictions: input.predictions.measurements,
|
|
409
|
+
actuals: input.actuals.measurements,
|
|
410
|
+
criteria,
|
|
411
|
+
predictionComparisons,
|
|
412
|
+
achievement,
|
|
413
|
+
judgmentAtTimeValidity: input.validity,
|
|
414
|
+
evaluatedAt: input.evaluatedAt
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
function evaluateCriterion(variable, actual, evaluatedAt) {
|
|
418
|
+
const base = {
|
|
419
|
+
criterionIndex: variable.criterionIndex,
|
|
420
|
+
variableRef: cloneFoundationRef(variable.reference),
|
|
421
|
+
operator: variable.criterion.operator,
|
|
422
|
+
...(variable.criterion.target === undefined ? {} : { target: variable.criterion.target })
|
|
423
|
+
};
|
|
424
|
+
if (!actual)
|
|
425
|
+
return { ...base, status: 'indeterminate', reason: 'actual_measurement_missing' };
|
|
426
|
+
if (actual.status !== 'observed')
|
|
427
|
+
return { ...base, status: 'indeterminate', reason: `actual_${actual.status}` };
|
|
428
|
+
if (Date.parse(actual.descriptor.period.until) > Date.parse(evaluatedAt)) {
|
|
429
|
+
return { ...base, status: 'indeterminate', reason: 'evaluation_period_not_complete' };
|
|
430
|
+
}
|
|
431
|
+
const target = variable.criterion.target;
|
|
432
|
+
if (target === undefined || actual.value === undefined) {
|
|
433
|
+
return { ...base, status: 'indeterminate', reason: 'criterion_value_unavailable' };
|
|
434
|
+
}
|
|
435
|
+
const achieved = variable.criterion.operator === 'at_least'
|
|
436
|
+
? typeof actual.value === 'number' && typeof target === 'number' && actual.value >= target
|
|
437
|
+
: variable.criterion.operator === 'at_most'
|
|
438
|
+
? typeof actual.value === 'number' && typeof target === 'number' && actual.value <= target
|
|
439
|
+
: Object.is(actual.value, target);
|
|
440
|
+
return {
|
|
441
|
+
...base,
|
|
442
|
+
status: achieved ? 'achieved' : 'not_achieved',
|
|
443
|
+
reason: achieved ? 'criterion_satisfied' : 'criterion_not_satisfied'
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
function comparePrediction(variable, prediction, actual, evaluatedAt) {
|
|
447
|
+
const base = { criterionIndex: variable.criterionIndex, variableRef: cloneFoundationRef(variable.reference) };
|
|
448
|
+
if (!prediction || !actual)
|
|
449
|
+
return { ...base, status: 'indeterminate', reason: 'prediction_or_actual_missing' };
|
|
450
|
+
if (prediction.status !== 'observed' || actual.status !== 'observed') {
|
|
451
|
+
return {
|
|
452
|
+
...base,
|
|
453
|
+
status: 'indeterminate',
|
|
454
|
+
reason: 'prediction_or_actual_not_observed',
|
|
455
|
+
predictionStatus: prediction.status,
|
|
456
|
+
actualStatus: actual.status
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
if (Date.parse(actual.descriptor.period.until) > Date.parse(evaluatedAt)) {
|
|
460
|
+
return {
|
|
461
|
+
...base,
|
|
462
|
+
status: 'indeterminate',
|
|
463
|
+
reason: 'evaluation_period_not_complete',
|
|
464
|
+
predictionStatus: prediction.status,
|
|
465
|
+
actualStatus: actual.status,
|
|
466
|
+
predictionValue: prediction.value,
|
|
467
|
+
actualValue: actual.value
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const matched = Object.is(prediction.value, actual.value);
|
|
471
|
+
return {
|
|
472
|
+
...base,
|
|
473
|
+
status: matched ? 'matched' : 'missed',
|
|
474
|
+
reason: matched ? 'prediction_matches_actual' : 'prediction_differs_from_actual',
|
|
475
|
+
predictionStatus: prediction.status,
|
|
476
|
+
actualStatus: actual.status,
|
|
477
|
+
predictionValue: prediction.value,
|
|
478
|
+
actualValue: actual.value
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
function aggregateAchievement(criteria) {
|
|
482
|
+
if (criteria.some((criterion) => criterion.status === 'indeterminate'))
|
|
483
|
+
return 'indeterminate';
|
|
484
|
+
return criteria.every((criterion) => criterion.status === 'achieved') ? 'achieved' : 'not_achieved';
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Rebuild all derived fields before returning an immutable record. The
|
|
488
|
+
* sidecar is deliberately treated as untrusted evidence: a syntactically
|
|
489
|
+
* valid JSON edit must not be able to turn a failed criterion into success or
|
|
490
|
+
* make a measurement refer to a different Foundation revision.
|
|
491
|
+
*/
|
|
492
|
+
async function assertEvaluationRecordConsistency(record, objectiveRecord, foundation, access) {
|
|
493
|
+
if (objectiveRecord.definition.type !== 'objective') {
|
|
494
|
+
throw new CompanyOsEvaluationError('corrupt_record', 'Evaluation objective has the wrong type');
|
|
495
|
+
}
|
|
496
|
+
const objectiveReference = foundationRef(objectiveRecord);
|
|
497
|
+
if (!sameFoundationRef(record.objectiveRef, objectiveReference)) {
|
|
498
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Evaluation objective reference does not match the canonical definition');
|
|
499
|
+
}
|
|
500
|
+
const objective = objectiveRecord.definition;
|
|
501
|
+
const objectiveValidation = validateFoundationDefinition(objective, { use: 'evaluation' });
|
|
502
|
+
if (!objectiveValidation.valid) {
|
|
503
|
+
throw new CompanyOsEvaluationError('corrupt_record', `Evaluation objective is no longer evaluable: ${objectiveValidation.issues.map((issue) => issue.message).join('; ')}`);
|
|
504
|
+
}
|
|
505
|
+
if (record.criteria.length !== objective.criteria.length || record.criteria.length === 0
|
|
506
|
+
|| record.predictionComparisons.length !== objective.criteria.length) {
|
|
507
|
+
throw new CompanyOsEvaluationError('corrupt_record', 'Evaluation derived criteria do not match the Objective');
|
|
508
|
+
}
|
|
509
|
+
const variables = [];
|
|
510
|
+
for (const [criterionIndex, criterion] of objective.criteria.entries()) {
|
|
511
|
+
const storedCriterion = record.criteria[criterionIndex];
|
|
512
|
+
if (!storedCriterion || storedCriterion.criterionIndex !== criterionIndex
|
|
513
|
+
|| storedCriterion.variableRef.type !== 'variable'
|
|
514
|
+
|| storedCriterion.variableRef.id !== criterion.variableRef.id
|
|
515
|
+
|| storedCriterion.variableRef.revision !== criterion.variableRef.revision
|
|
516
|
+
|| storedCriterion.operator !== criterion.operator
|
|
517
|
+
|| !sameOptionalScalar(storedCriterion.target, criterion.target)) {
|
|
518
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Evaluation criterion ${criterionIndex} does not match the Objective`);
|
|
519
|
+
}
|
|
520
|
+
const variableRecord = await foundation.readExact(storedCriterion.variableRef, access);
|
|
521
|
+
if (!variableRecord) {
|
|
522
|
+
throw new CompanyOsEvaluationError('corrupt_record', `Variable for evaluation criterion ${criterionIndex} is missing`);
|
|
523
|
+
}
|
|
524
|
+
if (variableRecord.definition.type !== 'variable') {
|
|
525
|
+
throw new CompanyOsEvaluationError('corrupt_record', `Evaluation criterion ${criterionIndex} resolved to ${variableRecord.definition.type}`);
|
|
526
|
+
}
|
|
527
|
+
const variableValidation = validateFoundationDefinition(variableRecord.definition, { use: 'evaluation' });
|
|
528
|
+
if (!variableValidation.valid) {
|
|
529
|
+
throw new CompanyOsEvaluationError('corrupt_record', `Variable for evaluation criterion ${criterionIndex} is not evaluable: ${variableValidation.issues.map((issue) => issue.message).join('; ')}`);
|
|
530
|
+
}
|
|
531
|
+
try {
|
|
532
|
+
assertCriterionCompatibility(criterion, variableRecord.definition, criterionIndex);
|
|
533
|
+
}
|
|
534
|
+
catch (error) {
|
|
535
|
+
throw new CompanyOsEvaluationError('corrupt_record', formatError(error));
|
|
536
|
+
}
|
|
537
|
+
variables.push({
|
|
538
|
+
criterionIndex,
|
|
539
|
+
criterion,
|
|
540
|
+
reference: foundationRef(variableRecord),
|
|
541
|
+
definition: variableRecord.definition,
|
|
542
|
+
record: variableRecord
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
const predictions = await validateStoredMeasurementSet(record.predictions, variables, foundation, access, objective.evaluationPeriod, 'prediction');
|
|
546
|
+
const actuals = await validateStoredMeasurementSet(record.actuals, variables, foundation, access, objective.evaluationPeriod, 'actual');
|
|
547
|
+
const expectedCriteria = variables.map((variable) => evaluateCriterion(variable, actuals.byVariable.get(foundationReferenceKey(variable.reference)), record.evaluatedAt));
|
|
548
|
+
const expectedComparisons = variables.map((variable) => comparePrediction(variable, predictions.byVariable.get(foundationReferenceKey(variable.reference)), actuals.byVariable.get(foundationReferenceKey(variable.reference)), record.evaluatedAt));
|
|
549
|
+
if (!sameJson(record.criteria, expectedCriteria)
|
|
550
|
+
|| !sameJson(record.predictionComparisons, expectedComparisons)
|
|
551
|
+
|| record.achievement !== aggregateAchievement(expectedCriteria)) {
|
|
552
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Evaluation derived results do not match its measurements and Objective');
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async function validateStoredMeasurementSet(measurements, variables, foundation, access, expectedPeriod, phase) {
|
|
556
|
+
const byVariable = new Map();
|
|
557
|
+
for (const [index, measurement] of measurements.entries()) {
|
|
558
|
+
const variable = variables.find((candidate) => sameFoundationRef(candidate.reference, measurement.effectiveVariableRef));
|
|
559
|
+
if (!variable) {
|
|
560
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${phase} measurement ${index} is not pinned to an Objective criterion`);
|
|
561
|
+
}
|
|
562
|
+
if (measurement.conversion !== undefined) {
|
|
563
|
+
const sourceRecord = await foundation.readExact(measurement.conversion.sourceRef, access);
|
|
564
|
+
if (!sourceRecord || sourceRecord.definition.type !== 'variable') {
|
|
565
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${phase} measurement ${index} conversion source is missing or is not a Variable`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
else if (!sameFoundationRef(measurement.variableRef, variable.reference)) {
|
|
569
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${phase} measurement ${index} changed definition without conversion provenance`);
|
|
570
|
+
}
|
|
571
|
+
const descriptor = {
|
|
572
|
+
variableRef: {
|
|
573
|
+
id: variable.reference.id,
|
|
574
|
+
type: 'variable',
|
|
575
|
+
revision: variable.reference.revision
|
|
576
|
+
},
|
|
577
|
+
...measurement.descriptor
|
|
578
|
+
};
|
|
579
|
+
const compatibility = validateEvaluationCompatibility(variable.definition, descriptor, { period: expectedPeriod });
|
|
580
|
+
if (compatibility.issues.length > 0) {
|
|
581
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${phase} measurement ${index} is incompatible: ${compatibility.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ')}`);
|
|
582
|
+
}
|
|
583
|
+
try {
|
|
584
|
+
assertValueKind(measurement.value, variable.definition.valueKind, measurement.status);
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', formatError(error));
|
|
588
|
+
}
|
|
589
|
+
const key = foundationReferenceKey(variable.reference);
|
|
590
|
+
if (byVariable.has(key)) {
|
|
591
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${phase} contains duplicate measurements for ${key}`);
|
|
592
|
+
}
|
|
593
|
+
byVariable.set(key, measurement);
|
|
594
|
+
}
|
|
595
|
+
return { measurements, byVariable };
|
|
596
|
+
}
|
|
597
|
+
function sameOptionalScalar(left, right) {
|
|
598
|
+
return left === undefined || right === undefined ? left === right : Object.is(left, right);
|
|
599
|
+
}
|
|
600
|
+
function sameJson(left, right) {
|
|
601
|
+
return JSON.stringify(sortJson(left)) === JSON.stringify(sortJson(right));
|
|
602
|
+
}
|
|
603
|
+
function sortJson(value) {
|
|
604
|
+
if (Array.isArray(value))
|
|
605
|
+
return value.map(sortJson);
|
|
606
|
+
if (!isRecord(value))
|
|
607
|
+
return value;
|
|
608
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
|
|
609
|
+
}
|
|
610
|
+
function assertCurrentFoundation(current, reference, expected, access) {
|
|
611
|
+
if (current.graph.version !== 2)
|
|
612
|
+
throw new CompanyOsEvaluationError('unsupported_graph', 'Foundation data requires Graph v2');
|
|
613
|
+
let actual;
|
|
614
|
+
let latest;
|
|
615
|
+
try {
|
|
616
|
+
const catalog = foundationCatalogOrEmpty(current.graph.foundation);
|
|
617
|
+
actual = findFoundationRecord(catalog, reference);
|
|
618
|
+
latest = findLatestFoundationRecord(catalog, reference.type, reference.id);
|
|
619
|
+
}
|
|
620
|
+
catch (error) {
|
|
621
|
+
throw new CompanyOsEvaluationError('corrupt_record', formatError(error));
|
|
622
|
+
}
|
|
623
|
+
if (!actual || !latest)
|
|
624
|
+
throw new CompanyOsEvaluationError('revision_conflict', `Foundation ${reference.type}/${reference.id}@${reference.revision} changed before evaluation commit`);
|
|
625
|
+
if (!aclAllowsRead(latest.definition.acl, access.principal)) {
|
|
626
|
+
throw new CompanyOsEvaluationError('authorization_denied', `Principal ${access.principal} cannot read ${reference.type}/${reference.id}`);
|
|
627
|
+
}
|
|
628
|
+
if (access.scope !== undefined && !scopeHasSubjectOverlap(latest.definition.scope, access.scope)) {
|
|
629
|
+
throw new CompanyOsEvaluationError('scope_violation', `Principal ${access.principal} cannot access ${reference.type}/${reference.id} outside the trusted scope`);
|
|
630
|
+
}
|
|
631
|
+
if (actual.digest !== expected.digest) {
|
|
632
|
+
throw new CompanyOsEvaluationError('revision_conflict', `Foundation ${reference.type}/${reference.id}@${reference.revision} changed before evaluation commit`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
async function readCanonicalOutcomeCase(port, input, access) {
|
|
636
|
+
assertOutcomeCaseReference(input);
|
|
637
|
+
let resolved;
|
|
638
|
+
try {
|
|
639
|
+
resolved = await port.read(input, access);
|
|
640
|
+
}
|
|
641
|
+
catch (error) {
|
|
642
|
+
throw error;
|
|
643
|
+
}
|
|
644
|
+
assertOutcomeCaseRead(resolved);
|
|
645
|
+
if (input.revision !== undefined && input.revision !== resolved.reference.revision) {
|
|
646
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'OutcomeCase resolver returned a different requested revision');
|
|
647
|
+
}
|
|
648
|
+
if (input.digest !== undefined && input.digest !== resolved.reference.digest) {
|
|
649
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'OutcomeCase resolver returned a different requested digest');
|
|
650
|
+
}
|
|
651
|
+
if (!aclAllowsRead(resolved.acl, access.principal)) {
|
|
652
|
+
throw new CompanyOsEvaluationError('authorization_denied', `Principal ${access.principal} cannot read OutcomeCase ${resolved.reference.id}`);
|
|
653
|
+
}
|
|
654
|
+
if (access.scope !== undefined && !scopeHasSubjectOverlap(resolved.scope, access.scope)) {
|
|
655
|
+
throw new CompanyOsEvaluationError('scope_violation', `Principal ${access.principal} cannot access OutcomeCase ${resolved.reference.id} outside the trusted scope`);
|
|
656
|
+
}
|
|
657
|
+
return {
|
|
658
|
+
reference: cloneCaseReference(resolved.reference),
|
|
659
|
+
source: cloneOutcomeCaseSource(resolved.source),
|
|
660
|
+
acl: cloneAcl(resolved.acl),
|
|
661
|
+
scope: cloneScope(resolved.scope)
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
function normalizeJudgmentValidity(input) {
|
|
665
|
+
if (!input || !['valid', 'invalid', 'indeterminate'].includes(input.status) || !isNonEmptyString(input.basis)) {
|
|
666
|
+
throw new CompanyOsEvaluationError('invalid_input', 'judgmentAtTimeValidity requires status and a non-empty basis');
|
|
667
|
+
}
|
|
668
|
+
if (input.evidenceRefs !== undefined && (!Array.isArray(input.evidenceRefs) || input.evidenceRefs.some((ref) => !isNonEmptyString(ref)))) {
|
|
669
|
+
throw new CompanyOsEvaluationError('invalid_input', 'judgmentAtTimeValidity.evidenceRefs must contain non-empty strings');
|
|
670
|
+
}
|
|
671
|
+
return deepFreeze({
|
|
672
|
+
status: input.status,
|
|
673
|
+
basis: input.basis,
|
|
674
|
+
...(input.evidenceRefs === undefined ? {} : { evidenceRefs: [...input.evidenceRefs] })
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
function assertEvaluationRequest(request) {
|
|
678
|
+
if (!request || typeof request !== 'object')
|
|
679
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Evaluation request is required');
|
|
680
|
+
if (!isNonEmptyString(request.id))
|
|
681
|
+
throw new CompanyOsEvaluationError('invalid_input', 'Evaluation id is required');
|
|
682
|
+
if (!request.snapshot || !isNonEmptyString(request.snapshot.root) || !request.snapshot.referenceProvider) {
|
|
683
|
+
throw new CompanyOsEvaluationError('invalid_input', 'A snapshot root and referenceProvider are required');
|
|
684
|
+
}
|
|
685
|
+
if (!Array.isArray(request.predictions) || !Array.isArray(request.actuals)) {
|
|
686
|
+
throw new CompanyOsEvaluationError('invalid_input', 'predictions and actuals must be arrays');
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
function assertOutcomeCaseReference(input) {
|
|
690
|
+
if (!input || !isNonEmptyString(input.id))
|
|
691
|
+
throw new CompanyOsEvaluationError('invalid_input', 'OutcomeCase id is required');
|
|
692
|
+
if (input.revision !== undefined && !/^[1-9]\d*$/.test(input.revision))
|
|
693
|
+
throw new CompanyOsEvaluationError('invalid_input', 'OutcomeCase revision must be positive');
|
|
694
|
+
if (input.digest !== undefined && !isDigest(input.digest))
|
|
695
|
+
throw new CompanyOsEvaluationError('invalid_input', 'OutcomeCase digest must be sha256:<64 lowercase hex>');
|
|
696
|
+
}
|
|
697
|
+
function assertOutcomeCaseRead(value) {
|
|
698
|
+
if (!value || typeof value !== 'object' || !value.reference || !isNonEmptyString(value.reference.id)
|
|
699
|
+
|| !/^[1-9]\d*$/.test(value.reference.revision) || !isDigest(value.reference.digest)
|
|
700
|
+
|| !isValidAcl(value.acl) || !isValidScope(value.scope)) {
|
|
701
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'OutcomeCase resolver returned invalid canonical metadata');
|
|
702
|
+
}
|
|
703
|
+
assertOutcomeCaseSource(value.source);
|
|
704
|
+
}
|
|
705
|
+
function assertOutcomeCaseSource(value) {
|
|
706
|
+
if (!isJsonPlainObject(value) || !isNonEmptyString(value.state) || !Array.isArray(value.owner_refs)) {
|
|
707
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'OutcomeCase resolver returned invalid canonical source metadata');
|
|
708
|
+
}
|
|
709
|
+
value.owner_refs.forEach((owner, index) => assertOutcomeCaseOwnerReference(owner, `source.owner_refs[${index}]`));
|
|
710
|
+
if (value.conditions !== undefined)
|
|
711
|
+
assertOutcomeCaseConditions(value.conditions, 'source.conditions');
|
|
712
|
+
}
|
|
713
|
+
function assertOutcomeCaseOwnerReference(value, label) {
|
|
714
|
+
if (!isJsonPlainObject(value) || (value.status !== 'typed' && value.status !== 'unknown')) {
|
|
715
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label} has an invalid status`);
|
|
716
|
+
}
|
|
717
|
+
if (value.status === 'unknown') {
|
|
718
|
+
if (!['not_recorded', 'legacy_untyped', 'source_unavailable'].includes(String(value.reason))) {
|
|
719
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label} has an invalid unknown reason`);
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
if (!isJsonPlainObject(value.ref)
|
|
724
|
+
|| !isNonEmptyString(value.ref.id)
|
|
725
|
+
|| !isNonEmptyString(value.ref.type)
|
|
726
|
+
|| (value.ref.revision !== undefined && !isNonEmptyString(value.ref.revision))
|
|
727
|
+
|| (value.ref.digest !== undefined && !isNonEmptyString(value.ref.digest))) {
|
|
728
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label}.ref has an invalid shape`);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
function assertOutcomeCaseConditions(value, label) {
|
|
732
|
+
if (!isJsonPlainObject(value)) {
|
|
733
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label} must be an object`);
|
|
734
|
+
}
|
|
735
|
+
assertOutcomeCaseConditionValue(value, label);
|
|
736
|
+
}
|
|
737
|
+
function assertOutcomeCaseConditionValue(value, label) {
|
|
738
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean')
|
|
739
|
+
return;
|
|
740
|
+
if (typeof value === 'number') {
|
|
741
|
+
if (Number.isFinite(value))
|
|
742
|
+
return;
|
|
743
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label} contains a non-finite number`);
|
|
744
|
+
}
|
|
745
|
+
if (Array.isArray(value)) {
|
|
746
|
+
value.forEach((item, index) => assertOutcomeCaseConditionValue(item, `${label}[${index}]`));
|
|
747
|
+
return;
|
|
748
|
+
}
|
|
749
|
+
if (isJsonPlainObject(value)) {
|
|
750
|
+
for (const [key, item] of Object.entries(value)) {
|
|
751
|
+
if (!isNonEmptyString(key)) {
|
|
752
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label} contains an empty key`);
|
|
753
|
+
}
|
|
754
|
+
assertOutcomeCaseConditionValue(item, `${label}.${key}`);
|
|
755
|
+
}
|
|
756
|
+
return;
|
|
757
|
+
}
|
|
758
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `${label} contains a non-JSON value`);
|
|
759
|
+
}
|
|
760
|
+
function assertCriterionCompatibility(criterion, variable, index) {
|
|
761
|
+
if (criterion.target === undefined)
|
|
762
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Criterion ${index} has no target`);
|
|
763
|
+
if ((criterion.operator === 'at_least' || criterion.operator === 'at_most')
|
|
764
|
+
&& (variable.valueKind !== 'number' || typeof criterion.target !== 'number' || !Number.isFinite(criterion.target))) {
|
|
765
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Criterion ${index} requires a numeric Variable and finite number target`);
|
|
766
|
+
}
|
|
767
|
+
if (criterion.operator === 'equals' && !valueMatchesKind(criterion.target, variable.valueKind)) {
|
|
768
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Criterion ${index} target does not match Variable valueKind`);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
function assertValueKind(value, kind, status) {
|
|
772
|
+
if (value === undefined || status !== 'observed')
|
|
773
|
+
return;
|
|
774
|
+
if (!valueMatchesKind(value, kind))
|
|
775
|
+
throw new CompanyOsEvaluationError('incompatible_measurement', `Measurement value does not match Variable valueKind ${kind}`);
|
|
776
|
+
}
|
|
777
|
+
function valueMatchesKind(value, kind) {
|
|
778
|
+
if (kind === 'number')
|
|
779
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
780
|
+
if (kind === 'boolean')
|
|
781
|
+
return typeof value === 'boolean';
|
|
782
|
+
return typeof value === 'string';
|
|
783
|
+
}
|
|
784
|
+
function foundationReference(reference) {
|
|
785
|
+
if (!['objective', 'variable', 'model', 'constraint'].includes(reference.kind)) {
|
|
786
|
+
throw new CompanyOsEvaluationError('invalid_input', `Reference ${reference.kind} is not a Foundation definition`);
|
|
787
|
+
}
|
|
788
|
+
return {
|
|
789
|
+
id: reference.id,
|
|
790
|
+
type: reference.kind,
|
|
791
|
+
revision: reference.revision,
|
|
792
|
+
digest: reference.digest
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
function assertFoundationRef(value, label) {
|
|
796
|
+
if (!value || typeof value !== 'object' || !isNonEmptyString(value.id)
|
|
797
|
+
|| !['objective', 'variable', 'model', 'constraint'].includes(value.type)
|
|
798
|
+
|| !/^[1-9]\d*$/.test(String(value.revision))
|
|
799
|
+
|| !isDigest(value.digest)) {
|
|
800
|
+
throw new CompanyOsEvaluationError('invalid_input', `${label} must contain id, type, revision, and digest`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function assertUniqueReferences(references, label) {
|
|
804
|
+
const keys = new Set();
|
|
805
|
+
for (const reference of references) {
|
|
806
|
+
const key = `${reference.id}@${reference.revision}:${reference.digest}`;
|
|
807
|
+
if (keys.has(key))
|
|
808
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', `Duplicate ${label} reference ${key}`);
|
|
809
|
+
keys.add(key);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
function assertAccessContext(value) {
|
|
813
|
+
if (!value || !isNonEmptyString(value.principal))
|
|
814
|
+
throw new CompanyOsEvaluationError('authorization_denied', 'A trusted principal is required');
|
|
815
|
+
return { principal: value.principal, ...(value.scope === undefined ? {} : { scope: cloneScope(value.scope) }) };
|
|
816
|
+
}
|
|
817
|
+
function foundationReferenceKey(reference) {
|
|
818
|
+
return JSON.stringify([reference.type, reference.id, reference.revision, reference.digest]);
|
|
819
|
+
}
|
|
820
|
+
function sameFoundationRef(left, right) {
|
|
821
|
+
return left.type === right.type && left.id === right.id && left.revision === right.revision && left.digest === right.digest;
|
|
822
|
+
}
|
|
823
|
+
function sameCaseReference(left, right) {
|
|
824
|
+
return left.id === right.id && left.revision === right.revision && left.digest === right.digest;
|
|
825
|
+
}
|
|
826
|
+
function cloneFoundationRef(reference) {
|
|
827
|
+
return { id: reference.id, type: reference.type, revision: reference.revision, digest: reference.digest };
|
|
828
|
+
}
|
|
829
|
+
function cloneCaseReference(reference) {
|
|
830
|
+
return { id: reference.id, revision: reference.revision, digest: reference.digest };
|
|
831
|
+
}
|
|
832
|
+
function cloneOutcomeCaseSource(source) {
|
|
833
|
+
return {
|
|
834
|
+
state: source.state,
|
|
835
|
+
owner_refs: source.owner_refs.map((owner) => owner.status === 'typed'
|
|
836
|
+
? {
|
|
837
|
+
status: 'typed',
|
|
838
|
+
ref: {
|
|
839
|
+
id: owner.ref.id,
|
|
840
|
+
type: owner.ref.type,
|
|
841
|
+
...(owner.ref.revision === undefined ? {} : { revision: owner.ref.revision }),
|
|
842
|
+
...(owner.ref.digest === undefined ? {} : { digest: owner.ref.digest })
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
: { status: 'unknown', reason: owner.reason }),
|
|
846
|
+
...(source.conditions === undefined ? {} : { conditions: cloneOutcomeCaseConditions(source.conditions) })
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
function cloneOutcomeCaseConditions(conditions) {
|
|
850
|
+
return cloneOutcomeCaseConditionValue(conditions);
|
|
851
|
+
}
|
|
852
|
+
function cloneOutcomeCaseConditionValue(value) {
|
|
853
|
+
if (Array.isArray(value))
|
|
854
|
+
return value.map(cloneOutcomeCaseConditionValue);
|
|
855
|
+
if (isRecord(value)) {
|
|
856
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneOutcomeCaseConditionValue(child)]));
|
|
857
|
+
}
|
|
858
|
+
return value;
|
|
859
|
+
}
|
|
860
|
+
function cloneEvaluationMeasurement(measurement) {
|
|
861
|
+
return deepFreeze(cloneJson(measurement));
|
|
862
|
+
}
|
|
863
|
+
function cloneEvaluationRecord(record) {
|
|
864
|
+
return deepFreeze(cloneJson(record));
|
|
865
|
+
}
|
|
866
|
+
function cloneAcl(acl) {
|
|
867
|
+
return { ownerId: acl.ownerId, visibility: acl.visibility, readerIds: [...acl.readerIds], writerIds: [...acl.writerIds] };
|
|
868
|
+
}
|
|
869
|
+
function cloneScope(scope) {
|
|
870
|
+
return { subjectIds: [...scope.subjectIds], validFrom: scope.validFrom, ...(scope.validUntil === undefined ? {} : { validUntil: scope.validUntil }) };
|
|
871
|
+
}
|
|
872
|
+
function aclAllowsRead(acl, principal) {
|
|
873
|
+
return acl.visibility === 'public' || acl.ownerId === principal || acl.readerIds.includes(principal) || acl.writerIds.includes(principal);
|
|
874
|
+
}
|
|
875
|
+
function scopeHasSubjectOverlap(resource, trusted) {
|
|
876
|
+
const trustedSubjects = new Set(trusted.subjectIds);
|
|
877
|
+
return resource.subjectIds.some((subjectId) => trustedSubjects.has(subjectId));
|
|
878
|
+
}
|
|
879
|
+
function isDigest(value) {
|
|
880
|
+
return typeof value === 'string' && /^sha256:[0-9a-f]{64}$/.test(value);
|
|
881
|
+
}
|
|
882
|
+
function isValidAcl(value) {
|
|
883
|
+
if (!value || typeof value !== 'object')
|
|
884
|
+
return false;
|
|
885
|
+
const candidate = value;
|
|
886
|
+
return isNonEmptyString(candidate.ownerId)
|
|
887
|
+
&& ['private', 'project', 'organization', 'public'].includes(candidate.visibility)
|
|
888
|
+
&& Array.isArray(candidate.readerIds) && candidate.readerIds.every(isNonEmptyString)
|
|
889
|
+
&& Array.isArray(candidate.writerIds) && candidate.writerIds.every(isNonEmptyString);
|
|
890
|
+
}
|
|
891
|
+
function isValidScope(value) {
|
|
892
|
+
if (!value || typeof value !== 'object')
|
|
893
|
+
return false;
|
|
894
|
+
const candidate = value;
|
|
895
|
+
const from = parseStrictRfc3339(candidate.validFrom);
|
|
896
|
+
const until = candidate.validUntil === undefined ? undefined : parseStrictRfc3339(candidate.validUntil);
|
|
897
|
+
return Array.isArray(candidate.subjectIds) && candidate.subjectIds.length > 0
|
|
898
|
+
&& candidate.subjectIds.every(isNonEmptyString)
|
|
899
|
+
&& from !== undefined
|
|
900
|
+
&& (candidate.validUntil === undefined || (until !== undefined
|
|
901
|
+
&& until > from));
|
|
902
|
+
}
|
|
903
|
+
function parseIsoTimestamp(value) {
|
|
904
|
+
return typeof value === 'string' && parseStrictRfc3339(value) !== undefined;
|
|
905
|
+
}
|
|
906
|
+
function parseStrictRfc3339(value) {
|
|
907
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
|
|
908
|
+
if (!match)
|
|
909
|
+
return undefined;
|
|
910
|
+
const year = Number(match[1]);
|
|
911
|
+
const month = Number(match[2]);
|
|
912
|
+
const day = Number(match[3]);
|
|
913
|
+
const hour = Number(match[4]);
|
|
914
|
+
const minute = Number(match[5]);
|
|
915
|
+
const second = Number(match[6]);
|
|
916
|
+
const fraction = match[7] ?? '';
|
|
917
|
+
const zone = match[8];
|
|
918
|
+
if (month < 1 || month > 12 || day < 1 || day > daysInMonth(year, month))
|
|
919
|
+
return undefined;
|
|
920
|
+
if (hour > 23 || minute > 59 || second > 59)
|
|
921
|
+
return undefined;
|
|
922
|
+
let offsetMinutes = 0;
|
|
923
|
+
if (zone !== 'Z') {
|
|
924
|
+
const sign = zone[0] === '-' ? -1 : 1;
|
|
925
|
+
const offsetHour = Number(zone.slice(1, 3));
|
|
926
|
+
const offsetMinute = Number(zone.slice(4, 6));
|
|
927
|
+
if (offsetHour > 23 || offsetMinute > 59)
|
|
928
|
+
return undefined;
|
|
929
|
+
offsetMinutes = sign * (offsetHour * 60 + offsetMinute);
|
|
930
|
+
}
|
|
931
|
+
const milliseconds = Number((fraction + '000').slice(0, 3));
|
|
932
|
+
const timestamp = Date.UTC(year, month - 1, day, hour, minute, second, milliseconds)
|
|
933
|
+
- offsetMinutes * 60_000;
|
|
934
|
+
return Number.isFinite(timestamp) ? timestamp : undefined;
|
|
935
|
+
}
|
|
936
|
+
function daysInMonth(year, month) {
|
|
937
|
+
if (month === 2) {
|
|
938
|
+
return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0) ? 29 : 28;
|
|
939
|
+
}
|
|
940
|
+
return [4, 6, 9, 11].includes(month) ? 30 : 31;
|
|
941
|
+
}
|
|
942
|
+
function isNonEmptyString(value) {
|
|
943
|
+
return typeof value === 'string' && value.trim().length > 0 && !value.includes('\0');
|
|
944
|
+
}
|
|
945
|
+
function cloneJson(value) {
|
|
946
|
+
return JSON.parse(JSON.stringify(value));
|
|
947
|
+
}
|
|
948
|
+
function deepFreeze(value) {
|
|
949
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value))
|
|
950
|
+
return value;
|
|
951
|
+
for (const child of Object.values(value))
|
|
952
|
+
deepFreeze(child);
|
|
953
|
+
return Object.freeze(value);
|
|
954
|
+
}
|
|
955
|
+
function isRecord(value) {
|
|
956
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
957
|
+
}
|
|
958
|
+
function isJsonPlainObject(value) {
|
|
959
|
+
if (!isRecord(value))
|
|
960
|
+
return false;
|
|
961
|
+
try {
|
|
962
|
+
const prototype = Object.getPrototypeOf(value);
|
|
963
|
+
return prototype === Object.prototype || prototype === null;
|
|
964
|
+
}
|
|
965
|
+
catch {
|
|
966
|
+
return false;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
function isEvaluationScalar(value) {
|
|
970
|
+
return typeof value === 'string'
|
|
971
|
+
|| typeof value === 'boolean'
|
|
972
|
+
|| (typeof value === 'number' && Number.isFinite(value));
|
|
973
|
+
}
|
|
974
|
+
function assertStoredMeasurement(value, label) {
|
|
975
|
+
if (!isRecord(value) || !value.variableRef || !value.effectiveVariableRef
|
|
976
|
+
|| !value.descriptor || !isNonEmptyString(value.status)
|
|
977
|
+
|| !parseIsoTimestamp(value.recordedAt)) {
|
|
978
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} is invalid`);
|
|
979
|
+
}
|
|
980
|
+
assertFoundationRef(value.variableRef, `${label}.variableRef`);
|
|
981
|
+
assertFoundationRef(value.effectiveVariableRef, `${label}.effectiveVariableRef`);
|
|
982
|
+
if (value.variableRef.type !== 'variable' || value.effectiveVariableRef.type !== 'variable') {
|
|
983
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} must reference Variables`);
|
|
984
|
+
}
|
|
985
|
+
if (!['observed', 'missing', 'not_arrived'].includes(value.status)) {
|
|
986
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.status is invalid`);
|
|
987
|
+
}
|
|
988
|
+
const descriptor = value.descriptor;
|
|
989
|
+
if (!isRecord(descriptor) || Object.prototype.hasOwnProperty.call(descriptor, 'variableRef')
|
|
990
|
+
|| (descriptor.unit !== undefined && typeof descriptor.unit !== 'string')
|
|
991
|
+
|| !isFoundationAggregation(descriptor.aggregation)
|
|
992
|
+
|| !isNonEmptyString(descriptor.granularity)
|
|
993
|
+
|| !isValidScope(descriptor.scope)
|
|
994
|
+
|| !isValidPeriod(descriptor.period)) {
|
|
995
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.descriptor is invalid`);
|
|
996
|
+
}
|
|
997
|
+
if (value.status === 'observed' && !isEvaluationScalar(value.value)) {
|
|
998
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} observed value is invalid`);
|
|
999
|
+
}
|
|
1000
|
+
if (value.status !== 'observed' && value.value !== undefined) {
|
|
1001
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} non-observed value must be absent`);
|
|
1002
|
+
}
|
|
1003
|
+
if (value.evidenceRef !== undefined && !isNonEmptyString(value.evidenceRef)) {
|
|
1004
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.evidenceRef is invalid`);
|
|
1005
|
+
}
|
|
1006
|
+
if (value.conversion !== undefined) {
|
|
1007
|
+
if (!isRecord(value.conversion) || !value.conversion.sourceRef || !value.conversion.targetRef
|
|
1008
|
+
|| !isRecord(value.conversion.provenance)) {
|
|
1009
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.conversion is invalid`);
|
|
1010
|
+
}
|
|
1011
|
+
assertFoundationRef(value.conversion.sourceRef, `${label}.conversion.sourceRef`);
|
|
1012
|
+
assertFoundationRef(value.conversion.targetRef, `${label}.conversion.targetRef`);
|
|
1013
|
+
if (value.conversion.sourceRef.type !== 'variable' || value.conversion.targetRef.type !== 'variable'
|
|
1014
|
+
|| !sameFoundationRef(value.conversion.sourceRef, value.variableRef)
|
|
1015
|
+
|| !sameFoundationRef(value.conversion.targetRef, value.effectiveVariableRef)
|
|
1016
|
+
|| !isNonEmptyString(value.conversion.provenance.id)
|
|
1017
|
+
|| !isNonEmptyString(value.conversion.provenance.description)) {
|
|
1018
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.conversion provenance or references are invalid`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
else if (!sameFoundationRef(value.variableRef, value.effectiveVariableRef)) {
|
|
1022
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} changed definition without conversion provenance`);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
function assertStoredCriterion(value, label) {
|
|
1026
|
+
if (!isRecord(value) || typeof value.criterionIndex !== 'number' || !Number.isInteger(value.criterionIndex) || value.criterionIndex < 0
|
|
1027
|
+
|| !value.variableRef || !isNonEmptyString(value.operator)
|
|
1028
|
+
|| !isNonEmptyString(value.status) || !isNonEmptyString(value.reason)) {
|
|
1029
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} is invalid`);
|
|
1030
|
+
}
|
|
1031
|
+
assertFoundationRef(value.variableRef, `${label}.variableRef`);
|
|
1032
|
+
if (value.variableRef.type !== 'variable' || !['at_least', 'at_most', 'equals'].includes(value.operator)
|
|
1033
|
+
|| !['achieved', 'not_achieved', 'indeterminate'].includes(value.status)) {
|
|
1034
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} contains an invalid criterion contract`);
|
|
1035
|
+
}
|
|
1036
|
+
if (value.target !== undefined && !isEvaluationScalar(value.target)) {
|
|
1037
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.target is invalid`);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function assertStoredPredictionComparison(value, label) {
|
|
1041
|
+
if (!isRecord(value) || typeof value.criterionIndex !== 'number' || !Number.isInteger(value.criterionIndex) || value.criterionIndex < 0
|
|
1042
|
+
|| !value.variableRef || !isNonEmptyString(value.status) || !isNonEmptyString(value.reason)) {
|
|
1043
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} is invalid`);
|
|
1044
|
+
}
|
|
1045
|
+
assertFoundationRef(value.variableRef, `${label}.variableRef`);
|
|
1046
|
+
if (value.variableRef.type !== 'variable' || !['matched', 'missed', 'indeterminate'].includes(value.status)) {
|
|
1047
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} contains an invalid comparison contract`);
|
|
1048
|
+
}
|
|
1049
|
+
for (const status of ['predictionStatus', 'actualStatus']) {
|
|
1050
|
+
const statusValue = value[status];
|
|
1051
|
+
if (statusValue !== undefined && (typeof statusValue !== 'string' || !['observed', 'missing', 'not_arrived'].includes(statusValue))) {
|
|
1052
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.${status} is invalid`);
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
for (const field of ['predictionValue', 'actualValue']) {
|
|
1056
|
+
if (value[field] !== undefined && !isEvaluationScalar(value[field])) {
|
|
1057
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label}.${field} is invalid`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function assertStoredJudgmentValidity(value, label) {
|
|
1062
|
+
if (!isRecord(value) || typeof value.status !== 'string' || !['valid', 'invalid', 'indeterminate'].includes(value.status)
|
|
1063
|
+
|| !isNonEmptyString(value.basis)
|
|
1064
|
+
|| (value.evidenceRefs !== undefined
|
|
1065
|
+
&& (!Array.isArray(value.evidenceRefs) || value.evidenceRefs.some((ref) => !isNonEmptyString(ref))))) {
|
|
1066
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} is invalid`);
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
function assertOutcomeCaseCanonicalReference(value, label) {
|
|
1070
|
+
if (!isRecord(value) || !isNonEmptyString(value.id) || !/^[1-9]\d*$/.test(String(value.revision)) || !isDigest(value.digest)) {
|
|
1071
|
+
throw new CompanyOsEvaluationError('corrupt_record', `${label} must contain a canonical id, revision, and digest`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function isFoundationAggregation(value) {
|
|
1075
|
+
return ['none', 'sum', 'average', 'count', 'min', 'max', 'last', 'custom'].includes(String(value));
|
|
1076
|
+
}
|
|
1077
|
+
function isValidPeriod(value) {
|
|
1078
|
+
if (!isRecord(value))
|
|
1079
|
+
return false;
|
|
1080
|
+
const from = typeof value.from === 'string' ? parseStrictRfc3339(value.from) : undefined;
|
|
1081
|
+
const until = typeof value.until === 'string' ? parseStrictRfc3339(value.until) : undefined;
|
|
1082
|
+
return isRecord(value)
|
|
1083
|
+
&& from !== undefined
|
|
1084
|
+
&& until !== undefined
|
|
1085
|
+
&& until > from;
|
|
1086
|
+
}
|
|
1087
|
+
function parseEvaluationCatalog(rawText) {
|
|
1088
|
+
if (rawText === undefined)
|
|
1089
|
+
return { version: COMPANY_OS_EVALUATION_RECORD_CATALOG_VERSION, records: [] };
|
|
1090
|
+
try {
|
|
1091
|
+
const raw = JSON.parse(rawText);
|
|
1092
|
+
assertEvaluationCatalog(raw);
|
|
1093
|
+
return deepFreeze(cloneJson(raw));
|
|
1094
|
+
}
|
|
1095
|
+
catch (error) {
|
|
1096
|
+
if (error instanceof CompanyOsEvaluationError) {
|
|
1097
|
+
// Persisted JSON is untrusted input. A shape helper may share the
|
|
1098
|
+
// request-side invalid_input code, but callers must see a corrupt record
|
|
1099
|
+
// when the sidecar itself is malformed.
|
|
1100
|
+
if (error.code === 'invalid_input') {
|
|
1101
|
+
throw new CompanyOsEvaluationError('corrupt_record', error.message);
|
|
1102
|
+
}
|
|
1103
|
+
throw error;
|
|
1104
|
+
}
|
|
1105
|
+
throw new CompanyOsEvaluationError('corrupt_record', formatError(error));
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
function assertEvaluationCatalog(value) {
|
|
1109
|
+
if (!value || typeof value !== 'object' || value.version !== COMPANY_OS_EVALUATION_RECORD_CATALOG_VERSION
|
|
1110
|
+
|| !Array.isArray(value.records)) {
|
|
1111
|
+
throw new CompanyOsEvaluationError('corrupt_record', 'Evaluation evidence must contain version 1 records');
|
|
1112
|
+
}
|
|
1113
|
+
const ids = new Set();
|
|
1114
|
+
for (const record of value.records) {
|
|
1115
|
+
if (!record || typeof record !== 'object' || !isNonEmptyString(record.id) || ids.has(record.id)
|
|
1116
|
+
|| record.version !== COMPANY_OS_EVALUATION_RECORD_CATALOG_VERSION
|
|
1117
|
+
|| !record.snapshot || !isNonEmptyString(record.snapshot.snapshotId)
|
|
1118
|
+
|| !isNonEmptyString(record.snapshot.problemId) || !/^[1-9]\d*$/.test(record.snapshot.revision)
|
|
1119
|
+
|| !isNonEmptyString(record.snapshot.digest) || !record.objectiveRef
|
|
1120
|
+
|| !record.outcomeCaseRef || !Array.isArray(record.predictions) || !Array.isArray(record.actuals)
|
|
1121
|
+
|| !Array.isArray(record.criteria) || !Array.isArray(record.predictionComparisons)
|
|
1122
|
+
|| !['achieved', 'not_achieved', 'indeterminate'].includes(record.achievement)
|
|
1123
|
+
|| !record.judgmentAtTimeValidity || !parseIsoTimestamp(record.evaluatedAt)) {
|
|
1124
|
+
throw new CompanyOsEvaluationError('corrupt_record', 'Evaluation evidence contains an invalid immutable record');
|
|
1125
|
+
}
|
|
1126
|
+
assertFoundationRef(record.objectiveRef, 'record.objectiveRef');
|
|
1127
|
+
assertOutcomeCaseCanonicalReference(record.outcomeCaseRef, 'record.outcomeCaseRef');
|
|
1128
|
+
if (!isDigest(record.snapshot.snapshotId) || !isDigest(record.snapshot.digest)) {
|
|
1129
|
+
throw new CompanyOsEvaluationError('corrupt_record', 'Evaluation snapshot ids must be sha256 digests');
|
|
1130
|
+
}
|
|
1131
|
+
if (record.snapshot.digest !== record.snapshot.snapshotId) {
|
|
1132
|
+
throw new CompanyOsEvaluationError('integrity_mismatch', 'Evaluation snapshot digest must equal its snapshot id');
|
|
1133
|
+
}
|
|
1134
|
+
if (record.criteria.length === 0 || record.criteria.length !== record.predictionComparisons.length) {
|
|
1135
|
+
throw new CompanyOsEvaluationError('corrupt_record', 'Evaluation criteria and prediction comparisons are inconsistent');
|
|
1136
|
+
}
|
|
1137
|
+
record.predictions.forEach((measurement, index) => assertStoredMeasurement(measurement, `record.predictions[${index}]`));
|
|
1138
|
+
record.actuals.forEach((measurement, index) => assertStoredMeasurement(measurement, `record.actuals[${index}]`));
|
|
1139
|
+
record.criteria.forEach((criterion, index) => assertStoredCriterion(criterion, `record.criteria[${index}]`));
|
|
1140
|
+
record.predictionComparisons.forEach((comparison, index) => assertStoredPredictionComparison(comparison, `record.predictionComparisons[${index}]`));
|
|
1141
|
+
assertStoredJudgmentValidity(record.judgmentAtTimeValidity, 'record.judgmentAtTimeValidity');
|
|
1142
|
+
ids.add(record.id);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
function serializeEvaluationCatalog(catalog) {
|
|
1146
|
+
return `${JSON.stringify(catalog, null, 2)}\n`;
|
|
1147
|
+
}
|
|
1148
|
+
function formatError(error) {
|
|
1149
|
+
return error instanceof Error ? error.message : String(error);
|
|
1150
|
+
}
|