@debriefer/core 2.0.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 (98) hide show
  1. package/README.md +86 -0
  2. package/dist/__tests__/base-source.test.d.ts +2 -0
  3. package/dist/__tests__/base-source.test.d.ts.map +1 -0
  4. package/dist/__tests__/base-source.test.js +333 -0
  5. package/dist/__tests__/base-source.test.js.map +1 -0
  6. package/dist/__tests__/batch-runner.test.d.ts +2 -0
  7. package/dist/__tests__/batch-runner.test.d.ts.map +1 -0
  8. package/dist/__tests__/batch-runner.test.js +217 -0
  9. package/dist/__tests__/batch-runner.test.js.map +1 -0
  10. package/dist/__tests__/cache.test.d.ts +2 -0
  11. package/dist/__tests__/cache.test.d.ts.map +1 -0
  12. package/dist/__tests__/cache.test.js +127 -0
  13. package/dist/__tests__/cache.test.js.map +1 -0
  14. package/dist/__tests__/confidence.test.d.ts +2 -0
  15. package/dist/__tests__/confidence.test.d.ts.map +1 -0
  16. package/dist/__tests__/confidence.test.js +81 -0
  17. package/dist/__tests__/confidence.test.js.map +1 -0
  18. package/dist/__tests__/cost-tracker.test.d.ts +2 -0
  19. package/dist/__tests__/cost-tracker.test.d.ts.map +1 -0
  20. package/dist/__tests__/cost-tracker.test.js +149 -0
  21. package/dist/__tests__/cost-tracker.test.js.map +1 -0
  22. package/dist/__tests__/orchestrator.test.d.ts +2 -0
  23. package/dist/__tests__/orchestrator.test.d.ts.map +1 -0
  24. package/dist/__tests__/orchestrator.test.js +751 -0
  25. package/dist/__tests__/orchestrator.test.js.map +1 -0
  26. package/dist/__tests__/rate-limiter.test.d.ts +2 -0
  27. package/dist/__tests__/rate-limiter.test.d.ts.map +1 -0
  28. package/dist/__tests__/rate-limiter.test.js +83 -0
  29. package/dist/__tests__/rate-limiter.test.js.map +1 -0
  30. package/dist/__tests__/reliability.test.d.ts +2 -0
  31. package/dist/__tests__/reliability.test.d.ts.map +1 -0
  32. package/dist/__tests__/reliability.test.js +207 -0
  33. package/dist/__tests__/reliability.test.js.map +1 -0
  34. package/dist/__tests__/synthesizer.test.d.ts +2 -0
  35. package/dist/__tests__/synthesizer.test.d.ts.map +1 -0
  36. package/dist/__tests__/synthesizer.test.js +50 -0
  37. package/dist/__tests__/synthesizer.test.js.map +1 -0
  38. package/dist/__tests__/telemetry.test.d.ts +2 -0
  39. package/dist/__tests__/telemetry.test.d.ts.map +1 -0
  40. package/dist/__tests__/telemetry.test.js +81 -0
  41. package/dist/__tests__/telemetry.test.js.map +1 -0
  42. package/dist/__tests__/types.test.d.ts +2 -0
  43. package/dist/__tests__/types.test.d.ts.map +1 -0
  44. package/dist/__tests__/types.test.js +708 -0
  45. package/dist/__tests__/types.test.js.map +1 -0
  46. package/dist/base-source.d.ts +91 -0
  47. package/dist/base-source.d.ts.map +1 -0
  48. package/dist/base-source.js +144 -0
  49. package/dist/base-source.js.map +1 -0
  50. package/dist/batch-runner.d.ts +40 -0
  51. package/dist/batch-runner.d.ts.map +1 -0
  52. package/dist/batch-runner.js +65 -0
  53. package/dist/batch-runner.js.map +1 -0
  54. package/dist/cache/in-memory.d.ts +26 -0
  55. package/dist/cache/in-memory.d.ts.map +1 -0
  56. package/dist/cache/in-memory.js +51 -0
  57. package/dist/cache/in-memory.js.map +1 -0
  58. package/dist/confidence.d.ts +13 -0
  59. package/dist/confidence.d.ts.map +1 -0
  60. package/dist/confidence.js +29 -0
  61. package/dist/confidence.js.map +1 -0
  62. package/dist/cost-tracker.d.ts +37 -0
  63. package/dist/cost-tracker.d.ts.map +1 -0
  64. package/dist/cost-tracker.js +62 -0
  65. package/dist/cost-tracker.js.map +1 -0
  66. package/dist/index.d.ts +25 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +28 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/orchestrator.d.ts +92 -0
  71. package/dist/orchestrator.d.ts.map +1 -0
  72. package/dist/orchestrator.js +373 -0
  73. package/dist/orchestrator.js.map +1 -0
  74. package/dist/rate-limiter.d.ts +31 -0
  75. package/dist/rate-limiter.d.ts.map +1 -0
  76. package/dist/rate-limiter.js +79 -0
  77. package/dist/rate-limiter.js.map +1 -0
  78. package/dist/reliability.d.ts +49 -0
  79. package/dist/reliability.d.ts.map +1 -0
  80. package/dist/reliability.js +67 -0
  81. package/dist/reliability.js.map +1 -0
  82. package/dist/synthesizer.d.ts +31 -0
  83. package/dist/synthesizer.d.ts.map +1 -0
  84. package/dist/synthesizer.js +47 -0
  85. package/dist/synthesizer.js.map +1 -0
  86. package/dist/telemetry/console.d.ts +7 -0
  87. package/dist/telemetry/console.d.ts.map +1 -0
  88. package/dist/telemetry/console.js +21 -0
  89. package/dist/telemetry/console.js.map +1 -0
  90. package/dist/telemetry/noop.d.ts +7 -0
  91. package/dist/telemetry/noop.d.ts.map +1 -0
  92. package/dist/telemetry/noop.js +12 -0
  93. package/dist/telemetry/noop.js.map +1 -0
  94. package/dist/types.d.ts +417 -0
  95. package/dist/types.d.ts.map +1 -0
  96. package/dist/types.js +102 -0
  97. package/dist/types.js.map +1 -0
  98. package/package.json +46 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Type-checking tests for the core debriefer type system.
3
+ *
4
+ * These tests verify that the types are structurally correct, that required
5
+ * and optional fields behave as expected, that error classes have the right
6
+ * properties, and that interfaces can be partially implemented.
7
+ */
8
+ import { describe, it, expect } from "vitest";
9
+ import { ReliabilityTier } from "../reliability.js";
10
+ import { CostLimitExceededError, SourceTimeoutError, SourceAccessBlockedError } from "../types.js";
11
+ // ============================================================================
12
+ // ResearchSubject
13
+ // ============================================================================
14
+ describe("ResearchSubject", () => {
15
+ it("accepts minimal fields (string id)", () => {
16
+ const subject = { id: "abc-123", name: "Test Subject" };
17
+ expect(subject.id).toBe("abc-123");
18
+ expect(subject.name).toBe("Test Subject");
19
+ expect(subject.context).toBeUndefined();
20
+ });
21
+ it("accepts minimal fields (numeric id)", () => {
22
+ const subject = { id: 42, name: "Test Subject" };
23
+ expect(subject.id).toBe(42);
24
+ });
25
+ it("accepts context with arbitrary metadata", () => {
26
+ const subject = {
27
+ id: 2157,
28
+ name: "John Wayne",
29
+ context: {
30
+ deathday: "1979-06-11",
31
+ tmdbId: 2157,
32
+ genres: ["western", "war"],
33
+ isAlive: false,
34
+ },
35
+ };
36
+ expect(subject.context?.deathday).toBe("1979-06-11");
37
+ expect(subject.context?.tmdbId).toBe(2157);
38
+ expect(subject.context?.genres).toEqual(["western", "war"]);
39
+ expect(subject.context?.isAlive).toBe(false);
40
+ });
41
+ it("accepts empty context", () => {
42
+ const subject = { id: 1, name: "X", context: {} };
43
+ expect(subject.context).toEqual({});
44
+ });
45
+ });
46
+ // ============================================================================
47
+ // RawFinding
48
+ // ============================================================================
49
+ describe("RawFinding", () => {
50
+ it("has required fields", () => {
51
+ const finding = {
52
+ text: "He died of stomach cancer on June 11, 1979.",
53
+ confidence: 0.85,
54
+ costUsd: 0,
55
+ };
56
+ expect(finding.text).toContain("stomach cancer");
57
+ expect(finding.confidence).toBe(0.85);
58
+ expect(finding.costUsd).toBe(0);
59
+ });
60
+ it("optional fields are undefined when omitted", () => {
61
+ const finding = {
62
+ text: "Content",
63
+ confidence: 0.5,
64
+ costUsd: 0,
65
+ };
66
+ expect(finding.url).toBeUndefined();
67
+ expect(finding.publication).toBeUndefined();
68
+ expect(finding.articleTitle).toBeUndefined();
69
+ expect(finding.metadata).toBeUndefined();
70
+ });
71
+ it("accepts all optional fields", () => {
72
+ const finding = {
73
+ text: "He died of stomach cancer.",
74
+ url: "https://en.wikipedia.org/wiki/John_Wayne",
75
+ publication: "Wikipedia",
76
+ articleTitle: "John Wayne - Death",
77
+ confidence: 0.95,
78
+ costUsd: 0.001,
79
+ metadata: { section: "Death", wordCount: 150 },
80
+ };
81
+ expect(finding.url).toBe("https://en.wikipedia.org/wiki/John_Wayne");
82
+ expect(finding.publication).toBe("Wikipedia");
83
+ expect(finding.articleTitle).toBe("John Wayne - Death");
84
+ expect(finding.metadata?.section).toBe("Death");
85
+ });
86
+ it("accepts zero confidence", () => {
87
+ const finding = { text: "Unrelated content", confidence: 0, costUsd: 0 };
88
+ expect(finding.confidence).toBe(0);
89
+ });
90
+ it("accepts max confidence", () => {
91
+ const finding = { text: "Verified death record", confidence: 1, costUsd: 0 };
92
+ expect(finding.confidence).toBe(1);
93
+ });
94
+ });
95
+ // ============================================================================
96
+ // ScoredFinding
97
+ // ============================================================================
98
+ describe("ScoredFinding", () => {
99
+ it("extends RawFinding with source reliability info", () => {
100
+ const finding = {
101
+ text: "He died of stomach cancer.",
102
+ confidence: 0.85,
103
+ costUsd: 0,
104
+ sourceType: "wikipedia",
105
+ sourceName: "Wikipedia",
106
+ reliabilityTier: ReliabilityTier.SECONDARY_COMPILATION,
107
+ reliabilityScore: 0.85,
108
+ };
109
+ expect(finding.sourceType).toBe("wikipedia");
110
+ expect(finding.sourceName).toBe("Wikipedia");
111
+ expect(finding.reliabilityTier).toBe(ReliabilityTier.SECONDARY_COMPILATION);
112
+ expect(finding.reliabilityScore).toBe(0.85);
113
+ // Inherited from RawFinding
114
+ expect(finding.text).toContain("stomach cancer");
115
+ expect(finding.confidence).toBe(0.85);
116
+ });
117
+ it("includes optional RawFinding fields", () => {
118
+ const finding = {
119
+ text: "Content",
120
+ url: "https://example.com",
121
+ publication: "The Guardian",
122
+ articleTitle: "Obituary: John Wayne",
123
+ confidence: 0.9,
124
+ costUsd: 0.005,
125
+ metadata: { author: "Jane Doe" },
126
+ sourceType: "guardian",
127
+ sourceName: "The Guardian",
128
+ reliabilityTier: ReliabilityTier.TIER_1_NEWS,
129
+ reliabilityScore: 0.95,
130
+ };
131
+ expect(finding.publication).toBe("The Guardian");
132
+ expect(finding.reliabilityScore).toBe(0.95);
133
+ });
134
+ });
135
+ // ============================================================================
136
+ // SynthesisOptions
137
+ // ============================================================================
138
+ describe("SynthesisOptions", () => {
139
+ it("all fields are optional", () => {
140
+ const options = {};
141
+ expect(options.model).toBeUndefined();
142
+ expect(options.maxTokens).toBeUndefined();
143
+ expect(options.systemPrompt).toBeUndefined();
144
+ });
145
+ it("accepts all fields", () => {
146
+ const options = {
147
+ model: "claude-sonnet-4-20250514",
148
+ maxTokens: 4096,
149
+ systemPrompt: "You are a research assistant.",
150
+ };
151
+ expect(options.model).toBe("claude-sonnet-4-20250514");
152
+ expect(options.maxTokens).toBe(4096);
153
+ });
154
+ });
155
+ // ============================================================================
156
+ // SynthesisResult
157
+ // ============================================================================
158
+ describe("SynthesisResult", () => {
159
+ it("contains structured output and cost metadata", () => {
160
+ const result = {
161
+ data: { summary: "Died of stomach cancer", confidence: "high" },
162
+ costUsd: 0.025,
163
+ inputTokens: 3000,
164
+ outputTokens: 500,
165
+ model: "claude-sonnet-4-20250514",
166
+ };
167
+ expect(result.data.summary).toBe("Died of stomach cancer");
168
+ expect(result.costUsd).toBe(0.025);
169
+ expect(result.inputTokens).toBe(3000);
170
+ expect(result.outputTokens).toBe(500);
171
+ expect(result.model).toBe("claude-sonnet-4-20250514");
172
+ });
173
+ });
174
+ // ============================================================================
175
+ // Synthesizer interface
176
+ // ============================================================================
177
+ describe("Synthesizer", () => {
178
+ it("can be implemented with custom subject and output types", async () => {
179
+ const mockSynthesizer = {
180
+ async synthesize(subject, findings, options) {
181
+ return {
182
+ data: { summary: `Report for ${subject.name} with ${findings.length} findings` },
183
+ costUsd: 0.01,
184
+ inputTokens: 1000,
185
+ outputTokens: 200,
186
+ model: options.model ?? "test-model",
187
+ };
188
+ },
189
+ };
190
+ const result = await mockSynthesizer.synthesize({ id: 1, name: "Test Movie", context: { releaseYear: 2020 } }, [], { model: "claude-sonnet-4-20250514" });
191
+ expect(result.data.summary).toBe("Report for Test Movie with 0 findings");
192
+ expect(result.model).toBe("claude-sonnet-4-20250514");
193
+ });
194
+ });
195
+ // ============================================================================
196
+ // MinimalSource
197
+ // ============================================================================
198
+ describe("MinimalSource", () => {
199
+ it("defines the contract for source phase group membership", () => {
200
+ const source = {
201
+ name: "wikipedia",
202
+ type: "wikipedia",
203
+ reliabilityTier: ReliabilityTier.SECONDARY_COMPILATION,
204
+ reliabilityScore: 0.85,
205
+ isFree: true,
206
+ estimatedCostPerQuery: 0,
207
+ domain: "en.wikipedia.org",
208
+ isAvailable: () => true,
209
+ lookup: async () => ({
210
+ text: "Content from Wikipedia",
211
+ confidence: 0.8,
212
+ costUsd: 0,
213
+ }),
214
+ };
215
+ expect(source.name).toBe("wikipedia");
216
+ expect(source.isFree).toBe(true);
217
+ expect(source.isAvailable()).toBe(true);
218
+ });
219
+ it("lookup can return null for no results", async () => {
220
+ const source = {
221
+ name: "obscure-source",
222
+ type: "obscure",
223
+ reliabilityTier: ReliabilityTier.UNRELIABLE_UGC,
224
+ reliabilityScore: 0.35,
225
+ isFree: true,
226
+ estimatedCostPerQuery: 0,
227
+ domain: "obscure.example.com",
228
+ isAvailable: () => true,
229
+ lookup: async () => null,
230
+ };
231
+ const result = await source.lookup({ id: 1, name: "Nobody" }, AbortSignal.timeout(5000));
232
+ expect(result).toBeNull();
233
+ });
234
+ });
235
+ // ============================================================================
236
+ // SourcePhaseGroup
237
+ // ============================================================================
238
+ describe("SourcePhaseGroup", () => {
239
+ it("groups sources by phase with optional name", () => {
240
+ const mockSource = {
241
+ name: "wikidata",
242
+ type: "wikidata",
243
+ reliabilityTier: ReliabilityTier.STRUCTURED_DATA,
244
+ reliabilityScore: 1.0,
245
+ isFree: true,
246
+ estimatedCostPerQuery: 0,
247
+ domain: "wikidata.org",
248
+ isAvailable: () => true,
249
+ lookup: async () => null,
250
+ };
251
+ const phase = {
252
+ phase: 1,
253
+ name: "Structured Data",
254
+ sources: [mockSource],
255
+ };
256
+ expect(phase.phase).toBe(1);
257
+ expect(phase.name).toBe("Structured Data");
258
+ expect(phase.sources).toHaveLength(1);
259
+ expect(phase.sources[0].name).toBe("wikidata");
260
+ });
261
+ it("name is optional", () => {
262
+ const phase = {
263
+ phase: 2,
264
+ sources: [],
265
+ };
266
+ expect(phase.name).toBeUndefined();
267
+ expect(phase.sources).toHaveLength(0);
268
+ });
269
+ });
270
+ // ============================================================================
271
+ // DebriefResult
272
+ // ============================================================================
273
+ describe("DebriefResult", () => {
274
+ it("contains all required fields for a successful debrief", () => {
275
+ const result = {
276
+ subject: { id: 1, name: "John Wayne" },
277
+ data: { summary: "Died of stomach cancer" },
278
+ findings: [
279
+ {
280
+ text: "He died of stomach cancer.",
281
+ confidence: 0.85,
282
+ costUsd: 0,
283
+ sourceType: "wikipedia",
284
+ sourceName: "Wikipedia",
285
+ reliabilityTier: ReliabilityTier.SECONDARY_COMPILATION,
286
+ reliabilityScore: 0.85,
287
+ },
288
+ ],
289
+ synthesisResult: {
290
+ data: { summary: "Died of stomach cancer" },
291
+ costUsd: 0.025,
292
+ inputTokens: 3000,
293
+ outputTokens: 500,
294
+ model: "claude-sonnet-4-20250514",
295
+ },
296
+ totalCostUsd: 0.025,
297
+ sourcesAttempted: 5,
298
+ sourcesSucceeded: 3,
299
+ stoppedAtPhase: 2,
300
+ durationMs: 1500,
301
+ };
302
+ expect(result.subject.name).toBe("John Wayne");
303
+ expect(result.data?.summary).toBe("Died of stomach cancer");
304
+ expect(result.findings).toHaveLength(1);
305
+ expect(result.totalCostUsd).toBe(0.025);
306
+ expect(result.stoppedAtPhase).toBe(2);
307
+ });
308
+ it("data is null when synthesis fails or is skipped", () => {
309
+ const result = {
310
+ subject: { id: 1, name: "Unknown Person" },
311
+ data: null,
312
+ findings: [],
313
+ totalCostUsd: 0,
314
+ sourcesAttempted: 3,
315
+ sourcesSucceeded: 0,
316
+ durationMs: 500,
317
+ };
318
+ expect(result.data).toBeNull();
319
+ expect(result.synthesisResult).toBeUndefined();
320
+ expect(result.stoppedAtPhase).toBeUndefined();
321
+ });
322
+ });
323
+ // ============================================================================
324
+ // ResearchConfig
325
+ // ============================================================================
326
+ describe("ResearchConfig", () => {
327
+ it("can be constructed with no fields (all optional)", () => {
328
+ const config = {};
329
+ expect(config.concurrency).toBeUndefined();
330
+ expect(config.categories).toBeUndefined();
331
+ expect(config.costLimits).toBeUndefined();
332
+ });
333
+ it("can be constructed with sensible defaults", () => {
334
+ const config = {
335
+ concurrency: 5,
336
+ confidenceThreshold: 0.6,
337
+ reliabilityThreshold: 0.6,
338
+ earlyStopThreshold: 3,
339
+ costLimits: {
340
+ maxCostPerSubject: 0.5,
341
+ maxTotalCost: 50.0,
342
+ },
343
+ };
344
+ expect(config.concurrency).toBe(5);
345
+ expect(config.confidenceThreshold).toBe(0.6);
346
+ expect(config.reliabilityThreshold).toBe(0.6);
347
+ expect(config.earlyStopThreshold).toBe(3);
348
+ expect(config.costLimits?.maxCostPerSubject).toBe(0.5);
349
+ expect(config.costLimits?.maxTotalCost).toBe(50.0);
350
+ });
351
+ it("accepts category flags", () => {
352
+ const config = {
353
+ categories: {
354
+ free: true,
355
+ news: true,
356
+ books: true,
357
+ archives: false,
358
+ ai: false,
359
+ },
360
+ };
361
+ expect(config.categories?.free).toBe(true);
362
+ expect(config.categories?.ai).toBe(false);
363
+ });
364
+ it("accepts synthesis options", () => {
365
+ const config = {
366
+ synthesis: {
367
+ model: "claude-sonnet-4-20250514",
368
+ maxTokens: 4096,
369
+ },
370
+ };
371
+ expect(config.synthesis?.model).toBe("claude-sonnet-4-20250514");
372
+ });
373
+ it("accepts cache and telemetry providers", () => {
374
+ const mockCache = {
375
+ get: async () => null,
376
+ set: async () => { },
377
+ delete: async () => { },
378
+ };
379
+ const mockTelemetry = {
380
+ recordEvent: () => { },
381
+ startSpan: () => ({ end: () => { }, setAttributes: () => { } }),
382
+ recordError: () => { },
383
+ };
384
+ const config = {
385
+ cache: mockCache,
386
+ telemetry: mockTelemetry,
387
+ };
388
+ expect(config.cache).toBeDefined();
389
+ expect(config.telemetry).toBeDefined();
390
+ });
391
+ it("cost limits are individually optional", () => {
392
+ const config1 = {
393
+ costLimits: { maxCostPerSubject: 1.0 },
394
+ };
395
+ expect(config1.costLimits?.maxCostPerSubject).toBe(1.0);
396
+ expect(config1.costLimits?.maxTotalCost).toBeUndefined();
397
+ const config2 = {
398
+ costLimits: { maxTotalCost: 100.0 },
399
+ };
400
+ expect(config2.costLimits?.maxCostPerSubject).toBeUndefined();
401
+ expect(config2.costLimits?.maxTotalCost).toBe(100.0);
402
+ });
403
+ });
404
+ // ============================================================================
405
+ // CacheProvider
406
+ // ============================================================================
407
+ describe("CacheProvider", () => {
408
+ it("can be implemented with basic get/set/delete", async () => {
409
+ const store = new Map();
410
+ const cache = {
411
+ get: async (key) => store.get(key) ?? null,
412
+ set: async (key, value) => {
413
+ store.set(key, value);
414
+ },
415
+ delete: async (key) => {
416
+ store.delete(key);
417
+ },
418
+ };
419
+ expect(await cache.get("missing")).toBeNull();
420
+ await cache.set("key", "value");
421
+ expect(await cache.get("key")).toBe("value");
422
+ await cache.delete("key");
423
+ expect(await cache.get("key")).toBeNull();
424
+ });
425
+ });
426
+ // ============================================================================
427
+ // TelemetryProvider and TelemetrySpan
428
+ // ============================================================================
429
+ describe("TelemetryProvider", () => {
430
+ it("can be implemented as a noop", () => {
431
+ const noop = {
432
+ recordEvent: () => { },
433
+ startSpan: () => ({ end: () => { }, setAttributes: () => { } }),
434
+ recordError: () => { },
435
+ };
436
+ // Should not throw
437
+ noop.recordEvent("test", { key: "value" });
438
+ const span = noop.startSpan("operation");
439
+ span.setAttributes({ source: "wikipedia", cost: 0.01, success: true });
440
+ span.end();
441
+ noop.recordError(new Error("test error"), { phase: 1 });
442
+ });
443
+ it("startSpan returns a TelemetrySpan with end and setAttributes", () => {
444
+ const events = [];
445
+ const telemetry = {
446
+ recordEvent: () => { },
447
+ startSpan: (name) => ({
448
+ end: () => events.push({ name }),
449
+ setAttributes: (attrs) => events.push({ name, attrs }),
450
+ }),
451
+ recordError: () => { },
452
+ };
453
+ const span = telemetry.startSpan("lookup");
454
+ span.setAttributes({ source: "wikidata", cost: 0 });
455
+ span.end();
456
+ expect(events).toHaveLength(2);
457
+ expect(events[0].attrs?.source).toBe("wikidata");
458
+ expect(events[1].name).toBe("lookup");
459
+ });
460
+ });
461
+ // ============================================================================
462
+ // BatchProgressStats and BatchStats
463
+ // ============================================================================
464
+ describe("BatchProgressStats", () => {
465
+ it("tracks in-progress batch metrics", () => {
466
+ const stats = {
467
+ completed: 3,
468
+ total: 10,
469
+ costUsd: 0.15,
470
+ elapsedMs: 5000,
471
+ };
472
+ expect(stats.completed).toBe(3);
473
+ expect(stats.total).toBe(10);
474
+ expect(stats.costUsd).toBe(0.15);
475
+ expect(stats.elapsedMs).toBe(5000);
476
+ });
477
+ });
478
+ describe("BatchStats", () => {
479
+ it("extends BatchProgressStats with final metrics", () => {
480
+ const stats = {
481
+ completed: 10,
482
+ total: 10,
483
+ costUsd: 0.5,
484
+ elapsedMs: 30000,
485
+ succeeded: 8,
486
+ failed: 2,
487
+ avgCostPerSubject: 0.05,
488
+ avgDurationMs: 3000,
489
+ };
490
+ expect(stats.succeeded).toBe(8);
491
+ expect(stats.failed).toBe(2);
492
+ expect(stats.avgCostPerSubject).toBe(0.05);
493
+ expect(stats.avgDurationMs).toBe(3000);
494
+ // Inherited from BatchProgressStats
495
+ expect(stats.completed).toBe(10);
496
+ expect(stats.total).toBe(10);
497
+ });
498
+ });
499
+ // ============================================================================
500
+ // LifecycleHooks
501
+ // ============================================================================
502
+ describe("LifecycleHooks", () => {
503
+ it("can be partially implemented (all hooks are optional)", () => {
504
+ const hooks = {
505
+ onSubjectComplete: (subject, result) => {
506
+ // Only care about completion
507
+ void subject;
508
+ void result;
509
+ },
510
+ };
511
+ expect(hooks.onSubjectComplete).toBeDefined();
512
+ expect(hooks.onRunStart).toBeUndefined();
513
+ expect(hooks.onSourceAttempt).toBeUndefined();
514
+ expect(hooks.onPhaseComplete).toBeUndefined();
515
+ expect(hooks.onEarlyStop).toBeUndefined();
516
+ expect(hooks.onSynthesisStart).toBeUndefined();
517
+ expect(hooks.onSynthesisComplete).toBeUndefined();
518
+ expect(hooks.onBatchProgress).toBeUndefined();
519
+ expect(hooks.onCostLimitReached).toBeUndefined();
520
+ expect(hooks.onRunComplete).toBeUndefined();
521
+ expect(hooks.onRunFailed).toBeUndefined();
522
+ });
523
+ it("can be fully implemented", () => {
524
+ const events = [];
525
+ const hooks = {
526
+ onRunStart: (count) => events.push(`run-start:${count}`),
527
+ onSubjectStart: (s, i, t) => events.push(`subject-start:${s.name}:${i}/${t}`),
528
+ onSourceAttempt: (s, name) => events.push(`source-attempt:${name}`),
529
+ onSourceComplete: (s, name, finding) => events.push(`source-complete:${name}:${finding ? "found" : "empty"}`),
530
+ onPhaseComplete: (s, phase, findings) => events.push(`phase-complete:${phase}:${findings.length}`),
531
+ onEarlyStop: (s, phase, reason) => events.push(`early-stop:${phase}:${reason}`),
532
+ onSynthesisStart: (s, count) => events.push(`synthesis-start:${count}`),
533
+ onSynthesisComplete: (s, result) => events.push(`synthesis-complete:$${result.costUsd}`),
534
+ onSubjectComplete: (s, result) => events.push(`subject-complete:${result.totalCostUsd}`),
535
+ onBatchProgress: (stats) => events.push(`progress:${stats.completed}/${stats.total}`),
536
+ onCostLimitReached: (s, cost, limit) => events.push(`cost-limit:${cost}/${limit}`),
537
+ onRunComplete: (stats) => events.push(`run-complete:${stats.succeeded}`),
538
+ onRunFailed: (error) => events.push(`run-failed:${error.message}`),
539
+ };
540
+ // Simulate calling all hooks
541
+ hooks.onRunStart(10, {});
542
+ hooks.onSubjectStart({ id: 1, name: "Test" }, 0, 10);
543
+ hooks.onSourceAttempt({ id: 1, name: "Test" }, "wikipedia", 1);
544
+ hooks.onSourceComplete({ id: 1, name: "Test" }, "wikipedia", null, 0);
545
+ hooks.onPhaseComplete({ id: 1, name: "Test" }, 1, []);
546
+ hooks.onEarlyStop({ id: 1, name: "Test" }, 2, "3+ families");
547
+ hooks.onSynthesisStart({ id: 1, name: "Test" }, 5);
548
+ hooks.onSynthesisComplete({ id: 1, name: "Test" }, {
549
+ data: null,
550
+ costUsd: 0.02,
551
+ inputTokens: 1000,
552
+ outputTokens: 200,
553
+ model: "test",
554
+ });
555
+ hooks.onSubjectComplete({ id: 1, name: "Test" }, {
556
+ subject: { id: 1, name: "Test" },
557
+ data: null,
558
+ findings: [],
559
+ totalCostUsd: 0.02,
560
+ sourcesAttempted: 5,
561
+ sourcesSucceeded: 3,
562
+ durationMs: 1000,
563
+ });
564
+ hooks.onBatchProgress({ completed: 5, total: 10, costUsd: 0.1, elapsedMs: 5000 });
565
+ hooks.onCostLimitReached({ id: 1, name: "Test" }, 1.5, 1.0);
566
+ hooks.onRunComplete({
567
+ completed: 10,
568
+ total: 10,
569
+ costUsd: 0.5,
570
+ elapsedMs: 30000,
571
+ succeeded: 8,
572
+ failed: 2,
573
+ avgCostPerSubject: 0.05,
574
+ avgDurationMs: 3000,
575
+ });
576
+ hooks.onRunFailed(new Error("Fatal error"));
577
+ expect(events).toHaveLength(13);
578
+ expect(events[0]).toBe("run-start:10");
579
+ expect(events[events.length - 1]).toBe("run-failed:Fatal error");
580
+ });
581
+ it("empty object is valid (no hooks configured)", () => {
582
+ const hooks = {};
583
+ expect(Object.keys(hooks)).toHaveLength(0);
584
+ });
585
+ });
586
+ // ============================================================================
587
+ // Error Types
588
+ // ============================================================================
589
+ describe("CostLimitExceededError", () => {
590
+ it("has correct name and message", () => {
591
+ const error = new CostLimitExceededError("actor-123", 1.5, 1.0);
592
+ expect(error.name).toBe("CostLimitExceededError");
593
+ expect(error.message).toBe("Cost limit exceeded for subject actor-123: $1.5000 > $1.0000");
594
+ });
595
+ it("exposes subjectId, costUsd, and limit properties", () => {
596
+ const error = new CostLimitExceededError(42, 0.5123, 0.5);
597
+ expect(error.subjectId).toBe(42);
598
+ expect(error.costUsd).toBe(0.5123);
599
+ expect(error.limit).toBe(0.5);
600
+ });
601
+ it("is an instance of Error", () => {
602
+ const error = new CostLimitExceededError("1", 1, 0.5);
603
+ expect(error).toBeInstanceOf(Error);
604
+ expect(error).toBeInstanceOf(CostLimitExceededError);
605
+ });
606
+ it("accepts string subject id", () => {
607
+ const error = new CostLimitExceededError("uuid-abc-123", 2.0, 1.0);
608
+ expect(error.subjectId).toBe("uuid-abc-123");
609
+ });
610
+ it("accepts numeric subject id", () => {
611
+ const error = new CostLimitExceededError(12345, 2.0, 1.0);
612
+ expect(error.subjectId).toBe(12345);
613
+ });
614
+ it("formats small costs correctly", () => {
615
+ const error = new CostLimitExceededError("1", 0.0001, 0.00005);
616
+ expect(error.message).toContain("$0.0001");
617
+ expect(error.message).toContain("$0.0001"); // 0.00005 rounds to 0.0001
618
+ });
619
+ });
620
+ describe("SourceTimeoutError", () => {
621
+ it("has correct name and message", () => {
622
+ const error = new SourceTimeoutError("Wikipedia", 30000);
623
+ expect(error.name).toBe("SourceTimeoutError");
624
+ expect(error.message).toBe("Source Wikipedia timed out after 30000ms");
625
+ });
626
+ it("exposes sourceName and timeoutMs properties", () => {
627
+ const error = new SourceTimeoutError("Google Search", 15000);
628
+ expect(error.sourceName).toBe("Google Search");
629
+ expect(error.timeoutMs).toBe(15000);
630
+ });
631
+ it("is an instance of Error", () => {
632
+ const error = new SourceTimeoutError("test", 1000);
633
+ expect(error).toBeInstanceOf(Error);
634
+ expect(error).toBeInstanceOf(SourceTimeoutError);
635
+ });
636
+ });
637
+ describe("SourceAccessBlockedError", () => {
638
+ it("has correct name and message for 403", () => {
639
+ const error = new SourceAccessBlockedError("NYTimes", 403);
640
+ expect(error.name).toBe("SourceAccessBlockedError");
641
+ expect(error.message).toBe("Source NYTimes blocked with status 403");
642
+ });
643
+ it("has correct message for 429 rate limit", () => {
644
+ const error = new SourceAccessBlockedError("Guardian", 429);
645
+ expect(error.message).toBe("Source Guardian blocked with status 429");
646
+ });
647
+ it("exposes sourceName and statusCode properties", () => {
648
+ const error = new SourceAccessBlockedError("BBC", 451);
649
+ expect(error.sourceName).toBe("BBC");
650
+ expect(error.statusCode).toBe(451);
651
+ });
652
+ it("is an instance of Error", () => {
653
+ const error = new SourceAccessBlockedError("test", 403);
654
+ expect(error).toBeInstanceOf(Error);
655
+ expect(error).toBeInstanceOf(SourceAccessBlockedError);
656
+ });
657
+ });
658
+ // ============================================================================
659
+ // Type Compatibility — verifies structural typing works across interfaces
660
+ // ============================================================================
661
+ describe("type compatibility", () => {
662
+ it("ScoredFinding is assignable to RawFinding (structural subtype)", () => {
663
+ const scored = {
664
+ text: "Content",
665
+ confidence: 0.5,
666
+ costUsd: 0,
667
+ sourceType: "test",
668
+ sourceName: "Test",
669
+ reliabilityTier: ReliabilityTier.SECONDARY_COMPILATION,
670
+ reliabilityScore: 0.85,
671
+ };
672
+ // ScoredFinding extends RawFinding, so this should work
673
+ const raw = scored;
674
+ expect(raw.text).toBe("Content");
675
+ expect(raw.confidence).toBe(0.5);
676
+ });
677
+ it("BatchStats is assignable to BatchProgressStats (structural subtype)", () => {
678
+ const batch = {
679
+ completed: 10,
680
+ total: 10,
681
+ costUsd: 0.5,
682
+ elapsedMs: 30000,
683
+ succeeded: 8,
684
+ failed: 2,
685
+ avgCostPerSubject: 0.05,
686
+ avgDurationMs: 3000,
687
+ };
688
+ const progress = batch;
689
+ expect(progress.completed).toBe(10);
690
+ expect(progress.total).toBe(10);
691
+ });
692
+ it("domain-specific subject extends ResearchSubject", () => {
693
+ const actor = {
694
+ id: 2157,
695
+ name: "John Wayne",
696
+ context: {
697
+ birthday: "1907-05-26",
698
+ deathday: "1979-06-11",
699
+ tmdbId: 2157,
700
+ },
701
+ };
702
+ // Should be assignable to ResearchSubject
703
+ const subject = actor;
704
+ expect(subject.name).toBe("John Wayne");
705
+ expect(subject.context?.birthday).toBe("1907-05-26");
706
+ });
707
+ });
708
+ //# sourceMappingURL=types.test.js.map