@aiwg/cli 2026.9.24 → 2026.10.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/agentic/code/providers/capability-matrix.yaml +2 -1
- package/dist/src/artifacts/discovery-shadow-evaluator.js +177 -0
- package/dist/src/artifacts/discovery-shadow.js +195 -0
- package/dist/src/artifacts/index-builder.js +47 -9
- package/dist/src/artifacts/query-engine.js +94 -1
- package/dist/src/artifacts/types.js +1 -0
- package/dist/src/cli/handlers/decision.js +88 -0
- package/dist/src/cli/handlers/gates.js +154 -0
- package/dist/src/cli/handlers/index.js +5 -1
- package/dist/src/cli/handlers/sessions.js +10 -7
- package/dist/src/cli/handlers/utilities.js +20 -0
- package/dist/src/config/aiwg-config.js +15 -2
- package/dist/src/decision/acceptance.js +25 -0
- package/dist/src/decision/adapters/jev.js +24 -2
- package/dist/src/decision/calibration/registry.js +9 -0
- package/dist/src/decision/compose.js +74 -15
- package/dist/src/decision/context-live-promotion.js +344 -0
- package/dist/src/decision/context-live-qualification.js +591 -0
- package/dist/src/decision/context-pruning.js +847 -0
- package/dist/src/decision/driver.js +430 -0
- package/dist/src/decision/egress-live-qualification.js +874 -0
- package/dist/src/decision/ensemble/contract.js +64 -1
- package/dist/src/decision/ensemble/index.js +1 -0
- package/dist/src/decision/ensemble/report.js +8 -42
- package/dist/src/decision/ensemble/runtime.js +571 -0
- package/dist/src/decision/ensemble-study/artifacts.js +85 -0
- package/dist/src/decision/ensemble-study/calibration.js +443 -0
- package/dist/src/decision/ensemble-study/corpus.js +229 -0
- package/dist/src/decision/ensemble-study/multifact-artifacts.js +23 -0
- package/dist/src/decision/ensemble-study/multifact-study.js +596 -0
- package/dist/src/decision/ensemble-study/multifact.js +241 -0
- package/dist/src/decision/ensemble-study/protocol.js +77 -0
- package/dist/src/decision/ensemble-study/score.js +462 -0
- package/dist/src/decision/ensemble-study/staged.js +87 -0
- package/dist/src/decision/ensemble-study/statistics.js +84 -0
- package/dist/src/decision/evaluate.js +227 -14
- package/dist/src/decision/export.js +4 -0
- package/dist/src/decision/feature-export.js +1074 -0
- package/dist/src/decision/graph-index.js +16 -0
- package/dist/src/decision/graph-live-qualification.js +1041 -0
- package/dist/src/decision/graph-live-workload.js +245 -0
- package/dist/src/decision/graph-skill-bridge.js +21 -3
- package/dist/src/decision/heldout/calibration.js +140 -0
- package/dist/src/decision/heldout/collector.js +430 -0
- package/dist/src/decision/heldout/contract.js +309 -0
- package/dist/src/decision/heldout/d29-generator-ids.js +12 -0
- package/dist/src/decision/heldout/d29-passage-baseline-v2.js +93 -0
- package/dist/src/decision/heldout/d29-passage-baseline-v3.js +100 -0
- package/dist/src/decision/heldout/d29-passage-baseline.js +66 -0
- package/dist/src/decision/heldout/d29-pools-v8.js +575 -0
- package/dist/src/decision/heldout/d29-pools.js +288 -0
- package/dist/src/decision/heldout/d29-v6.js +169 -0
- package/dist/src/decision/heldout/d29-v7.js +202 -0
- package/dist/src/decision/heldout/d29-v8.js +586 -0
- package/dist/src/decision/heldout/generator-registry.js +67 -0
- package/dist/src/decision/heldout/generators.js +696 -0
- package/dist/src/decision/heldout/index.js +8 -0
- package/dist/src/decision/heldout/journal.js +346 -0
- package/dist/src/decision/heldout/study-generators.js +64 -0
- package/dist/src/decision/heldout/types.js +2 -0
- package/dist/src/decision/host-policy.js +61 -0
- package/dist/src/decision/index.js +11 -0
- package/dist/src/decision/interop-profile.v1.json +112 -0
- package/dist/src/decision/interop.js +653 -0
- package/dist/src/decision/issue-triage/index.js +3 -0
- package/dist/src/decision/issue-triage/runtime.js +1069 -0
- package/dist/src/decision/issue-triage/types.js +2 -0
- package/dist/src/decision/patterns/catalog.js +28 -8
- package/dist/src/decision/patterns/playground.js +28 -11
- package/dist/src/decision/patterns/specs.js +19 -10
- package/dist/src/decision/preprocessed-evidence.js +724 -0
- package/dist/src/decision/qualification/capture.js +8 -2
- package/dist/src/decision/qualification/gate-evidence.js +19 -4
- package/dist/src/decision/qualification/quality.js +57 -49
- package/dist/src/decision/qualification/release.js +76 -3
- package/dist/src/decision/qualification/runner.js +5 -2
- package/dist/src/decision/routing/contract.js +175 -0
- package/dist/src/decision/routing/index.js +5 -0
- package/dist/src/decision/routing/report.js +216 -0
- package/dist/src/decision/routing/runtime.js +685 -0
- package/dist/src/decision/routing/types.js +2 -0
- package/dist/src/decision/sdlc-screening.js +1157 -0
- package/dist/src/decision/sensitivity/comparative.js +154 -0
- package/dist/src/decision/sensitivity/contract.js +218 -0
- package/dist/src/decision/sensitivity/index.js +5 -0
- package/dist/src/decision/sensitivity/runtime.js +912 -0
- package/dist/src/decision/sensitivity/types.js +2 -0
- package/dist/src/decision/validate.js +26 -0
- package/dist/src/extensions/commands/definitions.js +38 -0
- package/dist/src/extensions/manifest.js +44 -0
- package/dist/src/gates/discovery.js +315 -0
- package/dist/src/gates/driver.js +388 -0
- package/dist/src/gates/evaluate.js +719 -0
- package/dist/src/gates/floors.js +255 -0
- package/dist/src/gates/index.js +17 -0
- package/dist/src/gates/providers/evidence.js +38 -0
- package/dist/src/gates/providers/index.js +21 -0
- package/dist/src/gates/providers/isolated-child-source.js +446 -0
- package/dist/src/gates/providers/loader.js +1069 -0
- package/dist/src/gates/providers/paired.js +52 -0
- package/dist/src/gates/providers/proportion.js +60 -0
- package/dist/src/gates/providers/registry.js +40 -0
- package/dist/src/gates/providers/scalar.js +41 -0
- package/dist/src/gates/providers/screening.js +53 -0
- package/dist/src/gates/providers/types.js +25 -0
- package/dist/src/gates/registry.js +1003 -0
- package/dist/src/gates/report.js +102 -0
- package/dist/src/gates/schema.js +56 -0
- package/dist/src/gates/stats/binomial.js +26 -0
- package/dist/src/gates/stats/bootstrap.js +62 -0
- package/dist/src/gates/stats/clopper-pearson.js +95 -0
- package/dist/src/gates/stats/digest.js +26 -0
- package/dist/src/gates/stats/error.js +5 -0
- package/dist/src/gates/stats/index.js +9 -0
- package/dist/src/gates/stats/normal.js +35 -0
- package/dist/src/gates/stats/paired.js +120 -0
- package/dist/src/gates/stats/splits.js +30 -0
- package/dist/src/gates/types.js +8 -0
- package/dist/src/governance/redaction.js +14 -4
- package/dist/src/mcp/muse-native-config.mjs +123 -0
- package/dist/src/mcp/tools/decision.mjs +188 -0
- package/dist/src/mcp/tools/subsystems.mjs +2 -0
- package/dist/src/providers/capability-matrix.yaml +2 -1
- package/dist/src/providers/provider-definitions.js +2 -1
- package/dist/src/sessions/adapters/muse.js +336 -39
- package/dist/src/sessions/batch-import.js +7 -0
- package/dist/src/sessions/workspace-discovery.js +13 -2
- package/package.json +1 -1
- package/schemas/decision/ConformalFrozenOpenData.v2.schema.json +371 -0
- package/schemas/decision/ConformalOpenDataReport.v2.schema.json +421 -0
- package/schemas/decision/ConformalPreregistration.v2.schema.json +529 -0
- package/schemas/decision/ContextPruningCandidate.v1.schema.json +47 -0
- package/schemas/decision/ContextPruningEvaluationReport.v1.schema.json +758 -0
- package/schemas/decision/ContextPruningPreregistration.v1.schema.json +175 -0
- package/schemas/decision/ContextPruningReceipt.v1.schema.json +72 -0
- package/schemas/decision/D17Multifact.v1.schema.json +1851 -0
- package/schemas/decision/D17StudyAnalysis.v1.schema.json +42 -0
- package/schemas/decision/D17StudyAnalysis.v2.schema.json +51 -0
- package/schemas/decision/D17StudyCalibration.v1.schema.json +819 -0
- package/schemas/decision/D17StudyDryRun.v1.schema.json +169 -0
- package/schemas/decision/D17StudyDryRun.v2.schema.json +239 -0
- package/schemas/decision/D17StudyGold.v1.schema.json +108 -0
- package/schemas/decision/D17StudyGuide.v1.schema.json +63 -0
- package/schemas/decision/D17StudyNativeTemplates.v1.schema.json +619 -0
- package/schemas/decision/D17StudyProtocol.v1.schema.json +241 -0
- package/schemas/decision/D17StudyReport.v1.schema.json +902 -0
- package/schemas/decision/D17StudyReport.v2.schema.json +1319 -0
- package/schemas/decision/D17StudyReview.v1.schema.json +175 -0
- package/schemas/decision/D17StudySplits.v1.schema.json +217 -0
- package/schemas/decision/D29Study.v1.schema.json +1903 -0
- package/schemas/decision/D29Study.v2.schema.json +1344 -0
- package/schemas/decision/D29Study.v3.schema.json +609 -0
- package/schemas/decision/D29Study.v4.schema.json +1133 -0
- package/schemas/decision/D29Study.v6.schema.json +1205 -0
- package/schemas/decision/D29Study.v7.schema.json +1471 -0
- package/schemas/decision/D29Study.v8.schema.json +671 -0
- package/schemas/decision/D29Study.v9.schema.json +778 -0
- package/schemas/decision/DecisionComparativeReplayAuditSample.v1.schema.json +137 -0
- package/schemas/decision/DecisionComparativeReplayCorpus.v1.schema.json +258 -0
- package/schemas/decision/DecisionComparativeReplayFreeze.v1.schema.json +74 -0
- package/schemas/decision/DecisionComparativeReplayGold.v1.schema.json +50 -0
- package/schemas/decision/DecisionComparativeReplayGoldAsset.v1.schema.json +37 -0
- package/schemas/decision/DecisionComparativeReplayIntegrity.v1.schema.json +81 -0
- package/schemas/decision/DecisionComparativeReplayMembers.v1.schema.json +52 -0
- package/schemas/decision/DecisionComparativeReplayPreregistration.v1.schema.json +113 -0
- package/schemas/decision/DecisionComparativeReplayReport.v1.schema.json +407 -0
- package/schemas/decision/DecisionComparativeReplayReviewPackets.v1.schema.json +260 -0
- package/schemas/decision/DecisionComparativeReplaySplit.v1.schema.json +84 -0
- package/schemas/decision/DecisionFeatureObservation.v1.schema.json +564 -0
- package/schemas/decision/DecisionFeatureSet.v1.schema.json +155 -0
- package/schemas/decision/DecisionInteropMapping.v1.schema.json +85 -0
- package/schemas/decision/DecisionIssueTriageEvaluationManifest.v1.schema.json +174 -0
- package/schemas/decision/DecisionIssueTriageEvaluationReport.v1.schema.json +280 -0
- package/schemas/decision/DecisionIssueTriagePilotPack.v1.schema.json +114 -0
- package/schemas/decision/DecisionRouteReceipt.v1.schema.json +155 -0
- package/schemas/decision/DecisionRoutingPolicy.v1.schema.json +132 -0
- package/schemas/decision/DecisionRoutingShadowReport.v1.schema.json +170 -0
- package/schemas/decision/DecisionRoutingTask.v1.schema.json +49 -0
- package/schemas/decision/DecisionRuleset.schema.json +8 -2
- package/schemas/decision/DecisionRuleset.v1alpha2.schema.json +8 -2
- package/schemas/decision/DecisionSensitivityPlan.v1.schema.json +191 -0
- package/schemas/decision/DecisionSensitivityReport.v1.schema.json +163 -0
- package/schemas/decision/HeldoutApproval.v1.schema.json +312 -0
- package/schemas/decision/HeldoutAttempt.v1.schema.json +211 -0
- package/schemas/decision/HeldoutBaseline.v1.schema.json +90 -0
- package/schemas/decision/HeldoutCalibrationPhase.v1.schema.json +87 -0
- package/schemas/decision/HeldoutCorpus.v1.schema.json +167 -0
- package/schemas/decision/HeldoutEvent.v1.schema.json +41 -0
- package/schemas/decision/HeldoutFrozen.v1.schema.json +142 -0
- package/schemas/decision/HeldoutPreregistration.v1.schema.json +222 -0
- package/schemas/decision/HeldoutSpendEvent.v1.schema.json +122 -0
- package/schemas/decision/HeldoutSpendHead.v1.schema.json +25 -0
- package/schemas/decision/HeldoutSpendHead.v2.schema.json +46 -0
- package/schemas/decision/HeldoutSummary.v1.schema.json +101 -0
- package/schemas/decision/OpaDecisionLogExport.v1.schema.json +65 -0
- package/schemas/decision/PreprocessedEvidence.v1.schema.json +306 -0
- package/schemas/decision/RulesetResult.v1alpha2.schema.json +175 -0
- package/schemas/decision/SdlcEvidenceScreening.v1.schema.json +499 -0
- package/schemas/decision/SdlcScreeningPreregistration.v1.schema.json +132 -0
- package/schemas/decision/SdlcScreeningPreregistration.v2.schema.json +138 -0
- package/schemas/decision/SdlcScreeningRelease.v1.schema.json +654 -0
- package/schemas/decision/SdlcScreeningRelease.v2.schema.json +654 -0
- package/tools/agents/deploy-agents.mjs +1 -1
- package/tools/agents/providers/base.mjs +26 -7
- package/tools/agents/providers/factory.mjs +10 -2
- package/tools/agents/providers/muse-hooks.mjs +1 -29
- package/tools/agents/providers/muse.mjs +6 -3
|
@@ -0,0 +1,1074 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
6
|
+
import { canonicalJson } from '../security/artifact-trust.js';
|
|
7
|
+
import { artifactDigest, artifactPin, assertArtifactPin, validateDecisionDocument } from './validate.js';
|
|
8
|
+
import { allocateEstimatedUsage } from './batch-receipts/accounting.js';
|
|
9
|
+
import { validateDecisionLifecyclePolicy } from './lifecycle.js';
|
|
10
|
+
import { verifyQualificationReleaseDigest } from './qualification/release.js';
|
|
11
|
+
export const DECISION_FEATURE_EXPORT_VERSION = 'decision-feature-export/v1';
|
|
12
|
+
export const DECISION_FEATURE_SET_KIND = 'DecisionFeatureSet';
|
|
13
|
+
export const DECISION_FEATURE_OBSERVATION_KIND = 'DecisionFeatureObservation';
|
|
14
|
+
export const DECISION_FEATURE_EXPORT_MANIFEST_KIND = 'DecisionFeatureExportManifest';
|
|
15
|
+
export class DecisionFeatureExportError extends Error {
|
|
16
|
+
constructor(message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = 'DecisionFeatureExportError';
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
let featureValidators = null;
|
|
22
|
+
const BATCH_ACCOUNTING_EVIDENCE = Symbol('decisionFeatureBatchAccountingEvidence');
|
|
23
|
+
export class MemoryDecisionFeatureExportStore {
|
|
24
|
+
records = new Map();
|
|
25
|
+
async create(observation) {
|
|
26
|
+
if (this.records.has(observation.observationId))
|
|
27
|
+
throw new DecisionFeatureExportError('Feature observation already exists');
|
|
28
|
+
this.records.set(observation.observationId, cloneObservation(observation));
|
|
29
|
+
}
|
|
30
|
+
async read(identity, observationId) {
|
|
31
|
+
const value = this.records.get(observationId);
|
|
32
|
+
return value && sameIdentity(value.authorization, identity) ? cloneObservation(value) : null;
|
|
33
|
+
}
|
|
34
|
+
async list(identity) {
|
|
35
|
+
return [...this.records.values()].filter(value => sameIdentity(value.authorization, identity)).map(value => cloneObservation(value));
|
|
36
|
+
}
|
|
37
|
+
async updateLifecycle(identity, observationId, patch) {
|
|
38
|
+
const current = await this.read(identity, observationId);
|
|
39
|
+
if (!current)
|
|
40
|
+
return null;
|
|
41
|
+
current.lifecycle = { ...current.lifecycle, ...patch };
|
|
42
|
+
this.records.set(observationId, cloneObservation(current));
|
|
43
|
+
return current;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export class DecisionFeatureExportService {
|
|
47
|
+
featureSet;
|
|
48
|
+
store;
|
|
49
|
+
lifecyclePolicy;
|
|
50
|
+
lifecycleStore;
|
|
51
|
+
constructor(featureSet, store, lifecyclePolicy, lifecycleStore) {
|
|
52
|
+
this.featureSet = featureSet;
|
|
53
|
+
this.store = store;
|
|
54
|
+
this.lifecyclePolicy = lifecyclePolicy;
|
|
55
|
+
this.lifecycleStore = lifecycleStore;
|
|
56
|
+
validateFeatureSet(featureSet);
|
|
57
|
+
}
|
|
58
|
+
async create(input) {
|
|
59
|
+
validateDecisionFeatureExportAuthorization(input.authorization, 'create', this.lifecyclePolicy);
|
|
60
|
+
if (input.featureSet.metadata.digest !== this.featureSet.metadata.digest) {
|
|
61
|
+
throw new DecisionFeatureExportError('Feature observation feature set does not match the service feature set');
|
|
62
|
+
}
|
|
63
|
+
const observation = createDecisionFeatureObservation(input);
|
|
64
|
+
assertObservationMatchesFeatureSet(this.featureSet, observation);
|
|
65
|
+
assertExportLifecycleActive(observation, Date.parse(input.exportTime));
|
|
66
|
+
if (this.lifecycleStore)
|
|
67
|
+
await registerDecisionFeatureExportLifecycle(observation, this.lifecycleStore);
|
|
68
|
+
await this.store.create(observation);
|
|
69
|
+
return observation;
|
|
70
|
+
}
|
|
71
|
+
async read(policy, observationId, nowEpochMs) {
|
|
72
|
+
validateDecisionFeatureExportAuthorization(policy, 'read', this.lifecyclePolicy);
|
|
73
|
+
const observation = await this.store.read(authIdentity(policy), observationId);
|
|
74
|
+
if (!observation)
|
|
75
|
+
return null;
|
|
76
|
+
assertObservationMatchesFeatureSet(this.featureSet, observation);
|
|
77
|
+
await this.assertHostLifecycleActive(observation, nowEpochMs);
|
|
78
|
+
return observation;
|
|
79
|
+
}
|
|
80
|
+
async list(policy, nowEpochMs) {
|
|
81
|
+
validateDecisionFeatureExportAuthorization(policy, 'list', this.lifecyclePolicy);
|
|
82
|
+
const active = [];
|
|
83
|
+
for (const observation of await this.store.list(authIdentity(policy))) {
|
|
84
|
+
try {
|
|
85
|
+
assertObservationMatchesFeatureSet(this.featureSet, observation);
|
|
86
|
+
await this.assertHostLifecycleActive(observation, nowEpochMs);
|
|
87
|
+
active.push(observation);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (!(error instanceof DecisionFeatureExportError))
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return active;
|
|
95
|
+
}
|
|
96
|
+
async delete(policy, observationId, nowEpochMs) {
|
|
97
|
+
validateDecisionFeatureExportAuthorization(policy, 'delete', this.lifecyclePolicy);
|
|
98
|
+
const observation = await this.store.read(authIdentity(policy), observationId);
|
|
99
|
+
if (!observation)
|
|
100
|
+
throw new DecisionFeatureExportError('Feature observation not found');
|
|
101
|
+
assertObservationMatchesFeatureSet(this.featureSet, observation);
|
|
102
|
+
await this.assertDeletionAllowed(observation, nowEpochMs);
|
|
103
|
+
const reference = { surface: 'export', opaqueId: observation.lifecycle.reference };
|
|
104
|
+
if (this.lifecycleStore) {
|
|
105
|
+
await this.lifecycleStore.tombstone({ subject: observation.subjectId, reference, deletedAt: nowEpochMs });
|
|
106
|
+
await this.lifecycleStore.erase(reference);
|
|
107
|
+
}
|
|
108
|
+
const deleted = await this.store.updateLifecycle(authIdentity(policy), observationId, { tombstone: true, deletedAtEpochMs: nowEpochMs });
|
|
109
|
+
if (!deleted)
|
|
110
|
+
throw new DecisionFeatureExportError('Feature observation deletion failed');
|
|
111
|
+
}
|
|
112
|
+
async revoke(policy, observationId, nowEpochMs) {
|
|
113
|
+
validateDecisionFeatureExportAuthorization(policy, 'delete', this.lifecyclePolicy);
|
|
114
|
+
const observation = await this.store.read(authIdentity(policy), observationId);
|
|
115
|
+
if (!observation)
|
|
116
|
+
throw new DecisionFeatureExportError('Feature observation not found');
|
|
117
|
+
assertObservationMatchesFeatureSet(this.featureSet, observation);
|
|
118
|
+
const revoked = await this.store.updateLifecycle(authIdentity(policy), observationId, { revokedAtEpochMs: nowEpochMs });
|
|
119
|
+
if (!revoked)
|
|
120
|
+
throw new DecisionFeatureExportError('Feature observation not found');
|
|
121
|
+
}
|
|
122
|
+
async share(policy, observationId, nowEpochMs) {
|
|
123
|
+
validateDecisionFeatureExportAuthorization(policy, 'share', this.lifecyclePolicy);
|
|
124
|
+
const observation = await this.store.read(authIdentity(policy), observationId);
|
|
125
|
+
if (!observation)
|
|
126
|
+
throw new DecisionFeatureExportError('Feature observation not found');
|
|
127
|
+
assertObservationMatchesFeatureSet(this.featureSet, observation);
|
|
128
|
+
await this.assertHostLifecycleActive(observation, nowEpochMs);
|
|
129
|
+
validateObservationForExport(observation, policy, nowEpochMs);
|
|
130
|
+
return observation;
|
|
131
|
+
}
|
|
132
|
+
async exportJsonl(policy, generatedAt, nowEpochMs, evalIntegrity) {
|
|
133
|
+
const rows = await this.list(policy, nowEpochMs);
|
|
134
|
+
return serializeDecisionFeatureJsonl(this.featureSet, rows, policy, { generatedAt, evalIntegrity });
|
|
135
|
+
}
|
|
136
|
+
async exportCsv(policy, generatedAt, nowEpochMs, evalIntegrity) {
|
|
137
|
+
const rows = await this.list(policy, nowEpochMs);
|
|
138
|
+
return serializeDecisionFeatureCsv(this.featureSet, rows, policy, { generatedAt, evalIntegrity });
|
|
139
|
+
}
|
|
140
|
+
async assertHostLifecycleActive(observation, nowEpochMs) {
|
|
141
|
+
assertExportLifecycleActive(observation, nowEpochMs);
|
|
142
|
+
if (!this.lifecycleStore)
|
|
143
|
+
return;
|
|
144
|
+
const reference = { surface: 'export', opaqueId: observation.lifecycle.reference };
|
|
145
|
+
if (!this.lifecycleStore.resolveReference) {
|
|
146
|
+
throw new DecisionFeatureExportError('Feature export lifecycle source status is unavailable');
|
|
147
|
+
}
|
|
148
|
+
if (this.lifecycleStore.resolveReference) {
|
|
149
|
+
const state = await this.lifecycleStore.resolveReference(reference);
|
|
150
|
+
if (state.state !== 'linked')
|
|
151
|
+
throw new DecisionFeatureExportError('Feature export lifecycle reference is not active');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
async assertDeletionAllowed(observation, nowEpochMs) {
|
|
156
|
+
assertExportLifecycleActive(observation, nowEpochMs);
|
|
157
|
+
if (observation.lifecycle.legalHold)
|
|
158
|
+
throw new DecisionFeatureExportError('Feature observation deletion denied by legal hold');
|
|
159
|
+
if (!this.lifecycleStore)
|
|
160
|
+
return;
|
|
161
|
+
const holds = await this.lifecycleStore.holds(observation.subjectId);
|
|
162
|
+
if (holds.some(hold => activeExportHold(hold, observation.subjectId, nowEpochMs))) {
|
|
163
|
+
throw new DecisionFeatureExportError('Feature observation deletion denied by legal hold');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
export function validateDecisionFeatureExportAuthorization(policy, operation, lifecycle) {
|
|
168
|
+
if (!policy.tenantId || !policy.projectId || !policy.actorId || !policy.recipient
|
|
169
|
+
|| !policy.purpose || !policy.datasetPolicyId || !policy.accessScope) {
|
|
170
|
+
throw new DecisionFeatureExportError('Feature export authorization is incomplete');
|
|
171
|
+
}
|
|
172
|
+
if (!policy.operations.includes(operation))
|
|
173
|
+
throw new DecisionFeatureExportError(`Feature export ${operation} is not authorized`);
|
|
174
|
+
if ((operation === 'export' || operation === 'share') && !policy.allowExternalDelivery) {
|
|
175
|
+
throw new DecisionFeatureExportError('External feature delivery requires an approved export policy');
|
|
176
|
+
}
|
|
177
|
+
if (policy.allowRawState || policy.allowOutcomeLabels) {
|
|
178
|
+
throw new DecisionFeatureExportError('Feature export policy cannot authorize raw state or outcome labels');
|
|
179
|
+
}
|
|
180
|
+
if (lifecycle) {
|
|
181
|
+
validateDecisionLifecyclePolicy(lifecycle);
|
|
182
|
+
const rule = lifecycle.surfaces.export;
|
|
183
|
+
if (rule.export !== 'sanitized' || !rule.accessScopes.includes(policy.accessScope)) {
|
|
184
|
+
throw new DecisionFeatureExportError('Feature export lifecycle policy does not authorize sanitized export access');
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
export async function registerDecisionFeatureExportLifecycle(observation, store) {
|
|
189
|
+
assertExportLifecycleActive(observation, Date.parse(observation.exportTime));
|
|
190
|
+
if (!store.register)
|
|
191
|
+
throw new DecisionFeatureExportError('Feature export lifecycle registration unavailable');
|
|
192
|
+
await store.register(observation.subjectId, { surface: 'export', opaqueId: observation.lifecycle.reference });
|
|
193
|
+
}
|
|
194
|
+
export function generateDecisionFeatureSet(input) {
|
|
195
|
+
validateDecisionDocument(input.ruleset);
|
|
196
|
+
validateDecisionDocument(input.binding);
|
|
197
|
+
assertArtifactPin(input.ruleset, input.binding.spec.ruleset, 'ruleset');
|
|
198
|
+
const pins = {
|
|
199
|
+
ruleset: artifactPin(input.ruleset),
|
|
200
|
+
binding: artifactPin(input.binding),
|
|
201
|
+
definitions: {},
|
|
202
|
+
adapters: {},
|
|
203
|
+
calibration: structuredClone(input.calibration ?? {}),
|
|
204
|
+
};
|
|
205
|
+
const columns = [];
|
|
206
|
+
const aliases = input.ruleset.spec.evaluations.map(item => item.alias);
|
|
207
|
+
for (const entry of input.ruleset.spec.evaluations) {
|
|
208
|
+
const definition = input.definitions[entry.alias];
|
|
209
|
+
if (!definition)
|
|
210
|
+
throw new DecisionFeatureExportError(`Missing definition for ${entry.alias}`);
|
|
211
|
+
validateDecisionDocument(definition);
|
|
212
|
+
assertArtifactPin(definition, entry.decision, `definition ${entry.alias}`);
|
|
213
|
+
pins.definitions[entry.alias] = artifactPin(definition);
|
|
214
|
+
const bindingEntry = input.binding.spec.evaluations[entry.alias];
|
|
215
|
+
const target = bindingEntry?.targets[0];
|
|
216
|
+
if (!target)
|
|
217
|
+
throw new DecisionFeatureExportError(`Missing binding target for ${entry.alias}`);
|
|
218
|
+
pins.adapters[entry.alias] = {
|
|
219
|
+
adapter: target.adapter,
|
|
220
|
+
adapterVersion: target.adapterVersion,
|
|
221
|
+
requestedModel: target.model,
|
|
222
|
+
servedModel: null,
|
|
223
|
+
};
|
|
224
|
+
columns.push(...columnsForDefinition(entry.alias, definition));
|
|
225
|
+
}
|
|
226
|
+
columns.push(...rulesetColumns(aliases));
|
|
227
|
+
columns.forEach((column, index) => { column.order = index; });
|
|
228
|
+
const draft = {
|
|
229
|
+
schemaVersion: DECISION_FEATURE_EXPORT_VERSION,
|
|
230
|
+
kind: DECISION_FEATURE_SET_KIND,
|
|
231
|
+
metadata: { id: input.id, version: input.version, description: input.description, digest: `sha256:${'0'.repeat(64)}` },
|
|
232
|
+
spec: {
|
|
233
|
+
pins,
|
|
234
|
+
compatibleServedModels: structuredClone(input.compatibleServedModels ?? {}),
|
|
235
|
+
missingValuePolicy: { numeric: 'null', categorical: 'null', missingStateColumn: true, missingNeverZero: true },
|
|
236
|
+
ordering: 'definition-pins-declared-order',
|
|
237
|
+
privacy: structuredClone(input.privacy),
|
|
238
|
+
columns,
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
draft.metadata.digest = digestFeatureSet(draft);
|
|
242
|
+
return draft;
|
|
243
|
+
}
|
|
244
|
+
export function decisionFeatureTrainServeContract(featureSet) {
|
|
245
|
+
validateFeatureSet(featureSet);
|
|
246
|
+
return {
|
|
247
|
+
featureSet: { id: featureSet.metadata.id, version: featureSet.metadata.version, digest: featureSet.metadata.digest },
|
|
248
|
+
columns: featureSet.spec.columns.map(column => ({ name: column.name, valueType: column.valueType,
|
|
249
|
+
nullable: column.nullable, ...(column.domain ? { domain: [...column.domain] } : {}) })),
|
|
250
|
+
compatibleServedModels: structuredClone(featureSet.spec.compatibleServedModels),
|
|
251
|
+
calibration: structuredClone(featureSet.spec.pins.calibration),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
export function createDecisionFeatureObservation(input) {
|
|
255
|
+
validateDecisionFeatureExportAuthorization(input.authorization, 'create');
|
|
256
|
+
validateFeatureSet(input.featureSet);
|
|
257
|
+
const decisions = decisionsFromResult(input.result);
|
|
258
|
+
for (const decision of decisions)
|
|
259
|
+
validateDecisionDocument(decision);
|
|
260
|
+
const rulesetPin = input.result.kind === 'RulesetResult' ? input.result.spec.ruleset : decisions[0]?.spec.ruleset;
|
|
261
|
+
const bindingPin = input.result.kind === 'RulesetResult' ? input.result.spec.binding : decisions[0]?.spec.binding;
|
|
262
|
+
if (!rulesetPin || !bindingPin)
|
|
263
|
+
throw new DecisionFeatureExportError('Feature export source is empty');
|
|
264
|
+
requireSamePin(rulesetPin, input.featureSet.spec.pins.ruleset, 'ruleset');
|
|
265
|
+
requireSamePin(bindingPin, input.featureSet.spec.pins.binding, 'binding');
|
|
266
|
+
const features = {};
|
|
267
|
+
for (const column of input.featureSet.spec.columns)
|
|
268
|
+
features[column.name] = missingCell(column, 'missing-evidence', '$');
|
|
269
|
+
for (const decision of decisions) {
|
|
270
|
+
const definition = input.definitions[decision.spec.alias];
|
|
271
|
+
const pin = input.featureSet.spec.pins.definitions[decision.spec.alias];
|
|
272
|
+
if (!definition || !pin)
|
|
273
|
+
throw new DecisionFeatureExportError(`Missing definition pin for ${decision.spec.alias}`);
|
|
274
|
+
requireSamePin(artifactPin(definition), pin, `definition ${decision.spec.alias}`);
|
|
275
|
+
requireSamePin(decision.spec.decision, pin, `decision ${decision.spec.alias}`);
|
|
276
|
+
assertAttemptMatchesPins(input.featureSet, decision);
|
|
277
|
+
for (const cell of cellsForDecision(input.featureSet, decision, definition))
|
|
278
|
+
features[cell.name] = cell.cell;
|
|
279
|
+
}
|
|
280
|
+
for (const column of input.featureSet.spec.columns) {
|
|
281
|
+
if (!features[column.name])
|
|
282
|
+
features[column.name] = missingCell(column, 'missing-evidence', '$');
|
|
283
|
+
validateCell(column, features[column.name]);
|
|
284
|
+
}
|
|
285
|
+
const alias = decisions.length === 1 ? decisions[0].spec.alias : null;
|
|
286
|
+
const observedModels = Object.fromEntries(decisions.map(decision => [decision.spec.alias, latestActualModel(decision)]));
|
|
287
|
+
const calibrationRefs = Object.fromEntries(decisions.map(decision => [decision.spec.alias, decision.spec.uncertainty?.calibrationRef ?? null]));
|
|
288
|
+
const lifecycleReference = input.lifecycle?.reference ?? opaqueExportReference(input.featureSet, input.subjectId, input.result.spec.invocationId, alias);
|
|
289
|
+
const exportEpoch = Date.parse(input.exportTime);
|
|
290
|
+
const expiresAtEpochMs = input.lifecycle?.expiresAtEpochMs ?? exportEpoch + input.featureSet.spec.privacy.retentionMs;
|
|
291
|
+
const batch = batchAccountingFor(input.result, decisions);
|
|
292
|
+
const observation = {
|
|
293
|
+
schemaVersion: DECISION_FEATURE_EXPORT_VERSION,
|
|
294
|
+
kind: DECISION_FEATURE_OBSERVATION_KIND,
|
|
295
|
+
featureSet: { id: input.featureSet.metadata.id, version: input.featureSet.metadata.version, digest: input.featureSet.metadata.digest },
|
|
296
|
+
observationId: opaqueExportReference(input.featureSet, input.subjectId, input.result.spec.invocationId, alias ?? 'ruleset'),
|
|
297
|
+
subjectId: input.subjectId,
|
|
298
|
+
eventTime: input.eventTime,
|
|
299
|
+
exportTime: input.exportTime,
|
|
300
|
+
sourceReceipt: { kind: input.result.kind, runId: input.result.spec.runId, invocationId: input.result.spec.invocationId, alias },
|
|
301
|
+
authorization: authIdentity(input.authorization),
|
|
302
|
+
lifecycle: { surface: 'export', reference: lifecycleReference, tombstone: input.lifecycle?.tombstone ?? false,
|
|
303
|
+
legalHold: input.lifecycle?.legalHold ?? false, expiresAtEpochMs,
|
|
304
|
+
revokedAtEpochMs: input.lifecycle?.revokedAtEpochMs ?? null, deletedAtEpochMs: input.lifecycle?.deletedAtEpochMs ?? null },
|
|
305
|
+
lineage: { ruleset: structuredClone(rulesetPin), binding: structuredClone(bindingPin),
|
|
306
|
+
decision: decisions.length === 1 ? structuredClone(decisions[0].spec.decision) : null,
|
|
307
|
+
definition: decisions.length === 1 ? structuredClone(input.featureSet.spec.pins.definitions[decisions[0].spec.alias]) : null },
|
|
308
|
+
trainServe: { featureSetDigest: input.featureSet.metadata.digest, servedModels: observedModels, calibrationRefs },
|
|
309
|
+
batchAccounting: batch.accounting,
|
|
310
|
+
features,
|
|
311
|
+
};
|
|
312
|
+
attachBatchEvidence(observation, batch.evidence);
|
|
313
|
+
assertNoFeatureLeakage(observation, input.authorization);
|
|
314
|
+
assertExportLifecycleActive(observation, exportEpoch);
|
|
315
|
+
validateDecisionFeatureObservation(input.featureSet, observation);
|
|
316
|
+
return observation;
|
|
317
|
+
}
|
|
318
|
+
export function validateDecisionFeatureObservation(featureSet, observation) {
|
|
319
|
+
validateFeatureSet(featureSet);
|
|
320
|
+
if (observation.schemaVersion !== DECISION_FEATURE_EXPORT_VERSION || observation.kind !== DECISION_FEATURE_OBSERVATION_KIND) {
|
|
321
|
+
throw new DecisionFeatureExportError('Unsupported feature observation');
|
|
322
|
+
}
|
|
323
|
+
if (observation.featureSet.id !== featureSet.metadata.id || observation.featureSet.version !== featureSet.metadata.version
|
|
324
|
+
|| observation.featureSet.digest !== featureSet.metadata.digest || observation.trainServe.featureSetDigest !== featureSet.metadata.digest) {
|
|
325
|
+
throw new DecisionFeatureExportError('Feature observation was produced for a different feature set');
|
|
326
|
+
}
|
|
327
|
+
validateFeatureSchema(observation, 'DecisionFeatureObservation');
|
|
328
|
+
const expected = featureSet.spec.columns.map(column => column.name);
|
|
329
|
+
const actual = Object.keys(observation.features);
|
|
330
|
+
if (actual.length !== expected.length || expected.some((name, index) => actual[index] !== name)) {
|
|
331
|
+
throw new DecisionFeatureExportError('Feature observation column order does not match the feature set');
|
|
332
|
+
}
|
|
333
|
+
for (const column of featureSet.spec.columns)
|
|
334
|
+
validateCell(column, observation.features[column.name]);
|
|
335
|
+
}
|
|
336
|
+
export function validateDecisionFeatureTrainServe(featureSet, contract, observation, nowEpochMs) {
|
|
337
|
+
validateDecisionFeatureObservation(featureSet, observation);
|
|
338
|
+
if (contract.featureSet.id !== featureSet.metadata.id || contract.featureSet.version !== featureSet.metadata.version
|
|
339
|
+
|| contract.featureSet.digest !== featureSet.metadata.digest) {
|
|
340
|
+
throw new DecisionFeatureExportError('Train/serve feature-set pin mismatch');
|
|
341
|
+
}
|
|
342
|
+
const columns = featureSet.spec.columns;
|
|
343
|
+
if (contract.columns.length !== columns.length || contract.columns.some((column, index) => column.name !== columns[index].name
|
|
344
|
+
|| column.valueType !== columns[index].valueType || column.nullable !== columns[index].nullable
|
|
345
|
+
|| canonicalJson(column.domain ?? []) !== canonicalJson(columns[index].domain ?? []))) {
|
|
346
|
+
throw new DecisionFeatureExportError('Train/serve feature-domain mismatch');
|
|
347
|
+
}
|
|
348
|
+
const aliases = Object.keys(featureSet.spec.pins.definitions).sort();
|
|
349
|
+
if (!sameStringSet(Object.keys(contract.compatibleServedModels), Object.keys(featureSet.spec.compatibleServedModels))
|
|
350
|
+
|| canonicalJson(contract.compatibleServedModels) !== canonicalJson(featureSet.spec.compatibleServedModels)) {
|
|
351
|
+
throw new DecisionFeatureExportError('Train/serve model compatibility contract mismatch');
|
|
352
|
+
}
|
|
353
|
+
if (!sameStringSet(Object.keys(contract.calibration), Object.keys(featureSet.spec.pins.calibration))
|
|
354
|
+
|| canonicalJson(contract.calibration) !== canonicalJson(featureSet.spec.pins.calibration)) {
|
|
355
|
+
throw new DecisionFeatureExportError('Train/serve calibration contract mismatch');
|
|
356
|
+
}
|
|
357
|
+
if (!sameStringSet(Object.keys(observation.trainServe.servedModels), aliases)) {
|
|
358
|
+
throw new DecisionFeatureExportError('Train/serve served-model alias mismatch');
|
|
359
|
+
}
|
|
360
|
+
if (!sameStringSet(Object.keys(observation.trainServe.calibrationRefs), aliases)) {
|
|
361
|
+
throw new DecisionFeatureExportError('Train/serve calibration alias mismatch');
|
|
362
|
+
}
|
|
363
|
+
for (const [alias, servedModel] of Object.entries(observation.trainServe.servedModels)) {
|
|
364
|
+
const allowed = contract.compatibleServedModels[alias];
|
|
365
|
+
if (allowed?.length && (!servedModel || !allowed.includes(servedModel))) {
|
|
366
|
+
throw new DecisionFeatureExportError(`Train/serve served-model incompatibility for ${alias}`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
for (const [alias, rule] of Object.entries(contract.calibration)) {
|
|
370
|
+
const observed = observation.trainServe.calibrationRefs[alias];
|
|
371
|
+
if (observed !== rule.calibrationRef)
|
|
372
|
+
throw new DecisionFeatureExportError(`Train/serve calibration mismatch for ${alias}`);
|
|
373
|
+
if (rule.recordedAtEpochMs !== null && rule.maxAgeMs !== null && nowEpochMs - rule.recordedAtEpochMs > rule.maxAgeMs) {
|
|
374
|
+
throw new DecisionFeatureExportError(`Train/serve stale calibration for ${alias}`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
export function serializeDecisionFeatureJsonl(featureSet, observations, authorization, options) {
|
|
379
|
+
validateDecisionFeatureExportAuthorization(authorization, 'export');
|
|
380
|
+
const resolved = typeof options === 'string' ? { generatedAt: options } : options;
|
|
381
|
+
const rows = observations.map(observation => {
|
|
382
|
+
validateDecisionFeatureObservation(featureSet, observation);
|
|
383
|
+
validateObservationForExport(observation, authorization, Date.parse(resolved.generatedAt));
|
|
384
|
+
return observation;
|
|
385
|
+
});
|
|
386
|
+
const payload = `${rows.map(row => canonicalJson(row)).join('\n')}${rows.length ? '\n' : ''}`;
|
|
387
|
+
return { payload, manifest: manifestFor(featureSet, rows, authorization, 'jsonl', payload, resolved) };
|
|
388
|
+
}
|
|
389
|
+
export function serializeDecisionFeatureCsv(featureSet, observations, authorization, options) {
|
|
390
|
+
validateDecisionFeatureExportAuthorization(authorization, 'export');
|
|
391
|
+
const resolved = typeof options === 'string' ? { generatedAt: options } : options;
|
|
392
|
+
const rows = observations.map(observation => {
|
|
393
|
+
validateDecisionFeatureObservation(featureSet, observation);
|
|
394
|
+
validateObservationForExport(observation, authorization, Date.parse(resolved.generatedAt));
|
|
395
|
+
return observation;
|
|
396
|
+
});
|
|
397
|
+
const header = csvHeader(featureSet);
|
|
398
|
+
const lines = [header, ...rows.map(row => csvRow(featureSet, row))];
|
|
399
|
+
const payload = `${lines.join('\n')}\n`;
|
|
400
|
+
return { payload, manifest: manifestFor(featureSet, rows, authorization, 'csv', payload, resolved) };
|
|
401
|
+
}
|
|
402
|
+
export function loadDecisionFeatureCsv(featureSet, payload) {
|
|
403
|
+
const records = parseCsv(payload);
|
|
404
|
+
if (!records.length)
|
|
405
|
+
return [];
|
|
406
|
+
const expected = csvHeader(featureSet);
|
|
407
|
+
if (records[0].join(',') !== expected)
|
|
408
|
+
throw new DecisionFeatureExportError('CSV feature columns are missing, extra, or reordered');
|
|
409
|
+
return records.slice(1).filter(record => record.length > 1 || record[0] !== '').map(record => observationFromCsvRecord(featureSet, records[0], record));
|
|
410
|
+
}
|
|
411
|
+
export function semanticFeatureContentDigest(observations) {
|
|
412
|
+
const normalized = observations.map(normalizedSemanticRow);
|
|
413
|
+
return `sha256:${createHash('sha256').update(canonicalJson(normalized)).digest('hex')}`;
|
|
414
|
+
}
|
|
415
|
+
export function deterministicFeatureSplit(observations, seed) {
|
|
416
|
+
if (!Number.isSafeInteger(seed))
|
|
417
|
+
throw new DecisionFeatureExportError('Feature split seed must be an integer');
|
|
418
|
+
const ordered = observations.map(row => row.observationId).sort((a, b) => digestText(`${seed}:${a}`).localeCompare(digestText(`${seed}:${b}`)));
|
|
419
|
+
const trainEnd = Math.max(1, Math.floor(ordered.length * 0.6));
|
|
420
|
+
const validationEnd = Math.max(trainEnd, Math.floor(ordered.length * 0.8));
|
|
421
|
+
return { train: ordered.slice(0, trainEnd), validation: ordered.slice(trainEnd, validationEnd),
|
|
422
|
+
test: ordered.slice(validationEnd), seed, algorithm: 'sha256-stable-60-20-20/v1' };
|
|
423
|
+
}
|
|
424
|
+
function validateFeatureSet(featureSet) {
|
|
425
|
+
if (featureSet.schemaVersion !== DECISION_FEATURE_EXPORT_VERSION || featureSet.kind !== DECISION_FEATURE_SET_KIND
|
|
426
|
+
|| featureSet.metadata.digest !== digestFeatureSet(featureSet)) {
|
|
427
|
+
throw new DecisionFeatureExportError('Invalid decision feature set');
|
|
428
|
+
}
|
|
429
|
+
const names = featureSet.spec.columns.map(column => column.name);
|
|
430
|
+
if (new Set(names).size !== names.length || featureSet.spec.columns.some((column, index) => column.order !== index)) {
|
|
431
|
+
throw new DecisionFeatureExportError('Decision feature columns must be uniquely and deterministically ordered');
|
|
432
|
+
}
|
|
433
|
+
validateFeatureSchema(featureSet, 'DecisionFeatureSet');
|
|
434
|
+
}
|
|
435
|
+
function featureSchemaRoot() {
|
|
436
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
437
|
+
const candidates = [resolve(here, '../../schemas/decision'), resolve(here, '../../../schemas/decision')];
|
|
438
|
+
const found = candidates.find(candidate => existsSync(resolve(candidate, 'DecisionFeatureSet.v1.schema.json')));
|
|
439
|
+
if (!found)
|
|
440
|
+
throw new DecisionFeatureExportError('Decision feature schema directory is unavailable');
|
|
441
|
+
return found;
|
|
442
|
+
}
|
|
443
|
+
function getFeatureValidators() {
|
|
444
|
+
if (featureValidators)
|
|
445
|
+
return featureValidators;
|
|
446
|
+
const ajv = new Ajv2020({ strict: false, allErrors: true, validateFormats: false });
|
|
447
|
+
featureValidators = new Map();
|
|
448
|
+
for (const [kind, filename] of [
|
|
449
|
+
['DecisionFeatureSet', 'DecisionFeatureSet.v1.schema.json'],
|
|
450
|
+
['DecisionFeatureObservation', 'DecisionFeatureObservation.v1.schema.json'],
|
|
451
|
+
]) {
|
|
452
|
+
const schema = JSON.parse(readFileSync(resolve(featureSchemaRoot(), filename), 'utf8'));
|
|
453
|
+
featureValidators.set(kind, ajv.compile(schema));
|
|
454
|
+
}
|
|
455
|
+
return featureValidators;
|
|
456
|
+
}
|
|
457
|
+
function validateFeatureSchema(value, kind) {
|
|
458
|
+
const validate = getFeatureValidators().get(kind);
|
|
459
|
+
if (!validate(value)) {
|
|
460
|
+
const message = validate.errors?.map(error => `${error.instancePath || '/'} ${error.message ?? 'invalid'}`).join('; ') ?? 'invalid';
|
|
461
|
+
throw new DecisionFeatureExportError(`${kind} schema validation failed: ${message}`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
function digestFeatureSet(featureSet) {
|
|
465
|
+
const clone = structuredClone(featureSet);
|
|
466
|
+
clone.metadata.digest = `sha256:${'0'.repeat(64)}`;
|
|
467
|
+
return artifactDigest(clone);
|
|
468
|
+
}
|
|
469
|
+
function columnsForDefinition(alias, definition) {
|
|
470
|
+
const answer = definition.spec.answer;
|
|
471
|
+
const out = [];
|
|
472
|
+
const primitive = answer.kind;
|
|
473
|
+
if (answer.kind === 'choice') {
|
|
474
|
+
for (const option of answer.options) {
|
|
475
|
+
out.push(column(`raw.provider.${alias}.choice.${option.id}.probability`, alias, primitive, 'number', 'provider-native', `Native probability for choice option ${option.id}.`, { domain: answer.options.map(value => value.id) }));
|
|
476
|
+
out.push(column(`compat.one_hot.${alias}.choice.${option.id}`, alias, primitive, 'number', 'adapter-one-hot', `One-hot compatibility output for selected option ${option.id}.`, { derivation: { algorithm: 'selected-choice-one-hot', version: '1' } }));
|
|
477
|
+
}
|
|
478
|
+
out.push(column(`derived.${alias}.selected_probability`, alias, primitive, 'number', 'derived', 'Selected option probability.', { derivation: { algorithm: 'selected-choice-probability', version: '1' } }));
|
|
479
|
+
out.push(column(`derived.${alias}.top_two_margin`, alias, primitive, 'number', 'derived', 'Top-two probability margin.', { derivation: { algorithm: 'top-two-margin', version: '1' } }));
|
|
480
|
+
out.push(column(`derived.${alias}.entropy`, alias, primitive, 'number', 'derived', 'Shannon entropy over choice probabilities.', { derivation: { algorithm: 'shannon-entropy-natural-log', version: '1' } }));
|
|
481
|
+
out.push(column(`derived.${alias}.concentration`, alias, primitive, 'number', 'derived', 'Sum of squared choice probabilities.', { derivation: { algorithm: 'sum-squared-probabilities', version: '1' } }));
|
|
482
|
+
}
|
|
483
|
+
else if (answer.kind === 'ordinal-score') {
|
|
484
|
+
for (const [index] of answer.levels.entries()) {
|
|
485
|
+
out.push(column(`raw.provider.${alias}.score.level_${index}.probability`, alias, primitive, 'number', 'provider-native', `Native probability for score level ${index}.`, { domain: answer.levels.map((_, level) => String(level)) }));
|
|
486
|
+
}
|
|
487
|
+
out.push(column(`derived.${alias}.expected_score`, alias, primitive, 'number', 'derived', 'Expected score over declared levels.', { derivation: { algorithm: 'ordinal-expected-value', version: '1' } }));
|
|
488
|
+
out.push(column(`derived.${alias}.variance`, alias, primitive, 'number', 'derived', 'Ordinal score variance.', { derivation: { algorithm: 'ordinal-variance', version: '1' } }));
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
out.push(column(`raw.provider.${alias}.noul.truth_probability`, alias, primitive, 'number', 'provider-native', 'Native truth probability. No fabricated two-class distribution is emitted.'));
|
|
492
|
+
}
|
|
493
|
+
out.push(column(`model_self_report.${alias}.confidence`, alias, primitive, 'number', 'model-self-report', 'LLM self-reported confidence, if present; distinct from Jev-native probability.'));
|
|
494
|
+
out.push(column(`calibrated.${alias}.risk`, alias, primitive, 'number', 'calibrated', 'Separately pinned calibrated risk.', { calibrationRef: null }));
|
|
495
|
+
return out;
|
|
496
|
+
}
|
|
497
|
+
function rulesetColumns(aliases) {
|
|
498
|
+
return aliases.map(alias => column(`ruleset.${alias}.status_success`, alias, 'ruleset', 'boolean', 'derived', 'Ruleset-level success marker for this evaluation.', { derivation: { algorithm: 'status-success-marker', version: '1' } }));
|
|
499
|
+
}
|
|
500
|
+
function column(name, alias, primitive, valueType, source, description, options = {}) {
|
|
501
|
+
return { name, alias, primitive, valueType, nullable: true, order: -1, source, uncertaintyProfile: null,
|
|
502
|
+
calibrationRef: options.calibrationRef ?? null, derivation: options.derivation ?? null, description,
|
|
503
|
+
...(options.domain ? { domain: options.domain } : {}) };
|
|
504
|
+
}
|
|
505
|
+
function decisionsFromResult(result) {
|
|
506
|
+
return result.kind === 'DecisionResult' ? [result] : Object.values(result.spec.evaluations);
|
|
507
|
+
}
|
|
508
|
+
function cellsForDecision(featureSet, decision, definition) {
|
|
509
|
+
const answer = definition.spec.answer;
|
|
510
|
+
const uncertainty = decision.spec.uncertainty;
|
|
511
|
+
const distribution = uncertainty?.distribution ?? null;
|
|
512
|
+
const values = [];
|
|
513
|
+
const col = (suffix) => featureSet.spec.columns.find(column => column.alias === decision.spec.alias && column.name.endsWith(suffix));
|
|
514
|
+
if (answer.kind === 'choice') {
|
|
515
|
+
for (const option of answer.options) {
|
|
516
|
+
const raw = col(`choice.${option.id}.probability`);
|
|
517
|
+
if (raw)
|
|
518
|
+
values.push({ name: raw.name, cell: uncertainty?.source === 'provider'
|
|
519
|
+
? numericCell(raw, distribution?.[option.id] ?? null, uncertainty, '$.spec.uncertainty.distribution')
|
|
520
|
+
: missingCell(raw, 'missing-evidence', '$.spec.uncertainty.distribution') });
|
|
521
|
+
const oneHot = col(`choice.${option.id}`);
|
|
522
|
+
if (oneHot?.name.startsWith('compat.one_hot.')) {
|
|
523
|
+
values.push({ name: oneHot.name, cell: decision.spec.status === 'success' && typeof decision.spec.value === 'string'
|
|
524
|
+
&& answer.options.some(item => item.id === decision.spec.value)
|
|
525
|
+
? numericCell(oneHot, decision.spec.value === option.id ? 1 : 0, null, '$.spec.value')
|
|
526
|
+
: missingCell(oneHot, 'missing-evidence', '$.spec.value') });
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (uncertainty?.source === 'provider' && distribution && answer.options.every(option => Number.isFinite(distribution[option.id]))) {
|
|
530
|
+
const probabilities = answer.options.map(option => distribution[option.id]);
|
|
531
|
+
const selected = typeof decision.spec.value === 'string' ? distribution[decision.spec.value] ?? null : null;
|
|
532
|
+
const sorted = [...probabilities].sort((a, b) => b - a);
|
|
533
|
+
pushMetric(values, col('selected_probability'), selected, '$.spec.uncertainty.distribution');
|
|
534
|
+
pushMetric(values, col('top_two_margin'), sorted[0] !== undefined && sorted[1] !== undefined ? sorted[0] - sorted[1] : null, '$.spec.uncertainty.distribution');
|
|
535
|
+
pushMetric(values, col('entropy'), -probabilities.reduce((sum, value) => value > 0 ? sum + value * Math.log(value) : sum, 0), '$.spec.uncertainty.distribution');
|
|
536
|
+
pushMetric(values, col('concentration'), probabilities.reduce((sum, value) => sum + value * value, 0), '$.spec.uncertainty.distribution');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
else if (answer.kind === 'ordinal-score') {
|
|
540
|
+
for (const [index] of answer.levels.entries()) {
|
|
541
|
+
const raw = col(`level_${index}.probability`);
|
|
542
|
+
if (raw)
|
|
543
|
+
values.push({ name: raw.name, cell: uncertainty?.source === 'provider'
|
|
544
|
+
? numericCell(raw, distribution?.[String(index)] ?? null, uncertainty, '$.spec.uncertainty.distribution')
|
|
545
|
+
: missingCell(raw, 'missing-evidence', '$.spec.uncertainty.distribution') });
|
|
546
|
+
}
|
|
547
|
+
if (uncertainty?.source === 'provider' && distribution && answer.levels.every((_, index) => Number.isFinite(distribution[String(index)]))) {
|
|
548
|
+
const pairs = answer.levels.map((_, index) => [index, distribution[String(index)]]);
|
|
549
|
+
const mean = pairs.reduce((sum, [index, probability]) => sum + index * probability, 0);
|
|
550
|
+
const variance = pairs.reduce((sum, [index, probability]) => sum + probability * (index - mean) ** 2, 0);
|
|
551
|
+
pushMetric(values, col('expected_score'), mean, '$.spec.uncertainty.distribution');
|
|
552
|
+
pushMetric(values, col('variance'), variance, '$.spec.uncertainty.distribution');
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
const raw = col('truth_probability');
|
|
557
|
+
const value = typeof decision.spec.value === 'number' ? decision.spec.value : null;
|
|
558
|
+
if (raw)
|
|
559
|
+
values.push({ name: raw.name, cell: uncertainty?.source === 'provider'
|
|
560
|
+
? numericCell(raw, value, uncertainty, '$.spec.value') : missingCell(raw, 'missing-evidence', '$.spec.value') });
|
|
561
|
+
}
|
|
562
|
+
pushMetric(values, col('confidence'), uncertainty?.source === 'model-self-report' ? uncertainty.confidence : null, '$.spec.uncertainty.confidence');
|
|
563
|
+
const calibrated = col('risk');
|
|
564
|
+
if (calibrated)
|
|
565
|
+
values.push({ name: calibrated.name, cell: numericCell(calibrated, uncertainty?.calibratedRisk?.value ?? null, uncertainty, '$.spec.uncertainty.calibratedRisk') });
|
|
566
|
+
const status = col('status_success');
|
|
567
|
+
if (status)
|
|
568
|
+
values.push({ name: status.name, cell: { value: decision.spec.status === 'success', state: 'present',
|
|
569
|
+
source: 'derived', missingReason: null, calibrationRef: null, derivation: status.derivation, evidencePath: '$.spec.status' } });
|
|
570
|
+
return values;
|
|
571
|
+
}
|
|
572
|
+
function assertAttemptMatchesPins(featureSet, decision) {
|
|
573
|
+
const expected = featureSet.spec.pins.adapters[decision.spec.alias];
|
|
574
|
+
if (!expected)
|
|
575
|
+
throw new DecisionFeatureExportError(`Feature export adapter pin missing for ${decision.spec.alias}`);
|
|
576
|
+
const attempt = decision.spec.attempts.find(value => value.adapter === expected.adapter
|
|
577
|
+
&& value.adapterVersion === expected.adapterVersion && value.requestedModel === expected.requestedModel);
|
|
578
|
+
if (!attempt)
|
|
579
|
+
throw new DecisionFeatureExportError(`Feature export adapter pin mismatch for ${decision.spec.alias}`);
|
|
580
|
+
}
|
|
581
|
+
function pushMetric(values, column, value, evidencePath) {
|
|
582
|
+
if (column)
|
|
583
|
+
values.push({ name: column.name, cell: numericCell(column, value, null, evidencePath) });
|
|
584
|
+
}
|
|
585
|
+
function numericCell(column, value, uncertainty, evidencePath) {
|
|
586
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
587
|
+
return missingCell(column, 'missing-evidence', evidencePath);
|
|
588
|
+
return { value, state: 'present', source: column.source, missingReason: null,
|
|
589
|
+
calibrationRef: column.source === 'calibrated' ? uncertainty?.calibratedRisk?.calibrationRef ?? column.calibrationRef : column.calibrationRef,
|
|
590
|
+
derivation: column.derivation, evidencePath };
|
|
591
|
+
}
|
|
592
|
+
function missingCell(column, reason, evidencePath) {
|
|
593
|
+
return { value: null, state: 'missing', source: 'missing', missingReason: reason, calibrationRef: column.calibrationRef,
|
|
594
|
+
derivation: column.derivation, evidencePath };
|
|
595
|
+
}
|
|
596
|
+
function validateCell(column, cell) {
|
|
597
|
+
if (cell.state === 'missing') {
|
|
598
|
+
if (cell.value !== null || cell.missingReason === null)
|
|
599
|
+
throw new DecisionFeatureExportError(`Invalid missing feature ${column.name}`);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (cell.value === null || typeof cell.value !== column.valueType)
|
|
603
|
+
throw new DecisionFeatureExportError(`Wrong type for feature ${column.name}`);
|
|
604
|
+
if (typeof cell.value === 'number' && !Number.isFinite(cell.value))
|
|
605
|
+
throw new DecisionFeatureExportError(`Invalid numeric feature ${column.name}`);
|
|
606
|
+
if (column.domain && typeof cell.value === 'string' && !column.domain.includes(cell.value)) {
|
|
607
|
+
throw new DecisionFeatureExportError(`Out-of-domain feature ${column.name}`);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function batchAccountingFor(result, decisions) {
|
|
611
|
+
if (result.kind === 'RulesetResult' && result.spec.batchRequests?.length) {
|
|
612
|
+
const requests = result.spec.batchRequests;
|
|
613
|
+
const owners = requests.map(request => batchRequestOwner(result.spec.runId, result.spec.invocationId, request));
|
|
614
|
+
const usage = requests.reduce((sum, request) => ({
|
|
615
|
+
inputTokens: addNullable(sum.inputTokens, request.usage.inputTokens),
|
|
616
|
+
outputTokens: addNullable(sum.outputTokens, request.usage.outputTokens),
|
|
617
|
+
costUsd: addNullable(sum.costUsd, request.usage.costUsd),
|
|
618
|
+
}), { inputTokens: 0, outputTokens: 0, costUsd: 0 });
|
|
619
|
+
const questionIds = [...new Set(requests.flatMap(request => request.questionIds))].sort();
|
|
620
|
+
const allocations = allocateEstimatedUsage(questionIds, usage);
|
|
621
|
+
const allocationUsage = requests.length === 1 ? { inputTokens: allocations.reduce((sum, value) => sum === null || value.inputTokens === null ? null : sum + value.inputTokens, 0),
|
|
622
|
+
outputTokens: allocations.reduce((sum, value) => sum === null || value.outputTokens === null ? null : sum + value.outputTokens, 0),
|
|
623
|
+
costUsd: null } : null;
|
|
624
|
+
return { accounting: { scope: 'ruleset-batch-request', reference: batchReference(owners), authoritativeUsage: null, owners,
|
|
625
|
+
allocation: { kind: 'estimated', algorithm: 'largest-remainder-weighted', algorithmVersion: '1', usage: allocationUsage } }, evidence: owners.map(owner => {
|
|
626
|
+
const request = requests.find(item => item.groupId === owner.groupId && item.ordinal === owner.ordinal && item.requestId === owner.requestId);
|
|
627
|
+
return { ...owner, authoritativeUsage: structuredClone(request.usage) };
|
|
628
|
+
}) };
|
|
629
|
+
}
|
|
630
|
+
const durableDecision = decisions.find(decision => decision.spec.batchResult);
|
|
631
|
+
const durable = durableDecision?.spec.batchResult;
|
|
632
|
+
if (durable && durableDecision) {
|
|
633
|
+
const owner = { ownerId: `durable-batch-receipt:${durableDecision.spec.runId}:${durableDecision.spec.invocationId}:${durable.batchId}:${durable.questionId}`,
|
|
634
|
+
runId: durableDecision.spec.runId, invocationId: durableDecision.spec.invocationId, groupId: durable.batchId, ordinal: null,
|
|
635
|
+
requestId: null, questionIds: [durable.questionId], usageDigest: null };
|
|
636
|
+
return { accounting: { scope: 'durable-batch-receipt', reference: owner.ownerId, authoritativeUsage: null, owners: [owner],
|
|
637
|
+
allocation: { kind: 'none', algorithm: null, algorithmVersion: null, usage: null } }, evidence: [] };
|
|
638
|
+
}
|
|
639
|
+
return { accounting: { scope: 'none', reference: null, authoritativeUsage: null, owners: [],
|
|
640
|
+
allocation: { kind: 'none', algorithm: null, algorithmVersion: null, usage: null } }, evidence: [] };
|
|
641
|
+
}
|
|
642
|
+
function batchRequestOwner(runId, invocationId, request) {
|
|
643
|
+
const questionIds = [...request.questionIds].sort();
|
|
644
|
+
const usageDigest = digestUsage(request.usage);
|
|
645
|
+
const ownerPayload = { runId, invocationId, groupId: request.groupId, ordinal: request.ordinal, requestId: request.requestId, questionIds };
|
|
646
|
+
return { ownerId: `ruleset-batch-request:${createHash('sha256').update(canonicalJson(ownerPayload)).digest('hex')}`,
|
|
647
|
+
runId, invocationId, groupId: request.groupId, ordinal: request.ordinal, requestId: request.requestId, questionIds, usageDigest };
|
|
648
|
+
}
|
|
649
|
+
function batchReference(owners) {
|
|
650
|
+
return `sha256:${createHash('sha256').update(canonicalJson(owners.map(owner => owner.ownerId).sort())).digest('hex')}`;
|
|
651
|
+
}
|
|
652
|
+
function digestUsage(usage) {
|
|
653
|
+
return `sha256:${createHash('sha256').update(canonicalJson(usage)).digest('hex')}`;
|
|
654
|
+
}
|
|
655
|
+
function attachBatchEvidence(observation, evidence) {
|
|
656
|
+
Object.defineProperty(observation, BATCH_ACCOUNTING_EVIDENCE, { value: evidence.map(item => structuredClone(item)), enumerable: false });
|
|
657
|
+
}
|
|
658
|
+
function batchEvidence(observation) {
|
|
659
|
+
return (observation[BATCH_ACCOUNTING_EVIDENCE] ?? []).map(item => structuredClone(item));
|
|
660
|
+
}
|
|
661
|
+
function cloneObservation(observation) {
|
|
662
|
+
const clone = structuredClone(observation);
|
|
663
|
+
attachBatchEvidence(clone, batchEvidence(observation));
|
|
664
|
+
return clone;
|
|
665
|
+
}
|
|
666
|
+
function assertObservationMatchesFeatureSet(featureSet, observation) {
|
|
667
|
+
if (observation.featureSet.id !== featureSet.metadata.id || observation.featureSet.version !== featureSet.metadata.version
|
|
668
|
+
|| observation.featureSet.digest !== featureSet.metadata.digest) {
|
|
669
|
+
throw new DecisionFeatureExportError('Feature observation feature set does not match the service feature set');
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
function manifestBatchAccounting(observations, explicitEvidence = []) {
|
|
673
|
+
const grouped = new Map();
|
|
674
|
+
for (const row of observations) {
|
|
675
|
+
const accounting = row.batchAccounting;
|
|
676
|
+
if (accounting.scope === 'none')
|
|
677
|
+
continue;
|
|
678
|
+
const evidenceByOwner = new Map([...batchEvidence(row), ...explicitEvidence].map(item => [item.ownerId, item]));
|
|
679
|
+
for (const owner of accounting.owners) {
|
|
680
|
+
const evidence = evidenceByOwner.get(owner.ownerId);
|
|
681
|
+
if (!evidence) {
|
|
682
|
+
if (accounting.scope === 'durable-batch-receipt')
|
|
683
|
+
continue;
|
|
684
|
+
throw new DecisionFeatureExportError(`Feature export authoritative batch accounting is missing for ${owner.ownerId}`);
|
|
685
|
+
}
|
|
686
|
+
if (owner.usageDigest !== digestUsage(evidence.authoritativeUsage)) {
|
|
687
|
+
throw new DecisionFeatureExportError(`Feature export authoritative batch accounting digest mismatch for ${owner.ownerId}`);
|
|
688
|
+
}
|
|
689
|
+
const allocation = { observationId: row.observationId, kind: accounting.allocation.kind,
|
|
690
|
+
algorithm: accounting.allocation.algorithm, algorithmVersion: accounting.allocation.algorithmVersion,
|
|
691
|
+
usage: accounting.owners.length === 1 && accounting.allocation.usage ? structuredClone(accounting.allocation.usage) : null };
|
|
692
|
+
const existing = grouped.get(owner.ownerId);
|
|
693
|
+
if (existing) {
|
|
694
|
+
if (canonicalJson(existing.authoritativeUsage) !== canonicalJson(evidence.authoritativeUsage)) {
|
|
695
|
+
throw new DecisionFeatureExportError(`Feature export conflicting authoritative batch accounting for ${owner.ownerId}`);
|
|
696
|
+
}
|
|
697
|
+
existing.rowAllocations.push(allocation);
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
grouped.set(owner.ownerId, { scope: accounting.scope, ownerId: owner.ownerId, reference: accounting.reference ?? owner.ownerId,
|
|
701
|
+
groupId: owner.groupId, ordinal: owner.ordinal, requestId: owner.requestId, questionIds: [...owner.questionIds],
|
|
702
|
+
authoritativeUsage: structuredClone(evidence.authoritativeUsage), rowAllocations: [allocation] });
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return [...grouped.values()].sort((a, b) => a.ownerId.localeCompare(b.ownerId));
|
|
706
|
+
}
|
|
707
|
+
function manifestFor(featureSet, observations, authorization, format, payload, options) {
|
|
708
|
+
const evalIntegrity = normalizeEvalIntegrity(options.evalIntegrity);
|
|
709
|
+
return {
|
|
710
|
+
schemaVersion: DECISION_FEATURE_EXPORT_VERSION,
|
|
711
|
+
kind: DECISION_FEATURE_EXPORT_MANIFEST_KIND,
|
|
712
|
+
featureSet: { id: featureSet.metadata.id, version: featureSet.metadata.version, digest: featureSet.metadata.digest },
|
|
713
|
+
format,
|
|
714
|
+
rowCount: observations.length,
|
|
715
|
+
columnOrder: featureSet.spec.columns.map(column => column.name),
|
|
716
|
+
semanticContentDigest: semanticFeatureContentDigest(observations),
|
|
717
|
+
payloadDigest: `sha256:${createHash('sha256').update(payload).digest('hex')}`,
|
|
718
|
+
generatedAt: options.generatedAt,
|
|
719
|
+
authorization: authIdentity(authorization),
|
|
720
|
+
lifecycle: { surface: 'export', references: observations.map(row => row.lifecycle.reference) },
|
|
721
|
+
batchAccounting: manifestBatchAccounting(observations, options.batchAccountingEvidence ?? []),
|
|
722
|
+
...(evalIntegrity ? { evalIntegrity } : {}),
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
function csvHeader(featureSet) {
|
|
726
|
+
const fixed = ['observationId', 'subjectId', 'eventTime', 'exportTime', 'featureSetId', 'featureSetVersion', 'featureSetDigest', 'aiwgSemantic'];
|
|
727
|
+
const featureColumns = featureSet.spec.columns.flatMap(column => [
|
|
728
|
+
`${column.name}__value`, `${column.name}__state`, `${column.name}__source`,
|
|
729
|
+
`${column.name}__missing`, `${column.name}__calibration`, `${column.name}__derivation`,
|
|
730
|
+
]);
|
|
731
|
+
return [...fixed, ...featureColumns].map(csvEscape).join(',');
|
|
732
|
+
}
|
|
733
|
+
function csvRow(featureSet, observation) {
|
|
734
|
+
const fixed = [observation.observationId, observation.subjectId, observation.eventTime, observation.exportTime,
|
|
735
|
+
observation.featureSet.id, observation.featureSet.version, observation.featureSet.digest,
|
|
736
|
+
canonicalJson(normalizedSemanticRow(observation))];
|
|
737
|
+
const values = featureSet.spec.columns.flatMap(column => {
|
|
738
|
+
const cell = observation.features[column.name];
|
|
739
|
+
return [cell.value === null ? '' : String(cell.value), cell.state, cell.source, cell.missingReason ?? '',
|
|
740
|
+
cell.calibrationRef ?? '', cell.derivation ? `${cell.derivation.algorithm}@${cell.derivation.version}` : ''];
|
|
741
|
+
});
|
|
742
|
+
return [...fixed, ...values].map(csvEscape).join(',');
|
|
743
|
+
}
|
|
744
|
+
function parseCsv(payload) {
|
|
745
|
+
const rows = [];
|
|
746
|
+
let field = '';
|
|
747
|
+
let row = [];
|
|
748
|
+
let state = 'start';
|
|
749
|
+
const finishField = () => { row.push(field); field = ''; state = 'start'; };
|
|
750
|
+
const finishRow = () => { finishField(); rows.push(row); row = []; };
|
|
751
|
+
for (let index = 0; index < payload.length; index++) {
|
|
752
|
+
const char = payload[index];
|
|
753
|
+
if (state === 'quoted') {
|
|
754
|
+
if (char === '"' && payload[index + 1] === '"') {
|
|
755
|
+
field += '"';
|
|
756
|
+
index++;
|
|
757
|
+
}
|
|
758
|
+
else if (char === '"')
|
|
759
|
+
state = 'closed';
|
|
760
|
+
else
|
|
761
|
+
field += char;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
if (state === 'closed') {
|
|
765
|
+
if (char === ',') {
|
|
766
|
+
finishField();
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
if (char === '\n') {
|
|
770
|
+
finishRow();
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
773
|
+
if (char === '\r' && payload[index + 1] === '\n') {
|
|
774
|
+
finishRow();
|
|
775
|
+
index++;
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
throw new DecisionFeatureExportError('CSV quoted field has trailing characters');
|
|
779
|
+
}
|
|
780
|
+
if (char === '"') {
|
|
781
|
+
if (state !== 'start')
|
|
782
|
+
throw new DecisionFeatureExportError('CSV quote inside unquoted field');
|
|
783
|
+
state = 'quoted';
|
|
784
|
+
}
|
|
785
|
+
else if (char === ',')
|
|
786
|
+
finishField();
|
|
787
|
+
else if (char === '\n')
|
|
788
|
+
finishRow();
|
|
789
|
+
else if (char === '\r') {
|
|
790
|
+
if (payload[index + 1] !== '\n')
|
|
791
|
+
throw new DecisionFeatureExportError('CSV bare carriage return is invalid');
|
|
792
|
+
finishRow();
|
|
793
|
+
index++;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
field += char;
|
|
797
|
+
state = 'unquoted';
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
if (state === 'quoted')
|
|
801
|
+
throw new DecisionFeatureExportError('CSV quoted field is unterminated');
|
|
802
|
+
if (state === 'closed' || field || row.length)
|
|
803
|
+
finishField();
|
|
804
|
+
if (row.length)
|
|
805
|
+
rows.push(row);
|
|
806
|
+
return rows;
|
|
807
|
+
}
|
|
808
|
+
function observationFromCsvRecord(featureSet, header, record) {
|
|
809
|
+
if (record.length !== header.length)
|
|
810
|
+
throw new DecisionFeatureExportError('CSV feature row has the wrong width');
|
|
811
|
+
const at = (name) => record[header.indexOf(name)] ?? '';
|
|
812
|
+
const features = {};
|
|
813
|
+
for (const column of featureSet.spec.columns) {
|
|
814
|
+
const value = at(`${column.name}__value`);
|
|
815
|
+
const state = at(`${column.name}__state`);
|
|
816
|
+
const derivation = at(`${column.name}__derivation`);
|
|
817
|
+
features[column.name] = {
|
|
818
|
+
value: parseCsvFeatureValue(column, value),
|
|
819
|
+
state,
|
|
820
|
+
source: at(`${column.name}__source`),
|
|
821
|
+
missingReason: (at(`${column.name}__missing`) || null),
|
|
822
|
+
calibrationRef: at(`${column.name}__calibration`) || null,
|
|
823
|
+
derivation: derivation ? { algorithm: derivation.split('@')[0], version: derivation.split('@')[1] ?? '' } : null,
|
|
824
|
+
evidencePath: '$.csv',
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function parseCsvFeatureValue(column, value) {
|
|
828
|
+
if (value === '')
|
|
829
|
+
return null;
|
|
830
|
+
if (column.valueType === 'number') {
|
|
831
|
+
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/u.test(value)) {
|
|
832
|
+
throw new DecisionFeatureExportError(`CSV numeric feature ${column.name} is not canonical`);
|
|
833
|
+
}
|
|
834
|
+
const parsed = Number(value);
|
|
835
|
+
if (!Number.isFinite(parsed))
|
|
836
|
+
throw new DecisionFeatureExportError(`CSV numeric feature ${column.name} is not finite`);
|
|
837
|
+
return parsed;
|
|
838
|
+
}
|
|
839
|
+
if (column.valueType === 'boolean') {
|
|
840
|
+
if (value !== 'true' && value !== 'false')
|
|
841
|
+
throw new DecisionFeatureExportError(`CSV boolean feature ${column.name} is not canonical`);
|
|
842
|
+
return value === 'true';
|
|
843
|
+
}
|
|
844
|
+
return value;
|
|
845
|
+
}
|
|
846
|
+
const semantic = JSON.parse(at('aiwgSemantic'));
|
|
847
|
+
assertCsvFixedColumn(at, 'observationId', semantic.observationId);
|
|
848
|
+
assertCsvFixedColumn(at, 'subjectId', semantic.subjectId);
|
|
849
|
+
assertCsvFixedColumn(at, 'eventTime', semantic.eventTime);
|
|
850
|
+
assertCsvFixedColumn(at, 'exportTime', semantic.exportTime);
|
|
851
|
+
assertCsvFixedColumn(at, 'featureSetId', semantic.featureSet.id);
|
|
852
|
+
assertCsvFixedColumn(at, 'featureSetVersion', semantic.featureSet.version);
|
|
853
|
+
assertCsvFixedColumn(at, 'featureSetDigest', semantic.featureSet.digest);
|
|
854
|
+
if (canonicalJson(visibleFeatureCells(semantic.features)) !== canonicalJson(visibleFeatureCells(features))) {
|
|
855
|
+
throw new DecisionFeatureExportError('CSV semantic row does not match visible feature columns');
|
|
856
|
+
}
|
|
857
|
+
const observation = {
|
|
858
|
+
schemaVersion: DECISION_FEATURE_EXPORT_VERSION,
|
|
859
|
+
kind: DECISION_FEATURE_OBSERVATION_KIND,
|
|
860
|
+
featureSet: semantic.featureSet,
|
|
861
|
+
observationId: semantic.observationId,
|
|
862
|
+
subjectId: semantic.subjectId,
|
|
863
|
+
eventTime: semantic.eventTime,
|
|
864
|
+
exportTime: semantic.exportTime,
|
|
865
|
+
sourceReceipt: semantic.sourceReceipt,
|
|
866
|
+
authorization: semantic.authorization,
|
|
867
|
+
lifecycle: semantic.lifecycle,
|
|
868
|
+
lineage: semantic.lineage,
|
|
869
|
+
trainServe: semantic.trainServe,
|
|
870
|
+
batchAccounting: semantic.batchAccounting,
|
|
871
|
+
features: Object.fromEntries(featureSet.spec.columns.map(column => [column.name, semantic.features[column.name]])),
|
|
872
|
+
};
|
|
873
|
+
validateDecisionFeatureObservation(featureSet, observation);
|
|
874
|
+
return observation;
|
|
875
|
+
}
|
|
876
|
+
function assertCsvFixedColumn(at, name, expected) {
|
|
877
|
+
if (at(name) !== expected)
|
|
878
|
+
throw new DecisionFeatureExportError(`CSV fixed column ${name} does not match semantic row`);
|
|
879
|
+
}
|
|
880
|
+
function csvEscape(value) {
|
|
881
|
+
return /[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
|
|
882
|
+
}
|
|
883
|
+
function authIdentity(policy) {
|
|
884
|
+
return { tenantId: policy.tenantId, projectId: policy.projectId, actorId: policy.actorId,
|
|
885
|
+
recipient: policy.recipient, purpose: policy.purpose, datasetPolicyId: policy.datasetPolicyId,
|
|
886
|
+
accessScope: policy.accessScope };
|
|
887
|
+
}
|
|
888
|
+
function requireSamePin(actual, expected, label) {
|
|
889
|
+
if (actual.id !== expected.id || actual.version !== expected.version || actual.digest !== expected.digest) {
|
|
890
|
+
throw new DecisionFeatureExportError(`Feature export ${label} pin mismatch`);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
function validateObservationForExport(observation, policy, nowEpochMs) {
|
|
894
|
+
if (!sameIdentity(observation.authorization, authIdentity(policy))) {
|
|
895
|
+
throw new DecisionFeatureExportError('Feature export authorization does not match the observation');
|
|
896
|
+
}
|
|
897
|
+
assertExportLifecycleActive(observation, nowEpochMs);
|
|
898
|
+
assertSafeExportMetadata(observation);
|
|
899
|
+
assertNoFeatureLeakage(observation, policy);
|
|
900
|
+
}
|
|
901
|
+
function lifecycleActive(observation, nowEpochMs) {
|
|
902
|
+
return !observation.lifecycle.tombstone && observation.lifecycle.deletedAtEpochMs === null
|
|
903
|
+
&& observation.lifecycle.revokedAtEpochMs === null && nowEpochMs < observation.lifecycle.expiresAtEpochMs;
|
|
904
|
+
}
|
|
905
|
+
function assertExportLifecycleActive(observation, nowEpochMs) {
|
|
906
|
+
if (!Number.isSafeInteger(nowEpochMs) || nowEpochMs < 0)
|
|
907
|
+
throw new DecisionFeatureExportError('Feature export time is invalid');
|
|
908
|
+
if (!lifecycleActive(observation, nowEpochMs))
|
|
909
|
+
throw new DecisionFeatureExportError('Feature export lifecycle is not active');
|
|
910
|
+
}
|
|
911
|
+
function sameIdentity(left, right) {
|
|
912
|
+
return left.tenantId === right.tenantId && left.projectId === right.projectId && left.actorId === right.actorId
|
|
913
|
+
&& left.recipient === right.recipient && left.purpose === right.purpose
|
|
914
|
+
&& left.datasetPolicyId === right.datasetPolicyId && left.accessScope === right.accessScope;
|
|
915
|
+
}
|
|
916
|
+
function sameStringSet(left, right) {
|
|
917
|
+
return left.length === right.length && left.every(value => right.includes(value));
|
|
918
|
+
}
|
|
919
|
+
function addNullable(left, right) {
|
|
920
|
+
return left === null || right === null ? null : left + right;
|
|
921
|
+
}
|
|
922
|
+
export function buildDecisionFeatureEvalIntegrity(input) {
|
|
923
|
+
const upstreamDisposition = input.qualificationRelease.decision;
|
|
924
|
+
const disposition = input.disposition ?? upstreamDisposition;
|
|
925
|
+
const payload = {
|
|
926
|
+
schemaVersion: 'decision-feature-eval-integrity/v1',
|
|
927
|
+
qualificationRelease: structuredClone(input.qualificationRelease),
|
|
928
|
+
upstreamDisposition,
|
|
929
|
+
disposition,
|
|
930
|
+
reportRef: input.reportRef,
|
|
931
|
+
};
|
|
932
|
+
const value = { ...payload, digest: digestEvalIntegrity(payload) };
|
|
933
|
+
return normalizeEvalIntegrity(value);
|
|
934
|
+
}
|
|
935
|
+
function normalizeEvalIntegrity(value) {
|
|
936
|
+
if (!value)
|
|
937
|
+
return undefined;
|
|
938
|
+
if (value.schemaVersion !== 'decision-feature-eval-integrity/v1' || !value.reportRef) {
|
|
939
|
+
throw new DecisionFeatureExportError('Feature export integrity report is invalid');
|
|
940
|
+
}
|
|
941
|
+
validateQualificationRelease(value.qualificationRelease);
|
|
942
|
+
if (value.upstreamDisposition !== value.qualificationRelease.decision) {
|
|
943
|
+
throw new DecisionFeatureExportError('Feature export integrity must preserve the upstream qualification release decision');
|
|
944
|
+
}
|
|
945
|
+
if (integrityRank(value.disposition) < integrityRank(value.upstreamDisposition)) {
|
|
946
|
+
throw new DecisionFeatureExportError(`Feature export integrity cannot upgrade ${value.upstreamDisposition} to ${value.disposition}`);
|
|
947
|
+
}
|
|
948
|
+
const { digest, ...payload } = value;
|
|
949
|
+
if (digest !== digestEvalIntegrity(payload))
|
|
950
|
+
throw new DecisionFeatureExportError('Feature export integrity digest does not match its content');
|
|
951
|
+
return structuredClone(value);
|
|
952
|
+
}
|
|
953
|
+
function validateQualificationRelease(record) {
|
|
954
|
+
if (!record || (record.schemaVersion !== 'decision-qualification-release/v1'
|
|
955
|
+
&& record.schemaVersion !== 'decision-qualification-release/v2') || !record.runId || !record.sourceCommit
|
|
956
|
+
|| !['PROMOTE', 'HOLD', 'ROLLBACK'].includes(record.decision) || !/^sha256:[0-9a-f]{64}$/.test(record.digest)) {
|
|
957
|
+
throw new DecisionFeatureExportError('Feature export qualification release is invalid');
|
|
958
|
+
}
|
|
959
|
+
// Versioned legacy allowlist: pre-migration v1 release records hashed
|
|
960
|
+
// JSON.stringify output. The v1 schema version is the pre-migration lineage,
|
|
961
|
+
// so it alone allowlists legacy; v2 verifies canonical-only even under an
|
|
962
|
+
// explicit allowlist.
|
|
963
|
+
const digestModes = record.schemaVersion === 'decision-qualification-release/v1'
|
|
964
|
+
? ['canonical', 'legacy'] : ['canonical'];
|
|
965
|
+
if (verifyQualificationReleaseDigest(record, { digestModes: [...digestModes] }) === null) {
|
|
966
|
+
throw new DecisionFeatureExportError('Feature export qualification release digest does not match its content');
|
|
967
|
+
}
|
|
968
|
+
validateQualificationIntegrity(record.integrity);
|
|
969
|
+
if (integrityRank(record.decision) < integrityRank(record.integrity.release_gate.decision)) {
|
|
970
|
+
throw new DecisionFeatureExportError('Feature export qualification release cannot upgrade its integrity release gate');
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function validateQualificationIntegrity(value) {
|
|
974
|
+
if (!value || !Number.isSafeInteger(value.sample_n) || value.sample_n < 0
|
|
975
|
+
|| typeof value.integrity_mode !== 'string' || !value.integrity_mode
|
|
976
|
+
|| typeof value.fresh_workspace_required !== 'boolean' || typeof value.fresh_workspace_verified !== 'boolean'
|
|
977
|
+
|| typeof value.integrity_state !== 'string' || !value.integrity_state
|
|
978
|
+
|| typeof value.trusted_score_source !== 'string' || !value.trusted_score_source
|
|
979
|
+
|| !Array.isArray(value.compromise_labels) || value.compromise_labels.some(label => typeof label !== 'string')
|
|
980
|
+
|| !(value.weak_signal_reason === null || typeof value.weak_signal_reason === 'string')
|
|
981
|
+
|| !value.release_gate || !['PROMOTE', 'HOLD', 'ROLLBACK'].includes(value.release_gate.decision)
|
|
982
|
+
|| !Array.isArray(value.release_gate.reasons) || value.release_gate.reasons.some(reason => typeof reason !== 'string')) {
|
|
983
|
+
throw new DecisionFeatureExportError('Feature export integrity report is invalid');
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function digestEvalIntegrity(value) {
|
|
987
|
+
return `sha256:${createHash('sha256').update(canonicalJson(value)).digest('hex')}`;
|
|
988
|
+
}
|
|
989
|
+
function integrityRank(decision) {
|
|
990
|
+
return { PROMOTE: 0, HOLD: 1, ROLLBACK: 2 }[decision];
|
|
991
|
+
}
|
|
992
|
+
function activeExportHold(hold, subject, nowEpochMs) {
|
|
993
|
+
return hold.subject === subject && hold.expiresAt > nowEpochMs && hold.scope.includes('export');
|
|
994
|
+
}
|
|
995
|
+
function latestActualModel(decision) {
|
|
996
|
+
for (let index = decision.spec.attempts.length - 1; index >= 0; index--) {
|
|
997
|
+
const model = decision.spec.attempts[index].actualModel;
|
|
998
|
+
if (model)
|
|
999
|
+
return model;
|
|
1000
|
+
}
|
|
1001
|
+
return null;
|
|
1002
|
+
}
|
|
1003
|
+
function opaqueExportReference(featureSet, subjectId, invocationId, alias) {
|
|
1004
|
+
return digestText(`${featureSet.metadata.digest}:${subjectId}:${invocationId}:${alias ?? 'ruleset'}`).slice(0, 40);
|
|
1005
|
+
}
|
|
1006
|
+
function digestText(value) {
|
|
1007
|
+
return createHash('sha256').update(value).digest('hex');
|
|
1008
|
+
}
|
|
1009
|
+
const SAFE_EXPORT_IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
1010
|
+
const SENSITIVE_EXPORT_IDENTIFIER = /(?:bearer\s+\S+|(?:https?|file|s3):\/\/|\/home\/|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}|sk-[A-Za-z0-9_-]{16,}|AKIA[0-9A-Z]{16}|private)/iu;
|
|
1011
|
+
function assertSafeExportMetadata(observation) {
|
|
1012
|
+
const values = [
|
|
1013
|
+
['featureSet.id', observation.featureSet.id], ['featureSet.version', observation.featureSet.version],
|
|
1014
|
+
['observationId', observation.observationId], ['subjectId', observation.subjectId],
|
|
1015
|
+
['sourceReceipt.runId', observation.sourceReceipt.runId], ['sourceReceipt.invocationId', observation.sourceReceipt.invocationId],
|
|
1016
|
+
['sourceReceipt.alias', observation.sourceReceipt.alias], ['lifecycle.reference', observation.lifecycle.reference],
|
|
1017
|
+
['authorization.tenantId', observation.authorization.tenantId], ['authorization.projectId', observation.authorization.projectId],
|
|
1018
|
+
['authorization.actorId', observation.authorization.actorId], ['authorization.recipient', observation.authorization.recipient],
|
|
1019
|
+
['authorization.purpose', observation.authorization.purpose], ['authorization.datasetPolicyId', observation.authorization.datasetPolicyId],
|
|
1020
|
+
['authorization.accessScope', observation.authorization.accessScope],
|
|
1021
|
+
['lineage.ruleset.id', observation.lineage.ruleset.id], ['lineage.binding.id', observation.lineage.binding.id],
|
|
1022
|
+
['lineage.decision.id', observation.lineage.decision?.id ?? null], ['lineage.definition.id', observation.lineage.definition?.id ?? null],
|
|
1023
|
+
];
|
|
1024
|
+
for (const owner of observation.batchAccounting.owners) {
|
|
1025
|
+
values.push(['batchAccounting.ownerId', owner.ownerId], ['batchAccounting.runId', owner.runId], ['batchAccounting.invocationId', owner.invocationId], ['batchAccounting.groupId', owner.groupId], ['batchAccounting.requestId', owner.requestId]);
|
|
1026
|
+
owner.questionIds.forEach(questionId => values.push(['batchAccounting.questionId', questionId]));
|
|
1027
|
+
}
|
|
1028
|
+
for (const [label, value] of values)
|
|
1029
|
+
assertSafeExportIdentifier(value, label);
|
|
1030
|
+
}
|
|
1031
|
+
function assertSafeExportIdentifier(value, label) {
|
|
1032
|
+
if (value === null)
|
|
1033
|
+
return;
|
|
1034
|
+
if (!SAFE_EXPORT_IDENTIFIER.test(value) || SENSITIVE_EXPORT_IDENTIFIER.test(value)) {
|
|
1035
|
+
throw new DecisionFeatureExportError(`Feature export metadata identifier is outside the safe opaque subset: ${label}`);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
function assertNoFeatureLeakage(observation, authorization) {
|
|
1039
|
+
const serialized = canonicalJson(observation);
|
|
1040
|
+
for (const forbidden of ['rawState', 'prompt', 'credential', 'responseBody', 'privateLocator', 'SECRET_DO_NOT_EXPORT']) {
|
|
1041
|
+
if (serialized.includes(forbidden))
|
|
1042
|
+
throw new DecisionFeatureExportError(`Feature export leaked ${forbidden}`);
|
|
1043
|
+
}
|
|
1044
|
+
if (authorization.reidentificationCanary && serialized.includes(authorization.reidentificationCanary)) {
|
|
1045
|
+
throw new DecisionFeatureExportError('Feature export leaked the re-identification canary');
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
function normalizedSemanticRow(observation) {
|
|
1049
|
+
return {
|
|
1050
|
+
featureSet: observation.featureSet,
|
|
1051
|
+
observationId: observation.observationId,
|
|
1052
|
+
subjectId: observation.subjectId,
|
|
1053
|
+
eventTime: observation.eventTime,
|
|
1054
|
+
exportTime: observation.exportTime,
|
|
1055
|
+
sourceReceipt: observation.sourceReceipt,
|
|
1056
|
+
authorization: observation.authorization,
|
|
1057
|
+
lifecycle: observation.lifecycle,
|
|
1058
|
+
lineage: observation.lineage,
|
|
1059
|
+
trainServe: observation.trainServe,
|
|
1060
|
+
batchAccounting: observation.batchAccounting,
|
|
1061
|
+
features: observation.features,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
function visibleFeatureCells(features) {
|
|
1065
|
+
return Object.fromEntries(Object.entries(features).map(([name, cell]) => [name, {
|
|
1066
|
+
value: cell.value,
|
|
1067
|
+
state: cell.state,
|
|
1068
|
+
source: cell.source,
|
|
1069
|
+
missingReason: cell.missingReason,
|
|
1070
|
+
calibrationRef: cell.calibrationRef,
|
|
1071
|
+
derivation: cell.derivation,
|
|
1072
|
+
}]));
|
|
1073
|
+
}
|
|
1074
|
+
//# sourceMappingURL=feature-export.js.map
|