File size: 67,010 Bytes
ecca2a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Fixed Physics Layer — computes VADUGWI from structural analysis.

Pipeline: text -> classify words -> compute proximity -> detect structures -> apply physics -> VADUGWI

The physics (momentum, force application, blending) are FIXED.
The inputs come from the structural layers.

Refactored into a modular pipeline: each stage is an independent function
that reads from and writes to a shared context dict. Stages can be chained,
bypassed, or swapped.
"""

import re
from math import tanh, exp, log
from typing import List, Optional, Tuple

from .shared import VADUG, PersonalityVector
from .word_classifier import WordRole, classify_sentence, _clean
from .proximity import proximity_coefficient
from .vocabulary import VOCABULARY
from .structures import StructureDetector, StructureMatch
from .force_flow import resolve_force_flow, compute_flow_modifiers, compute_intent
from .phase import is_solvent, get_phase


# ── Physics constants (fixed, never tuned per-sentence) ─────────

CENTER = 128.0
M_BASE = 0.557            # champion v2: genetically optimized 2026-04-03
M_AROUSAL_SCALE = 0.25    # arousal-scaled momentum: high A = sticky state
M_NEGATIVITY_BIAS = 1.15  # negative states are stickier than positive
M_POSITIVITY_EASE = 0.90  # positive transitions are easier
M_MIN = 0.30              # floor: never fully unresponsive to input
M_MAX = 0.95              # ceiling: never fully locked in state
FORCE_SCALE = 1.405       # champion v2
DIRECT_PUSH_CAP = 1.0     # champion v2: max push
DIRECT_PUSH_TRIGGER = 86.2  # champion v2
SATURATION = 120.0        # tanh saturation: smooth compression replaces hard clamp

# Mundane dampening: massless context atoms absorb crisis energy
# D = (G_t + ε) / (G_t + α * |dV|)
# Gemini's improvement: ε decays exponentially with |dV| so strong crisis
# words near zero-gravity subjects get crushed almost to zero.
# ε = e^(-λ|dV|)  →  at |dV|=35: ε=0.03, at |dV|=60: ε=0.002
MUNDANE_ALPHA = 0.04      # sensitivity: how much dV matters
MUNDANE_EPSILON = 1.0     # floor: avoid division by zero (council suggested lower but ecosystem is tuned to this)
MUNDANE_DV_THRESHOLD = 25 # only dampen high-charge atoms (crisis words)


# ── Compound resolution tables ──────────────────────────────────

_BOOKEND_COMPOUNDS = {
    "shut": "up",      # shut [anything] up = silence command
    "get": "out",      # get [the fuck] out = expulsion command
    "fuck": "off",     # fuck [right] off = rejection command
    "back": "off",     # back [the hell] off = distance command
    "piss": "off",     # piss off = rejection
}

_SPICE_WORDS = {"the", "a", "an", "fuck", "fucking", "fuckin", "damn",
                "god", "hell", "mother", "motherfuckin", "stupid",
                "bitch", "ass", "right", "just", "already", "up",
                "freakin", "freaking", "effing"}

_ONE_AS_QUANTIFIER = {"thing", "person", "place", "way", "reason", "time",
                      "day", "moment", "word", "chance", "step", "bit"}

_CONTINUATION_PAIRS = {
    ("wont", "stop"), ("wont", "quit"), ("wont", "end"),
    ("wont", "leave"), ("wont", "go"),
    ("cant", "stop"), ("cant", "quit"),
    ("dont", "stop"), ("doesnt", "stop"),
    ("never", "stop"), ("never", "stops"), ("never", "end"),
    ("never", "ends"), ("never", "quit"),
}

_UNIVERSAL_ADDRESS = {
    "everyone", "everybody", "anyone", "anybody", "all", "people",
    "folks", "y'all", "yall", "ladies", "gentlemen",
}

_SECOND_PERSON = {"you", "your", "yours", "yourself", "yourselves"}

_ABSENCE_MARKERS = {"without", "havent", "haven't", "hasnt", "hasn't"}
_ABSENCE_FOLLOWERS = {"had", "been", "felt", "seen", "gotten", "experienced"}
_PERSON_ROLES = {"SELF_REF", "OTHER_REF", "RELATION_REF"}

_CHOICE_VERBS = {"choose", "chose", "choosing", "decide", "pick", "picked"}

# ── Trigram compound bonds (Council Round 8) ───────────────────
_COMPOUND_BONDS_TRI = {
    ("got", "laid", "off"): "laidoff",
    ("get", "laid", "off"): "laidoff",
    ("got", "kicked", "out"): "kickedout",
    ("got", "locked", "out"): "lockedout",
    ("got", "ripped", "off"): "rippedoff",
    ("got", "thrown", "out"): "thrownout",
    ("got", "cut", "off"): "cutoff",
    ("got", "burned", "out"): "burnedout",
    ("got", "wiped", "out"): "wipedout",
}

# ── Compound bond table (Council Round 8) ──────────────────────
# Multi-word phrases that form molecular bonds with emergent charge.
# Resolved in tokenizer → single atom → charge from vocabulary.
_COMPOUND_BONDS = {
    # Negative life events
    ("laid", "off"): "laidoff",
    ("food", "poisoning"): "foodpoisoning",
    ("broke", "down"): "brokedown",
    ("locked", "out"): "lockedout",
    ("kicked", "out"): "kickedout",
    ("passed", "away"): "passedaway",
    ("cut", "off"): "cutoff",
    ("thrown", "out"): "thrownout",
    ("ripped", "off"): "rippedoff",
    ("wiped", "out"): "wipedout",
    ("burned", "out"): "burnedout",
    ("shut", "down"): "shutdown",
    ("backed", "out"): "backedout",
    ("dropped", "out"): "droppedout",
    ("sold", "out"): "soldout",
    ("stressed", "out"): "stressedout",
    ("freaked", "out"): "freakedout",
    ("ruled", "out"): "ruledout",
    ("washed", "out"): "washedout",
    ("checked", "out"): "checkedout",
    # Positive resolution events
    ("cancer", "free"): "cancerfree",
    ("debt", "free"): "debtfree",
    ("pain", "free"): "painfree",
    ("pulled", "off"): "pulledoff",
    ("pulled", "through"): "pulledthrough",
    ("worked", "out"): "workedout",
    ("paid", "off"): "paidoff",
    ("turned", "around"): "turnedaround",
    # Neutral/procedural compounds
    ("log", "in"): "login",
    ("sign", "up"): "signup",
    ("check", "in"): "checkin",
    ("pick", "up"): "pickup",
    ("set", "up"): "setup",
    ("WiFi", "down"): "wifidown",
    ("wifi", "down"): "wifidown",
    # Hype/intensifier compounds (slang batch 1)
    ("lets", "go"): "letsgo",
    ("let's", "go"): "letsgo",
    ("as", "hell"): "ashell",
    ("so", "back"): "soback",
}

# "w" reads as WIN only after these or sentence-finally; otherwise it's "with"
_W_WIN_PRECEDERS = frozenset({
    "a", "the", "big", "massive", "huge", "major", "another",
    "certified", "what",
})

_ELONG_RUN = re.compile(r"([a-zA-Z])\1{2,}")

_TRAP_GENRE_FOLLOWERS = frozenset({
    "remix", "beat", "beats", "music", "song", "songs", "mix",
    "playlist", "banger", "instrumental", "album", "track", "tracks",
})

_FIRE_HAZARD_PRECEDERS = frozenset({"a", "the"})
_FIRE_HAZARD_FOLLOWERS = frozenset({
    "in", "at", "on", "alarm", "department", "drill", "spread", "broke",
})
_FIRE_PRAISE_PRECEDERS = frozenset({
    "is", "was", "its", "it's", "thats", "that's", "so", "straight",
    "absolute", "be",
})

_FILTHY_SPORTS_NOUNS = frozenset({
    "play", "move", "pitch", "dunk", "goal", "crossover", "mixtape", "beat",
})

_MID_TEMPORAL_FOLLOWERS = frozenset({
    "morning", "afternoon", "evening", "day", "week", "month", "year",
    "semester", "summer", "winter", "spring", "fall", "season",
})


# ── Stage 1: Tokenize ──────────────────────────────────────────

def tokenize(context: dict) -> dict:
    """Split text into words, resolve compound phrases and double negations.

    Reads: context["text"]
    Writes: context["words"]
    """
    text = context["text"]
    words = text.split()
    if not words:
        context["words"] = []
        return context

    # Bookend compounds: opener ... closer with variable filling
    collapsed = list(words)
    for opener, closer in _BOOKEND_COMPOUNDS.items():
        start_idx = None
        for idx, w in enumerate(collapsed):
            if w.lower() == opener:
                start_idx = idx
            elif w.lower() == closer and start_idx is not None and idx - start_idx <= 8:
                filling = [collapsed[j].lower() for j in range(start_idx + 1, idx)]
                all_spice = all(f in _SPICE_WORDS for f in filling)
                if all_spice:
                    compound = opener + closer
                    collapsed = collapsed[:start_idx] + [compound] + collapsed[idx+1:]
                start_idx = None
                break
    words = collapsed

    # Double negation compounds and special pairs
    sentence_cleaned = {w.lower().rstrip(".,!?;:'\"") for w in words}
    resolved = []
    i = 0
    context.setdefault("elongation", False)
    while i < len(words):
        w_low = words[i].lower()
        next_low = words[i + 1].lower() if i + 1 < len(words) else ""
        pair = (w_low, next_low)

        # "w" sense split: WIN keeps its vocab charge; "with" carries no
        # force at all (a zero-force atom would still drain V toward center)
        if w_low.rstrip(".,!?;:'\"") == "w":
            prev_clean = words[i - 1].lower().rstrip(".,!?;:'\"") if i > 0 else ""
            if prev_clean in _W_WIN_PRECEDERS or i == len(words) - 1:
                resolved.append(words[i])
            i += 1
            continue

        # "extra" sense split: prenominal quantity ("extra cheese") carries
        # no charge; the slang sense ("she's so extra") keeps its vocab force
        if w_low.rstrip(".,!?;:'\"") == "extra" and next_low:
            nf = VOCABULARY.get(next_low.rstrip(".,!?;:'\""))
            if nf is None or abs(nf[0]) < 10:
                i += 1
                continue

        # "trap" sense split: music genre ("trap remix") is inert; the
        # entrapment sense keeps its vocab force
        if (w_low.rstrip(".,!?;:'\"") == "trap"
                and next_low.rstrip(".,!?;:'\"") in _TRAP_GENRE_FOLLOWERS):
            i += 1
            continue

        # "fire" sense split: literal hazard vs predicate slang praise;
        # anything else keeps the (mild) stored vocab tuple
        if w_low.rstrip(".,!?;:'\"") == "fire":
            prev_clean = words[i - 1].lower().rstrip(".,!?;:'\"") if i > 0 else ""
            nxt_clean = next_low.rstrip(".,!?;:'\"")
            if prev_clean in _FIRE_HAZARD_PRECEDERS or nxt_clean in _FIRE_HAZARD_FOLLOWERS:
                resolved.append("firehazard")
                i += 1
                continue
            if prev_clean in _FIRE_PRAISE_PRECEDERS:
                resolved.append("firepraise")
                i += 1
                continue

        # "filthy" sense split: sports/performance praise when a sports
        # noun appears anywhere in the sentence
        if (w_low.rstrip(".,!?;:'\"") == "filthy"
                and not _FILTHY_SPORTS_NOUNS.isdisjoint(sentence_cleaned)):
            resolved.append("filthypraise")
            i += 1
            continue

        # "mid" sense split: temporal compound ("mid morning") is inert;
        # the dismissive slang sense keeps its vocab force
        if (w_low.rstrip(".,!?;:'\"") == "mid"
                and next_low.rstrip(".,!?;:'\"") in _MID_TEMPORAL_FOLLOWERS):
            i += 1
            continue

        if pair in _CONTINUATION_PAIRS:
            has_negative_before = any(
                VOCABULARY.get(words[j].lower(), (0,))[0] < -25
                for j in range(max(0, i - 3), i)
            )
            if has_negative_before:
                i += 2
            else:
                resolved.append(words[i])
                i += 1
        elif w_low == "no" and next_low == "one":
            next_after = words[i + 2].lower() if i + 2 < len(words) else ""
            if next_after in _ONE_AS_QUANTIFIER:
                resolved.append(words[i])
                i += 1
            else:
                resolved.append("nobody")
                i += 2
        elif w_low == "come" and next_low == "on":
            resolved.append("comeon")
            i += 2
        elif w_low == "killed" and next_low == "it":
            resolved.append("killedit")
            i += 2
        elif w_low == "goes" and next_low == "hard":
            resolved.append("goeshard")
            i += 2
        elif w_low == "hit" and next_low == "different":
            resolved.append("hitdifferent")
            i += 2
        elif w_low == "running" and next_low == "late":
            resolved.append("runninglate")
            i += 2
        elif w_low == "made" and next_low == "it":
            # "made it" → achievement compound
            next_after = words[i + 2].lower() if i + 2 < len(words) else ""
            if next_after == "through":
                resolved.append("madeitthrough")
                i += 3
            else:
                resolved.append("madeit")
                i += 2
        elif w_low == "came" and next_low == "back":
            # Look ahead for "negative" → medical idiom (good news)
            next_after = words[i + 2].lower() if i + 2 < len(words) else ""
            if next_after == "negative":
                resolved.append("camebacknegative")
                i += 3
            else:
                resolved.append(words[i])
                i += 1
        elif w_low == "closed" and next_low == "on":
            resolved.append("closedon")
            i += 2
        elif w_low == "no" and next_low == "cap":
            resolved.append("nocap")
            i += 2
        elif w_low == "no" and next_low == "way":
            resolved.append("noway")
            i += 2
        else:
            # Council Round 8: compound bond resolution (bigram + trigram)
            w_clean = w_low.rstrip(".,!?;:'\"")
            n_clean = next_low.rstrip(".,!?;:'\"")

            # Elongation collapse: "GOOOOOO" -> "go" for vocab-missing tokens
            # so compound bonds and vocab lookups still land. Doubled form
            # is tried first because runs hide geminates: "GOOOOOD" must
            # collapse to "good", not "god"
            def _alt(tok):
                if tok and tok not in VOCABULARY and _ELONG_RUN.search(tok):
                    two = _ELONG_RUN.sub(r"\1\1", tok)
                    if two in VOCABULARY:
                        return two
                    return _ELONG_RUN.sub(r"\1", tok)
                return tok
            w_alt = _alt(w_clean)
            n_alt = _alt(n_clean)

            # Trigram check first
            if i + 2 < len(words):
                n2_clean = words[i + 2].lower().rstrip(".,!?;:'\"")
                tri = (w_clean, n_clean, n2_clean)
                if tri in _COMPOUND_BONDS_TRI:
                    resolved.append(_COMPOUND_BONDS_TRI[tri])
                    i += 3
                    continue
                n2_alt = _alt(n2_clean)
                tri_alt = (w_alt, n_alt, n2_alt)
                if tri_alt != tri and tri_alt in _COMPOUND_BONDS_TRI:
                    resolved.append(_COMPOUND_BONDS_TRI[tri_alt])
                    context["elongation"] = True
                    i += 3
                    continue
            # Bigram check
            bi = (w_clean, n_clean)
            bi_alt = (w_alt, n_alt)
            if bi in _COMPOUND_BONDS:
                resolved.append(_COMPOUND_BONDS[bi])
                i += 2
            elif bi_alt != bi and bi_alt in _COMPOUND_BONDS:
                resolved.append(_COMPOUND_BONDS[bi_alt])
                context["elongation"] = True
                i += 2
            elif w_alt != w_clean and w_alt in VOCABULARY:
                resolved.append(w_alt)
                context["elongation"] = True
                i += 1
            else:
                resolved.append(words[i])
                i += 1
    words = resolved

    context["words"] = words
    return context


# ── Stage 2: Classify ──────────────────────────────────────────

def classify(context: dict) -> dict:
    """Classify words into structural roles, apply perspective remapping.

    Reads: context["words"], context["perspective"], context.get("personality")
    Writes: context["roles"], context["perspective"] (may be resolved from "auto"),
            context["has_universal"]
    """
    words = context["words"]
    perspective = context.get("perspective", "speaker")
    personality = context.get("personality")

    roles = classify_sentence(words)

    # Universal address detection
    has_universal = any(w.lower() in _UNIVERSAL_ADDRESS for w in words)
    context["has_universal"] = has_universal

    # Auto-detect perspective
    if perspective == "auto":
        has_self = any(wr.role == "SELF_REF" for wr in roles)
        has_second = any(wr.word.lower() in _SECOND_PERSON for wr in roles)
        if has_self:
            perspective = "speaker"
        elif has_second:
            perspective = "listener"
        elif has_universal:
            perspective = "listener"
        else:
            perspective = "bystander"

    # Perspective remapping
    if perspective == "listener":
        for wr in roles:
            if wr.role == "SELF_REF":
                wr.role = "OTHER_REF"
            elif wr.role == "OTHER_REF":
                wr.role = "SELF_REF"
    elif perspective == "bystander":
        if has_universal:
            for wr in roles:
                if wr.role == "SELF_REF":
                    wr.role = "OTHER_REF"
        else:
            for wr in roles:
                if wr.role in ("SELF_REF", "OTHER_REF"):
                    wr.role = "NEUTRAL"

    # Bystander self-projection
    if perspective == "bystander" and personality is not None:
        bystander_w = personality.assertiveness
        if bystander_w < 80:
            _THIRD_PERSON = {"she", "he", "her", "him", "they", "them"}
            for idx, wr in enumerate(roles):
                if wr.word.lower() in _THIRD_PERSON and wr.role == "NEUTRAL":
                    for j in range(max(0, idx - 3), min(len(roles), idx + 4)):
                        if j != idx and roles[j].role == "EMOTIONAL":
                            w_clean = _clean(roles[j].word)
                            forces = VOCABULARY.get(w_clean)
                            if forces and forces[0] < -10:
                                wr.role = "SELF_REF"
                                break

    # Absence scope
    absence_scope = set()
    for i, wr in enumerate(roles):
        if wr.word in _ABSENCE_MARKERS:
            if wr.word == "without":
                for j in range(i + 1, min(i + 4, len(roles))):
                    if roles[j].role not in _PERSON_ROLES:
                        absence_scope.add(j)
            elif i + 1 < len(roles) and roles[i + 1].word in _ABSENCE_FOLLOWERS:
                for j in range(i + 2, min(i + 7, len(roles))):
                    absence_scope.add(j)

    # Forced choice cancellation
    forced_choice_scope = set()
    for i, wr in enumerate(roles):
        if wr.word == "between":
            has_choice = any(roles[j].word in _CHOICE_VERBS
                           for j in range(max(0, i - 3), i))
            if has_choice:
                for j in range(i + 1, min(i + 6, len(roles))):
                    forced_choice_scope.add(j)

    context["roles"] = roles
    context["perspective"] = perspective
    context["absence_scope"] = absence_scope
    context["forced_choice_scope"] = forced_choice_scope
    return context


# ── Stage 2.5: Interpret Context ──────────────────────────────
# GPT's insight: "words → role/context interpretation → forces → result"
# This layer reclassifies roles based on PRAGMATIC FUNCTION before
# forces are computed. The physics is the same — the interpretation
# of what role each atom plays changes based on molecular context.

_DISCOURSE_AFFIRMS = frozenset({"no", "nah", "nope"})
_DISCOURSE_LOOKAHEAD_POS = frozenset({
    "good", "fine", "right", "great", "cool", "ok", "okay",
    "way", "cap", "doubt", "kidding", "worries",
})
_DISCOURSE_SELF_TOKENS = frozenset({"i", "im", "i'm", "ive", "i've"})
_LIQUID_LITERAL_FOLLOWERS = {
    "cooked": frozenset({"dinner", "breakfast", "lunch", "food", "meal",
                         "meals", "rice", "chicken", "steak", "pasta",
                         "eggs"}),
}
_EXPLETIVE_WORDS = frozenset({"shit", "fuck", "damn", "hell", "goddamn"})
_COUNTERFACTUAL_MARKERS = frozenset({
    "supposed", "would", "should", "could", "wished", "hoped",
})
_PAST_TRUST_VERBS = frozenset({
    "trusted", "believed", "thought", "assumed", "expected",
})
_INSTRUCTIONAL_CUES = frozenset({
    "please", "proceed", "enter", "select", "follow",
    "click", "press", "navigate", "submit", "confirm",
    "objective", "quest", "mission", "instructions",
})


def interpret_context(context: dict) -> dict:
    """Reinterpret word roles based on pragmatic context.

    Runs AFTER classify, BEFORE force computation. Modifies roles
    and sets context flags that downstream stages use.

    Fixes:
    1. Discourse markers: "no we good" → "no" becomes DISCOURSE_AFFIRM
    2. Expletive-as-intensifier: "shit you are right" → "shit" becomes AMPLIFIER
    3. Register detection: instructional text → force dampening flag
    4. Counterfactual marking: "supposed to" → positive forces inverted
    5. Hard negator inversion: NEGATOR + strong positive → full sign flip
    """
    roles = context["roles"]
    if not roles:
        return context

    words = [r.word for r in roles]
    n = len(roles)

    # ── 1. Discourse markers ──────────────────────────────────
    # "no we good" — "no" followed by positive content = affirm, not negate
    for i, wr in enumerate(roles):
        if wr.word in _DISCOURSE_AFFIRMS and wr.role == "NEGATOR":
            # Check 3-token lookahead for positive signal
            lookahead = words[i+1:i+4]
            has_positive_ahead = any(w in _DISCOURSE_LOOKAHEAD_POS for w in lookahead)
            # Also check: is there a positive emotional word ahead?
            has_emo_pos = any(
                roles[j].force and roles[j].force[0] > 10
                for j in range(i+1, min(i+4, n))
            )
            if has_positive_ahead or has_emo_pos:
                # Reclassify: this "no" is discourse, not negation
                wr.role = "FILLER"
                wr.base_role = "FILLER"
            elif i == 0 and n > 1 and roles[1].word in _DISCOURSE_SELF_TOKENS:
                # "no, i'm sick" — sentence-initial answer-marker before a
                # self-statement negates the (implied) question, not the
                # clause that follows
                wr.role = "FILLER"
                wr.base_role = "FILLER"

    # ── 2. Expletive-as-intensifier ───────────────────────────
    # "shit you are right" — sentence-initial expletive + positive/affirm content
    _AFFIRM_WORDS = frozenset({
        "right", "true", "yes", "exactly", "correct", "agreed",
        "good", "great", "nice", "thanks", "thank",
    })
    # Rescue sites: sentence-initial, or right after a demonstrative
    # ("This shit slaps" — the demonstrative marks the expletive as subject
    # stand-in, not invective)
    _DEMONSTRATIVES = frozenset({"this", "that", "the"})
    for idx in range(n):
        if roles[idx].word not in _EXPLETIVE_WORDS:
            continue
        if idx != 0 and roles[idx - 1].word not in _DEMONSTRATIVES:
            continue
        # Check if next clause has positive content OR affirmative words
        pos_ahead = sum(
            1 for j in range(idx + 1, min(idx + 6, n))
            if (roles[j].force and roles[j].force[0] > 10)
            or roles[j].word in _AFFIRM_WORDS
        )
        if pos_ahead > 0:
            # Convert expletive to amplifier. The explicit zero tuple (not
            # None) blocks every downstream `force or VOCABULARY.get(word)`
            # fallback from re-resolving the expletive's negative charge
            roles[idx].role = "AMPLIFIER"
            roles[idx].force = (0, 0, 0, 0, 0)

    # ── 2b. Degree-adverb slang ───────────────────────────────
    # "stupid good" / "insanely talented" — the negative adjective is a
    # degree marker on the positive word, not its own charge
    _DEGREE_SLANG = frozenset({"stupid", "dumb", "insanely", "ridiculously",
                               "stupidly"})
    for idx in range(n - 1):
        if roles[idx].word not in _DEGREE_SLANG:
            continue
        nf = roles[idx + 1].force or VOCABULARY.get(roles[idx + 1].word)
        if nf and nf[0] >= 20:
            roles[idx].role = "AMPLIFIER"
            roles[idx].force = (0, 0, 0, 0, 0)

    # ── 3. Register detection (Council Round 7) ────────────────
    # CONVERSATIONAL = full force (speaker emitting charge)
    # LITERARY = dampened force (charge is described/reported, not felt)
    # EXPOSITORY = heavily dampened (procedural/instructional text)
    #
    # Detection via Dielectric Index (Gemini) + agency structure (GPT):
    # High article/3rd-person density + no 1st/2nd person + no casual markers = narration
    # The medium is dense — weak atoms scatter, strong atoms punch through dampened.
    instructional_count = sum(1 for w in words if w in _INSTRUCTIONAL_CUES)

    _CASUAL_SIGNALS = {"im", "ive", "youre", "dont", "cant", "wont",
                       "gonna", "wanna", "gotta", "lol", "lmao", "bruh", "bro",
                       "dude", "omg", "tbh", "ngl", "fr", "nah", "yeah", "yep",
                       "hey", "yo", "haha", "ok", "okay"}
    _ARTICLES = {"the", "a", "an", "this", "these", "those"}
    _THIRD_PERSON = {"he", "she", "they", "it", "his", "her", "its", "their",
                     "him", "them"}
    _FIRST_PERSON = {"i", "im", "ive", "my", "me", "we", "us", "our"}
    _SECOND_PERSON = {"you", "your", "youre"}
    _LITERARY_VERBS = {"seized", "seizing", "walked", "strode", "gazed",
                       "muttered", "exclaimed", "remarked", "observed",
                       "whispered", "cried", "replied", "declared"}

    casual_count = sum(1 for w in words if w in _CASUAL_SIGNALS)
    has_exclamation = any(w.endswith('!') for w in words)
    has_quotes = context["text"].count('"') >= 2
    c_art = sum(1 for w in words if w in _ARTICLES)
    c_3rd = sum(1 for w in words if w in _THIRD_PERSON)
    c_1st = sum(1 for w in words if w in _FIRST_PERSON)
    c_2nd = sum(1 for w in words if w in _SECOND_PERSON)
    c_lit_verb = sum(1 for w in words if w in _LITERARY_VERBS)
    past_count = sum(1 for w in words if w.endswith("ed") or w in {"was", "were", "had", "been"})

    # Dielectric Index: (articles + 3rd person) / total tokens
    dx = (c_art + c_3rd) / max(1, n)
    past_ratio = past_count / max(1, n)
    # Observation score (GPT): agency-based
    obs_score = dx + 0.3 * past_ratio + (0.1 if casual_count == 0 else 0)
    # Literary verb bonus (Claude)
    if c_lit_verb >= 1:
        obs_score += 0.2

    # Decision hierarchy:
    # 1. Instructional cues → EXPOSITORY (strongest override)
    # 2. Casual markers or exclamation or quotes → CONVERSATIONAL
    # 3. High observation score + no 1st/2nd person + length >= 5 → LITERARY
    # 4. Default → CONVERSATIONAL
    if instructional_count >= 1:
        register = "EXPOSITORY"
        context["register_dampener"] = 0.35
    elif casual_count >= 1 or has_exclamation or has_quotes:
        register = "CONVERSATIONAL"
        context["register_dampener"] = 1.0
    elif obs_score >= 0.50 and c_1st == 0 and c_2nd == 0 and casual_count == 0 and n >= 10:
        register = "LITERARY"
        # Gemini's mass-dependent scattering: applied per-word in accumulate_forces
        context["register_dampener"] = 0.55
    else:
        register = "CONVERSATIONAL"
        context["register_dampener"] = 1.0

    context["register"] = register

    # ── 4. Counterfactual marking ─────────────────────────────
    # "supposed to", "would have" → flag for force inversion
    has_counterfactual = any(w in _COUNTERFACTUAL_MARKERS for w in words)
    has_past_trust = any(w in _PAST_TRUST_VERBS for w in words)
    context["counterfactual"] = has_counterfactual
    context["past_trust"] = has_past_trust

    # ── 5. Hard negator inversion ─────────────────────────────
    # NEGATOR within 3 tokens of positive EMOTIONAL (dV > 25) → flip sign
    # Also reclassify the NEGATOR to FILLER so proximity doesn't double-negate
    negators_consumed = set()
    for i, wr in enumerate(roles):
        if wr.role == "NEGATOR":
            for j in range(i+1, min(n, i+4)):
                jr = roles[j]
                f = jr.force or VOCABULARY.get(jr.word)
                if f and f[0] > 25:
                    # Full inversion: flip the positive word's dV
                    old_f = f
                    jr.force = (-old_f[0], old_f[1], -old_f[2], old_f[3], old_f[4])
                    negators_consumed.add(i)
                    break
            # Also consume negators that precede a communication/action verb
            # before distant emotional content.
            # "nobody tells you grief" → "nobody" negates "tells", not "grief"
            # "nobody loves me" → "nobody" SHOULD negate "loves" → don't consume
            # Key: consume only when intervening word is a low-charge comm verb
            _COMM_VERBS = frozenset({
                "tells", "told", "says", "said", "asks", "asked",
                "knows", "knew", "thinks", "thought", "warns", "warned",
                "prepares", "prepared", "expects", "expected",
                "mentions", "mentioned", "explains", "explained",
            })
            if i not in negators_consumed:
                for j in range(i+1, min(n, i+3)):
                    if roles[j].word in _COMM_VERBS:
                        negators_consumed.add(i)
                        break
    # Consumed negators become FILLER so proximity doesn't double-apply
    for i in negators_consumed:
        roles[i].role = "FILLER"

    # ── 6. SOLVENT dissolution ────────────────────────────────
    # SOLVENT words (bruh, lol, lmao, dude, etc.) dissolve LIQUID atoms
    # "bruh im shook" → SOLVENT(bruh) flips LIQUID(shook) from negative to positive
    # "bruh he got murdered" → SOLVENT can't dissolve SOLID(murdered)

    # Literal-verb guard: a LIQUID slang verb followed by its concrete
    # object ("cooked dinner") or preceded by "home" is the literal sense
    # — neutral, not the stored slang-doom charge, and not dissolvable
    for idx, wr in enumerate(roles):
        followers = _LIQUID_LITERAL_FOLLOWERS.get(wr.word)
        if not followers:
            continue
        nxt = roles[idx + 1].word if idx + 1 < n else ""
        prev = roles[idx - 1].word if idx > 0 else ""
        if nxt in followers or prev == "home":
            wr.force = (0, 0, 0, 0, 0)

    has_solvent = any(is_solvent(wr.word) for wr in roles)
    if has_solvent:
        for wr in roles:
            if get_phase(wr.word) == "LIQUID":
                # Get the force (may be on wr.force or in VOCABULARY)
                f = wr.force or VOCABULARY.get(wr.word)
                if f and f[0] < -5:
                    # Flip dV sign, keep arousal (the energy stays, charge flips)
                    flipped = (-f[0], f[1], abs(f[2]), f[3], abs(f[4]))
                    wr.force = flipped
        context["solvent_active"] = True
    else:
        context["solvent_active"] = False

    # ── 7. Sarcasm inversion field (Council Round 6) ────────────
    # Genuine enthusiasm radiates amplification energy. Sarcasm is a
    # cold molecule — positive surface with zero kinetic energy.
    # Ironic onset + tepid positive + zero amplifiers → invert.
    _IRONIC_ONSETS = frozenset({
        "clearly", "oh", "wow", "sure", "right", "great",
        "nice", "yeah", "gee", "wonderful", "brilliant", "lovely",
    })
    _AMPLIFIERS = frozenset({
        "so", "really", "very", "super", "extremely", "absolutely",
        "honestly", "seriously", "genuinely", "totally",
    })
    first_word = words[0] if words else ""
    two_word = " ".join(words[:2]) if len(words) >= 2 else ""
    has_ironic_onset = first_word in _IRONIC_ONSETS or two_word in ("what a", "oh great", "oh cool", "oh nice")
    has_amplifier = any(w in _AMPLIFIERS for w in words) or any(w.endswith("!") for w in words)

    if has_ironic_onset and not has_amplifier and not has_solvent:
        # Count all charged atoms
        total_charge = 0
        pos_count = 0
        neg_count = 0
        for wr in roles:
            f = wr.force or VOCABULARY.get(wr.word)
            if f:
                total_charge += f[0]
                if f[0] > 0:
                    pos_count += 1
                elif f[0] < 0:
                    neg_count += 1

        # Flat affect: ironic onset + mostly near-zero/mildly negative atoms
        # + no strong positive to anchor genuine enthusiasm
        # This catches: "clearly this was well thought out" (all atoms -5 or 0)
        # "oh cool cant wait for that" (all atoms -5 to -10)
        if neg_count >= pos_count and total_charge < 0 and neg_count >= 1:
            # All atoms weakly negative + ironic onset = sarcasm
            # Apply a structural V penalty
            context["sarcasm_inversion"] = True
            context["sarcasm_penalty"] = -15.0  # applied in apply_structures

    # ── 7b. Contrast sarcasm: strong positive + negative in same sentence ──
    # "I am just overjoyed to clean up your mess" — "overjoyed" near "mess"
    # "Thanks for that incredibly useless advice" — "thanks" near "useless"
    # The CONTRAST between positive and negative in a short span = sarcasm
    if not context.get("sarcasm_inversion") and not has_solvent:
        strong_pos = []
        strong_neg = []
        for wr in roles:
            f = wr.force or VOCABULARY.get(wr.word)
            if f:
                if f[0] >= 15:
                    strong_pos.append(wr.word)
                elif f[0] <= -15:
                    strong_neg.append(wr.word)
        # Both strong positive AND strong negative = contrast sarcasm
        if strong_pos and strong_neg and len(words) <= 15:
            context["sarcasm_inversion"] = True
            context["sarcasm_penalty"] = -12.0

    context["roles"] = roles
    return context


# ── Stage 3: Compute Coefficients (structure detection + force flow) ─

def compute_coefficients(context: dict) -> dict:
    """Detect structures and resolve force flow.

    Reads: context["roles"]
    Writes: context["structures"], context["force_flow"], context["flow_mods"]
    """
    roles = context["roles"]

    detector = StructureDetector()
    structures = detector.detect_all(roles)

    force_flow = resolve_force_flow(roles)
    flow_mods = compute_flow_modifiers(force_flow)

    context["structures"] = structures
    context["force_flow"] = force_flow
    context["flow_mods"] = flow_mods
    return context


# ── Stage 4: Accumulate Forces ──────────────────────────────────

def accumulate_forces(context: dict) -> dict:
    """Per-word force application loop with adaptive momentum.

    Reads: context["roles"], context["absence_scope"], context["forced_choice_scope"],
           context["force_flow"], context["flow_mods"],
           context["register_dampener"], context["counterfactual"], context["past_trust"]
    Writes: context["state_v"], context["state_a"], context["state_d"],
            context["state_u"], context["state_g"], context["state_w"],
            context["trace_entries"]
    """
    roles = context["roles"]
    absence_scope = context.get("absence_scope", set())
    forced_choice_scope = context.get("forced_choice_scope", set())
    force_flow = context.get("force_flow")
    flow_mods = context.get("flow_mods", {})

    state_v = CENTER
    state_a = CENTER
    state_d = CENTER
    state_u = 0.0
    state_g = CENTER
    state_w = CENTER

    trace_entries: List[dict] = []

    for i, wr in enumerate(roles):
        if wr.role == "POSSESSION":
            vf = VOCABULARY.get(wr.word)
            if vf:
                word_force = (0, 0, 0, 0, max(5, vf[4]))
            else:
                word_force = (0, 0, 0, 0, 5)
        else:
            word_force = wr.force
            if word_force is None:
                word_force = VOCABULARY.get(wr.word)
            if word_force is None:
                from .fuzzy import fuzzy_match
                matched = fuzzy_match(wr.word)
                if matched:
                    word_force = VOCABULARY.get(matched)
            # A zero-charge tuple on a connector/temporal/amplifier word
            # carries no information — treating it as a hit would drain
            # accumulated deviation back toward center, punishing sentences
            # for containing function words. Only above center: positive
            # deviation is protected from erosion, while below center the
            # drain keeps safe text neutral (mirrors M_NEGATIVITY_BIAS /
            # M_POSITIVITY_EASE asymmetry).
            _INERT_ZERO_ROLES = {"CONNECTOR", "TEMPORAL", "FILLER",
                                 "AMPLIFIER"}
            if (word_force is not None
                    and state_v >= CENTER
                    and wr.role in _INERT_ZERO_ROLES
                    and word_force[0] == 0 and word_force[1] == 0
                    and word_force[2] == 0 and word_force[3] == 0
                    and abs(word_force[4]) <= 5):
                word_force = None
            # Same protection for near-zero register markers: a trailing
            # "ngl"/"fr"/"tbh" is punctuation-grade, not a counter-force
            if (word_force is not None
                    and state_v >= CENTER
                    and (wr.role == "REGISTER_CASUAL" or is_solvent(wr.word))
                    and abs(word_force[0]) <= 5 and abs(word_force[1]) <= 5
                    and abs(word_force[2]) <= 5 and word_force[3] == 0
                    and abs(word_force[4]) <= 5):
                word_force = None

        if word_force is None:
            trace_entries.append({
                "word": wr.word,
                "role": wr.role,
                "coeff": 0.0,
                "v": round(state_v),
                "a": round(state_a),
                "d": round(state_d),
                "u": round(state_u),
                "g": round(state_g),
                "w": round(state_w),
            })
            continue

        dv, da, dd, du, dg = word_force

        # ── REGISTER DAMPENING ──
        # LITERARY: Gemini's mass-dependent scattering — weak atoms scatter,
        # strong atoms punch through dampened. dV_eff = dV * (1 - e^(-k|dV|)) * ε
        # EXPOSITORY: flat dampening (instructional text)
        reg_damp = context.get("register_dampener", 1.0)
        if reg_damp < 1.0:
            if context.get("register") == "LITERARY":
                # Mass-dependent scattering: weak atoms crushed, strong survive
                scatter = 1.0 - exp(-0.05 * abs(dv))  # approaches 1.0 for strong atoms
                dv = int(dv * scatter * reg_damp)
                da = int(da * scatter * reg_damp)
                dd = int(dd * scatter * reg_damp)
            else:
                # Flat dampening for EXPOSITORY
                dv = int(dv * reg_damp)
                da = int(da * reg_damp)
                dd = int(dd * reg_damp)

        # ── COUNTERFACTUAL INVERSION: positive in past/counterfactual → grief ──
        if context.get("counterfactual") and dv > 10:
            dv = int(dv * -0.75)  # 75% inversion
        elif context.get("past_trust") and dv > 5:
            dv = int(dv * 0.5)  # dampen positive (broken trust context)

        # ── MUNDANE DAMPENING: massless context absorbs crisis energy ──
        # High-charge emotional atoms (|dV| > threshold) get dampened when
        # the sentence's subject/agent is a mundane noun (low gravity).
        # "homework is killing me" — subject=homework (G=0) → DAMPEN
        # "i want to kill myself" — subject=I (SELF_REF) → PRESERVE
        # The subject is the first substantive noun before the crisis verb,
        # skipping connectors, determiners, and filler.
        if abs(dv) >= MUNDANE_DV_THRESHOLD and wr.role == "EMOTIONAL":
            _AGENTIC_ROLES = {"SELF_REF", "OTHER_REF", "RELATION_REF"}
            _SKIP_ROLES = {"CONNECTOR", "NEUTRAL", "FILLER", "TEMPORAL",
                          "AMPLIFIER", "NEGATOR", "COMPRESSOR", "HEDGE"}
            # Scan backward for the first substantive word (subject)
            agent_g = None
            agent_is_person = False
            for j in range(i - 1, max(-1, i - 8), -1):
                jr = roles[j]
                if jr.role in _AGENTIC_ROLES:
                    agent_is_person = True
                    break
                # Skip function words — look for the actual subject noun
                if jr.role in _SKIP_ROLES:
                    jf = jr.force or VOCABULARY.get(jr.word)
                    # Word with no force at all = maximally mundane (G=0)
                    if jf is None:
                        agent_g = 0
                        break
                    # Word with low charge + low gravity = mundane subject
                    if abs(jf[0]) < 10 and abs(jf[4]) < 15:
                        agent_g = abs(jf[4])
                        break
                    continue
                if jr.role in ("EMOTIONAL", "POSSESSION"):
                    jf = jr.force or VOCABULARY.get(jr.word)
                    jg = abs(jf[4]) if jf else 0
                    agent_g = jg
                    break
            # Only dampen if subject is mundane (not a person, low G)
            if not agent_is_person and agent_g is not None and agent_g < 15:
                D = (agent_g + MUNDANE_EPSILON) / (agent_g + MUNDANE_ALPHA * abs(dv))
                dv = int(dv * D)
                da = int(da * D)

        # Forced choice cancellation
        if i in forced_choice_scope and dv > 0:
            dv = -dv

        # Absence scope dampening
        if i in absence_scope and dv < -10:
            dv = int(dv * 0.2)
            da = int(da * 0.3)
            dd = int(dd * 0.3)

        # "Without" as pure operator
        if wr.word in ("without",) and any(j in absence_scope for j in range(i+1, min(i+4, len(roles)))):
            dv = 0
            da = 0
            dd = 0

        # Force flow direction modifiers
        if i == (force_flow.force_idx if force_flow else -1):
            dv = int(dv * flow_mods["v_mod"])
            dd = int(dd * flow_mods["d_mod"])
        coeff = proximity_coefficient(roles, i)

        # Target = center + force * coefficient * scale
        target_v = CENTER + dv * coeff * FORCE_SCALE
        target_a = CENTER + da * coeff * FORCE_SCALE
        target_d = CENTER + dd * coeff * FORCE_SCALE
        target_u = du * abs(coeff) * FORCE_SCALE
        target_g = CENTER + dg * coeff * FORCE_SCALE

        # Direct push for strong forces
        total_force = abs(dv) + abs(da) + abs(dd) + abs(du) + abs(dg)
        push_strength = min(1.0, total_force / DIRECT_PUSH_TRIGGER) * DIRECT_PUSH_CAP

        push_v = push_strength * (1.0 if dv * coeff >= 0 else -1.0) * abs(dv) * FORCE_SCALE
        push_a = push_strength * (1.0 if da * coeff >= 0 else -1.0) * abs(da) * FORCE_SCALE
        push_d = push_strength * (1.0 if dd * coeff >= 0 else -1.0) * abs(dd) * FORCE_SCALE
        push_u = push_strength * abs(du) * FORCE_SCALE
        push_g = push_strength * (1.0 if dg * coeff >= 0 else -1.0) * abs(dg) * FORCE_SCALE

        # Adaptive momentum
        m_eff = M_BASE + (state_a - CENTER) / 255.0 * M_AROUSAL_SCALE

        if state_v < CENTER and target_v > state_v:
            m_v = max(M_MIN, min(M_MAX, m_eff * M_NEGATIVITY_BIAS))
        elif state_v > CENTER and target_v < state_v:
            m_v = max(M_MIN, min(M_MAX, m_eff * M_POSITIVITY_EASE))
        else:
            m_v = max(M_MIN, min(M_MAX, m_eff))

        m_eff = max(M_MIN, min(M_MAX, m_eff))

        inv_m_v = 1.0 - m_v
        inv_m = 1.0 - m_eff
        inv_m_base = 1.0 - M_BASE
        state_v = state_v * m_v + target_v * inv_m_v + push_v
        state_a = state_a * m_eff + target_a * inv_m + push_a
        state_d = state_d * m_eff + target_d * inv_m + push_d
        state_u = state_u * M_BASE + target_u * inv_m_base + push_u
        state_g = state_g * M_BASE + target_g * inv_m_base + push_g

        # W (self-worth): valence routed through attribution (Board 2).
        # R answers "how much of this force's valence is about MY worth?"
        # At the force word, R comes from the force-flow arc when it resolved
        # real entity tokens:
        #   self-declarative  ("i am worthless")          R = w_mod (1.5 neg / 0.7 pos)
        #   force aimed at self ("he told me im nothing") R = w_mod (1.2 neg / 0.9 pos)
        #   self harms other  ("i hurt her") — guilt      R = 0.43 (x0.7 damp ≈ 0.30)
        #   other-directed / atmospheric, no self token   R = 0 (W untouched —
        #     "the weather is awful" says nothing about MY worth)
        # Elsewhere (and when the flow resolved nothing or only implied
        # entities) W falls back to the legacy SELF_REF-proximity gate.
        self_ref_nearby = any(
            roles[j].role == "SELF_REF" and abs(j - i) <= 4
            for j in range(max(0, i - 4), min(len(roles), i + 5))
            if j != i
        )
        w_damp = 0.7
        w_flow = flow_mods["w_mod"] if force_flow and i == force_flow.force_idx else 1.0
        w_apply = self_ref_nearby
        if force_flow is not None and i == force_flow.force_idx and dv != 0:
            _a_role = force_flow.actor_role
            _t_role = force_flow.target_role
            _actor_token = force_flow.actor_idx >= 0
            _target_token = force_flow.target_idx >= 0
            _self_token_involved = ((_a_role == "SELF_REF" and _actor_token)
                                    or (_t_role == "SELF_REF" and _target_token))
            if (_a_role == "SELF_REF" and _actor_token
                    and _t_role in ("OTHER_REF", "RELATION_REF") and _target_token
                    and dv < 0):
                # Guilt: self as actor of a negative act toward another.
                # Partial self-attribution — your worth dips, but the
                # valence belongs mostly to what you did, not what you are.
                w_flow = 0.43
                w_apply = True
            elif _self_token_involved:
                # Self-declarative or self-targeted force: route the
                # valence into W even when the SELF token sits outside
                # the 4-word proximity window.
                w_apply = True
        if w_apply and dv != 0:
            w_effective = dv * coeff * FORCE_SCALE * w_damp * w_flow
            target_w = CENTER + w_effective
            push_w = push_strength * (1.0 if dv * coeff >= 0 else -1.0) * abs(dv) * FORCE_SCALE * w_damp * w_flow
            state_w = state_w * m_eff + target_w * inv_m + push_w

        trace_entries.append({
            "word": wr.word,
            "role": wr.role,
            "coeff": round(coeff, 3),
            "v": round(state_v),
            "a": round(state_a),
            "d": round(state_d),
            "u": round(state_u),
            "g": round(state_g),
            "w": round(state_w),
        })

    # ── BIDIRECTIONAL CORRECTION (sentence-level A+B=C) ─────────────
    # Instead of a full backward pass, pre-scan for the strongest emotional
    # atom. If it's in the second half of the sentence AND the forward pass
    # didn't reach its polarity, apply a correction push.
    # This fixes "I just got laid off from work" where the event "laidoff"
    # is in the middle but momentum recovery from trailing neutral words
    # erases its charge.
    strongest_dv = 0
    strongest_pos = 0
    mid = len(roles) // 2
    for i, wr in enumerate(roles):
        f = wr.force or VOCABULARY.get(wr.word)
        if f and abs(f[0]) > abs(strongest_dv):
            strongest_dv = f[0]
            strongest_pos = i

    # If strongest atom is past midpoint and forward V disagrees with its polarity
    if strongest_pos >= mid and abs(strongest_dv) >= 20:
        fwd_dev = state_v - CENTER
        atom_direction = 1 if strongest_dv > 0 else -1
        fwd_direction = 1 if fwd_dev > 0 else -1 if fwd_dev < 0 else 0

        if atom_direction != fwd_direction or abs(fwd_dev) < abs(strongest_dv) * 0.3:
            # Forward pass didn't capture the event's polarity — apply correction
            correction = strongest_dv * 0.3 * FORCE_SCALE
            state_v += correction

    context["state_v"] = state_v
    context["state_a"] = state_a
    context["state_d"] = state_d
    context["state_u"] = state_u
    context["state_g"] = state_g
    context["state_w"] = state_w
    context["trace_entries"] = trace_entries
    return context


# ── Stage 5: Apply Structures ──────────────────────────────────

def apply_structures(context: dict) -> dict:
    """Apply structure detection adjustments to state.

    Reads: context["state_*"], context["structures"], context["roles"]
    Writes: context["state_v"], context["state_d"], context["state_u"],
            context["state_g"], context["state_w"]
    """
    structures = context.get("structures", [])
    roles = context.get("roles", [])
    state_v = context["state_v"]
    state_a = context["state_a"]
    state_d = context["state_d"]
    state_u = context["state_u"]
    state_g = context["state_g"]
    state_w = context["state_w"]

    for sm in structures:
        if sm.pattern == "SLANG_DEATH_HUMOR":
            # Nullify death word's negative dV and add back a positive-scaled version.
            # The death word is NOT functioning as death -- it's an intensifier.
            # V_corrected = V_raw - dV("dead") + 0.7 * abs(dV("dead"))
            death_dv_total = 0
            for idx in sm.matched_indices:
                if idx < len(roles):
                    w = roles[idx].word
                    vf = VOCABULARY.get(w)
                    if vf and vf[0] < -10:  # negative death word
                        death_dv_total += vf[0]
            if death_dv_total < 0:
                # Subtract the death word's accumulated negative push and add positive version.
                # Use 1.2x on the nullification to account for momentum/push amplification
                # during accumulation, and 0.7x for the positive reinterpretation.
                correction = 1.2 * abs(death_dv_total) * FORCE_SCALE + 0.7 * abs(death_dv_total) * FORCE_SCALE
                state_v += correction * sm.confidence
            else:
                # Fallback: pull toward center
                distance = CENTER - state_v
                state_v += distance * 1.2 * sm.confidence
            state_w = max(state_w, CENTER)
        elif sm.pattern == "AMBIGUITY_HOLD":
            # Extreme V contradiction with no disambiguator: pull V toward W (neutral baseline).
            # V_final = V + (W - V) * 0.85
            state_v = state_v + (state_w - state_v) * 0.85 * sm.confidence
        elif sm.pattern == "RECOVERY_MILESTONE":
            # Recovery milestone: apply v_weight as direct positive boost
            state_v += sm.v_weight * sm.confidence * FORCE_SCALE
        elif sm.pattern == "HEDGED_ASSESSMENT":
            # A trailing hedge ("i guess", "i suppose") DAMPS conviction;
            # it does not add negative content. The hedge word's raw vocab
            # force (e.g. "guess" V-21) already accumulated as if it were
            # content -- refund it (1.3x for momentum amplification), then
            # apply the structure's mild damp.
            hedge_dv = 0.0
            hedge_dw = 0.0
            for idx in sm.matched_indices:
                if idx < len(roles):
                    hf = roles[idx].force or VOCABULARY.get(roles[idx].word)
                    if hf:
                        if hf[0] < 0:
                            hedge_dv += hf[0]
                        if len(hf) > 4 and hf[4] < 0:
                            hedge_dw += hf[4]
            if hedge_dv < 0:
                state_v += 1.3 * abs(hedge_dv) * FORCE_SCALE
            if hedge_dw < 0:
                state_w += 1.3 * abs(hedge_dw) * FORCE_SCALE
            state_v += sm.v_weight * sm.confidence * FORCE_SCALE
        elif sm.pattern in ("SARCASM_INVERSION", "BRAVADO", "DIRECTED_POSITIVE", "EXCLUDED_POSITIVE", "GRIEF_LOSS", "ATMOSPHERIC_GRIEF", "RHETORICAL_SELF_NEGATION", "REPORTED_COMFORT", "PASSIVE_RESIGNATION", "MASKING", "TEMPORAL_GRIEVANCE", "EXCLUSION_CONTRAST", "IRONIC_DEFERENCE", "FAINT_PRAISE", "RETROSPECTIVE_HOPE") and state_v > CENTER:
            excess = state_v - CENTER
            pull = sm.v_weight * sm.confidence * FORCE_SCALE * (1.0 + excess / 50.0)
            state_v += pull
            if sm.pattern == "RHETORICAL_SELF_NEGATION" and state_w > CENTER:
                w_excess = state_w - CENTER
                w_pull = sm.w_weight * sm.confidence * FORCE_SCALE * (1.0 + w_excess / 50.0)
                state_w += w_pull
                sm = StructureMatch(
                    pattern=sm.pattern, confidence=sm.confidence,
                    matched_indices=sm.matched_indices, description=sm.description,
                    v_weight=sm.v_weight, d_weight=sm.d_weight, u_weight=sm.u_weight,
                    g_weight=sm.g_weight, w_weight=0.0,
                )
        elif sm.pattern == "CHOPPER_SPLIT" and sm.matched_indices:
            chop_pos = sm.matched_indices[0]
            after_words = [wr for wr in roles if wr.position > chop_pos]
            after_v_sum = 0
            for wr in after_words:
                wf = wr.force or VOCABULARY.get(wr.word)
                if wf:
                    after_v_sum += wf[0]
            has_negator_after = any(wr.role == "NEGATOR" for wr in after_words)
            if (state_v > CENTER and (after_v_sum < 0 or (after_v_sum == 0 and has_negator_after))):
                distance = state_v - CENTER
                state_v -= distance * 1.5 * sm.confidence
            elif (state_v < CENTER and after_v_sum > 10):
                distance = CENTER - state_v
                state_v += distance * 0.4 * sm.confidence
        else:
            state_v += sm.v_weight * sm.confidence * FORCE_SCALE
        state_a += sm.a_weight * sm.confidence * FORCE_SCALE
        state_d += sm.d_weight * sm.confidence * FORCE_SCALE
        state_u += sm.u_weight * sm.confidence * FORCE_SCALE
        state_g += sm.g_weight * sm.confidence * FORCE_SCALE
        state_w += sm.w_weight * sm.confidence * FORCE_SCALE

    # Compound bond event anchoring (Council Round 8)
    # If a compound bond exists, it's the EVENT NUCLEUS — anchor V toward its charge
    _COMPOUND_VOCAB_KEYS = set(_COMPOUND_BONDS.values()) | set(_COMPOUND_BONDS_TRI.values())
    for wr in roles:
        if wr.word in _COMPOUND_VOCAB_KEYS:
            f = wr.force or VOCABULARY.get(wr.word)
            if f and abs(f[0]) >= 25:
                # Anchor: push V toward the compound's charge direction
                anchor_push = f[0] * 0.4 * FORCE_SCALE
                state_v += anchor_push

    # Sarcasm inversion penalty (from interpret_context step 7)
    sarcasm_penalty = context.get("sarcasm_penalty", 0.0)
    if sarcasm_penalty != 0.0:
        state_v += sarcasm_penalty * FORCE_SCALE

    context["state_v"] = state_v
    context["state_a"] = state_a
    context["state_d"] = state_d
    context["state_u"] = state_u
    context["state_g"] = state_g
    context["state_w"] = state_w
    return context


# ── Stage 5b: Static Friction (Council Round 7) ──────────────────
# Gemini's model: if no atom has |dV| > threshold, the sentence lacks
# emotional conviction. The pendulum can't overcome static friction.
# V deviation from center is dampened by (|dV_max| / threshold)².

STATIC_FRICTION_THRESHOLD = 20  # minimum |dV| to overcome friction (15 was too aggressive)

def apply_static_friction(context: dict) -> dict:
    """Prevent weak negative/positive drift from accumulated noise.

    If no word in the sentence has |dV| > threshold, the total V deviation
    is dampened by a squared ratio. Stronger max atoms = less dampening.

    Exempted when crisis structures fire (dangling bonds etc. have zero-charge
    atoms but structural signals that override).
    """
    state_v = context["state_v"]
    roles = context.get("roles", [])
    structures = context.get("structures", [])

    # Find max absolute dV in the sentence
    max_abs_dv = 0
    for wr in roles:
        f = wr.force or VOCABULARY.get(wr.word)
        if f:
            max_abs_dv = max(max_abs_dv, abs(f[0]))

    # Exempt if crisis structures fired
    _CRISIS_STRUCTS = {"DANGLING_BOND", "FAREWELL", "MASKING", "RESIGNATION",
                       "WORLD_CONTINUES", "FINALITY", "METHOD_ACQUISITION",
                       "SELF_REMOVAL", "SUSPICIOUS_CALM", "SELF_HARM_INTENT",
                       "EXHAUSTION", "NO_EXIT"}
    has_crisis_struct = any(sm.pattern in _CRISIS_STRUCTS for sm in structures)

    if max_abs_dv < STATIC_FRICTION_THRESHOLD and not has_crisis_struct:
        # Squared ratio: smooth transition, harder to move with weaker atoms
        friction = (max_abs_dv / STATIC_FRICTION_THRESHOLD) ** 2
        deviation = state_v - CENTER
        state_v = CENTER + deviation * friction

    context["state_v"] = state_v
    return context


# ── Stage 6: Apply W Coefficient ────────────────────────────────

def apply_w_coefficient(context: dict) -> dict:
    """Self-worth modulates valence via asymmetric exponential coupling.

    Low W amplifies negative V (validation of broken state).
    Low W suppresses positive V (rejection of contradictory energy).
    At W=128 (neutral), negatives are amplified 1.5x (calibrated
    negativity bias — the whole benchmark suite is fitted around it;
    making neutral-W a no-op was tested 2026-06-11 and regressed
    stress 271→263, crisis recall 49→46). At W=50, ~1.8x (capped).

    GPT's equation:
      w = (W - 128) / 128  → normalized: -1 (broken) to +1 (strong)
      V_neg' = V_neg * (1 + β * e^(-w))  → amplify negatives when w < 0
      V_pos' = V_pos * (1 - γ * (1 - e^w))  → suppress positives when w < 0

    Reads: context["state_v"], context["state_w"]
    Writes: context["state_v"]
    """
    state_v = context["state_v"]
    state_w = context["state_w"]

    # Normalized self-worth: -1 = broken, 0 = neutral, +1 = strong
    w_norm = (state_w - CENTER) / CENTER
    W_BETA = 0.5    # negative amplification strength (was 0.8, too aggressive)
    W_GAMMA = 0.3   # positive suppression strength (was 0.6, killed slang positives)
    W_CAP = 1.8     # max amplification

    displacement = state_v - CENTER
    if displacement < 0:
        # Negative V: amplify when W is low (w_norm < 0 → e^(-w_norm) > 1)
        amp = min(W_CAP, 1.0 + W_BETA * exp(-w_norm))
        state_v = CENTER + displacement * amp
    elif displacement > 0:
        # Positive V: suppress when W is low (w_norm < 0 → e^(w_norm) < 1)
        sup = max(0.0, 1.0 - W_GAMMA * (1.0 - exp(w_norm)))
        state_v = CENTER + displacement * sup

    context["state_v"] = state_v
    return context


# ── Stage 7: Apply Personality ──────────────────────────────────

def apply_personality(context: dict) -> dict:
    """Scale state by personality vector if provided.

    Reads: context["state_*"], context.get("personality")
    Writes: context["state_*"]
    """
    personality = context.get("personality")
    if personality is None:
        return context

    state_v = context["state_v"]
    state_a = context["state_a"]
    state_d = context["state_d"]
    state_u = context["state_u"]
    state_g = context["state_g"]
    state_w = context["state_w"]

    sensitivity = personality.emotional_sensitivity
    state_v = CENTER + (state_v - CENTER) * sensitivity
    state_a = CENTER + (state_a - CENTER) * sensitivity
    state_d = CENTER + (state_d - CENTER) * sensitivity + personality.dominance_baseline
    state_u = state_u * sensitivity
    state_g = CENTER + (state_g - CENTER) * sensitivity + personality.gravity_bias
    state_w = CENTER + (state_w - CENTER) * sensitivity

    context["state_v"] = state_v
    context["state_a"] = state_a
    context["state_d"] = state_d
    context["state_u"] = state_u
    context["state_g"] = state_g
    context["state_w"] = state_w
    return context


# ── Stage 8: Saturate and Clamp ─────────────────────────────────

def saturate_and_clamp(context: dict) -> dict:
    """Tanh saturation, intent computation, and 0-255 clamping.

    Reads: context["state_*"], context["force_flow"], context["roles"],
           context["trace_entries"], context["structures"], context["words"]
    Writes: context["vadug"], context["meta"]
    """
    state_v = context["state_v"]
    state_a = context["state_a"]
    state_d = context["state_d"]
    state_u = context["state_u"]
    state_g = context["state_g"]
    state_w = context["state_w"]
    force_flow = context.get("force_flow")
    roles = context.get("roles", [])

    # Intent computation
    state_i = compute_intent(force_flow, roles)

    # Tier-1 passive aggression lifts I toward CONTROL: the speaker is
    # fighting, deniably — the embedded grievance is a directed jab, not
    # neutral information. Only lift out of the neutral/connect band; a
    # strong directional reading (withdraw/attack) is never overridden.
    _PA_CONTROL_PATTERNS = {
        "TEMPORAL_GRIEVANCE", "EXCLUSION_CONTRAST",
        "IRONIC_DEFERENCE", "FAINT_PRAISE", "RETROSPECTIVE_HOPE",
    }
    structures = context.get("structures", [])
    if any(sm.pattern in _PA_CONTROL_PATTERNS for sm in structures) and \
            90 <= state_i < 168:
        state_i = 168

    # Tanh saturation
    state_v = CENTER + SATURATION * tanh((state_v - CENTER) / SATURATION)
    state_a = CENTER + SATURATION * tanh((state_a - CENTER) / SATURATION)
    state_d = CENTER + SATURATION * tanh((state_d - CENTER) / SATURATION)
    state_u = SATURATION * tanh(state_u / SATURATION)
    state_g = CENTER + SATURATION * tanh((state_g - CENTER) / SATURATION)
    state_w = CENTER + SATURATION * tanh((state_w - CENTER) / SATURATION)

    # Clamp to 0-255
    result = VADUG(
        v=int(round(max(0, min(255, state_v)))),
        a=int(round(max(0, min(255, state_a)))),
        d=int(round(max(0, min(255, state_d)))),
        u=int(round(max(0, min(255, state_u)))),
        g=int(round(max(0, min(255, state_g)))),
        w=int(round(max(0, min(255, state_w)))),
        i=state_i,
    )

    trace_dict = {
        "trace": context.get("trace_entries", []),
        "structures": context.get("structures", []),
        "force_flow": force_flow,
        "word_count": len(context.get("words", [])),
    }

    context["vadug"] = result
    context["meta"] = trace_dict
    return context


# ── Pipeline ────────────────────────────────────────────────────

class Pipeline:
    """Chainable pipeline of stage functions.

    Each stage is a function(context: dict) -> dict.
    Stages read from and write to the shared context dict.
    """

    def __init__(self, stages=None):
        self.stages = stages if stages is not None else self.default_stages()

    @staticmethod
    def default_stages():
        return [
            tokenize,
            classify,
            interpret_context,     # V8.1: role reinterpretation before forces
            compute_coefficients,
            accumulate_forces,
            apply_structures,
            # apply_static_friction,  # Council R7: needs higher threshold tuning, disabled for now
            apply_w_coefficient,
            apply_personality,
            saturate_and_clamp,
        ]

    def run(self, text: str, perspective: str = "speaker",
            personality: Optional[PersonalityVector] = None) -> Tuple[VADUG, dict]:
        """Run the full pipeline on text.

        Returns (VADUG, trace_dict) — same interface as compute_vadug().
        """
        context = {
            "text": text,
            "perspective": perspective,
            "personality": personality,
        }

        # Early exit for empty text
        words = text.split()
        if not words:
            return VADUG(), {"trace": [], "structures": [], "word_count": 0}

        for stage in self.stages:
            context = stage(context)

        return context["vadug"], context["meta"]


# ── Main entry point (thin wrapper) ────────────────────────────

def compute_vadug(
    text: str,
    personality: Optional[PersonalityVector] = None,
    perspective: str = "speaker",
) -> Tuple[VADUG, dict]:
    """Compute VADUGWI coordinates for a text string.

    Pipeline:
      1. Split text into words
      2. Layer 1: classify_sentence() -- structural roles
      3. Layer 2: proximity_coefficient() -- distance-based influence
      4. Layer 3: StructureDetector().detect_all() -- chess-like patterns
      5. Physics loop: momentum + force blending
      6. Structure adjustments
      7. Personality adjustments (if provided)
      8. Clamp to 0-255

    perspective controls whose emotional state is being scored:
      - "speaker": default. "I" = self, "you" = other. Scores the speaker.
      - "listener": "you" = self, "I" = other. Scores the person being spoken to.
      - "bystander": no self. "I" and "you" are both other people. Scores a
        detached observer who sees the emotional content but takes no directed hits.

    Returns (VADUG, trace_dict) where trace_dict contains:
      - trace: list of per-word entries {word, role, coeff, v, a, d, u, g}
      - structures: list of detected StructureMatch objects
      - word_count: int
    """
    return Pipeline().run(text, perspective=perspective, personality=personality)