@semiont/jobs 0.5.28 → 0.5.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { promises, mkdtempSync, writeFileSync, readFileSync, rmSync } from 'fs';
2
2
  import * as path from 'path';
3
3
  import { join } from 'path';
4
- import { jobId, deriveViews, reconcileSelector, GENERATABLE_MEDIA_TYPES, estimateTokens, chunkText, isObject, isString, getLocaleEnglishName } from '@semiont/core';
5
- import { withSpan } from '@semiont/observability';
4
+ import { replyChannelsFor, jobId, deriveViews, GENERATABLE_MEDIA_TYPES, estimateTokens, chunkText, isObject, isString, reconcileSelector, getLocaleEnglishName } from '@semiont/core';
5
+ import { withSpan, recordAnchorOutcome, recordDetectionCall } from '@semiont/observability';
6
+ import { StructuredReadError } from '@semiont/inference';
6
7
  import { execFileSync } from 'child_process';
7
8
  import { tmpdir } from 'os';
8
9
  import { withinByteBudget, MAX_PDF_BYTES } from '@semiont/content';
@@ -10,6 +11,13 @@ import '@semiont/event-sourcing';
10
11
  import '@semiont/sdk';
11
12
  import '@semiont/http-transport';
12
13
 
14
+ // src/fs-job-queue.ts
15
+
16
+ // src/will-retry.ts
17
+ function willRetryAfter(metadata, failureClass) {
18
+ return failureClass !== "deterministic" && metadata.retryCount < metadata.maxRetries;
19
+ }
20
+
13
21
  // src/fs-job-queue.ts
14
22
  var REANNOUNCE_INTERVAL_MS = 3e4;
15
23
  var STALE_RUNNING_MS = 30 * 6e4;
@@ -17,9 +25,9 @@ var PROGRESS_WRITE_MIN_INTERVAL_MS = 5e3;
17
25
  var RETENTION_HOURS = 24;
18
26
  var CLEANUP_INTERVAL_MS = 36e5;
19
27
  var FsJobQueue = class {
20
- constructor(project, logger, eventBus) {
28
+ constructor(state, logger, eventBus) {
21
29
  this.eventBus = eventBus;
22
- this.jobsDir = project.jobsDir;
30
+ this.jobsDir = state.jobsDir;
23
31
  this.logger = logger;
24
32
  }
25
33
  eventBus;
@@ -194,24 +202,29 @@ var FsJobQueue = class {
194
202
  * re-announced); after that it lands in `failed` with the error.
195
203
  * Returns null (and changes nothing) if the job isn't running.
196
204
  */
197
- async failJob(jobId, error) {
205
+ async failJob(jobId, error, completedUnits, failureClass) {
198
206
  const job = await this.getJob(jobId);
199
207
  if (!job || job.status !== "running") {
200
208
  return null;
201
209
  }
202
210
  this.lastProgressWrite.delete(jobId);
203
- if (job.metadata.retryCount < job.metadata.maxRetries) {
211
+ const checkpoint = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits ?? []])];
212
+ const metadata = checkpoint.length > 0 ? { ...job.metadata, completedUnits: checkpoint } : job.metadata;
213
+ if (willRetryAfter(job.metadata, failureClass)) {
204
214
  const retried = {
205
215
  status: "pending",
206
- metadata: { ...job.metadata, retryCount: job.metadata.retryCount + 1 },
216
+ metadata: { ...metadata, retryCount: job.metadata.retryCount + 1 },
207
217
  params: job.params
208
218
  };
209
219
  await this.updateJob(retried, "running");
210
220
  return "retried";
211
221
  }
222
+ if (failureClass === "deterministic" && job.metadata.retryCount < job.metadata.maxRetries) {
223
+ this.logger.info("Job failed without retry \u2014 deterministic failure", { jobId, error });
224
+ }
212
225
  const failed = {
213
226
  status: "failed",
214
- metadata: job.metadata,
227
+ metadata,
215
228
  params: job.params,
216
229
  startedAt: job.startedAt,
217
230
  completedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -220,6 +233,30 @@ var FsJobQueue = class {
220
233
  await this.updateJob(failed, "running");
221
234
  return "failed";
222
235
  }
236
+ /**
237
+ * Persist a running job's completed-unit checkpoint at unit completion
238
+ * (JOB-RESTART-SAFETY P2). `failJob` writes this checkpoint on a clean
239
+ * failure, but a worker that dies without emitting `job:fail` would lose
240
+ * its finished units — so the janitor's recovery (which re-queues with
241
+ * whatever `metadata.completedUnits` the running file holds) would redo
242
+ * them. Writing it HERE, as each unit lands, makes a crash lose at most
243
+ * the in-flight unit. Unioned with any existing checkpoint, unthrottled
244
+ * (a unit completion is rare and must never be dropped), a no-op for
245
+ * non-running jobs. Written directly, like `recordProgress`, so it also
246
+ * refreshes the mtime heartbeat.
247
+ */
248
+ async checkpointUnits(jobId, completedUnits) {
249
+ const job = await this.getJob(jobId);
250
+ if (!job || job.status !== "running") {
251
+ return;
252
+ }
253
+ const merged = [.../* @__PURE__ */ new Set([...job.metadata.completedUnits ?? [], ...completedUnits])];
254
+ const updated = {
255
+ ...job,
256
+ metadata: { ...job.metadata, completedUnits: merged }
257
+ };
258
+ await promises.writeFile(this.getJobPath(jobId, "running"), JSON.stringify(updated, null, 2), "utf-8");
259
+ }
223
260
  /**
224
261
  * Write progress into a running job's file. Throttled per job, and
225
262
  * a no-op for jobs that aren't running. Beyond surfacing live
@@ -427,6 +464,9 @@ function isCancelledJob(job) {
427
464
  return job.status === "cancelled";
428
465
  }
429
466
  var INFERENCE_TIMEOUT_MS = 10 * 6e4;
467
+ var InferenceTimeoutError = class extends Error {
468
+ name = "InferenceTimeoutError";
469
+ };
430
470
  var INFERENCE_HEARTBEAT_MS = 15e3;
431
471
  function spanned(client, kind, maxTokens, work) {
432
472
  return withSpan(`inference:${kind}`, work, {
@@ -437,12 +477,20 @@ function spanned(client, kind, maxTokens, work) {
437
477
  }
438
478
  });
439
479
  }
440
- async function withTimeout(work, label, onHeartbeat) {
480
+ async function withTimeout(work, meta, onHeartbeat, logger) {
481
+ const controller = new AbortController();
441
482
  let timer;
442
483
  const timedOut = new Promise((_, reject) => {
443
484
  timer = setTimeout(() => {
444
- reject(new Error(
445
- `Inference call timed out after ${INFERENCE_TIMEOUT_MS / 6e4} minutes (${label}) \u2014 failing the job to keep the claim loop live`
485
+ logger?.warn("Aborting in-flight inference call at the timeout bound", {
486
+ provider: meta.provider,
487
+ model: meta.model,
488
+ label: meta.label,
489
+ boundMs: INFERENCE_TIMEOUT_MS
490
+ });
491
+ controller.abort();
492
+ reject(new InferenceTimeoutError(
493
+ `Inference call timed out after ${INFERENCE_TIMEOUT_MS / 6e4} minutes (${meta.label}) \u2014 failing the job to keep the claim loop live`
446
494
  ));
447
495
  }, INFERENCE_TIMEOUT_MS);
448
496
  timer.unref?.();
@@ -457,10 +505,11 @@ async function withTimeout(work, label, onHeartbeat) {
457
505
  }, INFERENCE_HEARTBEAT_MS);
458
506
  heartbeat.unref?.();
459
507
  }
508
+ const pending = work(controller.signal);
460
509
  try {
461
- return await Promise.race([work, timedOut]);
510
+ return await Promise.race([pending, timedOut]);
462
511
  } catch (err) {
463
- work.catch(() => {
512
+ pending.catch(() => {
464
513
  });
465
514
  throw err;
466
515
  } finally {
@@ -468,28 +517,55 @@ async function withTimeout(work, label, onHeartbeat) {
468
517
  if (heartbeat) clearInterval(heartbeat);
469
518
  }
470
519
  }
471
- function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, onHeartbeat) {
520
+ function boundedGenerateWithMetadata(client, prompt, maxTokens, temperature, onHeartbeat, logger) {
472
521
  return spanned(client, "text", maxTokens, () => withTimeout(
473
- client.generateTextWithMetadata(prompt, maxTokens, temperature),
474
- `${client.type}:${client.modelId}`,
475
- onHeartbeat
522
+ (signal) => client.generateTextWithMetadata(prompt, maxTokens, temperature, signal),
523
+ { provider: client.type, model: client.modelId, label: `${client.type}:${client.modelId}` },
524
+ onHeartbeat,
525
+ logger
476
526
  ));
477
527
  }
478
- function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema, onHeartbeat) {
528
+ function boundedGenerateStructured(client, prompt, maxTokens, temperature, elementSchema, onHeartbeat, logger) {
479
529
  return spanned(client, "structured", maxTokens, () => withTimeout(
480
- client.generateStructured(prompt, maxTokens, temperature, elementSchema),
481
- `${client.type}:${client.modelId}`,
482
- onHeartbeat
530
+ (signal) => client.generateStructured(prompt, maxTokens, temperature, elementSchema, signal),
531
+ { provider: client.type, model: client.modelId, label: `${client.type}:${client.modelId}` },
532
+ onHeartbeat,
533
+ logger
483
534
  ));
484
535
  }
536
+ var DeterministicJobError = class extends Error {
537
+ // Typed string, not the literal: subclasses (YieldCollapseError) carry
538
+ // their own name — classification is instanceof, never name-matching.
539
+ name = "DeterministicJobError";
540
+ };
485
541
 
486
542
  // src/workers/detection/detection-chunking.ts
543
+ function assertNotTruncated(response, label, chunk, totalChunks, outputBudget) {
544
+ if (response.stopReason === "max_tokens") {
545
+ throw new DeterministicJobError(`${label} response truncated (max_tokens) on chunk ${chunk}/${totalChunks} despite the derived output budget of ${outputBudget} tokens \u2014 failing the job rather than under-reporting annotations.`);
546
+ }
547
+ }
487
548
  var SELECTOR_CONTEXT_CHARS = 64;
488
549
  var OVERLAP_CHARS = SELECTOR_CONTEXT_CHARS + // prefix
489
550
  SELECTOR_CONTEXT_CHARS + // suffix
490
551
  2 * SELECTOR_CONTEXT_CHARS;
491
552
  var OVERLAP_TOKENS = Math.ceil(OVERLAP_CHARS / 4);
492
- function deriveDetectionBudget(limits, scaffoldTokens) {
553
+ var DETECTION_TEMPERATURE = 0;
554
+ var ASSUMED_OUTPUT_TOKENS_PER_HOUR = 108e3;
555
+ var YIELD_COLLAPSE_BAND = 2;
556
+ var YieldCollapseError = class extends DeterministicJobError {
557
+ /** What the flagged extraction DID find — every span write-time-verified,
558
+ * so discarding it at the floor would add loss on top of the under-report.
559
+ * Carried on the error because the flag site cannot know whether descent
560
+ * remains possible; the floor is the subdivider's knowledge. */
561
+ constructor(message, salvage = []) {
562
+ super(message);
563
+ this.salvage = salvage;
564
+ }
565
+ salvage;
566
+ name = "YieldCollapseError";
567
+ };
568
+ function deriveDetectionBudget(limits, scaffoldTokens, typesPerCall) {
493
569
  const { contextTokens, maxOutputTokens } = limits;
494
570
  const available = contextTokens - scaffoldTokens;
495
571
  let inputBudget;
@@ -505,6 +581,15 @@ function deriveDetectionBudget(limits, scaffoldTokens) {
505
581
  outputBudget = available - inputBudget;
506
582
  }
507
583
  }
584
+ const outputTokensPerHour = limits.outputTokensPerHour ?? ASSUMED_OUTPUT_TOKENS_PER_HOUR;
585
+ {
586
+ const durationSafeOutput = Math.floor(outputTokensPerHour * (INFERENCE_TIMEOUT_MS / 2) / 36e5);
587
+ if (outputBudget > durationSafeOutput) {
588
+ inputBudget = Math.floor(inputBudget * (durationSafeOutput / outputBudget));
589
+ outputBudget = durationSafeOutput;
590
+ }
591
+ }
592
+ inputBudget = Math.min(inputBudget, Math.floor(outputBudget / (2 * typesPerCall)));
508
593
  if (inputBudget <= OVERLAP_TOKENS) {
509
594
  throw new Error(
510
595
  `Inference window too small for detection: context ${contextTokens} tokens minus scaffold ${scaffoldTokens} leaves an input budget of ${inputBudget} (need > ${OVERLAP_TOKENS}). Use a model with a larger context window or reduce the prompt scaffold.`
@@ -515,6 +600,91 @@ function deriveDetectionBudget(limits, scaffoldTokens) {
515
600
  outputBudget
516
601
  };
517
602
  }
603
+ var MAX_SUBDIVISION_DEPTH = 2;
604
+ function unknownUnreadable(error) {
605
+ return error instanceof StructuredReadError && error.stopReason === "unknown";
606
+ }
607
+ function subdividable(error) {
608
+ return error instanceof InferenceTimeoutError || truncation(error) || unknownUnreadable(error);
609
+ }
610
+ function truncation(error) {
611
+ return error instanceof DeterministicJobError || error instanceof StructuredReadError && error.stopReason === "max_tokens";
612
+ }
613
+ function outcomeOf(error) {
614
+ if (error instanceof YieldCollapseError) return "collapsed";
615
+ if (truncation(error)) return "truncated";
616
+ if (error instanceof InferenceTimeoutError) return "timeout";
617
+ return "error";
618
+ }
619
+ async function callChunkSubdividing(label, chunk, chunking, call, logger) {
620
+ async function recorded(piece, depth, reroll) {
621
+ const start = performance.now();
622
+ try {
623
+ const result = await call(piece);
624
+ recordDetectionCall({
625
+ label,
626
+ pieceChars: piece.length,
627
+ durationMs: performance.now() - start,
628
+ items: result.items.length,
629
+ depth,
630
+ reroll,
631
+ outcome: "success",
632
+ ...result.usage ? { inputTokens: result.usage.inputTokens, outputTokens: result.usage.outputTokens } : {}
633
+ });
634
+ return result;
635
+ } catch (error) {
636
+ recordDetectionCall({
637
+ label,
638
+ pieceChars: piece.length,
639
+ durationMs: performance.now() - start,
640
+ items: 0,
641
+ depth,
642
+ reroll,
643
+ outcome: outcomeOf(error)
644
+ });
645
+ throw error;
646
+ }
647
+ }
648
+ async function attempt(piece, chunkSize, depth) {
649
+ try {
650
+ return (await recorded(piece, depth, false)).items;
651
+ } catch (error) {
652
+ if (!subdividable(error)) throw error;
653
+ const half = Math.floor(chunkSize / 2);
654
+ const pieces = chunkText(piece, { chunkSize: half, overlap: chunking.overlap });
655
+ const shrinks = pieces.length > 1 || pieces[0] !== piece;
656
+ const canDescend = shrinks && (truncation(error) || unknownUnreadable(error) ? half > 2 * OVERLAP_TOKENS : depth < MAX_SUBDIVISION_DEPTH);
657
+ if (!canDescend) {
658
+ if (error instanceof YieldCollapseError) {
659
+ logger?.warn("Floor-size piece still flagged as collapsed \u2014 accepting its under-reported salvage and continuing", {
660
+ pieceChars: piece.length,
661
+ salvaged: error.salvage.length,
662
+ error: error.message
663
+ });
664
+ return error.salvage;
665
+ }
666
+ if (!truncation(error)) throw error;
667
+ logger?.warn("Floor-size piece truncated \u2014 re-rolling once before giving up", {
668
+ pieceChars: piece.length,
669
+ error: error instanceof Error ? error.message : String(error)
670
+ });
671
+ return (await recorded(piece, depth, true)).items;
672
+ }
673
+ logger?.warn("Chunk call failed at a size-shaped bound \u2014 subdividing and retrying smaller", {
674
+ depth: depth + 1,
675
+ pieceChars: piece.length,
676
+ nextChunkSizeTokens: half,
677
+ error: error instanceof Error ? error.message : String(error)
678
+ });
679
+ const collected = [];
680
+ for (const p of pieces) {
681
+ collected.push(...await attempt(p, half, depth + 1));
682
+ }
683
+ return collected;
684
+ }
685
+ }
686
+ return attempt(chunk, chunking.chunkSize, 0);
687
+ }
518
688
  function languageName(tag) {
519
689
  return getLocaleEnglishName(tag) || tag;
520
690
  }
@@ -812,6 +982,16 @@ Example format:
812
982
  return prompt;
813
983
  }
814
984
  };
985
+ var DEGRADED = /* @__PURE__ */ new Set(["first-of-many", "fuzzy-match"]);
986
+ function noteAnchor(label, exact, method, logger) {
987
+ recordAnchorOutcome(label, method);
988
+ if (!DEGRADED.has(method)) return;
989
+ const detail = { text: exact, anchorMethod: method };
990
+ if (logger) logger.warn("Annotation anchored via degraded method", { label, ...detail });
991
+ else console.warn(`[${label}] anchored via ${method}: "${exact}"`);
992
+ }
993
+
994
+ // src/workers/detection/motivation-parsers.ts
815
995
  var COMMENT_ELEMENT_SCHEMA = {
816
996
  type: "object",
817
997
  properties: {
@@ -1000,35 +1180,31 @@ var MotivationParsers = class {
1000
1180
  }
1001
1181
  };
1002
1182
  function logAnchorMethod(motivation, exact, anchorMethod) {
1003
- if (anchorMethod === "first-of-many" || anchorMethod === "fuzzy-match") {
1004
- console.warn(`[MotivationParsers] ${motivation} anchored via ${anchorMethod}: "${exact}"`);
1005
- }
1183
+ noteAnchor(motivation, exact, anchorMethod);
1006
1184
  }
1007
1185
 
1008
1186
  // src/workers/annotation-detection.ts
1009
- function assertNotTruncated(response, motivation, chunk, totalChunks, outputBudget) {
1010
- if (response.stopReason === "max_tokens") {
1011
- throw new Error(`${motivation} detection response truncated (max_tokens) on chunk ${chunk}/${totalChunks} despite the derived output budget of ${outputBudget} tokens \u2014 failing the job rather than under-reporting annotations.`);
1012
- }
1013
- }
1014
- async function detectInChunks(client, content, buildPrompt, temperature, motivation, elementSchema, parse, onActivity) {
1187
+ async function detectInChunks(client, content, buildPrompt, motivation, elementSchema, parse, onActivity) {
1015
1188
  const limits = await client.limits();
1016
1189
  const scaffoldTokens = estimateTokens(buildPrompt(""));
1017
- const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
1190
+ const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens, 1);
1018
1191
  const chunks = chunkText(content, chunking);
1019
1192
  const collected = [];
1020
1193
  for (let i = 0; i < chunks.length; i++) {
1021
- const response = await boundedGenerateStructured(
1022
- client,
1023
- buildPrompt(chunks[i]),
1024
- outputBudget,
1025
- temperature,
1026
- elementSchema,
1027
- // Still alive, same position (a long single call is otherwise silent).
1028
- () => onActivity?.(i, chunks.length)
1029
- );
1030
- assertNotTruncated(response, motivation, i + 1, chunks.length, outputBudget);
1031
- collected.push(...parse(response.items));
1194
+ const items = await callChunkSubdividing(motivation, chunks[i], chunking, async (piece) => {
1195
+ const response = await boundedGenerateStructured(
1196
+ client,
1197
+ buildPrompt(piece),
1198
+ outputBudget,
1199
+ DETECTION_TEMPERATURE,
1200
+ elementSchema,
1201
+ // Still alive, same position (a long single call is otherwise silent).
1202
+ () => onActivity?.(i, chunks.length)
1203
+ );
1204
+ assertNotTruncated(response, `${motivation} detection`, i + 1, chunks.length, outputBudget);
1205
+ return { items: response.items, ...response.usage ? { usage: response.usage } : {} };
1206
+ });
1207
+ collected.push(...parse(items));
1032
1208
  if (i < chunks.length - 1) {
1033
1209
  onActivity?.(i + 1, chunks.length);
1034
1210
  }
@@ -1049,7 +1225,6 @@ var AnnotationDetection = class {
1049
1225
  client,
1050
1226
  content,
1051
1227
  (chunk) => MotivationPrompts.buildCommentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
1052
- 0.4,
1053
1228
  "comment",
1054
1229
  COMMENT_ELEMENT_SCHEMA,
1055
1230
  (items) => MotivationParsers.parseComments(items, content),
@@ -1068,7 +1243,6 @@ var AnnotationDetection = class {
1068
1243
  client,
1069
1244
  content,
1070
1245
  (chunk) => MotivationPrompts.buildHighlightPrompt(chunk, instructions, density, sourceLanguage),
1071
- 0.3,
1072
1246
  "highlight",
1073
1247
  HIGHLIGHT_ELEMENT_SCHEMA,
1074
1248
  (items) => MotivationParsers.parseHighlights(items, content),
@@ -1087,7 +1261,6 @@ var AnnotationDetection = class {
1087
1261
  client,
1088
1262
  content,
1089
1263
  (chunk) => MotivationPrompts.buildAssessmentPrompt(chunk, instructions, tone, density, language, sourceLanguage),
1090
- 0.3,
1091
1264
  "assessment",
1092
1265
  ASSESSMENT_ELEMENT_SCHEMA,
1093
1266
  (items) => MotivationParsers.parseAssessments(items, content),
@@ -1124,7 +1297,6 @@ var AnnotationDetection = class {
1124
1297
  categoryInfo.examples,
1125
1298
  sourceLanguage
1126
1299
  ),
1127
- 0.2,
1128
1300
  "tag",
1129
1301
  TAG_ELEMENT_SCHEMA,
1130
1302
  (items) => MotivationParsers.parseTags(items),
@@ -1144,6 +1316,40 @@ var ENTITY_ELEMENT_SCHEMA = {
1144
1316
  required: ["exact", "entityType"],
1145
1317
  additionalProperties: false
1146
1318
  };
1319
+ var COUNT_MAX_TOKENS = 16;
1320
+ function parseCount(text) {
1321
+ const m = text.trim().match(/\d+/);
1322
+ return m ? Number(m[0]) : void 0;
1323
+ }
1324
+ async function assertYieldNotCollapsed(client, piece, items, entityTypesDescription, logger) {
1325
+ const prompt = `Count every mention of: ${entityTypesDescription} in the following text. Repeated mentions of the same entity count separately. Respond with only the number.
1326
+
1327
+ Text:
1328
+ """
1329
+ ${piece}
1330
+ """`;
1331
+ let counted;
1332
+ try {
1333
+ const response = await boundedGenerateWithMetadata(client, prompt, COUNT_MAX_TOKENS, DETECTION_TEMPERATURE, void 0, logger);
1334
+ counted = parseCount(response.text);
1335
+ } catch (err) {
1336
+ logger.warn("Count-verifier call failed \u2014 yield check skipped for this chunk", {
1337
+ pieceChars: piece.length,
1338
+ error: err instanceof Error ? err.message : String(err)
1339
+ });
1340
+ return;
1341
+ }
1342
+ if (counted === void 0) {
1343
+ logger.warn("Count-verifier answer carried no number \u2014 yield check skipped for this chunk", { pieceChars: piece.length });
1344
+ return;
1345
+ }
1346
+ if (items.length * YIELD_COLLAPSE_BAND < counted) {
1347
+ throw new YieldCollapseError(
1348
+ `Extraction found ${items.length} entities where a count call reports ~${counted} mentions (band \xD7${YIELD_COLLAPSE_BAND}) on a ${piece.length}-char chunk \u2014 silent yield collapse (F7): deterministic \u2014 a same-size retry returns the identical under-report.`,
1349
+ [...items]
1350
+ );
1351
+ }
1352
+ }
1147
1353
  async function extractEntities(exact, entityTypes, client, includeDescriptiveReferences, logger, sourceLanguage, onActivity) {
1148
1354
  const entityTypesDescription = entityTypes.map((et) => {
1149
1355
  if (typeof et === "string") {
@@ -1194,8 +1400,9 @@ If no entities are found, respond with an empty array [].
1194
1400
  Example output:
1195
1401
  [{"exact":"Alice","entityType":"Person","prefix":"","suffix":" went to"},{"exact":"Paris","entityType":"Location","prefix":"went to ","suffix":" yesterday"}]`;
1196
1402
  const limits = await client.limits();
1403
+ const verifyYield = client.verifyDetectionYield;
1197
1404
  const scaffoldTokens = estimateTokens(buildPrompt(""));
1198
- const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens);
1405
+ const { chunking, outputBudget } = deriveDetectionBudget(limits, scaffoldTokens, entityTypes.length);
1199
1406
  const chunks = chunkText(exact, chunking);
1200
1407
  logger.debug("Sending entity extraction request", {
1201
1408
  entityTypes: entityTypesDescription,
@@ -1205,28 +1412,31 @@ Example output:
1205
1412
  });
1206
1413
  const collected = [];
1207
1414
  for (let i = 0; i < chunks.length; i++) {
1208
- const response = await boundedGenerateStructured(
1209
- client,
1210
- buildPrompt(chunks[i]),
1211
- outputBudget,
1212
- 0.3,
1213
- // Lower temperature for more consistent extraction
1214
- ENTITY_ELEMENT_SCHEMA,
1215
- // Still alive, same position: a long single call would otherwise emit
1216
- // nothing at all between start and finish.
1217
- () => onActivity?.(i, chunks.length)
1218
- );
1219
- logger.debug("Got entity extraction response", {
1220
- chunk: i + 1,
1221
- chunks: chunks.length,
1222
- items: response.items.length
1223
- });
1224
- if (response.stopReason === "max_tokens") {
1225
- const errorMsg = `Entity extraction response truncated (max_tokens) on chunk ${i + 1}/${chunks.length} despite the derived output budget of ${outputBudget} tokens \u2014 failing the job rather than dropping annotations.`;
1226
- logger.error(errorMsg, { items: response.items.length });
1227
- throw new Error(errorMsg);
1228
- }
1229
- for (const e of response.items) {
1415
+ const items = await callChunkSubdividing("reference", chunks[i], chunking, async (piece) => {
1416
+ const response = await boundedGenerateStructured(
1417
+ client,
1418
+ buildPrompt(piece),
1419
+ outputBudget,
1420
+ DETECTION_TEMPERATURE,
1421
+ ENTITY_ELEMENT_SCHEMA,
1422
+ // Still alive, same position: a long single call would otherwise emit
1423
+ // nothing at all between start and finish.
1424
+ () => onActivity?.(i, chunks.length),
1425
+ logger
1426
+ );
1427
+ logger.debug("Got entity extraction response", {
1428
+ chunk: i + 1,
1429
+ chunks: chunks.length,
1430
+ pieceChars: piece.length,
1431
+ items: response.items.length
1432
+ });
1433
+ assertNotTruncated(response, "Entity extraction", i + 1, chunks.length, outputBudget);
1434
+ if (verifyYield) {
1435
+ await assertYieldNotCollapsed(client, piece, response.items, entityTypesDescription, logger);
1436
+ }
1437
+ return { items: response.items, ...response.usage ? { usage: response.usage } : {} };
1438
+ }, logger);
1439
+ for (const e of items) {
1230
1440
  if (isObject(e) && isString(e.exact) && isString(e.entityType)) {
1231
1441
  collected.push({
1232
1442
  exact: e.exact,
@@ -1457,7 +1667,7 @@ ${formatRequirements}`;
1457
1667
  temperature: finalTemperature,
1458
1668
  maxTokens: finalMaxTokens
1459
1669
  });
1460
- const response = await boundedGenerateWithMetadata(client, prompt, finalMaxTokens, finalTemperature);
1670
+ const response = await boundedGenerateWithMetadata(client, prompt, finalMaxTokens, finalTemperature, void 0, logger);
1461
1671
  logger.debug("Got response from inference", { responseLength: response.text.length, stopReason: response.stopReason });
1462
1672
  const result = parseResponse(response.text);
1463
1673
  logger.debug("Parsed response", {
@@ -1552,6 +1762,24 @@ function resolveCitationTokens(content, validResourceIds, logger) {
1552
1762
  clean += content.slice(last);
1553
1763
  return { content: clean, citations };
1554
1764
  }
1765
+
1766
+ // src/workers/detection/bounded-concurrency.ts
1767
+ async function runBounded(items, limit, worker) {
1768
+ const results = new Array(items.length);
1769
+ let next = 0;
1770
+ async function pump() {
1771
+ while (true) {
1772
+ const i = next++;
1773
+ if (i >= items.length) return;
1774
+ results[i] = await worker(items[i], i);
1775
+ }
1776
+ }
1777
+ const poolSize = Math.max(1, Math.min(limit, items.length));
1778
+ await Promise.all(Array.from({ length: poolSize }, () => pump()));
1779
+ return results;
1780
+ }
1781
+
1782
+ // src/processors.ts
1555
1783
  function toMatch(r) {
1556
1784
  return {
1557
1785
  exact: r.exact,
@@ -1689,31 +1917,33 @@ async function processAssessmentJob(content, inferenceClient, params, buildAnnot
1689
1917
  result: { kind: "assessment-annotation", assessmentsFound: assessments.length, assessmentsCreated: annotations.length }
1690
1918
  };
1691
1919
  }
1692
- async function processReferenceJob(content, inferenceClient, params, buildAnnotation, onProgress, logger) {
1920
+ async function processReferenceJob(content, inferenceClient, params, buildAnnotation, onProgress, logger, onUnitComplete, signal) {
1693
1921
  const entityTypeNames = params.entityTypes.map(String);
1694
1922
  const requestParams = [{ label: "entity-types", value: entityTypeNames.join(", ") }];
1695
1923
  const completedItems = [];
1696
1924
  let totalFound = 0;
1697
1925
  let totalEmitted = 0;
1698
1926
  let errors = 0;
1699
- const allAnnotations = [];
1700
1927
  onProgress(10, { code: "loading" }, { requestParams });
1701
1928
  const bodyLanguage = params.language ?? "en";
1702
- for (let i = 0; i < entityTypeNames.length; i++) {
1703
- const entityTypeName = entityTypeNames[i];
1704
- if (!entityTypeName) continue;
1705
- const pct = 20 + Math.round(i / entityTypeNames.length * 60);
1929
+ let completed = 0;
1930
+ const total = entityTypeNames.length;
1931
+ const emitTypeProgress = (entityTypeName) => {
1932
+ const pct = 20 + Math.round(completed / total * 60);
1706
1933
  onProgress(pct, { code: "detecting-entities", entityType: entityTypeName }, {
1707
- // One vocabulary for "what is in flight" (CLEAN-PROGRESS D2): the entity
1708
- // type is KB data, `kind` is the code the client localizes around it.
1709
1934
  current: { kind: "entity-type", value: entityTypeName },
1710
- processed: i,
1711
- total: entityTypeNames.length,
1935
+ processed: completed,
1936
+ total,
1712
1937
  entitiesFound: totalFound,
1713
1938
  entitiesEmitted: totalEmitted,
1714
1939
  completedItems: [...completedItems],
1715
1940
  requestParams
1716
1941
  });
1942
+ };
1943
+ await runBounded(entityTypeNames, inferenceClient.maxConcurrency, async (entityTypeName) => {
1944
+ if (!entityTypeName) return;
1945
+ if (signal?.aborted) return;
1946
+ emitTypeProgress(entityTypeName);
1717
1947
  const extractedEntities = await extractEntities(
1718
1948
  content,
1719
1949
  [entityTypeName],
@@ -1721,31 +1951,17 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
1721
1951
  params.includeDescriptiveReferences ?? false,
1722
1952
  logger,
1723
1953
  params.sourceLanguage,
1724
- // Liveness: fires at chunk boundaries AND every ~15 s while a single
1725
- // inference call is in flight (DETECTION-HEARTBEAT). Progress feeds the
1726
- // stall watchdog, the janitor, AND the client's inter-emission timeout,
1727
- // so a long single-chunk call must not be silent. Percentage
1728
- // interpolates within this entity type's band of the 20–80 range; a
1729
- // heartbeat repeats the current position rather than inventing an
1730
- // advance.
1731
- (completed, total) => {
1732
- const interpolated = 20 + Math.round((i + completed / total) / entityTypeNames.length * 60);
1733
- onProgress(interpolated, { code: "detecting-entities", entityType: entityTypeName }, {
1734
- current: { kind: "entity-type", value: entityTypeName },
1735
- processed: i,
1736
- total: entityTypeNames.length,
1737
- entitiesFound: totalFound,
1738
- entitiesEmitted: totalEmitted,
1739
- completedItems: [...completedItems],
1740
- requestParams
1741
- });
1742
- }
1954
+ // Liveness heartbeat (DETECTION-HEARTBEAT): fires at chunk boundaries and
1955
+ // every ~15 s while a call is in flight, so a long single-chunk call is
1956
+ // not silent. It repeats the current position rather than inventing an
1957
+ // advance the stall watchdog, janitor and client timeout need a signal,
1958
+ // not a monotone.
1959
+ () => emitTypeProgress(entityTypeName)
1743
1960
  );
1744
- totalFound += extractedEntities.length;
1745
- completedItems.push({ value: entityTypeName, foundCount: extractedEntities.length });
1746
1961
  const unresolvedBody = [
1747
1962
  { type: "TextualBody", value: entityTypeName, purpose: "tagging", format: "text/plain", language: bodyLanguage }
1748
1963
  ];
1964
+ const built = [];
1749
1965
  for (const entity of extractedEntities) {
1750
1966
  const reconciled = reconcileSelector(content, {
1751
1967
  exact: entity.exact,
@@ -1760,23 +1976,28 @@ async function processReferenceJob(content, inferenceClient, params, buildAnnota
1760
1976
  errors++;
1761
1977
  continue;
1762
1978
  }
1763
- if (reconciled.anchorMethod === "first-of-many" || reconciled.anchorMethod === "fuzzy-match") {
1764
- logger.warn("Entity anchored via degraded method", {
1765
- text: entity.exact,
1766
- entityType: entity.entityType,
1767
- anchorMethod: reconciled.anchorMethod
1768
- });
1769
- }
1979
+ noteAnchor("reference", entity.exact, reconciled.anchorMethod, logger);
1770
1980
  const ann = buildAnnotation("linking", toMatch(reconciled), unresolvedBody);
1771
- allAnnotations.push(ann);
1772
- totalEmitted++;
1981
+ built.push(ann);
1773
1982
  }
1774
- }
1775
- const annotations = dedupeAnnotations(allAnnotations);
1776
- onProgress(100, { code: "complete-created", count: annotations.length, kind: "reference" }, { requestParams });
1983
+ const unitAnnotations = dedupeAnnotations(built);
1984
+ await onUnitComplete(entityTypeName, unitAnnotations);
1985
+ totalEmitted += unitAnnotations.length;
1986
+ totalFound += extractedEntities.length;
1987
+ completedItems.push({
1988
+ value: entityTypeName,
1989
+ foundCount: extractedEntities.length,
1990
+ persistedCount: unitAnnotations.length
1991
+ });
1992
+ completed++;
1993
+ emitTypeProgress(entityTypeName);
1994
+ });
1995
+ onProgress(100, { code: "complete-created", count: totalEmitted, kind: "reference" }, {
1996
+ completedItems: [...completedItems],
1997
+ requestParams
1998
+ });
1777
1999
  return {
1778
- annotations,
1779
- result: { kind: "reference-annotation", totalFound, totalEmitted: annotations.length, errors }
2000
+ result: { kind: "reference-annotation", totalFound, totalEmitted, errors }
1780
2001
  };
1781
2002
  }
1782
2003
  async function processTagJob(content, inferenceClient, params, buildAnnotation, onProgress) {
@@ -1980,6 +2201,17 @@ async function processGenerationJob(inferenceClient, params, onProgress, logger)
1980
2201
  };
1981
2202
  }
1982
2203
  var STALL_THRESHOLD_MS = 15 * 6e4;
2204
+ var WORKER_AWAITED_OPERATIONS = [
2205
+ "job:claim",
2206
+ "browse:resource-requested",
2207
+ "browse:anchored-text-by-checksum-requested",
2208
+ // Durability acknowledgement for a unit's annotations (JOB-RESTART-SAFETY
2209
+ // P6). The worker AWAITS this one — a unit may not advance until its
2210
+ // annotations are in the event log — so its replies must be in the narrow
2211
+ // channel set or every commit fails fast with `bus.unsubscribed`.
2212
+ "mark:commit"
2213
+ ];
2214
+ replyChannelsFor(WORKER_AWAITED_OPERATIONS);
1983
2215
 
1984
2216
  export { AnnotationDetection, FsJobQueue, STALL_THRESHOLD_MS, generateResourceFromTopic, isCancelledJob, isCompleteJob, isFailedJob, isPendingJob, isRunningJob, processAssessmentJob, processCommentJob, processGenerationJob, processHighlightJob, processReferenceJob, processTagJob };
1985
2217
  //# sourceMappingURL=index.js.map