-
Notifications
You must be signed in to change notification settings - Fork 7
Expand file tree
/
Copy pathChatterMode.jl
More file actions
1423 lines (1253 loc) · 59.7 KB
/
Copy pathChatterMode.jl
File metadata and controls
1423 lines (1253 loc) · 59.7 KB
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
# ChatterMode.jl
# ==============================================================================
# IDLE / CHATTER MODE — GROUP-BASED VOTE+PATTERN STEAL (v8.0)
# ==============================================================================
#
# WHAT CHANGED FROM v7.19
# ----------------------
# Old chatter used cursor walks, random window snapshots, semantic-compat
# checks, and family-coinflip swaps. It was scattered and random.
#
# New chatter is GROUP-BASED and PAIRWISE:
# 1. Sample a few random groups from GROUP_MAP
# 2. Per group: pair strong nodes with weak nodes (2×2, non-colliding)
# 3. Each pair: weak node STEALS the strong node's pattern (straight swap)
# AND steals the strong node's vote — but the vote gets a Markov remix
# based on both nodes' previous action packets (not a stale copy)
# 4. Bottom-tier weak nodes still grave
# 5. Group membership IS the similarity gate — no separate Jaccard check
#
# "should also steal the votes for those patterns. thats more coherent."
# "the vote needs an automata remix slightly to not be stale. base it on
# both nodes votes. like a markov is fine for this"
# "just select a few random groups to do this with thats the sample size"
# "just swap the pattern dont bother doing pattern blend not necessary"
#
# CORE RULES (v8.0)
# -----------------
# 1. Group-based dispatch: sample 8–16 random groups from GROUP_MAP
# from GROUP_MAP each cycle. Groups are the natural similarity clusters.
# 2. 2×2 pairing: per group, pair strong nodes with weak nodes. Each node
# appears in exactly one pair — no collisions, no concurrent mutation.
# 3. Pattern: straight swap from strong → weak. No blend, no remix.
# 4. Vote: stolen from strong node, then Markov-remixed using both nodes'
# action vocabularies so the result isn't a stale copy.
# 5. Grave: bottom-tier weak nodes (below CHATTER_GRAVE_FLOOR) still grave.
# Mid-tier weak nodes get chatter (the steal+remix). That's type 2.
# 6. 1-hour cooldown PER NODE (engine-side: chatter_cooldown_remaining()).
# 7. Disk persistence: the chatter log is written to a compressed JSON file
# so cross-session telemetry survives reboot.
#
# WHAT CHATTER DOES NOT DO
# ------------------------
# - It does not blend patterns (straight swap only).
# - It does not change neighbor_ids / latch state.
# - It does not move nodes between groups (phagy does that at idle).
# - It does not touch crystalized attachments — those always fire regardless.
#
# IDLE SCHEDULING
# ---------------
# should_trigger_idle / IDLE_THRESHOLD_SECONDS / IDLE_JITTER_SECONDS still
# live here unchanged so the orchestrator in Main.jl can keep its 50/50
# chatter-vs-phagy coinflip.
# ==============================================================================
module ChatterMode
# ╔══════════════════════════════════════════════════════════════════════════════╗
# ║ ⚠️ ARCHITECTURE REMINDERS — READ THESE OR SUFFER ║
# ║ ║
# ║ 1. ANTIMATCH NODES WERE REMOVED. They do not exist. Do not reference ║
# ║ is_antimatch_node, antimatch scoring, antimatch drains, or any ║
# ║ antimatch logic. That entire concept was deprecated and deleted. ║
# ║ If you find yourself writing antimatch code, STOP — you are ║
# ║ reimplementing a removed feature. ║
# ║ ║
# ║ 2. SIGILS CAN AND DO APPEAR IN RELATIONAL TRIPLES. A triple like ║
# ║ (&n, is_greater_than, &n) is DYNAMIC — it evaluates at match time ║
# ║ with sigil-bound values, NOT a fixed string comparison. Relational ║
# ║ triples are not limited to literal words. Any triple field may ║
# ║ contain &sigil tokens. Code that consumes triples MUST handle this. ║
# ║ Forgetting this means you break arithmetic, comparisons, and ║
# ║ any dynamic relational outcome. ║
# ║ ║
# ║ 3. HOPFIELD CACHING WAS REMOVED. The hopfield_key field on Node is ║
# ║ a DEAD FIELD — it exists only for specimen save/load round-trip ║
# ║ compatibility. Do not use it for caching, lookups, or any logic. ║
# ║ Pattern scanning does NOT use hopfield caching. It was disabled ║
# ║ ages ago. New code must never depend on hopfield_key. ║
# ╚══════════════════════════════════════════════════════════════════════════════╝
using Random
using JSON
export ChatterSession, start_chatter_session!, process_chatter_queue!
export ChatterNodeClone, ChatterLog, get_chatter_status
export should_trigger_idle, is_morph_allowed, record_morph!
export should_trigger_chatter
export apply_chatter_diffs!, drain_input_queue!, enqueue_input!
export persist_chatter_log!, load_persisted_chatter_log!
export MORPH_COOLDOWN_MAP, MORPH_COOLDOWN_LOCK
export MIN_POPULATION_FOR_CHATTER, IDLE_THRESHOLD_SECONDS
export CHATTER_GROUP_SAMPLE_MIN, CHATTER_GROUP_SAMPLE_MAX
export CHATTER_LOG, CHATTER_CURSOR
export ChatterError
# BUG-011: Bridge functions to the permanent chatter mutation registry in
# engine.jl. ChatterMode is its own module and cannot directly see Main-scoped
# identifiers. These thin wrappers resolve to the parent module at call time.
function _is_chatter_mutated(node_id::String)::Bool
return getfield(parentmodule(@__MODULE__), :is_chatter_mutated)(node_id)
end
function _mark_chatter_mutated!(node_id::String)
getfield(parentmodule(@__MODULE__), :mark_chatter_mutated!)(node_id)
end
# ==============================================================================
# CONSTANTS (v8.0)
# ==============================================================================
# GRUG: Minimum alive non-image node population required before chatter is
# allowed to fire. New specimens (< 1000 nodes) skip chatter entirely — they
# need explicit /grow shaping before random vote swaps add value.
const MIN_POPULATION_FOR_CHATTER = 1000
# GRUG: Default idle threshold in seconds before any idle event (chatter OR
# phagy) fires. Both chatter and phagy share this timer; the 50/50 coinflip
# in Main.jl decides which one runs.
const IDLE_THRESHOLD_SECONDS = 120.0
const IDLE_JITTER_SECONDS = 30.0
# GRUG (v8.0): How many random groups to sample each chatter cycle.
# Range: 8–16 groups. Each sampled group produces exactly ONE pair (2 nodes:
# 1 strong + 1 weak). So each cycle dispatches 8–16 unique steal+remix pairs.
# Not all groups chatter every cycle — just a random slice. Keeps chatter
# focused and avoids sweeping the entire topology each round.
const CHATTER_GROUP_SAMPLE_MIN = 8
const CHATTER_GROUP_SAMPLE_MAX = 16
# GRUG (v8.0): Strength thresholds.
# Strong nodes donate votes and patterns. Mid-tier weak nodes receive them
# (type 2 chatter — steal+remix). Bottom-tier weak nodes grave.
const CHATTER_WEAK_FLOOR = 2.0 # node.strength <= this → weak (eligible receiver)
const CHATTER_STRONG_FLOOR = 5.0 # node.strength >= this → strong (eligible donor)
const CHATTER_GRAVE_FLOOR = 0.5 # node.strength <= this → grave (too weak to save)
# GRUG (v8.0): Weight jitter on the remixed vote. The borrowed action item
# gets a small shake so receivers don't all converge on identical packets.
const CHATTER_WEIGHT_JITTER_SIGMA = 0.10
# GRUG (v8.0): Markov remix constants.
# When a vote is stolen, it's remixed via a Markov bigram chain so it's not
# a stale copy of the donor's action. The remix draws from BOTH the receiver's
# and donor's action vocabularies.
const MARKOV_MAX_ATTEMPTS = 12 # collision retries before prefix-splice fallback
const WEIGHT_BLEND_RECEIVER_SHARE = 0.60 # receiver weight dominance in the blend
# GRUG (v8.0): NONJITTER override. A strong node whose vote nonetheless
# came back low-confidence is uncertain authority — jitter still applies
# even if the node has the NONJITTER tag set.
const STRONG_LOW_CONF_OVERRIDE = 0.35
# GRUG (legacy v7.19): Semantic compatibility intensity is still exercised by
# the chatter unit tests even though v8 dispatch uses group membership as the
# primary gate. Keep the cap and helper API available as a no-side-effect
# compatibility layer for tests and older callers.
const CHATTER_SEMANTIC_INTENSITY_CAP = 0.85
# GRUG (v8.0): Disk persistence path for the chatter log. Compressed JSON.
const CHATTER_LOG_PATH_DEFAULT = "chatter_log.json.gz"
const MAX_CHATTER_LOG = 200 # in-memory ring — disk is unbounded.
# GRUG (legacy v7.1): pattern-morph cooldown. Kept for backwards compat.
const MORPH_COOLDOWN_SECONDS = 86400.0
# GRUG: Test override refs — same pattern as v7.19.
const _TEST_MIN_POPULATION = Ref{Int}(MIN_POPULATION_FOR_CHATTER)
const _TEST_WEAK_FLOOR = Ref{Float64}(-1.0)
const _TEST_STRONG_FLOOR = Ref{Float64}(-1.0)
const _TEST_GRAVE_FLOOR = Ref{Float64}(-1.0)
const _TEST_GROUP_SAMPLE_MIN = Ref{Int}(CHATTER_GROUP_SAMPLE_MIN)
const _TEST_GROUP_SAMPLE_MAX = Ref{Int}(CHATTER_GROUP_SAMPLE_MAX)
function _override_test_gates!(; min_population=nothing, weak_floor=nothing,
strong_floor=nothing, grave_floor=nothing,
group_sample_min=nothing, group_sample_max=nothing,
window_min=nothing, window_max=nothing)
prev = (
min_population = _TEST_MIN_POPULATION[],
weak_floor = _TEST_WEAK_FLOOR[],
strong_floor = _TEST_STRONG_FLOOR[],
grave_floor = _TEST_GRAVE_FLOOR[],
group_sample_min = _TEST_GROUP_SAMPLE_MIN[],
group_sample_max = _TEST_GROUP_SAMPLE_MAX[],
window_min = _TEST_GROUP_SAMPLE_MIN[],
window_max = _TEST_GROUP_SAMPLE_MAX[],
)
isnothing(min_population) || (_TEST_MIN_POPULATION[] = Int(min_population))
isnothing(weak_floor) || (_TEST_WEAK_FLOOR[] = Float64(weak_floor))
isnothing(strong_floor) || (_TEST_STRONG_FLOOR[] = Float64(strong_floor))
isnothing(grave_floor) || (_TEST_GRAVE_FLOOR[] = Float64(grave_floor))
isnothing(group_sample_min) || (_TEST_GROUP_SAMPLE_MIN[] = Int(group_sample_min))
isnothing(group_sample_max) || (_TEST_GROUP_SAMPLE_MAX[] = Int(group_sample_max))
isnothing(window_min) || (_TEST_GROUP_SAMPLE_MIN[] = Int(window_min))
isnothing(window_max) || (_TEST_GROUP_SAMPLE_MAX[] = Int(window_max))
return prev
end
_effective_min_population() = _TEST_MIN_POPULATION[]
_effective_weak_floor() = _TEST_WEAK_FLOOR[] < 0.0 ? CHATTER_WEAK_FLOOR : _TEST_WEAK_FLOOR[]
_effective_strong_floor() = _TEST_STRONG_FLOOR[] < 0.0 ? CHATTER_STRONG_FLOOR : _TEST_STRONG_FLOOR[]
_effective_grave_floor() = _TEST_GRAVE_FLOOR[] < 0.0 ? CHATTER_GRAVE_FLOOR : _TEST_GRAVE_FLOOR[]
_effective_group_sample_min() = _TEST_GROUP_SAMPLE_MIN[]
_effective_group_sample_max() = _TEST_GROUP_SAMPLE_MAX[]
_effective_group_sample() = rand(_effective_group_sample_min():_effective_group_sample_max())
# ==============================================================================
# ERRORS — NO SILENT FAILURES
# ==============================================================================
struct ChatterError <: Exception
msg::String
end
Base.showerror(io::IO, e::ChatterError) =
print(io, "ChatterError: ", e.msg)
# ==============================================================================
# LEGACY MORPH COOLDOWN MAP (v7.1) — retained for backwards compat
# ==============================================================================
const MORPH_COOLDOWN_MAP = Dict{String, Float64}()
const MORPH_COOLDOWN_LOCK = ReentrantLock()
function is_morph_allowed(node_id::String)::Bool
if strip(node_id) == ""
throw(ChatterError("!!! FATAL: is_morph_allowed got empty node_id! !!!"))
end
return lock(MORPH_COOLDOWN_LOCK) do
haskey(MORPH_COOLDOWN_MAP, node_id) || return true
(time() - MORPH_COOLDOWN_MAP[node_id]) >= MORPH_COOLDOWN_SECONDS
end
end
function record_morph!(node_id::String)
if strip(node_id) == ""
throw(ChatterError("!!! FATAL: record_morph! got empty node_id! !!!"))
end
lock(MORPH_COOLDOWN_LOCK) do
MORPH_COOLDOWN_MAP[node_id] = time()
end
end
# ==============================================================================
# CHATTER CLONE — carries the stolen pattern + remixed vote
# ==============================================================================
# GRUG (v8.0): A clone carries the result of one pair's steal+remix.
# proposed_pattern is the straight-swapped pattern from the strong node.
# proposed_action_packet is the Markov-remixed action packet.
# Both get written back to the weak node's live fields at apply time.
mutable struct ChatterNodeClone
source_id::String # the weak node that received
pattern::String # frozen snapshot (old pattern)
action_packet::String # frozen snapshot (old packet)
strength::Float64 # for diagnostics
# GRUG (v8.0): Cargo — the stolen pattern + remixed vote
proposed_pattern::String # strong node's pattern (straight swap)
proposed_action_packet::String # Markov-remixed action packet
accepted_swap::Bool # gates apply_chatter_diffs!
donor_id::String # the strong node that donated
donor_action_name::String # the original donor action name (before remix)
end
# Constructor for a bare clone (no swap staged yet)
function ChatterNodeClone(source_id::String, pattern::String,
action_packet::String, strength::Float64)
return ChatterNodeClone(source_id, pattern, action_packet, strength,
"", "", false, "", "")
end
# ==============================================================================
# CHATTER SESSION + LOG
# ==============================================================================
mutable struct ChatterSession
session_id::String
start_time::Float64
end_time::Float64
groups_sampled::Int # GRUG (v8.0): how many groups were sampled
pairs_formed::Int # GRUG (v8.0): how many 2×2 pairs
clones::Vector{ChatterNodeClone}
is_running::Bool
queued_inputs::Vector{String}
swaps_attempted::Int
swaps_accepted::Int
swaps_blocked_cooldown::Int
swaps_graved::Int # GRUG (v8.0): bottom-tier weak nodes graved
swaps_blocked_semantic::Int
swaps_blocked_coinflip::Int
end
# GRUG (legacy v7.19): Older tests/readers ask for cursor/window fields that
# were replaced by group-sampling counters in v8. Expose read-only aliases.
function Base.getproperty(s::ChatterSession, name::Symbol)
if name === :window_size
return getfield(s, :groups_sampled)
elseif name === :cursor_start
return get(_LEGACY_SESSION_CURSOR_START, getfield(s, :session_id), CHATTER_CURSOR[])
elseif name === :cursor_end
return get(_LEGACY_SESSION_CURSOR_END, getfield(s, :session_id), CHATTER_CURSOR[])
else
return getfield(s, name)
end
end
# GRUG (v8.0): Kept for cursor compat but no longer used for dispatch.
const CHATTER_CURSOR = Ref{Int}(0)
const _LEGACY_SESSION_CURSOR_START = Dict{String, Int}()
const _LEGACY_SESSION_CURSOR_END = Dict{String, Int}()
# GRUG: In-memory ring buffer of completed sessions for /status. Disk has
# the long history.
const CHATTER_LOG = ChatterSession[]
const CHATTER_LOG_LOCK = ReentrantLock()
# GRUG: Global flag: is chatter currently running?
const CHATTER_RUNNING = Ref{Bool}(false)
const CHATTER_LOCK = ReentrantLock()
const INPUT_QUEUE = String[]
const INPUT_QUEUE_LOCK = ReentrantLock()
struct ChatterLog
session_id::String
start_time::Float64
end_time::Float64
groups_sampled::Int
pairs_formed::Int
swaps_attempted::Int
swaps_accepted::Int
swaps_blocked_cooldown::Int
swaps_graved::Int
swaps_blocked_semantic::Int
swaps_blocked_coinflip::Int
end
function get_chatter_status()
is_running = lock(CHATTER_LOCK) do; CHATTER_RUNNING[] end
queue_depth = lock(INPUT_QUEUE_LOCK) do; length(INPUT_QUEUE) end
log_count = lock(CHATTER_LOG_LOCK) do; length(CHATTER_LOG) end
return (
is_running = is_running,
queue_depth = queue_depth,
sessions_run = log_count,
cursor = CHATTER_CURSOR[],
)
end
# ==============================================================================
# INPUT QUEUE — user input parked while chatter runs
# ==============================================================================
function enqueue_input!(input::String)
if strip(input) == ""
throw(ChatterError("!!! FATAL: enqueue_input! got empty string! !!!"))
end
lock(INPUT_QUEUE_LOCK) do
push!(INPUT_QUEUE, input)
end
println("[CHATTER] ⏸ User input queued (chatter in progress). Queue depth: $(length(INPUT_QUEUE))")
end
function drain_input_queue!()::Vector{String}
return lock(INPUT_QUEUE_LOCK) do
queued = copy(INPUT_QUEUE)
empty!(INPUT_QUEUE)
queued
end
end
# ==============================================================================
# IDLE TIMER — unchanged from v7.1
# ==============================================================================
function should_trigger_idle(last_input_time::Float64)::Bool
if last_input_time <= 0.0
throw(ChatterError(
"!!! FATAL: should_trigger_idle got invalid last_input_time: $last_input_time! !!!"
))
end
elapsed = time() - last_input_time
jittered = IDLE_THRESHOLD_SECONDS +
(rand() * 2.0 * IDLE_JITTER_SECONDS - IDLE_JITTER_SECONDS)
return elapsed >= jittered
end
# Backwards-compat alias.
should_trigger_chatter(last_input_time::Float64, _ignored::Float64=120.0)::Bool =
should_trigger_idle(last_input_time)
# ==============================================================================
# ACTION PACKET HELPERS (parser-light, swap-only)
# ==============================================================================
# GRUG: Same parser as v7.19 — we need structured ActionItem for the Markov
# remix. Kept in-module to avoid load-order coupling with engine.jl.
struct ActionItem
action::String
negatives::Vector{String}
weight::Float64
has_weight::Bool
end
# GRUG BUG-011: Keep pipe-delimited packets, but allow literal pipes in
# action names via {PIPE}/{{PIPE}} macro decoded after slot splitting.
_expand_action_macro_string(s::AbstractString)::String = replace(replace(String(s), "{{PIPE}}" => "|"), "{PIPE}" => "|")
function _parse_action_items(packet::String)::Vector{ActionItem}
if strip(packet) == ""
throw(ChatterError("!!! FATAL: _parse_action_items got empty packet! !!!"))
end
items = ActionItem[]
for raw in split(packet, '|')
p = strip(raw)
isempty(p) && continue
negs = String[]
weight = 1.0
has_weight = false
m = match(r"^(.+?)\[([^\]]*)\](?:\^([\d.]+))?$", p)
if !isnothing(m)
action_name = strip(m.captures[1])
for n in split(m.captures[2], ',')
ns = strip(n)
!isempty(ns) && push!(negs, String(ns))
end
wstr = m.captures[3]
if !isnothing(wstr)
w = tryparse(Float64, strip(wstr))
isnothing(w) && throw(ChatterError("bad weight '$wstr' in '$packet'"))
weight = w
has_weight = true
end
push!(items, ActionItem(_expand_action_macro_string(action_name), negs, weight, has_weight))
elseif contains(p, '^')
parts = split(p, '^'; limit=2)
action_name = strip(parts[1])
w = tryparse(Float64, strip(parts[2]))
isnothing(w) && throw(ChatterError("bad weight '$(parts[2])' in '$packet'"))
push!(items, ActionItem(_expand_action_macro_string(action_name), String[], w, true))
else
push!(items, ActionItem(_expand_action_macro_string(p), String[], 1.0, false))
end
end
isempty(items) && throw(ChatterError("no actions in packet '$packet'"))
return items
end
function _serialize_action_items(items::Vector{ActionItem})::String
parts = String[]
for it in items
body = isempty(it.negatives) ? it.action : "$(it.action)[" * join(it.negatives, ", ") * "]"
push!(parts, it.has_weight ? "$(body)^$(round(it.weight, digits=3))" : body)
end
return join(parts, " | ")
end
# GRUG (legacy v7.19): lightweight action-family compatibility for older
# chatter vote-swap tests/callers. This is intentionally local and conservative:
# it only decides whether a donor ActionItem may enter a receiver packet and
# computes a bounded text-overlap intensity. It does not mutate packets/nodes.
const _CHATTER_ACTION_FAMILIES = Dict{String,String}(
"warn" => "ESCALATE", "alert" => "ESCALATE", "flee" => "ESCALATE",
"danger" => "ESCALATE", "acknowledge" => "ESCALATE", "smile" => "ESCALATE",
"ponder" => "QUERY", "query" => "QUERY", "ask" => "QUERY", "question" => "QUERY", "why" => "QUERY",
"answer" => "ASSERT", "assert" => "ASSERT", "say" => "ASSERT", "tell" => "ASSERT",
"observe" => "ASSERT", "validate" => "ASSERT", "greet" => "ASSERT",
)
function _chatter_action_family(action::String)::String
toks = _tokenize_action(action)
for t in toks
if haskey(_CHATTER_ACTION_FAMILIES, t)
return _CHATTER_ACTION_FAMILIES[t]
end
end
low = lowercase(strip(action))
for (needle, fam) in _CHATTER_ACTION_FAMILIES
occursin(needle, low) && return fam
end
return "ASSERT"
end
function _text_overlap_intensity(a::String, b::String)::Float64
toks_a = Set([m.match for m in eachmatch(r"[a-z0-9]+", lowercase(a))])
toks_b = Set([m.match for m in eachmatch(r"[a-z0-9]+", lowercase(b))])
if isempty(toks_a) && isempty(toks_b)
return 0.0
end
inter = length(intersect(toks_a, toks_b))
uni = length(union(toks_a, toks_b))
uni == 0 && return 0.0
return min(CHATTER_SEMANTIC_INTENSITY_CAP, inter / uni)
end
function _semantic_compat(donor::ActionItem,
receiver::Vector{ActionItem},
receiver_pattern::String,
donor_pattern::String)::Tuple{Bool, Float64}
donor_action = lowercase(strip(donor.action))
isempty(donor_action) && return (false, 0.0)
for it in receiver
recv_action = lowercase(strip(it.action))
recv_action == donor_action && return (false, 0.0)
for neg in it.negatives
lowercase(strip(neg)) == donor_action && return (false, 0.0)
end
end
donor_family = _chatter_action_family(donor.action)
receiver_families = Set(_chatter_action_family(it.action) for it in receiver)
(donor_family in receiver_families) || return (false, 0.0)
intensity = _text_overlap_intensity(receiver_pattern, donor_pattern)
return (true, intensity)
end
# ==============================================================================
# MARKOV REMIX ENGINE — vote stealing with automata remix
# ==============================================================================
# GRUG: When a vote is stolen from a strong node, it can't be a stale copy.
# The stolen vote is remixed via a Markov bigram chain that draws from BOTH
# the receiver's and donor's action vocabularies, producing a new action
# that belongs to the receiver's identity while carrying the donor's trace.
"""
_tokenize_action(action_name::String)::Vector{String}
GRUG: Split an action name into tokens by underscores, spaces, and hyphens.
"""
function _tokenize_action(action_name::String)::Vector{String}
tokens = String[]
for raw in split(replace(lowercase(strip(action_name)), "-" => "_"), '_')
t = strip(raw)
!isempty(t) && push!(tokens, t)
end
return tokens
end
"""
_build_bigram_table(token_sequences::Vector{Vector{String}})::Dict{String, Vector{String}}
GRUG: Build a bigram transition table from multiple token sequences.
"""
function _build_bigram_table(token_sequences::Vector{Vector{String}})::Dict{String, Vector{String}}
table = Dict{String, Vector{String}}()
for seq in token_sequences
for i in 1:(length(seq) - 1)
src = seq[i]
dst = seq[i + 1]
if !haskey(table, src)
table[src] = String[]
end
push!(table[src], dst)
end
end
return table
end
"""
_walk_markov(bigram_table, start_tokens, max_len)::Vector{String}
GRUG: Walk a Markov chain starting from a random start token.
Legacy single-strand walk — kept for fallback.
"""
function _walk_markov(bigram_table::Dict{String, Vector{String}},
start_tokens::Vector{String},
max_len::Int)::Vector{String}
isempty(start_tokens) && return String[]
seed = rand(start_tokens)
chain = [seed]
for _ in 1:max_len
current = chain[end]
if !haskey(bigram_table, current) || isempty(bigram_table[current])
break
end
push!(chain, rand(bigram_table[current]))
end
return chain
end
# ==============================================================================
# GRUG (v8.1): STRUCTURAL CROSSOVER MARKOV — two-strand blend
# ==============================================================================
# The old Markov dumped both vocabularies into one bigram table and walked
# blindly. The remix was accidental. The new approach builds TWO separate
# bigram tables (strand A = donor, strand B = receiver) and walks with
# deliberate crossover between them. Like genetic crossover: at each step,
# there's a chance to jump to the other strand and continue from there.
# The result is a chain that STRUCTURALLY blends the two inputs, not one
# that wanders through a merged soup.
#
# "the markov mutator for vote swaps should work by converting the original
# node vote + the node vote you are stealing then markov remix both inputs
# in a way where markov knows these are two structures to blend together"
# ==============================================================================
# GRUG: Crossover rate — probability of jumping to the other strand at each step.
const MARKOV_CROSSOVER_RATE = 0.35
"""
_walk_crossover_markov(strand_a, strand_b, bigram_a, bigram_b;
start_in_a::Bool = true,
crossover_rate::Float64 = MARKOV_CROSSOVER_RATE,
max_len::Int = 4)::Vector{String}
GRUG: Walk a two-strand Markov chain with deliberate crossover. strand_a is
the donor (stolen vote), strand_b is the receiver (original vote). We start
in strand A (the donor — the stolen vote is the seed identity). At each step:
1. Try to continue in the current strand (bigram transition)
2. If crossover roll succeeds, jump to the other strand:
- If the other strand has a token at this position, use it
- If not (strand is shorter), try the other strand's bigram table instead
3. If both strands are dead ends at this token, stop
The walk produces a chain that traces through BOTH structures deliberately.
Like genetic crossover — the child inherits from both parents at marked points.
"""
function _walk_crossover_markov(strand_a::Vector{String},
strand_b::Vector{String},
bigram_a::Dict{String, Vector{String}},
bigram_b::Dict{String, Vector{String}};
start_in_a::Bool = true,
crossover_rate::Float64 = MARKOV_CROSSOVER_RATE,
max_len::Int = 4)::Vector{String}
isempty(strand_a) && isempty(strand_b) && return String[]
# GRUG: Start from the first token of the starting strand.
# Default: start in strand A (donor) — the stolen vote is the seed.
in_a = start_in_a
current_strand = in_a ? strand_a : strand_b
if isempty(current_strand)
# GRUG: Starting strand is empty, flip to the other.
in_a = !in_a
current_strand = in_a ? strand_a : strand_b
isempty(current_strand) && return String[]
end
chain = [current_strand[1]]
step = 1 # position index in the current strand
for _ in 1:max_len
current_token = chain[end]
current_bigram = in_a ? bigram_a : bigram_b
other_bigram = in_a ? bigram_b : bigram_a
other_strand = in_a ? strand_b : strand_a
# GRUG: Crossover roll — should we jump to the other strand?
if rand() < crossover_rate && !isempty(other_strand)
# GRUG: Cross over! Try to continue from the other strand.
# If the other strand has a token at our current position + 1,
# use it (positional crossover — like genetic recombination).
next_pos = step + 1
if next_pos <= length(other_strand)
# GRUG: Positional crossover — take the token at this position
# from the other strand. This is the structural blend.
cross_token = other_strand[next_pos]
push!(chain, cross_token)
in_a = !in_a
step = next_pos
continue
elseif haskey(other_bigram, current_token) && !isempty(other_bigram[current_token])
# GRUG: No positional token, but the other strand's bigram
# knows this token. Transition into the other strand.
push!(chain, rand(other_bigram[current_token]))
in_a = !in_a
step += 1
continue
end
# GRUG: Crossover failed — other strand has nothing at this point.
# Fall through to same-strand continuation.
end
# GRUG: Continue in the current strand (no crossover, or crossover failed).
if haskey(current_bigram, current_token) && !isempty(current_bigram[current_token])
push!(chain, rand(current_bigram[current_token]))
step += 1
else
# GRUG: Dead end in current strand. Try the other strand as rescue.
if haskey(other_bigram, current_token) && !isempty(other_bigram[current_token])
push!(chain, rand(other_bigram[current_token]))
in_a = !in_a
step += 1
else
break # Both strands dead. Walk ends here.
end
end
end
return chain
end
"""
_markov_remix_action_name(donor_action::String, receiver_actions::Vector{String};
max_len::Int = 4)::String
GRUG: Produce a Markov-remixed action name from the donor's action and the
receiver's existing action vocabulary. Uses structural crossover Markov: builds
TWO separate bigram tables (one per strand) and walks with deliberate crossover
between them. The donor strand is the stolen vote identity; the receiver strand
is the node's own voice. Crossover blends them structurally, not by accident.
"""
function _markov_remix_action_name(donor_action::String,
receiver_actions::Vector{String};
max_len::Int = 4)::String
donor_tokens = _tokenize_action(donor_action)
isempty(donor_tokens) && return donor_action
# GRUG: Tokenize ALL of the receiver's actions. The receiver's full
# action vocabulary forms strand B (the node's own voice).
recv_token_seqs = Vector{Vector{String}}()
for a in receiver_actions
t = _tokenize_action(a)
!isempty(t) && push!(recv_token_seqs, t)
end
# GRUG: Build TWO separate bigram tables — strand A (donor) and
# strand B (receiver). The crossover walk knows these are two
# distinct structures to blend, not one merged soup.
donor_bigram = _build_bigram_table([donor_tokens])
recv_bigram = _build_bigram_table(recv_token_seqs)
# GRUG: The receiver's primary action (strongest) forms strand B's
# main sequence for positional crossover. The donor is strand A.
recv_primary = isempty(recv_token_seqs) ? String[] : recv_token_seqs[1]
# GRUG: Walk the two-strand crossover chain.
chain = _walk_crossover_markov(donor_tokens, recv_primary,
donor_bigram, recv_bigram;
start_in_a = true,
max_len = max_len)
# GRUG: If the crossover walk produced only 1 token (just the seed),
# try starting from the receiver's strand instead. The donor strand
# might have no transitions (single-token action name).
if length(chain) <= 1 && !isempty(recv_primary)
chain = _walk_crossover_markov(donor_tokens, recv_primary,
donor_bigram, recv_bigram;
start_in_a = false,
max_len = max_len)
end
# GRUG: Last resort — if crossover still can't produce a multi-token
# chain, fall back to the old single-strand walk on a combined table.
if length(chain) <= 1
all_seqs = vcat([donor_tokens], recv_token_seqs)
combined_bigram = _build_bigram_table(all_seqs)
all_starts = vcat(donor_tokens, reduce(vcat, recv_token_seqs; init=String[]))
chain = _walk_markov(combined_bigram, all_starts, max_len)
end
remixed = join(chain, "_")
isempty(remixed) && return donor_action
return remixed
end
"""
_prefix_splice_fallback(donor_action::String, receiver_actions::Vector{String})::String
GRUG: Fallback when all Markov walks produce collisions.
"""
function _prefix_splice_fallback(donor_action::String,
receiver_actions::Vector{String})::String
d_tokens = _tokenize_action(donor_action)
r_tokens = isempty(receiver_actions) ? String[] :
_tokenize_action(rand(receiver_actions))
prefix = isempty(d_tokens) ? "remix" : d_tokens[1]
suffix = isempty(r_tokens) ? "drift" : r_tokens[end]
return "$(prefix)_$(suffix)"
end
"""
_jitter_weight(weight) -> Float64
Apply a small symmetric jitter to a weight.
"""
function _jitter_weight(weight::Float64)::Float64
j = (rand() * 2.0 - 1.0) * CHATTER_WEIGHT_JITTER_SIGMA
return max(0.05, weight + j)
end
# ==============================================================================
# VOTE REMIX — main entry point for stolen vote processing
# ==============================================================================
"""
_remix_vote(donor_action_name, receiver_packet, donor_packet; overlap=0.5) -> String
GRUG: Steal the donor's vote, Markov-remix it based on both nodes' action
packets, and produce a new action_packet for the receiver. The stolen vote
replaces the receiver's weakest action. Weight is blended (60% receiver / 40%
donor). Donor negatives are carried over.
"""
function _remix_vote(donor_action_name::String,
receiver_packet::String,
donor_packet::String;
overlap::Float64 = 0.5)::String
recv_items = _parse_action_items(receiver_packet)
donor_items = _parse_action_items(donor_packet)
isempty(recv_items) && return receiver_packet
# GRUG: Find the donor item matching the donor_action_name.
donor_item = nothing
for it in donor_items
if it.action == donor_action_name
donor_item = it
break
end
end
if isnothing(donor_item) && !isempty(donor_items)
donor_item = donor_items[1]
end
if isnothing(donor_item)
return receiver_packet
end
# GRUG: Build the receiver's action name list for Markov vocabulary.
recv_action_names = [it.action for it in recv_items]
# GRUG: Run Markov remix with collision retries.
remixed_name = donor_action_name
for attempt in 1:MARKOV_MAX_ATTEMPTS
candidate = _markov_remix_action_name(donor_item.action, recv_action_names)
if !(candidate in recv_action_names)
remixed_name = candidate
break
end
if attempt == MARKOV_MAX_ATTEMPTS
remixed_name = _prefix_splice_fallback(donor_item.action, recv_action_names)
if remixed_name in recv_action_names
remixed_name = "$(remixed_name)_$(rand(100:999))"
end
end
end
# GRUG: Merge negatives from donor.
remixed_negs = String[]
for n in donor_item.negatives
(!(n in remixed_negs) && n != remixed_name) && push!(remixed_negs, n)
end
# GRUG: Replace the receiver's lowest-weight item.
swap_idx = argmin([it.weight for it in recv_items])
weakest_weight = recv_items[swap_idx].weight
# GRUG: Blend weight — receiver dominance, donor trace.
donor_w = donor_item.has_weight ? donor_item.weight : 1.0
blended_weight = (WEIGHT_BLEND_RECEIVER_SHARE * weakest_weight +
(1.0 - WEIGHT_BLEND_RECEIVER_SHARE) * donor_w)
blended_weight += (rand() * 2.0 - 1.0) * 0.05
blended_weight = max(0.05, blended_weight)
remixed_item = ActionItem(remixed_name, remixed_negs,
round(blended_weight, digits=3), true)
new_items = copy(recv_items)
new_items[swap_idx] = remixed_item
return _serialize_action_items(new_items)
end
# ==============================================================================
# CHATTER SESSION RUNNER (v8.0 — group-based 2×2 steal+remix)
# ==============================================================================
"""
start_chatter_session!(node_map, node_lock, group_map, group_lock;
cooldown_query, stamp_fn) -> ChatterSession
Run one chatter cycle. v8.0 architecture:
1. Sample 8–16 random groups from GROUP_MAP (sample size = rand(8:16))
2. Per group: identify strong + weak members (non-grave, non-image)
3. Pair strong+weak 2×2 — each node appears in exactly one pair
4. For each pair:
a. If weak node strength <= CHATTER_GRAVE_FLOOR: grave it (bottom-tier)
b. Otherwise (mid-tier weak): steal pattern (straight swap) + steal vote
(Markov remix based on both nodes' action packets)
5. Return session with clones for apply step
Group membership IS the similarity gate — no separate Jaccard check needed.
"""
function start_chatter_session!(
node_map::Dict,
node_lock::ReentrantLock,
group_map::Dict,
group_lock::ReentrantLock;
cooldown_query::Function = (id) -> 0.0,
stamp_fn::Function = (id) -> nothing,
grave_fn::Function = (id, reason) -> nothing,
)::ChatterSession
lock(CHATTER_LOCK) do
CHATTER_RUNNING[] = true
end
session_id = "chatter_$(round(Int, time() * 1000))"
session_start = time()
try
# ── STEP 1: Sample random groups ──
all_group_ids = lock(group_lock) do
collect(keys(group_map))
end
if isempty(all_group_ids)
println("[CHATTER] ⚠ No groups available. Skipping chatter.")
session = ChatterSession(session_id, session_start, 0.0, 0, 0,
ChatterNodeClone[], false, String[],
0, 0, 0, 0, 0, 0)
session.end_time = time()
_store_session!(session)
return session
end
sample_size = min(_effective_group_sample(), length(all_group_ids))
shuffled = collect(all_group_ids)
shuffle!(shuffled)
sampled_ids = shuffled[1:sample_size]
println("[CHATTER] 🗣 Session $session_id: sampling $sample_size group(s) from $(length(all_group_ids)) total")
# ── STEP 2+3+4: Per group, pair strong+weak, steal+remix ──
clones = ChatterNodeClone[]
swaps_attempted = 0
swaps_accepted = 0
swaps_blocked_cooldown = 0
swaps_graved = 0
swaps_blocked_semantic = 0
swaps_blocked_coinflip = 0
pairs_formed = 0
# GRUG BUG-011: Permanent mutation guard + session-scoped donor set.
# A node that has EVER been chatter-mutated (receiver OR donor whose
# vote was stolen) may NEVER participate again. The permanent registry
# lives in engine.jl (CHATTER_MUTATED_SET). Additionally, a donor
# can only be used once per session to avoid repeat steals.
session_used = Set{String}()
for gid in sampled_ids
group = lock(group_lock) do
get(group_map, gid, nothing)
end
isnothing(group) && continue
# GRUG v7.39: NOCHAT enforcement. Groups with is_chatter_eligible=false
# are invisible to ChatterMode. These are singleton/under-populated groups
# that haven't yet graduated. Skipping them prevents whacky remixes.
if hasproperty(group, :is_chatter_eligible) && !group.is_chatter_eligible
continue
end
# GRUG: Categorize members into strong / weak / grave-tier
strong_ids = String[]
weak_ids = String[]
lock(node_lock) do
for mid in group.members
_is_chatter_mutated(mid) && continue # BUG-011: permanently excluded
(mid in session_used) && continue # BUG-011: already used this session
!haskey(node_map, mid) && continue
node = node_map[mid]
node.is_grave && continue
node.is_image_node && continue
# GRUG v8.26h: antimatch nodes removed. is_antimatch_node is always false for new nodes. # node.is_antimatch_node && continue
# GRUG v7.59: Sigil nodes are NOCHAT — their patterns are syntactic
# grammars (e.g. "&n &op &n"), not semantic content. Chatter must not
# remix procedural votes. People don't dream in procedures. Relational
# triples can optionally USE sigils, making them dynamic rather than
# static — easy to forget when authoring specimens.
(isdefined(node, :node_type) && node.node_type === :sigil) && continue
# GRUG: Cooldown check — skip nodes still on cooldown
if cooldown_query(mid) > 0.0
continue
end
if node.strength >= _effective_strong_floor()
push!(strong_ids, mid)
elseif node.strength <= _effective_weak_floor()
push!(weak_ids, mid)
end
end
end
isempty(strong_ids) && continue # no donors in this group
isempty(weak_ids) && continue # no receivers in this group
# GRUG (v8.0): Exactly ONE pair per group — 2 nodes: 1 strong + 1 weak.
# Shuffle both lists so the pick is random each cycle, then take
# the first of each. This is the 2×2 unique dispatch: each group
# contributes exactly 2 random nodes to the chatter cycle.
shuffle!(strong_ids)
shuffle!(weak_ids)
strong_id = strong_ids[1]
weak_id = weak_ids[1]
# BUG-011: double-check permanent + session guards after shuffle pick