Skip to content

[Feature] Configurable Tempo & Owner-Triggered Epochs#2638

Open
evgeny-s wants to merge 25 commits into
devnet-readyfrom
feature/dynamic-tempo
Open

[Feature] Configurable Tempo & Owner-Triggered Epochs#2638
evgeny-s wants to merge 25 commits into
devnet-readyfrom
feature/dynamic-tempo

Conversation

@evgeny-s
Copy link
Copy Markdown
Collaborator

@evgeny-s evgeny-s commented May 6, 2026

Description

Implementation of the specification.

Demo (non-technical): https://dynamic-tempo-non-technical.netlify.app/
Demo (technical): https://dynamic-tempo-technical.netlify.app/#1

Related Issue(s)

  • Closes #[issue number]

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update
  • Other (please describe):

Breaking Change

Check the 11. Breaking changes & compatibility section.

Checklist

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have run ./scripts/fix_rust.sh to ensure my code is formatted and linted correctly
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • New and existing unit tests pass locally with my changes
  • Any dependent changes have been merged and published in downstream modules

Screenshots (if applicable)

Please include any relevant screenshots or GIFs that demonstrate the changes made.

Additional Notes

Please provide any additional information or context that may be helpful for reviewers.

Simulations (miner rewards Events):

Trigger epoch:
image
Set tempo:
image

@evgeny-s evgeny-s added the skip-cargo-audit This PR fails cargo audit but needs to be merged anyway label May 6, 2026
@evgeny-s evgeny-s changed the title Dynamic tempo implementation [Feature] Configurable Tempo & Owner-Triggered Epochs May 6, 2026
Self::blocks_until_next_epoch(netuid, Self::get_tempo(netuid), current_block) == 0
let tempo = Self::get_tempo(netuid);
if tempo == 0 {
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This means that if tempo is set to 0, manual trigger or max cap of MAX_TEMPO will not work. Is this by design?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, the previous implementation meant that if tempo is 0, we don't run tempo on this subnet.
We don't have this case in Mainnet, but we support it. There is still a possibility to set the tempo to 0 by the root.
So we support the same semantics.

/// stateful scheduler (period `tempo + 1`, anchored on `LastEpochBlock`). Used by the
/// admin-freeze-window predicate and external tooling. Returns `u64::MAX` when
/// `tempo == 0` (legacy defensive short-circuit).
pub fn blocks_until_next_epoch(netuid: NetUid, tempo: u16, block_number: u64) -> u64 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Period is tempo + 1: next firing at last + tempo + 1." comment and the code below is not anymore correct.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated the function name and the comment to avoid the confusion: ad7ba80


let now = Self::get_current_block_as_u64();

Tempo::<T>::insert(netuid, tempo);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a function since Tempo and LastEpochBlock are always updated together?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a helper function: df184e3

@evgeny-s
Copy link
Copy Markdown
Collaborator Author

Hey, @basfroman , we have a bunch of tests that failed on the SDK side.
The main reason is now we have a stateful calculation for the next epoch block, because the tempo is dynamic.
So, here you can just call the new runtime API - get_next_epoch_start_block.


/// Effective activity cutoff in blocks, derived from `ActivityCutoffFactorMilli` and `Tempo`.
/// `cutoff_blocks = (factor × tempo) / 1000`, clamped to ≥ 1.
pub fn get_activity_cutoff_blocks(netuid: NetUid) -> u64 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we use it to replace get_activity_cutoff everywhere? for instance in metagraph.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, because ActivityCutoff is deprecated and will be removed in the follow-up release.
Who can help with updating it?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is fine to remove the storage later. Actually, my question is for https://github.com/opentensor/subtensor/blob/devnet-ready/pallets/subtensor/src/rpc_info/metagraph.rs#L713, if we should use get_activity_cutoff_blocks to replace get_activity_cutoff? It will change the return type, from u16 to u64.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, for me to update:

  • metagraph call
  • breaking changes section in the description, regarding the metagraph

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please mark ActivityCutoff as #[deprecated(note = "Some explanation")]? It will propagate to metadata.

evgeny-s added 3 commits May 15, 2026 14:07
# Conflicts:
#	pallets/subtensor/src/utils/rate_limiting.rs
# Conflicts:
#	pallets/subtensor/src/coinbase/run_coinbase.rs
#	pallets/subtensor/src/macros/dispatches.rs
#	pallets/subtensor/src/macros/events.rs
#	pallets/subtensor/src/macros/hooks.rs
#	pallets/subtensor/src/tests/migration.rs
#	pallets/subtensor/src/tests/mod.rs
#	pallets/subtensor/src/utils/rate_limiting.rs
@github-actions github-actions Bot mentioned this pull request May 21, 2026
4 tasks
- Updated reveal/commit logic based on stateful epoch index
- Updated migration for CR-v2 storage item
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 22, 2026

🛡️ AI Review — Skeptic (security review)

VERDICT: SAFE

Baseline scrutiny: established contributor with write permission, same author/committer, no known Gittensor allowlist hit, targeting devnet-ready.

Reviewed the pre-fetched PR metadata, prior Skeptic sticky, changed-file list, commits, comments, and the runtime diff statically. No .github/ai-review/* or .github/copilot-instructions.md changes were present, and the PR does not add or update Cargo dependencies. The security-sensitive changes remain concentrated in the dynamic tempo scheduler, owner epoch controls, commit-reveal epoch indexing, and migration paths; I did not find a malicious pattern, practical origin bypass, runtime panic source, or exploitable supply-chain change in the current diff.

Findings

No findings.

Conclusion

No security vulnerability or malicious behavior found in the reviewed diff. Verdict is SAFE.


🔍 AI Review — Auditor (domain review)

VERDICT: 👎

Established contributor with write permission; no direct Gittensor allowlist hit, LIKELY by repo-contribution heuristic; overlapping PRs share common runtime files but do not duplicate this dynamic-tempo scope.

The Auditor proposed a replacement PR description, but the current body is non-trivial; not overwriting. Maintainers: ask the Auditor to regenerate if you want it.

The prior owner-extrinsic guard findings still apply. The implementation still authenticates only against SubnetOwner before writing per-netuid tempo state, but prior subnet-deletion migrations removed NetworksAdded without clearing SubnetOwner, so deleted netuids can retain an owner entry.

I did not run the test suite; the issue is visible statically. I also attempted the devnet-ready spec_version check, but this sandbox could not resolve dev.chain.opentensor.ai, so no spec-version auto-fix was applied.

Findings

Sev File Finding
MEDIUM pallets/subtensor/src/coinbase/tempo_control.rs:13 Guard set_tempo against non-existent subnets inline
MEDIUM pallets/subtensor/src/coinbase/tempo_control.rs:42 Guard activity-cutoff updates against non-existent subnets inline
MEDIUM pallets/subtensor/src/coinbase/tempo_control.rs:70 Guard manual epoch triggers against non-existent subnets inline

Prior-comment reconciliation

  • ca054247: not addresseddo_set_tempo still calls ensure_subnet_owner without first checking Self::if_subnet_exist(netuid).
  • ad9f12b4: not addresseddo_set_activity_cutoff_factor still lacks a live-subnet guard before mutating activity cutoff state.
  • a9c1062f: not addresseddo_trigger_epoch still lacks a live-subnet guard before writing PendingEpochAt.

Conclusion

Blocking on the missing subnet-existence guard in the new owner extrinsics. Add if_subnet_exist checks consistently before mutating per-netuid tempo state, then regenerate weights if the added read changes the benchmarked read counts.


📜 Previous run (superseded)
Sev File Finding Status
MEDIUM pallets/subtensor/src/coinbase/tempo_control.rs:13 Guard set_tempo against non-existent subnets ➡️ Carried forward to current findings
do_set_tempo still calls ensure_subnet_owner without first checking Self::if_subnet_exist(netuid).
MEDIUM pallets/subtensor/src/coinbase/tempo_control.rs:42 Guard activity-cutoff updates against non-existent subnets ➡️ Carried forward to current findings
do_set_activity_cutoff_factor still lacks a live-subnet guard before mutating activity cutoff state.
MEDIUM pallets/subtensor/src/coinbase/tempo_control.rs:70 Guard manual epoch triggers against non-existent subnets ➡️ Carried forward to current findings
do_trigger_epoch still lacks a live-subnet guard before writing PendingEpochAt.

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

impl<T: Config> Pallet<T> {
/// Owner-side `set_tempo` implementation.
pub fn do_set_tempo(origin: OriginFor<T>, netuid: NetUid, tempo: u16) -> DispatchResult {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Guard set_tempo against non-existent subnets

ensure_subnet_owner only compares against SubnetOwner[netuid], whose storage type has a default account value; it does not prove that NetworksAdded[netuid] exists. This direct owner extrinsic should match the admin-utils pattern and reject unknown netuids before writing Tempo / LastEpochBlock / rate-limit state. Otherwise a call against an unregistered netuid can create scheduler/hyperparameter state outside the subnet lifecycle.

Suggested change
let who = Self::ensure_subnet_owner(origin, netuid)?;
ensure!(Self::if_subnet_exist(netuid), Error::<T>::SubnetNotExists);
let who = Self::ensure_subnet_owner(origin, netuid)?;

netuid: NetUid,
factor_milli: u32,
) -> DispatchResult {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Guard activity-cutoff updates against non-existent subnets

This has the same lifecycle hole as set_tempo: the owner check does not establish that the subnet exists, but the call writes ActivityCutoffFactorMilli and rate-limit state. Add the explicit subnet existence check before consuming the origin.

Suggested change
let who = Self::ensure_subnet_owner(origin, netuid)?;
ensure!(Self::if_subnet_exist(netuid), Error::<T>::SubnetNotExists);
let who = Self::ensure_subnet_owner(origin, netuid)?;

/// Schedules the triggered epoch to fire after `AdminFreezeWindow` blocks; that
/// countdown engages the freeze window for the subnet via `is_in_admin_freeze_window`.
pub fn do_trigger_epoch(origin: OriginFor<T>, netuid: NetUid) -> Result<(), DispatchError> {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Guard manual epoch triggers against non-existent subnets

trigger_epoch can currently write PendingEpochAt for a netuid that is not in NetworksAdded. Since PendingEpochAt participates in scheduling and the admin freeze predicate, keep it inside the normal subnet lifecycle by rejecting missing subnets first.

Suggested change
let who = Self::ensure_subnet_owner(origin, netuid)?;
ensure!(Self::if_subnet_exist(netuid), Error::<T>::SubnetNotExists);
let who = Self::ensure_subnet_owner(origin, netuid)?;

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

Copy link
Copy Markdown
Contributor

@github-actions github-actions Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI review — see the sticky summary comment for the verdict and the inline comments below for specific findings.

impl<T: Config> Pallet<T> {
/// Owner-side `set_tempo` implementation.
pub fn do_set_tempo(origin: OriginFor<T>, netuid: NetUid, tempo: u16) -> DispatchResult {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Guard set_tempo against non-existent subnets

ensure_subnet_owner only compares SubnetOwner[netuid] to the signer; it does not prove NetworksAdded[netuid] is alive. Prior delete-subnet migrations removed NetworksAdded without clearing SubnetOwner, so an old owner of a deleted subnet can still pass this check and recreate Tempo / LastEpochBlock entries for a non-existent netuid. Check Self::if_subnet_exist(netuid) before the owner check, and update the benchmarked weight if the added read is not already covered.

Suggested change
let who = Self::ensure_subnet_owner(origin, netuid)?;
ensure!(Self::if_subnet_exist(netuid), Error::<T>::SubnetNotExists);
let who = Self::ensure_subnet_owner(origin, netuid)?;

netuid: NetUid,
factor_milli: u32,
) -> DispatchResult {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Guard activity-cutoff updates against non-existent subnets

This path has the same stale-owner problem: it can write ActivityCutoffFactorMilli[netuid] for a netuid that is no longer present in NetworksAdded. Add the subnet-existence check before accepting the owner origin, and account for the extra storage read in weights if needed.

Suggested change
let who = Self::ensure_subnet_owner(origin, netuid)?;
ensure!(Self::if_subnet_exist(netuid), Error::<T>::SubnetNotExists);
let who = Self::ensure_subnet_owner(origin, netuid)?;

/// Schedules the triggered epoch to fire after `AdminFreezeWindow` blocks; that
/// countdown engages the freeze window for the subnet via `is_in_admin_freeze_window`.
pub fn do_trigger_epoch(origin: OriginFor<T>, netuid: NetUid) -> Result<(), DispatchError> {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Guard manual epoch triggers against non-existent subnets

A stale SubnetOwner entry can still schedule PendingEpochAt[netuid] for a deleted subnet because this extrinsic never checks NetworksAdded. The scheduler only iterates live subnets, but leaving trigger state behind for dead netuids is inconsistent with the rest of subnet lifecycle cleanup and can become active if the netuid is later reused. Require Self::if_subnet_exist(netuid) before the owner check, and update the weight if needed.

Suggested change
let who = Self::ensure_subnet_owner(origin, netuid)?;
ensure!(Self::if_subnet_exist(netuid), Error::<T>::SubnetNotExists);
let who = Self::ensure_subnet_owner(origin, netuid)?;

@github-actions
Copy link
Copy Markdown
Contributor

🔄 AI review updated — Skeptic: SAFE Auditor: 👎

@evgeny-s evgeny-s added the breaking-change This PR introduces a noteworthy breaking change label May 22, 2026
@github-actions
Copy link
Copy Markdown
Contributor

@opentensor/cerebrum / @opentensor/gyrus / @opentensor/cortex breaking change detected! Please prepare accordingly!

/// locked-mass decay. Passing `false` removes the flag, returning the
/// caller's lock to normal decay.
#[pallet::call_index(138)]
#[pallet::call_index(139)]
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we changing the call index here?

},

/// Activity-cutoff factor (per-mille) set on a subnet by its owner.
ActivityCutoffFactorMilliSet(NetUid, u32),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should prefer named fields over tuple for events, easier to access from client side. event.netuid is more meaningful than event.0.

Comment on lines +638 to +645
/// `should_run_epoch` returned true but `is_epoch_input_state_consistent` returned false;
/// schedule advanced, epoch execution skipped.
EpochSkippedDueToInconsistentState {
/// The subnet identifier.
netuid: NetUid,
/// The block at which the slot was consumed.
block: u64,
},
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be more flexible to have EpochSkipped, more future-proof if we decide to add new skip reasons.

Comment on lines +308 to +313
/// Tempo value out of `[MinTempo, MaxTempo]` bounds.
TempoOutOfBounds,
/// Activity-cutoff factor out of `[MinActivityCutoffFactorMilli, MaxActivityCutoffFactorMilli]` bounds.
ActivityCutoffFactorMilliOutOfBounds,
/// `trigger_epoch` called while a previously triggered epoch is still pending.
EpochTriggerAlreadyPending,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefer plain English doc comments here rather than references to specific function names, since these comments are exposed through runtime metadata and may be shown client-side.

Comment on lines +638 to +639
/// `should_run_epoch` returned true but `is_epoch_input_state_consistent` returned false;
/// schedule advanced, epoch execution skipped.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment as errors.


/// Effective activity cutoff in blocks, derived from `ActivityCutoffFactorMilli` and `Tempo`.
/// `cutoff_blocks = (factor × tempo) / 1000`, clamped to ≥ 1.
pub fn get_activity_cutoff_blocks(netuid: NetUid) -> u64 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please mark ActivityCutoff as #[deprecated(note = "Some explanation")]? It will propagate to metadata.

Comment on lines +1770 to +1782
/// Lower bound for owner-set tempo. Also the fixed cooldown for `set_tempo`.
pub const MIN_TEMPO: u16 = 360;
/// Upper bound for owner-set tempo (≈ 7 days at 12 s/block).
pub const MAX_TEMPO: u16 = 50_400;
/// Lower bound for activity-cutoff factor (per-mille). 1_000 = one full tempo.
pub const MIN_ACTIVITY_CUTOFF_FACTOR_MILLI: u32 = 1_000;
/// Upper bound for activity-cutoff factor (per-mille). 50_000 = 50 tempos.
pub const MAX_ACTIVITY_CUTOFF_FACTOR_MILLI: u32 = 50_000;
/// Default activity-cutoff factor (per-mille). 13_889 ≈ legacy 5000-block cutoff
/// at default tempo 360 (`13_889 * 360 / 1000 = 5_000`, exact via ceiling rounding).
pub const INITIAL_ACTIVITY_CUTOFF_FACTOR_MILLI: u32 = 13_889;
/// Per-block cap on number of epochs that may execute in a single `block_step`.
pub const MAX_EPOCHS_PER_BLOCK: u32 = prod_or_fast!(2, 32);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want this to be hardcoded here or maybe as config params?

netuid: NetUid,
factor_milli: u32,
) -> DispatchResult {
let who = Self::ensure_subnet_owner(origin, netuid)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we also want root to be able to change this?

Comment on lines +41 to +43
// If the subnet is deferred past this block the
// commits are taken once here and the later block(s) become no-ops.
let cur_epoch = Self::current_epoch_with_lookahead(netuid);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here we reveal commits before knowing whether the subnet epoch will actually execute in this block. If MaxEpochsPerBlock has already been reached, the epoch can be deferred, leaving the revealed weights public before being used. That seems to weaken the commit-reveal property and may allow actors to react to revealed weights through other epoch-relevant state. Can we either reveal only on the successful execution path, or document/test why this gap is not exploitable?

Comment on lines +67 to 75
// Bonds masking inside `distribute_emission` reads `LastMechansimStepBlock` and
// must see the previous successful run, so we delay the write until after.
Self::distribute_emissions_to_subnets(&emissions_to_distribute);

// --- 7. Mark each successful epoch run as the last mechanism step.
for netuid in emissions_to_distribute.keys() {
LastMechansimStepBlock::<T>::insert(*netuid, current_block);
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we move the LastMechansimStepBlock update into distribute_emissions_to_subnets after each successful distribute_emission call? Keeping the write outside the distribution function makes this sequencing easy to miss for future call sites.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking-change This PR introduces a noteworthy breaking change skip-cargo-audit This PR fails cargo audit but needs to be merged anyway

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants