graphlin 0.1.3 → 0.2.1
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/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +12 -3
- package/docs/decision-service.md +393 -0
- package/docs/extension-authoring.md +553 -0
- package/docs/model-api.md +293 -0
- package/docs/usage.md +472 -0
- package/docs/visualizer-views.md +240 -0
- package/node_modules/@vscode/tree-sitter-wasm/LICENSE +21 -0
- package/node_modules/@vscode/tree-sitter-wasm/README.md +36 -0
- package/node_modules/@vscode/tree-sitter-wasm/SECURITY.md +41 -0
- package/node_modules/@vscode/tree-sitter-wasm/cgmanifest.json +16 -0
- package/node_modules/@vscode/tree-sitter-wasm/package.json +42 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-bash.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-c-sharp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-cpp.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-css.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-go.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ini.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-java.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-javascript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-php.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-powershell.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-python.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-regex.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-ruby.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-rust.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-tsx.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter-typescript.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.js +4075 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/tree-sitter.wasm +0 -0
- package/node_modules/@vscode/tree-sitter-wasm/wasm/web-tree-sitter.d.ts +1027 -0
- package/package.json +78 -9
- package/plugin.json +4 -2
- package/runtime/architecture/analysis.mjs +344 -0
- package/runtime/architecture/controller.mjs +209 -0
- package/runtime/architecture/evidence.mjs +108 -0
- package/runtime/architecture/profile.mjs +56 -0
- package/runtime/core/evidence.mjs +43 -9
- package/runtime/core/graph.mjs +11 -6
- package/runtime/core/privacy.mjs +1 -0
- package/runtime/daemon/auth.mjs +7 -3
- package/runtime/daemon/diagnostics.mjs +1 -1
- package/runtime/daemon/extension-api.mjs +203 -0
- package/runtime/daemon/lineage.mjs +70 -0
- package/runtime/daemon/manager.mjs +9 -6
- package/runtime/daemon/model-api.mjs +728 -0
- package/runtime/daemon/model-persistence.mjs +220 -0
- package/runtime/daemon/server.mjs +81 -14
- package/runtime/daemon/settings.mjs +11 -3
- package/runtime/decisions/broker.mjs +349 -0
- package/runtime/decisions/contracts.mjs +179 -0
- package/runtime/decisions/evaluation.mjs +305 -0
- package/runtime/decisions/faults.mjs +32 -0
- package/runtime/decisions/index.mjs +818 -0
- package/runtime/decisions/profiles.mjs +93 -0
- package/runtime/decisions/questions.mjs +268 -0
- package/runtime/discovery/index.mjs +2 -0
- package/runtime/discovery/inventory.mjs +160 -0
- package/runtime/discovery/parser.mjs +40 -0
- package/runtime/discovery/structure.mjs +232 -0
- package/runtime/extensions/contracts.mjs +59 -0
- package/runtime/extensions/frame.mjs +64 -0
- package/runtime/extensions/index.mjs +9 -0
- package/runtime/extensions/manifest.mjs +95 -0
- package/runtime/extensions/packages.mjs +222 -0
- package/runtime/extensions/profiles.mjs +36 -0
- package/runtime/extensions/projection.mjs +130 -0
- package/runtime/extensions/registry.mjs +285 -0
- package/runtime/extensions/scene.mjs +105 -0
- package/runtime/extensions/sdk.d.ts +205 -0
- package/runtime/extensions/sdk.mjs +88 -0
- package/runtime/jev/index.mjs +13 -777
- package/runtime/jev/provider.mjs +101 -0
- package/runtime/jev/questions.mjs +16 -258
- package/runtime/jev/wire.mjs +17 -25
- package/runtime/model/changes.mjs +42 -0
- package/runtime/model/history.mjs +124 -0
- package/runtime/model/index.mjs +2 -0
- package/runtime/model/project-model.mjs +1020 -0
- package/runtime/model/records.mjs +240 -0
- package/runtime/pipeline.mjs +267 -55
- package/runtime/platform.mjs +254 -0
- package/runtime/visualizers/blocks.mjs +5 -0
- package/runtime/visualizers/c4.mjs +154 -0
- package/runtime/visualizers/changes.mjs +24 -0
- package/runtime/visualizers/code.mjs +5 -0
- package/runtime/visualizers/index.mjs +23 -0
- package/runtime/visualizers/structure.mjs +120 -0
- package/runtime/visualizers/timeline.mjs +66 -0
- package/runtime/web/app.js +225 -63
- package/runtime/web/extension-frame.js +128 -0
- package/runtime/web/index.html +38 -1
- package/runtime/web/model-client.js +162 -0
- package/runtime/web/platform.js +445 -0
- package/runtime/web/scene.js +111 -0
- package/runtime/web/style.css +51 -0
- package/schemas/graph.schema.json +4 -1
- package/scripts/arguments.mjs +5 -1
- package/scripts/build-packages.mjs +6 -2
- package/scripts/control.mjs +1 -1
- package/scripts/daemon.mjs +2 -1
- package/scripts/extensions.mjs +44 -0
- package/scripts/graphlin.mjs +23 -3
- package/scripts/onboarding.mjs +10 -3
- package/scripts/validate-packages.mjs +54 -8
package/runtime/jev/index.mjs
CHANGED
|
@@ -1,784 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
} from './wire.mjs';
|
|
7
|
-
import { FIXTURE_TRANSPORT } from './fixture.mjs';
|
|
1
|
+
// Compatibility entry point: existing callers still select the Jev adapter.
|
|
2
|
+
import { createDecisionService as createService } from '../decisions/index.mjs';
|
|
3
|
+
import { DecisionFault } from '../decisions/faults.mjs';
|
|
4
|
+
import { createJevProvider } from './provider.mjs';
|
|
5
|
+
import { JevFault } from './wire.mjs';
|
|
8
6
|
|
|
7
|
+
export { DEFAULT_LIMITS, DEFAULT_INTAKE_POLICY, DEFAULT_ADMISSION_POLICY } from '../decisions/index.mjs';
|
|
9
8
|
export { createFixtureTransport } from './fixture.mjs';
|
|
9
|
+
export { createJevProvider } from './provider.mjs';
|
|
10
10
|
|
|
11
|
-
const ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
|
|
12
|
-
export const DEFAULT_LIMITS = Object.freeze({
|
|
13
|
-
concurrency: 2,
|
|
14
|
-
maxQueue: 32,
|
|
15
|
-
eventDeadlineMs: 2000,
|
|
16
|
-
maxRequestsPerEvent: 2,
|
|
17
|
-
maxCandidates: 12,
|
|
18
|
-
maxQuestionsPerStage: 40,
|
|
19
|
-
maxRelationProposals: 12,
|
|
20
|
-
maxRequestBytes: 64 * 1024,
|
|
21
|
-
maxResponseBytes: 256 * 1024,
|
|
22
|
-
maxCandidateBytes: 8192,
|
|
23
|
-
maxLabelBytes: 256,
|
|
24
|
-
cooldownMs: 1000,
|
|
25
|
-
maxCooldownMs: 30_000,
|
|
26
|
-
});
|
|
27
|
-
export const DEFAULT_INTAKE_POLICY = Object.freeze({
|
|
28
|
-
version: 'intake-policy-v1', sensitiveMax: 0.1, relevantMin: 0.5,
|
|
29
|
-
});
|
|
30
|
-
export const DEFAULT_ADMISSION_POLICY = Object.freeze({
|
|
31
|
-
version: 'admission-policy-v1',
|
|
32
|
-
relevanceMin: 0.3,
|
|
33
|
-
nodeSupportMin: 0.85,
|
|
34
|
-
roleProbabilityMin: 0.8,
|
|
35
|
-
roleConfidenceMin: 0.6,
|
|
36
|
-
edgeSupportMin: 0.85,
|
|
37
|
-
missingContextMax: 0.1,
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const kinds = new Set([
|
|
41
|
-
'session.started', 'turn.prompted', 'intent.observed', 'tool.requested',
|
|
42
|
-
'tool.succeeded', 'tool.failed', 'tool.interrupted', 'tool.denied',
|
|
43
|
-
'tool.unresolved', 'batch.completed', 'artifact.changed',
|
|
44
|
-
'verification.observed', 'agent.started', 'agent.stopped', 'turn.stopped',
|
|
45
|
-
'session.ended', 'capture.gap',
|
|
46
|
-
]);
|
|
47
|
-
const categories = new Set(['read', 'write', 'edit', 'search', 'shell', 'test', 'other']);
|
|
48
|
-
const outcomes = new Set(['succeeded', 'failed', 'interrupted', 'denied', 'unresolved',
|
|
49
|
-
'pending', 'running', 'observed', 'unknown']);
|
|
50
|
-
const boundedId = (value) => typeof value === 'string'
|
|
51
|
-
&& /^[A-Za-z0-9_.:-]{1,160}$/.test(value);
|
|
52
|
-
const validVersion = (value) => boundedId(value)
|
|
53
|
-
|| (Number.isSafeInteger(value) && value >= 0);
|
|
54
|
-
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
55
|
-
|
|
56
|
-
// Audit data is a separate allowlisted projection, never a copy of inputs or
|
|
57
|
-
// transport errors. Core's opaque IDs retain their identity; arbitrary IDs from
|
|
58
|
-
// injected adapters are hashed so labels/relative paths cannot become log text.
|
|
59
|
-
const auditId = (value, prefix = 'candidate') =>
|
|
60
|
-
new RegExp(`^${prefix}-[a-f0-9]{32}$`).test(value)
|
|
61
|
-
? value : `${prefix}-${hash(value).slice(0, 32)}`;
|
|
62
|
-
const auditStatuses = new Set([
|
|
63
|
-
'ok', 'accepted', 'irrelevant', 'abstained', 'invalid', 'unavailable', 'timeout', 'overloaded',
|
|
64
|
-
]);
|
|
65
|
-
const auditCodes = new Set([
|
|
66
|
-
'ok', 'invalid_event', 'invalid_candidates', 'invalid_candidate', 'candidate_too_large',
|
|
67
|
-
'invalid_bundle', 'invalid_proposals', 'invalid_proposal', 'duplicate_proposal',
|
|
68
|
-
'core_unavailable', 'deadline_exceeded', 'request_budget', 'question_budget',
|
|
69
|
-
'request_too_large', 'invalid_http_response', 'remote_cooldown', 'authentication_failed',
|
|
70
|
-
'request_rejected', 'http_error', 'transport_failure', 'no_approved_candidates',
|
|
71
|
-
'insufficient_relevance', 'no_accepted_classification', 'decision_failure',
|
|
72
|
-
'service_closed', 'invalid_input', 'invalid_signal', 'metadata_only', 'missing_key',
|
|
73
|
-
'cancelled', 'invalid_deadline', 'queue_full', 'invalid_policy', 'no_candidates',
|
|
74
|
-
'inconsistent_evidence', 'invalid_probabilities', 'invalid_probability_sum',
|
|
75
|
-
'invalid_response', 'invalid_answer_type', 'invalid_noul', 'invalid_confidence',
|
|
76
|
-
'invalid_choice', 'invalid_score', 'invalid_question_type', 'invalid_response_body',
|
|
77
|
-
'response_too_large', 'invalid_json', 'unknown_fixture_question',
|
|
78
|
-
]);
|
|
79
|
-
const auditOutcome = (status, code) => ({
|
|
80
|
-
status: auditStatuses.has(status) ? status : 'unavailable',
|
|
81
|
-
code: auditCodes.has(code) ? code : 'decision_failure',
|
|
82
|
-
});
|
|
83
|
-
const auditDuration = (start, end) => Number.isFinite(end - start)
|
|
84
|
-
? Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, end - start)) : 0;
|
|
85
|
-
|
|
86
|
-
function freeze(value) {
|
|
87
|
-
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
88
|
-
Object.values(value).forEach(freeze);
|
|
89
|
-
Object.freeze(value);
|
|
90
|
-
}
|
|
91
|
-
return value;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function safeEvent(event) {
|
|
95
|
-
if (!isRecord(event)) throw new JevFault('invalid_event');
|
|
96
|
-
return {
|
|
97
|
-
kind: kinds.has(event.kind) ? event.kind : 'capture.gap',
|
|
98
|
-
toolCategory: categories.has(event.toolCategory) ? event.toolCategory : 'other',
|
|
99
|
-
outcome: outcomes.has(event.outcome) ? event.outcome : 'unknown',
|
|
100
|
-
incomplete: event.incomplete !== false,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function validateSourceRef(ref) {
|
|
105
|
-
if (!isRecord(ref) || !boundedId(ref.hash)) return false;
|
|
106
|
-
if (ref.type === 'artifact') {
|
|
107
|
-
return boundedId(ref.artifactId)
|
|
108
|
-
&& Number.isSafeInteger(ref.generation) && ref.generation >= 0;
|
|
109
|
-
}
|
|
110
|
-
return ref.type === 'message' && boundedId(ref.messageId)
|
|
111
|
-
&& validVersion(ref.contentVersion);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function snapshotCandidates(input, limits) {
|
|
115
|
-
if (!Array.isArray(input)) throw new JevFault('invalid_candidates');
|
|
116
|
-
// Candidate overflow is coverage loss, not another request or unbounded cloning.
|
|
117
|
-
const maximum = Math.min(limits.maxCandidates,
|
|
118
|
-
Math.max(0, Math.floor((limits.maxQuestionsPerStage - 1) / 2)));
|
|
119
|
-
const candidates = [];
|
|
120
|
-
const ids = new Set();
|
|
121
|
-
for (const candidate of input.slice(0, maximum)) {
|
|
122
|
-
if (!isRecord(candidate) || !boundedId(candidate.id) || ids.has(candidate.id)
|
|
123
|
-
|| !boundedId(candidate.digest) || !boundedId(candidate.entityKey)
|
|
124
|
-
|| typeof candidate.label !== 'string'
|
|
125
|
-
|| Buffer.byteLength(candidate.label) > limits.maxLabelBytes
|
|
126
|
-
|| typeof candidate.text !== 'string'
|
|
127
|
-
|| Buffer.byteLength(candidate.text) > limits.maxCandidateBytes
|
|
128
|
-
|| !['source', 'public_intent'].includes(candidate.sourceClass)
|
|
129
|
-
|| typeof candidate.complete !== 'boolean'
|
|
130
|
-
|| !Number.isSafeInteger(candidate.startLine) || candidate.startLine < 1
|
|
131
|
-
|| !Number.isSafeInteger(candidate.endLine) || candidate.endLine < candidate.startLine
|
|
132
|
-
|| !validateSourceRef(candidate.sourceRef)
|
|
133
|
-
|| (candidate.sourceClass === 'source' && candidate.sourceRef.type !== 'artifact')
|
|
134
|
-
|| (candidate.sourceClass === 'public_intent' && candidate.sourceRef.type !== 'message')) {
|
|
135
|
-
throw new JevFault('invalid_candidate');
|
|
136
|
-
}
|
|
137
|
-
// Bound local metadata too; it is retained for core, never sent on the wire.
|
|
138
|
-
let encoded;
|
|
139
|
-
try { encoded = JSON.stringify(candidate); } catch { throw new JevFault('invalid_candidate'); }
|
|
140
|
-
if (Buffer.byteLength(encoded) > limits.maxCandidateBytes + limits.maxLabelBytes + 4096) {
|
|
141
|
-
throw new JevFault('candidate_too_large');
|
|
142
|
-
}
|
|
143
|
-
candidates.push(freeze(JSON.parse(encoded)));
|
|
144
|
-
ids.add(candidate.id);
|
|
145
|
-
}
|
|
146
|
-
return { candidates: freeze(candidates), omitted: input.length - candidates.length };
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function endpointFor(value, injected) {
|
|
150
|
-
let url;
|
|
151
|
-
try { url = new URL(value); } catch { throw new JevFault('invalid_endpoint'); }
|
|
152
|
-
if (url.href === ENDPOINT) return ENDPOINT;
|
|
153
|
-
if (!injected || !['http:', 'https:'].includes(url.protocol)
|
|
154
|
-
|| !['127.0.0.1', '[::1]', 'localhost'].includes(url.hostname)
|
|
155
|
-
|| url.username || url.password || url.search || url.hash
|
|
156
|
-
|| url.pathname !== '/v1/systemone') {
|
|
157
|
-
throw new JevFault('invalid_endpoint');
|
|
158
|
-
}
|
|
159
|
-
return url.href;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function normalizeLimits(options = {}) {
|
|
163
|
-
if (!isRecord(options) || Object.keys(options).some((key) => !(key in DEFAULT_LIMITS))) {
|
|
164
|
-
throw new JevFault('invalid_limits');
|
|
165
|
-
}
|
|
166
|
-
const limits = { ...DEFAULT_LIMITS, ...options };
|
|
167
|
-
for (const [key, value] of Object.entries(limits)) {
|
|
168
|
-
const minimum = ['maxQueue', 'maxRequestsPerEvent', 'maxRelationProposals'].includes(key) ? 0 : 1;
|
|
169
|
-
if (!Number.isSafeInteger(value) || value < minimum || value > 16 * 1024 * 1024) {
|
|
170
|
-
throw new JevFault('invalid_limits');
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
if (limits.concurrency > 64 || limits.maxQueue > 4096 || limits.maxCandidates > 128
|
|
174
|
-
|| limits.maxQuestionsPerStage > 1024 || limits.eventDeadlineMs > 60_000
|
|
175
|
-
|| limits.maxCooldownMs > 300_000 || limits.cooldownMs > limits.maxCooldownMs) {
|
|
176
|
-
throw new JevFault('invalid_limits');
|
|
177
|
-
}
|
|
178
|
-
return freeze(limits);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function normalizePolicy(input, defaults) {
|
|
182
|
-
const result = { ...defaults, ...input };
|
|
183
|
-
if (!validVersion(result.version)
|
|
184
|
-
|| Object.keys(defaults).some((key) => key !== 'version' && !isProbability(result[key]))) {
|
|
185
|
-
throw new JevFault('invalid_thresholds');
|
|
186
|
-
}
|
|
187
|
-
return freeze(result);
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
function validateBundle(bundle, candidates, verdicts, policy, intakePolicy) {
|
|
191
|
-
if (!isRecord(bundle) || !boundedId(bundle.id) || bundle.policyVersion !== policy.version
|
|
192
|
-
|| !Array.isArray(bundle.candidates) || !Array.isArray(bundle.readSet)
|
|
193
|
-
|| bundle.candidates.length > candidates.length || bundle.readSet.length > candidates.length) {
|
|
194
|
-
throw new JevFault('invalid_bundle');
|
|
195
|
-
}
|
|
196
|
-
const byId = new Map(candidates.map((candidate) => [candidate.id, candidate]));
|
|
197
|
-
const allowed = new Set(verdicts.filter((verdict) =>
|
|
198
|
-
verdict.sensitive <= intakePolicy.sensitiveMax && verdict.relevant >= intakePolicy.relevantMin)
|
|
199
|
-
.map((verdict) => verdict.candidateId));
|
|
200
|
-
const seen = new Set();
|
|
201
|
-
for (const candidate of bundle.candidates) {
|
|
202
|
-
if (!isRecord(candidate) || seen.has(candidate.id) || !allowed.has(candidate.id)
|
|
203
|
-
|| !isDeepStrictEqual(candidate, byId.get(candidate.id))) {
|
|
204
|
-
throw new JevFault('invalid_bundle');
|
|
205
|
-
}
|
|
206
|
-
seen.add(candidate.id);
|
|
207
|
-
}
|
|
208
|
-
const refMatches = (ref, candidate) => {
|
|
209
|
-
const source = candidate.sourceRef;
|
|
210
|
-
if (source.type === 'message') return isDeepStrictEqual(ref, source);
|
|
211
|
-
return isDeepStrictEqual(ref, source) || isDeepStrictEqual(ref, {
|
|
212
|
-
artifactId: source.artifactId, hash: source.hash, generation: source.generation,
|
|
213
|
-
});
|
|
214
|
-
};
|
|
215
|
-
if (!bundle.readSet.every((ref) => bundle.candidates.some((candidate) => refMatches(ref, candidate)))
|
|
216
|
-
|| !bundle.candidates.every((candidate) => bundle.readSet.some((ref) => refMatches(ref, candidate)))) {
|
|
217
|
-
throw new JevFault('invalid_bundle');
|
|
218
|
-
}
|
|
219
|
-
return freeze(bundle); // Preserve the exact core-owned bundle, including object identity.
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function validateProposals(result, bundle, maximum) {
|
|
223
|
-
if (!isRecord(result) || !Array.isArray(result.proposals)
|
|
224
|
-
|| !Number.isSafeInteger(result.omitted) || result.omitted < 0) {
|
|
225
|
-
throw new JevFault('invalid_proposals');
|
|
226
|
-
}
|
|
227
|
-
const ids = new Set(bundle.candidates.map((candidate) => candidate.id));
|
|
228
|
-
const seen = new Set();
|
|
229
|
-
const propositions = new Set();
|
|
230
|
-
const proposals = result.proposals.slice(0, maximum).map((proposal) => {
|
|
231
|
-
if (!isRecord(proposal) || !boundedId(proposal.id) || seen.has(proposal.id)
|
|
232
|
-
|| !ids.has(proposal.sourceCandidateId) || !ids.has(proposal.targetCandidateId)
|
|
233
|
-
|| proposal.sourceCandidateId === proposal.targetCandidateId
|
|
234
|
-
|| !RELATIONS.includes(proposal.relation) || !Array.isArray(proposal.evidenceCandidateIds)
|
|
235
|
-
|| proposal.evidenceCandidateIds.length < 2 || proposal.evidenceCandidateIds.length > ids.size
|
|
236
|
-
|| ![proposal.sourceCandidateId, proposal.targetCandidateId]
|
|
237
|
-
.every((id) => proposal.evidenceCandidateIds.includes(id))
|
|
238
|
-
|| new Set(proposal.evidenceCandidateIds).size !== proposal.evidenceCandidateIds.length
|
|
239
|
-
|| !proposal.evidenceCandidateIds.every((id) => ids.has(id))) {
|
|
240
|
-
throw new JevFault('invalid_proposal');
|
|
241
|
-
}
|
|
242
|
-
const identity = JSON.stringify([proposal.sourceCandidateId, proposal.targetCandidateId,
|
|
243
|
-
proposal.relation, proposal.evidenceCandidateIds]);
|
|
244
|
-
if (propositions.has(identity)) throw new JevFault('duplicate_proposal');
|
|
245
|
-
seen.add(proposal.id);
|
|
246
|
-
propositions.add(identity);
|
|
247
|
-
return freeze({
|
|
248
|
-
id: proposal.id,
|
|
249
|
-
sourceCandidateId: proposal.sourceCandidateId,
|
|
250
|
-
targetCandidateId: proposal.targetCandidateId,
|
|
251
|
-
relation: proposal.relation,
|
|
252
|
-
evidenceCandidateIds: [...proposal.evidenceCandidateIds],
|
|
253
|
-
});
|
|
254
|
-
});
|
|
255
|
-
return {
|
|
256
|
-
proposals: freeze(proposals),
|
|
257
|
-
omitted: result.omitted + result.proposals.length - proposals.length,
|
|
258
|
-
};
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Dependency-free, per-daemon two-stage classifier. Only supplied apiKey is used;
|
|
263
|
-
* environment credentials are never read. Core functions can be injected, and
|
|
264
|
-
* otherwise are lazily imported from ../core/index.mjs.
|
|
265
|
-
*/
|
|
266
11
|
export function createDecisionService(options = {}) {
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|| (apiKey !== undefined && (typeof apiKey !== 'string' || apiKey.length > 4096
|
|
273
|
-
|| /[\r\n]/.test(apiKey)))) {
|
|
274
|
-
throw new JevFault('invalid_configuration');
|
|
275
|
-
}
|
|
276
|
-
const target = endpointFor(endpoint, Object.hasOwn(options, 'fetchImpl'));
|
|
277
|
-
const limits = normalizeLimits(options.limits);
|
|
278
|
-
const intakePolicy = normalizePolicy(options.intakePolicy, DEFAULT_INTAKE_POLICY);
|
|
279
|
-
const admissionPolicy = normalizePolicy(options.admissionPolicy, DEFAULT_ADMISSION_POLICY);
|
|
280
|
-
if (intakePolicy.sensitiveMax >= 0.5 || admissionPolicy.missingContextMax >= 0.5) {
|
|
281
|
-
throw new JevFault('invalid_thresholds');
|
|
282
|
-
}
|
|
283
|
-
const auditThresholds = freeze({
|
|
284
|
-
intake: Object.fromEntries(Object.keys(DEFAULT_INTAKE_POLICY)
|
|
285
|
-
.filter((key) => key !== 'version').map((key) => [key, intakePolicy[key]])),
|
|
286
|
-
admission: Object.fromEntries(Object.keys(DEFAULT_ADMISSION_POLICY)
|
|
287
|
-
.filter((key) => key !== 'version').map((key) => [key, admissionPolicy[key]])),
|
|
288
|
-
});
|
|
289
|
-
const auditModel = typeof model === 'string' && model.length <= 64 ? model : 'unknown';
|
|
290
|
-
const clock = options.clock ?? {
|
|
291
|
-
now: Date.now, setTimeout: globalThis.setTimeout, clearTimeout: globalThis.clearTimeout,
|
|
292
|
-
};
|
|
293
|
-
if (!['now', 'setTimeout', 'clearTimeout'].every((key) => typeof clock[key] === 'function')) {
|
|
294
|
-
throw new JevFault('invalid_clock');
|
|
295
|
-
}
|
|
296
|
-
const mode = fetchImpl[FIXTURE_TRANSPORT] === true ? 'demo' : 'live';
|
|
297
|
-
const key = mode === 'demo' ? 'graphlin-offline-fixture' : apiKey;
|
|
298
|
-
let corePromise;
|
|
299
|
-
async function coreFunctions() {
|
|
300
|
-
if (options.materializeBundle && options.buildRelationProposals) {
|
|
301
|
-
return {
|
|
302
|
-
materializeBundle: options.materializeBundle,
|
|
303
|
-
buildRelationProposals: options.buildRelationProposals,
|
|
304
|
-
};
|
|
305
|
-
}
|
|
306
|
-
corePromise ??= import('../core/index.mjs');
|
|
307
|
-
let core;
|
|
308
|
-
try { core = await corePromise; } catch { throw new JevFault('core_unavailable', 'unavailable'); }
|
|
309
|
-
const result = {
|
|
310
|
-
materializeBundle: options.materializeBundle ?? core.materializeBundle,
|
|
311
|
-
buildRelationProposals: options.buildRelationProposals ?? core.buildRelationProposals,
|
|
312
|
-
};
|
|
313
|
-
if (Object.values(result).some((value) => typeof value !== 'function')) {
|
|
314
|
-
throw new JevFault('core_unavailable', 'unavailable');
|
|
12
|
+
try {
|
|
13
|
+
return createService({ ...options, provider: createJevProvider(options) });
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error instanceof DecisionFault && !(error instanceof JevFault)) {
|
|
16
|
+
throw new JevFault(error.code, error.status);
|
|
315
17
|
}
|
|
316
|
-
|
|
18
|
+
throw error;
|
|
317
19
|
}
|
|
318
|
-
|
|
319
|
-
let closed = false;
|
|
320
|
-
let active = 0;
|
|
321
|
-
let cooldownUntil = 0;
|
|
322
|
-
let wakeTimer;
|
|
323
|
-
const queue = [];
|
|
324
|
-
const running = new Set();
|
|
325
|
-
const counters = {
|
|
326
|
-
submitted: 0, completed: 0, calls: 0, callsA: 0, callsB: 0,
|
|
327
|
-
inputTokens: 0, outputTokens: 0, rejected: 0,
|
|
328
|
-
};
|
|
329
|
-
const statuses = {};
|
|
330
|
-
|
|
331
|
-
function check(job) {
|
|
332
|
-
if (!job.controller.signal.aborted && clock.now() >= job.deadlineAt) {
|
|
333
|
-
job.controller.abort(new JevFault('deadline_exceeded', 'timeout'));
|
|
334
|
-
}
|
|
335
|
-
if (job.controller.signal.aborted) throw abortFault(job.controller.signal);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Trace v1: intake.approved is the A threshold decision; materialized is null
|
|
339
|
-
// until core returns a valid bundle, then records actual bundle membership.
|
|
340
|
-
// Null activity/relevance and empty judgments mean no validated answers were
|
|
341
|
-
// available. Choice distributions contain only the known activity/role keys.
|
|
342
|
-
// "skipped" judgments retain B scores vetoed by overall relevance. Request
|
|
343
|
-
// duration includes any cooldown wait; dispatched distinguishes network calls
|
|
344
|
-
// from local rejections/waits. Existing diagnostics retain omission counts.
|
|
345
|
-
function traceFor(job, status, code) {
|
|
346
|
-
const outcome = auditOutcome(status, code ?? 'ok');
|
|
347
|
-
return {
|
|
348
|
-
version: 1,
|
|
349
|
-
outcome,
|
|
350
|
-
thresholds: auditThresholds,
|
|
351
|
-
activity: job.trace.activity ? {
|
|
352
|
-
...job.trace.activity, probabilities: { ...job.trace.activity.probabilities },
|
|
353
|
-
} : null,
|
|
354
|
-
intake: job.trace.intake.map((entry) => ({ ...entry })),
|
|
355
|
-
relevance: job.trace.relevance,
|
|
356
|
-
nodes: job.trace.nodes.map((entry) => ({
|
|
357
|
-
...entry, roleProbabilities: { ...entry.roleProbabilities }, reasons: [...entry.reasons],
|
|
358
|
-
})),
|
|
359
|
-
edges: job.trace.edges.map((entry) => ({ ...entry, reasons: [...entry.reasons] })),
|
|
360
|
-
// Abort listeners settle results before request catch/finally handlers.
|
|
361
|
-
// Snapshot unfinished attempts with the terminal outcome here; never freeze
|
|
362
|
-
// the mutable request records or leave a published "pending" record behind.
|
|
363
|
-
requests: job.trace.requests.map((entry) => ({
|
|
364
|
-
stage: entry.stage,
|
|
365
|
-
model: auditModel,
|
|
366
|
-
rubricVersion: RUBRICS[entry.stage],
|
|
367
|
-
...(entry.outcome ?? outcome),
|
|
368
|
-
durationMs: auditDuration(entry.startedAt, entry.finishedAt ?? clock.now()),
|
|
369
|
-
dispatched: entry.dispatched,
|
|
370
|
-
questionCount: entry.questionCount,
|
|
371
|
-
requestBytes: entry.requestBytes,
|
|
372
|
-
httpStatus: entry.httpStatus,
|
|
373
|
-
usage: entry.usage ? { ...entry.usage } : null,
|
|
374
|
-
})),
|
|
375
|
-
};
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
function resultFor(job, status, code, nodes = [], edges = []) {
|
|
379
|
-
return freeze({
|
|
380
|
-
status,
|
|
381
|
-
activity: job.activity ?? 'other',
|
|
382
|
-
bundle: job.bundle ?? null,
|
|
383
|
-
nodes,
|
|
384
|
-
edges,
|
|
385
|
-
stages: { ...job.stages },
|
|
386
|
-
diagnostics: {
|
|
387
|
-
code: code ?? 'ok',
|
|
388
|
-
codes: code ? [code] : [],
|
|
389
|
-
mode,
|
|
390
|
-
durationMs: Math.max(0, clock.now() - job.startedAt),
|
|
391
|
-
calls: job.calls ?? 0,
|
|
392
|
-
candidatesOmitted: job.candidatesOmitted ?? 0,
|
|
393
|
-
proposalsOmitted: job.proposalsOmitted ?? 0,
|
|
394
|
-
questionCounts: { ...job.questionCounts },
|
|
395
|
-
stageDurationMs: Object.fromEntries(Object.entries(job.stageStartedAt ?? {})
|
|
396
|
-
.map(([stage, startedAt]) => [stage, job.stageDurationMs?.[stage]
|
|
397
|
-
?? Math.max(0, clock.now() - startedAt)])),
|
|
398
|
-
usage: {
|
|
399
|
-
input_tokens: Object.values(job.stages ?? {})
|
|
400
|
-
.reduce((sum, stage) => sum + stage.usage.input_tokens, 0),
|
|
401
|
-
output_tokens: Object.values(job.stages ?? {})
|
|
402
|
-
.reduce((sum, stage) => sum + stage.usage.output_tokens, 0),
|
|
403
|
-
},
|
|
404
|
-
// Failed requests may have been billed even when no usage reached us.
|
|
405
|
-
usageIncomplete: (job.calls ?? 0) > Object.keys(job.stages ?? {}).length,
|
|
406
|
-
intakePolicyVersion: intakePolicy.version,
|
|
407
|
-
admissionPolicyVersion: admissionPolicy.version,
|
|
408
|
-
trace: traceFor(job, status, code),
|
|
409
|
-
},
|
|
410
|
-
});
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
function finish(job, result) {
|
|
414
|
-
if (job.settled) return;
|
|
415
|
-
job.settled = true;
|
|
416
|
-
clock.clearTimeout(job.timer);
|
|
417
|
-
job.externalSignal?.removeEventListener('abort', job.cancel);
|
|
418
|
-
job.controller.signal.removeEventListener('abort', job.onAbort);
|
|
419
|
-
const index = queue.indexOf(job);
|
|
420
|
-
if (index >= 0) queue.splice(index, 1);
|
|
421
|
-
counters.completed += 1;
|
|
422
|
-
statuses[result.status] = (statuses[result.status] ?? 0) + 1;
|
|
423
|
-
job.resolve(result);
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function cooldown(response) {
|
|
427
|
-
let delay = limits.cooldownMs;
|
|
428
|
-
const milliseconds = response.headers?.get('retry-after-ms');
|
|
429
|
-
const seconds = response.headers?.get('retry-after');
|
|
430
|
-
if (milliseconds && milliseconds.length <= 128 && Number.isFinite(Number(milliseconds))) {
|
|
431
|
-
delay = Number(milliseconds);
|
|
432
|
-
} else if (seconds && seconds.length <= 128) {
|
|
433
|
-
delay = /^\d+(?:\.\d+)?$/.test(seconds)
|
|
434
|
-
? Number(seconds) * 1000 : Date.parse(seconds) - clock.now();
|
|
435
|
-
}
|
|
436
|
-
if (!Number.isFinite(delay) || delay <= 0) delay = limits.cooldownMs;
|
|
437
|
-
cooldownUntil = Math.max(cooldownUntil, clock.now() + Math.min(delay, limits.maxCooldownMs));
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
async function waitForCooldown(job) {
|
|
441
|
-
while (clock.now() < cooldownUntil) {
|
|
442
|
-
check(job);
|
|
443
|
-
let timer;
|
|
444
|
-
try {
|
|
445
|
-
await withAbort(() => new Promise((resolve) => {
|
|
446
|
-
timer = clock.setTimeout(resolve, cooldownUntil - clock.now());
|
|
447
|
-
}), job.controller.signal);
|
|
448
|
-
} finally {
|
|
449
|
-
clock.clearTimeout(timer);
|
|
450
|
-
}
|
|
451
|
-
}
|
|
452
|
-
check(job);
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
async function request(job, stage, payload) {
|
|
456
|
-
const attempt = {
|
|
457
|
-
stage, startedAt: clock.now(), finishedAt: null, outcome: null,
|
|
458
|
-
dispatched: false, questionCount: null, requestBytes: null, httpStatus: null, usage: null,
|
|
459
|
-
};
|
|
460
|
-
job.trace.requests.push(attempt); // At most one A and one B attempt per job.
|
|
461
|
-
try {
|
|
462
|
-
check(job);
|
|
463
|
-
if (job.calls >= limits.maxRequestsPerEvent) throw new JevFault('request_budget', 'abstained');
|
|
464
|
-
const questionCount = Object.keys(payload.questions).length;
|
|
465
|
-
attempt.questionCount = questionCount;
|
|
466
|
-
if (questionCount < 1 || questionCount > limits.maxQuestionsPerStage) {
|
|
467
|
-
throw new JevFault('question_budget', 'abstained');
|
|
468
|
-
}
|
|
469
|
-
const body = JSON.stringify(payload);
|
|
470
|
-
attempt.requestBytes = Buffer.byteLength(body);
|
|
471
|
-
if (attempt.requestBytes > limits.maxRequestBytes) {
|
|
472
|
-
throw new JevFault('request_too_large', 'abstained');
|
|
473
|
-
}
|
|
474
|
-
await waitForCooldown(job);
|
|
475
|
-
check(job);
|
|
476
|
-
job.calls += 1;
|
|
477
|
-
job.questionCounts[stage] = questionCount;
|
|
478
|
-
job.stageStartedAt[stage] = clock.now();
|
|
479
|
-
attempt.dispatched = true;
|
|
480
|
-
counters.calls += 1;
|
|
481
|
-
counters[`calls${stage}`] += 1;
|
|
482
|
-
let response;
|
|
483
|
-
try {
|
|
484
|
-
response = await withAbort(() => fetchImpl(target, {
|
|
485
|
-
method: 'POST',
|
|
486
|
-
headers: { authorization: `Bearer ${key}`, 'content-type': 'application/json' },
|
|
487
|
-
body,
|
|
488
|
-
signal: job.controller.signal,
|
|
489
|
-
redirect: 'error',
|
|
490
|
-
credentials: 'omit',
|
|
491
|
-
}), job.controller.signal);
|
|
492
|
-
check(job);
|
|
493
|
-
if (!response || !Number.isInteger(response.status)) throw new JevFault('invalid_http_response');
|
|
494
|
-
if (response.status >= 100 && response.status <= 599) attempt.httpStatus = response.status;
|
|
495
|
-
if (response.status !== 200) {
|
|
496
|
-
if (response.body?.cancel) Promise.resolve(response.body.cancel()).catch(() => {});
|
|
497
|
-
if ([429, 529].includes(response.status)) {
|
|
498
|
-
cooldown(response);
|
|
499
|
-
throw new JevFault('remote_cooldown', 'overloaded');
|
|
500
|
-
}
|
|
501
|
-
if (response.status === 401 || response.status === 403) {
|
|
502
|
-
throw new JevFault('authentication_failed', 'unavailable');
|
|
503
|
-
}
|
|
504
|
-
if (response.status === 400 || response.status === 422) {
|
|
505
|
-
throw new JevFault('request_rejected');
|
|
506
|
-
}
|
|
507
|
-
throw new JevFault('http_error', 'unavailable');
|
|
508
|
-
}
|
|
509
|
-
const value = await readResponse(response, limits.maxResponseBytes, job.controller.signal);
|
|
510
|
-
check(job);
|
|
511
|
-
const validated = validateResponse(value, payload);
|
|
512
|
-
job.stages[stage] = freeze({
|
|
513
|
-
model: validated.model,
|
|
514
|
-
rubricVersion: RUBRICS[stage],
|
|
515
|
-
inputHash: hash(body),
|
|
516
|
-
usage: validated.usage,
|
|
517
|
-
mode,
|
|
518
|
-
});
|
|
519
|
-
attempt.outcome = auditOutcome('ok', 'ok');
|
|
520
|
-
attempt.usage = validated.usage;
|
|
521
|
-
counters.inputTokens += validated.usage.input_tokens;
|
|
522
|
-
counters.outputTokens += validated.usage.output_tokens;
|
|
523
|
-
return validated.answers;
|
|
524
|
-
} catch (error) {
|
|
525
|
-
check(job);
|
|
526
|
-
if (error instanceof JevFault) throw error;
|
|
527
|
-
throw new JevFault('transport_failure', 'unavailable');
|
|
528
|
-
}
|
|
529
|
-
} catch (error) {
|
|
530
|
-
attempt.outcome = error instanceof JevFault
|
|
531
|
-
? auditOutcome(error.status, error.code) : auditOutcome('unavailable', 'decision_failure');
|
|
532
|
-
throw error;
|
|
533
|
-
} finally {
|
|
534
|
-
attempt.finishedAt = clock.now();
|
|
535
|
-
if (attempt.dispatched) {
|
|
536
|
-
job.stageDurationMs[stage] = Math.max(0, clock.now() - job.stageStartedAt[stage]);
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
async function run(job) {
|
|
542
|
-
check(job);
|
|
543
|
-
const core = await withAbort(coreFunctions, job.controller.signal);
|
|
544
|
-
check(job);
|
|
545
|
-
const intakeRequest = buildIntakeRequest(model, job.event, job.candidates);
|
|
546
|
-
const a = await request(job, 'A', intakeRequest);
|
|
547
|
-
job.activity = a.a_activity.choice;
|
|
548
|
-
job.trace.activity = {
|
|
549
|
-
choice: a.a_activity.choice,
|
|
550
|
-
confidence: a.a_activity.confidence,
|
|
551
|
-
probabilities: Object.fromEntries(ACTIVITIES.map((activity) =>
|
|
552
|
-
[activity, a.a_activity.probabilities[activity]])),
|
|
553
|
-
};
|
|
554
|
-
const verdicts = job.candidates.map((candidate, i) => ({
|
|
555
|
-
candidateId: candidate.id,
|
|
556
|
-
digest: candidate.digest,
|
|
557
|
-
relevant: a[`a_relevant_${i}`].noul,
|
|
558
|
-
sensitive: a[`a_sensitive_${intakeRequest.state.entities[i].sourceIndex}`].noul,
|
|
559
|
-
}));
|
|
560
|
-
job.trace.intake = verdicts.map(({ candidateId, relevant, sensitive }) => {
|
|
561
|
-
const tooSensitive = sensitive > intakePolicy.sensitiveMax;
|
|
562
|
-
const irrelevant = relevant < intakePolicy.relevantMin;
|
|
563
|
-
return {
|
|
564
|
-
candidateId: auditId(candidateId), relevant, sensitive,
|
|
565
|
-
approved: !tooSensitive && !irrelevant,
|
|
566
|
-
reason: tooSensitive ? (irrelevant ? 'sensitive_and_irrelevant' : 'sensitive')
|
|
567
|
-
: (irrelevant ? 'irrelevant' : 'approved'),
|
|
568
|
-
materialized: null,
|
|
569
|
-
};
|
|
570
|
-
});
|
|
571
|
-
const materialized = await withAbort(() => core.materializeBundle({
|
|
572
|
-
candidates: job.candidates, verdicts: freeze(verdicts),
|
|
573
|
-
policy: job.policy, intakePolicy,
|
|
574
|
-
}), job.controller.signal);
|
|
575
|
-
check(job);
|
|
576
|
-
job.bundle = validateBundle(materialized, job.candidates, verdicts, job.policy, intakePolicy);
|
|
577
|
-
const materializedIds = new Set(job.bundle.candidates.map((candidate) => auditId(candidate.id)));
|
|
578
|
-
job.trace.intake.forEach((entry) => { entry.materialized = materializedIds.has(entry.candidateId); });
|
|
579
|
-
if (!job.bundle.candidates.length) return resultFor(job, 'irrelevant', 'no_approved_candidates');
|
|
580
|
-
|
|
581
|
-
const maximum = Math.min(limits.maxRelationProposals,
|
|
582
|
-
Math.max(0, Math.floor((limits.maxQuestionsPerStage - 1 - 2 * job.bundle.candidates.length) / 2)));
|
|
583
|
-
const proposed = await withAbort(() => core.buildRelationProposals(job.bundle, {
|
|
584
|
-
...limits, maxProposals: maximum, maxRelationProposals: maximum,
|
|
585
|
-
}), job.controller.signal);
|
|
586
|
-
check(job);
|
|
587
|
-
const { proposals, omitted } = validateProposals(proposed, job.bundle, maximum);
|
|
588
|
-
job.proposalsOmitted = omitted;
|
|
589
|
-
const b = await request(job, 'B', buildGraphRequest(model, job.event, job.bundle, proposals));
|
|
590
|
-
job.trace.relevance = b.b_relevance.noul;
|
|
591
|
-
const irrelevant = b.b_relevance.noul < admissionPolicy.relevanceMin;
|
|
592
|
-
const nodes = job.bundle.candidates.map((candidate, i) => {
|
|
593
|
-
const role = b[`b_role_${i}`];
|
|
594
|
-
const support = b[`b_support_${i}`].noul;
|
|
595
|
-
const accepted = candidate.complete && !job.event.incomplete && role.choice !== 'unknown'
|
|
596
|
-
&& support >= admissionPolicy.nodeSupportMin
|
|
597
|
-
&& role.probabilities[role.choice] >= admissionPolicy.roleProbabilityMin
|
|
598
|
-
&& role.confidence >= admissionPolicy.roleConfidenceMin;
|
|
599
|
-
job.trace.nodes.push({
|
|
600
|
-
candidateId: auditId(candidate.id), role: role.choice,
|
|
601
|
-
supportProbability: support, roleProbability: role.probabilities[role.choice],
|
|
602
|
-
roleConfidence: role.confidence,
|
|
603
|
-
roleProbabilities: Object.fromEntries(ROLES.map((kind) => [kind, role.probabilities[kind]])),
|
|
604
|
-
classification: irrelevant ? 'skipped' : accepted ? 'accepted' : 'tentative',
|
|
605
|
-
reasons: [
|
|
606
|
-
...(irrelevant ? ['insufficient_relevance'] : []),
|
|
607
|
-
...(!candidate.complete ? ['candidate_incomplete'] : []),
|
|
608
|
-
...(job.event.incomplete ? ['event_incomplete'] : []),
|
|
609
|
-
...(role.choice === 'unknown' ? ['unknown_role'] : []),
|
|
610
|
-
...(support < admissionPolicy.nodeSupportMin ? ['node_support_below_min'] : []),
|
|
611
|
-
...(role.probabilities[role.choice] < admissionPolicy.roleProbabilityMin
|
|
612
|
-
? ['role_probability_below_min'] : []),
|
|
613
|
-
...(role.confidence < admissionPolicy.roleConfidenceMin ? ['role_confidence_below_min'] : []),
|
|
614
|
-
],
|
|
615
|
-
});
|
|
616
|
-
return {
|
|
617
|
-
candidateId: candidate.id,
|
|
618
|
-
role: role.choice,
|
|
619
|
-
supportProbability: support,
|
|
620
|
-
roleProbability: role.probabilities[role.choice],
|
|
621
|
-
roleConfidence: role.confidence,
|
|
622
|
-
roleProbabilities: role.probabilities,
|
|
623
|
-
classification: accepted ? 'accepted' : 'tentative',
|
|
624
|
-
};
|
|
625
|
-
});
|
|
626
|
-
const acceptedNodes = new Set(nodes.filter((node) => !irrelevant && node.classification === 'accepted')
|
|
627
|
-
.map((node) => node.candidateId));
|
|
628
|
-
const candidateById = new Map(job.bundle.candidates.map((candidate) => [candidate.id, candidate]));
|
|
629
|
-
const edges = proposals.map((proposal, i) => {
|
|
630
|
-
const support = b[`b_relation_${i}`].noul;
|
|
631
|
-
const missing = b[`b_context_${i}`].noul;
|
|
632
|
-
const accepted = acceptedNodes.has(proposal.sourceCandidateId)
|
|
633
|
-
&& acceptedNodes.has(proposal.targetCandidateId)
|
|
634
|
-
&& proposal.evidenceCandidateIds.every((id) => candidateById.get(id).complete)
|
|
635
|
-
&& support >= admissionPolicy.edgeSupportMin && missing <= admissionPolicy.missingContextMax;
|
|
636
|
-
job.trace.edges.push({
|
|
637
|
-
proposalId: auditId(proposal.id, 'proposal'),
|
|
638
|
-
sourceCandidateId: auditId(proposal.sourceCandidateId),
|
|
639
|
-
targetCandidateId: auditId(proposal.targetCandidateId),
|
|
640
|
-
relation: proposal.relation, supportProbability: support, missingContextProbability: missing,
|
|
641
|
-
classification: irrelevant ? 'skipped' : accepted ? 'accepted' : 'tentative',
|
|
642
|
-
reasons: [
|
|
643
|
-
...(irrelevant ? ['insufficient_relevance'] : []),
|
|
644
|
-
...(!acceptedNodes.has(proposal.sourceCandidateId) ? ['source_not_accepted'] : []),
|
|
645
|
-
...(!acceptedNodes.has(proposal.targetCandidateId) ? ['target_not_accepted'] : []),
|
|
646
|
-
...(!proposal.evidenceCandidateIds.every((id) => candidateById.get(id).complete)
|
|
647
|
-
? ['evidence_incomplete'] : []),
|
|
648
|
-
...(support < admissionPolicy.edgeSupportMin ? ['edge_support_below_min'] : []),
|
|
649
|
-
...(missing > admissionPolicy.missingContextMax ? ['missing_context_above_max'] : []),
|
|
650
|
-
],
|
|
651
|
-
});
|
|
652
|
-
return {
|
|
653
|
-
proposalId: proposal.id,
|
|
654
|
-
sourceCandidateId: proposal.sourceCandidateId,
|
|
655
|
-
targetCandidateId: proposal.targetCandidateId,
|
|
656
|
-
relation: proposal.relation,
|
|
657
|
-
evidenceCandidateIds: [...proposal.evidenceCandidateIds],
|
|
658
|
-
supportProbability: support,
|
|
659
|
-
missingContextProbability: missing,
|
|
660
|
-
classification: accepted ? 'accepted' : 'tentative',
|
|
661
|
-
};
|
|
662
|
-
});
|
|
663
|
-
if (irrelevant) return resultFor(job, 'irrelevant', 'insufficient_relevance');
|
|
664
|
-
check(job);
|
|
665
|
-
const status = acceptedNodes.size ? 'accepted' : 'abstained';
|
|
666
|
-
return resultFor(job, status, status === 'accepted' ? null : 'no_accepted_classification', nodes, edges);
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
function pump() {
|
|
670
|
-
if (closed) return;
|
|
671
|
-
clock.clearTimeout(wakeTimer);
|
|
672
|
-
wakeTimer = undefined;
|
|
673
|
-
if (queue.length && clock.now() < cooldownUntil) {
|
|
674
|
-
wakeTimer = clock.setTimeout(pump, cooldownUntil - clock.now());
|
|
675
|
-
return;
|
|
676
|
-
}
|
|
677
|
-
while (active < limits.concurrency && queue.length) {
|
|
678
|
-
const job = queue.shift();
|
|
679
|
-
if (job.settled) continue;
|
|
680
|
-
active += 1;
|
|
681
|
-
running.add(job);
|
|
682
|
-
// One workflow owns a slot through A and B; there is no nested request queue.
|
|
683
|
-
run(job)
|
|
684
|
-
.then((result) => finish(job, result))
|
|
685
|
-
.catch((error) => {
|
|
686
|
-
const fault = error instanceof JevFault ? error : new JevFault('decision_failure', 'unavailable');
|
|
687
|
-
finish(job, resultFor(job, fault.status, fault.code));
|
|
688
|
-
})
|
|
689
|
-
.finally(() => {
|
|
690
|
-
active -= 1;
|
|
691
|
-
running.delete(job);
|
|
692
|
-
pump();
|
|
693
|
-
});
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function classify(input = {}) {
|
|
698
|
-
counters.submitted += 1;
|
|
699
|
-
const startedAt = clock.now();
|
|
700
|
-
const base = {
|
|
701
|
-
startedAt, stages: {}, questionCounts: {}, stageStartedAt: {}, stageDurationMs: {},
|
|
702
|
-
trace: { activity: null, intake: [], relevance: null, nodes: [], edges: [], requests: [] },
|
|
703
|
-
};
|
|
704
|
-
const immediate = (status, code) => {
|
|
705
|
-
counters.completed += 1;
|
|
706
|
-
counters.rejected += 1;
|
|
707
|
-
statuses[status] = (statuses[status] ?? 0) + 1;
|
|
708
|
-
return Promise.resolve(resultFor(base, status, code));
|
|
709
|
-
};
|
|
710
|
-
if (closed) return immediate('abstained', 'service_closed');
|
|
711
|
-
if (!isRecord(input)) return immediate('invalid', 'invalid_input');
|
|
712
|
-
if (input.signal !== undefined && (!input.signal
|
|
713
|
-
|| typeof input.signal.aborted !== 'boolean'
|
|
714
|
-
|| typeof input.signal.addEventListener !== 'function'
|
|
715
|
-
|| typeof input.signal.removeEventListener !== 'function')) {
|
|
716
|
-
return immediate('invalid', 'invalid_signal');
|
|
717
|
-
}
|
|
718
|
-
if (input.policy?.transmitSource !== true) return immediate('abstained', 'metadata_only');
|
|
719
|
-
if (!key) return immediate('unavailable', 'missing_key');
|
|
720
|
-
if (input.signal?.aborted) return immediate('abstained', 'cancelled');
|
|
721
|
-
if (limits.maxRequestsPerEvent < 2) return immediate('abstained', 'request_budget');
|
|
722
|
-
const deadlineAt = Math.min(input.deadlineAt ?? startedAt + limits.eventDeadlineMs,
|
|
723
|
-
startedAt + limits.eventDeadlineMs);
|
|
724
|
-
if (!Number.isFinite(deadlineAt)) return immediate('invalid', 'invalid_deadline');
|
|
725
|
-
if (deadlineAt <= startedAt) return immediate('timeout', 'deadline_exceeded');
|
|
726
|
-
if (queue.length >= limits.maxQueue
|
|
727
|
-
&& (active >= limits.concurrency || clock.now() < cooldownUntil)) {
|
|
728
|
-
return immediate('overloaded', 'queue_full');
|
|
729
|
-
}
|
|
730
|
-
let snapshot;
|
|
731
|
-
let event;
|
|
732
|
-
let policy;
|
|
733
|
-
try {
|
|
734
|
-
if (!validVersion(input.policy.version)) throw new JevFault('invalid_policy');
|
|
735
|
-
policy = freeze(structuredClone(input.policy));
|
|
736
|
-
event = freeze(safeEvent(input.event));
|
|
737
|
-
snapshot = snapshotCandidates(input.candidates, limits);
|
|
738
|
-
base.candidatesOmitted = snapshot.omitted;
|
|
739
|
-
if (snapshot.candidates.length === 0) {
|
|
740
|
-
return immediate(input.candidates.length ? 'abstained' : 'irrelevant',
|
|
741
|
-
input.candidates.length ? 'question_budget' : 'no_candidates');
|
|
742
|
-
}
|
|
743
|
-
} catch (error) {
|
|
744
|
-
return immediate('invalid', error instanceof JevFault ? error.code : 'invalid_input');
|
|
745
|
-
}
|
|
746
|
-
return new Promise((resolve) => {
|
|
747
|
-
const job = {
|
|
748
|
-
...base, ...snapshot, candidatesOmitted: snapshot.omitted,
|
|
749
|
-
event, policy, deadlineAt, resolve, calls: 0,
|
|
750
|
-
externalSignal: input.signal, controller: new AbortController(),
|
|
751
|
-
};
|
|
752
|
-
job.cancel = () => job.controller.abort(new JevFault('cancelled', 'abstained'));
|
|
753
|
-
job.onAbort = () => {
|
|
754
|
-
const fault = abortFault(job.controller.signal);
|
|
755
|
-
finish(job, resultFor(job, fault.status, fault.code));
|
|
756
|
-
pump();
|
|
757
|
-
};
|
|
758
|
-
job.controller.signal.addEventListener('abort', job.onAbort, { once: true });
|
|
759
|
-
job.externalSignal?.addEventListener('abort', job.cancel, { once: true });
|
|
760
|
-
job.timer = clock.setTimeout(() => job.controller.abort(
|
|
761
|
-
new JevFault('deadline_exceeded', 'timeout')), deadlineAt - clock.now());
|
|
762
|
-
queue.push(job);
|
|
763
|
-
if (job.externalSignal?.aborted) job.cancel();
|
|
764
|
-
pump();
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
return Object.freeze({
|
|
769
|
-
classify,
|
|
770
|
-
stats: () => ({
|
|
771
|
-
...counters, active, queued: queue.length, closed, mode,
|
|
772
|
-
cooldownRemainingMs: Math.max(0, cooldownUntil - clock.now()),
|
|
773
|
-
statuses: { ...statuses },
|
|
774
|
-
}),
|
|
775
|
-
close() {
|
|
776
|
-
if (closed) return;
|
|
777
|
-
closed = true;
|
|
778
|
-
clock.clearTimeout(wakeTimer);
|
|
779
|
-
for (const job of [...queue, ...running]) {
|
|
780
|
-
job.controller.abort(new JevFault('service_closed', 'abstained'));
|
|
781
|
-
}
|
|
782
|
-
},
|
|
783
|
-
});
|
|
784
20
|
}
|