From ee756d2a8cd032a9050df42f2cd3e339d876f1fb Mon Sep 17 00:00:00 2001 From: wouter Date: Fri, 10 Jul 2026 12:54:50 +0200 Subject: [PATCH] fix(sudo): show single hyperparameter table in interactive sudo set Interactive `btcli sudo set` printed the full hyperparameter values table and then a second picker table repeating names and descriptions. The values table now carries the selection numbers itself. Settable hyperparameters absent from the get_subnet_hyperparams_v3 response (max_allowed_uids, min_allowed_uids, network_pow_registration_allowed, recycle_or_burn, sn_owner_hotkey) are read from SubtensorModule storage so their rows show real values. Setter aliases already reachable from displayed rows (alpha_values, yuma3_enabled, subnet_owner_hotkey) get no row of their own. --- bittensor_cli/cli.py | 59 +++------- bittensor_cli/src/__init__.py | 17 +++ bittensor_cli/src/commands/sudo.py | 68 +++++++++-- .../test_sudo_get_hyperparameters.py | 111 ++++++++++++++++++ 4 files changed, 201 insertions(+), 54 deletions(-) create mode 100644 tests/unit_tests/test_sudo_get_hyperparameters.py diff --git a/bittensor_cli/cli.py b/bittensor_cli/cli.py index 5a81b224b..b87aa61df 100755 --- a/bittensor_cli/cli.py +++ b/bittensor_cli/cli.py @@ -42,7 +42,6 @@ COLORS, HYPERPARAMS, HYPERPARAMS_METADATA, - RootSudoOnly, WalletOptions, ) from bittensor_cli.src.bittensor import utils @@ -7304,12 +7303,17 @@ def sudo_set( self.verbosity_handler(quiet, verbose, json_output, prompt, decline) proxy = self.is_valid_proxy_name_or_ss58(proxy, False) + param_names: list[str] = [] if not param_name or not param_value: - hyperparams = self._run_command( - sudo.get_hyperparameters(self.initialize_chain(network), netuid), + success, param_names = self._run_command( + sudo.get_hyperparameters( + self.initialize_chain(network), + netuid, + numbered=not param_name, + ), exit_early=False, - ) - if not hyperparams: + ) or (False, []) + if not success: # Ensure we don't leave the websocket connection hanging. if self.subtensor: try: @@ -7328,49 +7332,13 @@ def sudo_set( "Param name not supplied with `--no-prompt` flag. Cannot continue" ) return False - hyperparam_list = sorted(HYPERPARAMS.keys()) - console.print("Available hyperparameters:\n") - - # Create a table to show hyperparameters with descriptions - param_table = Table( - Column("[white]#", style="dim", width=4), - Column("[white]HYPERPARAMETER", style=COLORS.SU.HYPERPARAMETER), - Column("[white]OWNER SETTABLE", style="bright_cyan", width=18), - Column("[white]DESCRIPTION", style="dim", overflow="fold"), - box=box.SIMPLE, - show_edge=False, - pad_edge=False, - ) - - for idx, param in enumerate(hyperparam_list, start=1): - metadata = HYPERPARAMS_METADATA.get(param, {}) - description = metadata.get("description", "No description available.") - - # Check ownership from HYPERPARAMS - _, root_sudo = HYPERPARAMS.get(param, ("", RootSudoOnly.FALSE)) - if root_sudo == RootSudoOnly.TRUE: - owner_settable_str = "[red]No (Root Only)[/red]" - elif root_sudo == RootSudoOnly.COMPLICATED: - owner_settable_str = "[yellow]COMPLICATED[/yellow]" - else: - owner_settable_str = "[green]Yes[/green]" - - param_table.add_row( - str(idx), - f"[bold]{param}[/bold]", - owner_settable_str, - description, - ) - - console.print(param_table) console.print() - choice = IntPrompt.ask( "Enter the [bold]number[/bold] of the hyperparameter", - choices=[str(i) for i in range(1, len(hyperparam_list) + 1)], + choices=[str(i) for i in range(1, len(param_names) + 1)], show_choices=False, ) - param_name = hyperparam_list[choice - 1] + param_name = param_names[choice - 1] # Show additional info for selected parameter metadata = HYPERPARAMS_METADATA.get(param_name, {}) @@ -7561,11 +7529,12 @@ def sudo_get( [green]$[/green] btcli sudo get --netuid 1 """ self.verbosity_handler(quiet, verbose, json_output, False) - return self._run_command( + success, _ = self._run_command( sudo.get_hyperparameters( self.initialize_chain(network), netuid, json_output ) - ) + ) or (False, []) + return success def sudo_senate( self, diff --git a/bittensor_cli/src/__init__.py b/bittensor_cli/src/__init__.py index 390a1329c..16db41ed0 100644 --- a/bittensor_cli/src/__init__.py +++ b/bittensor_cli/src/__init__.py @@ -656,6 +656,23 @@ class RootSudoOnly(Enum): "activity_cutoff_factor": "SubtensorModule", } +# Settable hyperparameters not reported by the get_subnet_hyperparams_v3 runtime +# API but readable from SubtensorModule storage (netuid-keyed maps). +HYPERPARAMS_STORAGE: dict[str, str] = { + "max_allowed_uids": "MaxAllowedUids", + "min_allowed_uids": "MinAllowedUids", + "network_pow_registration_allowed": "NetworkPowRegistrationAllowed", + "recycle_or_burn": "RecycleOrBurn", + "sn_owner_hotkey": "SubnetOwnerHotkey", +} + +# Setter-only registry entries reachable from other displayed rows: selecting +# alpha_high/alpha_low routes to alpha_values, yuma_version routes to +# yuma3_enabled, and subnet_owner_hotkey is an alias of sn_owner_hotkey. +HYPERPARAMS_SETTER_ALIASES = frozenset( + {"alpha_values", "subnet_owner_hotkey", "yuma3_enabled"} +) + # Hyperparameters whose root-sudo path uses a different extrinsic than the owner path. # Maps btcli name -> (pallet, extrinsic) for the call wrapped in Sudo. Owner-side # set_tempo is bounded to [360, 50400] and rate-limited; root's sudo_set_tempo accepts diff --git a/bittensor_cli/src/commands/sudo.py b/bittensor_cli/src/commands/sudo.py index d9dea2dd3..978ed744b 100644 --- a/bittensor_cli/src/commands/sudo.py +++ b/bittensor_cli/src/commands/sudo.py @@ -15,6 +15,8 @@ HYPERPARAMS_MODULE, HYPERPARAMS_METADATA, HYPERPARAMS_ROOT_EXTRINSIC, + HYPERPARAMS_SETTER_ALIASES, + HYPERPARAMS_STORAGE, RootSudoOnly, COLOR_PALETTE, ) @@ -44,6 +46,7 @@ ) if TYPE_CHECKING: + from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters from bittensor_cli.src.bittensor.subtensor_interface import ( SubtensorInterface, ProposalVoteData, @@ -1138,13 +1141,39 @@ def _sanitize_json_string( return value +async def _supplement_hyperparameters_from_storage( + subtensor: "SubtensorInterface", subnet: "SubnetHyperparameters", netuid: int +) -> None: + """Fill in values for settable hyperparameters absent from the runtime API + response but readable from SubtensorModule storage.""" + missing = [name for name in HYPERPARAMS_STORAGE if name not in subnet] + values = await asyncio.gather( + *( + subtensor.query("SubtensorModule", HYPERPARAMS_STORAGE[name], [netuid]) + for name in missing + ) + ) + for name, value in zip(missing, values): + if value is not None: + subnet.hyperparameters[name] = value + + async def get_hyperparameters( subtensor: "SubtensorInterface", netuid: int, json_output: bool = False, show_descriptions: bool = True, -) -> bool: - """View hyperparameters of a subnetwork.""" + numbered: bool = False, +) -> tuple[bool, list[str]]: + """View hyperparameters of a subnetwork. + + When `numbered` is True, each row is prefixed with a selection number and + settable hyperparameters not reported by the runtime API are added (with + their values read from storage where available), so callers can prompt the + user to pick a row from this table. + + Returns (success, hyperparameter names in display order). + """ print_verbose("Fetching hyperparameters") try: if not await subtensor.subnet_exists(netuid): @@ -1153,7 +1182,7 @@ async def get_hyperparameters( json_console.print_json(data={"error": error_msg}) else: print_error(error_msg) - return False + return False, [] subnet, subnet_info = await asyncio.gather( subtensor.get_subnet_hyperparameters(netuid), subtensor.subnet(netuid) ) @@ -1163,19 +1192,24 @@ async def get_hyperparameters( json_console.print_json(data={"error": error_msg}) else: print_error(error_msg) - return False + return False, [] except Exception as e: if json_output: json_console.print_json(data={"error": str(e)}) else: raise - return False + return False, [] # Determine if we should show extended info (descriptions, ownership) show_extended = show_descriptions and not json_output + numbered = numbered and show_extended + if numbered: + await _supplement_hyperparameters_from_storage(subtensor, subnet, netuid) if show_extended: + columns = [Column("[white]#", style="dim")] if numbered else [] table = Table( + *columns, Column("[white]HYPERPARAMETER", style=COLOR_PALETTE.SU.HYPERPARAMETER), Column("[white]VALUE", style=COLOR_PALETTE.SU.VALUE), Column("[white]NORMALIZED", style=COLOR_PALETTE.SU.NORMAL), @@ -1209,10 +1243,23 @@ async def get_hyperparameters( show_edge=True, ) dict_out = [] + param_names = [] normalized_values = normalize_hyperparameters(subnet, json_output=json_output) sorted_values = sorted(normalized_values, key=lambda x: x[0]) + if numbered: + # Keep settable hyperparameters without a readable value selectable from + # this single table, except setter aliases already reachable from other + # rows (see HYPERPARAMS_SETTER_ALIASES). + displayed = {param for param, _, _ in sorted_values} + sorted_values += [ + (param, "-", "-") + for param in sorted( + set(HYPERPARAMS) - displayed - HYPERPARAMS_SETTER_ALIASES + ) + ] for param, value, norm_value in sorted_values: + param_names.append(param) if not json_output: if show_extended: # Get metadata for this hyperparameter @@ -1236,13 +1283,16 @@ async def get_hyperparameters( else: description_with_link = description - table.add_row( + row = [ " " + param, value, norm_value, owner_settable_str, description_with_link, - ) + ] + if numbered: + row.insert(0, str(len(param_names))) + table.add_row(*row) else: table.add_row(" " + param, value, norm_value) else: @@ -1272,7 +1322,7 @@ async def get_hyperparameters( json_str = json.dumps(dict_out, ensure_ascii=True) sys.stdout.write(json_str + "\n") sys.stdout.flush() - return True + return True, param_names else: console.print(table) if show_extended: @@ -1295,7 +1345,7 @@ async def get_hyperparameters( console.print( "[dim]📚 For detailed documentation, visit: [link]https://docs.bittensor.com[/link]" ) - return True + return True, param_names async def get_senate( diff --git a/tests/unit_tests/test_sudo_get_hyperparameters.py b/tests/unit_tests/test_sudo_get_hyperparameters.py new file mode 100644 index 000000000..093da2333 --- /dev/null +++ b/tests/unit_tests/test_sudo_get_hyperparameters.py @@ -0,0 +1,111 @@ +""" +Unit tests for sudo.get_hyperparameters. + +Covers the (success, hyperparameter names) return contract and the `numbered` +selection mode used by the interactive `btcli sudo set` flow: one table where +params missing from the runtime API are filled in from SubtensorModule storage +and setter aliases are excluded. +""" + +import pytest +from unittest.mock import AsyncMock + +from bittensor_cli.src import ( + HYPERPARAMS, + HYPERPARAMS_SETTER_ALIASES, + HYPERPARAMS_STORAGE, +) +from bittensor_cli.src.bittensor.chain_data import SubnetHyperparameters +from bittensor_cli.src.commands.sudo import get_hyperparameters + +CHAIN_PARAMS = {"immunity_period": 65535, "kappa": 32767, "tempo": 360} +STORAGE_VALUES = { + "MaxAllowedUids": 257, + "MinAllowedUids": 64, + "NetworkPowRegistrationAllowed": True, + "RecycleOrBurn": "Burn", + "SubnetOwnerHotkey": "5HdrwVQQbMa8Wh271PDzvMHmM44wYM5wfnXCW3o97gDisuaY", +} + + +def _prepare_subtensor(mock_subtensor) -> SubnetHyperparameters: + subnet = SubnetHyperparameters(hyperparameters=dict(CHAIN_PARAMS)) + mock_subtensor.get_subnet_hyperparameters = AsyncMock(return_value=subnet) + mock_subtensor.subnet = AsyncMock(return_value=AsyncMock(subnet_name="test")) + mock_subtensor.query = AsyncMock( + side_effect=lambda module, func, params: STORAGE_VALUES.get(func) + ) + return subnet + + +@pytest.mark.asyncio +async def test_returns_chain_params_in_display_order(mock_subtensor): + _prepare_subtensor(mock_subtensor) + + success, param_names = await get_hyperparameters(mock_subtensor, netuid=18) + + assert success is True + assert param_names == sorted(CHAIN_PARAMS) + mock_subtensor.query.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_numbered_fills_missing_values_from_storage(mock_subtensor): + subnet = _prepare_subtensor(mock_subtensor) + + success, param_names = await get_hyperparameters( + mock_subtensor, netuid=18, numbered=True + ) + + assert success is True + for name, storage_function in HYPERPARAMS_STORAGE.items(): + assert subnet.hyperparameters[name] == STORAGE_VALUES[storage_function] + assert name in param_names + mock_subtensor.query.assert_awaited_with( + "SubtensorModule", "SubnetOwnerHotkey", [18] + ) + + +@pytest.mark.asyncio +async def test_numbered_excludes_setter_aliases_and_has_no_duplicates(mock_subtensor): + _prepare_subtensor(mock_subtensor) + + _, param_names = await get_hyperparameters(mock_subtensor, netuid=18, numbered=True) + + assert HYPERPARAMS_SETTER_ALIASES.isdisjoint(param_names) + assert set(HYPERPARAMS) - HYPERPARAMS_SETTER_ALIASES <= set(param_names) + assert len(param_names) == len(set(param_names)) + + +@pytest.mark.asyncio +async def test_numbered_falls_back_to_placeholder_when_storage_empty(mock_subtensor): + _prepare_subtensor(mock_subtensor) + mock_subtensor.query = AsyncMock(return_value=None) + + _, param_names = await get_hyperparameters(mock_subtensor, netuid=18, numbered=True) + + assert "sn_owner_hotkey" in param_names # appended with "-" placeholders + + +@pytest.mark.asyncio +async def test_numbered_has_no_effect_on_json_output(mock_subtensor, capsys): + _prepare_subtensor(mock_subtensor) + + success, param_names = await get_hyperparameters( + mock_subtensor, netuid=18, json_output=True, numbered=True + ) + + assert success is True + assert param_names == sorted(CHAIN_PARAMS) + assert "sn_owner_hotkey" not in capsys.readouterr().out + mock_subtensor.query.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_nonexistent_subnet_returns_failure(mock_subtensor): + mock_subtensor.subnet_exists = AsyncMock(return_value=False) + + success, param_names = await get_hyperparameters(mock_subtensor, netuid=999) + + assert success is False + assert param_names == []