recursive-llm-ts 4.9.0 → 5.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.
@@ -0,0 +1,387 @@
1
+ package rlm
2
+
3
+ import (
4
+ "fmt"
5
+ "strings"
6
+ "testing"
7
+ "time"
8
+ )
9
+
10
+ func deterministicSentence(idx int) string {
11
+ topics := []string{
12
+ "architecture", "testing", "performance", "reliability", "observability",
13
+ "security", "scalability", "maintainability", "usability", "automation",
14
+ }
15
+ details := []string{
16
+ "input validation", "error handling", "resource limits", "data flow", "boundary conditions",
17
+ "traceability", "deployment safety", "schema consistency", "latency targets", "integration behavior",
18
+ }
19
+
20
+ topic := topics[idx%len(topics)]
21
+ detail := details[(idx*7)%len(details)]
22
+ return fmt.Sprintf("Sentence %d discusses topic %s with details about %s. ", idx, topic, detail)
23
+ }
24
+
25
+ func generateDeterministicContext(targetTokens int) string {
26
+ if targetTokens <= 0 {
27
+ return ""
28
+ }
29
+
30
+ var b strings.Builder
31
+ total := 0
32
+ for i := 1; total < targetTokens; i++ {
33
+ s := deterministicSentence(i)
34
+ b.WriteString(s)
35
+ total += EstimateTokens(s)
36
+ }
37
+ return b.String()
38
+ }
39
+
40
+ func fixedEnglishProse500Words() string {
41
+ words := []string{
42
+ "software", "teams", "benefit", "from", "clear", "requirements", "because", "stable", "interfaces", "reduce",
43
+ "rework", "and", "improve", "delivery", "predictability", "when", "engineers", "document", "assumptions", "carefully",
44
+ "review", "cycles", "become", "faster", "while", "quality", "signals", "remain", "visible", "across",
45
+ "planning", "implementation", "testing", "and", "maintenance", "phases", "in", "long", "lived", "systems",
46
+ }
47
+
48
+ var b strings.Builder
49
+ for i := 0; i < 500; i++ {
50
+ if i > 0 {
51
+ b.WriteByte(' ')
52
+ }
53
+ w := words[i%len(words)]
54
+ if (i+1)%25 == 0 {
55
+ w += "."
56
+ }
57
+ b.WriteString(w)
58
+ }
59
+ return b.String()
60
+ }
61
+
62
+ func percentDifference(base, compare int) float64 {
63
+ if base == 0 {
64
+ return 0
65
+ }
66
+ return (float64(compare-base) / float64(base)) * 100
67
+ }
68
+
69
+ func percentSavings(original, reduced int) float64 {
70
+ if original <= 0 {
71
+ return 0
72
+ }
73
+ return (float64(original-reduced) / float64(original)) * 100
74
+ }
75
+
76
+ func yesNo(v bool) string {
77
+ if v {
78
+ return "yes"
79
+ }
80
+ return "no"
81
+ }
82
+
83
+ func preservesOriginalSentences(original, reduced string) bool {
84
+ originalSentences := SplitSentences(original)
85
+ if len(originalSentences) == 0 {
86
+ return true
87
+ }
88
+
89
+ origSet := make(map[string]bool, len(originalSentences))
90
+ for _, s := range originalSentences {
91
+ origSet[strings.TrimSpace(s)] = true
92
+ }
93
+
94
+ for _, s := range SplitSentences(reduced) {
95
+ s = strings.TrimSpace(s)
96
+ if s == "" {
97
+ continue
98
+ }
99
+ if strings.Contains(s, "content truncated") {
100
+ continue
101
+ }
102
+ if !origSet[s] {
103
+ return false
104
+ }
105
+ }
106
+ return true
107
+ }
108
+
109
+ func episodeContextCost(episodes []*Episode) int {
110
+ total := 0
111
+ for _, ep := range episodes {
112
+ cost := ep.Tokens
113
+ if ep.Status != EpisodeActive && ep.SummaryTokens > 0 {
114
+ cost = ep.SummaryTokens
115
+ }
116
+ total += cost
117
+ }
118
+ return total
119
+ }
120
+
121
+ func TestContextSavings_TokenizerAccuracy(t *testing.T) {
122
+ useHeuristicTokenizerForTest(t)
123
+
124
+ bpeTokenizer, err := NewTiktokenTokenizer("gpt-4o")
125
+ if err != nil {
126
+ t.Fatalf("failed to create BPE tokenizer: %v", err)
127
+ }
128
+
129
+ goSnippet := `package main
130
+
131
+ import (
132
+ "fmt"
133
+ "strings"
134
+ )
135
+
136
+ func summarize(items []string) map[string]int {
137
+ result := map[string]int{}
138
+ for _, item := range items {
139
+ normalized := strings.TrimSpace(strings.ToLower(item))
140
+ if normalized == "" {
141
+ continue
142
+ }
143
+ result[normalized]++
144
+ }
145
+ return result
146
+ }
147
+
148
+ func main() {
149
+ data := []string{"alpha", "beta", "alpha", "gamma", "beta", "alpha"}
150
+ stats := summarize(data)
151
+ fmt.Println("stats:", stats)
152
+ }
153
+ `
154
+
155
+ jsonData := `{
156
+ "project": "recursive-llm-ts",
157
+ "version": "1.0.0",
158
+ "features": {
159
+ "lcm": true,
160
+ "observability": true,
161
+ "context_overflow": {
162
+ "enabled": true,
163
+ "strategy": "tfidf",
164
+ "max_reduction_attempts": 3
165
+ }
166
+ },
167
+ "items": [
168
+ {"id": 1, "name": "alpha", "priority": "high"},
169
+ {"id": 2, "name": "beta", "priority": "medium"},
170
+ {"id": 3, "name": "gamma", "priority": "low"}
171
+ ]
172
+ }`
173
+
174
+ cjkText := "这是一个固定的中文测试句子,用于衡量分词稳定性。日本語の固定テスト文を使ってトークン数を比較します。고정된 한국어 문장으로 토큰 계산 결과를 확인합니다。"
175
+
176
+ testCases := []struct {
177
+ name string
178
+ content string
179
+ }{
180
+ {name: "english_prose", content: fixedEnglishProse500Words()},
181
+ {name: "go_code", content: goSnippet},
182
+ {name: "json", content: jsonData},
183
+ {name: "cjk", content: cjkText},
184
+ }
185
+
186
+ t.Logf("Tokenizer accuracy comparison (heuristic default + direct BPE)")
187
+ for _, tc := range testCases {
188
+ heuristic := EstimateTokens(tc.content)
189
+ bpe := bpeTokenizer.CountTokens(tc.content)
190
+ chars := len([]rune(tc.content))
191
+ diffPct := percentDifference(bpe, heuristic)
192
+
193
+ t.Logf("type=%-14s chars=%5d heuristic=%5d bpe=%5d diff=%7.2f%%", tc.name, chars, heuristic, bpe, diffPct)
194
+
195
+ if heuristic <= 0 {
196
+ t.Fatalf("heuristic token count should be > 0 for %s", tc.name)
197
+ }
198
+ if bpe <= 0 {
199
+ t.Fatalf("BPE token count should be > 0 for %s", tc.name)
200
+ }
201
+ }
202
+ }
203
+
204
+ func TestContextSavings_FiveLevelEscalation(t *testing.T) {
205
+ useHeuristicTokenizerForTest(t)
206
+
207
+ original := generateDeterministicContext(5000)
208
+ originalTokens := EstimateTokens(original)
209
+
210
+ level3 := CompressContextTFIDF(original, 2000)
211
+ level4 := CompressContextTextRank(original, 2000)
212
+ level5 := TruncateText(original, TruncateTextParams{MaxTokens: 2000})
213
+
214
+ level3Tokens := EstimateTokens(level3)
215
+ level4Tokens := EstimateTokens(level4)
216
+ level5Tokens := EstimateTokens(level5)
217
+
218
+ t.Logf("Five-level non-LLM escalation comparison")
219
+ t.Logf("original_tokens=%d", originalTokens)
220
+ t.Logf("level=3 strategy=tfidf tokens=%d reduction=%6.2f%% sentence_preserved=%s", level3Tokens, percentSavings(originalTokens, level3Tokens), yesNo(preservesOriginalSentences(original, level3)))
221
+ t.Logf("level=4 strategy=textrank tokens=%d reduction=%6.2f%% sentence_preserved=%s", level4Tokens, percentSavings(originalTokens, level4Tokens), yesNo(preservesOriginalSentences(original, level4)))
222
+ t.Logf("level=5 strategy=truncate tokens=%d reduction=%6.2f%% sentence_preserved=%s", level5Tokens, percentSavings(originalTokens, level5Tokens), yesNo(preservesOriginalSentences(original, level5)))
223
+
224
+ if level3Tokens >= originalTokens {
225
+ t.Fatalf("expected TF-IDF to reduce tokens: original=%d level3=%d", originalTokens, level3Tokens)
226
+ }
227
+ if level4Tokens >= originalTokens {
228
+ t.Fatalf("expected TextRank to reduce tokens: original=%d level4=%d", originalTokens, level4Tokens)
229
+ }
230
+ if level5Tokens >= originalTokens {
231
+ t.Fatalf("expected Truncate to reduce tokens: original=%d level5=%d", originalTokens, level5Tokens)
232
+ }
233
+ }
234
+
235
+ func TestContextSavings_EpisodicMemoryBudget(t *testing.T) {
236
+ useHeuristicTokenizerForTest(t)
237
+
238
+ manager := NewEpisodeManager("ctx-savings-episodes", EpisodeConfig{
239
+ MaxEpisodeMessages: 5,
240
+ MaxEpisodeTokens: 500,
241
+ TopicChangeThreshold: 0.5,
242
+ AutoCompactAfterClose: false,
243
+ })
244
+
245
+ baseTime := time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)
246
+ rawTokens := 0
247
+ for i := 0; i < 50; i++ {
248
+ content := fmt.Sprintf("Message %d. %s", i+1, generateDeterministicContext(100))
249
+ tokens := EstimateTokens(content)
250
+ rawTokens += tokens
251
+
252
+ manager.AddMessage(&StoreMessage{
253
+ ID: fmt.Sprintf("msg_%03d", i+1),
254
+ Role: RoleUser,
255
+ Content: content,
256
+ Tokens: tokens,
257
+ Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
258
+ })
259
+ }
260
+
261
+ episodes := manager.GetAllEpisodes()
262
+ t.Logf("episodes_created=%d (expected around 10)", len(episodes))
263
+ if len(episodes) < 9 || len(episodes) > 11 {
264
+ t.Fatalf("expected around 10 episodes, got %d", len(episodes))
265
+ }
266
+
267
+ for i := 0; i < len(episodes)-1; i++ {
268
+ summary := fmt.Sprintf("Episode %d summary. %s", i+1, generateDeterministicContext(30))
269
+ if err := manager.CompactEpisode(episodes[i].ID, summary); err != nil {
270
+ t.Fatalf("failed to compact episode %s: %v", episodes[i].ID, err)
271
+ }
272
+ }
273
+
274
+ budgets := []int{200, 500, 1000, 2000}
275
+ for _, budget := range budgets {
276
+ selected := manager.GetEpisodesForContext(budget)
277
+ contextTokens := episodeContextCost(selected)
278
+ savings := percentSavings(rawTokens, contextTokens)
279
+ t.Logf("budget=%4d episodes=%2d context_tokens=%5d raw_tokens=%5d savings=%6.2f%%", budget, len(selected), contextTokens, rawTokens, savings)
280
+
281
+ if len(selected) == 0 {
282
+ t.Fatalf("expected at least one episode for budget %d", budget)
283
+ }
284
+ }
285
+ }
286
+
287
+ func TestContextSavings_AllStrategiesComparison(t *testing.T) {
288
+ useHeuristicTokenizerForTest(t)
289
+
290
+ original := generateDeterministicContext(35000)
291
+ originalTokens := EstimateTokens(original)
292
+ target := 16000
293
+
294
+ tfidf := CompressContextTFIDF(original, target)
295
+ textrank := CompressContextTextRank(original, target)
296
+ truncated := TruncateText(original, TruncateTextParams{MaxTokens: target})
297
+
298
+ results := []struct {
299
+ strategy string
300
+ content string
301
+ tokens int
302
+ preserved bool
303
+ }{
304
+ {strategy: "TF-IDF", content: tfidf, tokens: EstimateTokens(tfidf), preserved: preservesOriginalSentences(original, tfidf)},
305
+ {strategy: "TextRank", content: textrank, tokens: EstimateTokens(textrank), preserved: preservesOriginalSentences(original, textrank)},
306
+ {strategy: "Truncate", content: truncated, tokens: EstimateTokens(truncated), preserved: preservesOriginalSentences(original, truncated)},
307
+ }
308
+
309
+ t.Logf("strategy comparison for target=%d tokens (original=%d)", target, originalTokens)
310
+ t.Logf("strategy output_tokens reduction%% sentence_preserved")
311
+ for _, r := range results {
312
+ t.Logf("%-9s %12d %9.2f%% %s", r.strategy, r.tokens, percentSavings(originalTokens, r.tokens), yesNo(r.preserved))
313
+ if r.tokens >= originalTokens {
314
+ t.Fatalf("strategy %s did not reduce tokens: original=%d output=%d", r.strategy, originalTokens, r.tokens)
315
+ }
316
+ }
317
+ }
318
+
319
+ func TestContextSavings_CombinedPipeline(t *testing.T) {
320
+ useHeuristicTokenizerForTest(t)
321
+
322
+ manager := NewEpisodeManager("ctx-savings-pipeline", EpisodeConfig{
323
+ MaxEpisodeMessages: 10,
324
+ MaxEpisodeTokens: 1000000,
325
+ TopicChangeThreshold: 0.5,
326
+ AutoCompactAfterClose: false,
327
+ })
328
+
329
+ baseTime := time.Date(2024, 5, 10, 9, 30, 0, 0, time.UTC)
330
+ messageContentByID := make(map[string]string)
331
+ rawTokens := 0
332
+
333
+ for i := 0; i < 100; i++ {
334
+ id := fmt.Sprintf("pipeline_msg_%03d", i+1)
335
+ content := fmt.Sprintf("Message %d segment. %s", i+1, generateDeterministicContext(500))
336
+ tokens := EstimateTokens(content)
337
+ rawTokens += tokens
338
+ messageContentByID[id] = content
339
+
340
+ manager.AddMessage(&StoreMessage{
341
+ ID: id,
342
+ Role: RoleUser,
343
+ Content: content,
344
+ Tokens: tokens,
345
+ Timestamp: baseTime.Add(time.Duration(i) * time.Minute),
346
+ })
347
+ }
348
+
349
+ episodes := manager.GetAllEpisodes()
350
+ if len(episodes) != 10 {
351
+ t.Fatalf("expected 10 episodes from 100 messages with MaxEpisodeMessages=10, got %d", len(episodes))
352
+ }
353
+
354
+ afterGrouping := episodeContextCost(episodes)
355
+
356
+ for i := 0; i < len(episodes)-1; i++ {
357
+ ep := episodes[i]
358
+ var b strings.Builder
359
+ for _, msgID := range ep.MessageIDs {
360
+ b.WriteString(messageContentByID[msgID])
361
+ b.WriteString("\n")
362
+ }
363
+ summary := CompressContextTFIDF(b.String(), 300)
364
+ if err := manager.CompactEpisode(ep.ID, summary); err != nil {
365
+ t.Fatalf("failed to compact episode %s: %v", ep.ID, err)
366
+ }
367
+ }
368
+
369
+ afterCompaction := episodeContextCost(manager.GetAllEpisodes())
370
+ selected := manager.GetEpisodesForContext(8000)
371
+ afterBudgetSelection := episodeContextCost(selected)
372
+ totalSavings := percentSavings(rawTokens, afterBudgetSelection)
373
+
374
+ t.Logf("Combined pipeline results")
375
+ t.Logf("original_total_tokens=%d", rawTokens)
376
+ t.Logf("after_episodic_grouping=%d", afterGrouping)
377
+ t.Logf("after_compaction=%d", afterCompaction)
378
+ t.Logf("after_budget_selection=%d", afterBudgetSelection)
379
+ t.Logf("total_savings=%6.2f%%", totalSavings)
380
+
381
+ if afterCompaction >= afterGrouping {
382
+ t.Fatalf("expected compaction to reduce context tokens: grouped=%d compacted=%d", afterGrouping, afterCompaction)
383
+ }
384
+ if afterBudgetSelection > 8000 && len(selected) > 0 && selected[0].Status != EpisodeActive {
385
+ t.Fatalf("expected selected context <= budget when active episode is not the reason for overflow: selected=%d budget=8000", afterBudgetSelection)
386
+ }
387
+ }
@@ -0,0 +1,140 @@
1
+ package rlm
2
+
3
+ import (
4
+ "encoding/json"
5
+ "strings"
6
+ )
7
+
8
+ // ─── Shared JSON Extraction Utilities ───────────────────────────────────────
9
+ // Consolidated from structured.go and lcm_map.go to eliminate duplication.
10
+ // Both the structured output parser and the LLM-Map operator need to extract
11
+ // valid JSON from LLM responses that may contain markdown, explanatory text,
12
+ // or malformed output.
13
+
14
+ // StripMarkdownCodeBlock removes markdown ``` fencing from LLM output.
15
+ func StripMarkdownCodeBlock(s string) string {
16
+ s = strings.TrimSpace(s)
17
+ if strings.HasPrefix(s, "```") {
18
+ lines := strings.Split(s, "\n")
19
+ if len(lines) >= 3 {
20
+ s = strings.Join(lines[1:len(lines)-1], "\n")
21
+ s = strings.TrimSpace(s)
22
+ }
23
+ }
24
+ return s
25
+ }
26
+
27
+ // ExtractBalancedBraces finds the first balanced JSON object or array
28
+ // starting with startChar ('{' or '['). Handles nested structures,
29
+ // string escaping, and arbitrary depth.
30
+ // Returns the balanced substring or "" if no balanced match is found.
31
+ func ExtractBalancedBraces(s string, startChar byte) string {
32
+ endChar := byte('}')
33
+ if startChar == '[' {
34
+ endChar = ']'
35
+ }
36
+
37
+ depth := 0
38
+ inString := false
39
+ escape := false
40
+
41
+ for i := 0; i < len(s); i++ {
42
+ c := s[i]
43
+ if escape {
44
+ escape = false
45
+ continue
46
+ }
47
+ if c == '\\' && inString {
48
+ escape = true
49
+ continue
50
+ }
51
+ if c == '"' {
52
+ inString = !inString
53
+ continue
54
+ }
55
+ if inString {
56
+ continue
57
+ }
58
+ switch c {
59
+ case startChar:
60
+ depth++
61
+ case endChar:
62
+ depth--
63
+ if depth == 0 {
64
+ return s[:i+1]
65
+ }
66
+ }
67
+ }
68
+ return ""
69
+ }
70
+
71
+ // ExtractAllBalancedJSON finds all top-level JSON objects in a string by tracking
72
+ // balanced braces. Handles arbitrary nesting depth and string escaping.
73
+ // Used by structured output parsing which needs all candidates for schema matching.
74
+ func ExtractAllBalancedJSON(s string) []string {
75
+ var results []string
76
+ inString := false
77
+ escaped := false
78
+
79
+ for i := 0; i < len(s); i++ {
80
+ c := s[i]
81
+
82
+ if c == '{' && !inString {
83
+ // Found start of a potential JSON object; extract balanced match
84
+ balanced := ExtractBalancedBraces(s[i:], '{')
85
+ if balanced != "" {
86
+ results = append(results, balanced)
87
+ i += len(balanced) - 1 // skip past this object
88
+ continue
89
+ }
90
+ }
91
+
92
+ // Track string state in the outer scan (for skipping { inside strings)
93
+ if escaped {
94
+ escaped = false
95
+ continue
96
+ }
97
+ if c == '\\' && inString {
98
+ escaped = true
99
+ continue
100
+ }
101
+ if c == '"' {
102
+ inString = !inString
103
+ }
104
+ }
105
+
106
+ return results
107
+ }
108
+
109
+ // ExtractFirstJSON finds the first valid JSON object or array in a string.
110
+ // Tries full content first, then searches for { or [ and attempts balanced extraction.
111
+ // Returns nil if no valid JSON is found.
112
+ func ExtractFirstJSON(content string) json.RawMessage {
113
+ content = StripMarkdownCodeBlock(content)
114
+
115
+ // Try to parse the whole content as JSON
116
+ var js json.RawMessage
117
+ if err := json.Unmarshal([]byte(content), &js); err == nil {
118
+ return js
119
+ }
120
+
121
+ // Find first { or [ and try balanced extraction
122
+ for _, startChar := range []byte{'{', '['} {
123
+ idx := strings.IndexByte(content, startChar)
124
+ if idx >= 0 {
125
+ sub := content[idx:]
126
+ // Try full remainder first
127
+ if err := json.Unmarshal([]byte(sub), &js); err == nil {
128
+ return js
129
+ }
130
+ // Try balanced brace extraction
131
+ if balanced := ExtractBalancedBraces(sub, startChar); balanced != "" {
132
+ if err := json.Unmarshal([]byte(balanced), &js); err == nil {
133
+ return js
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ return nil
140
+ }