-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy patherd_search.py
More file actions
executable file
·1938 lines (1697 loc) · 84 KB
/
Copy patherd_search.py
File metadata and controls
executable file
·1938 lines (1697 loc) · 84 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
#!/usr/bin/env python3.13
"""erd_search.py — Parallel ERD_ALL precache CLI.
Subcommands
-----------
start Start the supervisor and the report web server via systemd
(systemctl --user start), unless each is already running.
--swarm-only starts the supervisor alone; --web-only starts
the report web server alone.
stop Stop the supervisor and the report web server via systemd
(systemctl --user stop). --swarm-only stops the supervisor
alone; --web-only stops the report web server alone.
restart Restart the supervisor and the report web server via systemd
(systemctl --user restart): a stop followed by a start in one
step, per service. --swarm-only restarts the supervisor
alone; --web-only restarts the report web server alone.
view Shared swarm reports in text, JSON, or watched JSON Lines.
run Start the supervisor directly (without systemd), for
development or one-shot use. All output goes to
runtime/erd_search.log.
queue Queue mutation operations.
queue add Add branches for one or more words to the work queue.
Idempotent: existing branches are never duplicated; priority
is upgraded if the new request is higher. Words are queued on
a descending priority ladder in the order given, --priority-step
apart, so the swarm works them one at a time, and the batch is
appended below every word the queue still owes work unless
--priority names a rung outright. If there is not enough room
below the queue's current floor to seat the batch on distinct
rungs, every unfinished request is shifted upward first to
reclaim it, rather than clamping the new batch onto the
priority floor; reports the headroom left below the batch it
seats. With --words-file,
--priority-words restricts the ladder to a subset of the file's
words. --delete-erd-cache forces a recompute of branches that
are already cached, discarding both the cache entry and the
queue-side work state of any branch already completed (its
claims, bundle stats and active-branch row) so it is claimable
again; a branch a worker is still solving is left alone.
Otherwise, branches with reusable cached results are not
queued. Reports how many branches are new versus already
queued, and how many were already solved.
queue clear Wipe all queue state (pending branches, active state, candidate
claims, heartbeats). Does not touch the ERD cache.
queue remove Remove a pending branch from the queue. Use --force to also
cancel an in-progress branch (workers move on after their
current candidate evaluation completes).
queue priority Change the priority of a queued branch. Higher numbers are
worked sooner; 0 is the default.
queue opener-priority
Change the requested priority of an opener-work request by
word. Takes effect immediately for both its pending roots
and its active/promoted descendants.
epoch Show or change the telemetry epoch used to compare swarm
telemetry from one claiming regime.
queue set-disk-stop
Keep the swarm down across reboots and systemd restarts.
queue reconcile-orphaned-ownership
Demote open owned branches whose opener-work membership was
lost while they were still open (its promoting candidate was
retracted, or its owning request completed moments before it
attached), making them claimable again. The run loop
self-heals this on every membership resolution; this command
is for branches stranded before that (e.g. accumulated while
the swarm was down).
For exporting a trimmed cache snapshot to sync to the iPhone, or importing
one from another machine, see export_cache.py and import_cache.py — the
cache is shared with interactive play (wordle.py), not swarm-specific.
"""
from __future__ import annotations
import argparse
import json
import logging
import multiprocessing
import os
import sqlite3
import signal
import sys
import time
from datetime import datetime
from cache_sqlite import ScoreCache
from hint_cache import HintCacheError, open_hint_cache
from report_model import (
BRANCH_STATUSES,
BRANCH_WORKER_STATUSES,
OVERVIEW_BRANCH_STATUSES,
OVERVIEW_BRANCH_WORKER_STATUSES,
applied_branch_filters,
is_overview_request,
OPENER_SORT_FIELDS,
ROOT_PROGRESS_SORT_FIELDS,
OPENER_STATES,
ReportFilters,
ReportRequest,
WORKER_LIVENESS_SECONDS,
parse_branch_filter,
parse_report_branch_target,
parse_rich_spine as _parse_spine,
validate_report_request,
)
from runtime_paths import (
DEFAULT_ANSWER_LIST_PATH,
DEFAULT_CACHE_PATH,
DEFAULT_CANDIDATE_LIST_PATH,
DEFAULT_QUEUE_PATH,
DEFAULT_SEARCH_LOG_PATH,
ensure_runtime_dir,
)
from wordle_engine import ERD_ALL, GAME_GUESSES, ResponseCache, load_word_list
from erd_queue import (
DISK_STOP_FRACTION,
ERDQueue,
QUEUE_WAL_HARD_CEILING_BYTES,
OPENER_PRIORITY_MAX,
OPENER_PRIORITY_MIN,
check_opener_priority_range,
disk_stats,
encode_subset,
)
import erd_swarm
ANSWER_FILE = DEFAULT_ANSWER_LIST_PATH
WORDS_FILE = DEFAULT_CANDIDATE_LIST_PATH
DEFAULT_CACHE = DEFAULT_CACHE_PATH
DEFAULT_QUEUE = DEFAULT_QUEUE_PATH
logger = logging.getLogger('wordle')
def _view_watch_interval(value):
interval = float(value)
if interval < 0.2:
raise argparse.ArgumentTypeError("watch interval must be at least 0.2 seconds")
return interval
def _comma_separated_filter(value, option_name, choices):
try:
return parse_branch_filter(value, option_name, choices)
except ValueError as error:
raise argparse.ArgumentTypeError(str(error)) from error
def _branch_status_filter(value):
return _comma_separated_filter(value, "branch status", BRANCH_STATUSES)
def _branch_worker_status_filter(value):
return _comma_separated_filter(value, "branch worker status", BRANCH_WORKER_STATUSES)
def _opener_state_filter(value):
return _comma_separated_filter(value, "opener state", OPENER_STATES)
def cmd_view(args):
from report_terminal import run_view
run_view(args)
# ---------------------------------------------------------------------------
# queue add
# ---------------------------------------------------------------------------
DEFAULT_PRIORITY_STEP = 5
def priority_ladder(words, top_priority, step):
"""Map words to descending opener priorities, first word highest.
Words tied at one priority are all eligible to start at once, so a
blocked worker widens the batch by starting whichever of them has no
branch open yet rather than opening another branch of the word already
running. A ladder breaks every tie, which keeps the swarm on one word
until that word runs out of claimable work.
Adjacent words differ by `step`, leaving unused values between rungs so
a word can be reordered later with `queue opener-priority` without
disturbing its neighbours. A step of 0 puts every word on
`top_priority`.
Rungs below OPENER_PRIORITY_MIN clamp onto it, so a list too long to seat
on distinct rungs gives them to the leading words and ties the remainder
on the minimum. The tail is undifferentiated but still ranks below every
seated word.
"""
if step <= 0:
return {word: top_priority for word in words}
return {word: max(OPENER_PRIORITY_MIN, top_priority - step * index)
for index, word in enumerate(words)}
def _make_room_for_append(queue, lowest_queued, step, n_words):
"""Reclaim room for an append whose ladder would clamp onto
OPENER_PRIORITY_MIN, by shifting every unfinished opener-work request
upward instead of ratcheting the append's own floor below the minimum.
Called only when `queue add` has no explicit --priority and the queue
already owes work. Without this, `lowest_queued - 1` is the append's
only ceiling and it only ever falls as batches accumulate, making
OPENER_PRIORITY_MAX practically unreachable on a queue that never fully
drains (issue #276). Shifting the unfinished set up by the shortfall
instead preserves every request's relative order and every tie, and
frees exactly the room this batch needs.
Returns (lowest_queued, shift): the (possibly raised) floor to seat the
new batch below, and how far unfinished work was shifted (0 if it
already fit). Raises ValueError, before touching anything, if shifting
every unfinished request up to OPENER_PRIORITY_MAX still cannot make
room — the range is already fully occupied.
"""
required_span = step * max(0, n_words - 1)
naive_floor = (lowest_queued - 1) - required_span
if naive_floor >= OPENER_PRIORITY_MIN:
return lowest_queued, 0
max_unfinished = queue.max_unfinished_opener_priority()
headroom_above = OPENER_PRIORITY_MAX - max_unfinished
shortfall = OPENER_PRIORITY_MIN - naive_floor
shift = min(shortfall, headroom_above)
if naive_floor + shift < OPENER_PRIORITY_MIN:
needed_ceiling = lowest_queued - 1 + (shortfall - headroom_above)
raise ValueError(
f'{n_words:,} words at step {step:,} need a ladder reaching '
f'{shortfall:,} below the current queue floor of '
f'{lowest_queued:,}; shifting every unfinished opener-work '
f'request up to the maximum {OPENER_PRIORITY_MAX:,} only frees '
f'{headroom_above:,}. The range would need a ceiling of at '
f'least {needed_ceiling:,} to hold both. Use queue '
f'opener-priority to make room by hand, or pass --priority to '
f'place this batch deliberately.')
queue.shift_unfinished_opener_priorities(shift)
return lowest_queued + shift, shift
def ladder_top_priority(lowest_queued, explicit_priority, step, n_words):
"""The rung a batch's first word takes.
`queue add` appends: with no --priority the batch descends from just
below `lowest_queued`, the lowest priority the queue still owes work, so
it preempts nothing already queued and leaves the space beneath it for
the next batch. Ladders therefore run downward from the top of the range
rather than upward from its floor, which is what keeps repeated appends
from exhausting the space. `lowest_queued` is None when nothing is owed,
and the batch takes the top of the range.
An explicit --priority names the *last* word's rung instead, and the
batch is seated high enough for the ladder to land on it — that is how a
batch is deliberately placed ahead of queued work. Raises ValueError if
the range cannot hold a ladder whose last rung is the one named: silently
seating the batch lower would hand back something other than the priority
the caller asked for.
"""
if explicit_priority is not None:
top_priority = explicit_priority + step * max(0, n_words - 1)
if top_priority > OPENER_PRIORITY_MAX:
raise ValueError(
f'--priority {explicit_priority:,} needs a ladder reaching '
f'{top_priority:,} for {n_words:,} words at step {step:,}, '
f'above the maximum {OPENER_PRIORITY_MAX:,}. Lower '
f'--priority, use a smaller --priority-step, or add fewer '
f'words at a time.')
return top_priority
if lowest_queued is None:
return OPENER_PRIORITY_MAX
return max(OPENER_PRIORITY_MIN, lowest_queued - 1)
def invalidate_branches_for_recompute(queue, score_cache, branch_keys):
"""Drop each branch's cached result and the work state that would keep it
unclaimable, so it is genuinely recomputed.
Nothing above these branches needs invalidating alongside them. A word's
own ERD is folded from its response groups' cached results on every read,
so a group deleted here reports as unresolved immediately, and the word
reads as pending until it is solved again.
A branch the queue finished keeps a `done` pending row, and can still
hold candidate claims and an `active_branches` row. Re-adding it leaves
all three in place — `add_pending_many` carries priority forward but not
status, `create_branch` ignores an existing `active_branches` row, and
claims already marked done finalize the branch again without evaluating
anything.
A branch carrying an *open* active_branches row is left entirely alone,
cache entry included. A worker is solving it, and the two halves have to
agree: deleting the cached result while refusing to requeue would leave
exactly the branch this function exists to prevent — no cached answer and
a `done` row no worker will claim.
Returns (reset_count, busy_count).
"""
reset_count = 0
busy_count = 0
for branch_key in branch_keys:
active = queue.get_active_branch(branch_key)
if active is not None and active['status'] == 'open':
busy_count += 1
continue
score_cache.delete(branch_key, ERD_ALL)
if queue.requeue_completed_branch(branch_key):
reset_count += 1
return reset_count, busy_count
def cmd_queue_add(args):
"""Add branches for one or more words (or a words-file) to the queue.
With --word: adds all response branches for each given word with at
least 2 answer words (and at most --max-branch-size, if given). With
--pattern as well: adds only that single branch per word.
With --words-file: walks every word in the file, same as --word with
that file's contents. --priority-words marks a subset of those words as
higher priority: they are queued at --priority while the rest are queued
at 0.
Words are queued on a descending priority ladder in the order given, so
the swarm works them one at a time rather than starting all of them at
once; --priority-step sets the gap between rungs. Without --priority the
whole ladder sits below every request the queue still owes work, so a new
batch never preempts one already queued; --priority names the last rung
outright and is how a batch is put ahead of queued work. With
--priority-words, the ladder covers that subset and the rest stay flat at
the minimum. A word repeated in the input takes its first position and is
queued once.
An append (no --priority) whose ladder would run past the priority
minimum first shifts every unfinished request upward by the shortfall,
reclaiming exactly the room the new batch needs rather than ratcheting
its own floor down or clamping onto the minimum — a queue fed
continuously (never fully draining) therefore does not lose rungs to
successive appends. Reports the headroom left below the batch it seats,
and raises before queuing anything if even that shift cannot make room.
A batch that ends up with nothing to queue (every branch already cached,
or none large enough) never reladders: already-queued priorities are
left untouched, and the request cannot be refused at the ceiling for
what is ultimately a no-op.
Already-queued branches are never duplicated; their priority is upgraded
if the new request is higher. Branches with reusable cached results are
already solved and are not queued, unless --delete-erd-cache is given.
For each word, reports how many unresolved branches are new versus already
queued, and how many response groups were already solved.
--delete-erd-cache deletes each queued branch's existing ERD cache entry
first, so it gets recomputed instead of being claimed and immediately
marked done as already-cached. For branches the queue already completed
it also discards the work state that would otherwise keep them
unclaimable — candidate claims, republish and hole rows, bundle stats,
and the active-branch row — and returns them to 'pending'. A branch a
worker is still solving is skipped rather than pulled out from under it.
"""
from wordle_ui import parse_pattern, fmt_pattern
all_answers = load_word_list(ANSWER_FILE)
if args.word:
words_to_process = [word.strip().lower() for word in args.word]
else:
words_to_process = [word.strip().lower()
for word in load_word_list(args.words_file)]
words_to_process = list(dict.fromkeys(words_to_process))
candidate_words = set(load_word_list(WORDS_FILE))
invalid_words = [word for word in words_to_process
if len(word) != 5 or word not in candidate_words]
if invalid_words:
invalid_display = ', '.join(sorted(set(invalid_words)))
raise ValueError(
f'invalid candidate word(s): {invalid_display}; expected '
f'five-letter words from {WORDS_FILE}')
priority_words = {w.strip().lower() for w in (args.priority_words or [])}
if priority_words and not args.words_file:
print('Warning: --priority-words only applies with --words-file; '
'ignoring it. Use --priority directly with --word.')
priority_words = set()
base_priority = (OPENER_PRIORITY_MIN if args.priority is None
else args.priority)
check_opener_priority_range(base_priority)
if args.priority_step < 0:
raise ValueError('--priority-step must not be negative')
score_cache = ScoreCache(args.cache, all_answers)
rcache = ResponseCache(all_answers, score_cache)
queue = ERDQueue(args.queue)
unknown = priority_words - set(words_to_process)
if unknown:
print(f'Warning: priority words not in the word list: '
f'{", ".join(sorted(unknown))}')
# A branch reached by --word has guess_depth 1 (one guess played), so it
# is solved at ROOT_BUDGET - 1 == GAME_GUESSES - 1.
branch_budget = GAME_GUESSES - 1
n_new = 0
n_already_queued = 0
n_already_solved = 0
n_reset = 0
n_busy = 0
try:
# First pass: work out what each word actually needs queuing --
# cache state, and (under --delete-erd-cache) invalidation -- none of
# which depends on priority. This decides whether the batch has any
# real work *before* the ladder is seated, so a batch that is fully
# cached (nothing to queue) never shifts already-queued opener-work
# priorities, and can't be refused at the ceiling for a no-op.
word_plans = {}
skip_message_by_word = {}
for word in words_to_process:
if args.pattern:
code = parse_pattern(args.pattern)
groups = rcache.group_words(word, all_answers)
branch = groups.get(code, [])
if len(branch) < 2:
skip_message_by_word[word] = (
f'{word.upper()} {fmt_pattern(code)}: '
f'{len(branch)} answer word(s) — nothing to queue.')
rows = []
elif (args.max_branch_size is not None
and len(branch) > args.max_branch_size):
skip_message_by_word[word] = (
f'{word.upper()} {fmt_pattern(code)}: '
f'{len(branch)} words exceeds --max-branch-size '
f'{args.max_branch_size}, skipping.')
rows = []
else:
rows = [(encode_subset(branch), len(branch), code)]
else:
groups = rcache.group_words(word, all_answers)
rows = [
(encode_subset(branch), len(branch), code)
for code, branch in groups.items()
if len(branch) >= 2
and (args.max_branch_size is None
or len(branch) <= args.max_branch_size)
]
if not rows:
word_plans[word] = None
continue
branch_keys = [branch_key for branch_key, _count, _code in rows]
cache_states = score_cache.report_branch_states(
branch_keys, ERD_ALL, budget=branch_budget)
already_cached_keys = {
key for key, state in cache_states.items()
if state['cache_state'] in ('exact', 'loss')}
if args.delete_erd_cache:
word_reset, word_busy = invalidate_branches_for_recompute(
queue, score_cache, branch_keys)
n_reset += word_reset
n_busy += word_busy
rows_to_queue = rows
already_solved_keys = set()
else:
rows_to_queue = [
row for row in rows if row[0] not in already_cached_keys]
already_solved_keys = already_cached_keys
already_queued_keys = set(
queue.status_by_branch_keys(
[branch_key for branch_key, _count, _code
in rows_to_queue]))
word_plans[word] = (rows_to_queue, already_queued_keys,
already_solved_keys)
any_rows_to_queue = any(
plan is not None and plan[0] for plan in word_plans.values())
laddered_words = [word for word in words_to_process
if not priority_words or word in priority_words]
lowest_queued = queue.lowest_unfinished_opener_priority()
shift = 0
if (args.priority is None and lowest_queued is not None
and laddered_words and any_rows_to_queue):
lowest_queued, shift = _make_room_for_append(
queue, lowest_queued, args.priority_step, len(laddered_words))
top_priority = ladder_top_priority(
lowest_queued, args.priority, args.priority_step,
len(laddered_words))
ladder = priority_ladder(laddered_words, top_priority,
args.priority_step)
if laddered_words:
if shift:
print(f'Raised every unfinished opener-work request by '
f'{shift:,} to make room for this batch below priority '
f'{lowest_queued:,}.')
# _make_room_for_append guarantees room below lowest_queued (or
# raises before we get here), so an append never ties the incumbent.
appended = args.priority is None and lowest_queued is not None
placement = (f', behind queued work down to priority {lowest_queued:,}'
if appended else '')
print(f'{laddered_words[0].upper()} first at priority '
f'{ladder[laddered_words[0]]:,}, '
f'{laddered_words[-1].upper()} last at '
f'{ladder[laddered_words[-1]]:,}{placement}.')
floor = min(ladder.values())
headroom = floor - OPENER_PRIORITY_MIN
print(f'{headroom:,} priority value(s) of headroom remain below it, '
f'down to {OPENER_PRIORITY_MIN:,}.')
on_floor = sum(1 for word in laddered_words if ladder[word] == floor)
if args.priority_step and on_floor > 1:
# Distinct rungs only collide once the ladder clamps, so a tie here
# always sits on OPENER_PRIORITY_MIN with nothing beneath it. A
# smaller step cannot divide headroom that does not exist; raising
# the incumbent or naming a priority is what actually works.
print(f'Warning: {len(laddered_words):,} words do not fit on a '
f'ladder of step {args.priority_step:,} below priority '
f'{top_priority:,}; the last {on_floor:,} share priority '
f'{floor:,} and will start together. Raise the queued work '
f'with queue opener-priority, or pass --priority to place '
f'this batch deliberately.')
# Second pass: assign each word's rung and write it, now that the
# ladder (and any reladdering) has been decided.
for word in words_to_process:
plan = word_plans[word]
if plan is None:
message = skip_message_by_word.get(word)
if message is not None:
print(message)
continue
rows_to_queue, already_queued_keys, already_solved_keys = plan
priority = ladder.get(word, OPENER_PRIORITY_MIN)
rows_with_priority = [
(branch_key, count, priority, word, code)
for branch_key, count, code in rows_to_queue]
if rows_with_priority:
queue.add_pending_many(rows_with_priority)
word_already_queued = len(already_queued_keys)
word_new = len(rows_to_queue) - word_already_queued
word_already_solved = len(already_solved_keys)
n_new += word_new
n_already_queued += word_already_queued
n_already_solved += word_already_solved
if not rows_to_queue:
response_group_label = (
'response group' if word_already_solved == 1
else 'response groups')
print(f'{word.upper()}: already solved — '
f'{word_already_solved:,} {response_group_label} already '
f'cached; nothing queued.')
else:
print(f'{word.upper()}: {len(rows_to_queue):,} branch(es) '
f'— {word_new:,} new, '
f'{word_already_queued:,} already queued, '
f'{word_already_solved:,} already solved.')
total = queue.total_branches()
n_added = n_new + n_already_queued
if n_reset:
print(f'\n{n_reset:,} completed branch(es) cleared for recompute '
f'(claims and active state discarded with the cache entry).')
if n_busy:
print(f'{n_busy:,} branch(es) are being solved right now and were '
f'left untouched, cache entry included. Re-run once they '
f'finish to recompute them.')
print(f'\n{n_added:,} branch(es) queued across '
f'{len(words_to_process):,} word(s): {n_new:,} new, '
f'{n_already_queued:,} already queued, '
f'{n_already_solved:,} already solved. '
f'Queue total: {total:,}.')
except KeyboardInterrupt:
print('\nInterrupted.')
finally:
score_cache.checkpoint()
score_cache.close()
queue.close()
# ---------------------------------------------------------------------------
# queue clear
# ---------------------------------------------------------------------------
def cmd_queue_clear_disk_stop(args):
"""Release the disk-stop latch so `run` will start again."""
queue = ERDQueue(args.queue)
try:
latch = queue.disk_stop()
if latch is None:
print('No disk-stop latch is set.')
return
used_fraction = disk_stats(args.queue)['used_fraction']
queue.clear_disk_stop()
print(f'Disk-stop latch cleared (was: {latch["reason"]}). '
f'Disk is now {100 * used_fraction:.1f}% full.')
if used_fraction >= DISK_STOP_FRACTION:
print(f'Warning: still at or above the '
f'{100 * DISK_STOP_FRACTION:.0f}% stop threshold — '
f'run will refuse to start until space is freed.',
file=sys.stderr)
finally:
queue.close()
def cmd_queue_set_disk_stop(args):
"""Latch the swarm down without replacing an existing latch reason."""
queue = ERDQueue(args.queue)
try:
if queue.set_disk_stop_if_unset(args.reason):
print(f'Disk-stop latch set: {args.reason}.')
return
latch = queue.disk_stop()
print(f'Disk-stop latch is already set ({latch["reason"]}); '
'it remains unchanged.')
finally:
queue.close()
def cmd_queue_clear(args):
"""Wipe all queue state (pending branches, active branches, candidate claims,
heartbeats, and run metadata). The ERD cache is not touched.
Requires confirmation unless --yes is passed.
"""
queue = ERDQueue(args.queue)
try:
counts = queue.counts_by_status()
pending = counts.get('pending', 0)
done = counts.get('done', 0)
in_prog = len(queue.branches_in_progress())
print(f'Queue: {pending:,} pending {done:,} done {in_prog} in progress')
if not args.yes:
ans = input('Clear all queue state? [y/N] ').strip().lower()
if ans != 'y':
print('Aborted.')
return
queue.clear()
print('Queue cleared.')
finally:
queue.close()
# ---------------------------------------------------------------------------
# queue remove
# ---------------------------------------------------------------------------
def cmd_queue_remove(args):
"""Remove a branch from the pending queue.
Only removes branches with status='pending'. If the branch is currently
in-progress (being worked by a worker), use --force to also cancel it by
clearing its active_branches and candidate_claims rows so the worker's next heartbeat
yields no further claims. The worker will eventually notice the branch
is gone and move on.
"""
from wordle_ui import parse_pattern, fmt_pattern
all_answers = load_word_list(ANSWER_FILE)
word = args.word.strip().lower()
code = parse_pattern(args.pattern)
pat = fmt_pattern(code)
sc = ScoreCache(args.cache, all_answers)
rcache = ResponseCache(all_answers, sc)
groups = rcache.group_words(word, all_answers)
branch = groups.get(code, [])
sc.close()
if not branch:
print(f'{word.upper()} {pat}: no branch.')
return
branch_key = encode_subset(branch)
queue = ERDQueue(args.queue)
active = queue.get_active_branch(branch_key)
if active and not args.force:
print(f'{word.upper()} {pat}: branch is in-progress. '
f'Use --force to also cancel the active work.')
queue.close()
return
if active and args.force:
# Atomically clear candidate claims, the active_branches row, and the
# pending_branches row. All three DELETEs run in one transaction so a
# crash partway through cannot leave orphaned rows. (remove_pending()
# alone would silently no-op here because the pending row still has
# status='in_progress' after the active state is cleared.)
queue.cancel_active_branch(branch_key, remove_from_queue=True)
queue.close()
print(f'Cancelled in-progress work and removed {word.upper()} {pat} from queue.')
return
removed = queue.remove_pending(branch_key)
queue.close()
if removed:
print(f'Removed {word.upper()} {pat} from queue.')
else:
print(f'{word.upper()} {pat}: not found in pending queue '
f'(may already be done or not queued).')
# ---------------------------------------------------------------------------
# queue priority
# ---------------------------------------------------------------------------
def cmd_queue_priority(args):
"""Set the priority of a queued branch.
Priority is an integer; higher numbers are worked sooner. User-settable
values in the range 0–999,999 are reserved for normal use: 0 = default,
higher = sooner.
"""
if getattr(args, 'opener_word', None):
queue = ERDQueue(args.queue)
try:
updated = queue.set_ownerless_active_priority(
args.opener_word.strip().lower(), args.priority)
except ValueError as error:
print(error)
return
finally:
queue.close()
print(f'{updated:,} ownerless open branch(es) for '
f'{args.opener_word.strip().upper()}: priority set to '
f'{args.priority}.')
return
from wordle_ui import parse_pattern, fmt_pattern
all_answers = load_word_list(ANSWER_FILE)
word = args.word.strip().lower()
code = parse_pattern(args.pattern)
pat = fmt_pattern(code)
sc = ScoreCache(args.cache, all_answers)
rcache = ResponseCache(all_answers, sc)
groups = rcache.group_words(word, all_answers)
branch = groups.get(code, [])
sc.close()
if not branch:
print(f'{word.upper()} {pat}: no branch.')
return
branch_key = encode_subset(branch)
queue = ERDQueue(args.queue)
updated = queue.set_priority(branch_key, args.priority)
queue.close()
if updated:
print(f'{word.upper()} {pat}: priority set to {args.priority}.')
else:
print(f'{word.upper()} {pat}: not found in pending queue.')
# ---------------------------------------------------------------------------
# queue opener-priority
# ---------------------------------------------------------------------------
def cmd_queue_opener_priority(args):
"""Set the requested priority of an opener-work request, by word.
Resolves the word to an open (non-complete) opener_work_id via
ERDQueue.opener_work_candidates() and defers to
ERDQueue.set_opener_work_priority(), which applies the change to both the
request's pending roots and its active/promoted descendants in one
transaction. A branch owned by more than one live request keeps the
higher of their requested priorities (MAX(owner_priority) at the branch
level), so lowering one request's priority does not necessarily lower a
branch it shares with a higher-priority request.
A word with more than one open request is ambiguous; --opener-work-id
picks one. --opener-work-id may also name a completed request directly,
which is reported as such rather than as "not found". A word whose
requests are all complete is reported distinctly from a word with none.
"""
word = args.word.strip().lower()
try:
check_opener_priority_range(args.priority)
except ValueError as error:
print(error)
return
queue = ERDQueue(args.queue)
try:
all_rows = {row['opener_work_id']: row
for row in queue.opener_work_rows()
if row['opener'] == word}
if args.opener_work_id is not None:
if args.opener_work_id not in all_rows:
print(f'{word.upper()}: no opener-work request with id '
f'{args.opener_work_id}.')
return
opener_work_id = args.opener_work_id
else:
open_ids = [row['opener_work_id']
for row in queue.opener_work_candidates()
if row['opener'] == word]
if not open_ids:
if all_rows:
print(f'{word.upper()}: all {len(all_rows)} '
f'opener-work request(s) are complete.')
else:
print(f'{word.upper()}: no opener-work request found.')
return
if len(open_ids) > 1:
print(f'{word.upper()}: ambiguous, {len(open_ids)} open '
f'opener-work requests match. '
f'Use --opener-work-id to disambiguate.')
for candidate_id in sorted(open_ids):
row = all_rows[candidate_id]
requested_at = datetime.fromtimestamp(
row['requested_at']).strftime('%Y-%m-%d %H:%M')
print(f' id {candidate_id} '
f'priority {row["requested_priority"]} '
f'{row["state"]} {row["root_count"]} direct, '
f'{row["branch_count"]} branch(es) '
f'requested {requested_at}')
return
opener_work_id = open_ids[0]
updated = queue.set_opener_work_priority(opener_work_id, args.priority)
finally:
queue.close()
if updated:
print(f'{word.upper()} (id {opener_work_id}): '
f'requested priority set to {args.priority}.')
else:
print(f'{word.upper()} (id {opener_work_id}): '
f'request is complete, cannot reprioritize.')
# ---------------------------------------------------------------------------
# start / stop (systemd delegation)
# ---------------------------------------------------------------------------
_SYSTEMD_SERVICE = 'wordle-erd'
_REPORT_SERVER_SYSTEMD_SERVICE = 'wordle-report-server'
def _run_systemctl(service: str, action: str, *extra: str) -> int:
"""Run `systemctl --user <action> <service> [extra...]` and return the
exit code."""
import subprocess
result = subprocess.run(
['systemctl', '--user', action, service, *extra],
capture_output=False)
return result.returncode
def _run_journalctl(service: str, since: float) -> int:
"""Print journal entries for ``service`` written since ``since``."""
import subprocess
result = subprocess.run(
['journalctl', '--user', '--unit', service, '--since', f'@{since}',
'--no-pager', '--full'],
capture_output=False)
return result.returncode
def _service_scope_noun(args) -> str:
"""The subject describing which services a scoped command acted on."""
if args.swarm_only:
return 'Supervisor'
if args.web_only:
return 'Report server'
return 'Supervisor and report server'
def _add_service_scope_flags(parser, verb: str) -> None:
"""Add the mutually exclusive --swarm-only / --web-only scope flags shared
by start, stop, and restart. Neither flag means act on both services."""
scope = parser.add_mutually_exclusive_group()
scope.add_argument(
'--swarm-only', action='store_true',
help=f'{verb} the supervisor only; leave the report web server alone')
scope.add_argument(
'--web-only', action='store_true',
help=f'{verb} the report web server only; leave the supervisor alone')
def _start_or_restart_services(args, action: str) -> None:
"""Shared body for `start` and `restart`: run `systemctl <action>` on the
supervisor (unless --web-only) and the report server (unless --swarm-only).
A supervisor failure aborts before touching the report server: a broken
supervisor is the primary problem, and the report server has nothing new
to add while it's down. Once the supervisor action succeeds, the report
server is attempted independently and its failure is reported without
undoing the supervisor action -- these are two separately-managed
services, not a transaction."""
diagnostics_since = time.time()
if not args.web_only:
rc = _run_systemctl(_SYSTEMD_SERVICE, action)
if rc != 0:
print(f'systemctl {action} failed (exit {rc}). '
f'Is the service installed? '
f'Check: systemctl --user status {_SYSTEMD_SERVICE}',
file=sys.stderr)
sys.exit(rc)
server_rc = 0
if not args.swarm_only:
server_rc = _run_systemctl(_REPORT_SERVER_SYSTEMD_SERVICE, action)
if server_rc != 0:
print(f'systemctl {action} failed (exit {server_rc}). '
f'Is the service installed? '
f'Check: systemctl --user status '
f'{_REPORT_SERVER_SYSTEMD_SERVICE}',
file=sys.stderr)
if not args.web_only:
_run_systemctl(_SYSTEMD_SERVICE, 'status', '--no-pager', '--lines=0')
_run_journalctl(_SYSTEMD_SERVICE, diagnostics_since)
if not args.swarm_only:
_run_systemctl(
_REPORT_SERVER_SYSTEMD_SERVICE, 'status', '--no-pager',
'--lines=0')
_run_journalctl(_REPORT_SERVER_SYSTEMD_SERVICE, diagnostics_since)
if server_rc != 0:
sys.exit(server_rc)
def cmd_start(args):
"""Start the supervisor and the report web server via systemd, scoped by
--swarm-only / --web-only. Starting a service that is already running is a
no-op."""
_start_or_restart_services(args, 'start')
def cmd_stop(args):
"""Stop the supervisor and the report web server via systemd, scoped by
--swarm-only / --web-only.
Both stops are attempted even if the first fails: stopping is best-effort
cleanup, not a pipeline, so a failure on one service must never skip the
other."""
rc = 0
if not args.web_only:
rc = _run_systemctl(_SYSTEMD_SERVICE, 'stop')
if rc != 0:
print(f'systemctl stop failed (exit {rc}).', file=sys.stderr)
server_rc = 0
if not args.swarm_only:
server_rc = _run_systemctl(_REPORT_SERVER_SYSTEMD_SERVICE, 'stop')
if server_rc != 0:
print(f'systemctl stop failed for report server (exit '
f'{server_rc}).', file=sys.stderr)
if rc == 0 and server_rc == 0:
print(f'{_service_scope_noun(args)} stopped.')
else:
sys.exit(rc or server_rc)
def cmd_restart(args):
"""Restart the supervisor and the report web server via systemd, scoped by
--swarm-only / --web-only (stop + start in one step, per service)."""
_start_or_restart_services(args, 'restart')
# ---------------------------------------------------------------------------
# run (supervisor)
# ---------------------------------------------------------------------------
def _checkpoint_cache_on_start(cache_path):
"""Flush any leftover WAL into the main DB through SQLite's own recovery.
A worker killed mid-write leaves a -wal holding committed transactions.
NEVER delete that file: SQLite replays it on the next open, and removing
it discards committed data and corrupts the main DB. Instead open the
DB single-threaded and TRUNCATE-checkpoint, which is the blessed way to
drain the WAL cleanly before the worker swarm starts hammering it.
"""
import sqlite3
try:
conn = sqlite3.connect(cache_path, timeout=60)
conn.execute('PRAGMA busy_timeout = 60000')
conn.execute('PRAGMA wal_checkpoint(TRUNCATE)')
conn.close()
print('Startup: WAL checkpointed clean.')