@advicenxt/sbp-server 0.1.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.
- package/benchmarks/bench.ts +272 -0
- package/dist/auth.d.ts +20 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +69 -0
- package/dist/auth.js.map +1 -0
- package/dist/blackboard.d.ts +84 -0
- package/dist/blackboard.d.ts.map +1 -0
- package/dist/blackboard.js +502 -0
- package/dist/blackboard.js.map +1 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +102 -0
- package/dist/cli.js.map +1 -0
- package/dist/conditions.d.ts +27 -0
- package/dist/conditions.d.ts.map +1 -0
- package/dist/conditions.js +240 -0
- package/dist/conditions.js.map +1 -0
- package/dist/decay.d.ts +21 -0
- package/dist/decay.d.ts.map +1 -0
- package/dist/decay.js +88 -0
- package/dist/decay.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/rate-limiter.d.ts +21 -0
- package/dist/rate-limiter.d.ts.map +1 -0
- package/dist/rate-limiter.js +75 -0
- package/dist/rate-limiter.js.map +1 -0
- package/dist/server.d.ts +63 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +401 -0
- package/dist/server.js.map +1 -0
- package/dist/store.d.ts +54 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/dist/types.d.ts +247 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +26 -0
- package/dist/types.js.map +1 -0
- package/dist/validation.d.ts +296 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +205 -0
- package/dist/validation.js.map +1 -0
- package/eslint.config.js +26 -0
- package/package.json +66 -0
- package/src/auth.ts +89 -0
- package/src/blackboard.test.ts +287 -0
- package/src/blackboard.ts +651 -0
- package/src/cli.ts +116 -0
- package/src/conditions.ts +305 -0
- package/src/conformance.test.ts +686 -0
- package/src/decay.ts +103 -0
- package/src/index.ts +24 -0
- package/src/rate-limiter.ts +104 -0
- package/src/server.integration.test.ts +436 -0
- package/src/server.ts +500 -0
- package/src/store.ts +108 -0
- package/src/types.ts +314 -0
- package/src/validation.ts +251 -0
- package/tsconfig.eslint.json +5 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SBP Conformance Test Suite
|
|
3
|
+
* Protocol-level tests to verify any SBP implementation against the spec
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
7
|
+
import { Blackboard } from "./blackboard.js";
|
|
8
|
+
import { computeIntensity, isEvaporated } from "./decay.js";
|
|
9
|
+
import { evaluateCondition } from "./conditions.js";
|
|
10
|
+
import type { Pheromone, ScentCondition } from "./types.js";
|
|
11
|
+
|
|
12
|
+
// -- Test Helpers --
|
|
13
|
+
|
|
14
|
+
function makePheromone(overrides: Partial<Pheromone> = {}): Pheromone {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
return {
|
|
17
|
+
id: `test-${Math.random().toString(36).slice(2)}`,
|
|
18
|
+
trail: "test/trail",
|
|
19
|
+
type: "signal",
|
|
20
|
+
emitted_at: now,
|
|
21
|
+
last_reinforced_at: now,
|
|
22
|
+
initial_intensity: 1.0,
|
|
23
|
+
decay_model: { type: "exponential", half_life_ms: 10000 },
|
|
24
|
+
payload: {},
|
|
25
|
+
tags: [],
|
|
26
|
+
ttl_floor: 0.01,
|
|
27
|
+
...overrides,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ============================================================================
|
|
32
|
+
// DECAY MODELS
|
|
33
|
+
// ============================================================================
|
|
34
|
+
|
|
35
|
+
describe("Decay Models", () => {
|
|
36
|
+
describe("Exponential Decay", () => {
|
|
37
|
+
it("returns full intensity at t=0", () => {
|
|
38
|
+
const p = makePheromone({
|
|
39
|
+
decay_model: { type: "exponential", half_life_ms: 10000 },
|
|
40
|
+
initial_intensity: 1.0,
|
|
41
|
+
});
|
|
42
|
+
expect(computeIntensity(p, p.emitted_at)).toBeCloseTo(1.0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns half intensity at t=half_life", () => {
|
|
46
|
+
const p = makePheromone({
|
|
47
|
+
decay_model: { type: "exponential", half_life_ms: 10000 },
|
|
48
|
+
initial_intensity: 1.0,
|
|
49
|
+
});
|
|
50
|
+
expect(computeIntensity(p, p.emitted_at + 10000)).toBeCloseTo(0.5, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns quarter intensity at t=2*half_life", () => {
|
|
54
|
+
const p = makePheromone({
|
|
55
|
+
decay_model: { type: "exponential", half_life_ms: 10000 },
|
|
56
|
+
initial_intensity: 1.0,
|
|
57
|
+
});
|
|
58
|
+
expect(computeIntensity(p, p.emitted_at + 20000)).toBeCloseTo(0.25, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("respects initial_intensity", () => {
|
|
62
|
+
const p = makePheromone({
|
|
63
|
+
decay_model: { type: "exponential", half_life_ms: 10000 },
|
|
64
|
+
initial_intensity: 0.6,
|
|
65
|
+
});
|
|
66
|
+
expect(computeIntensity(p, p.emitted_at)).toBeCloseTo(0.6);
|
|
67
|
+
expect(computeIntensity(p, p.emitted_at + 10000)).toBeCloseTo(0.3, 1);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe("Linear Decay", () => {
|
|
72
|
+
it("returns full intensity at t=0", () => {
|
|
73
|
+
const p = makePheromone({
|
|
74
|
+
decay_model: { type: "linear", rate_per_ms: 0.0001 },
|
|
75
|
+
initial_intensity: 1.0,
|
|
76
|
+
});
|
|
77
|
+
expect(computeIntensity(p, p.emitted_at)).toBe(1.0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("decreases linearly", () => {
|
|
81
|
+
const p = makePheromone({
|
|
82
|
+
decay_model: { type: "linear", rate_per_ms: 0.0001 },
|
|
83
|
+
initial_intensity: 1.0,
|
|
84
|
+
});
|
|
85
|
+
// After 5000ms: 1.0 - 0.0001 * 5000 = 0.5
|
|
86
|
+
expect(computeIntensity(p, p.emitted_at + 5000)).toBeCloseTo(0.5);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("clamps to zero (never negative)", () => {
|
|
90
|
+
const p = makePheromone({
|
|
91
|
+
decay_model: { type: "linear", rate_per_ms: 0.0001 },
|
|
92
|
+
initial_intensity: 1.0,
|
|
93
|
+
});
|
|
94
|
+
expect(computeIntensity(p, p.emitted_at + 20000)).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("Step Decay", () => {
|
|
99
|
+
const steps = [
|
|
100
|
+
{ at_ms: 0, intensity: 1.0 },
|
|
101
|
+
{ at_ms: 5000, intensity: 0.7 },
|
|
102
|
+
{ at_ms: 10000, intensity: 0.3 },
|
|
103
|
+
{ at_ms: 20000, intensity: 0.0 },
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
it("returns correct intensity at each step boundary", () => {
|
|
107
|
+
const p = makePheromone({
|
|
108
|
+
decay_model: { type: "step", steps },
|
|
109
|
+
initial_intensity: 1.0,
|
|
110
|
+
});
|
|
111
|
+
expect(computeIntensity(p, p.emitted_at + 0)).toBe(1.0);
|
|
112
|
+
expect(computeIntensity(p, p.emitted_at + 5000)).toBe(0.7);
|
|
113
|
+
expect(computeIntensity(p, p.emitted_at + 10000)).toBe(0.3);
|
|
114
|
+
expect(computeIntensity(p, p.emitted_at + 20000)).toBe(0.0);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("holds intensity between steps", () => {
|
|
118
|
+
const p = makePheromone({
|
|
119
|
+
decay_model: { type: "step", steps },
|
|
120
|
+
initial_intensity: 1.0,
|
|
121
|
+
});
|
|
122
|
+
expect(computeIntensity(p, p.emitted_at + 7500)).toBe(0.7);
|
|
123
|
+
expect(computeIntensity(p, p.emitted_at + 15000)).toBe(0.3);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("Immortal Decay", () => {
|
|
128
|
+
it("never decays", () => {
|
|
129
|
+
const p = makePheromone({
|
|
130
|
+
decay_model: { type: "immortal" },
|
|
131
|
+
initial_intensity: 0.8,
|
|
132
|
+
});
|
|
133
|
+
expect(computeIntensity(p, p.emitted_at)).toBe(0.8);
|
|
134
|
+
expect(computeIntensity(p, p.emitted_at + 999999999)).toBe(0.8);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("Evaporation", () => {
|
|
139
|
+
it("marks pheromone as evaporated below ttl_floor", () => {
|
|
140
|
+
const p = makePheromone({
|
|
141
|
+
decay_model: { type: "linear", rate_per_ms: 0.001 },
|
|
142
|
+
initial_intensity: 0.1,
|
|
143
|
+
ttl_floor: 0.01,
|
|
144
|
+
});
|
|
145
|
+
// After 100ms: 0.1 - 0.001 * 100 = 0.0
|
|
146
|
+
expect(isEvaporated(p, p.emitted_at + 100)).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("keeps pheromone alive above ttl_floor", () => {
|
|
150
|
+
const p = makePheromone({
|
|
151
|
+
decay_model: { type: "exponential", half_life_ms: 100000 },
|
|
152
|
+
initial_intensity: 1.0,
|
|
153
|
+
ttl_floor: 0.01,
|
|
154
|
+
});
|
|
155
|
+
expect(isEvaporated(p, p.emitted_at + 1000)).toBe(false);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// ============================================================================
|
|
161
|
+
// MERGE STRATEGIES
|
|
162
|
+
// ============================================================================
|
|
163
|
+
|
|
164
|
+
describe("Merge Strategies", () => {
|
|
165
|
+
let bb: Blackboard;
|
|
166
|
+
|
|
167
|
+
beforeEach(() => {
|
|
168
|
+
bb = new Blackboard({ trackEmissionHistory: false });
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("reinforce: resets decay clock", () => {
|
|
172
|
+
const r1 = bb.emit({
|
|
173
|
+
trail: "merge",
|
|
174
|
+
type: "test",
|
|
175
|
+
intensity: 0.8,
|
|
176
|
+
merge_strategy: "new",
|
|
177
|
+
});
|
|
178
|
+
expect(r1.action).toBe("created");
|
|
179
|
+
|
|
180
|
+
const r2 = bb.emit({
|
|
181
|
+
trail: "merge",
|
|
182
|
+
type: "test",
|
|
183
|
+
intensity: 0.9,
|
|
184
|
+
merge_strategy: "reinforce",
|
|
185
|
+
});
|
|
186
|
+
expect(r2.action).toBe("reinforced");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("replace: overwrites payload and tags", () => {
|
|
190
|
+
bb.emit({
|
|
191
|
+
trail: "merge",
|
|
192
|
+
type: "replace-test",
|
|
193
|
+
intensity: 0.5,
|
|
194
|
+
payload: { data: "original" },
|
|
195
|
+
tags: ["tag1"],
|
|
196
|
+
merge_strategy: "reinforce",
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
const r = bb.emit({
|
|
200
|
+
trail: "merge",
|
|
201
|
+
type: "replace-test",
|
|
202
|
+
intensity: 0.7,
|
|
203
|
+
payload: { data: "original" },
|
|
204
|
+
tags: ["tag2"],
|
|
205
|
+
merge_strategy: "replace",
|
|
206
|
+
});
|
|
207
|
+
expect(r.action).toBe("replaced");
|
|
208
|
+
|
|
209
|
+
const sniff = bb.sniff({ trails: ["merge"], types: ["replace-test"] });
|
|
210
|
+
expect(sniff.pheromones[0].tags).toEqual(["tag2"]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("max: picks higher intensity", () => {
|
|
214
|
+
bb.emit({
|
|
215
|
+
trail: "merge",
|
|
216
|
+
type: "max-test",
|
|
217
|
+
intensity: 0.3,
|
|
218
|
+
merge_strategy: "new",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const r = bb.emit({
|
|
222
|
+
trail: "merge",
|
|
223
|
+
type: "max-test",
|
|
224
|
+
intensity: 0.9,
|
|
225
|
+
merge_strategy: "max",
|
|
226
|
+
});
|
|
227
|
+
expect(r.action).toBe("merged");
|
|
228
|
+
expect(r.new_intensity).toBeGreaterThanOrEqual(0.9);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("add: sums intensities (clamped to 1.0)", () => {
|
|
232
|
+
bb.emit({
|
|
233
|
+
trail: "merge",
|
|
234
|
+
type: "add-test",
|
|
235
|
+
intensity: 0.7,
|
|
236
|
+
merge_strategy: "new",
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
const r = bb.emit({
|
|
240
|
+
trail: "merge",
|
|
241
|
+
type: "add-test",
|
|
242
|
+
intensity: 0.5,
|
|
243
|
+
merge_strategy: "add",
|
|
244
|
+
});
|
|
245
|
+
expect(r.action).toBe("merged");
|
|
246
|
+
expect(r.new_intensity).toBeLessThanOrEqual(1.0);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("new: always creates separate pheromone", () => {
|
|
250
|
+
const r1 = bb.emit({
|
|
251
|
+
trail: "merge",
|
|
252
|
+
type: "new-test",
|
|
253
|
+
intensity: 0.5,
|
|
254
|
+
merge_strategy: "new",
|
|
255
|
+
});
|
|
256
|
+
const r2 = bb.emit({
|
|
257
|
+
trail: "merge",
|
|
258
|
+
type: "new-test",
|
|
259
|
+
intensity: 0.5,
|
|
260
|
+
merge_strategy: "new",
|
|
261
|
+
});
|
|
262
|
+
expect(r1.pheromone_id).not.toBe(r2.pheromone_id);
|
|
263
|
+
expect(r2.action).toBe("created");
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
// ============================================================================
|
|
268
|
+
// CONDITIONS
|
|
269
|
+
// ============================================================================
|
|
270
|
+
|
|
271
|
+
describe("Condition Evaluation", () => {
|
|
272
|
+
const now = Date.now();
|
|
273
|
+
|
|
274
|
+
const pheromones: Pheromone[] = [
|
|
275
|
+
makePheromone({ trail: "a", type: "alert", initial_intensity: 0.8 }),
|
|
276
|
+
makePheromone({ trail: "a", type: "alert", initial_intensity: 0.3 }),
|
|
277
|
+
makePheromone({ trail: "b", type: "data", initial_intensity: 0.5 }),
|
|
278
|
+
];
|
|
279
|
+
|
|
280
|
+
describe("Threshold Conditions", () => {
|
|
281
|
+
it("sum aggregation >= value", () => {
|
|
282
|
+
const condition: ScentCondition = {
|
|
283
|
+
type: "threshold",
|
|
284
|
+
trail: "a",
|
|
285
|
+
signal_type: "alert",
|
|
286
|
+
aggregation: "sum",
|
|
287
|
+
operator: ">=",
|
|
288
|
+
value: 1.0,
|
|
289
|
+
};
|
|
290
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
291
|
+
expect(result.met).toBe(true);
|
|
292
|
+
expect(result.value).toBeCloseTo(1.1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("max aggregation", () => {
|
|
296
|
+
const condition: ScentCondition = {
|
|
297
|
+
type: "threshold",
|
|
298
|
+
trail: "a",
|
|
299
|
+
signal_type: "alert",
|
|
300
|
+
aggregation: "max",
|
|
301
|
+
operator: ">=",
|
|
302
|
+
value: 0.7,
|
|
303
|
+
};
|
|
304
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
305
|
+
expect(result.met).toBe(true);
|
|
306
|
+
expect(result.value).toBeCloseTo(0.8);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("count aggregation", () => {
|
|
310
|
+
const condition: ScentCondition = {
|
|
311
|
+
type: "threshold",
|
|
312
|
+
trail: "a",
|
|
313
|
+
signal_type: "alert",
|
|
314
|
+
aggregation: "count",
|
|
315
|
+
operator: "==",
|
|
316
|
+
value: 2,
|
|
317
|
+
};
|
|
318
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
319
|
+
expect(result.met).toBe(true);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("returns false when condition not met", () => {
|
|
323
|
+
const condition: ScentCondition = {
|
|
324
|
+
type: "threshold",
|
|
325
|
+
trail: "a",
|
|
326
|
+
signal_type: "alert",
|
|
327
|
+
aggregation: "sum",
|
|
328
|
+
operator: ">=",
|
|
329
|
+
value: 5.0,
|
|
330
|
+
};
|
|
331
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
332
|
+
expect(result.met).toBe(false);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("Composite Conditions", () => {
|
|
337
|
+
it("AND: all must be met", () => {
|
|
338
|
+
const condition: ScentCondition = {
|
|
339
|
+
type: "composite",
|
|
340
|
+
operator: "and",
|
|
341
|
+
conditions: [
|
|
342
|
+
{
|
|
343
|
+
type: "threshold",
|
|
344
|
+
trail: "a",
|
|
345
|
+
signal_type: "alert",
|
|
346
|
+
aggregation: "any",
|
|
347
|
+
operator: ">=",
|
|
348
|
+
value: 0.1,
|
|
349
|
+
},
|
|
350
|
+
{
|
|
351
|
+
type: "threshold",
|
|
352
|
+
trail: "b",
|
|
353
|
+
signal_type: "data",
|
|
354
|
+
aggregation: "any",
|
|
355
|
+
operator: ">=",
|
|
356
|
+
value: 0.1,
|
|
357
|
+
},
|
|
358
|
+
],
|
|
359
|
+
};
|
|
360
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
361
|
+
expect(result.met).toBe(true);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("OR: at least one must be met", () => {
|
|
365
|
+
const condition: ScentCondition = {
|
|
366
|
+
type: "composite",
|
|
367
|
+
operator: "or",
|
|
368
|
+
conditions: [
|
|
369
|
+
{
|
|
370
|
+
type: "threshold",
|
|
371
|
+
trail: "nonexistent",
|
|
372
|
+
signal_type: "test",
|
|
373
|
+
aggregation: "any",
|
|
374
|
+
operator: ">=",
|
|
375
|
+
value: 1.0,
|
|
376
|
+
},
|
|
377
|
+
{
|
|
378
|
+
type: "threshold",
|
|
379
|
+
trail: "a",
|
|
380
|
+
signal_type: "alert",
|
|
381
|
+
aggregation: "any",
|
|
382
|
+
operator: ">=",
|
|
383
|
+
value: 0.1,
|
|
384
|
+
},
|
|
385
|
+
],
|
|
386
|
+
};
|
|
387
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
388
|
+
expect(result.met).toBe(true);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("NOT: inverts result", () => {
|
|
392
|
+
const condition: ScentCondition = {
|
|
393
|
+
type: "composite",
|
|
394
|
+
operator: "not",
|
|
395
|
+
conditions: [
|
|
396
|
+
{
|
|
397
|
+
type: "threshold",
|
|
398
|
+
trail: "nonexistent",
|
|
399
|
+
signal_type: "test",
|
|
400
|
+
aggregation: "any",
|
|
401
|
+
operator: ">=",
|
|
402
|
+
value: 1.0,
|
|
403
|
+
},
|
|
404
|
+
],
|
|
405
|
+
};
|
|
406
|
+
const result = evaluateCondition(condition, { pheromones, now });
|
|
407
|
+
expect(result.met).toBe(true);
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
describe("Rate Conditions", () => {
|
|
412
|
+
it("detects emission rate", () => {
|
|
413
|
+
const history = [
|
|
414
|
+
{ trail: "a", type: "alert", timestamp: now - 100 },
|
|
415
|
+
{ trail: "a", type: "alert", timestamp: now - 200 },
|
|
416
|
+
{ trail: "a", type: "alert", timestamp: now - 300 },
|
|
417
|
+
];
|
|
418
|
+
|
|
419
|
+
const condition: ScentCondition = {
|
|
420
|
+
type: "rate",
|
|
421
|
+
trail: "a",
|
|
422
|
+
signal_type: "alert",
|
|
423
|
+
metric: "emissions_per_second",
|
|
424
|
+
window_ms: 1000,
|
|
425
|
+
operator: ">=",
|
|
426
|
+
value: 2.0,
|
|
427
|
+
};
|
|
428
|
+
const result = evaluateCondition(condition, {
|
|
429
|
+
pheromones,
|
|
430
|
+
now,
|
|
431
|
+
emissionHistory: history,
|
|
432
|
+
});
|
|
433
|
+
expect(result.met).toBe(true);
|
|
434
|
+
expect(result.value).toBe(3.0); // 3 emissions per 1 second window
|
|
435
|
+
});
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
describe("Pattern Conditions", () => {
|
|
439
|
+
it("matches ordered sequence", () => {
|
|
440
|
+
const history = [
|
|
441
|
+
{ trail: "pipeline", type: "step-1", timestamp: now - 300 },
|
|
442
|
+
{ trail: "pipeline", type: "step-2", timestamp: now - 200 },
|
|
443
|
+
{ trail: "pipeline", type: "step-3", timestamp: now - 100 },
|
|
444
|
+
];
|
|
445
|
+
|
|
446
|
+
const condition: ScentCondition = {
|
|
447
|
+
type: "pattern",
|
|
448
|
+
sequence: [
|
|
449
|
+
{ trail: "pipeline", signal_type: "step-1" },
|
|
450
|
+
{ trail: "pipeline", signal_type: "step-2" },
|
|
451
|
+
{ trail: "pipeline", signal_type: "step-3" },
|
|
452
|
+
],
|
|
453
|
+
window_ms: 1000,
|
|
454
|
+
ordered: true,
|
|
455
|
+
};
|
|
456
|
+
const result = evaluateCondition(condition, {
|
|
457
|
+
pheromones: [],
|
|
458
|
+
now,
|
|
459
|
+
emissionHistory: history,
|
|
460
|
+
});
|
|
461
|
+
expect(result.met).toBe(true);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("rejects wrong order when ordered is true", () => {
|
|
465
|
+
const history = [
|
|
466
|
+
{ trail: "pipeline", type: "step-3", timestamp: now - 300 },
|
|
467
|
+
{ trail: "pipeline", type: "step-1", timestamp: now - 200 },
|
|
468
|
+
{ trail: "pipeline", type: "step-2", timestamp: now - 100 },
|
|
469
|
+
];
|
|
470
|
+
|
|
471
|
+
const condition: ScentCondition = {
|
|
472
|
+
type: "pattern",
|
|
473
|
+
sequence: [
|
|
474
|
+
{ trail: "pipeline", signal_type: "step-1" },
|
|
475
|
+
{ trail: "pipeline", signal_type: "step-2" },
|
|
476
|
+
{ trail: "pipeline", signal_type: "step-3" },
|
|
477
|
+
],
|
|
478
|
+
window_ms: 1000,
|
|
479
|
+
ordered: true,
|
|
480
|
+
};
|
|
481
|
+
const result = evaluateCondition(condition, {
|
|
482
|
+
pheromones: [],
|
|
483
|
+
now,
|
|
484
|
+
emissionHistory: history,
|
|
485
|
+
});
|
|
486
|
+
expect(result.met).toBe(false);
|
|
487
|
+
});
|
|
488
|
+
|
|
489
|
+
it("matches unordered sequence", () => {
|
|
490
|
+
const history = [
|
|
491
|
+
{ trail: "pipeline", type: "step-3", timestamp: now - 300 },
|
|
492
|
+
{ trail: "pipeline", type: "step-1", timestamp: now - 200 },
|
|
493
|
+
{ trail: "pipeline", type: "step-2", timestamp: now - 100 },
|
|
494
|
+
];
|
|
495
|
+
|
|
496
|
+
const condition: ScentCondition = {
|
|
497
|
+
type: "pattern",
|
|
498
|
+
sequence: [
|
|
499
|
+
{ trail: "pipeline", signal_type: "step-1" },
|
|
500
|
+
{ trail: "pipeline", signal_type: "step-2" },
|
|
501
|
+
{ trail: "pipeline", signal_type: "step-3" },
|
|
502
|
+
],
|
|
503
|
+
window_ms: 1000,
|
|
504
|
+
ordered: false,
|
|
505
|
+
};
|
|
506
|
+
const result = evaluateCondition(condition, {
|
|
507
|
+
pheromones: [],
|
|
508
|
+
now,
|
|
509
|
+
emissionHistory: history,
|
|
510
|
+
});
|
|
511
|
+
expect(result.met).toBe(true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("fails when sequence outside window", () => {
|
|
515
|
+
const condition: ScentCondition = {
|
|
516
|
+
type: "pattern",
|
|
517
|
+
sequence: [
|
|
518
|
+
{ trail: "pipeline", signal_type: "step-1" },
|
|
519
|
+
],
|
|
520
|
+
window_ms: 100,
|
|
521
|
+
};
|
|
522
|
+
const result = evaluateCondition(condition, {
|
|
523
|
+
pheromones: [],
|
|
524
|
+
now,
|
|
525
|
+
emissionHistory: [
|
|
526
|
+
{ trail: "pipeline", type: "step-1", timestamp: now - 200 },
|
|
527
|
+
],
|
|
528
|
+
});
|
|
529
|
+
expect(result.met).toBe(false);
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
// ============================================================================
|
|
535
|
+
// TAG FILTERING
|
|
536
|
+
// ============================================================================
|
|
537
|
+
|
|
538
|
+
describe("Tag Filtering", () => {
|
|
539
|
+
let bb: Blackboard;
|
|
540
|
+
|
|
541
|
+
beforeEach(() => {
|
|
542
|
+
bb = new Blackboard({ trackEmissionHistory: false });
|
|
543
|
+
bb.emit({ trail: "tags", type: "a", intensity: 0.8, tags: ["urgent", "finance"], merge_strategy: "new" });
|
|
544
|
+
bb.emit({ trail: "tags", type: "b", intensity: 0.6, tags: ["routine"], merge_strategy: "new" });
|
|
545
|
+
bb.emit({ trail: "tags", type: "c", intensity: 0.9, tags: ["urgent", "health"], merge_strategy: "new" });
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
it("any: matches if pheromone has any of the tags", () => {
|
|
549
|
+
const r = bb.sniff({ trails: ["tags"], tags: { any: ["finance"] } });
|
|
550
|
+
expect(r.pheromones.length).toBe(1);
|
|
551
|
+
expect(r.pheromones[0].type).toBe("a");
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("all: matches if pheromone has all tags", () => {
|
|
555
|
+
const r = bb.sniff({ trails: ["tags"], tags: { all: ["urgent", "finance"] } });
|
|
556
|
+
expect(r.pheromones.length).toBe(1);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it("none: excludes pheromones with tag", () => {
|
|
560
|
+
const r = bb.sniff({ trails: ["tags"], tags: { none: ["urgent"] } });
|
|
561
|
+
expect(r.pheromones.length).toBe(1);
|
|
562
|
+
expect(r.pheromones[0].type).toBe("b");
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
// ============================================================================
|
|
567
|
+
// SCENT COOLDOWNS & EDGE TRIGGERING
|
|
568
|
+
// ============================================================================
|
|
569
|
+
|
|
570
|
+
describe("Scent Behavior", () => {
|
|
571
|
+
let bb: Blackboard;
|
|
572
|
+
|
|
573
|
+
beforeEach(() => {
|
|
574
|
+
bb = new Blackboard({ evaluationInterval: 10, trackEmissionHistory: true });
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
it("respects cooldown_ms", async () => {
|
|
578
|
+
let triggerCount = 0;
|
|
579
|
+
|
|
580
|
+
bb.registerScent({
|
|
581
|
+
scent_id: "cooldown-test",
|
|
582
|
+
condition: {
|
|
583
|
+
type: "threshold",
|
|
584
|
+
trail: "cd",
|
|
585
|
+
signal_type: "ping",
|
|
586
|
+
aggregation: "any",
|
|
587
|
+
operator: ">=",
|
|
588
|
+
value: 0.1,
|
|
589
|
+
},
|
|
590
|
+
cooldown_ms: 500,
|
|
591
|
+
trigger_mode: "level",
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
bb.onTrigger("cooldown-test", async () => {
|
|
595
|
+
triggerCount++;
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
bb.emit({ trail: "cd", type: "ping", intensity: 0.8, decay: { type: "immortal" } });
|
|
599
|
+
bb.start();
|
|
600
|
+
|
|
601
|
+
// Wait for evaluation to fire
|
|
602
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
603
|
+
const firstCount = triggerCount;
|
|
604
|
+
expect(firstCount).toBeGreaterThanOrEqual(1);
|
|
605
|
+
|
|
606
|
+
// During cooldown, should not trigger again fast
|
|
607
|
+
const midCount = triggerCount;
|
|
608
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
609
|
+
// Count should not have increased significantly during cooldown
|
|
610
|
+
// (may increase by 1 due to timing, but shouldn't be >> firstCount)
|
|
611
|
+
|
|
612
|
+
bb.stop();
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("edge_rising only fires on transition from false → true", async () => {
|
|
616
|
+
let triggerCount = 0;
|
|
617
|
+
|
|
618
|
+
bb.registerScent({
|
|
619
|
+
scent_id: "edge-test",
|
|
620
|
+
condition: {
|
|
621
|
+
type: "threshold",
|
|
622
|
+
trail: "edge",
|
|
623
|
+
signal_type: "sig",
|
|
624
|
+
aggregation: "any",
|
|
625
|
+
operator: ">=",
|
|
626
|
+
value: 0.5,
|
|
627
|
+
},
|
|
628
|
+
trigger_mode: "edge_rising",
|
|
629
|
+
cooldown_ms: 0,
|
|
630
|
+
});
|
|
631
|
+
|
|
632
|
+
bb.onTrigger("edge-test", async () => {
|
|
633
|
+
triggerCount++;
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
bb.start();
|
|
637
|
+
|
|
638
|
+
// No pheromone yet — condition is false
|
|
639
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
640
|
+
expect(triggerCount).toBe(0);
|
|
641
|
+
|
|
642
|
+
// Emit — condition becomes true (rising edge)
|
|
643
|
+
bb.emit({ trail: "edge", type: "sig", intensity: 0.8, decay: { type: "immortal" } });
|
|
644
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
645
|
+
expect(triggerCount).toBe(1);
|
|
646
|
+
|
|
647
|
+
// Condition stays true — should NOT trigger again
|
|
648
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
649
|
+
expect(triggerCount).toBe(1);
|
|
650
|
+
|
|
651
|
+
bb.stop();
|
|
652
|
+
});
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// ============================================================================
|
|
656
|
+
// GARBAGE COLLECTION
|
|
657
|
+
// ============================================================================
|
|
658
|
+
|
|
659
|
+
describe("Garbage Collection", () => {
|
|
660
|
+
it("removes evaporated pheromones", () => {
|
|
661
|
+
const bb = new Blackboard({ trackEmissionHistory: false });
|
|
662
|
+
|
|
663
|
+
// Emit a fast-decaying pheromone
|
|
664
|
+
bb.emit({
|
|
665
|
+
trail: "gc",
|
|
666
|
+
type: "temp",
|
|
667
|
+
intensity: 0.1,
|
|
668
|
+
decay: { type: "linear", rate_per_ms: 0.01 },
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
expect(bb.size).toBe(1);
|
|
672
|
+
|
|
673
|
+
// Wait for it to decay
|
|
674
|
+
const wait = (ms: number) => {
|
|
675
|
+
const start = Date.now();
|
|
676
|
+
while (Date.now() - start < ms) {
|
|
677
|
+
// busy wait
|
|
678
|
+
}
|
|
679
|
+
};
|
|
680
|
+
wait(50);
|
|
681
|
+
|
|
682
|
+
const removed = bb.gc();
|
|
683
|
+
expect(removed).toBe(1);
|
|
684
|
+
expect(bb.size).toBe(0);
|
|
685
|
+
});
|
|
686
|
+
});
|