File size: 107,390 Bytes
9c7d451
 
 
 
 
 
eac2890
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
9c7d451
 
c1feb60
9c7d451
 
 
175746c
9c7d451
c1feb60
 
 
175746c
c1feb60
dd832fe
c1feb60
e4a41fa
 
 
 
 
 
 
 
 
c1feb60
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
9c7d451
 
e4a41fa
 
 
 
 
 
 
9c7d451
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf779b5
 
 
 
 
 
c1feb60
 
e4a41fa
 
 
 
 
c1feb60
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
 
 
 
c1feb60
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
e4a41fa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd832fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313fe01
 
dd832fe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
c1feb60
9c7d451
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
ac3b21b
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
e4a41fa
9c7d451
e4a41fa
 
 
9c7d451
 
e4a41fa
9c7d451
 
e4a41fa
9c7d451
c1feb60
e4a41fa
c1feb60
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bf779b5
9c7d451
 
 
 
 
 
 
 
 
 
 
e4a41fa
9c7d451
c1feb60
 
9c7d451
c1feb60
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
c1feb60
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
e4a41fa
9c7d451
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
9c7d451
c1feb60
 
 
 
 
 
 
 
9c7d451
 
 
bf779b5
 
9c7d451
 
 
 
 
c1feb60
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
9c7d451
 
c1feb60
e4a41fa
c1feb60
 
 
9c7d451
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
e4a41fa
 
bf779b5
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
e4a41fa
c1feb60
 
 
 
e4a41fa
c1feb60
 
 
e4a41fa
 
c1feb60
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
 
c1feb60
 
9c7d451
 
 
 
 
c1feb60
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
9c7d451
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
ac3b21b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
c1feb60
dd832fe
9c7d451
c1feb60
9c7d451
 
c1feb60
 
 
dd832fe
 
 
 
c1feb60
dd832fe
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
dd832fe
c1feb60
 
 
9c7d451
c1feb60
 
 
 
9c7d451
 
dd832fe
313fe01
 
 
dd832fe
c1feb60
 
 
 
 
 
 
9c7d451
 
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
dd832fe
 
 
 
 
 
 
 
c1feb60
 
dd832fe
c1feb60
 
 
 
 
 
dd832fe
c1feb60
 
 
 
 
 
dd832fe
c1feb60
9c7d451
 
c1feb60
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
9c7d451
 
 
 
eac2890
 
 
9c7d451
eac2890
9c7d451
c1feb60
9c7d451
 
 
 
 
 
eac2890
 
 
9c7d451
 
 
c1feb60
 
 
9c7d451
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
9c7d451
 
 
c1feb60
 
 
 
 
 
 
 
9c7d451
c1feb60
9c7d451
 
 
c1feb60
 
e4a41fa
c1feb60
9c7d451
c1feb60
 
 
 
 
 
9c7d451
 
 
 
e4a41fa
 
 
9c7d451
 
 
c1feb60
 
9c7d451
 
 
bf779b5
9c7d451
e4a41fa
 
 
bf779b5
9c7d451
 
c1feb60
bf779b5
9c7d451
 
c1feb60
9c7d451
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
dd832fe
 
 
313fe01
 
 
dd832fe
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e4a41fa
c1feb60
 
 
 
 
 
 
 
e4a41fa
 
 
c1feb60
 
 
9c7d451
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
c1feb60
 
 
 
 
 
 
 
 
 
9c7d451
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd832fe
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dd832fe
 
c1feb60
 
 
dd832fe
 
c1feb60
 
9c7d451
 
 
 
 
 
c1feb60
 
9c7d451
c1feb60
 
 
 
 
 
 
 
 
 
 
e4a41fa
 
 
 
 
c1feb60
9c7d451
 
 
 
 
c1feb60
 
 
 
 
9c7d451
e4a41fa
 
 
9c7d451
 
e4a41fa
 
 
9c7d451
 
e4a41fa
 
 
9c7d451
 
e4a41fa
 
 
9c7d451
 
 
 
 
 
 
 
 
c1feb60
 
9c7d451
 
 
 
c1feb60
9c7d451
c1feb60
 
 
 
 
9c7d451
c1feb60
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
9c7d451
22fa9eb
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
22fa9eb
c1feb60
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
c1feb60
 
9c7d451
 
 
c1feb60
9c7d451
c1feb60
 
 
 
 
9c7d451
c1feb60
 
9c7d451
 
 
c1feb60
9c7d451
 
 
c1feb60
9c7d451
 
 
c1feb60
 
 
9c7d451
c1feb60
 
 
 
 
 
 
9c7d451
c1feb60
9c7d451
c1feb60
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22fa9eb
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
22fa9eb
c1feb60
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
c1feb60
 
9c7d451
c1feb60
9c7d451
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
9c7d451
c1feb60
 
 
 
9c7d451
c1feb60
 
 
 
9c7d451
c1feb60
9c7d451
 
e4a41fa
 
 
9c7d451
 
 
 
c1feb60
 
e4a41fa
9c7d451
 
c1feb60
 
9c7d451
 
 
c1feb60
 
 
 
 
 
 
 
 
22fa9eb
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22fa9eb
c1feb60
 
 
 
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
c1feb60
 
9c7d451
c1feb60
9c7d451
c1feb60
9c7d451
 
c1feb60
9c7d451
 
 
c1feb60
 
 
 
 
 
 
9c7d451
c1feb60
 
 
9c7d451
 
 
 
 
 
 
 
 
 
 
c1feb60
 
 
 
 
 
 
 
9c7d451
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22fa9eb
 
 
 
 
 
 
 
 
9c7d451
 
 
 
 
 
 
22fa9eb
c1feb60
 
 
 
 
 
 
 
9c7d451
 
 
 
 
e4a41fa
9c7d451
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c1feb60
9c7d451
 
 
 
c1feb60
 
 
 
9c7d451
 
c1feb60
 
 
9c7d451
 
 
 
 
c1feb60
 
 
 
 
 
 
 
 
 
 
9c7d451
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
"""Smart statistical tool output compression for Headroom SDK.

This module provides intelligent JSON compression based on statistical analysis
rather than fixed rules. It analyzes data patterns and applies optimal compression
strategies to maximize token reduction while preserving important information.

SCOPE: SmartCrusher handles JSON arrays only. Non-JSON content (plain text,
search results, logs, code, diffs) passes through UNCHANGED.

TEXT COMPRESSION IS OPT-IN: For text-based content, Headroom provides standalone
utilities that applications can use explicitly:
- SearchCompressor: For grep/ripgrep output (file:line:content format)
- LogCompressor: For build/test logs (pytest, npm, cargo output)
- TextCompressor: For generic plain text with anchor preservation

Applications should decide when and how to use text compression based on their
specific needs. This design prevents lossy text compression from being applied
automatically, which could lose important context in coding tasks.

SCHEMA-PRESERVING: Output contains only items from the original array.
No wrappers, no generated text, no metadata keys. This ensures downstream
tools and parsers work unchanged.

Safe V1 Compression Recipe - Always keeps:
- First K items (default 3)
- Last K items (default 2)
- Error items (containing 'error', 'exception', 'failed', 'critical')
- Anomalous numeric items (> 2 std from mean)
- Items around detected change points
- Top-K by score if score field present
- Items with high relevance score to user query (via RelevanceScorer)

Key Features:
- RelevanceScorer: ML-powered or BM25-based relevance matching (replaces regex)
- Variance-based change point detection (preserve anomalies)
- Error item detection (never lose error messages)
- Pattern detection (time series, logs, search results)
- Strategy selection based on data characteristics
"""

from __future__ import annotations

import hashlib
import json
import logging
import math
import re
import statistics
import threading
from collections import Counter
from dataclasses import dataclass, field
from enum import Enum
from typing import Any

from ..cache.compression_feedback import CompressionFeedback, get_compression_feedback
from ..cache.compression_store import CompressionStore, get_compression_store
from ..config import CCRConfig, RelevanceScorerConfig, TransformResult
from ..relevance import RelevanceScorer, create_scorer
from ..telemetry import TelemetryCollector, ToolSignature, get_telemetry_collector
from ..telemetry.models import FieldSemantics
from ..telemetry.toin import ToolIntelligenceNetwork, get_toin
from ..tokenizer import Tokenizer
from ..utils import (
    compute_short_hash,
    create_tool_digest_marker,
    deep_copy_messages,
    safe_json_dumps,
    safe_json_loads,
)
from .base import Transform

logger = logging.getLogger(__name__)

# Legacy patterns for backwards compatibility (extract_query_anchors)
_UUID_PATTERN = re.compile(
    r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
)
_NUMERIC_ID_PATTERN = re.compile(r"\b\d{4,}\b")  # 4+ digit numbers (likely IDs)
_HOSTNAME_PATTERN = re.compile(
    r"\b[a-zA-Z0-9][-a-zA-Z0-9]*\.[a-zA-Z0-9][-a-zA-Z0-9]*(?:\.[a-zA-Z]{2,})?\b"
)
_QUOTED_STRING_PATTERN = re.compile(r"['\"]([^'\"]{1,50})['\"]")  # Short quoted strings
_EMAIL_PATTERN = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b")


def extract_query_anchors(text: str) -> set[str]:
    """Extract query anchors from user text (legacy regex-based method).

    DEPRECATED: Use RelevanceScorer.score_batch() for better semantic matching.

    Query anchors are identifiers or values that the user is likely searching for.
    When crushing tool outputs, items matching these anchors should be preserved.

    Extracts:
    - UUIDs (e.g., "550e8400-e29b-41d4-a716-446655440000")
    - Numeric IDs (4+ digits, e.g., "12345", "1001234")
    - Hostnames (e.g., "api.example.com", "server-01.prod")
    - Quoted strings (e.g., 'Alice', "error_code")
    - Email addresses (e.g., "user@example.com")

    Args:
        text: User message text to extract anchors from.

    Returns:
        Set of anchor strings (lowercased for case-insensitive matching).
    """
    anchors: set[str] = set()

    if not text:
        return anchors

    # UUIDs
    for match in _UUID_PATTERN.findall(text):
        anchors.add(match.lower())

    # Numeric IDs
    for match in _NUMERIC_ID_PATTERN.findall(text):
        anchors.add(match)

    # Hostnames
    for match in _HOSTNAME_PATTERN.findall(text):
        # Filter out common false positives
        if match.lower() not in ("e.g", "i.e", "etc."):
            anchors.add(match.lower())

    # Quoted strings
    for match in _QUOTED_STRING_PATTERN.findall(text):
        if len(match.strip()) >= 2:  # Skip very short matches
            anchors.add(match.lower())

    # Email addresses
    for match in _EMAIL_PATTERN.findall(text):
        anchors.add(match.lower())

    return anchors


def item_matches_anchors(item: dict, anchors: set[str]) -> bool:
    """Check if an item matches any query anchors (legacy method).

    DEPRECATED: Use RelevanceScorer for better matching.

    Args:
        item: Dictionary item from tool output.
        anchors: Set of anchor strings to match.

    Returns:
        True if any anchor is found in the item's string representation.
    """
    if not anchors:
        return False

    item_str = str(item).lower()
    return any(anchor in item_str for anchor in anchors)


def _hash_field_name(field_name: str) -> str:
    """Hash a field name to match TOIN's anonymized preserve_fields.

    TOIN stores field names as SHA256[:8] hashes for privacy.
    This function produces the same hash format.
    """
    return hashlib.sha256(field_name.encode()).hexdigest()[:8]


def _get_preserve_field_values(
    item: dict,
    preserve_field_hashes: list[str],
) -> list[tuple[str, Any]]:
    """Get values from item fields that match TOIN's preserve_field hashes.

    TOIN stores preserve_fields as hashed field names (SHA256[:8]).
    This function iterates over item fields, hashes each, and returns
    matching field names and values.

    Args:
        item: Dictionary item from tool output.
        preserve_field_hashes: List of SHA256[:8] hashed field names from TOIN.

    Returns:
        List of (field_name, value) tuples for fields that match.
    """
    if not preserve_field_hashes or not item:
        return []

    # Convert preserve_fields to set for O(1) lookup
    hash_set = set(preserve_field_hashes)

    matches = []
    for field_name, value in item.items():
        field_hash = _hash_field_name(field_name)
        if field_hash in hash_set:
            matches.append((field_name, value))

    return matches


def _item_has_preserve_field_match(
    item: dict,
    preserve_field_hashes: list[str],
    query_context: str,
) -> bool:
    """Check if item has a preserve_field value that matches query context.

    Args:
        item: Dictionary item from tool output.
        preserve_field_hashes: List of SHA256[:8] hashed field names from TOIN.
        query_context: User's query to match against field values.

    Returns:
        True if any preserve_field value matches the query context.
    """
    if not query_context:
        return False

    query_lower = query_context.lower()

    for _field_name, value in _get_preserve_field_values(item, preserve_field_hashes):
        if value is not None:
            value_str = str(value).lower()
            if value_str in query_lower or query_lower in value_str:
                return True

    return False


class CompressionStrategy(Enum):
    """Compression strategies based on data patterns."""

    NONE = "none"  # No compression needed
    SKIP = "skip"  # Explicitly skip - not safe to crush
    TIME_SERIES = "time_series"  # Keep change points, summarize stable
    CLUSTER_SAMPLE = "cluster"  # Dedupe similar items
    TOP_N = "top_n"  # Keep highest scored items
    SMART_SAMPLE = "smart_sample"  # Statistical sampling with constants


# =====================================================================
# STATISTICAL FIELD DETECTION (replaces hardcoded string patterns)
# =====================================================================
# Instead of matching field names like "id", "score", "error", we use
# statistical and structural properties of the data to detect field types.


def _is_uuid_format(value: str) -> bool:
    """Check if a string looks like a UUID (structural pattern)."""
    if not isinstance(value, str) or len(value) != 36:
        return False
    # UUID format: 8-4-4-4-12 hex chars
    parts = value.split("-")
    if len(parts) != 5:
        return False
    expected_lens = [8, 4, 4, 4, 12]
    for part, expected_len in zip(parts, expected_lens):
        if len(part) != expected_len:
            return False
        if not all(c in "0123456789abcdefABCDEF" for c in part):
            return False
    return True


def _calculate_string_entropy(s: str) -> float:
    """Calculate Shannon entropy of a string, normalized to [0, 1].

    High entropy (>0.7) suggests random/ID-like content.
    Low entropy (<0.3) suggests repetitive/predictable content.
    """
    if not s or len(s) < 2:
        return 0.0

    # Count character frequencies
    freq: dict[str, int] = {}
    for c in s:
        freq[c] = freq.get(c, 0) + 1

    # Calculate entropy
    import math

    entropy = 0.0
    length = len(s)
    for count in freq.values():
        p = count / length
        if p > 0:
            entropy -= p * math.log2(p)

    # Normalize by max possible entropy for this length
    max_entropy = math.log2(min(len(freq), length))
    if max_entropy > 0:
        return entropy / max_entropy
    return 0.0


def _detect_sequential_pattern(values: list[Any], check_order: bool = True) -> bool:
    """Detect if numeric values form a sequential pattern (like IDs: 1,2,3,...).

    Returns True if values appear to be auto-incrementing or sequential.

    Args:
        values: List of values to check.
        check_order: If True, also check if values are in ascending order in the array.
                     Score fields are often sorted descending, while IDs are ascending.
    """
    if len(values) < 5:
        return False

    # Get numeric values
    nums = []
    for v in values:
        if isinstance(v, (int, float)) and not isinstance(v, bool):
            nums.append(v)
        elif isinstance(v, str):
            try:
                nums.append(int(v))
            except ValueError:
                pass

    if len(nums) < 5:
        return False

    # Check if sorted values form a near-sequence
    sorted_nums = sorted(nums)
    diffs = [sorted_nums[i + 1] - sorted_nums[i] for i in range(len(sorted_nums) - 1)]

    if not diffs:
        return False

    # If most differences are 1 (or small constant), it's sequential
    avg_diff = sum(diffs) / len(diffs)
    if 0.5 <= avg_diff <= 2.0:
        # Check consistency - sequential IDs have consistent spacing
        consistent_count = sum(1 for d in diffs if 0.5 <= d <= 2.0)
        is_sequential = consistent_count / len(diffs) > 0.8

        # Additional check: IDs are typically in ASCENDING order in the array
        # Scores sorted by relevance are typically in DESCENDING order
        if check_order and is_sequential:
            # Check if original order is ascending (like IDs)
            ascending_count = sum(1 for i in range(len(nums) - 1) if nums[i] <= nums[i + 1])
            is_ascending = ascending_count / (len(nums) - 1) > 0.7
            return is_ascending  # Only flag as sequential if ascending (ID-like)

        return is_sequential

    return False


def _detect_id_field_statistically(stats: FieldStats, values: list[Any]) -> tuple[bool, float]:
    """Detect if a field is an ID field using statistical properties.

    Returns (is_id_field, confidence).

    ID fields have:
    - Very high uniqueness (>0.95)
    - Sequential numeric pattern OR UUID format OR high entropy strings
    """
    # Must have high uniqueness
    if stats.unique_ratio < 0.9:
        return False, 0.0

    confidence = 0.0

    # Check for UUID format (structural detection)
    if stats.field_type == "string":
        sample_values = [v for v in values[:20] if isinstance(v, str)]
        uuid_count = sum(1 for v in sample_values if _is_uuid_format(v))
        if sample_values and uuid_count / len(sample_values) > 0.8:
            return True, 0.95

        # Check for high entropy (random string IDs)
        if sample_values:
            avg_entropy = sum(_calculate_string_entropy(v) for v in sample_values) / len(
                sample_values
            )
            if avg_entropy > 0.7 and stats.unique_ratio > 0.95:
                confidence = 0.8
                return True, confidence

    # Check for sequential numeric pattern
    if stats.field_type == "numeric":
        if _detect_sequential_pattern(values) and stats.unique_ratio > 0.95:
            return True, 0.9

        # High uniqueness numeric with high range suggests ID
        if stats.min_val is not None and stats.max_val is not None:
            value_range = stats.max_val - stats.min_val
            if value_range > 0 and stats.unique_ratio > 0.95:
                return True, 0.85

    # Very high uniqueness alone is a signal (even without other patterns)
    if stats.unique_ratio > 0.98:
        return True, 0.7

    return False, 0.0


def _detect_score_field_statistically(stats: FieldStats, items: list[dict]) -> tuple[bool, float]:
    """Detect if a field is a score/ranking field using statistical properties.

    Returns (is_score_field, confidence).

    Score fields have:
    - Numeric type
    - Bounded range (0-1, 0-10, 0-100, or similar)
    - NOT sequential (unlike IDs)
    - Often the data appears sorted by this field (descending)
    """
    if stats.field_type != "numeric":
        return False, 0.0

    if stats.min_val is None or stats.max_val is None:
        return False, 0.0

    confidence = 0.0

    # Check for bounded range typical of scores
    stats.max_val - stats.min_val
    min_val, max_val = stats.min_val, stats.max_val

    # Common score ranges: [0,1], [0,10], [0,100], [-1,1], [0,5]
    is_bounded = False
    if 0 <= min_val <= 1 and 0 <= max_val <= 1:  # [0,1] range
        is_bounded = True
        confidence += 0.4
    elif 0 <= min_val <= 10 and 0 <= max_val <= 10:  # [0,10] range
        is_bounded = True
        confidence += 0.3
    elif 0 <= min_val <= 100 and 0 <= max_val <= 100:  # [0,100] range
        is_bounded = True
        confidence += 0.25
    elif -1 <= min_val and max_val <= 1:  # [-1,1] range
        is_bounded = True
        confidence += 0.35

    if not is_bounded:
        return False, 0.0

    # Should NOT be sequential (IDs are sequential, scores are not)
    sample_values = [item.get(stats.name) for item in items[:50] if stats.name in item]
    if _detect_sequential_pattern(sample_values):
        return False, 0.0

    # Check if data appears sorted by this field (descending = relevance sorted)
    # Filter out NaN/Inf which break comparisons
    values_in_order: list[float] = []
    for item in items:
        if stats.name in item:
            val = item.get(stats.name)
            if isinstance(val, (int, float)) and math.isfinite(val):
                values_in_order.append(float(val))
    if len(values_in_order) >= 5:
        # Check for descending sort
        descending_count = sum(
            1
            for i in range(len(values_in_order) - 1)
            if values_in_order[i] >= values_in_order[i + 1]
        )
        if descending_count / (len(values_in_order) - 1) > 0.7:
            confidence += 0.3

    # Score fields often have floating point values
    # Filter out NaN/Inf which can't be converted to int
    float_count = sum(
        1 for v in values_in_order[:20] if isinstance(v, float) and math.isfinite(v) and v != int(v)
    )
    if float_count > len(values_in_order[:20]) * 0.3:
        confidence += 0.1

    return confidence >= 0.4, min(confidence, 0.95)


def _detect_structural_outliers(items: list[dict]) -> list[int]:
    """Detect items that are structural outliers (error-like items).

    Instead of looking for "error" keywords, we detect:
    1. Items with extra fields that others don't have
    2. Items with rare status/state values
    3. Items with significantly different structure

    Returns indices of outlier items.
    """
    if len(items) < 5:
        return []

    outlier_indices: list[int] = []

    # 1. Detect items with extra fields
    # Find the "common" field set (fields present in >80% of items)
    field_counts: dict[str, int] = {}
    for item in items:
        if isinstance(item, dict):
            for key in item.keys():
                field_counts[key] = field_counts.get(key, 0) + 1

    n = len(items)
    common_fields = {k for k, v in field_counts.items() if v >= n * 0.8}
    rare_fields = {k for k, v in field_counts.items() if v < n * 0.2}

    for i, item in enumerate(items):
        if not isinstance(item, dict):
            continue

        item_fields = set(item.keys())

        # Has rare fields that most items don't have
        has_rare = bool(item_fields & rare_fields)
        if has_rare:
            outlier_indices.append(i)
            continue

    # 2. Detect rare status/state values
    # Find fields that look like status fields (low cardinality, categorical)
    status_outliers = _detect_rare_status_values(items, common_fields)
    outlier_indices.extend(status_outliers)

    return list(set(outlier_indices))


def _detect_rare_status_values(items: list[dict], common_fields: set[str]) -> list[int]:
    """Detect items with rare values in status-like fields.

    A status field has low cardinality (few distinct values).
    If 95%+ have the same value, items with different values are interesting.
    """
    outlier_indices: list[int] = []

    # Find potential status fields (low cardinality)
    for field_name in common_fields:
        values = [
            item.get(field_name) for item in items if isinstance(item, dict) and field_name in item
        ]

        # Skip if too few values or non-hashable
        try:
            unique_values = {str(v) for v in values if v is not None}
        except Exception:
            continue

        # Status field = low cardinality (2-10 distinct values)
        if not (2 <= len(unique_values) <= 10):
            continue

        # Count value frequencies
        value_counts: dict[str, int] = {}
        for v in values:
            key = str(v) if v is not None else "__none__"
            value_counts[key] = value_counts.get(key, 0) + 1

        # Find the dominant value
        if not value_counts:
            continue

        max_count = max(value_counts.values())
        total = len(values)

        # If one value dominates (>90%), others are interesting
        if max_count >= total * 0.9:
            dominant_value = max(value_counts.keys(), key=lambda k: value_counts[k])

            for i, item in enumerate(items):
                if not isinstance(item, dict) or field_name not in item:
                    continue
                item_value = str(item[field_name]) if item[field_name] is not None else "__none__"
                if item_value != dominant_value:
                    outlier_indices.append(i)

    return outlier_indices


# Error keywords for PRESERVATION guarantee (not crushability detection)
# This is for the quality guarantee: "ALL error items are ALWAYS preserved"
# regardless of how common they are. Used in _prioritize_indices().
_ERROR_KEYWORDS_FOR_PRESERVATION = frozenset(
    {
        "error",
        "exception",
        "failed",
        "failure",
        "critical",
        "fatal",
        "crash",
        "panic",
        "abort",
        "timeout",
        "denied",
        "rejected",
    }
)


def _detect_error_items_for_preservation(items: list[dict]) -> list[int]:
    """Detect items containing error keywords for PRESERVATION guarantee.

    This is NOT for crushability analysis - it's for ensuring ALL error items
    are retained during compression. The quality guarantee is that error items
    are NEVER dropped, even if errors are common in the dataset.

    Uses keywords because error semantics are well-defined across domains.
    """
    error_indices: list[int] = []

    for i, item in enumerate(items):
        if not isinstance(item, dict):
            continue

        # Serialize item to check all content
        try:
            item_str = json.dumps(item).lower()
        except Exception:
            continue

        # Check if any error keyword is present
        for keyword in _ERROR_KEYWORDS_FOR_PRESERVATION:
            if keyword in item_str:
                error_indices.append(i)
                break

    return error_indices


def _detect_items_by_learned_semantics(
    items: list[dict],
    field_semantics: dict[str, FieldSemantics],
) -> list[int]:
    """Detect items with important values based on learned field semantics.

    This is the TOIN Evolution integration - uses learned field semantic types
    to identify items that should be preserved during compression.

    Key insight: Instead of hardcoded patterns, we learn from user behavior
    which field values are actually important (e.g., error indicators, rare
    status values, identifiers that get queried).

    Args:
        items: List of items to analyze.
        field_semantics: Learned field semantics from TOIN (field_hash -> FieldSemantics).

    Returns:
        List of indices for items containing important values.
    """
    if not field_semantics or not items:
        return []

    important_indices: list[int] = []

    # Build a quick lookup for field_hash -> FieldSemantics
    # Pre-filter to fields with sufficient confidence
    confident_semantics = {
        fh: fs
        for fh, fs in field_semantics.items()
        if fs.confidence >= 0.3 and fs.inferred_type != "unknown"
    }

    if not confident_semantics:
        return []

    for i, item in enumerate(items):
        if not isinstance(item, dict):
            continue

        for field_name, value in item.items():
            # Hash the field name to match TOIN's format
            field_hash = hashlib.sha256(field_name.encode()).hexdigest()[:8]

            if field_hash not in confident_semantics:
                continue

            field_sem = confident_semantics[field_hash]

            # Hash the value to check importance
            if value is None:
                value_canonical = "null"
            elif isinstance(value, bool):
                value_canonical = "true" if value else "false"
            elif isinstance(value, (int, float)):
                value_canonical = str(value)
            elif isinstance(value, str):
                value_canonical = value
            elif isinstance(value, (list, dict)):
                try:
                    value_canonical = json.dumps(value, sort_keys=True, default=str)
                except (TypeError, ValueError):
                    value_canonical = str(value)
            else:
                value_canonical = str(value)

            value_hash = hashlib.sha256(value_canonical.encode()).hexdigest()[:8]

            # Check if this value is important based on learned semantics
            if field_sem.is_value_important(value_hash):
                important_indices.append(i)
                break  # Only need to mark item once

    return important_indices


@dataclass
class CrushabilityAnalysis:
    """Analysis of whether an array is safe to crush.

    The key insight: if we don't have a reliable SIGNAL to determine
    which items are important, we should NOT crush at all.

    Signals include:
    - Score/rank fields (search results)
    - Error keywords (logs)
    - Numeric anomalies (metrics)
    - Low uniqueness (repetitive data where sampling is representative)

    High variability + No signal = DON'T CRUSH
    """

    crushable: bool
    confidence: float  # 0.0 to 1.0
    reason: str
    signals_present: list[str] = field(default_factory=list)
    signals_absent: list[str] = field(default_factory=list)

    # Detailed metrics
    has_id_field: bool = False
    id_uniqueness: float = 0.0
    avg_string_uniqueness: float = 0.0
    has_score_field: bool = False
    error_item_count: int = 0
    anomaly_count: int = 0


@dataclass
class FieldStats:
    """Statistics for a single field across array items."""

    name: str
    field_type: str  # "numeric", "string", "boolean", "object", "array", "null"
    count: int
    unique_count: int
    unique_ratio: float
    is_constant: bool
    constant_value: Any = None

    # Numeric-specific stats
    min_val: float | None = None
    max_val: float | None = None
    mean_val: float | None = None
    variance: float | None = None
    change_points: list[int] = field(default_factory=list)

    # String-specific stats
    avg_length: float | None = None
    top_values: list[tuple[str, int]] = field(default_factory=list)


@dataclass
class ArrayAnalysis:
    """Complete analysis of an array."""

    item_count: int
    field_stats: dict[str, FieldStats]
    detected_pattern: str  # "time_series", "logs", "search_results", "generic"
    recommended_strategy: CompressionStrategy
    constant_fields: dict[str, Any]
    estimated_reduction: float
    crushability: CrushabilityAnalysis | None = None  # Whether it's safe to crush


@dataclass
class CompressionPlan:
    """Plan for how to compress an array."""

    strategy: CompressionStrategy
    keep_indices: list[int] = field(default_factory=list)
    constant_fields: dict[str, Any] = field(default_factory=dict)
    summary_ranges: list[tuple[int, int, dict]] = field(default_factory=list)
    cluster_field: str | None = None
    sort_field: str | None = None
    keep_count: int = 10


@dataclass
class CrushResult:
    """Result from SmartCrusher.crush() method.

    Used by ContentRouter when routing JSON arrays to SmartCrusher.
    """

    compressed: str
    original: str
    was_modified: bool
    strategy: str = "passthrough"


@dataclass
class SmartCrusherConfig:
    """Configuration for smart crusher.

    SCHEMA-PRESERVING: Output contains only items from the original array.
    No wrappers, no generated text, no metadata keys.
    """

    enabled: bool = True
    min_items_to_analyze: int = 5  # Don't analyze tiny arrays
    min_tokens_to_crush: int = 200  # Only crush if > N tokens
    variance_threshold: float = 2.0  # Std devs for change point detection
    uniqueness_threshold: float = 0.1  # Below this = nearly constant
    similarity_threshold: float = 0.8  # For clustering similar strings
    max_items_after_crush: int = 15  # Target max items in output
    preserve_change_points: bool = True
    factor_out_constants: bool = False  # Disabled - preserves original schema
    include_summaries: bool = False  # Disabled - no generated text

    # Feedback loop integration
    use_feedback_hints: bool = True  # Use learned patterns to adjust compression

    # LOW FIX #21: Make TOIN confidence threshold configurable
    # Minimum confidence required to apply TOIN recommendations
    toin_confidence_threshold: float = 0.5


class SmartAnalyzer:
    """Analyzes JSON arrays to determine optimal compression strategy."""

    def __init__(self, config: SmartCrusherConfig | None = None):
        self.config = config or SmartCrusherConfig()

    def analyze_array(self, items: list[dict]) -> ArrayAnalysis:
        """Perform complete statistical analysis of an array."""
        if not items or not isinstance(items[0], dict):
            return ArrayAnalysis(
                item_count=len(items) if items else 0,
                field_stats={},
                detected_pattern="generic",
                recommended_strategy=CompressionStrategy.NONE,
                constant_fields={},
                estimated_reduction=0.0,
            )

        # Analyze each field
        field_stats = {}
        all_keys: set[str] = set()
        for item in items:
            if isinstance(item, dict):
                all_keys.update(item.keys())

        for key in all_keys:
            field_stats[key] = self._analyze_field(key, items)

        # Detect pattern
        pattern = self._detect_pattern(field_stats, items)

        # Extract constants
        constant_fields = {k: v.constant_value for k, v in field_stats.items() if v.is_constant}

        # CRITICAL: Analyze crushability BEFORE selecting strategy
        crushability = self.analyze_crushability(items, field_stats)

        # Select strategy (respects crushability)
        strategy = self._select_strategy(field_stats, pattern, len(items), crushability)

        # Estimate reduction (0 if not crushable)
        if strategy == CompressionStrategy.SKIP:
            reduction = 0.0
        else:
            reduction = self._estimate_reduction(field_stats, strategy, len(items))

        return ArrayAnalysis(
            item_count=len(items),
            field_stats=field_stats,
            detected_pattern=pattern,
            recommended_strategy=strategy,
            constant_fields=constant_fields,
            estimated_reduction=reduction,
            crushability=crushability,
        )

    def _analyze_field(self, key: str, items: list[dict]) -> FieldStats:
        """Analyze a single field across all items."""
        values = [item.get(key) for item in items if isinstance(item, dict)]
        non_null_values = [v for v in values if v is not None]

        if not non_null_values:
            return FieldStats(
                name=key,
                field_type="null",
                count=len(values),
                unique_count=0,
                unique_ratio=0.0,
                is_constant=True,
                constant_value=None,
            )

        # Determine type from first non-null value
        first_val = non_null_values[0]
        if isinstance(first_val, bool):
            field_type = "boolean"
        elif isinstance(first_val, (int, float)):
            field_type = "numeric"
        elif isinstance(first_val, str):
            field_type = "string"
        elif isinstance(first_val, dict):
            field_type = "object"
        elif isinstance(first_val, list):
            field_type = "array"
        else:
            field_type = "unknown"

        # Compute uniqueness
        str_values = [str(v) for v in values]
        unique_values = set(str_values)
        unique_count = len(unique_values)
        unique_ratio = unique_count / len(values) if values else 0

        # Check if constant
        is_constant = unique_count == 1
        constant_value = non_null_values[0] if is_constant else None

        stats = FieldStats(
            name=key,
            field_type=field_type,
            count=len(values),
            unique_count=unique_count,
            unique_ratio=unique_ratio,
            is_constant=is_constant,
            constant_value=constant_value,
        )

        # Numeric-specific analysis
        if field_type == "numeric":
            # Filter out NaN and Infinity which break statistics functions
            nums = [v for v in non_null_values if isinstance(v, (int, float)) and math.isfinite(v)]
            if nums:
                try:
                    stats.min_val = min(nums)
                    stats.max_val = max(nums)
                    stats.mean_val = statistics.mean(nums)
                    stats.variance = statistics.variance(nums) if len(nums) > 1 else 0
                    stats.change_points = self._detect_change_points(nums)
                except (OverflowError, ValueError):
                    # Extreme values that overflow - skip detailed statistics
                    stats.min_val = None
                    stats.max_val = None
                    stats.mean_val = None
                    stats.variance = 0
                    stats.change_points = []

        # String-specific analysis
        elif field_type == "string":
            strs = [v for v in non_null_values if isinstance(v, str)]
            if strs:
                stats.avg_length = statistics.mean(len(s) for s in strs)
                stats.top_values = Counter(strs).most_common(5)

        return stats

    def _detect_change_points(self, values: list[float], window: int = 5) -> list[int]:
        """Detect indices where values change significantly."""
        if len(values) < window * 2:
            return []

        change_points = []

        # Calculate overall statistics
        overall_std = statistics.stdev(values) if len(values) > 1 else 0
        if overall_std == 0:
            return []

        threshold = self.config.variance_threshold * overall_std

        # Sliding window comparison
        for i in range(window, len(values) - window):
            before_mean = statistics.mean(values[i - window : i])
            after_mean = statistics.mean(values[i : i + window])

            if abs(after_mean - before_mean) > threshold:
                change_points.append(i)

        # Deduplicate nearby change points
        if change_points:
            deduped = [change_points[0]]
            for cp in change_points[1:]:
                if cp - deduped[-1] > window:
                    deduped.append(cp)
            return deduped

        return []

    def _detect_pattern(self, field_stats: dict[str, FieldStats], items: list[dict]) -> str:
        """Detect the data pattern using STATISTICAL analysis (no hardcoded field names).

        Pattern detection:
        - TIME_SERIES: Has a temporal field (detected by value format) + numeric variance
        - LOGS: Has a high-cardinality string field + low-cardinality categorical field
        - SEARCH_RESULTS: Has a score-like field (bounded numeric, possibly sorted)
        - GENERIC: Default
        """
        # Check for time series pattern using STRUCTURAL detection
        has_timestamp = self._detect_temporal_field(field_stats, items)

        numeric_fields = [k for k, v in field_stats.items() if v.field_type == "numeric"]
        has_numeric_with_variance = any(
            (field_stats[k].variance is not None and (field_stats[k].variance or 0) > 0)
            for k in numeric_fields
        )

        if has_timestamp and has_numeric_with_variance:
            return "time_series"

        # Check for logs pattern using STATISTICAL detection
        # Logs have: high-cardinality string (message) + low-cardinality categorical (level)
        has_message_like = False
        has_level_like = False

        for _name, stats in field_stats.items():
            if stats.field_type == "string":
                # High-cardinality string = likely message field
                if stats.unique_ratio > 0.5 and stats.avg_length and stats.avg_length > 20:
                    has_message_like = True
                # Low-cardinality string = likely level/status field
                elif stats.unique_ratio < 0.1 and 2 <= stats.unique_count <= 10:
                    has_level_like = True

        if has_message_like and has_level_like:
            return "logs"

        # Check for search results pattern using STATISTICAL score detection
        for _name, stats in field_stats.items():
            is_score, confidence = _detect_score_field_statistically(stats, items)
            if is_score and confidence >= 0.5:
                return "search_results"

        return "generic"

    def _detect_temporal_field(self, field_stats: dict[str, FieldStats], items: list[dict]) -> bool:
        """Detect if any field contains temporal values (dates/timestamps).

        Uses STRUCTURAL detection based on value format, not field names.
        """
        # Check string fields for ISO 8601 patterns
        iso_datetime_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}")
        iso_date_pattern = re.compile(r"^\d{4}-\d{2}-\d{2}$")

        for name, stats in field_stats.items():
            if stats.field_type == "string":
                # Sample some values
                sample_values = [
                    item.get(name) for item in items[:10] if isinstance(item.get(name), str)
                ]
                if sample_values:
                    # Check if values look like dates/datetimes
                    iso_count = sum(
                        1
                        for v in sample_values
                        if v is not None
                        and (iso_datetime_pattern.match(v) or iso_date_pattern.match(v))
                    )
                    if iso_count / len(sample_values) > 0.5:
                        return True

            # Check numeric fields for Unix timestamp range
            elif stats.field_type == "numeric":
                if stats.min_val and stats.max_val:
                    # Unix timestamps (seconds): 1000000000 to 2000000000 (roughly 2001-2033)
                    # Unix timestamps (milliseconds): 1000000000000 to 2000000000000
                    is_unix_seconds = 1000000000 <= stats.min_val <= 2000000000
                    is_unix_millis = 1000000000000 <= stats.min_val <= 2000000000000
                    if is_unix_seconds or is_unix_millis:
                        return True

        return False

    def analyze_crushability(
        self,
        items: list[dict],
        field_stats: dict[str, FieldStats],
    ) -> CrushabilityAnalysis:
        """Analyze whether it's SAFE to crush this array.

        The key insight: High variability + No importance signal = DON'T CRUSH.

        We use STATISTICAL detection (no hardcoded field names):
        1. ID fields detected by uniqueness + sequential/UUID/entropy patterns
        2. Score fields detected by bounded range + sorted order
        3. Error items detected by structural outliers (rare fields, rare status values)
        4. Numeric anomalies (importance signal)
        5. Low uniqueness (safe to sample)

        Returns:
            CrushabilityAnalysis with decision and reasoning.
        """
        signals_present: list[str] = []
        signals_absent: list[str] = []

        # 1. Detect ID field STATISTICALLY (no hardcoded field names)
        id_field_name = None
        id_uniqueness = 0.0
        id_confidence = 0.0
        for name, stats in field_stats.items():
            values = [item.get(name) for item in items if isinstance(item, dict)]
            is_id, confidence = _detect_id_field_statistically(stats, values)
            if is_id and confidence > id_confidence:
                id_field_name = name
                id_uniqueness = stats.unique_ratio
                id_confidence = confidence

        has_id_field = id_field_name is not None and id_confidence >= 0.7

        # 2. Detect score/rank field STATISTICALLY (no hardcoded field names)
        has_score_field = False
        for name, stats in field_stats.items():
            is_score, confidence = _detect_score_field_statistically(stats, items)
            if is_score:
                has_score_field = True
                signals_present.append(f"score_field:{name}(conf={confidence:.2f})")
                break
        if not has_score_field:
            signals_absent.append("score_field")

        # 3. Detect error items via STRUCTURAL OUTLIERS (no hardcoded keywords)
        outlier_indices = _detect_structural_outliers(items)
        structural_outlier_count = len(outlier_indices)

        if structural_outlier_count > 0:
            signals_present.append(f"structural_outliers:{structural_outlier_count}")
        else:
            signals_absent.append("structural_outliers")

        # 3b. Also detect errors via keywords in content (for log/message-style data)
        # This catches errors that are in the content but not structural outliers
        # (e.g., Slack messages where error is in the text field)
        error_keyword_indices = _detect_error_items_for_preservation(items)
        keyword_error_count = len(error_keyword_indices)

        if keyword_error_count > 0 and structural_outlier_count == 0:
            signals_present.append(f"error_keywords:{keyword_error_count}")

        # Combined error count for crushability analysis
        error_count = max(structural_outlier_count, keyword_error_count)

        # 4. Count numeric anomalies (importance signal)
        anomaly_count = 0
        anomaly_indices: set[int] = set()
        for stats in field_stats.values():
            if stats.field_type == "numeric" and stats.mean_val is not None and stats.variance:
                std = stats.variance**0.5
                if std > 0:
                    threshold = self.config.variance_threshold * std
                    for i, item in enumerate(items):
                        val = item.get(stats.name)
                        if isinstance(val, (int, float)):
                            if abs(val - stats.mean_val) > threshold:
                                anomaly_indices.add(i)

        anomaly_count = len(anomaly_indices)
        if anomaly_count > 0:
            signals_present.append(f"anomalies:{anomaly_count}")
        else:
            signals_absent.append("anomalies")

        # 5. Compute average string uniqueness (EXCLUDING statistically-detected ID fields)
        string_stats = [
            s for s in field_stats.values() if s.field_type == "string" and s.name != id_field_name
        ]
        avg_string_uniqueness = (
            statistics.mean(s.unique_ratio for s in string_stats) if string_stats else 0.0
        )

        # Compute uniqueness of non-ID numeric fields
        non_id_numeric_stats = [
            s for s in field_stats.values() if s.field_type == "numeric" and s.name != id_field_name
        ]
        avg_non_id_numeric_uniqueness = (
            statistics.mean(s.unique_ratio for s in non_id_numeric_stats)
            if non_id_numeric_stats
            else 0.0
        )

        # Combined uniqueness metric (including ID fields)
        max_uniqueness = max(avg_string_uniqueness, id_uniqueness, 0.0)

        # Non-ID content uniqueness (for detecting repetitive content with unique IDs)
        non_id_content_uniqueness = max(avg_string_uniqueness, avg_non_id_numeric_uniqueness)

        # 6. Check for change points (importance signal for time series)
        has_change_points = any(
            stats.change_points for stats in field_stats.values() if stats.field_type == "numeric"
        )
        if has_change_points:
            signals_present.append("change_points")

        # DECISION LOGIC
        has_any_signal = len(signals_present) > 0

        # Case 0: Repetitive content with unique IDs
        # If all non-ID fields are nearly constant, data is safe to sample
        # even if there's a unique ID field (e.g., status="success" for all items)
        if non_id_content_uniqueness < 0.1 and has_id_field:
            signals_present.append("repetitive_content")
            return CrushabilityAnalysis(
                crushable=True,
                confidence=0.85,
                reason="repetitive_content_with_ids",
                signals_present=signals_present,
                signals_absent=signals_absent,
                has_id_field=has_id_field,
                id_uniqueness=id_uniqueness,
                avg_string_uniqueness=avg_string_uniqueness,
                has_score_field=has_score_field,
                error_item_count=error_count,
                anomaly_count=anomaly_count,
            )

        # Case 1: Low uniqueness - safe to sample (data is repetitive)
        if max_uniqueness < 0.3:
            return CrushabilityAnalysis(
                crushable=True,
                confidence=0.9,
                reason="low_uniqueness_safe_to_sample",
                signals_present=signals_present,
                signals_absent=signals_absent,
                has_id_field=has_id_field,
                id_uniqueness=id_uniqueness,
                avg_string_uniqueness=avg_string_uniqueness,
                has_score_field=has_score_field,
                error_item_count=error_count,
                anomaly_count=anomaly_count,
            )

        # Case 2: High uniqueness + ID field + NO signal = DON'T CRUSH
        # This is the critical case: DB results, file listings, user lists
        if has_id_field and max_uniqueness > 0.8 and not has_any_signal:
            return CrushabilityAnalysis(
                crushable=False,
                confidence=0.85,
                reason="unique_entities_no_signal",
                signals_present=signals_present,
                signals_absent=signals_absent,
                has_id_field=has_id_field,
                id_uniqueness=id_uniqueness,
                avg_string_uniqueness=avg_string_uniqueness,
                has_score_field=has_score_field,
                error_item_count=error_count,
                anomaly_count=anomaly_count,
            )

        # Case 3: High uniqueness + has signal = CRUSH using signal
        if max_uniqueness > 0.8 and has_any_signal:
            return CrushabilityAnalysis(
                crushable=True,
                confidence=0.7,
                reason="unique_entities_with_signal",
                signals_present=signals_present,
                signals_absent=signals_absent,
                has_id_field=has_id_field,
                id_uniqueness=id_uniqueness,
                avg_string_uniqueness=avg_string_uniqueness,
                has_score_field=has_score_field,
                error_item_count=error_count,
                anomaly_count=anomaly_count,
            )

        # Case 4: Medium uniqueness + no signal = be cautious, don't crush
        if not has_any_signal:
            return CrushabilityAnalysis(
                crushable=False,
                confidence=0.6,
                reason="medium_uniqueness_no_signal",
                signals_present=signals_present,
                signals_absent=signals_absent,
                has_id_field=has_id_field,
                id_uniqueness=id_uniqueness,
                avg_string_uniqueness=avg_string_uniqueness,
                has_score_field=has_score_field,
                error_item_count=error_count,
                anomaly_count=anomaly_count,
            )

        # Case 5: Medium uniqueness + has signal = crush with caution
        return CrushabilityAnalysis(
            crushable=True,
            confidence=0.5,
            reason="medium_uniqueness_with_signal",
            signals_present=signals_present,
            signals_absent=signals_absent,
            has_id_field=has_id_field,
            id_uniqueness=id_uniqueness,
            avg_string_uniqueness=avg_string_uniqueness,
            has_score_field=has_score_field,
            error_item_count=error_count,
            anomaly_count=anomaly_count,
        )

    def _select_strategy(
        self,
        field_stats: dict[str, FieldStats],
        pattern: str,
        item_count: int,
        crushability: CrushabilityAnalysis | None = None,
    ) -> CompressionStrategy:
        """Select optimal compression strategy based on analysis."""
        if item_count < self.config.min_items_to_analyze:
            return CompressionStrategy.NONE

        # CRITICAL: Check crushability first
        if crushability is not None and not crushability.crushable:
            return CompressionStrategy.SKIP

        if pattern == "time_series":
            # Check if there are change points worth preserving
            numeric_fields = [v for v in field_stats.values() if v.field_type == "numeric"]
            has_change_points = any(f.change_points for f in numeric_fields)
            if has_change_points:
                return CompressionStrategy.TIME_SERIES

        if pattern == "logs":
            # Check if messages are clusterable (low-medium uniqueness)
            message_field = next(
                (v for k, v in field_stats.items() if "message" in k.lower()), None
            )
            if message_field and message_field.unique_ratio < 0.5:
                return CompressionStrategy.CLUSTER_SAMPLE

        if pattern == "search_results":
            return CompressionStrategy.TOP_N

        # Default: smart sampling
        return CompressionStrategy.SMART_SAMPLE

    def _estimate_reduction(
        self, field_stats: dict[str, FieldStats], strategy: CompressionStrategy, item_count: int
    ) -> float:
        """Estimate token reduction ratio."""
        if strategy == CompressionStrategy.NONE:
            return 0.0

        # Count constant fields (will be factored out)
        constant_ratio = sum(1 for v in field_stats.values() if v.is_constant) / len(field_stats)

        # Estimate based on strategy
        base_reduction = {
            CompressionStrategy.TIME_SERIES: 0.7,
            CompressionStrategy.CLUSTER_SAMPLE: 0.8,
            CompressionStrategy.TOP_N: 0.6,
            CompressionStrategy.SMART_SAMPLE: 0.5,
        }.get(strategy, 0.3)

        # Adjust for constants
        reduction = base_reduction + (constant_ratio * 0.2)

        return min(reduction, 0.95)


class SmartCrusher(Transform):
    """
    Intelligent tool output compression using statistical analysis.

    Unlike fixed-rule crushing, SmartCrusher:
    1. Analyzes JSON structure and computes field statistics
    2. Detects data patterns (time series, logs, search results)
    3. Identifies constant fields to factor out
    4. Finds change points in numeric data to preserve
    5. Applies optimal compression strategy per data type
    6. Uses RelevanceScorer for semantic matching of user queries

    This results in higher compression with lower information loss.
    """

    name = "smart_crusher"

    def __init__(
        self,
        config: SmartCrusherConfig | None = None,
        relevance_config: RelevanceScorerConfig | None = None,
        scorer: RelevanceScorer | None = None,
        ccr_config: CCRConfig | None = None,
    ):
        self.config = config or SmartCrusherConfig()
        self.analyzer = SmartAnalyzer(self.config)

        # CCR (Compress-Cache-Retrieve) configuration
        # When no ccr_config provided, default to caching enabled but markers disabled
        # This maintains backward compatibility - callers must opt-in to markers
        if ccr_config is None:
            self._ccr_config = CCRConfig(
                enabled=True,  # Still cache for potential retrieval
                inject_retrieval_marker=False,  # Don't break JSON parsing by default
            )
        else:
            self._ccr_config = ccr_config
        self._compression_store: CompressionStore | None = None

        # Feedback loop for learning compression patterns
        self._feedback: CompressionFeedback | None = None

        # CRITICAL FIX: Lock for thread-safe lazy initialization
        # Without this, multiple threads could call _get_* methods simultaneously
        # and potentially create redundant initialization calls.
        self._lazy_init_lock = threading.Lock()

        # Initialize relevance scorer
        if scorer is not None:
            self._scorer = scorer
        else:
            rel_config = relevance_config or RelevanceScorerConfig()
            # Build kwargs based on tier - BM25 params only apply to bm25 tier
            scorer_kwargs = {}
            if rel_config.tier == "bm25":
                scorer_kwargs = {"k1": rel_config.bm25_k1, "b": rel_config.bm25_b}
            elif rel_config.tier == "hybrid":
                scorer_kwargs = {
                    "alpha": rel_config.hybrid_alpha,
                    "adaptive": rel_config.adaptive_alpha,
                }
            self._scorer = create_scorer(tier=rel_config.tier, **scorer_kwargs)
        # Use threshold from config, or default from RelevanceScorerConfig
        rel_cfg = relevance_config or RelevanceScorerConfig()
        self._relevance_threshold = rel_cfg.relevance_threshold

        # NOTE: Error detection now uses structural outlier detection (_detect_structural_outliers)
        # instead of hardcoded keywords. This scales to any data domain.

    def crush(self, content: str, query: str = "") -> CrushResult:
        """Crush content string directly (for use by ContentRouter).

        This is a simplified interface for compressing a single content string,
        used by ContentRouter when routing JSON arrays to SmartCrusher.

        Args:
            content: JSON string content to compress.
            query: Query context for relevance-based compression.

        Returns:
            CrushResult with compressed content and metadata.
        """
        compressed, was_modified, analysis_info = self._smart_crush_content(
            content, query_context=query
        )
        return CrushResult(
            compressed=compressed,
            original=content,
            was_modified=was_modified,
            strategy=analysis_info or "passthrough",
        )

    def _get_compression_store(self) -> CompressionStore:
        """Get the compression store for CCR (lazy initialization).

        CRITICAL FIX: Thread-safe double-checked locking pattern.
        """
        if self._compression_store is None:
            with self._lazy_init_lock:
                # Double-check after acquiring lock
                if self._compression_store is None:
                    self._compression_store = get_compression_store(
                        max_entries=self._ccr_config.store_max_entries,
                        default_ttl=self._ccr_config.store_ttl_seconds,
                    )
        return self._compression_store

    def _get_feedback(self) -> CompressionFeedback:
        """Get the feedback analyzer (lazy initialization).

        CRITICAL FIX: Thread-safe double-checked locking pattern.
        """
        if self._feedback is None:
            with self._lazy_init_lock:
                if self._feedback is None:
                    self._feedback = get_compression_feedback()
        return self._feedback

    def _get_telemetry(self) -> TelemetryCollector:
        """Get the telemetry collector (lazy initialization).

        CRITICAL FIX: Thread-safe double-checked locking pattern.
        """
        # Use getattr to avoid hasattr race condition
        if getattr(self, "_telemetry", None) is None:
            with self._lazy_init_lock:
                if getattr(self, "_telemetry", None) is None:
                    self._telemetry = get_telemetry_collector()
        return self._telemetry

    def _get_toin(self) -> ToolIntelligenceNetwork:
        """Get the TOIN instance (lazy initialization).

        CRITICAL FIX: Thread-safe double-checked locking pattern.
        """
        # Use getattr to avoid hasattr race condition
        if getattr(self, "_toin", None) is None:
            with self._lazy_init_lock:
                if getattr(self, "_toin", None) is None:
                    self._toin = get_toin()
        return self._toin

    def _record_telemetry(
        self,
        items: list[dict],
        result: list,
        analysis: ArrayAnalysis,
        plan: CompressionPlan,
        tool_name: str | None = None,
    ) -> None:
        """Record compression telemetry for the data flywheel.

        This collects anonymized statistics about compression patterns to
        enable cross-user learning and improve compression over time.

        Privacy guarantees:
        - No actual data values are stored
        - Tool names can be hashed
        - Only structural patterns are captured
        """
        try:
            telemetry = self._get_telemetry()

            # Calculate what was kept
            kept_first_n = sum(1 for i in plan.keep_indices if i < 3)
            kept_last_n = sum(1 for i in plan.keep_indices if i >= len(items) - 2)

            # Count error items in result
            error_indices = set(_detect_error_items_for_preservation(items))
            kept_errors = sum(1 for i in plan.keep_indices if i in error_indices)

            # Count anomalies (approximate from change points)
            anomaly_count = 0
            for stats in analysis.field_stats.values():
                if stats.change_points:
                    anomaly_count += len(stats.change_points)
            kept_anomalies = min(anomaly_count, len(plan.keep_indices))

            # Crushability info
            crushability_score = None
            crushability_reason = None
            if analysis.crushability:
                crushability_score = analysis.crushability.confidence
                crushability_reason = analysis.crushability.reason

            # Record the event
            telemetry.record_compression(
                items=items[:100],  # Sample for structure analysis
                original_count=len(items),
                compressed_count=len(result),
                original_tokens=0,  # Not available here
                compressed_tokens=0,  # Not available here
                strategy=analysis.recommended_strategy.value,
                tool_name=tool_name,
                strategy_reason=analysis.detected_pattern,
                crushability_score=crushability_score,
                crushability_reason=crushability_reason,
                kept_first_n=kept_first_n,
                kept_last_n=kept_last_n,
                kept_errors=kept_errors,
                kept_anomalies=kept_anomalies,
                kept_by_relevance=0,  # Would need to track separately
                kept_by_score=0,  # Would need to track separately
            )
        except Exception:
            # Telemetry should never break compression
            pass

    def _prioritize_indices(
        self,
        keep_indices: set[int],
        items: list[dict],
        n: int,
        analysis: ArrayAnalysis | None = None,
        max_items: int | None = None,
        field_semantics: dict[str, FieldSemantics] | None = None,
    ) -> set[int]:
        """Prioritize indices when we exceed max_items, ALWAYS keeping critical items.

        Priority order:
        1. ALL error items (non-negotiable) - items with error keywords
        2. ALL structural outliers (non-negotiable) - items with rare fields/status values
        3. ALL numeric anomalies (non-negotiable) - e.g., unusual values like 999999
        4. ALL items with important values (learned) - TOIN field semantics
        5. First 3 items (context)
        6. Last 2 items (context)
        7. Other important items by index order

        Uses BOTH keyword detection (for preservation guarantee) AND statistical detection,
        PLUS learned field semantics from TOIN for zero-latency signal detection.

        HIGH FIX: Note that this function may return MORE items than effective_max
        when critical items (errors, outliers, anomalies) exceed the limit. This is
        intentional to preserve the quality guarantee. A warning is logged when this
        happens to help diagnose cases where compression is less effective than expected.

        Args:
            keep_indices: Initial set of indices to keep.
            items: The items being compressed.
            n: Total number of items.
            analysis: Optional analysis results for anomaly detection.
            max_items: Thread-safe max items limit (defaults to config value).
            field_semantics: Optional learned field semantics from TOIN.

        Returns:
            Set of indices to keep (may exceed max_items if critical items require it).
        """
        # Use provided max_items or fall back to config
        effective_max = max_items if max_items is not None else self.config.max_items_after_crush

        if len(keep_indices) <= effective_max:
            return keep_indices

        # Use provided field_semantics or fall back to instance variable (set by crush())
        effective_field_semantics = field_semantics or getattr(
            self, "_current_field_semantics", None
        )

        # Identify error items using KEYWORD detection (preservation guarantee)
        # This ensures ALL error items are kept, regardless of frequency
        error_indices = set(_detect_error_items_for_preservation(items))

        # Identify structural outlier indices using STATISTICAL detection
        # (items with rare fields or rare status values)
        outlier_indices = set(_detect_structural_outliers(items))

        # Identify numeric anomalies (MUST keep ALL of them)
        anomaly_indices = set()
        if analysis and analysis.field_stats:
            for field_name, stats in analysis.field_stats.items():
                if stats.field_type == "numeric" and stats.mean_val is not None and stats.variance:
                    std = stats.variance**0.5
                    if std > 0:
                        threshold = self.config.variance_threshold * std
                        for i, item in enumerate(items):
                            val = item.get(field_name)
                            if isinstance(val, (int, float)):
                                if abs(val - stats.mean_val) > threshold:
                                    anomaly_indices.add(i)

        # === TOIN Evolution: Identify items with important values (learned) ===
        # Uses learned field semantics for zero-latency signal detection
        learned_important_indices: set[int] = set()
        if effective_field_semantics:
            learned_important_indices = set(
                _detect_items_by_learned_semantics(items, effective_field_semantics)
            )

        # Start with all critical items (these are non-negotiable)
        # Error items are ALWAYS preserved (quality guarantee)
        prioritized = error_indices | outlier_indices | anomaly_indices | learned_important_indices

        # HIGH FIX: Log warning if critical items alone exceed the limit
        # This helps diagnose why compression may be less effective than expected
        critical_count = len(prioritized)
        if critical_count > effective_max:
            logger.warning(
                "Critical items (%d) exceed max_items (%d): errors=%d outliers=%d anomalies=%d learned=%d. "
                "Quality guarantee takes precedence - keeping all critical items.",
                critical_count,
                effective_max,
                len(error_indices),
                len(outlier_indices),
                len(anomaly_indices),
                len(learned_important_indices),
            )

        # Add first/last items if we have room
        remaining_slots = effective_max - len(prioritized)
        if remaining_slots > 0:
            # First 3 items
            for i in range(min(3, n)):
                if i not in prioritized and remaining_slots > 0:
                    prioritized.add(i)
                    remaining_slots -= 1
            # Last 2 items
            for i in range(max(0, n - 2), n):
                if i not in prioritized and remaining_slots > 0:
                    prioritized.add(i)
                    remaining_slots -= 1

        # Fill remaining slots with other important indices (by index order)
        if remaining_slots > 0:
            other_indices = sorted(keep_indices - prioritized)
            for i in other_indices:
                if remaining_slots <= 0:
                    break
                prioritized.add(i)
                remaining_slots -= 1

        return prioritized

    def should_apply(
        self,
        messages: list[dict[str, Any]],
        tokenizer: Tokenizer,
        **kwargs: Any,
    ) -> bool:
        """Check if any tool messages would benefit from smart crushing."""
        if not self.config.enabled:
            return False

        for msg in messages:
            # OpenAI style: role="tool"
            if msg.get("role") == "tool":
                content = msg.get("content", "")
                if isinstance(content, str):
                    tokens = tokenizer.count_text(content)
                    if tokens > self.config.min_tokens_to_crush:
                        # Check if it's JSON with arrays
                        parsed, success = safe_json_loads(content)
                        if success and self._has_crushable_arrays(parsed):
                            return True

            # Anthropic style: role="user" with tool_result content blocks
            content = msg.get("content")
            if isinstance(content, list):
                for block in content:
                    if isinstance(block, dict) and block.get("type") == "tool_result":
                        tool_content = block.get("content", "")
                        if isinstance(tool_content, str):
                            tokens = tokenizer.count_text(tool_content)
                            if tokens > self.config.min_tokens_to_crush:
                                parsed, success = safe_json_loads(tool_content)
                                if success and self._has_crushable_arrays(parsed):
                                    return True

        return False

    def _has_crushable_arrays(self, data: Any, depth: int = 0) -> bool:
        """Check if data contains arrays large enough to crush."""
        if depth > 5:
            return False

        if isinstance(data, list):
            if len(data) >= self.config.min_items_to_analyze:
                if data and isinstance(data[0], dict):
                    return True
            for item in data[:10]:  # Check first few items
                if self._has_crushable_arrays(item, depth + 1):
                    return True

        elif isinstance(data, dict):
            for value in data.values():
                if self._has_crushable_arrays(value, depth + 1):
                    return True

        return False

    def apply(
        self,
        messages: list[dict[str, Any]],
        tokenizer: Tokenizer,
        **kwargs: Any,
    ) -> TransformResult:
        """Apply smart crushing to messages."""
        tokens_before = tokenizer.count_messages(messages)
        result_messages = deep_copy_messages(messages)
        transforms_applied: list[str] = []
        markers_inserted: list[str] = []
        warnings: list[str] = []

        # Extract query context from recent user messages for relevance scoring
        query_context = self._extract_context_from_messages(result_messages)

        crushed_count = 0

        for msg in result_messages:
            # OpenAI style
            if msg.get("role") == "tool":
                content = msg.get("content", "")
                if not isinstance(content, str):
                    continue

                tokens = tokenizer.count_text(content)
                if tokens <= self.config.min_tokens_to_crush:
                    continue

                crushed, was_modified, analysis_info = self._smart_crush_content(
                    content, query_context
                )

                if was_modified:
                    original_hash = compute_short_hash(content)
                    marker = create_tool_digest_marker(original_hash)
                    msg["content"] = crushed + "\n" + marker
                    crushed_count += 1
                    markers_inserted.append(marker)
                    if analysis_info:
                        transforms_applied.append(f"smart:{analysis_info}")

            # Anthropic style
            content = msg.get("content")
            if isinstance(content, list):
                for i, block in enumerate(content):
                    if not isinstance(block, dict):
                        continue
                    if block.get("type") != "tool_result":
                        continue

                    tool_content = block.get("content", "")
                    if not isinstance(tool_content, str):
                        continue

                    tokens = tokenizer.count_text(tool_content)
                    if tokens <= self.config.min_tokens_to_crush:
                        continue

                    crushed, was_modified, analysis_info = self._smart_crush_content(
                        tool_content, query_context
                    )

                    if was_modified:
                        original_hash = compute_short_hash(tool_content)
                        marker = create_tool_digest_marker(original_hash)
                        content[i]["content"] = crushed + "\n" + marker
                        crushed_count += 1
                        markers_inserted.append(marker)
                        if analysis_info:
                            transforms_applied.append(f"smart:{analysis_info}")

        if crushed_count > 0:
            transforms_applied.insert(0, f"smart_crush:{crushed_count}")

        tokens_after = tokenizer.count_messages(result_messages)

        return TransformResult(
            messages=result_messages,
            tokens_before=tokens_before,
            tokens_after=tokens_after,
            transforms_applied=transforms_applied,
            markers_inserted=markers_inserted,
            warnings=warnings,
        )

    def _extract_context_from_messages(self, messages: list[dict[str, Any]]) -> str:
        """Extract query context from recent messages for relevance scoring.

        Builds a context string from:
        - Recent user messages (what the user is asking about)
        - Recent tool call arguments (what data was requested)

        This context is used by RelevanceScorer to determine which items
        to preserve during crushing.

        Args:
            messages: Full message list.

        Returns:
            Context string for relevance scoring.
        """
        context_parts: list[str] = []

        # Look at last 5 user messages (most relevant to recent tool calls)
        user_message_count = 0
        for msg in reversed(messages):
            if msg.get("role") == "user":
                content = msg.get("content")
                if isinstance(content, str):
                    context_parts.append(content)
                elif isinstance(content, list):
                    # Anthropic style - extract from text blocks
                    for block in content:
                        if isinstance(block, dict) and block.get("type") == "text":
                            text = block.get("text", "")
                            if text:
                                context_parts.append(text)

                user_message_count += 1
                if user_message_count >= 5:
                    break

            # Also check assistant tool_calls for function arguments
            if msg.get("role") == "assistant" and msg.get("tool_calls"):
                for tc in msg.get("tool_calls", []):
                    if isinstance(tc, dict):
                        func = tc.get("function", {})
                        args = func.get("arguments", "")
                        if isinstance(args, str) and args:
                            context_parts.append(args)

        return " ".join(context_parts)

    def _smart_crush_content(
        self, content: str, query_context: str = "", tool_name: str | None = None
    ) -> tuple[str, bool, str]:
        """
        Apply smart crushing to content.

        Handles both JSON (existing SmartCrusher logic) and plain text content
        (search results, logs, generic text) using specialized compressors.

        Args:
            content: Content to crush (JSON or plain text).
            query_context: Context string from user messages for relevance scoring.
            tool_name: Name of the tool that produced this output.

        Returns:
            Tuple of (crushed_content, was_modified, analysis_info).
        """
        parsed, success = safe_json_loads(content)
        if not success:
            # Not JSON - pass through unchanged
            # Text compression utilities (SearchCompressor, LogCompressor, TextCompressor)
            # are available as standalone tools for applications to use explicitly
            return content, False, ""

        # Recursively process and crush arrays
        crushed, info, ccr_markers = self._process_value(
            parsed, query_context=query_context, tool_name=tool_name
        )

        result = safe_json_dumps(crushed, indent=None)
        was_modified = result != content.strip()

        # CCR: Inject retrieval markers if compression happened and CCR is enabled
        if was_modified and ccr_markers and self._ccr_config.inject_retrieval_marker:
            for ccr_hash, original_count, compressed_count in ccr_markers:
                marker = self._ccr_config.marker_template.format(
                    original_count=original_count,
                    compressed_count=compressed_count,
                    hash=ccr_hash,
                )
                result += marker

        return result, was_modified, info

    def _process_value(
        self, value: Any, depth: int = 0, query_context: str = "", tool_name: str | None = None
    ) -> tuple[Any, str, list[tuple[str, int, int]]]:
        """Recursively process a value, crushing arrays where appropriate.

        Returns:
            Tuple of (processed_value, info_string, ccr_markers).
            ccr_markers is a list of (hash, original_count, compressed_count) tuples.
        """
        info_parts = []
        ccr_markers: list[tuple[str, int, int]] = []

        if isinstance(value, list):
            # Check if this array should be crushed
            # Must have enough items AND all items must be dicts (not mixed types)
            all_dicts = value and all(isinstance(item, dict) for item in value)
            if len(value) >= self.config.min_items_to_analyze and all_dicts:
                crushed, strategy, ccr_hash = self._crush_array(value, query_context, tool_name)
                info_parts.append(f"{strategy}({len(value)}->{len(crushed)})")

                # Track CCR marker for later injection
                if ccr_hash:
                    ccr_markers.append((ccr_hash, len(value), len(crushed)))

                return crushed, ",".join(info_parts), ccr_markers
            else:
                # Process items recursively
                processed = []
                for item in value:
                    p_item, p_info, p_markers = self._process_value(
                        item, depth + 1, query_context, tool_name
                    )
                    processed.append(p_item)
                    if p_info:
                        info_parts.append(p_info)
                    ccr_markers.extend(p_markers)
                return processed, ",".join(info_parts), ccr_markers

        elif isinstance(value, dict):
            # Process values recursively
            processed_dict: dict[str, Any] = {}
            for k, v in value.items():
                p_val, p_info, p_markers = self._process_value(
                    v, depth + 1, query_context, tool_name
                )
                processed_dict[k] = p_val
                if p_info:
                    info_parts.append(p_info)
                ccr_markers.extend(p_markers)
            return processed_dict, ",".join(info_parts), ccr_markers

        else:
            return value, "", []

    def _crush_array(
        self, items: list[dict], query_context: str = "", tool_name: str | None = None
    ) -> tuple[list, str, str | None]:
        """Crush an array using statistical analysis and relevance scoring.

        IMPORTANT: If crushability analysis determines it's not safe to crush
        (high variability + no importance signal), returns original array unchanged.

        TOIN-aware: Consults the Tool Output Intelligence Network for cross-user
        learned patterns. High retrieval rate across all users → compress less.

        Feedback-aware: Uses learned patterns to adjust compression aggressiveness.
        High retrieval rate for a tool → compress less aggressively.

        Returns:
            Tuple of (crushed_items, strategy_info, ccr_hash).
            ccr_hash is the hash for retrieval if CCR is enabled, None otherwise.
        """
        # BOUNDARY CHECK: If already at or below max_items, no compression needed
        if len(items) <= self.config.max_items_after_crush:
            return items, "none:at_limit", None

        # Get feedback hints if enabled
        # THREAD-SAFETY: Use a local effective_max_items instead of mutating shared config
        effective_max_items = self.config.max_items_after_crush
        hints_applied = False
        toin_hint_applied = False

        # Create ToolSignature for TOIN lookup
        tool_signature = ToolSignature.from_items(items)

        # TOIN: Get cross-user learned recommendations
        toin = self._get_toin()
        toin_hint = toin.get_recommendation(tool_signature, query_context)

        if toin_hint.skip_compression:
            return items, f"skip:toin({toin_hint.reason})", None

        # Apply TOIN recommendations if from network or local learning
        toin_preserve_fields: list[str] = []
        toin_recommended_strategy: str | None = None
        toin_compression_level: str | None = None
        # LOW FIX #21: Use configurable threshold instead of hardcoded 0.5
        if (
            toin_hint.source in ("network", "local")
            and toin_hint.confidence >= self.config.toin_confidence_threshold
        ):
            # TOIN recommendations take precedence over local feedback
            effective_max_items = toin_hint.max_items
            toin_preserve_fields = toin_hint.preserve_fields  # Fields to never remove
            toin_hint_applied = True
            # Store strategy and compression level for later use
            if toin_hint.recommended_strategy != "default":
                toin_recommended_strategy = toin_hint.recommended_strategy
            if toin_hint.compression_level != "moderate":
                toin_compression_level = toin_hint.compression_level

        # === TOIN Evolution: Extract field semantics for signal detection ===
        # Store temporarily on instance for use in _prioritize_indices
        # This enables learned signal detection without changing all method signatures
        self._current_field_semantics = (
            toin_hint.field_semantics if toin_hint.field_semantics else None
        )

        # Local feedback hints (if TOIN didn't apply)
        if not toin_hint_applied and self.config.use_feedback_hints and tool_name:
            feedback = self._get_feedback()
            hints = feedback.get_compression_hints(tool_name)

            # Check if hints recommend skipping compression
            if hints.skip_compression:
                return items, f"skip:feedback({hints.reason})", None

            # Adjust max_items based on feedback
            if hints.suggested_items is not None:
                effective_max_items = hints.suggested_items
                hints_applied = True

            # Use preserve_fields from local feedback (hash them for TOIN compatibility)
            # Note: CompressionFeedback stores actual field names, but _plan methods
            # expect SHA256[:8] hashes for privacy-preserving comparison
            if hints.preserve_fields:
                toin_preserve_fields = [_hash_field_name(field) for field in hints.preserve_fields]

            # Use recommended_strategy from local feedback if not already set by TOIN
            if hints.recommended_strategy and not toin_recommended_strategy:
                toin_recommended_strategy = hints.recommended_strategy

        try:
            # Analyze the array (includes crushability check)
            analysis = self.analyzer.analyze_array(items)

            # CRITICAL: If not crushable, return original array unchanged
            if analysis.recommended_strategy == CompressionStrategy.SKIP:
                reason = ""
                if analysis.crushability:
                    reason = f"skip:{analysis.crushability.reason}"
                return items, reason, None

            # Apply TOIN strategy recommendation if available
            # TOIN learns which strategies work best from cross-user patterns
            if toin_recommended_strategy:
                try:
                    toin_strategy = CompressionStrategy(toin_recommended_strategy)
                    # Only override if TOIN suggests a valid non-SKIP strategy
                    if toin_strategy != CompressionStrategy.SKIP:
                        analysis.recommended_strategy = toin_strategy
                except ValueError:
                    pass  # Invalid strategy name, keep analyzer's choice

            # Apply TOIN compression level to adjust effective_max_items
            if toin_compression_level:
                if toin_compression_level == "none":
                    # Don't compress - return original
                    return items, "skip:toin_level_none", None
                elif toin_compression_level == "conservative":
                    # Be conservative - keep more items
                    effective_max_items = max(effective_max_items, min(50, len(items) // 2))
                elif toin_compression_level == "aggressive":
                    # Be aggressive - keep fewer items
                    effective_max_items = min(effective_max_items, 15)

            # Create compression plan with relevance scoring
            # Pass TOIN preserve_fields so items with those fields get priority
            # Pass effective_max_items for thread-safe compression
            plan = self._create_plan(
                analysis,
                items,
                query_context,
                preserve_fields=toin_preserve_fields or None,
                effective_max_items=effective_max_items,
            )

            # Execute compression
            result = self._execute_plan(plan, items, analysis)

            # CCR: Store original content for retrieval if enabled
            ccr_hash = None
            if (
                self._ccr_config.enabled
                and len(items) >= self._ccr_config.min_items_to_cache
                and len(result) < len(items)  # Only cache if compression actually happened
            ):
                store = self._get_compression_store()
                original_json = json.dumps(items, default=str)
                compressed_json = json.dumps(result, default=str)

                ccr_hash = store.store(
                    original=original_json,
                    compressed=compressed_json,
                    original_item_count=len(items),
                    compressed_item_count=len(result),
                    tool_name=tool_name,
                    query_context=query_context,
                    # CRITICAL: Pass the tool_signature_hash so retrieval events
                    # can be correlated with compression events in TOIN
                    tool_signature_hash=tool_signature.structure_hash,
                    compression_strategy=analysis.recommended_strategy.value,
                )

                # Record compression event for feedback loop
                if self.config.use_feedback_hints and tool_name:
                    feedback = self._get_feedback()
                    feedback.record_compression(
                        tool_name=tool_name,
                        original_count=len(items),
                        compressed_count=len(result),
                        strategy=analysis.recommended_strategy.value,
                        tool_signature_hash=tool_signature.structure_hash,
                    )

            # Record telemetry for data flywheel
            self._record_telemetry(
                items=items,
                result=result,
                analysis=analysis,
                plan=plan,
                tool_name=tool_name,
            )

            # TOIN: Record compression event for cross-user learning
            try:
                # Calculate token counts (approximate)
                original_tokens = len(json.dumps(items, default=str)) // 4
                compressed_tokens = len(json.dumps(result, default=str)) // 4

                toin.record_compression(
                    tool_signature=tool_signature,
                    original_count=len(items),
                    compressed_count=len(result),
                    original_tokens=original_tokens,
                    compressed_tokens=compressed_tokens,
                    strategy=analysis.recommended_strategy.value,
                    query_context=query_context,
                    items=items,  # Pass items for field-level semantic learning
                )
            except Exception:
                # TOIN should never break compression
                pass

            strategy_info = analysis.recommended_strategy.value
            if toin_hint_applied:
                toin_parts = [f"items={toin_hint.max_items}", f"conf={toin_hint.confidence:.2f}"]
                if toin_recommended_strategy:
                    toin_parts.append(f"strategy={toin_recommended_strategy}")
                if toin_compression_level and toin_compression_level != "moderate":
                    toin_parts.append(f"level={toin_compression_level}")
                strategy_info += f"(toin:{','.join(toin_parts)})"
            elif hints_applied:
                strategy_info += f"(feedback:{effective_max_items})"

            # Clean up temporary instance variable
            self._current_field_semantics = None
            return result, strategy_info, ccr_hash

        except Exception:
            # Clean up temporary instance variable
            self._current_field_semantics = None
            # Re-raise any exceptions (removed finally block since we no longer mutate config)
            raise

    def _create_plan(
        self,
        analysis: ArrayAnalysis,
        items: list[dict],
        query_context: str = "",
        preserve_fields: list[str] | None = None,
        effective_max_items: int | None = None,
    ) -> CompressionPlan:
        """Create a detailed compression plan using relevance scoring.

        Args:
            analysis: The array analysis results.
            items: The items to compress.
            query_context: Context string from user messages for relevance scoring.
            preserve_fields: TOIN-learned fields that users commonly retrieve.
                Items with values in these fields get higher priority.
            effective_max_items: Thread-safe max items limit (defaults to config value).
        """
        # Use provided effective_max_items or fall back to config
        max_items = (
            effective_max_items
            if effective_max_items is not None
            else self.config.max_items_after_crush
        )

        plan = CompressionPlan(
            strategy=analysis.recommended_strategy,
            constant_fields=analysis.constant_fields if self.config.factor_out_constants else {},
        )

        # Handle SKIP - keep all items (shouldn't normally reach here)
        if analysis.recommended_strategy == CompressionStrategy.SKIP:
            plan.keep_indices = list(range(len(items)))
            return plan

        if analysis.recommended_strategy == CompressionStrategy.TIME_SERIES:
            plan = self._plan_time_series(
                analysis, items, plan, query_context, preserve_fields, max_items
            )

        elif analysis.recommended_strategy == CompressionStrategy.CLUSTER_SAMPLE:
            plan = self._plan_cluster_sample(
                analysis, items, plan, query_context, preserve_fields, max_items
            )

        elif analysis.recommended_strategy == CompressionStrategy.TOP_N:
            plan = self._plan_top_n(
                analysis, items, plan, query_context, preserve_fields, max_items
            )

        else:  # SMART_SAMPLE or NONE
            plan = self._plan_smart_sample(
                analysis, items, plan, query_context, preserve_fields, max_items
            )

        return plan

    def _plan_time_series(
        self,
        analysis: ArrayAnalysis,
        items: list[dict],
        plan: CompressionPlan,
        query_context: str = "",
        preserve_fields: list[str] | None = None,
        max_items: int | None = None,
    ) -> CompressionPlan:
        """Plan compression for time series data.

        Keeps items around change points (anomalies) plus first/last items.
        Uses STATISTICAL outlier detection for important items.
        Uses RelevanceScorer for semantic matching of user queries.

        Args:
            preserve_fields: TOIN-learned fields that users commonly retrieve.
                Items where query_context matches these field values get priority.
            max_items: Thread-safe max items limit (defaults to config value).
        """
        # Use provided max_items or fall back to config
        effective_max = max_items if max_items is not None else self.config.max_items_after_crush
        n = len(items)
        keep_indices = set()

        # 1. First 3 items
        for i in range(min(3, n)):
            keep_indices.add(i)

        # 2. Last 2 items
        for i in range(max(0, n - 2), n):
            keep_indices.add(i)

        # 3. Items around change points from numeric fields
        for stats in analysis.field_stats.values():
            if stats.change_points:
                for cp in stats.change_points:
                    # Keep a window around each change point
                    for offset in range(-2, 3):
                        idx = cp + offset
                        if 0 <= idx < n:
                            keep_indices.add(idx)

        # 4. Structural outlier items (STATISTICAL detection - no hardcoded keywords)
        outlier_indices = _detect_structural_outliers(items)
        keep_indices.update(outlier_indices)

        # 4b. Error items via KEYWORD detection (PRESERVATION GUARANTEE)
        # This is critical - errors must ALWAYS be preserved regardless of structure
        error_indices = _detect_error_items_for_preservation(items)
        keep_indices.update(error_indices)

        # 5. Items matching query anchors (DETERMINISTIC exact match)
        # Anchors provide reliable preservation for specific entity lookups (UUIDs, IDs, names)
        if query_context:
            anchors = extract_query_anchors(query_context)
            for i, item in enumerate(items):
                if item_matches_anchors(item, anchors):
                    keep_indices.add(i)

        # 6. Items with high relevance to query context (PROBABILISTIC semantic match)
        if query_context:
            item_strs = [json.dumps(item, default=str) for item in items]
            scores = self._scorer.score_batch(item_strs, query_context)
            for i, score in enumerate(scores):
                if score.score >= self._relevance_threshold:
                    keep_indices.add(i)

        # 6b. TOIN preserve_fields: boost items where query matches these fields
        # Note: preserve_fields are SHA256[:8] hashes, use helper to match
        if preserve_fields and query_context:
            for i, item in enumerate(items):
                if _item_has_preserve_field_match(item, preserve_fields, query_context):
                    keep_indices.add(i)

        # Limit to effective_max while ALWAYS preserving outliers and anomalies
        keep_indices = self._prioritize_indices(keep_indices, items, n, analysis, effective_max)

        plan.keep_indices = sorted(keep_indices)
        return plan

    def _plan_cluster_sample(
        self,
        analysis: ArrayAnalysis,
        items: list[dict],
        plan: CompressionPlan,
        query_context: str = "",
        preserve_fields: list[str] | None = None,
        max_items: int | None = None,
    ) -> CompressionPlan:
        """Plan compression for clusterable data (like logs).

        Uses clustering plus STATISTICAL outlier detection.
        Uses RelevanceScorer for semantic matching of user queries.

        Args:
            preserve_fields: TOIN-learned fields that users commonly retrieve.
                Items where query_context matches these field values get priority.
            max_items: Thread-safe max items limit (defaults to config value).
        """
        # Use provided max_items or fall back to config
        effective_max = max_items if max_items is not None else self.config.max_items_after_crush
        n = len(items)
        keep_indices = set()

        # 1. First 3 items
        for i in range(min(3, n)):
            keep_indices.add(i)

        # 2. Last 2 items
        for i in range(max(0, n - 2), n):
            keep_indices.add(i)

        # 3. Structural outlier items (STATISTICAL detection - no hardcoded keywords)
        outlier_indices = _detect_structural_outliers(items)
        keep_indices.update(outlier_indices)

        # 3b. Error items via KEYWORD detection (PRESERVATION GUARANTEE)
        # This is critical - errors must ALWAYS be preserved regardless of structure
        error_indices = _detect_error_items_for_preservation(items)
        keep_indices.update(error_indices)

        # 4. Cluster by message-like field and keep representatives
        # Find a high-cardinality string field (likely message field)
        message_field = None
        max_uniqueness = 0.0
        for name, stats in analysis.field_stats.items():
            if stats.field_type == "string" and stats.unique_ratio > max_uniqueness:
                # Prefer fields with moderate to high uniqueness (message-like)
                if stats.unique_ratio > 0.3:
                    message_field = name
                    max_uniqueness = stats.unique_ratio

        if message_field:
            plan.cluster_field = message_field

            # Simple clustering: group by first 50 chars of message
            clusters: dict[str, list[int]] = {}
            for i, item in enumerate(items):
                msg = str(item.get(message_field, ""))[:50]
                msg_hash = hashlib.md5(msg.encode()).hexdigest()[:8]
                if msg_hash not in clusters:
                    clusters[msg_hash] = []
                clusters[msg_hash].append(i)

            # Keep 1-2 representatives from each cluster
            for indices in clusters.values():
                for idx in indices[:2]:
                    keep_indices.add(idx)

        # 5. Items matching query anchors (DETERMINISTIC exact match)
        # Anchors provide reliable preservation for specific entity lookups (UUIDs, IDs, names)
        if query_context:
            anchors = extract_query_anchors(query_context)
            for i, item in enumerate(items):
                if item_matches_anchors(item, anchors):
                    keep_indices.add(i)

        # 6. Items with high relevance to query context (PROBABILISTIC semantic match)
        if query_context:
            item_strs = [json.dumps(item, default=str) for item in items]
            scores = self._scorer.score_batch(item_strs, query_context)
            for i, score in enumerate(scores):
                if score.score >= self._relevance_threshold:
                    keep_indices.add(i)

        # 6b. TOIN preserve_fields: boost items where query matches these fields
        # Note: preserve_fields are SHA256[:8] hashes, use helper to match
        if preserve_fields and query_context:
            for i, item in enumerate(items):
                if _item_has_preserve_field_match(item, preserve_fields, query_context):
                    keep_indices.add(i)

        # Limit total while ALWAYS preserving outliers and anomalies
        keep_indices = self._prioritize_indices(keep_indices, items, n, analysis, effective_max)

        plan.keep_indices = sorted(keep_indices)
        return plan

    def _plan_top_n(
        self,
        analysis: ArrayAnalysis,
        items: list[dict],
        plan: CompressionPlan,
        query_context: str = "",
        preserve_fields: list[str] | None = None,
        max_items: int | None = None,
    ) -> CompressionPlan:
        """Plan compression for scored/ranked data.

        For data with a score/relevance field, that field IS the primary relevance
        signal. Our internal relevance scoring is SECONDARY - it's used to find
        potential "needle" items that the original scoring might have missed.

        Strategy:
        1. Keep top N by score (the original system's relevance ranking)
        2. Add structural outliers (errors, anomalies)
        3. Add high-confidence relevance matches (needles the user is looking for)

        Args:
            preserve_fields: TOIN-learned fields that users commonly retrieve.
                Items where query_context matches these field values get priority.
            max_items: Thread-safe max items limit (defaults to config value).
        """
        # Use provided max_items or fall back to config
        effective_max = max_items if max_items is not None else self.config.max_items_after_crush

        # Find score field using STATISTICAL detection (no hardcoded field names)
        score_field = None
        max_confidence = 0.0
        for name, stats in analysis.field_stats.items():
            is_score, confidence = _detect_score_field_statistically(stats, items)
            if is_score and confidence > max_confidence:
                score_field = name
                max_confidence = confidence

        if not score_field:
            return self._plan_smart_sample(
                analysis, items, plan, query_context, preserve_fields, effective_max
            )

        plan.sort_field = score_field
        keep_indices = set()

        # 1. TOP N by score FIRST (the primary relevance signal)
        # The original system's score field is the authoritative ranking
        scored_items = [(i, item.get(score_field, 0)) for i, item in enumerate(items)]
        scored_items.sort(key=lambda x: x[1], reverse=True)

        # Reserve slots for outliers
        top_count = max(0, effective_max - 3)
        for idx, _ in scored_items[:top_count]:
            keep_indices.add(idx)

        # 2. Structural outlier items (STATISTICAL detection - no hardcoded keywords)
        outlier_indices = _detect_structural_outliers(items)
        keep_indices.update(outlier_indices)

        # 2b. Error items via KEYWORD detection (PRESERVATION GUARANTEE)
        # This is critical - errors must ALWAYS be preserved regardless of structure
        error_indices = _detect_error_items_for_preservation(items)
        keep_indices.update(error_indices)

        # 3. Items matching query anchors (DETERMINISTIC exact match) - ADDITIVE
        # Anchors provide reliable preservation for specific entity lookups (UUIDs, IDs, names)
        # These are ALWAYS preserved since they represent explicit user intent
        if query_context:
            anchors = extract_query_anchors(query_context)
            for i, item in enumerate(items):
                if i not in keep_indices and item_matches_anchors(item, anchors):
                    keep_indices.add(i)

        # 4. HIGH-CONFIDENCE relevance matches (potential needles) - ADDITIVE only
        # Only add items that are NOT already in top N but match the query strongly
        # Use a higher threshold (0.5) since the score field already captures relevance
        if query_context:
            item_strs = [json.dumps(item, default=str) for item in items]
            scores = self._scorer.score_batch(item_strs, query_context)
            # Higher threshold and limit count to avoid adding everything
            high_threshold = max(0.5, self._relevance_threshold * 2)
            added_count = 0
            max_relevance_adds = 3  # Limit additional relevance matches
            for i, score in enumerate(scores):
                if i not in keep_indices and score.score >= high_threshold:
                    keep_indices.add(i)
                    added_count += 1
                    if added_count >= max_relevance_adds:
                        break

        # 4b. TOIN preserve_fields: boost items where query matches these fields
        # Note: preserve_fields are SHA256[:8] hashes, use helper to match
        if preserve_fields and query_context:
            for i, item in enumerate(items):
                if i not in keep_indices:  # Only add if not already kept
                    if _item_has_preserve_field_match(item, preserve_fields, query_context):
                        keep_indices.add(i)

        plan.keep_count = len(keep_indices)
        plan.keep_indices = sorted(keep_indices)
        return plan

    def _plan_smart_sample(
        self,
        analysis: ArrayAnalysis,
        items: list[dict],
        plan: CompressionPlan,
        query_context: str = "",
        preserve_fields: list[str] | None = None,
        max_items: int | None = None,
    ) -> CompressionPlan:
        """Plan smart statistical sampling using STATISTICAL detection.

        Always keeps:
        - First K items (default 3)
        - Last K items (default 2)
        - Structural outliers (items with rare fields or rare status values)
        - Anomalous numeric items (> 2 std from mean)
        - Items around change points
        - Items with high relevance to query context (via RelevanceScorer)

        Uses STATISTICAL detection instead of hardcoded keywords.

        Args:
            preserve_fields: TOIN-learned fields that users commonly retrieve.
                Items where query_context matches these field values get priority.
            max_items: Thread-safe max items limit (defaults to config value).
        """
        # Use provided max_items or fall back to config
        effective_max = max_items if max_items is not None else self.config.max_items_after_crush

        n = len(items)
        keep_indices = set()

        # 1. First K items (default 3)
        for i in range(min(3, n)):
            keep_indices.add(i)

        # 2. Last K items (default 2)
        for i in range(max(0, n - 2), n):
            keep_indices.add(i)

        # 3. Structural outlier items (STATISTICAL detection - no hardcoded keywords)
        outlier_indices = _detect_structural_outliers(items)
        keep_indices.update(outlier_indices)

        # 3b. Error items via KEYWORD detection (PRESERVATION GUARANTEE)
        # This is critical - errors must ALWAYS be preserved regardless of structure
        error_indices = _detect_error_items_for_preservation(items)
        keep_indices.update(error_indices)

        # 4. Anomalous numeric items (> 2 std from mean)
        for name, stats in analysis.field_stats.items():
            if stats.field_type == "numeric" and stats.mean_val is not None and stats.variance:
                std = stats.variance**0.5
                if std > 0:
                    threshold = self.config.variance_threshold * std
                    for i, item in enumerate(items):
                        val = item.get(name)
                        if isinstance(val, (int, float)):
                            if abs(val - stats.mean_val) > threshold:
                                keep_indices.add(i)

        # 5. Items around change points (if detected)
        if self.config.preserve_change_points:
            for stats in analysis.field_stats.values():
                if stats.change_points:
                    for cp in stats.change_points:
                        # Keep items around change point
                        for offset in range(-1, 2):
                            idx = cp + offset
                            if 0 <= idx < n:
                                keep_indices.add(idx)

        # 6. Items matching query anchors (DETERMINISTIC exact match)
        # Anchors provide reliable preservation for specific entity lookups (UUIDs, IDs, names)
        if query_context:
            anchors = extract_query_anchors(query_context)
            for i, item in enumerate(items):
                if item_matches_anchors(item, anchors):
                    keep_indices.add(i)

        # 7. Items with high relevance to query context (PROBABILISTIC semantic match)
        if query_context:
            item_strs = [json.dumps(item, default=str) for item in items]
            scores = self._scorer.score_batch(item_strs, query_context)
            for i, score in enumerate(scores):
                if score.score >= self._relevance_threshold:
                    keep_indices.add(i)

        # 7b. TOIN preserve_fields: boost items where query matches these fields
        # Note: preserve_fields are SHA256[:8] hashes, use helper to match
        if preserve_fields and query_context:
            for i, item in enumerate(items):
                if _item_has_preserve_field_match(item, preserve_fields, query_context):
                    keep_indices.add(i)

        # Limit to effective_max while ALWAYS preserving outliers and anomalies
        keep_indices = self._prioritize_indices(keep_indices, items, n, analysis, effective_max)

        plan.keep_indices = sorted(keep_indices)
        return plan

    def _execute_plan(
        self, plan: CompressionPlan, items: list[dict], analysis: ArrayAnalysis
    ) -> list:
        """Execute a compression plan and return crushed array.

        SCHEMA-PRESERVING: Returns only items from the original array.
        No wrappers, no generated text, no metadata keys.
        """
        result = []

        # Return only the kept items, preserving original schema
        for idx in sorted(plan.keep_indices):
            if 0 <= idx < len(items):
                # Copy item unchanged - no modifications to schema
                result.append(items[idx].copy())

        return result


def smart_crush_tool_output(
    content: str,
    config: SmartCrusherConfig | None = None,
    ccr_config: CCRConfig | None = None,
) -> tuple[str, bool, str]:
    """
    Convenience function to smart-crush a single tool output.

    NOTE: CCR markers are DISABLED by default in this convenience function
    to maintain backward compatibility (output remains valid JSON).
    To enable CCR markers, pass a CCRConfig with inject_retrieval_marker=True.

    Args:
        content: The tool output content (JSON string).
        config: Optional SmartCrusher configuration.
        ccr_config: Optional CCR (Compress-Cache-Retrieve) configuration.
            By default, CCR is enabled (caching) but markers are disabled.

    Returns:
        Tuple of (crushed_content, was_modified, analysis_info).
    """
    cfg = config or SmartCrusherConfig()

    # Default: CCR enabled for caching, but markers disabled for clean JSON output
    if ccr_config is None:
        ccr_cfg = CCRConfig(
            enabled=True,  # Still cache for retrieval
            inject_retrieval_marker=False,  # Don't break JSON output
        )
    else:
        ccr_cfg = ccr_config

    crusher = SmartCrusher(cfg, ccr_config=ccr_cfg)
    return crusher._smart_crush_content(content)