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