@equationalapplications/core-llm-wiki 6.4.0 → 6.5.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/README.md CHANGED
@@ -454,6 +454,21 @@ wikiMemory.clearVectorCache();
454
454
 
455
455
  The cache is also automatically invalidated on any mutation (`runLibrarian`, `runHeal`, `runPrune`, `runReembed`, `ingestDocument`, `importDump`, `forget`).
456
456
 
457
+ ## Re-Embedding & Retry Behavior
458
+
459
+ `runReembed(entityId?, opts?)` returns `{ embedded, skipped, failed, deferred, permanentlyFailed }`.
460
+
461
+ - `failed` — attempted this sweep and failed.
462
+ - `deferred` — previously failed and still inside its exponential backoff window (60s doubling, capped at 24h). Not an error; a later sweep will retry.
463
+ - `permanentlyFailed` — excluded for good: a `float32_overflow` failure, or 5 failed attempts. Pass `{ force: true }` to retry these anyway.
464
+
465
+ Convergence loops should test `failed`, never `deferred` — deferred rows clear themselves once their backoff elapses.
466
+
467
+ Two caveats on that guidance:
468
+
469
+ - A `storage_error` failure counts in `failed` but is never marked for backoff — a failure marker is itself a DB write, which is exactly what is broken. While DB writes fail, every sweep re-attempts every row, so a loop testing `failed` can spin without converging. Treat repeated `storage_error` failures as an infrastructure signal to fix, not a retry window to wait out.
470
+ - `{ force: true }` bypasses more than `permanentlyFailed`: it also skips the backoff window, so rows that would otherwise be `deferred` are re-attempted immediately.
471
+
457
472
  ## Entity Status
458
473
 
459
474
  `WikiMemory` exposes the in-flight job state for a single entity through two complementary APIs.
@@ -2514,6 +2514,23 @@ var HEAL_ANCHOR_SEARCH_OVERFETCH = 4;
2514
2514
  var HEAL_MAX_PROMPT_CHARS = 4e4;
2515
2515
  var HEAL_BATCH_SIZE = 25;
2516
2516
  var HEAL_RECHECK_MS = 7 * 24 * 60 * 60 * 1e3;
2517
+ var MAX_EMBED_ATTEMPTS = 5;
2518
+ var EMBED_RETRY_BASE_MS = 6e4;
2519
+ var EMBED_RETRY_CAP_MS = 24 * 60 * 60 * 1e3;
2520
+ function embedRetryDelayMs(attempts) {
2521
+ const n = Math.max(1, Math.trunc(attempts || 0));
2522
+ const delay = EMBED_RETRY_BASE_MS * Math.pow(2, n - 1);
2523
+ return Math.min(delay, EMBED_RETRY_CAP_MS);
2524
+ }
2525
+ function classifyReembedRow(row, now, force) {
2526
+ if (force) return "attempt";
2527
+ const failedAt = row.embedding_failed_at;
2528
+ if (failedAt === null || failedAt === void 0) return "attempt";
2529
+ if (row.embedding_failure_kind === "float32_overflow") return "permanent";
2530
+ const attempts = row.embedding_attempts ?? 0;
2531
+ if (attempts >= MAX_EMBED_ATTEMPTS) return "permanent";
2532
+ return now - failedAt >= embedRetryDelayMs(attempts) ? "attempt" : "defer";
2533
+ }
2517
2534
  var SKIP_ERROR_LOG_CHARS = 4096;
2518
2535
  var formatSkipError = (err) => {
2519
2536
  let base;
@@ -2652,9 +2669,24 @@ var MaintenanceService = class {
2652
2669
  this.jobManager.releaseLock("ontologyBackfill", entityId);
2653
2670
  }
2654
2671
  }
2672
+ /**
2673
+ * Re-embed facts, honouring per-row failure markers and exponential backoff.
2674
+ *
2675
+ * Marker lifecycle (spec §2): markers are cleared by a successful embed, by
2676
+ * an upsert carrying a valid blob, and by an embedding-dimension promotion.
2677
+ * A promotion revives rows for the NEXT sweep, not this one — reconciliation
2678
+ * runs after candidates were already classified.
2679
+ *
2680
+ * Residual, by design: swapping to a provider with the SAME dimension never
2681
+ * sets embedding_dimension_mismatch, so promotion never fires and markers
2682
+ * survive the swap. Use `runReembed({ force: true })` to clear that state —
2683
+ * `force` bypasses classification entirely and retries every row.
2684
+ */
2655
2685
  async runReembed(entityId, opts) {
2656
2686
  const embedFn = this.options.llmProvider.embed;
2657
- if (!embedFn) return { embedded: 0, skipped: 0, failed: 0 };
2687
+ if (typeof embedFn !== "function") {
2688
+ return { embedded: 0, skipped: 0, failed: 0, deferred: 0, permanentlyFailed: 0 };
2689
+ }
2658
2690
  const op = entityId ? "reembed" : "global_reembed";
2659
2691
  this.jobManager.acquireLock(op, entityId ?? "*");
2660
2692
  try {
@@ -2677,6 +2709,10 @@ var MaintenanceService = class {
2677
2709
  let embedded = 0;
2678
2710
  let skipped = 0;
2679
2711
  let failed = 0;
2712
+ let deferred = 0;
2713
+ let permanentlyFailed = 0;
2714
+ const force = opts?.force ?? false;
2715
+ const now = Date.now();
2680
2716
  try {
2681
2717
  for (const row of rows) {
2682
2718
  const existingBlob = row.embedding_blob;
@@ -2688,8 +2724,17 @@ var MaintenanceService = class {
2688
2724
  continue;
2689
2725
  }
2690
2726
  }
2691
- const success = await this.embeddingService.embedFact(row);
2692
- if (success) embedded++;
2727
+ const disposition = classifyReembedRow(row, now, force);
2728
+ if (disposition === "defer") {
2729
+ deferred++;
2730
+ continue;
2731
+ }
2732
+ if (disposition === "permanent") {
2733
+ permanentlyFailed++;
2734
+ continue;
2735
+ }
2736
+ const result = await this.embeddingService.tryEmbedFact(row);
2737
+ if (result.ok) embedded++;
2693
2738
  else failed++;
2694
2739
  }
2695
2740
  if (embedded > 0) {
@@ -2698,7 +2743,7 @@ var MaintenanceService = class {
2698
2743
  } finally {
2699
2744
  this.searchService.evictCache(entityId);
2700
2745
  }
2701
- return { embedded, skipped, failed };
2746
+ return { embedded, skipped, failed, deferred, permanentlyFailed };
2702
2747
  } finally {
2703
2748
  this.jobManager.releaseLock(op, entityId ?? "*");
2704
2749
  }
@@ -3876,20 +3921,37 @@ var EmbeddingService = class {
3876
3921
  await this.metadataRepo.setMeta("embedding_dimension", String(dim), this.db);
3877
3922
  }
3878
3923
  }
3879
- /** Promotes embedding_dimension_mismatch to canonical embedding_dimension when safe. */
3924
+ /**
3925
+ * Promotes embedding_dimension_mismatch to canonical embedding_dimension when
3926
+ * safe, and clears embedding failure markers as part of the same event.
3927
+ *
3928
+ * Marker reset (spec §2.3): a promoted dimension means a different model is
3929
+ * producing the vectors, so prior failures no longer predict future ones.
3930
+ * `float32_overflow` is cleared too — it is terminal only because retrying is
3931
+ * the same arithmetic on the same vector, and after a model change it is not.
3932
+ *
3933
+ * All three writes commit atomically; see the transaction note inline.
3934
+ *
3935
+ * Revived rows are NOT embedded here. They become eligible and are picked up
3936
+ * by the NEXT sweep, because runReembed calls this after its candidates were
3937
+ * already classified; same-sweep revival would be re-entrant.
3938
+ */
3880
3939
  async reconcileEmbeddingDimension() {
3881
3940
  const mismatchValue = await this.metadataRepo.getMeta("embedding_dimension_mismatch");
3882
3941
  if (!mismatchValue) return;
3883
3942
  const newDim = parseInt(mismatchValue, 10);
3884
3943
  const residualCount = await this.entryRepo.countStaleEmbeddings(newDim);
3885
3944
  if (residualCount === 0) {
3886
- await this.metadataRepo.setMeta("embedding_dimension", mismatchValue, this.db);
3887
- await this.metadataRepo.clearDimensionMismatch(this.db);
3945
+ await this.db.withTransactionAsync(async (tx) => {
3946
+ await this.metadataRepo.setMeta("embedding_dimension", mismatchValue, tx);
3947
+ await this.metadataRepo.clearDimensionMismatch(tx);
3948
+ await this.entryRepo.clearEmbeddingFailureMarkers(tx);
3949
+ });
3888
3950
  }
3889
3951
  }
3890
- async embedFact(fact) {
3952
+ async tryEmbedFact(fact) {
3891
3953
  const embedFn = this.options.llmProvider.embed;
3892
- if (!embedFn) return false;
3954
+ if (typeof embedFn !== "function") return { ok: false, kind: "no_provider" };
3893
3955
  let tagsStr;
3894
3956
  if (Array.isArray(fact.tags)) {
3895
3957
  tagsStr = fact.tags.join(" ");
@@ -3904,13 +3966,15 @@ var EmbeddingService = class {
3904
3966
  const configuredMaxEmbedChars = this.options.config?.maxEmbedChars;
3905
3967
  const maxEmbedChars = Number.isFinite(configuredMaxEmbedChars) ? Math.min(Math.max(0, Math.trunc(configuredMaxEmbedChars)), EMBED_CHARS_CEILING) : DEFAULT_MAX_EMBED_CHARS;
3906
3968
  const text = clip(`${fact.title} ${fact.body} ${tagsStr}`.trim(), maxEmbedChars);
3969
+ let float32Vector;
3907
3970
  try {
3908
3971
  const vector = await embedFn(text);
3909
3972
  if (vector.length === 0 || !vector.every((v) => typeof v === "number" && isFinite(v))) {
3910
3973
  console.warn(`[WikiMemory] embedFact: embed() returned an invalid vector for ${fact.id}; skipping.`);
3911
- return false;
3974
+ await this.markFailure(fact.id, "invalid_vector");
3975
+ return { ok: false, kind: "invalid_vector" };
3912
3976
  }
3913
- const float32Vector = new Float32Array(vector);
3977
+ float32Vector = new Float32Array(vector);
3914
3978
  let hasNonFinite = false;
3915
3979
  for (let i = 0; i < float32Vector.length; i++) {
3916
3980
  if (!isFinite(float32Vector[i])) {
@@ -3920,21 +3984,45 @@ var EmbeddingService = class {
3920
3984
  }
3921
3985
  if (hasNonFinite) {
3922
3986
  console.warn(`[WikiMemory] embedFact: embed() returned values that overflow float32 for ${fact.id}; skipping.`);
3923
- return false;
3987
+ await this.markFailure(fact.id, "float32_overflow");
3988
+ return { ok: false, kind: "float32_overflow" };
3924
3989
  }
3990
+ } catch (err) {
3991
+ console.warn(`[WikiMemory] embedFact failed for ${fact.id}:`, err);
3992
+ await this.markFailure(fact.id, "provider_error");
3993
+ return { ok: false, kind: "provider_error" };
3994
+ }
3995
+ try {
3925
3996
  await this.storeEmbeddingDimension(float32Vector.length);
3926
3997
  const blob = new Uint8Array(float32Vector.buffer);
3927
3998
  await this.entryRepo.updateEmbeddingBlob(fact.id, blob);
3928
- try {
3929
- await this.notifyEmbeddingPersisted(fact.entity_id, fact.id, float32Vector);
3930
- } catch (hookErr) {
3931
- console.warn(`[WikiMemory] onEmbeddingPersisted hook failed for ${fact.id}:`, hookErr);
3932
- }
3933
- return true;
3934
3999
  } catch (err) {
3935
- console.warn(`[WikiMemory] embedFact failed for ${fact.id}:`, err);
3936
- return false;
4000
+ console.warn(`[WikiMemory] embedFact: persisting embedding failed for ${fact.id}:`, err);
4001
+ return { ok: false, kind: "storage_error" };
3937
4002
  }
4003
+ try {
4004
+ await this.notifyEmbeddingPersisted(fact.entity_id, fact.id, float32Vector);
4005
+ } catch (hookErr) {
4006
+ console.warn(`[WikiMemory] onEmbeddingPersisted hook failed for ${fact.id}:`, hookErr);
4007
+ }
4008
+ return { ok: true, dimension: float32Vector.length };
4009
+ }
4010
+ /** Marker writes must never fail the caller. Only marker-eligible kinds reach here. */
4011
+ async markFailure(id, kind) {
4012
+ try {
4013
+ await this.entryRepo.markEmbeddingFailure(id, kind, Date.now());
4014
+ } catch (err) {
4015
+ console.warn(`[WikiMemory] failed to record embedding failure for ${id}:`, err);
4016
+ }
4017
+ }
4018
+ /**
4019
+ * Back-compatible boolean form. Kept because callers such as
4020
+ * ImportExportService branch on `if (!embedded)`; a discriminated result
4021
+ * object is always truthy and would silently disable that branch.
4022
+ */
4023
+ async embedFact(fact) {
4024
+ const result = await this.tryEmbedFact(fact);
4025
+ return result.ok;
3938
4026
  }
3939
4027
  async notifyEmbeddingPersisted(entityId, factId, vector) {
3940
4028
  if (!this.options.vectorRanker?.onEmbeddingPersisted) return;
@@ -4779,5 +4867,5 @@ var WriteService = class {
4779
4867
  };
4780
4868
 
4781
4869
  export { BaseRepository, DEFAULT_CHUNK_OVERLAP, DEFAULT_MAX_CHUNK_LENGTH, DEFAULT_MAX_EMBED_CHARS, EMBED_CHARS_CEILING, EmbeddingService, HEAL_ANCHORS_PER_CANDIDATE, HEAL_BATCH_SIZE, HEAL_MAX_FACT_BODY_CHARS_L3, HEAL_MAX_TASKS, HEAL_RECHECK_MS, HOOK_TIMEOUT_MARKER, ImportExportService, IngestionService, JobManager, MaintenanceService, MetadataRepository, ONTOLOGY_BACKFILL_BATCH_SIZE, ONTOLOGY_BACKFILL_MAX_PROMPT_CHARS, ONTOLOGY_BACKFILL_RECHECK_MS, ONTOLOGY_BACKFILL_SYSTEM_PROMPT, PromptService, PrunePartialFailureError, RetrievalService, SearchService, WikiBusyError, WikiDuplicateHashError, WikiIngestEmptyError, WikiInvalidReadOptions, WikiParseError, WikiSourceRefHashCollision, WikiStrictOntologyViolation, WikiTransactionError, WriteService, __privateAdd, __privateGet, __privateSet, chunkText, configureRandomSource, emptyManifest, entitySummaryMetaKey, extractSqliteCode, generateId, normalizeSourceHash, normalizeSourceRef, normalizeTitleKey, parseEmbedding, resolveEdgeDefinitions, resolveNodeType, safeSlice, typeSatisfies, validateInlineEdges, validateManifest };
4782
- //# sourceMappingURL=chunk-DPFM33WV.mjs.map
4783
- //# sourceMappingURL=chunk-DPFM33WV.mjs.map
4870
+ //# sourceMappingURL=chunk-YNFYSLR6.mjs.map
4871
+ //# sourceMappingURL=chunk-YNFYSLR6.mjs.map