recursive-llm-ts 4.4.1 → 4.6.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,783 @@
1
+ package rlm
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "strings"
7
+ "testing"
8
+ )
9
+
10
+ // ─── Error Detection Tests ───────────────────────────────────────────────────
11
+
12
+ func TestIsContextOverflow_DirectType(t *testing.T) {
13
+ err := NewContextOverflowError(400, "test", 32768, 40354)
14
+ coe, ok := IsContextOverflow(err)
15
+ if !ok {
16
+ t.Fatal("expected IsContextOverflow to return true for ContextOverflowError")
17
+ }
18
+ if coe.ModelLimit != 32768 {
19
+ t.Errorf("expected ModelLimit 32768, got %d", coe.ModelLimit)
20
+ }
21
+ if coe.RequestTokens != 40354 {
22
+ t.Errorf("expected RequestTokens 40354, got %d", coe.RequestTokens)
23
+ }
24
+ }
25
+
26
+ func TestIsContextOverflow_FromAPIError_OpenAI(t *testing.T) {
27
+ // Real OpenAI error format
28
+ response := `{"error":{"message":"This model's maximum context length is 32768 tokens. However, your request has 40354 input tokens. Please reduce the length of the input messages.","type":"invalid_request_error","param":"messages","code":"context_length_exceeded"}}`
29
+ apiErr := NewAPIError(400, response)
30
+
31
+ coe, ok := IsContextOverflow(apiErr)
32
+ if !ok {
33
+ t.Fatal("expected IsContextOverflow to detect OpenAI context overflow error")
34
+ }
35
+ if coe.ModelLimit != 32768 {
36
+ t.Errorf("expected ModelLimit 32768, got %d", coe.ModelLimit)
37
+ }
38
+ if coe.RequestTokens != 40354 {
39
+ t.Errorf("expected RequestTokens 40354, got %d", coe.RequestTokens)
40
+ }
41
+ }
42
+
43
+ func TestIsContextOverflow_FromAPIError_vLLM(t *testing.T) {
44
+ // vLLM / Ray Serve error format (the user's actual error)
45
+ response := `{"error":{"message":"Message: This model's maximum context length is 32768 tokens. However, your request has 40354 input tokens. Please reduce the length of the input messages. None (Request ID: ad08ee3b-67df-4ab2-bdeb-1e3135847e2a), Internal exception: ray.llm._internal.serve.core.configs.openai_api_models.OpenAIHTTPException","type":"OpenAIHTTPException","param":null,"code":400}}`
46
+ apiErr := NewAPIError(400, response)
47
+
48
+ coe, ok := IsContextOverflow(apiErr)
49
+ if !ok {
50
+ t.Fatal("expected IsContextOverflow to detect vLLM context overflow error")
51
+ }
52
+ if coe.ModelLimit != 32768 {
53
+ t.Errorf("expected ModelLimit 32768, got %d", coe.ModelLimit)
54
+ }
55
+ if coe.RequestTokens != 40354 {
56
+ t.Errorf("expected RequestTokens 40354, got %d", coe.RequestTokens)
57
+ }
58
+ }
59
+
60
+ func TestIsContextOverflow_FromAPIError_Azure(t *testing.T) {
61
+ // Azure OpenAI format
62
+ response := `{"error":{"message":"This model's maximum context length is 8192 tokens, however you requested 12000 tokens","type":"invalid_request_error","code":"context_length_exceeded"}}`
63
+ apiErr := NewAPIError(400, response)
64
+
65
+ coe, ok := IsContextOverflow(apiErr)
66
+ if !ok {
67
+ t.Fatal("expected IsContextOverflow to detect Azure context overflow error")
68
+ }
69
+ if coe.ModelLimit != 8192 {
70
+ t.Errorf("expected ModelLimit 8192, got %d", coe.ModelLimit)
71
+ }
72
+ if coe.RequestTokens != 12000 {
73
+ t.Errorf("expected RequestTokens 12000, got %d", coe.RequestTokens)
74
+ }
75
+ }
76
+
77
+ func TestIsContextOverflow_NotOverflow(t *testing.T) {
78
+ tests := []error{
79
+ errors.New("rate limit exceeded"),
80
+ errors.New("timeout"),
81
+ NewAPIError(500, "internal server error"),
82
+ NewAPIError(429, "too many requests"),
83
+ NewMaxIterationsError(10),
84
+ NewMaxDepthError(5),
85
+ }
86
+
87
+ for _, err := range tests {
88
+ _, ok := IsContextOverflow(err)
89
+ if ok {
90
+ t.Errorf("expected IsContextOverflow to return false for: %v", err)
91
+ }
92
+ }
93
+ }
94
+
95
+ func TestIsContextOverflow_GenericError(t *testing.T) {
96
+ // Generic error with overflow message
97
+ err := fmt.Errorf("This model's maximum context length is 4096 tokens. However, your request has 5000 input tokens.")
98
+ coe, ok := IsContextOverflow(err)
99
+ if !ok {
100
+ t.Fatal("expected IsContextOverflow to detect overflow from generic error")
101
+ }
102
+ if coe.ModelLimit != 4096 {
103
+ t.Errorf("expected ModelLimit 4096, got %d", coe.ModelLimit)
104
+ }
105
+ if coe.RequestTokens != 5000 {
106
+ t.Errorf("expected RequestTokens 5000, got %d", coe.RequestTokens)
107
+ }
108
+ }
109
+
110
+ func TestContextOverflowError_OverflowRatio(t *testing.T) {
111
+ tests := []struct {
112
+ limit int
113
+ request int
114
+ expected float64
115
+ }{
116
+ {32768, 40354, 1.2314}, // ~23% over
117
+ {4096, 8192, 2.0}, // 100% over
118
+ {100, 100, 1.0}, // exactly at limit
119
+ {0, 100, 0.0}, // zero limit edge case
120
+ }
121
+
122
+ for _, tt := range tests {
123
+ coe := NewContextOverflowError(400, "test", tt.limit, tt.request)
124
+ ratio := coe.OverflowRatio()
125
+ if ratio < tt.expected-0.01 || ratio > tt.expected+0.01 {
126
+ t.Errorf("OverflowRatio(%d, %d) = %.4f, expected ~%.4f", tt.limit, tt.request, ratio, tt.expected)
127
+ }
128
+ }
129
+ }
130
+
131
+ // ─── Token Estimation Tests ──────────────────────────────────────────────────
132
+
133
+ func TestEstimateTokens(t *testing.T) {
134
+ tests := []struct {
135
+ text string
136
+ minTokens int
137
+ maxTokens int
138
+ }{
139
+ {"", 0, 0},
140
+ {"hello", 1, 3},
141
+ {"Hello, world!", 2, 5},
142
+ {strings.Repeat("a", 100), 20, 40}, // 100 chars -> ~25-30 tokens
143
+ {strings.Repeat("a", 1000), 200, 350}, // 1000 chars -> ~250-300 tokens
144
+ {strings.Repeat("a", 10000), 2000, 3500}, // 10000 chars -> ~2500-3000 tokens
145
+ }
146
+
147
+ for _, tt := range tests {
148
+ tokens := EstimateTokens(tt.text)
149
+ if tokens < tt.minTokens || tokens > tt.maxTokens {
150
+ t.Errorf("EstimateTokens(%d chars) = %d, expected between %d and %d",
151
+ len(tt.text), tokens, tt.minTokens, tt.maxTokens)
152
+ }
153
+ }
154
+ }
155
+
156
+ func TestEstimateTokens_ConservativeForEnglish(t *testing.T) {
157
+ // For English text, OpenAI's cl100k_base gives roughly 1 token per 4 chars
158
+ // Our estimator should be conservative (overestimate) to prevent overflow
159
+ englishText := "The quick brown fox jumped over the lazy dog. This is a test of the token estimation function."
160
+ estimated := EstimateTokens(englishText)
161
+
162
+ // Real token count for this text is about 22 (cl100k_base)
163
+ // We expect our estimate to be >= actual (conservative)
164
+ if estimated < 20 {
165
+ t.Errorf("EstimateTokens for English text should be at least 20, got %d", estimated)
166
+ }
167
+ }
168
+
169
+ func TestEstimateMessagesTokens(t *testing.T) {
170
+ messages := []Message{
171
+ {Role: "system", Content: "You are a helpful assistant."},
172
+ {Role: "user", Content: "Hello, how are you?"},
173
+ }
174
+
175
+ tokens := EstimateMessagesTokens(messages)
176
+ // 3 (base) + 2*(4 overhead) + tokens for both messages
177
+ if tokens < 15 {
178
+ t.Errorf("EstimateMessagesTokens expected at least 15, got %d", tokens)
179
+ }
180
+ }
181
+
182
+ // ─── Context Chunking Tests ─────────────────────────────────────────────────
183
+
184
+ func TestChunkContext_SmallContext(t *testing.T) {
185
+ context := "This is a small context."
186
+ chunks := ChunkContext(context, 1000)
187
+
188
+ if len(chunks) != 1 {
189
+ t.Errorf("expected 1 chunk for small context, got %d", len(chunks))
190
+ }
191
+ if chunks[0] != context {
192
+ t.Error("expected chunk to be the original context")
193
+ }
194
+ }
195
+
196
+ func TestChunkContext_LargeContext(t *testing.T) {
197
+ // Create context that's ~10000 tokens (~35000 chars at 3.5 chars/token)
198
+ context := strings.Repeat("The quick brown fox jumped over the lazy dog. ", 700)
199
+ chunks := ChunkContext(context, 2000)
200
+
201
+ if len(chunks) < 2 {
202
+ t.Errorf("expected at least 2 chunks, got %d", len(chunks))
203
+ }
204
+
205
+ // Verify all content is covered (with overlap, total chars may exceed original)
206
+ totalChars := 0
207
+ for _, chunk := range chunks {
208
+ totalChars += len(chunk)
209
+ // Each chunk should be within the token limit
210
+ chunkTokens := EstimateTokens(chunk)
211
+ if chunkTokens > 2500 { // Allow some slack
212
+ t.Errorf("chunk has %d estimated tokens, expected <= 2500", chunkTokens)
213
+ }
214
+ }
215
+ }
216
+
217
+ func TestChunkContext_ParagraphBoundaries(t *testing.T) {
218
+ // Context with clear paragraph boundaries
219
+ paragraphs := []string{
220
+ "First paragraph with some content here.",
221
+ "Second paragraph with different content.",
222
+ "Third paragraph with more information.",
223
+ "Fourth paragraph wrapping up the text.",
224
+ }
225
+ context := strings.Join(paragraphs, "\n\n")
226
+
227
+ // Use a budget that forces splitting into 2 chunks
228
+ chunks := ChunkContext(context, 30) // ~30 tokens per chunk
229
+
230
+ if len(chunks) < 2 {
231
+ t.Errorf("expected at least 2 chunks, got %d", len(chunks))
232
+ }
233
+
234
+ // Verify chunks preferentially split at paragraph boundaries
235
+ for _, chunk := range chunks {
236
+ trimmed := strings.TrimSpace(chunk)
237
+ if len(trimmed) == 0 {
238
+ t.Error("got empty chunk")
239
+ }
240
+ }
241
+ }
242
+
243
+ func TestChunkContext_ZeroTokenBudget(t *testing.T) {
244
+ context := "Some text content here"
245
+ chunks := ChunkContext(context, 0)
246
+ // Should use default of 4000 tokens
247
+ if len(chunks) != 1 {
248
+ t.Errorf("expected 1 chunk with default budget, got %d", len(chunks))
249
+ }
250
+ }
251
+
252
+ // ─── parseContextOverflowMessage Tests ──────────────────────────────────────
253
+
254
+ func TestParseContextOverflowMessage(t *testing.T) {
255
+ tests := []struct {
256
+ name string
257
+ msg string
258
+ limit int
259
+ request int
260
+ ok bool
261
+ }{
262
+ {
263
+ name: "OpenAI standard",
264
+ msg: "This model's maximum context length is 32768 tokens. However, your request has 40354 input tokens.",
265
+ limit: 32768,
266
+ request: 40354,
267
+ ok: true,
268
+ },
269
+ {
270
+ name: "Azure format",
271
+ msg: "This model's maximum context length is 8192 tokens, however you requested 12000 tokens",
272
+ limit: 8192,
273
+ request: 12000,
274
+ ok: true,
275
+ },
276
+ {
277
+ name: "With comma-separated numbers",
278
+ msg: "This model's maximum context length is 32,768 tokens. However, your request has 40,354 input tokens.",
279
+ limit: 32768,
280
+ request: 40354,
281
+ ok: true,
282
+ },
283
+ {
284
+ name: "context_length_exceeded code",
285
+ msg: `{"code":"context_length_exceeded","message":"This model's maximum context length is 4096 tokens. Your messages resulted in 5000 tokens."}`,
286
+ limit: 4096,
287
+ request: 5000,
288
+ ok: true,
289
+ },
290
+ {
291
+ name: "Not an overflow error",
292
+ msg: "rate limit exceeded",
293
+ limit: 0,
294
+ request: 0,
295
+ ok: false,
296
+ },
297
+ {
298
+ name: "Generic error",
299
+ msg: "internal server error",
300
+ limit: 0,
301
+ request: 0,
302
+ ok: false,
303
+ },
304
+ {
305
+ name: "vLLM wrapped error",
306
+ msg: "Message: This model's maximum context length is 16384 tokens. However, your request has 20000 input tokens. Internal exception: ray.llm._internal.serve",
307
+ limit: 16384,
308
+ request: 20000,
309
+ ok: true,
310
+ },
311
+ }
312
+
313
+ for _, tt := range tests {
314
+ t.Run(tt.name, func(t *testing.T) {
315
+ limit, request, ok := parseContextOverflowMessage(tt.msg)
316
+ if ok != tt.ok {
317
+ t.Errorf("parseContextOverflowMessage ok=%v, expected %v", ok, tt.ok)
318
+ }
319
+ if ok && limit != tt.limit {
320
+ t.Errorf("limit=%d, expected %d", limit, tt.limit)
321
+ }
322
+ if ok && request != tt.request {
323
+ t.Errorf("request=%d, expected %d", request, tt.request)
324
+ }
325
+ })
326
+ }
327
+ }
328
+
329
+ // ─── extractNumber Tests ─────────────────────────────────────────────────────
330
+
331
+ func TestExtractNumber(t *testing.T) {
332
+ tests := []struct {
333
+ s string
334
+ prefix string
335
+ suffix string
336
+ expected int
337
+ }{
338
+ {"maximum context length is 32768 tokens", "maximum context length is ", " tokens", 32768},
339
+ {"your request has 40354 input tokens", "your request has ", " input tokens", 40354},
340
+ {"you requested 12000 tokens", "you requested ", " tokens", 12000},
341
+ {"limit is 32,768 tokens", "limit is ", " tokens", 32768},
342
+ {"no match here", "limit is ", " tokens", 0},
343
+ {"limit is tokens", "limit is ", " tokens", 0}, // empty number
344
+ }
345
+
346
+ for _, tt := range tests {
347
+ result := extractNumber(tt.s, tt.prefix, tt.suffix)
348
+ if result != tt.expected {
349
+ t.Errorf("extractNumber(%q, %q, %q) = %d, expected %d", tt.s, tt.prefix, tt.suffix, result, tt.expected)
350
+ }
351
+ }
352
+ }
353
+
354
+ // ─── ContextOverflowConfig Tests ─────────────────────────────────────────────
355
+
356
+ func TestDefaultContextOverflowConfig(t *testing.T) {
357
+ config := DefaultContextOverflowConfig()
358
+
359
+ if !config.Enabled {
360
+ t.Error("expected default Enabled to be true")
361
+ }
362
+ if config.Strategy != "mapreduce" {
363
+ t.Errorf("expected default Strategy 'mapreduce', got %q", config.Strategy)
364
+ }
365
+ if config.SafetyMargin != 0.15 {
366
+ t.Errorf("expected default SafetyMargin 0.15, got %f", config.SafetyMargin)
367
+ }
368
+ if config.MaxReductionAttempts != 3 {
369
+ t.Errorf("expected default MaxReductionAttempts 3, got %d", config.MaxReductionAttempts)
370
+ }
371
+ if config.MaxModelTokens != 0 {
372
+ t.Errorf("expected default MaxModelTokens 0 (auto-detect), got %d", config.MaxModelTokens)
373
+ }
374
+ }
375
+
376
+ // ─── ConfigFromMap Integration Tests ─────────────────────────────────────────
377
+
378
+ func TestConfigFromMap_ContextOverflow(t *testing.T) {
379
+ configMap := map[string]interface{}{
380
+ "api_key": "test-key",
381
+ "context_overflow": map[string]interface{}{
382
+ "enabled": true,
383
+ "max_model_tokens": float64(32768),
384
+ "strategy": "truncate",
385
+ "safety_margin": 0.2,
386
+ "max_reduction_attempts": float64(5),
387
+ },
388
+ }
389
+
390
+ config := ConfigFromMap(configMap)
391
+
392
+ if config.ContextOverflow == nil {
393
+ t.Fatal("expected ContextOverflow to be set")
394
+ }
395
+ if !config.ContextOverflow.Enabled {
396
+ t.Error("expected Enabled to be true")
397
+ }
398
+ if config.ContextOverflow.MaxModelTokens != 32768 {
399
+ t.Errorf("expected MaxModelTokens 32768, got %d", config.ContextOverflow.MaxModelTokens)
400
+ }
401
+ if config.ContextOverflow.Strategy != "truncate" {
402
+ t.Errorf("expected Strategy 'truncate', got %q", config.ContextOverflow.Strategy)
403
+ }
404
+ if config.ContextOverflow.SafetyMargin != 0.2 {
405
+ t.Errorf("expected SafetyMargin 0.2, got %f", config.ContextOverflow.SafetyMargin)
406
+ }
407
+ if config.ContextOverflow.MaxReductionAttempts != 5 {
408
+ t.Errorf("expected MaxReductionAttempts 5, got %d", config.ContextOverflow.MaxReductionAttempts)
409
+ }
410
+ }
411
+
412
+ func TestConfigFromMap_NoContextOverflow(t *testing.T) {
413
+ configMap := map[string]interface{}{
414
+ "api_key": "test-key",
415
+ }
416
+
417
+ config := ConfigFromMap(configMap)
418
+
419
+ if config.ContextOverflow != nil {
420
+ t.Error("expected ContextOverflow to be nil when not specified in map")
421
+ }
422
+ }
423
+
424
+ // ─── Truncation Strategy Tests ───────────────────────────────────────────────
425
+
426
+ func TestReduceByTruncation(t *testing.T) {
427
+ // Create a large context
428
+ context := strings.Repeat("This is a sentence. ", 500) // ~10000 chars
429
+
430
+ obs := NewNoopObserver()
431
+ config := ContextOverflowConfig{
432
+ Enabled: true,
433
+ Strategy: "truncate",
434
+ SafetyMargin: 0.15,
435
+ MaxReductionAttempts: 3,
436
+ }
437
+
438
+ rlmEngine := &RLM{
439
+ model: "test-model",
440
+ observer: obs,
441
+ }
442
+
443
+ reducer := newContextReducer(rlmEngine, config, obs)
444
+ result, err := reducer.reduceByTruncation(context, 2000, 500)
445
+
446
+ if err != nil {
447
+ t.Fatalf("unexpected error: %v", err)
448
+ }
449
+
450
+ if len(result) >= len(context) {
451
+ t.Errorf("expected truncated context to be shorter: %d >= %d", len(result), len(context))
452
+ }
453
+
454
+ // Should contain the truncation marker
455
+ if !strings.Contains(result, "[... context truncated") {
456
+ t.Error("expected truncation marker in result")
457
+ }
458
+
459
+ // Should preserve beginning and end
460
+ if !strings.HasPrefix(result, "This is") {
461
+ t.Error("expected result to start with original beginning")
462
+ }
463
+ if !strings.HasSuffix(strings.TrimSpace(result), "sentence. ") && !strings.HasSuffix(strings.TrimSpace(result), "sentence.") {
464
+ // Just check it has some of the end content
465
+ if !strings.Contains(result[len(result)/2:], "sentence") {
466
+ t.Error("expected result to contain end content")
467
+ }
468
+ }
469
+ }
470
+
471
+ // ─── findBreakPoint Tests ────────────────────────────────────────────────────
472
+
473
+ func TestFindBreakPoint(t *testing.T) {
474
+ tests := []struct {
475
+ name string
476
+ text string
477
+ start int
478
+ end int
479
+ check func(int) bool
480
+ }{
481
+ {
482
+ name: "Prefers paragraph break",
483
+ text: "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
484
+ start: 0,
485
+ end: 20,
486
+ check: func(bp int) bool {
487
+ return bp == 18 // After first \n\n (search window reaches back to position 16)
488
+ },
489
+ },
490
+ {
491
+ name: "Falls back to line break",
492
+ text: "Line one.\nLine two.\nLine three.",
493
+ start: 0,
494
+ end: 20,
495
+ check: func(bp int) bool {
496
+ return bp == 10 || bp == 20 // After a \n
497
+ },
498
+ },
499
+ {
500
+ name: "End of text",
501
+ text: "Short text",
502
+ start: 0,
503
+ end: 100,
504
+ check: func(bp int) bool {
505
+ return bp == 10 // End of text
506
+ },
507
+ },
508
+ }
509
+
510
+ for _, tt := range tests {
511
+ t.Run(tt.name, func(t *testing.T) {
512
+ bp := findBreakPoint(tt.text, tt.start, tt.end)
513
+ if !tt.check(bp) {
514
+ t.Errorf("findBreakPoint returned %d", bp)
515
+ }
516
+ })
517
+ }
518
+ }
519
+
520
+ // ─── Error Chain Tests ───────────────────────────────────────────────────────
521
+
522
+ func TestContextOverflowError_ErrorChain(t *testing.T) {
523
+ coe := NewContextOverflowError(400, "test response", 32768, 40354)
524
+
525
+ // Verify the embedded types are accessible
526
+ if coe.APIError == nil {
527
+ t.Fatal("expected embedded APIError to be non-nil")
528
+ }
529
+ if coe.APIError.StatusCode != 400 {
530
+ t.Errorf("expected status 400, got %d", coe.APIError.StatusCode)
531
+ }
532
+ if coe.APIError.RLMError == nil {
533
+ t.Fatal("expected embedded RLMError to be non-nil")
534
+ }
535
+
536
+ // Verify errors.As finds ContextOverflowError itself
537
+ var coe2 *ContextOverflowError
538
+ if !errors.As(coe, &coe2) {
539
+ t.Error("expected errors.As to find ContextOverflowError")
540
+ }
541
+
542
+ // Verify errors.As finds APIError through Unwrap chain
543
+ var apiErr *APIError
544
+ if !errors.As(coe, &apiErr) {
545
+ t.Error("expected errors.As to find APIError in chain")
546
+ }
547
+
548
+ // Test error message
549
+ msg := coe.Error()
550
+ if !strings.Contains(msg, "context overflow") {
551
+ t.Errorf("expected error message to contain 'context overflow', got: %s", msg)
552
+ }
553
+ if !strings.Contains(msg, "32768") {
554
+ t.Errorf("expected error message to contain model limit, got: %s", msg)
555
+ }
556
+ }
557
+
558
+ // ─── RLM Integration Tests ──────────────────────────────────────────────────
559
+
560
+ func TestRLMDefaultContextOverflow(t *testing.T) {
561
+ // Creating an RLM without explicit context_overflow should enable it by default
562
+ config := Config{
563
+ APIKey: "test",
564
+ MaxDepth: 5,
565
+ MaxIterations: 30,
566
+ }
567
+
568
+ engine := New("test-model", config)
569
+
570
+ if engine.contextOverflow == nil {
571
+ t.Fatal("expected contextOverflow to be set by default")
572
+ }
573
+ if !engine.contextOverflow.Enabled {
574
+ t.Error("expected contextOverflow to be enabled by default")
575
+ }
576
+ if engine.contextOverflow.Strategy != "mapreduce" {
577
+ t.Errorf("expected default strategy 'mapreduce', got %q", engine.contextOverflow.Strategy)
578
+ }
579
+ }
580
+
581
+ func TestRLMExplicitContextOverflow(t *testing.T) {
582
+ config := Config{
583
+ APIKey: "test",
584
+ ContextOverflow: &ContextOverflowConfig{
585
+ Enabled: true,
586
+ MaxModelTokens: 16384,
587
+ Strategy: "truncate",
588
+ },
589
+ }
590
+
591
+ engine := New("test-model", config)
592
+
593
+ if engine.contextOverflow.MaxModelTokens != 16384 {
594
+ t.Errorf("expected MaxModelTokens 16384, got %d", engine.contextOverflow.MaxModelTokens)
595
+ }
596
+ if engine.contextOverflow.Strategy != "truncate" {
597
+ t.Errorf("expected strategy 'truncate', got %q", engine.contextOverflow.Strategy)
598
+ }
599
+ }
600
+
601
+ // ─── New Strategy Config Tests ──────────────────────────────────────────────
602
+
603
+ func TestRLMContextOverflow_TFIDFStrategy(t *testing.T) {
604
+ config := Config{
605
+ APIKey: "test",
606
+ ContextOverflow: &ContextOverflowConfig{
607
+ Enabled: true,
608
+ Strategy: "tfidf",
609
+ },
610
+ }
611
+ engine := New("test-model", config)
612
+ if engine.contextOverflow.Strategy != "tfidf" {
613
+ t.Errorf("expected strategy 'tfidf', got %q", engine.contextOverflow.Strategy)
614
+ }
615
+ }
616
+
617
+ func TestRLMContextOverflow_TextRankStrategy(t *testing.T) {
618
+ config := Config{
619
+ APIKey: "test",
620
+ ContextOverflow: &ContextOverflowConfig{
621
+ Enabled: true,
622
+ Strategy: "textrank",
623
+ },
624
+ }
625
+ engine := New("test-model", config)
626
+ if engine.contextOverflow.Strategy != "textrank" {
627
+ t.Errorf("expected strategy 'textrank', got %q", engine.contextOverflow.Strategy)
628
+ }
629
+ }
630
+
631
+ func TestRLMContextOverflow_RefineStrategy(t *testing.T) {
632
+ config := Config{
633
+ APIKey: "test",
634
+ ContextOverflow: &ContextOverflowConfig{
635
+ Enabled: true,
636
+ Strategy: "refine",
637
+ },
638
+ }
639
+ engine := New("test-model", config)
640
+ if engine.contextOverflow.Strategy != "refine" {
641
+ t.Errorf("expected strategy 'refine', got %q", engine.contextOverflow.Strategy)
642
+ }
643
+ }
644
+
645
+ func TestConfigFromMap_NewStrategies(t *testing.T) {
646
+ for _, strategy := range []string{"tfidf", "textrank", "refine"} {
647
+ configMap := map[string]interface{}{
648
+ "api_key": "test-key",
649
+ "context_overflow": map[string]interface{}{
650
+ "enabled": true,
651
+ "strategy": strategy,
652
+ },
653
+ }
654
+ config := ConfigFromMap(configMap)
655
+ if config.ContextOverflow == nil {
656
+ t.Fatalf("expected ContextOverflow for strategy %q", strategy)
657
+ }
658
+ if config.ContextOverflow.Strategy != strategy {
659
+ t.Errorf("expected strategy %q, got %q", strategy, config.ContextOverflow.Strategy)
660
+ }
661
+ }
662
+ }
663
+
664
+ // ─── TF-IDF Reducer Integration Tests ───────────────────────────────────────
665
+
666
+ func TestReduceByTFIDF(t *testing.T) {
667
+ // Build large context with multiple sentences
668
+ sentences := []string{
669
+ "The quarterly earnings report shows revenue of $4.2 billion.",
670
+ "Weather conditions are expected to be mild this week.",
671
+ "The merger was approved by regulatory authorities in March.",
672
+ "Traffic congestion increased 15% during rush hour.",
673
+ "Operating margins improved to 23.5% from 19.8% last year.",
674
+ "The local park received new playground equipment.",
675
+ "Customer retention rate reached 94% this quarter.",
676
+ "The movie earned $150 million at the box office opening weekend.",
677
+ "Year-over-year growth accelerated to 31% in Q4.",
678
+ "The recipe calls for two cups of flour and one egg.",
679
+ }
680
+ context := strings.Join(sentences, " ")
681
+
682
+ obs := NewNoopObserver()
683
+ config := ContextOverflowConfig{
684
+ Enabled: true,
685
+ Strategy: "tfidf",
686
+ SafetyMargin: 0.15,
687
+ }
688
+ rlmEngine := &RLM{model: "test-model", observer: obs}
689
+ reducer := newContextReducer(rlmEngine, config, obs)
690
+
691
+ result, err := reducer.reduceByTFIDF(context, 50, 10) // Very tight budget
692
+ if err != nil {
693
+ t.Fatalf("unexpected error: %v", err)
694
+ }
695
+
696
+ if len(result) >= len(context) {
697
+ t.Errorf("expected reduced context, got same or larger: %d >= %d", len(result), len(context))
698
+ }
699
+ if len(result) == 0 {
700
+ t.Error("expected non-empty result")
701
+ }
702
+ }
703
+
704
+ // ─── TextRank Reducer Integration Tests ─────────────────────────────────────
705
+
706
+ func TestReduceByTextRank(t *testing.T) {
707
+ sentences := []string{
708
+ "Machine learning algorithms process large datasets.",
709
+ "Deep learning models use neural network architectures.",
710
+ "Natural language processing handles text data efficiently.",
711
+ "The garden needs watering twice a week.",
712
+ "Transformer models revolutionized NLP tasks.",
713
+ "Computer vision detects objects in images.",
714
+ "The recipe requires fresh ingredients only.",
715
+ }
716
+ context := strings.Join(sentences, " ")
717
+
718
+ obs := NewNoopObserver()
719
+ config := ContextOverflowConfig{
720
+ Enabled: true,
721
+ Strategy: "textrank",
722
+ SafetyMargin: 0.15,
723
+ }
724
+ rlmEngine := &RLM{model: "test-model", observer: obs}
725
+ reducer := newContextReducer(rlmEngine, config, obs)
726
+
727
+ result, err := reducer.reduceByTextRank(context, 60, 10)
728
+ if err != nil {
729
+ t.Fatalf("unexpected error: %v", err)
730
+ }
731
+
732
+ if len(result) >= len(context) {
733
+ t.Errorf("expected reduced context: %d >= %d", len(result), len(context))
734
+ }
735
+ if len(result) == 0 {
736
+ t.Error("expected non-empty result")
737
+ }
738
+ }
739
+
740
+ // ─── Strategy Dispatch Tests ────────────────────────────────────────────────
741
+
742
+ func TestReduceForCompletion_DispatchesTFIDF(t *testing.T) {
743
+ // Large context that will need chunking
744
+ context := strings.Repeat("Sentence about machine learning algorithms. ", 100)
745
+
746
+ obs := NewNoopObserver()
747
+ config := ContextOverflowConfig{
748
+ Enabled: true,
749
+ Strategy: "tfidf",
750
+ SafetyMargin: 0.15,
751
+ }
752
+ rlmEngine := &RLM{model: "test-model", observer: obs}
753
+ reducer := newContextReducer(rlmEngine, config, obs)
754
+
755
+ result, err := reducer.ReduceForCompletion("What about ML?", context, 500)
756
+ if err != nil {
757
+ t.Fatalf("unexpected error: %v", err)
758
+ }
759
+ if len(result) >= len(context) {
760
+ t.Errorf("expected reduced context for tfidf strategy")
761
+ }
762
+ }
763
+
764
+ func TestReduceForCompletion_DispatchesTextRank(t *testing.T) {
765
+ context := strings.Repeat("Deep learning models process data efficiently. ", 100)
766
+
767
+ obs := NewNoopObserver()
768
+ config := ContextOverflowConfig{
769
+ Enabled: true,
770
+ Strategy: "textrank",
771
+ SafetyMargin: 0.15,
772
+ }
773
+ rlmEngine := &RLM{model: "test-model", observer: obs}
774
+ reducer := newContextReducer(rlmEngine, config, obs)
775
+
776
+ result, err := reducer.ReduceForCompletion("What about DL?", context, 500)
777
+ if err != nil {
778
+ t.Fatalf("unexpected error: %v", err)
779
+ }
780
+ if len(result) >= len(context) {
781
+ t.Errorf("expected reduced context for textrank strategy")
782
+ }
783
+ }