From 0ccdb1876b64e6eacef8341666199fab25ae642f Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 8 Apr 2026 00:55:30 +0200 Subject: [PATCH 1/4] Persist scoring window across validator restarts --- allways/validator/scoring_store.py | 157 +++++++++++++++++++++++++++++ allways/validator/swap_tracker.py | 33 ++++-- neurons/validator.py | 5 + 3 files changed, 185 insertions(+), 10 deletions(-) create mode 100644 allways/validator/scoring_store.py diff --git a/allways/validator/scoring_store.py b/allways/validator/scoring_store.py new file mode 100644 index 0000000..6480997 --- /dev/null +++ b/allways/validator/scoring_store.py @@ -0,0 +1,157 @@ +"""Persist the scoring window and voted set across validator restarts. + +Without persistence, SwapTracker.window starts empty after a restart. +With SCORING_EMA_ALPHA=1.0 (instantaneous scoring), the first scoring +cycle zeros all miner weights because the window contains no completed +swaps. This module writes the window to a JSON file after every update +and restores it on cold start so scoring is continuous. +""" + +import json +import os +from pathlib import Path +from typing import Dict, List, Optional, Set, Tuple + +import bittensor as bt + +from allways.classes import Swap, SwapStatus + + +def _swap_to_dict(swap: Swap) -> dict: + return { + 'id': swap.id, + 'user_hotkey': swap.user_hotkey, + 'miner_hotkey': swap.miner_hotkey, + 'source_chain': swap.source_chain, + 'dest_chain': swap.dest_chain, + 'source_amount': swap.source_amount, + 'dest_amount': swap.dest_amount, + 'tao_amount': swap.tao_amount, + 'user_source_address': swap.user_source_address, + 'user_dest_address': swap.user_dest_address, + 'miner_source_address': swap.miner_source_address, + 'miner_dest_address': swap.miner_dest_address, + 'rate': swap.rate, + 'source_tx_hash': swap.source_tx_hash, + 'source_tx_block': swap.source_tx_block, + 'dest_tx_hash': swap.dest_tx_hash, + 'dest_tx_block': swap.dest_tx_block, + 'status': swap.status.value, + 'initiated_block': swap.initiated_block, + 'timeout_block': swap.timeout_block, + 'fulfilled_block': swap.fulfilled_block, + 'completed_block': swap.completed_block, + } + + +def _dict_to_swap(d: dict) -> Optional[Swap]: + try: + return Swap( + id=d['id'], + user_hotkey=d['user_hotkey'], + miner_hotkey=d['miner_hotkey'], + source_chain=d['source_chain'], + dest_chain=d['dest_chain'], + source_amount=d['source_amount'], + dest_amount=d['dest_amount'], + tao_amount=d['tao_amount'], + user_source_address=d['user_source_address'], + user_dest_address=d['user_dest_address'], + miner_source_address=d.get('miner_source_address', ''), + miner_dest_address=d.get('miner_dest_address', ''), + rate=d.get('rate', ''), + source_tx_hash=d.get('source_tx_hash', ''), + source_tx_block=d.get('source_tx_block', 0), + dest_tx_hash=d.get('dest_tx_hash', ''), + dest_tx_block=d.get('dest_tx_block', 0), + status=SwapStatus(d['status']), + initiated_block=d.get('initiated_block', 0), + timeout_block=d.get('timeout_block', 0), + fulfilled_block=d.get('fulfilled_block', 0), + completed_block=d.get('completed_block', 0), + ) + except (KeyError, ValueError, TypeError) as e: + bt.logging.debug(f'Failed to restore swap from cache: {e}') + return None + + +class ScoringWindowStore: + """Atomic JSON persistence for the scoring window and voted-id set. + + Uses write-to-tmp-then-rename for crash safety (same pattern as + SwapFulfiller._save_sent_cache). + """ + + def __init__(self, path: Path): + self._path = path + + def save(self, window: List[Swap], voted_ids: Set[int]) -> None: + """Persist current window and voted set to disk.""" + data: Dict = { + 'window': [_swap_to_dict(s) for s in window], + 'voted_ids': sorted(voted_ids), + } + try: + self._path.parent.mkdir(parents=True, exist_ok=True) + tmp = self._path.with_suffix('.tmp') + tmp.write_text(json.dumps(data)) + tmp.rename(self._path) + except Exception as e: + bt.logging.warning(f'Failed to persist scoring window: {e}') + + def load(self, window_blocks: int, current_block: int) -> Tuple[List[Swap], Set[int]]: + """Restore window and voted set, pruning entries older than window_blocks. + + Returns (window: List[Swap], voted_ids: Set[int]). + """ + if not self._path.exists(): + return [], set() + + try: + raw = json.loads(self._path.read_text()) + except Exception as e: + bt.logging.warning(f'Failed to read scoring window cache: {e}') + return [], set() + + window_start = current_block - window_blocks + raw_window = raw.get('window', []) + raw_voted = raw.get('voted_ids', []) + + window: List[Swap] = [] + for entry in raw_window: + swap = _dict_to_swap(entry) + if swap is None: + continue + if resolved_block(swap) < window_start: + continue + window.append(swap) + + voted_ids: Set[int] = set() + for v in raw_voted: + if isinstance(v, int): + voted_ids.add(v) + + valid_voted_count = sum(1 for v in raw_voted if isinstance(v, int)) + if len(window) != len(raw_window) or len(voted_ids) != valid_voted_count: + self.save(window, voted_ids) + + if window: + bt.logging.info(f'Restored {len(window)} swap(s) and {len(voted_ids)} voted ID(s) from scoring cache') + + return window, voted_ids + + def remove(self) -> None: + """Delete the cache file (for tests or manual reset).""" + try: + os.remove(self._path) + except FileNotFoundError: + pass + + +def resolved_block(swap: Swap) -> int: + """Block when a terminal swap was resolved.""" + if swap.completed_block > 0: + return swap.completed_block + if swap.timeout_block > 0: + return swap.timeout_block + return swap.initiated_block diff --git a/allways/validator/swap_tracker.py b/allways/validator/swap_tracker.py index a5d358f..54edf44 100644 --- a/allways/validator/swap_tracker.py +++ b/allways/validator/swap_tracker.py @@ -1,12 +1,13 @@ """Incremental swap lifecycle tracker. Eliminates O(N) full scans.""" import asyncio -from typing import Dict, List, Set +from typing import Dict, List, Optional, Set import bittensor as bt from allways.classes import Swap, SwapStatus from allways.contract_client import AllwaysContractClient +from allways.validator.scoring_store import ScoringWindowStore, resolved_block ACTIVE_STATUSES = (SwapStatus.ACTIVE, SwapStatus.FULFILLED) @@ -27,6 +28,7 @@ def __init__( client: AllwaysContractClient, fulfillment_timeout_blocks: int, window_blocks: int, + store: Optional[ScoringWindowStore] = None, ): self.client = client self.last_scanned_id = 0 @@ -36,9 +38,19 @@ def __init__( self.fulfillment_timeout_blocks = fulfillment_timeout_blocks self.window_blocks = window_blocks + self._store = store def initialize(self, current_block: int): - """Cold start — scan backward from latest swap to populate active set.""" + """Cold start — scan backward from latest swap to populate active set. + + Also restores the scoring window and voted set from disk so that + scoring is continuous across restarts. + """ + if self._store: + restored_window, restored_voted = self._store.load(self.window_blocks, current_block) + self.window = restored_window + self.voted_ids = restored_voted + next_id = self.client.get_next_swap_id() if next_id <= 1: self.last_scanned_id = 0 @@ -64,12 +76,14 @@ def initialize(self, current_block: int): self.active[swap.id] = swap self.last_scanned_id = latest_id + self._persist() bt.logging.info(f'SwapTracker initialized: active={len(self.active)}, last_scanned_id={self.last_scanned_id}') def mark_voted(self, swap_id: int): """Mark a swap as voted on to prevent redundant vote extrinsics.""" self.voted_ids.add(swap_id) + self._persist() def is_voted(self, swap_id: int) -> bool: """Check if we've already voted on this swap.""" @@ -130,15 +144,17 @@ async def _poll_inner(self): if resolved_ids: bt.logging.debug(f'SwapTracker: resolved {len(resolved_ids)}, {len(self.active)} still active') + self._persist() def prune_window(self, current_block: int): """Remove resolved swaps older than the scoring window.""" window_start = current_block - self.window_blocks before = len(self.window) - self.window = [s for s in self.window if _resolved_block(s) >= window_start] + self.window = [s for s in self.window if resolved_block(s) >= window_start] pruned = before - len(self.window) if pruned > 0: bt.logging.debug(f'SwapTracker: pruned {pruned} expired swaps from window') + self._persist() def get_fulfilled(self, current_block: int) -> List[Swap]: """Active FULFILLED swaps not yet past timeout (ready for verification).""" @@ -166,11 +182,8 @@ def get_timed_out(self, current_block: int) -> List[Swap]: and current_block > s.timeout_block ] + def _persist(self) -> None: + """Save window and voted set to disk if a store is configured.""" + if self._store: + self._store.save(self.window, self.voted_ids) -def _resolved_block(swap: Swap) -> int: - """Block when a terminal swap was resolved.""" - if swap.completed_block > 0: - return swap.completed_block - if swap.timeout_block > 0: - return swap.timeout_block - return swap.initiated_block diff --git a/neurons/validator.py b/neurons/validator.py index 8d42dcc..e049536 100644 --- a/neurons/validator.py +++ b/neurons/validator.py @@ -11,6 +11,7 @@ import threading import time from functools import partial +from pathlib import Path import bittensor as bt from dotenv import load_dotenv @@ -32,6 +33,7 @@ from allways.validator.chain_verification import SwapVerifier from allways.validator.forward import forward from allways.validator.pending_confirms import PendingConfirmQueue +from allways.validator.scoring_store import ScoringWindowStore from allways.validator.swap_tracker import SwapTracker from allways.validator.voting import SwapVoter from neurons.base.validator import BaseValidatorNeuron @@ -60,10 +62,13 @@ def __init__(self, config=None): except Exception as e: bt.logging.warning(f'Failed to read fee_divisor, using default {DEFAULT_FEE_DIVISOR}: {e}') self.fee_divisor = DEFAULT_FEE_DIVISOR + scoring_cache_path = Path(self.config.neuron.full_path) / 'scoring_window.json' + self.scoring_store = ScoringWindowStore(scoring_cache_path) self.swap_tracker = SwapTracker( client=self.contract_client, fulfillment_timeout_blocks=timeout_blocks, window_blocks=SCORING_WINDOW_BLOCKS, + store=self.scoring_store, ) self.swap_tracker.initialize(self.block) bt.logging.debug(f'Validator components: fee_divisor={self.fee_divisor}, timeout={timeout_blocks}') From c7d2460a8b8ace3213a1556fc0522e179a9d0ee3 Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 8 Apr 2026 01:19:49 +0200 Subject: [PATCH 2/4] Prune stale voted IDs on startup --- allways/validator/swap_tracker.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/allways/validator/swap_tracker.py b/allways/validator/swap_tracker.py index 54edf44..cd407a0 100644 --- a/allways/validator/swap_tracker.py +++ b/allways/validator/swap_tracker.py @@ -53,6 +53,11 @@ def initialize(self, current_block: int): next_id = self.client.get_next_swap_id() if next_id <= 1: + stale_voted = len(self.voted_ids) + if stale_voted > 0: + self.voted_ids.clear() + bt.logging.debug(f'SwapTracker init: pruned {stale_voted} stale voted IDs (no active swaps)') + self._persist() self.last_scanned_id = 0 bt.logging.info('SwapTracker initialized: no swaps exist') return @@ -75,6 +80,13 @@ def initialize(self, current_block: int): if swap.status in ACTIVE_STATUSES: self.active[swap.id] = swap + if self.voted_ids: + before = len(self.voted_ids) + self.voted_ids.intersection_update(self.active.keys()) + pruned = before - len(self.voted_ids) + if pruned > 0: + bt.logging.debug(f'SwapTracker init: pruned {pruned} stale voted IDs') + self.last_scanned_id = latest_id self._persist() From af191a00586a55693148d2bcac0be64f969264cd Mon Sep 17 00:00:00 2001 From: bitloi Date: Wed, 8 Apr 2026 23:17:39 +0200 Subject: [PATCH 3/4] Fix voted-ids compaction check comparing against filtered count instead of raw length --- allways/validator/scoring_store.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/allways/validator/scoring_store.py b/allways/validator/scoring_store.py index 6480997..5ffa07b 100644 --- a/allways/validator/scoring_store.py +++ b/allways/validator/scoring_store.py @@ -131,8 +131,7 @@ def load(self, window_blocks: int, current_block: int) -> Tuple[List[Swap], Set[ if isinstance(v, int): voted_ids.add(v) - valid_voted_count = sum(1 for v in raw_voted if isinstance(v, int)) - if len(window) != len(raw_window) or len(voted_ids) != valid_voted_count: + if len(window) != len(raw_window) or len(voted_ids) != len(raw_voted): self.save(window, voted_ids) if window: From fa25d7ec560345829e855e201e8f9f9850a4b3df Mon Sep 17 00:00:00 2001 From: bitloi Date: Fri, 10 Apr 2026 03:07:29 +0200 Subject: [PATCH 4/4] Address review: add tests, restore local _resolved_block, update stale docstring --- allways/validator/swap_tracker.py | 16 +- tests/test_scoring_store.py | 286 ++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 3 deletions(-) create mode 100644 tests/test_scoring_store.py diff --git a/allways/validator/swap_tracker.py b/allways/validator/swap_tracker.py index cd407a0..f8b425f 100644 --- a/allways/validator/swap_tracker.py +++ b/allways/validator/swap_tracker.py @@ -7,7 +7,7 @@ from allways.classes import Swap, SwapStatus from allways.contract_client import AllwaysContractClient -from allways.validator.scoring_store import ScoringWindowStore, resolved_block +from allways.validator.scoring_store import ScoringWindowStore ACTIVE_STATUSES = (SwapStatus.ACTIVE, SwapStatus.FULFILLED) @@ -20,7 +20,8 @@ class SwapTracker: - Monitoring: re-fetch all tracked ACTIVE/FULFILLED swaps each poll Resolved swaps are no longer stored on-chain, so cold start only recovers - active swaps. The scoring window populates naturally as swaps complete. + active swaps from chain. When a store is configured, the scoring window and + voted IDs are restored from disk before active-swap reconciliation. """ def __init__( @@ -162,7 +163,7 @@ def prune_window(self, current_block: int): """Remove resolved swaps older than the scoring window.""" window_start = current_block - self.window_blocks before = len(self.window) - self.window = [s for s in self.window if resolved_block(s) >= window_start] + self.window = [s for s in self.window if _resolved_block(s) >= window_start] pruned = before - len(self.window) if pruned > 0: bt.logging.debug(f'SwapTracker: pruned {pruned} expired swaps from window') @@ -199,3 +200,12 @@ def _persist(self) -> None: if self._store: self._store.save(self.window, self.voted_ids) + +def _resolved_block(swap: Swap) -> int: + """Block when a terminal swap was resolved.""" + if swap.completed_block > 0: + return swap.completed_block + if swap.timeout_block > 0: + return swap.timeout_block + return swap.initiated_block + diff --git a/tests/test_scoring_store.py b/tests/test_scoring_store.py new file mode 100644 index 0000000..ab64839 --- /dev/null +++ b/tests/test_scoring_store.py @@ -0,0 +1,286 @@ +"""Tests for scoring window persistence (ScoringWindowStore + SwapTracker integration).""" + +import json +from unittest.mock import MagicMock + +from allways.classes import Swap, SwapStatus +from allways.validator.scoring_store import ScoringWindowStore, _dict_to_swap, _swap_to_dict, resolved_block +from allways.validator.swap_tracker import SwapTracker + + +def _make_swap(swap_id: int, status: SwapStatus, initiated: int, completed: int = 0, timeout: int = 0) -> Swap: + return Swap( + id=swap_id, + user_hotkey='5User', + miner_hotkey='5Miner', + source_chain='btc', + dest_chain='tao', + source_amount=100_000, + dest_amount=1_000_000_000, + tao_amount=1_000_000_000, + user_source_address='bc1qtest', + user_dest_address='5Dest', + status=status, + initiated_block=initiated, + completed_block=completed, + timeout_block=timeout, + ) + + +class TestScoringWindowStore: + def test_roundtrip_save_load_preserves_all_swap_fields(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + + swap = _make_swap(11, SwapStatus.COMPLETED, initiated=100, completed=170, timeout=150) + swap.miner_source_address = 'bc1qminer' + swap.miner_dest_address = '5MinerDest' + swap.rate = '321.12345' + swap.source_tx_hash = 'source-hash' + swap.source_tx_block = 12345 + swap.dest_tx_hash = 'dest-hash' + swap.dest_tx_block = 12399 + swap.fulfilled_block = 165 + + store.save([swap], {11}) + + window, voted = store.load(window_blocks=3600, current_block=200) + assert len(window) == 1 + assert window[0] == swap + assert voted == {11} + + def test_roundtrip(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + + swap = _make_swap(1, SwapStatus.COMPLETED, initiated=100, completed=120) + store.save([swap], {1, 2, 3}) + + window, voted = store.load(window_blocks=3600, current_block=200) + assert len(window) == 1 + assert window[0].id == 1 + assert window[0].status == SwapStatus.COMPLETED + assert window[0].completed_block == 120 + assert voted == {1, 2, 3} + + def test_prune_on_load(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + + old_swap = _make_swap(1, SwapStatus.COMPLETED, initiated=10, completed=20) + recent_swap = _make_swap(2, SwapStatus.COMPLETED, initiated=3500, completed=3550) + store.save([old_swap, recent_swap], {1, 2}) + + window, voted = store.load(window_blocks=3600, current_block=4000) + assert len(window) == 1 + assert window[0].id == 2 + + def test_empty_file(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + window, voted = store.load(window_blocks=3600, current_block=100) + assert window == [] + assert voted == set() + + def test_corrupt_file(self, tmp_path): + path = tmp_path / 'window.json' + path.write_text('not json') + store = ScoringWindowStore(path) + window, voted = store.load(window_blocks=3600, current_block=100) + assert window == [] + assert voted == set() + + def test_partial_swap_data(self, tmp_path): + path = tmp_path / 'window.json' + path.write_text(json.dumps({'window': [{'id': 99}], 'voted_ids': []})) + store = ScoringWindowStore(path) + window, voted = store.load(window_blocks=3600, current_block=100) + assert len(window) == 0 + + def test_remove(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + store.save([], set()) + assert path.exists() + store.remove() + assert not path.exists() + store.remove() # no error on double remove + + +class TestSwapToDict: + def test_roundtrip_all_fields(self): + swap = _make_swap(42, SwapStatus.TIMED_OUT, initiated=100, timeout=130) + swap.rate = '345.5' + swap.source_tx_hash = 'abc123' + d = _swap_to_dict(swap) + restored = _dict_to_swap(d) + assert restored is not None + assert restored.id == 42 + assert restored.status == SwapStatus.TIMED_OUT + assert restored.rate == '345.5' + assert restored.source_tx_hash == 'abc123' + assert restored.timeout_block == 130 + + def test_resolved_block_completed(self): + swap = _make_swap(8, SwapStatus.COMPLETED, initiated=100, completed=140) + assert resolved_block(swap) == 140 + + def test_resolved_block_timeout(self): + swap = _make_swap(9, SwapStatus.TIMED_OUT, initiated=100, timeout=150) + assert resolved_block(swap) == 150 + + def test_resolved_block_fallback(self): + swap = _make_swap(10, SwapStatus.ACTIVE, initiated=111) + assert resolved_block(swap) == 111 + + +class TestSwapTrackerIntegration: + def test_window_restored_on_initialize(self, tmp_path): + """Regression: without persistence, window starts empty after restart, + and the first scoring cycle with alpha=1.0 wipes all miner scores.""" + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + + completed = _make_swap(5, SwapStatus.COMPLETED, initiated=100, completed=120) + store.save([completed], {5}) + + mock_client = MagicMock() + mock_client.get_next_swap_id.return_value = 1 + + tracker = SwapTracker( + client=mock_client, + fulfillment_timeout_blocks=30, + window_blocks=3600, + store=store, + ) + tracker.initialize(current_block=200) + + assert len(tracker.window) == 1 + assert tracker.window[0].id == 5 + assert tracker.voted_ids == set() + + def test_window_empty_without_store(self): + """Without store, window is empty on cold start (the original bug).""" + mock_client = MagicMock() + mock_client.get_next_swap_id.return_value = 1 + + tracker = SwapTracker( + client=mock_client, + fulfillment_timeout_blocks=30, + window_blocks=3600, + ) + tracker.initialize(current_block=200) + + assert len(tracker.window) == 0 + assert len(tracker.voted_ids) == 0 + + def test_mark_voted_persisted_immediately(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + + active = _make_swap(1, SwapStatus.FULFILLED, initiated=190, timeout=230) + mock_client = MagicMock() + mock_client.get_next_swap_id.return_value = 2 + mock_client.get_swap.return_value = active + + tracker = SwapTracker( + client=mock_client, + fulfillment_timeout_blocks=30, + window_blocks=3600, + store=store, + ) + tracker.initialize(current_block=200) + tracker.mark_voted(1) + + restarted = SwapTracker( + client=mock_client, + fulfillment_timeout_blocks=30, + window_blocks=3600, + store=store, + ) + restarted.initialize(current_block=201) + assert 1 in restarted.voted_ids + + def test_initialize_intersects_voted_ids_with_active_swaps(self, tmp_path): + path = tmp_path / 'window.json' + store = ScoringWindowStore(path) + + completed = _make_swap(5, SwapStatus.COMPLETED, initiated=100, completed=120) + store.save([completed], {1, 2, 999}) + + active_1 = _make_swap(1, SwapStatus.ACTIVE, initiated=195, timeout=240) + active_2 = _make_swap(2, SwapStatus.FULFILLED, initiated=196, timeout=241) + + mock_client = MagicMock() + mock_client.get_next_swap_id.return_value = 3 + + def _get_swap(swap_id): + if swap_id == 1: + return active_1 + if swap_id == 2: + return active_2 + return None + + mock_client.get_swap.side_effect = _get_swap + + tracker = SwapTracker( + client=mock_client, + fulfillment_timeout_blocks=30, + window_blocks=3600, + store=store, + ) + tracker.initialize(current_block=200) + + assert tracker.voted_ids == {1, 2} + + persisted = json.loads(path.read_text()) + assert persisted['voted_ids'] == [1, 2] + + +class TestStoreCompaction: + def test_load_compacts_invalid_and_stale_entries(self, tmp_path): + path = tmp_path / 'window.json' + old_swap = _make_swap(1, SwapStatus.COMPLETED, initiated=10, completed=20) + fresh_swap = _make_swap(2, SwapStatus.COMPLETED, initiated=200, completed=220) + + path.write_text( + json.dumps( + { + 'window': [_swap_to_dict(old_swap), {'id': 'bad'}, _swap_to_dict(fresh_swap)], + 'voted_ids': [1, 'oops', 2], + } + ) + ) + + store = ScoringWindowStore(path) + window, voted = store.load(window_blocks=100, current_block=250) + assert [s.id for s in window] == [2] + assert voted == {1, 2} + + compacted = json.loads(path.read_text()) + assert len(compacted['window']) == 1 + assert compacted['window'][0]['id'] == 2 + assert compacted['voted_ids'] == [1, 2] + + def test_load_compacts_invalid_voted_ids_even_when_window_unchanged(self, tmp_path): + path = tmp_path / 'window.json' + fresh_swap = _make_swap(7, SwapStatus.COMPLETED, initiated=210, completed=230) + + path.write_text( + json.dumps( + { + 'window': [_swap_to_dict(fresh_swap)], + 'voted_ids': [7, 'oops'], + } + ) + ) + + store = ScoringWindowStore(path) + window, voted = store.load(window_blocks=100, current_block=250) + + assert [s.id for s in window] == [7] + assert voted == {7} + + compacted = json.loads(path.read_text()) + assert compacted['window'][0]['id'] == 7 + assert compacted['voted_ids'] == [7]