diff --git a/.gitignore b/.gitignore index 70cbedd8b..2bfecfb38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.log requirements.txt **/*.ipynb +debug/ debug_rewards.jsonl results.db* sn13_db.db* diff --git a/apex/validator/logger_db.py b/apex/validator/logger_db.py index 5f012873a..e6fdb0c6b 100644 --- a/apex/validator/logger_db.py +++ b/apex/validator/logger_db.py @@ -5,11 +5,12 @@ from typing import Literal, TypedDict import aiosqlite +from loguru import logger from apex.common.models import MinerDiscriminatorResults -class _DiscriminatorItem(TypedDict): +class DiscriminatorItem(TypedDict): """Item placed on the queue that represents one discriminator result row.""" kind: Literal["discriminator"] @@ -17,65 +18,115 @@ class _DiscriminatorItem(TypedDict): class LoggerDB: + _COMMIT_FREQ = 60 + _COMMIT_CHANGES = 1000 + def __init__(self, db_path: Path | str = "results.db"): self.db_path = Path(db_path) - self._queue: asyncio.Queue[_DiscriminatorItem | object] = asyncio.Queue(maxsize=10_000) + self.db_path.parent.mkdir(parents=True, exist_ok=True) + self._queue: asyncio.Queue[DiscriminatorItem | object] = asyncio.Queue(maxsize=10_000) self._SHUTDOWN = object() + self._closing = asyncio.Event() async def start_loop(self) -> None: async with aiosqlite.connect(self.db_path) as db: await db.executescript( """ - PRAGMA journal_mode = WAL; + PRAGMA journal_mode=WAL; + PRAGMA wal_autocheckpoint=1000; + PRAGMA synchronous=NORMAL; + PRAGMA busy_timeout=5000; - -- Results coming back from a discriminator step. - -- Store *all* fields from DiscriminatorQueryResults; list fields are serialized as JSON. CREATE TABLE IF NOT EXISTS discriminator_results ( - query TEXT, - generator_hotkey TEXT, - generator_result TEXT, - generator_score REAL, + query TEXT, + generator_hotkey TEXT, + generator_result TEXT, + generator_score REAL, discriminator_hotkeys TEXT, -- JSON array of strings discriminator_results TEXT, -- JSON array of strings - discriminator_scores TEXT, -- JSON array of floats - timestamp INTEGER, -- Unix timestamp when row was added - processed INTEGER DEFAULT 0, + discriminator_scores TEXT, -- JSON array of floats + timestamp INTEGER, -- Unix timestamp when row was added + processed INTEGER DEFAULT 0, PRIMARY KEY (query, generator_hotkey) ); + + CREATE INDEX IF NOT EXISTS idx_discriminator_processed + ON discriminator_results(processed); """ ) await db.commit() + last_commit = time.monotonic() + last_changes = db.total_changes while True: - item = await self._queue.get() + try: + item = await asyncio.wait_for(self._queue.get(), timeout=20.0) + except TimeoutError: + if db.total_changes != last_changes: + await db.commit() + last_commit = time.monotonic() + last_changes = db.total_changes + continue + if item is self._SHUTDOWN: + self._queue.task_done() + await db.commit() + await db.execute("PRAGMA wal_checkpoint(TRUNCATE);") break - if isinstance(item, dict) and item.get("kind") == "discriminator": - row: MinerDiscriminatorResults = item["data"] - - await db.execute( - "INSERT OR REPLACE INTO discriminator_results VALUES (?,?,?,?,?,?,?,?,0)", - ( - row.query, - row.generator_hotkey, - row.generator_result, - row.generator_score, - json.dumps(row.discriminator_hotkeys), - json.dumps(row.discriminator_results), - json.dumps(row.discriminator_scores), - int(time.time()), # Current Unix timestamp - ), - ) - - # flush every 1 000 rows or on demand - if self._queue.empty() or db.total_changes % 1000 == 0: + try: + await self.add_entry(db, item=item) + except Exception: + await db.rollback() + finally: + self._queue.task_done() + + commit_changes = (db.total_changes - last_changes) >= self._COMMIT_CHANGES + commit_timer = time.monotonic() - last_commit >= self._COMMIT_FREQ + if commit_changes or commit_timer: + logger.debug(f"Commiting scores to the {self.db_path}") await db.commit() - await db.execute("PRAGMA wal_checkpoint(FULL);") - self._queue.task_done() + last_commit = time.monotonic() + last_changes = db.total_changes + + async def add_entry(self, db: aiosqlite.Connection, item: DiscriminatorItem | object) -> None: + if isinstance(item, dict) and item.get("kind") == "discriminator": + row: MinerDiscriminatorResults = item["data"] + + await db.execute( + """ + INSERT INTO discriminator_results ( + query, generator_hotkey, generator_result, generator_score, + discriminator_hotkeys, discriminator_results, discriminator_scores, timestamp + ) VALUES (?,?,?,?,?,?,?,?) + ON CONFLICT(query, generator_hotkey) DO UPDATE SET + generator_result = excluded.generator_result, + generator_score = excluded.generator_score, + discriminator_hotkeys = excluded.discriminator_hotkeys, + discriminator_results = excluded.discriminator_results, + discriminator_scores = excluded.discriminator_scores, + timestamp = excluded.timestamp + """, + ( + row.query, + row.generator_hotkey, + row.generator_result, + row.generator_score, + json.dumps(row.discriminator_hotkeys), + json.dumps(row.discriminator_results), + json.dumps(row.discriminator_scores), + int(time.time()), + ), + ) async def log(self, row: MinerDiscriminatorResults) -> None: + if self._closing.is_set(): + logger.error("Database is shutting down") + return + await self._queue.put({"kind": "discriminator", "data": row}) async def shutdown(self) -> None: + self._closing.set() + await self._queue.join() await self._queue.put(self._SHUTDOWN) diff --git a/apex/validator/logger_local.py b/apex/validator/logger_local.py new file mode 100644 index 000000000..fd738be54 --- /dev/null +++ b/apex/validator/logger_local.py @@ -0,0 +1,37 @@ +import json +from datetime import datetime +from pathlib import Path + +from apex.common.models import MinerDiscriminatorResults, MinerGeneratorResults + + +class LoggerLocal: + def __init__(self, filepath: str = "debug/logs.jsonl"): + self._debug_file_path = Path(filepath) + self._debug_file_path.parent.mkdir(exist_ok=True) + + async def log( + self, + query: str, + ground_truth: int, + reference: str | None, + generator_results: MinerGeneratorResults | None, + discriminator_results: MinerDiscriminatorResults | None, + ) -> None: + day = datetime.now().strftime("%Y-%m-%d") + filepath = Path(f"{self._debug_file_path.with_suffix('')}-{day}.jsonl") + record: dict[str, str | int | list[str] | list[float] | None] = { + "date": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "query": query, + "ground_truth": ground_truth, + "reference": reference, + "generators": generator_results.generator_results if generator_results else [], + "generator_hotkeys": generator_results.generator_hotkeys if generator_results else [], + "discriminator_results": discriminator_results.discriminator_results if discriminator_results else [], + "discriminator_scores": discriminator_results.discriminator_scores if discriminator_results else [], + "discriminator_hotkeys": discriminator_results.discriminator_hotkeys if discriminator_results else [], + "generator_hotkey": discriminator_results.generator_hotkey if discriminator_results else "", + } + + with filepath.open("a+") as fh: + fh.write(f"{json.dumps(record)}\n") diff --git a/apex/validator/miner_sampler.py b/apex/validator/miner_sampler.py index c4fc10433..b6f192aa2 100644 --- a/apex/validator/miner_sampler.py +++ b/apex/validator/miner_sampler.py @@ -39,7 +39,8 @@ def __init__( self, chain: AsyncChain, sample_mode: Literal["random", "sequential"] = "sequential", - sample_size: int = 50, + discriminator_sample_size: int = 50, + generator_sample_size: int = 1, logger_db: LoggerDB | None = None, available_uids: Sequence[int] | None = None, available_addresses: Sequence[str] | None = None, @@ -52,7 +53,8 @@ def __init__( sample_mode: Sampling mode, available modes: - random: Samples random uids. - sequential: Samples all uids sequentially. - sample_size: Amount of miners to be samples in one call. + discriminator_sample_size: Amount of miners to be sampled for discriminator queries. + generator_sample_size: Amount of miners to be sampled for generator queries. logger_db: Optional logger DB object. available_uids: List of available UIDs. If None, use all UIDs. available_addresses: List of available addresses for given UIDs. If None, use metagraph addresses. @@ -61,7 +63,8 @@ def __init__( """ self._chain = chain self._sample_mode = sample_mode - self._sample_size = sample_size + self._discriminator_sample_size = discriminator_sample_size + self._generator_sample_size = generator_sample_size self._logger_db = logger_db self._available_uids = available_uids self._available_addresses = available_addresses @@ -74,7 +77,7 @@ def __init__( self._sample_lock = asyncio.Lock() @async_cache(_TTL_UIDS_RESYNC) - async def _get_all_miners(self) -> list[MinerInfo]: + async def _get_all_miners(self, sample_size: int) -> list[MinerInfo]: meta = await self._chain.metagraph() miners: list[MinerInfo] = [] for idx in range(meta.n.item()): @@ -101,24 +104,24 @@ async def _get_all_miners(self) -> list[MinerInfo]: miners_test.append(miner_info) miners = miners_test - if self._sample_size > len(miners): + if sample_size > len(miners): logger.warning( - f"Sample size is larger than amount of miners: {self._sample_size} > {len(miners)}. " + f"Sample size is larger than amount of miners: {sample_size} > {len(miners)}. " f"Setting sample size to {len(miners)}" ) - self._sample_size = len(miners) + sample_size = len(miners) return miners - async def _sample_miners(self) -> list[MinerInfo]: - miners = await self._get_all_miners() + async def _sample_miners(self, sample_size: int) -> list[MinerInfo]: + miners = await self._get_all_miners(sample_size=sample_size) miners_sample: list[MinerInfo] = [] if self._sample_mode == "random": - miners_sample = random.sample(miners, self._sample_size) + miners_sample = random.sample(miners, sample_size) elif self._sample_mode == "sequential": async with self._sample_lock: - while len(miners_sample) < self._sample_size: + while len(miners_sample) < (sample_size): if not self._epoch_deque: # Get shuffled deque of miners. self._epoch_deque = deque(random.sample(miners, len(miners))) @@ -127,9 +130,7 @@ async def _sample_miners(self) -> list[MinerInfo]: else: raise ValueError(f"Unknown sampling mode: {self._sample_mode}") - logger.debug( - f"Sampled uids (sample size = {self._sample_size}): {sorted([miner.uid for miner in miners_sample])}" - ) + logger.debug(f"Sampled uids (sample size = {sample_size}): {sorted([miner.uid for miner in miners_sample])}") return miners_sample async def query_miners( @@ -157,7 +158,7 @@ async def query_miners( async def query_generators(self, query: str) -> MinerGeneratorResults: """Query the miners for the query.""" - miner_information = await self._sample_miners() + miner_information = await self._sample_miners(sample_size=self._generator_sample_size) body = {"step": "generator", "query": query} hotkeys: list[str] = [] @@ -177,7 +178,7 @@ async def query_discriminators( ground_truth: int, ) -> MinerDiscriminatorResults: """Query the miners for the query.""" - miner_information = await self._sample_miners() + miner_information = await self._sample_miners(sample_size=self._discriminator_sample_size) # Flip the coin for the generator. if ground_truth and generator_results: selected_generator: tuple[str, str] = random.choice( diff --git a/apex/validator/miner_scorer.py b/apex/validator/miner_scorer.py index a3985c8a6..55ce02e0c 100644 --- a/apex/validator/miner_scorer.py +++ b/apex/validator/miner_scorer.py @@ -1,7 +1,7 @@ import asyncio import json import time -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Iterable from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path @@ -37,13 +37,13 @@ def __init__( async def start_loop(self) -> None: self._running = True while self._running: - await asyncio.sleep(self.interval) logger.debug("Attempting to set weights") success = await self.set_scores() if success: logger.info("Successfully set weights") else: logger.error("Failed to set weights") + await asyncio.sleep(self.interval) async def shutdown(self) -> None: self._running = False @@ -52,9 +52,37 @@ async def shutdown(self) -> None: @asynccontextmanager async def _db() -> AsyncGenerator[aiosqlite.Connection, None]: async with aiosqlite.connect("results.db") as conn: - await conn.execute("PRAGMA foreign_keys = ON") + await conn.execute("PRAGMA journal_mode=WAL") + await conn.execute("PRAGMA synchronous=NORMAL") + await conn.execute("PRAGMA busy_timeout=15000") + await conn.execute("PRAGMA foreign_keys=ON") yield conn + async def _delete_expired(self, conn: aiosqlite.Connection, cutoff_ts: int, batch_size: int = 5000) -> int: + total = 0 + while True: + cur = await conn.execute( + """ + DELETE FROM discriminator_results + WHERE rowid IN ( + SELECT rowid FROM discriminator_results + WHERE timestamp < ? + LIMIT ? + ) + """, + (cutoff_ts, batch_size), + ) + n = cur.rowcount if cur.rowcount is not None else 0 + total += max(n, 0) + + # Commit each batch to release the write lock early. + await conn.commit() + logger.debug(f"Deleted batch: {n} (total deleeted: {total})") + + if n < batch_size: + break + return total + async def set_scores(self) -> bool: """Set weights based on the current miner scores. @@ -77,15 +105,16 @@ async def set_scores(self) -> bool: """, (cutoff_timestamp,), ) as cursor: - rows = await cursor.fetchall() + rows: Iterable[aiosqlite.Row] = await cursor.fetchall() except BaseException as exc: logger.exception(f"Exception during DB fetch: {exc}") return False # 2. Iterate over the in-memory list so that the caller can process freely. - logger.debug("Pre-processing miner's rewards") hkey_agg_rewards: dict[str, float] = {} + rows_count = 0 for generator_hotkey, generator_score, disc_hotkeys_json, disc_scores_json in rows: + rows_count += 1 # Deserialize JSON columns. disc_hotkeys = json.loads(disc_hotkeys_json) disc_scores = json.loads(disc_scores_json) @@ -101,12 +130,16 @@ async def set_scores(self) -> bool: for hotkey, reward in reward_dict.items(): hkey_agg_rewards[hotkey] = float(hkey_agg_rewards.get(hotkey, 0.0)) + float(reward) + logger.debug(f"Fetched {rows_count} rows for scoring") + logger.debug(f"Total hotkeys to score: {len(hkey_agg_rewards)}") + # 3. Delete rows that are older than the time window. - logger.debug("Cleaning up miner's outdated history") - await conn.execute( - "DELETE FROM discriminator_results WHERE timestamp < ?", - (cutoff_timestamp,), - ) + logger.debug("Cleaning up expired miner's history") + try: + deleted_total = await asyncio.wait_for(self._delete_expired(conn, cutoff_timestamp), timeout=15) + logger.debug(f"Expired rows cleanup done: {deleted_total} rows") + except TimeoutError: + logger.warning("Timed out deleting expired rows; will retry next loop") if self._debug: record: dict[str, str | dict[str, float]] = { @@ -118,13 +151,18 @@ async def set_scores(self) -> bool: fh.write(f"{record_str}\n") if self._weight_syncer is not None: + logger.debug("Attempting to perform weight synchronization") try: hkey_agg_rewards = await self._weight_syncer.compute_weighted_rewards(hkey_agg_rewards) + logger.debug(f"Total hotkeys to score after weight sync: {len(hkey_agg_rewards)}") except BaseException as exc: logger.error(f"Failed to compute weighted average rewards over the network, skipping: {exc}") if hkey_agg_rewards: rewards_array = np.array(list(hkey_agg_rewards.values())) + if rewards_array.min() < 0: + logger.warning(f"Negative reward detected: {rewards_array.min():.4f}, assigning zero value instead") + hkey_agg_rewards = {hkey: max(reward, 0) for hkey, reward in hkey_agg_rewards.items()} logger.debug( f"Setting weights to {len(hkey_agg_rewards)} hotkeys; " f"reward mean={rewards_array.mean():.4f} min={rewards_array.min():.4f}" diff --git a/apex/validator/pipeline.py b/apex/validator/pipeline.py index 41079a985..4f0a7ef7e 100644 --- a/apex/validator/pipeline.py +++ b/apex/validator/pipeline.py @@ -12,6 +12,7 @@ from apex.services.websearch.websearch_base import WebSearchBase from apex.validator import generate_query, generate_reference from apex.validator.logger_apex import LoggerApex +from apex.validator.logger_local import LoggerLocal from apex.validator.miner_sampler import MinerSampler @@ -29,6 +30,7 @@ def __init__( queue_size: int = 10_000, redundancy_rate: float = 0.05, # The rate that references are generated in addition to generator steps reference_rate: float = 0.5, # The rate that references are generated as opposed to generator steps + debug: bool = False, ): self.websearch = websearch self.miner_registry = miner_sampler @@ -43,6 +45,8 @@ def __init__( self.q_out: asyncio.Queue[str] = asyncio.Queue() self.redundancy_rate = redundancy_rate self.reference_rate = reference_rate + self._debug = debug + self._logger_local = LoggerLocal() async def start_loop(self, initial_queries: Sequence[str] | None = None) -> None: """Kick off producer -> consumer workers. Runs in perpetuity, generating unique IDs for each task.""" @@ -110,6 +114,15 @@ async def run_single(self, task: QueryTask) -> str: reference=reference, discriminator_results=discriminator_results, tool_history=tool_history ) + if self._debug: + await self._logger_local.log( + query=query, + ground_truth=ground_truth, + reference=reference, + generator_results=generator_results, + discriminator_results=discriminator_results, + ) + return task.query_id async def _periodic_consumer(self) -> None: diff --git a/apex/validator/weight_syncer.py b/apex/validator/weight_syncer.py index a4bf55f00..3c9e4bc67 100644 --- a/apex/validator/weight_syncer.py +++ b/apex/validator/weight_syncer.py @@ -150,7 +150,7 @@ async def compute_weighted_rewards(self, hotkey_rewards: dict[str, float]) -> di results = await asyncio.gather(*validator_rewards_tasks.values(), return_exceptions=True) - all_miner_hotkeys: set[str] = set() + all_miner_hotkeys: set[str] = set(hotkey_rewards) validator_rewards: dict[int, dict[str, float]] = {} for uid, result in zip(validator_rewards_tasks, results, strict=True): if isinstance(result, BaseException) or not result: diff --git a/pyproject.toml b/pyproject.toml index f7d6498da..3231b11a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "apex" -version = "3.0.2" +version = "3.0.3" description = "Bittensor Subnet 1: Apex" readme = "README.md" requires-python = "~=3.11" @@ -26,7 +26,7 @@ dependencies = [ "loguru>=0.7.3", "tavily-python>=0.7.10", "pip>=25.1.1", - "bittensor>=9.7.0", + "bittensor>=9.8.3", "rouge>=1.0.1", "substrate-interface>=1.7.11", "types-netaddr>=1.3.0.20240530", diff --git a/tests/validator/test_miner_sampler.py b/tests/validator/test_miner_sampler.py index 8a4b2fcb1..2195abbcc 100644 --- a/tests/validator/test_miner_sampler.py +++ b/tests/validator/test_miner_sampler.py @@ -91,7 +91,7 @@ def test_miner_info_hash() -> None: @pytest.mark.asyncio async def test_get_all_miners(miner_sampler: MinerSampler, mock_metagraph: MockMetagraph) -> None: """Tests that all miners are returned.""" - miners = await miner_sampler._get_all_miners() + miners = await miner_sampler._get_all_miners(sample_size=3) assert len(miners) == 3 uids = {m.uid for m in miners} assert uids == {1, 3, 5} @@ -110,7 +110,7 @@ async def test_get_all_miners_with_available_uids(mock_chain: MockAsyncChain) -> available_uids=[1, 5, 10], validator_min_stake=16000, ) - miners = await sampler._get_all_miners() + miners = await sampler._get_all_miners(sample_size=3) assert len(miners) == 2 uids = {m.uid for m in miners} assert uids == {1, 5} @@ -125,7 +125,7 @@ async def test_get_all_miners_with_available_uids_and_addresses(mock_chain: Mock available_addresses=["http://localhost:1234", "http://localhost:5678"], validator_min_stake=16000, ) - miners = await sampler._get_all_miners() + miners = await sampler._get_all_miners(sample_size=3) assert len(miners) == 2 miner1 = next(m for m in miners if m.uid == 1) miner3 = next(m for m in miners if m.uid == 3) @@ -137,7 +137,7 @@ async def test_get_all_miners_with_available_uids_and_addresses(mock_chain: Mock async def test_sample_miners_random(miner_sampler: MinerSampler) -> None: """Tests that a random sample of miners is returned.""" miner_sampler._sample_mode = "random" - miner_sampler._sample_size = 2 + miner_sampler._discriminator_sample_size = 2 with patch( "random.sample", @@ -146,10 +146,10 @@ async def test_sample_miners_random(miner_sampler: MinerSampler) -> None: MinerInfo(hotkey="key3", uid=3, address="http://3.3.3.3:8002"), ], ) as mock_random_sample: - miners = await miner_sampler._sample_miners() + miners = await miner_sampler._sample_miners(sample_size=2) assert len(miners) == 2 mock_random_sample.assert_called_once() - all_miners = await miner_sampler._get_all_miners() + all_miners = await miner_sampler._get_all_miners(sample_size=2) arg_uids = {m.uid for m in mock_random_sample.call_args[0][0]} all_uids = {m.uid for m in all_miners} assert arg_uids == all_uids @@ -160,9 +160,9 @@ async def test_sample_miners_random(miner_sampler: MinerSampler) -> None: async def test_sample_miners_sequential(monkeypatch: MagicMock, miner_sampler: MinerSampler) -> None: """Tests that a sequential sample of miners is returned.""" miner_sampler._sample_mode = "sequential" - miner_sampler._sample_size = 2 + miner_sampler._discriminator_sample_size = 2 - all_miners = await miner_sampler._get_all_miners() + all_miners = await miner_sampler._get_all_miners(sample_size=2) all_miners.sort(key=lambda m: m.uid) monkeypatch.setattr(miner_sampler, "_get_all_miners", AsyncMock(return_value=all_miners)) @@ -171,7 +171,7 @@ async def test_sample_miners_sequential(monkeypatch: MagicMock, miner_sampler: M "random.sample", return_value=[MinerInfo(uid=1, address="", hotkey="1"), MinerInfo(uid=5, address="", hotkey="5")], ): - miners1 = await miner_sampler._sample_miners() + miners1 = await miner_sampler._sample_miners(sample_size=2) assert len(miners1) == 2 assert {m.uid for m in miners1} == {all_miners[0].uid, all_miners[2].uid} @@ -181,7 +181,7 @@ async def test_sample_miners_sequential(monkeypatch: MagicMock, miner_sampler: M "random.sample", return_value=[MinerInfo(uid=3, address="", hotkey="3"), MinerInfo(uid=5, address="", hotkey="5")], ): - miners2 = await miner_sampler._sample_miners() + miners2 = await miner_sampler._sample_miners(sample_size=2) assert len(miners2) == 2 assert {m.uid for m in miners2} == {all_miners[1].uid, all_miners[2].uid} diff --git a/tests/validator/test_miner_scorer.py b/tests/validator/test_miner_scorer.py index 8babcc5ac..9d4222c08 100644 --- a/tests/validator/test_miner_scorer.py +++ b/tests/validator/test_miner_scorer.py @@ -5,7 +5,7 @@ from collections.abc import AsyncGenerator from pathlib import Path from typing import Any -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, call, patch import aiosqlite import pytest @@ -408,7 +408,16 @@ async def test_db_context_manager(self) -> None: assert conn is mock_conn mock_connect.assert_called_once_with("results.db") - mock_conn.execute.assert_called_once_with("PRAGMA foreign_keys = ON") + + mock_conn.execute.assert_has_calls( + [ + call("PRAGMA journal_mode=WAL"), + call("PRAGMA synchronous=NORMAL"), + call("PRAGMA busy_timeout=15000"), + call("PRAGMA foreign_keys=ON"), + ] + ) + assert mock_conn.execute.call_count == 4 finally: Path(db_path).unlink() diff --git a/uv.lock b/uv.lock index 70e3f580f..373ffbbee 100644 --- a/uv.lock +++ b/uv.lock @@ -141,7 +141,7 @@ wheels = [ [[package]] name = "apex" -version = "3.0.1" +version = "3.0.3" source = { virtual = "." } dependencies = [ { name = "aiohttp" }, @@ -202,7 +202,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.8.3" }, { name = "aiosqlite", specifier = ">=0.21.0" }, { name = "beautifulsoup4", specifier = ">=4.13.3" }, - { name = "bittensor", specifier = ">=9.7.0" }, + { name = "bittensor", specifier = ">=9.8.3" }, { name = "cachetools", specifier = ">=5.0.0" }, { name = "dotenv", specifier = ">=0.9.9" }, { name = "faiss-cpu", specifier = ">=1.8.0" },