@kuralle-syrinx/core 4.2.0 → 4.4.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 (51) hide show
  1. package/package.json +2 -2
  2. package/src/audio/alaw.ts +56 -0
  3. package/src/audio/g722.ts +329 -0
  4. package/src/audio/index.ts +12 -0
  5. package/src/audio/loudness.ts +87 -0
  6. package/src/idle-timeout.ts +1 -0
  7. package/src/index.ts +65 -3
  8. package/src/interaction-coordinator.ts +17 -2
  9. package/src/interaction-policy.ts +12 -0
  10. package/src/observability-observer.ts +8 -4
  11. package/src/observability.ts +30 -1
  12. package/src/packet-factories.ts +124 -3
  13. package/src/packets.ts +139 -1
  14. package/src/plugin-contract.ts +41 -0
  15. package/src/policies/rule-based.ts +17 -2
  16. package/src/pricing.ts +137 -0
  17. package/src/primary-speaker-gate.ts +23 -3
  18. package/src/reasoner.ts +28 -1
  19. package/src/spend-cap.ts +52 -0
  20. package/src/turn-arbiter.ts +34 -2
  21. package/src/voice-agent-session-util.ts +18 -0
  22. package/src/voice-agent-session.ts +469 -36
  23. package/src/voice-text.ts +69 -0
  24. package/src/audio/audio.test.ts +0 -285
  25. package/src/audio-envelope.test.ts +0 -167
  26. package/src/confidence-to-wait.test.ts +0 -21
  27. package/src/error-handler.test.ts +0 -56
  28. package/src/hedge-throwing-backend.test.ts +0 -46
  29. package/src/interaction-coordinator.test.ts +0 -425
  30. package/src/iu-ledger.test.ts +0 -276
  31. package/src/latency-filler.test.ts +0 -62
  32. package/src/observability-observer.test.ts +0 -245
  33. package/src/observability.test.ts +0 -85
  34. package/src/packet-factories.test.ts +0 -53
  35. package/src/pipeline-bus.g10.test.ts +0 -145
  36. package/src/pipeline-bus.test.ts +0 -211
  37. package/src/policies/defer.test.ts +0 -86
  38. package/src/policies/rule-based.test.ts +0 -269
  39. package/src/primary-speaker-gate.test.ts +0 -150
  40. package/src/provider-fallback.test.ts +0 -87
  41. package/src/reasoner-hedge.test.ts +0 -361
  42. package/src/reasoner-route.test.ts +0 -248
  43. package/src/reasoner.test.ts +0 -69
  44. package/src/retry.test.ts +0 -83
  45. package/src/route-throwing-spec.test.ts +0 -44
  46. package/src/tts-playout-clock.test.ts +0 -125
  47. package/src/turn-arbiter.characterization.test.ts +0 -477
  48. package/src/turn-arbiter.test.ts +0 -567
  49. package/src/voice-agent-session.test.ts +0 -3316
  50. package/src/voice-text.test.ts +0 -94
  51. package/tsconfig.json +0 -21
@@ -1,425 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, it, expect, vi } from "vitest";
4
- import { PipelineBusImpl, Route } from "./pipeline-bus.js";
5
- import { InteractionCoordinator } from "./interaction-coordinator.js";
6
- import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "./interaction-policy.js";
7
- import { TurnArbiter } from "./turn-arbiter.js";
8
- import { PrimarySpeakerGate } from "./primary-speaker-gate.js";
9
- import { TtsPlayoutClock } from "./tts-playout-clock.js";
10
- import { TimerScheduler } from "./scheduler.js";
11
- import type { EndOfSpeechPacket, InteractionBackchannelPacket, InterruptionDetectedPacket } from "./packets.js";
12
-
13
- class StubPolicy implements InteractionPolicy {
14
- constructor(private readonly decisions: InteractionDecision[]) {}
15
-
16
- observe(_obs: InteractionObservation): readonly InteractionDecision[] {
17
- return this.decisions;
18
- }
19
-
20
- reset(): void {}
21
- }
22
-
23
- class StatefulPolicy implements InteractionPolicy {
24
- private step = 0;
25
-
26
- constructor(private readonly steps: Array<readonly InteractionDecision[]>) {}
27
-
28
- observe(_obs: InteractionObservation): readonly InteractionDecision[] {
29
- const out = this.steps[this.step] ?? [];
30
- this.step += 1;
31
- return out;
32
- }
33
-
34
- reset(): void {
35
- this.step = 0;
36
- }
37
- }
38
-
39
- async function createCoordinator(
40
- decisions: InteractionDecision[],
41
- options: {
42
- emitsBackchannel?: boolean;
43
- isUserSpeaking?: () => boolean;
44
- isTtsActive?: () => boolean;
45
- hasCueAsset?: (cueId: string) => boolean;
46
- } = {},
47
- ) {
48
- const bus = new PipelineBusImpl();
49
- void bus.start();
50
- const ttsPlayout = new TtsPlayoutClock();
51
- const executor = new TurnArbiter({
52
- bus,
53
- primarySpeakerGate: new PrimarySpeakerGate(),
54
- ttsPlayout,
55
- minInterruptionMs: 280,
56
- });
57
- const coordinator = new InteractionCoordinator({
58
- bus,
59
- policy: new StubPolicy(decisions),
60
- executor,
61
- scheduler: new TimerScheduler(),
62
- caps: { emitsBackchannel: options.emitsBackchannel },
63
- isUserSpeaking: options.isUserSpeaking,
64
- isTtsActive: options.isTtsActive,
65
- hasCueAsset: options.hasCueAsset,
66
- });
67
- coordinator.initialize();
68
- return { bus, coordinator, executor };
69
- }
70
-
71
- async function drainBus(): Promise<void> {
72
- await new Promise((resolve) => setTimeout(resolve, 0));
73
- }
74
-
75
- function metricNames(bus: PipelineBusImpl): string[] {
76
- const names: string[] = [];
77
- bus.on("metric.conversation", (pkt) => {
78
- names.push((pkt as unknown as { name: string }).name);
79
- });
80
- return names;
81
- }
82
-
83
- describe("InteractionCoordinator", () => {
84
- it("maps interrupt decision to executor.emitInterruptDetected", async () => {
85
- const { bus, coordinator, executor } = await createCoordinator([
86
- { kind: "interrupt", interruptedContextId: "assistant-turn" },
87
- ]);
88
- const interrupts: InterruptionDetectedPacket[] = [];
89
- bus.on("interrupt.detected", (pkt) => {
90
- interrupts.push(pkt as InterruptionDetectedPacket);
91
- });
92
- const emitSpy = vi.spyOn(executor, "emitInterruptDetected");
93
-
94
- coordinator.observe({
95
- kind: "vad_speech_activity",
96
- contextId: "user",
97
- timestampMs: 1000,
98
- });
99
- await drainBus();
100
-
101
- expect(emitSpy).toHaveBeenCalledOnce();
102
- expect(emitSpy).toHaveBeenCalledWith("assistant-turn");
103
- expect(interrupts).toHaveLength(1);
104
- expect(interrupts[0]!.contextId).toBe("assistant-turn");
105
- });
106
-
107
- it("emits interaction.backchannel for a backchannel decision", async () => {
108
- const { bus, coordinator } = await createCoordinator([{ kind: "backchannel", cue: "mm_hmm" }]);
109
- const packets: InteractionBackchannelPacket[] = [];
110
- const metrics = metricNames(bus);
111
- bus.on("interaction.backchannel", (pkt) => {
112
- packets.push(pkt as InteractionBackchannelPacket);
113
- });
114
-
115
- coordinator.observe({
116
- kind: "delegate_state",
117
- contextId: "turn-1",
118
- timestampMs: 2000,
119
- toolCallPhase: "delayed",
120
- });
121
- await drainBus();
122
-
123
- expect(packets).toHaveLength(1);
124
- expect(packets[0]).toMatchObject({ contextId: "turn-1", cue: "mm_hmm" });
125
- expect(metrics).toContain("backchannel.candidate");
126
- expect(metrics).toContain("backchannel.emitted");
127
- });
128
-
129
- it("suppresses backchannel when caps.emitsBackchannel is true", async () => {
130
- const { bus, coordinator } = await createCoordinator(
131
- [{ kind: "backchannel", cue: "mm_hmm" }],
132
- { emitsBackchannel: true },
133
- );
134
- const packets: InteractionBackchannelPacket[] = [];
135
- const metrics = metricNames(bus);
136
- bus.on("interaction.backchannel", (pkt) => {
137
- packets.push(pkt as InteractionBackchannelPacket);
138
- });
139
-
140
- coordinator.observe({
141
- kind: "delegate_state",
142
- contextId: "turn-1",
143
- timestampMs: 2000,
144
- toolCallPhase: "delayed",
145
- });
146
- await drainBus();
147
-
148
- expect(packets).toEqual([]);
149
- expect(metrics).toContain("backchannel.suppressed_caps");
150
- });
151
-
152
- it("suppresses backchannel when TTS is active", async () => {
153
- const { bus, coordinator } = await createCoordinator(
154
- [{ kind: "backchannel", cue: "mm_hmm" }],
155
- { isTtsActive: () => true },
156
- );
157
- const metrics = metricNames(bus);
158
-
159
- coordinator.observe({
160
- kind: "delegate_state",
161
- contextId: "turn-1",
162
- timestampMs: 2000,
163
- toolCallPhase: "delayed",
164
- });
165
- await drainBus();
166
-
167
- expect(metrics).toContain("backchannel.suppressed_tts_active");
168
- });
169
-
170
- it("suppresses backchannel when the user is speaking", async () => {
171
- const { bus, coordinator } = await createCoordinator(
172
- [{ kind: "backchannel", cue: "mm_hmm" }],
173
- { isUserSpeaking: () => true },
174
- );
175
- const metrics = metricNames(bus);
176
-
177
- coordinator.observe({
178
- kind: "delegate_state",
179
- contextId: "turn-1",
180
- timestampMs: 2000,
181
- toolCallPhase: "delayed",
182
- });
183
- await drainBus();
184
-
185
- expect(metrics).toContain("backchannel.suppressed_user_speaking");
186
- });
187
-
188
- it("suppresses backchannel when the cue asset is missing", async () => {
189
- const { bus, coordinator } = await createCoordinator(
190
- [{ kind: "backchannel", cue: "mm_hmm" }],
191
- { hasCueAsset: () => false },
192
- );
193
- const metrics = metricNames(bus);
194
-
195
- coordinator.observe({
196
- kind: "delegate_state",
197
- contextId: "turn-1",
198
- timestampMs: 2000,
199
- toolCallPhase: "delayed",
200
- });
201
- await drainBus();
202
-
203
- expect(metrics).toContain("backchannel.suppressed_missing_asset");
204
- });
205
-
206
- it("maps take_turn to stt.finalize and a delayed eos.turn_complete", async () => {
207
- vi.useFakeTimers();
208
- const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
209
- const finalizeRequests: string[] = [];
210
- const completions: Array<{ contextId: string; text: string }> = [];
211
- bus.on("stt.finalize", (pkt) => {
212
- finalizeRequests.push(pkt.contextId);
213
- });
214
- bus.on("eos.turn_complete", (pkt) => {
215
- const eos = pkt as EndOfSpeechPacket;
216
- completions.push({ contextId: eos.contextId, text: eos.text });
217
- });
218
-
219
- coordinator.observe({
220
- kind: "vad_speech_ended",
221
- contextId: "turn-1",
222
- timestampMs: 1000,
223
- hasActiveTts: false,
224
- });
225
- bus.push(Route.Main, {
226
- kind: "stt.result",
227
- contextId: "turn-1",
228
- timestampMs: 1100,
229
- text: "hello there",
230
- confidence: 0.95,
231
- language: "en-US",
232
- });
233
- await vi.advanceTimersByTimeAsync(0);
234
- expect(finalizeRequests).toEqual(["turn-1"]);
235
- expect(completions).toEqual([]);
236
-
237
- await vi.advanceTimersByTimeAsync(149);
238
- expect(completions).toEqual([]);
239
-
240
- await vi.advanceTimersByTimeAsync(1);
241
- expect(completions).toEqual([{ contextId: "turn-1", text: "hello there" }]);
242
- vi.useRealTimers();
243
- });
244
-
245
- it("uses a transcript that arrived before take_turn and requests STT finalization only once", async () => {
246
- vi.useFakeTimers();
247
- const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
248
- const finalizeRequests: string[] = [];
249
- const completions: EndOfSpeechPacket[] = [];
250
- bus.on("stt.finalize", (pkt) => {
251
- finalizeRequests.push(pkt.contextId);
252
- });
253
- bus.on("eos.turn_complete", (pkt) => {
254
- completions.push(pkt as EndOfSpeechPacket);
255
- });
256
-
257
- bus.push(Route.Main, {
258
- kind: "stt.result",
259
- contextId: "turn-cached",
260
- timestampMs: 900,
261
- text: "already final",
262
- confidence: 0.95,
263
- language: "en-US",
264
- });
265
- await vi.advanceTimersByTimeAsync(0);
266
- const observation = {
267
- kind: "audio_frame" as const,
268
- contextId: "turn-cached",
269
- timestampMs: 1000,
270
- audio: new Int16Array(320),
271
- };
272
- coordinator.observe(observation);
273
- coordinator.observe(observation);
274
-
275
- await vi.advanceTimersByTimeAsync(0);
276
- expect(finalizeRequests).toEqual(["turn-cached"]);
277
- await vi.advanceTimersByTimeAsync(150);
278
- expect(completions).toEqual([
279
- expect.objectContaining({ contextId: "turn-cached", text: "already final" }),
280
- ]);
281
- vi.useRealTimers();
282
- });
283
-
284
- it("completes a committed take_turn from the latest interim when no final STT ever arrives", async () => {
285
- vi.useFakeTimers();
286
- const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
287
- const finalizeRequests: string[] = [];
288
- const completions: EndOfSpeechPacket[] = [];
289
- bus.on("stt.finalize", (pkt) => {
290
- finalizeRequests.push(pkt.contextId);
291
- });
292
- bus.on("eos.turn_complete", (pkt) => {
293
- completions.push(pkt as EndOfSpeechPacket);
294
- });
295
-
296
- coordinator.observe({
297
- kind: "vad_speech_ended",
298
- contextId: "turn-interim",
299
- timestampMs: 1000,
300
- hasActiveTts: false,
301
- });
302
- // Only an interim arrives — the provider never emits a final stt.result.
303
- bus.push(Route.Main, {
304
- kind: "stt.interim",
305
- contextId: "turn-interim",
306
- timestampMs: 1100,
307
- text: "i wanted to ask",
308
- });
309
- await vi.advanceTimersByTimeAsync(0);
310
- expect(finalizeRequests).toEqual(["turn-interim"]);
311
- expect(completions).toEqual([]);
312
-
313
- // High confidence → 150ms wait; the committed turn must NOT be dropped.
314
- await vi.advanceTimersByTimeAsync(150);
315
- expect(completions).toHaveLength(1);
316
- expect(completions[0]).toMatchObject({ contextId: "turn-interim", text: "i wanted to ask" });
317
- expect(completions[0]!.transcripts).toHaveLength(1);
318
- vi.useRealTimers();
319
- });
320
-
321
- it("does not complete a take_turn when neither a final nor an interim ever arrives", async () => {
322
- vi.useFakeTimers();
323
- const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 1 }]);
324
- const completions: string[] = [];
325
- bus.on("eos.turn_complete", (pkt) => {
326
- completions.push(pkt.contextId);
327
- });
328
-
329
- coordinator.observe({
330
- kind: "vad_speech_ended",
331
- contextId: "turn-empty",
332
- timestampMs: 1000,
333
- hasActiveTts: false,
334
- });
335
- await vi.advanceTimersByTimeAsync(5000);
336
- expect(completions).toEqual([]);
337
- vi.useRealTimers();
338
- });
339
-
340
- it("revokes a pending take_turn on hold", async () => {
341
- vi.useFakeTimers();
342
- const bus = new PipelineBusImpl();
343
- void bus.start();
344
- const coordinator = new InteractionCoordinator({
345
- bus,
346
- policy: new StatefulPolicy([
347
- [{ kind: "take_turn", confidence: 0.2 }],
348
- [{ kind: "hold" }],
349
- ]),
350
- executor: new TurnArbiter({
351
- bus,
352
- primarySpeakerGate: new PrimarySpeakerGate(),
353
- ttsPlayout: new TtsPlayoutClock(),
354
- minInterruptionMs: 280,
355
- }),
356
- scheduler: new TimerScheduler(),
357
- caps: {},
358
- });
359
- coordinator.initialize();
360
-
361
- const completions: string[] = [];
362
- bus.on("eos.turn_complete", (pkt) => {
363
- completions.push(pkt.contextId);
364
- });
365
-
366
- coordinator.observe({
367
- kind: "vad_speech_ended",
368
- contextId: "turn-hold",
369
- timestampMs: 1000,
370
- hasActiveTts: false,
371
- });
372
- bus.push(Route.Main, {
373
- kind: "stt.result",
374
- contextId: "turn-hold",
375
- timestampMs: 1100,
376
- text: "wait",
377
- confidence: 0.9,
378
- language: "en-US",
379
- });
380
- await vi.advanceTimersByTimeAsync(0);
381
- coordinator.observe({
382
- kind: "vad_speech_activity",
383
- contextId: "turn-hold",
384
- timestampMs: 1200,
385
- });
386
-
387
- await vi.advanceTimersByTimeAsync(5000);
388
- expect(completions).toEqual([]);
389
- vi.useRealTimers();
390
- });
391
-
392
- it("revokes a pending take_turn when speech resumes", async () => {
393
- vi.useFakeTimers();
394
- const { bus, coordinator } = await createCoordinator([{ kind: "take_turn", confidence: 0.5 }]);
395
- const completions: string[] = [];
396
- bus.on("eos.turn_complete", (pkt) => {
397
- completions.push(pkt.contextId);
398
- });
399
-
400
- coordinator.observe({
401
- kind: "vad_speech_ended",
402
- contextId: "turn-resume",
403
- timestampMs: 1000,
404
- hasActiveTts: false,
405
- });
406
- bus.push(Route.Main, {
407
- kind: "stt.result",
408
- contextId: "turn-resume",
409
- timestampMs: 1100,
410
- text: "partial",
411
- confidence: 0.9,
412
- language: "en-US",
413
- });
414
- await vi.advanceTimersByTimeAsync(0);
415
- bus.push(Route.Main, {
416
- kind: "vad.speech_started",
417
- contextId: "turn-resume",
418
- timestampMs: 1200,
419
- confidence: 0.9,
420
- });
421
- await vi.advanceTimersByTimeAsync(5000);
422
- expect(completions).toEqual([]);
423
- vi.useRealTimers();
424
- });
425
- });
@@ -1,276 +0,0 @@
1
- // SPDX-License-Identifier: MIT
2
-
3
- import { describe, expect, it, vi } from "vitest";
4
- import type { IncrementalUnit, IncrementalUnitId } from "./incremental-unit.js";
5
- import { InMemoryIuLedger, type IuLedgerAnomaly } from "./iu-ledger.js";
6
-
7
- function makeId(contextId: string, iuId: string, epoch = 1): IncrementalUnitId {
8
- return { contextId, iuId, epoch };
9
- }
10
-
11
- function makeIu(
12
- contextId: string,
13
- iuId: string,
14
- kind: IncrementalUnit["kind"],
15
- epoch = 1,
16
- ): IncrementalUnit {
17
- return {
18
- id: makeId(contextId, iuId, epoch),
19
- kind,
20
- state: "hypothesized",
21
- };
22
- }
23
-
24
- function seedContexts(ledger: InMemoryIuLedger, count: number): IncrementalUnitId {
25
- const target = makeId("ctx-target", "iu-target");
26
- for (let i = 0; i < count; i++) {
27
- ledger.add(makeIu(`ctx-${i}`, `iu-${i}`, "user_turn", i));
28
- }
29
- ledger.add(makeIu(target.contextId, target.iuId, "user_turn", target.epoch));
30
- return target;
31
- }
32
-
33
- describe("InMemoryIuLedger", () => {
34
- describe("add", () => {
35
- it("registers a hypothesized IU (happy path)", () => {
36
- const ledger = new InMemoryIuLedger();
37
- const iu = makeIu("ctx-a", "iu-1", "user_turn");
38
- ledger.add(iu);
39
- expect(ledger.get(iu.id)?.state).toBe("hypothesized");
40
- });
41
-
42
- it("overwrites an existing iuId with the latest add (failure path: stale hypothesis replaced)", () => {
43
- const ledger = new InMemoryIuLedger();
44
- const first = makeIu("ctx-a", "iu-1", "user_turn", 1);
45
- const second = makeIu("ctx-a", "iu-1", "assistant_response", 2);
46
- ledger.add(first);
47
- ledger.add(second);
48
- expect(ledger.get(first.id)?.kind).toBe("assistant_response");
49
- expect(ledger.get(first.id)?.id.epoch).toBe(2);
50
- });
51
- });
52
-
53
- describe("commit", () => {
54
- it("transitions hypothesized → committed (happy path)", () => {
55
- const ledger = new InMemoryIuLedger();
56
- const iu = makeIu("ctx-a", "iu-1", "assistant_response");
57
- ledger.add(iu);
58
- ledger.commit(iu.id);
59
- expect(ledger.get(iu.id)?.state).toBe("committed");
60
- });
61
-
62
- it("records committedPrefix when provided (happy path)", () => {
63
- const ledger = new InMemoryIuLedger();
64
- const iu = makeIu("ctx-a", "iu-1", "tts_segment");
65
- ledger.add(iu);
66
- ledger.commit(iu.id, { ms: 800, chars: 40 });
67
- expect(ledger.get(iu.id)?.committedPrefix).toEqual({ ms: 800, chars: 40 });
68
- });
69
-
70
- it("is a no-op on an already-committed IU and fires terminal_op (failure path)", () => {
71
- const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
72
- const ledger = new InMemoryIuLedger(onEvent);
73
- const iu = makeIu("ctx-a", "iu-1", "user_turn");
74
- ledger.add(iu);
75
- ledger.commit(iu.id);
76
- ledger.commit(iu.id);
77
- expect(ledger.get(iu.id)?.state).toBe("committed");
78
- expect(onEvent).toHaveBeenCalledOnce();
79
- expect(onEvent).toHaveBeenCalledWith({
80
- kind: "terminal_op",
81
- op: "commit",
82
- id: iu.id,
83
- state: "committed",
84
- });
85
- });
86
-
87
- it("does not un-commit: revoke after commit is a no-op with terminal_op (failure path)", () => {
88
- const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
89
- const ledger = new InMemoryIuLedger(onEvent);
90
- const iu = makeIu("ctx-a", "iu-1", "user_turn");
91
- ledger.add(iu);
92
- ledger.commit(iu.id);
93
- ledger.revoke(iu.id);
94
- expect(ledger.get(iu.id)?.state).toBe("committed");
95
- expect(onEvent).toHaveBeenCalledOnce();
96
- expect(onEvent).toHaveBeenCalledWith({
97
- kind: "terminal_op",
98
- op: "revoke",
99
- id: iu.id,
100
- state: "committed",
101
- });
102
- });
103
-
104
- it("fail-open on unknown iuId: no throw, unknown_iu anomaly (failure path)", () => {
105
- const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
106
- const ledger = new InMemoryIuLedger(onEvent);
107
- const id = makeId("missing", "iu-1");
108
- expect(() => ledger.commit(id)).not.toThrow();
109
- expect(onEvent).toHaveBeenCalledWith({ kind: "unknown_iu", op: "commit", id });
110
- });
111
- });
112
-
113
- describe("revoke", () => {
114
- it("transitions hypothesized → revoked (happy path)", () => {
115
- const ledger = new InMemoryIuLedger();
116
- const iu = makeIu("ctx-a", "iu-1", "user_turn");
117
- ledger.add(iu);
118
- ledger.revoke(iu.id);
119
- expect(ledger.get(iu.id)?.state).toBe("revoked");
120
- });
121
-
122
- it("is a no-op on an already-revoked IU and fires terminal_op (failure path)", () => {
123
- const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
124
- const ledger = new InMemoryIuLedger(onEvent);
125
- const iu = makeIu("ctx-a", "iu-1", "user_turn");
126
- ledger.add(iu);
127
- ledger.revoke(iu.id);
128
- ledger.revoke(iu.id);
129
- expect(ledger.get(iu.id)?.state).toBe("revoked");
130
- expect(onEvent).toHaveBeenCalledOnce();
131
- expect(onEvent).toHaveBeenCalledWith({
132
- kind: "terminal_op",
133
- op: "revoke",
134
- id: iu.id,
135
- state: "revoked",
136
- });
137
- });
138
-
139
- it("fail-open on unknown iuId: no throw, unknown_iu anomaly (failure path)", () => {
140
- const onEvent = vi.fn<(a: IuLedgerAnomaly) => void>();
141
- const ledger = new InMemoryIuLedger(onEvent);
142
- const id = makeId("missing", "iu-1");
143
- expect(() => ledger.revoke(id)).not.toThrow();
144
- expect(onEvent).toHaveBeenCalledWith({ kind: "unknown_iu", op: "revoke", id });
145
- });
146
- });
147
-
148
- describe("get", () => {
149
- it("returns the registered IU (happy path)", () => {
150
- const ledger = new InMemoryIuLedger();
151
- const iu = makeIu("ctx-a", "iu-1", "user_turn");
152
- ledger.add(iu);
153
- expect(ledger.get(iu.id)).toBe(iu);
154
- });
155
-
156
- it("returns undefined for an unknown id without throwing (failure path)", () => {
157
- const ledger = new InMemoryIuLedger();
158
- expect(ledger.get(makeId("missing", "iu-1"))).toBeUndefined();
159
- });
160
- });
161
-
162
- describe("latest", () => {
163
- it("returns the most recently added IU of the requested kind (happy path)", () => {
164
- const ledger = new InMemoryIuLedger();
165
- ledger.add(makeIu("ctx-a", "iu-1", "user_turn"));
166
- ledger.add(makeIu("ctx-a", "iu-2", "assistant_response"));
167
- ledger.add(makeIu("ctx-a", "iu-3", "user_turn"));
168
- const latest = ledger.latest("ctx-a", "user_turn");
169
- expect(latest?.id.iuId).toBe("iu-3");
170
- });
171
-
172
- it("returns undefined when no IU of that kind exists (failure path)", () => {
173
- const ledger = new InMemoryIuLedger();
174
- ledger.add(makeIu("ctx-a", "iu-1", "user_turn"));
175
- expect(ledger.latest("ctx-a", "tts_segment")).toBeUndefined();
176
- expect(ledger.latest("missing", "user_turn")).toBeUndefined();
177
- });
178
- });
179
-
180
- describe("clear", () => {
181
- it("removes only the requested context (happy path)", () => {
182
- const ledger = new InMemoryIuLedger();
183
- const a = makeIu("ctx-a", "iu-1", "user_turn");
184
- const b = makeIu("ctx-b", "iu-1", "user_turn");
185
- ledger.add(a);
186
- ledger.add(b);
187
- ledger.clear("ctx-a");
188
- expect(ledger.get(a.id)).toBeUndefined();
189
- expect(ledger.get(b.id)?.state).toBe("hypothesized");
190
- });
191
-
192
- it("is a no-op for an unknown context without affecting others (failure path)", () => {
193
- const ledger = new InMemoryIuLedger();
194
- const b = makeIu("ctx-b", "iu-1", "user_turn");
195
- ledger.add(b);
196
- ledger.clear("ctx-missing");
197
- expect(ledger.get(b.id)?.state).toBe("hypothesized");
198
- });
199
- });
200
-
201
- describe("monotonic state machine", () => {
202
- it("follows hypothesized → committed and hypothesized → revoked only", () => {
203
- const ledger = new InMemoryIuLedger();
204
- const committed = makeIu("ctx-a", "iu-commit", "user_turn");
205
- const revoked = makeIu("ctx-a", "iu-revoke", "user_turn");
206
- ledger.add(committed);
207
- ledger.add(revoked);
208
- ledger.commit(committed.id);
209
- ledger.revoke(revoked.id);
210
- expect(ledger.get(committed.id)?.state).toBe("committed");
211
- expect(ledger.get(revoked.id)?.state).toBe("revoked");
212
- });
213
- });
214
-
215
- describe("maxContexts bound", () => {
216
- it("evicts the oldest context when a new context exceeds the cap (FIFO)", () => {
217
- const ledger = new InMemoryIuLedger(() => {}, 3);
218
- const a = makeIu("a", "iu-1", "user_turn");
219
- const b = makeIu("b", "iu-1", "user_turn");
220
- const c = makeIu("c", "iu-1", "user_turn");
221
- const d = makeIu("d", "iu-1", "user_turn");
222
- ledger.add(a);
223
- ledger.add(b);
224
- ledger.add(c);
225
- ledger.add(d);
226
- expect(ledger.get(a.id)).toBeUndefined();
227
- expect(ledger.get(b.id)?.state).toBe("hypothesized");
228
- expect(ledger.get(c.id)?.state).toBe("hypothesized");
229
- expect(ledger.get(d.id)?.state).toBe("hypothesized");
230
- });
231
-
232
- it("does not evict when adding another IU to an existing context", () => {
233
- const ledger = new InMemoryIuLedger(() => {}, 3);
234
- const a1 = makeIu("a", "iu-1", "user_turn");
235
- const a2 = makeIu("a", "iu-2", "assistant_response");
236
- const b = makeIu("b", "iu-1", "user_turn");
237
- const c = makeIu("c", "iu-1", "user_turn");
238
- ledger.add(a1);
239
- ledger.add(b);
240
- ledger.add(c);
241
- ledger.add(a2);
242
- expect(ledger.get(a1.id)?.state).toBe("hypothesized");
243
- expect(ledger.get(a2.id)?.state).toBe("hypothesized");
244
- expect(ledger.get(b.id)?.state).toBe("hypothesized");
245
- expect(ledger.get(c.id)?.state).toBe("hypothesized");
246
- });
247
- });
248
-
249
- describe("O(1) per-op (structural)", () => {
250
- it("commit/get touch only the target ctx bucket regardless of total ctx count", () => {
251
- // Map.get/set/delete are O(1); each hot-path op does at most one outer + one inner lookup.
252
- const ledgerSmall = new InMemoryIuLedger();
253
- const ledgerLarge = new InMemoryIuLedger();
254
- const targetSmall = seedContexts(ledgerSmall, 10);
255
- const targetLarge = seedContexts(ledgerLarge, 1000);
256
-
257
- ledgerSmall.commit(targetSmall);
258
- ledgerLarge.commit(targetLarge);
259
-
260
- expect(ledgerSmall.get(targetSmall)?.state).toBe("committed");
261
- expect(ledgerLarge.get(targetLarge)?.state).toBe("committed");
262
-
263
- const timeOp = (ledger: InMemoryIuLedger, id: IncrementalUnitId) => {
264
- const start = performance.now();
265
- for (let i = 0; i < 5000; i++) {
266
- ledger.get(id);
267
- }
268
- return performance.now() - start;
269
- };
270
-
271
- const tSmall = timeOp(ledgerSmall, targetSmall);
272
- const tLarge = timeOp(ledgerLarge, targetLarge);
273
- expect(tLarge).toBeLessThan(Math.max(tSmall * 20, 1));
274
- });
275
- });
276
- });