@kuralle-syrinx/core 4.0.0 → 4.2.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.
@@ -0,0 +1,240 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import type { VadAudioPacket } from "./packets.js";
4
+ import type { SttInterimPacket, SttResultPacket } from "./packets.js";
5
+ import { Route } from "./pipeline-bus.js";
6
+ import type { PipelineBus } from "./pipeline-bus.js";
7
+ import { confidenceToWaitMs } from "./confidence-to-wait.js";
8
+ import type { InteractionDecision, InteractionObservation, InteractionPolicy } from "./interaction-policy.js";
9
+ import type { TurnArbiter } from "./turn-arbiter.js";
10
+ import { RuleBasedInteractionPolicy } from "./policies/rule-based.js";
11
+ import { TimerScheduler, type Scheduler } from "./scheduler.js";
12
+ import * as make from "./packet-factories.js";
13
+
14
+ export interface InteractionCaps {
15
+ readonly emitsBackchannel?: boolean;
16
+ }
17
+
18
+ interface PendingTakeTurn {
19
+ confidence: number;
20
+ waitMs?: number;
21
+ transcripts: SttResultPacket[];
22
+ latestInterim: string;
23
+ finalizeRequested: boolean;
24
+ }
25
+
26
+ export class InteractionCoordinator {
27
+ private readonly disposers: Array<() => void> = [];
28
+ private readonly pendingTakeTurns = new Map<string, PendingTakeTurn>();
29
+ private readonly transcriptsByContext = new Map<string, SttResultPacket[]>();
30
+ private readonly interimByContext = new Map<string, string>();
31
+ private readonly scheduler: Scheduler;
32
+
33
+ constructor(
34
+ private readonly deps: {
35
+ bus: PipelineBus;
36
+ policy: InteractionPolicy;
37
+ executor: TurnArbiter;
38
+ caps: InteractionCaps;
39
+ scheduler?: Scheduler;
40
+ isUserSpeaking?: () => boolean;
41
+ isTtsActive?: () => boolean;
42
+ hasCueAsset?: (cueId: string) => boolean;
43
+ onBackchannelEmitted?: (contextId: string) => void;
44
+ },
45
+ ) {
46
+ this.scheduler = deps.scheduler ?? new TimerScheduler();
47
+ }
48
+
49
+ initialize(): void {
50
+ const { bus } = this.deps;
51
+ this.disposers.push(
52
+ bus.on("stt.result", (pkt) => {
53
+ this.handleSttResult(pkt as SttResultPacket);
54
+ }),
55
+ bus.on("stt.interim", (pkt) => {
56
+ this.handleSttInterim(pkt as SttInterimPacket);
57
+ }),
58
+ bus.on("vad.speech_started", (pkt) => {
59
+ this.revokePendingTakeTurn((pkt as { contextId: string }).contextId);
60
+ }),
61
+ );
62
+ }
63
+
64
+ dispose(): void {
65
+ for (const contextId of this.pendingTakeTurns.keys()) {
66
+ this.revokePendingTakeTurn(contextId);
67
+ }
68
+ for (const dispose of this.disposers.splice(0)) {
69
+ dispose();
70
+ }
71
+ this.transcriptsByContext.clear();
72
+ this.interimByContext.clear();
73
+ }
74
+
75
+ observe(obs: InteractionObservation): void {
76
+ for (const d of this.deps.policy.observe(obs)) {
77
+ this.apply(d, obs);
78
+ }
79
+ }
80
+
81
+ observeBargeInAudio(pkt: VadAudioPacket): boolean {
82
+ this.observe({
83
+ kind: "vad_barge_in_audio",
84
+ contextId: pkt.contextId,
85
+ timestampMs: pkt.timestampMs,
86
+ audio: pkt.audio,
87
+ });
88
+ if (this.deps.policy instanceof RuleBasedInteractionPolicy) {
89
+ return this.deps.policy.takeBargeInAudioConsumed();
90
+ }
91
+ return this.deps.executor.observeBargeInAudio(pkt);
92
+ }
93
+
94
+ private apply(d: InteractionDecision, obs: InteractionObservation): void {
95
+ switch (d.kind) {
96
+ case "take_turn":
97
+ this.armTakeTurn(obs.contextId, d.confidence, d.waitMs, obs.timestampMs);
98
+ break;
99
+ case "hold":
100
+ case "keep_listening":
101
+ this.revokePendingTakeTurn(obs.contextId);
102
+ break;
103
+ case "interrupt":
104
+ this.deps.executor.emitInterruptDetected(d.interruptedContextId);
105
+ break;
106
+ case "backchannel":
107
+ this.applyBackchannel(d, obs);
108
+ break;
109
+ default:
110
+ break;
111
+ }
112
+ }
113
+
114
+ private armTakeTurn(contextId: string, confidence: number, waitMs: number | undefined, timestampMs: number): void {
115
+ const existing = this.pendingTakeTurns.get(contextId);
116
+ const pending: PendingTakeTurn = existing ?? {
117
+ confidence,
118
+ transcripts: [...(this.transcriptsByContext.get(contextId) ?? [])],
119
+ latestInterim: this.interimByContext.get(contextId) ?? "",
120
+ finalizeRequested: false,
121
+ };
122
+ pending.confidence = confidence;
123
+ pending.waitMs = waitMs;
124
+ this.pendingTakeTurns.set(contextId, pending);
125
+
126
+ if (!pending.finalizeRequested) {
127
+ pending.finalizeRequested = true;
128
+ this.deps.bus.push(Route.Critical, make.finalizeStt(contextId, timestampMs));
129
+ }
130
+ this.tryScheduleTurnComplete(contextId);
131
+ }
132
+
133
+ private handleSttInterim(pkt: SttInterimPacket): void {
134
+ const text = pkt.text.trim();
135
+ if (!text) return;
136
+ this.interimByContext.set(pkt.contextId, text);
137
+ const pending = this.pendingTakeTurns.get(pkt.contextId);
138
+ if (pending) pending.latestInterim = text;
139
+ }
140
+
141
+ private handleSttResult(pkt: SttResultPacket): void {
142
+ const text = pkt.text.trim();
143
+ if (!text) return;
144
+ const transcripts = this.transcriptsByContext.get(pkt.contextId) ?? [];
145
+ if (transcripts.at(-1)?.text.trim() !== text) {
146
+ transcripts.push(pkt);
147
+ this.transcriptsByContext.set(pkt.contextId, transcripts);
148
+ }
149
+ this.interimByContext.delete(pkt.contextId);
150
+
151
+ const pending = this.pendingTakeTurns.get(pkt.contextId);
152
+ if (!pending) return;
153
+ pending.transcripts = [...transcripts];
154
+ pending.latestInterim = "";
155
+ this.tryScheduleTurnComplete(pkt.contextId);
156
+ }
157
+
158
+ private tryScheduleTurnComplete(contextId: string): void {
159
+ const pending = this.pendingTakeTurns.get(contextId);
160
+ if (!pending) return;
161
+
162
+ // Anchor the finalize timer at the policy's commitment even before any text
163
+ // exists — the callback reads live transcript/interim state at fire time and
164
+ // is the sole gate on whether the turn actually completes.
165
+ const delayMs = pending.waitMs ?? confidenceToWaitMs(pending.confidence);
166
+ const timerKey = takeTurnTimerKey(contextId);
167
+ this.scheduler.cancel(timerKey);
168
+ this.scheduler.schedule(timerKey, delayMs, () => {
169
+ const live = this.pendingTakeTurns.get(contextId);
170
+ if (!live) return;
171
+
172
+ const finalText = joinTranscript(live.transcripts, live.latestInterim);
173
+ // The policy committed to this turn. Prefer real STT finals; if none ever
174
+ // arrived, fall back to the latest interim so a committed turn is never
175
+ // silently dropped. Only bail when there is genuinely nothing to commit.
176
+ if (!finalText) return;
177
+
178
+ this.pendingTakeTurns.delete(contextId);
179
+ const transcripts: SttResultPacket[] =
180
+ live.transcripts.length > 0
181
+ ? live.transcripts
182
+ : [{ kind: "stt.result", contextId, timestampMs: Date.now(), text: finalText, confidence: 0 }];
183
+ this.deps.bus.push(Route.Main, make.eosTurnComplete(contextId, Date.now(), finalText, transcripts));
184
+ });
185
+ }
186
+
187
+ private revokePendingTakeTurn(contextId: string): void {
188
+ if (!this.pendingTakeTurns.has(contextId)) return;
189
+ this.scheduler.cancel(takeTurnTimerKey(contextId));
190
+ this.pendingTakeTurns.delete(contextId);
191
+ }
192
+
193
+ private applyBackchannel(
194
+ d: Extract<InteractionDecision, { kind: "backchannel" }>,
195
+ obs: InteractionObservation,
196
+ ): void {
197
+ const contextId = obs.contextId;
198
+ this.deps.bus.push(Route.Background, make.metric(contextId, "backchannel.candidate", d.cue));
199
+
200
+ if (this.deps.caps.emitsBackchannel) {
201
+ this.deps.bus.push(Route.Background, make.metric(contextId, "backchannel.suppressed_caps", d.cue));
202
+ return;
203
+ }
204
+ if (this.deps.isTtsActive?.()) {
205
+ this.deps.bus.push(Route.Background, make.metric(contextId, "backchannel.suppressed_tts_active", d.cue));
206
+ return;
207
+ }
208
+ if (this.deps.isUserSpeaking?.()) {
209
+ this.deps.bus.push(Route.Background, make.metric(contextId, "backchannel.suppressed_user_speaking", d.cue));
210
+ return;
211
+ }
212
+ if (this.deps.hasCueAsset && !this.deps.hasCueAsset(d.cue)) {
213
+ this.deps.bus.push(Route.Background, make.metric(contextId, "backchannel.suppressed_missing_asset", d.cue));
214
+ return;
215
+ }
216
+
217
+ this.deps.bus.push(Route.Main, make.interactionBackchannel(contextId, Date.now(), d.cue));
218
+ this.deps.bus.push(Route.Background, make.metric(contextId, "backchannel.emitted", d.cue));
219
+ this.deps.onBackchannelEmitted?.(contextId);
220
+ }
221
+
222
+ reset(contextId: string): void {
223
+ this.revokePendingTakeTurn(contextId);
224
+ this.transcriptsByContext.delete(contextId);
225
+ this.interimByContext.delete(contextId);
226
+ this.deps.policy.reset(contextId);
227
+ }
228
+ }
229
+
230
+ function takeTurnTimerKey(contextId: string): string {
231
+ return `interaction.take_turn:${contextId}`;
232
+ }
233
+
234
+ function joinTranscript(transcripts: readonly SttResultPacket[], interim: string): string {
235
+ const segments = [
236
+ ...transcripts.map((t) => t.text.trim()),
237
+ ...(interim ? [interim.trim()] : []),
238
+ ].filter(Boolean);
239
+ return segments.join(" ").replace(/\s+/g, " ").trim();
240
+ }
@@ -0,0 +1,104 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ export interface WordTiming {
4
+ readonly word: string;
5
+ readonly startMs: number;
6
+ readonly endMs: number;
7
+ readonly confidence: number;
8
+ }
9
+
10
+ export type InteractionObservation =
11
+ | {
12
+ readonly kind: "vad_speech_started";
13
+ readonly contextId: string;
14
+ readonly timestampMs: number;
15
+ readonly confidence: number;
16
+ readonly interruptedContextId?: string;
17
+ }
18
+ | {
19
+ readonly kind: "vad_speech_activity";
20
+ readonly contextId: string;
21
+ readonly timestampMs: number;
22
+ }
23
+ | {
24
+ readonly kind: "vad_speech_ended";
25
+ readonly contextId: string;
26
+ readonly timestampMs: number;
27
+ readonly hasActiveTts: boolean;
28
+ }
29
+ | {
30
+ readonly kind: "vad_barge_in_audio";
31
+ readonly contextId: string;
32
+ readonly timestampMs: number;
33
+ readonly audio: Uint8Array;
34
+ }
35
+ | {
36
+ readonly kind: "stt_partial";
37
+ readonly contextId: string;
38
+ readonly timestampMs: number;
39
+ readonly text: string;
40
+ readonly confidence?: number;
41
+ readonly interruptedContextId?: string;
42
+ readonly wordTimings?: readonly WordTiming[];
43
+ }
44
+ | {
45
+ readonly kind: "stt_final";
46
+ readonly contextId: string;
47
+ readonly timestampMs: number;
48
+ readonly text: string;
49
+ readonly confidence?: number;
50
+ readonly interruptedContextId?: string;
51
+ readonly wordTimings?: readonly WordTiming[];
52
+ }
53
+ | {
54
+ readonly kind: "audio_frame";
55
+ readonly contextId: string;
56
+ readonly timestampMs: number;
57
+ readonly audio?: Int16Array;
58
+ readonly sampleRateHz?: number;
59
+ readonly wordTimings?: readonly WordTiming[];
60
+ readonly prosody?: Float32Array;
61
+ }
62
+ | {
63
+ readonly kind: "playout_tick";
64
+ readonly contextId: string;
65
+ readonly timestampMs: number;
66
+ readonly playedOutMs?: number;
67
+ readonly ttsActive?: boolean;
68
+ /** PCM queued for assistant playout. Present on audio-bearing ticks. */
69
+ readonly audio?: Int16Array;
70
+ readonly sampleRateHz?: number;
71
+ }
72
+ | {
73
+ readonly kind: "delegate_state";
74
+ readonly contextId: string;
75
+ readonly timestampMs: number;
76
+ readonly delegateInFlight?: boolean;
77
+ readonly toolCallPhase?: "started" | "delayed" | "complete" | "failed";
78
+ };
79
+
80
+ export type InteractionDecision =
81
+ | { readonly kind: "keep_listening" }
82
+ | { readonly kind: "take_turn"; readonly confidence: number; readonly waitMs?: number }
83
+ /** `cue` is a stable pre-cached asset id (e.g. `mm_hmm`), not free-form text. */
84
+ | { readonly kind: "backchannel"; readonly cue: string }
85
+ | { readonly kind: "hold" }
86
+ | { readonly kind: "interrupt"; readonly interruptedContextId: string };
87
+
88
+ export interface InteractionPolicy {
89
+ observe(obs: InteractionObservation): readonly InteractionDecision[];
90
+ reset(contextId: string): void;
91
+ }
92
+
93
+ /** Optional lifecycle for externally supplied model policies (session-owned). */
94
+ export interface LifecycleInteractionPolicy extends InteractionPolicy {
95
+ initialize(config: Record<string, unknown>): Promise<void>;
96
+ close(): Promise<void>;
97
+ }
98
+
99
+ export function isLifecycleInteractionPolicy(
100
+ policy: InteractionPolicy,
101
+ ): policy is LifecycleInteractionPolicy {
102
+ const candidate = policy as LifecycleInteractionPolicy;
103
+ return typeof candidate.initialize === "function" && typeof candidate.close === "function";
104
+ }
@@ -0,0 +1,276 @@
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
+ });