@remit/mailbox-service 0.0.33 → 0.0.34

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/mailbox-service",
3
- "version": "0.0.33",
3
+ "version": "0.0.34",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -61,6 +61,7 @@ const buildPipeline = (filter: FilterItem) => {
61
61
  state.embedCalls += 1;
62
62
  return [];
63
63
  },
64
+ embeddingId: "test-model@0",
64
65
  },
65
66
  };
66
67
  return { pipeline: new FilterPipeline(config, { info: () => {} }), state };
@@ -101,3 +102,193 @@ describe("FilterPipeline — anchorless From/Or filter at index time", () => {
101
102
  assert.equal(state.embedCalls, 0);
102
103
  });
103
104
  });
105
+
106
+ describe("FilterPipeline — anchor lazy re-embed on model drift (reader #295)", () => {
107
+ const anchoredFilter = (): FilterItem =>
108
+ ({
109
+ filterId: "flt-semantic",
110
+ accountConfigId: "cfg-1",
111
+ name: "receipts",
112
+ scope: "Standing",
113
+ state: FilterState.Active,
114
+ hasAnchor: true,
115
+ ruleChangedAt: 1,
116
+ matchOperator: FilterMatchOperator.And,
117
+ literalClauses: [],
118
+ actionLabelId: "lbl-1",
119
+ actionMailboxId: NO_ACTION,
120
+ createdAt: 1,
121
+ updatedAt: 1,
122
+ }) as unknown as FilterItem;
123
+
124
+ const message = {
125
+ from: "billing@stripe.com",
126
+ fromName: "Stripe",
127
+ subject: "Your receipt",
128
+ text: "Thanks for your payment",
129
+ listId: "",
130
+ };
131
+
132
+ it("re-embeds a stale anchor and matches on the very next evaluation, persisting the refreshed row", async () => {
133
+ const putCalls: Array<Record<string, unknown>> = [];
134
+ let embedCalls = 0;
135
+ const config: FilterConfig = {
136
+ filterService: {
137
+ listByAccountAndState: async () => [anchoredFilter()],
138
+ refreshExpiry: async (f: FilterItem) => f,
139
+ } as unknown as IFilterRepository,
140
+ filterAnchorService: {
141
+ get: async () => ({
142
+ accountConfigId: "cfg-1",
143
+ filterId: "flt-semantic",
144
+ // Stale-model space — orthogonal to what the current model
145
+ // produces, so a raw comparison against it would not match.
146
+ anchorEmbedding: [0, 1, 0],
147
+ anchorEmbeddingId: "old-model@3",
148
+ anchorSourceText: "your receipt is ready",
149
+ anchorMessageId: "msg-anchor",
150
+ }),
151
+ put: async (input: Record<string, unknown>) => {
152
+ putCalls.push(input);
153
+ return { ...input, createdAt: 1, updatedAt: 2 };
154
+ },
155
+ } as unknown as IFilterAnchorRepository,
156
+ messageLabelService: {} as unknown as IMessageLabelRepository,
157
+ placementMoveService: {} as unknown as PlacementMoveService,
158
+ embedder: {
159
+ embed: async () => {
160
+ embedCalls += 1;
161
+ return [1, 0, 0];
162
+ },
163
+ embeddingId: "new-model@3",
164
+ },
165
+ };
166
+
167
+ const decision = await new FilterPipeline(config, {
168
+ info: () => {},
169
+ }).evaluate("cfg-1", "m-1", message);
170
+
171
+ assert.deepEqual(
172
+ decision.labels,
173
+ [{ labelId: "lbl-1", filterId: "flt-semantic" }],
174
+ "the anchor matches once re-embedded into the current model's space",
175
+ );
176
+ assert.equal(
177
+ putCalls.length,
178
+ 1,
179
+ "the refreshed anchor is written back exactly once",
180
+ );
181
+ assert.deepEqual(putCalls[0], {
182
+ accountConfigId: "cfg-1",
183
+ filterId: "flt-semantic",
184
+ anchorEmbedding: [1, 0, 0],
185
+ anchorEmbeddingId: "new-model@3",
186
+ anchorSourceText: "your receipt is ready",
187
+ anchorMessageId: "msg-anchor",
188
+ });
189
+ assert.equal(
190
+ embedCalls,
191
+ 2,
192
+ "one embed re-embeds the anchor's source text, one embeds the candidate message",
193
+ );
194
+ });
195
+
196
+ it("never re-embeds when the anchor's embeddingId is already current", async () => {
197
+ let putCalls = 0;
198
+ let embedCalls = 0;
199
+ const config: FilterConfig = {
200
+ filterService: {
201
+ listByAccountAndState: async () => [anchoredFilter()],
202
+ refreshExpiry: async (f: FilterItem) => f,
203
+ } as unknown as IFilterRepository,
204
+ filterAnchorService: {
205
+ get: async () => ({
206
+ anchorEmbedding: [1, 0, 0],
207
+ anchorEmbeddingId: "current-model@3",
208
+ anchorSourceText: "your receipt is ready",
209
+ anchorMessageId: "msg-anchor",
210
+ }),
211
+ put: async () => {
212
+ putCalls += 1;
213
+ throw new Error("must not be called");
214
+ },
215
+ } as unknown as IFilterAnchorRepository,
216
+ messageLabelService: {} as unknown as IMessageLabelRepository,
217
+ placementMoveService: {} as unknown as PlacementMoveService,
218
+ embedder: {
219
+ embed: async () => {
220
+ embedCalls += 1;
221
+ return [1, 0, 0];
222
+ },
223
+ embeddingId: "current-model@3",
224
+ },
225
+ };
226
+
227
+ const decision = await new FilterPipeline(config, {
228
+ info: () => {},
229
+ }).evaluate("cfg-1", "m-1", message);
230
+
231
+ assert.deepEqual(decision.labels, [
232
+ { labelId: "lbl-1", filterId: "flt-semantic" },
233
+ ]);
234
+ assert.equal(putCalls, 0, "a current anchor is never rewritten");
235
+ assert.equal(
236
+ embedCalls,
237
+ 1,
238
+ "only the candidate message is embedded — no wasted re-embed call",
239
+ );
240
+ });
241
+
242
+ it("degrades a re-embed failure to skipping just this filter, not the whole pass", async () => {
243
+ const literalFilter: FilterItem = {
244
+ filterId: "flt-literal",
245
+ accountConfigId: "cfg-1",
246
+ name: "stripe receipts",
247
+ scope: "Standing",
248
+ state: FilterState.Active,
249
+ hasAnchor: false,
250
+ ruleChangedAt: 1,
251
+ matchOperator: FilterMatchOperator.Or,
252
+ literalClauses: [{ field: FilterClauseField.From, value: "stripe.com" }],
253
+ actionLabelId: "lbl-literal",
254
+ actionMailboxId: NO_ACTION,
255
+ createdAt: 1,
256
+ updatedAt: 1,
257
+ } as unknown as FilterItem;
258
+
259
+ const config: FilterConfig = {
260
+ filterService: {
261
+ listByAccountAndState: async () => [anchoredFilter(), literalFilter],
262
+ refreshExpiry: async (f: FilterItem) => f,
263
+ } as unknown as IFilterRepository,
264
+ filterAnchorService: {
265
+ get: async () => ({
266
+ anchorEmbedding: [0, 1, 0],
267
+ anchorEmbeddingId: "old-model@3",
268
+ anchorSourceText: "your receipt is ready",
269
+ anchorMessageId: "msg-anchor",
270
+ }),
271
+ put: async () => {
272
+ throw new Error("SQLITE_BUSY");
273
+ },
274
+ } as unknown as IFilterAnchorRepository,
275
+ messageLabelService: {} as unknown as IMessageLabelRepository,
276
+ placementMoveService: {} as unknown as PlacementMoveService,
277
+ embedder: {
278
+ embed: async () => [1, 0, 0],
279
+ embeddingId: "new-model@3",
280
+ },
281
+ };
282
+
283
+ const decision = await new FilterPipeline(config, {
284
+ info: () => {},
285
+ error: () => {},
286
+ }).evaluate("cfg-1", "m-1", message);
287
+
288
+ assert.deepEqual(
289
+ decision.labels,
290
+ [{ labelId: "lbl-literal", filterId: "flt-literal" }],
291
+ "the anchored filter's re-embed failure is isolated — the literal filter still applies",
292
+ );
293
+ });
294
+ });
@@ -26,6 +26,15 @@ import {
26
26
  */
27
27
  export interface MessageEmbedder {
28
28
  embed(text: string): Promise<number[]>;
29
+ /**
30
+ * `<modelId>@<dimensions>` identifier of the model currently configured —
31
+ * the same scheme `EmbeddingService.embeddingId`
32
+ * (`packages/search-service/src/embeddings.ts`) derives. Compared against
33
+ * `FilterAnchor.anchorEmbeddingId` to detect a model drift a same-dimension
34
+ * swap would otherwise pass through silently (RFC 039 Decision 1a, reader
35
+ * #295).
36
+ */
37
+ readonly embeddingId: string;
29
38
  }
30
39
 
31
40
  export interface FilterLogger {
@@ -215,7 +224,7 @@ export class FilterPipeline {
215
224
  // One deterministic point read per semantic candidate that survived the
216
225
  // literal pre-filter — never a scan, never the anchor message itself
217
226
  // (RFC 034 Decision 2.3).
218
- const anchor = await this.config.filterAnchorService.get(
227
+ let anchor = await this.config.filterAnchorService.get(
219
228
  accountConfigId,
220
229
  filter.filterId,
221
230
  );
@@ -227,6 +236,27 @@ export class FilterPipeline {
227
236
  return false;
228
237
  }
229
238
 
239
+ const embedder = this.config.embedder;
240
+ if (embedder && anchor.anchorEmbeddingId !== embedder.embeddingId) {
241
+ // The embedding model has drifted since this anchor was last written
242
+ // (RFC 039 Decision 1a) — re-embed the already-persisted
243
+ // `anchorSourceText` and write it back in place, lazily, on this read.
244
+ // No migration job walks these rows proactively; a re-embed failure
245
+ // here propagates to the per-filter catch in `match()`, which logs and
246
+ // skips this filter for this one evaluation exactly as an unrecoverable
247
+ // stale anchor does today — never a terminal state, so the next
248
+ // message that reaches this filter retries.
249
+ const anchorEmbedding = await embedder.embed(anchor.anchorSourceText);
250
+ anchor = await this.config.filterAnchorService.put({
251
+ accountConfigId,
252
+ filterId: filter.filterId,
253
+ anchorEmbedding,
254
+ anchorEmbeddingId: embedder.embeddingId,
255
+ anchorSourceText: anchor.anchorSourceText,
256
+ anchorMessageId: anchor.anchorMessageId,
257
+ });
258
+ }
259
+
230
260
  const vector = await embed();
231
261
  if (!vector) {
232
262
  this.log.debug?.(