Skip to content

Commit f46129e

Browse files
authored
feat(weights): tip-track /v1/weights/latest via leaf supersede + reseal (#121)
* feat(weights): tip-track /v1/weights/latest via leaf supersede + reseal Make sealed tip weights follow live Design/Prism scores: supersede leaves on digest change, reseal tip with revision++, and continuously re-emit tip leaves. Validators still Match only sealed:true. * fix(test): expect prism tip refresh on same-epoch tick
1 parent b4037f1 commit f46129e

24 files changed

Lines changed: 493 additions & 184 deletions

File tree

crates/challenge-common/src/submit.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,9 @@ impl GatewayClient {
111111

112112
/// POST one signed leaf. Retries 5xx and transport errors.
113113
///
114-
/// Idempotency: HTTP 409 (already present) is success — never submits a
115-
/// conflicting `ScoreOrAbsence` for the same key from this client path;
116-
/// callers must not change the leaf between retries.
114+
/// Idempotency: HTTP 409 (identical digest already present) is success.
115+
/// Tip re-emits with a changed score/digest return 202 (supersede) and
116+
/// are also success. Callers may change tip leaves between ticks.
117117
///
118118
/// # Errors
119119
///

crates/db/.sqlx/query-14604982a6b089a47e32e4fa7189819526eca2c8fbf5840d857a0c2aad37edeb.json

Lines changed: 0 additions & 32 deletions
This file was deleted.
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- Tip leaf supersede: allow replacing a raw_weight_snapshot row when the
2+
-- payload_digest changes for the same (challenge_id, epoch, miner_hotkey).
3+
--
4+
-- `base_app` still has no direct UPDATE privilege on append-only tables
5+
-- (schema tests keep that invariant). Tip supersede runs through this
6+
-- SECURITY DEFINER helper owned by the migration role.
7+
8+
CREATE OR REPLACE FUNCTION upsert_raw_weight_tip(
9+
p_id uuid,
10+
p_challenge_id text,
11+
p_epoch bigint,
12+
p_miner_hotkey text,
13+
p_kind text,
14+
p_score bigint,
15+
p_absence_reason text,
16+
p_payload bytea,
17+
p_payload_digest bytea,
18+
p_signature bytea,
19+
p_nonce bytea
20+
) RETURNS uuid
21+
LANGUAGE plpgsql
22+
SECURITY DEFINER
23+
SET search_path = public
24+
AS $$
25+
DECLARE
26+
result_id uuid;
27+
BEGIN
28+
INSERT INTO raw_weight_snapshot (
29+
id, challenge_id, epoch, miner_hotkey, kind, score, absence_reason,
30+
payload, payload_digest, signature, nonce
31+
) VALUES (
32+
p_id, p_challenge_id, p_epoch, p_miner_hotkey, p_kind, p_score,
33+
p_absence_reason, p_payload, p_payload_digest, p_signature, p_nonce
34+
)
35+
ON CONFLICT (challenge_id, epoch, miner_hotkey) DO UPDATE SET
36+
id = EXCLUDED.id,
37+
kind = EXCLUDED.kind,
38+
score = EXCLUDED.score,
39+
absence_reason = EXCLUDED.absence_reason,
40+
payload = EXCLUDED.payload,
41+
payload_digest = EXCLUDED.payload_digest,
42+
signature = EXCLUDED.signature,
43+
nonce = EXCLUDED.nonce
44+
WHERE raw_weight_snapshot.payload_digest IS DISTINCT FROM EXCLUDED.payload_digest
45+
RETURNING id INTO result_id;
46+
47+
RETURN result_id;
48+
END;
49+
$$;
50+
51+
REVOKE ALL ON FUNCTION upsert_raw_weight_tip(
52+
uuid, text, bigint, text, text, bigint, text, bytea, bytea, bytea, bytea
53+
) FROM PUBLIC;
54+
GRANT EXECUTE ON FUNCTION upsert_raw_weight_tip(
55+
uuid, text, bigint, text, text, bigint, text, bytea, bytea, bytea, bytea
56+
) TO base_app;

crates/db/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,10 @@
33
//! # Roles
44
//!
55
//! Migrations run as the database owner (superuser in tests). Application
6-
//! connections should use the `base_app` role, which has **no** `UPDATE`
6+
//! connections should use the `base_app` role, which has **no** direct `UPDATE`
77
//! privilege on the append-only tables `raw_weight_snapshot`, `epoch_bundle`,
8-
//! and `peer_root_statement`.
8+
//! and `peer_root_statement`. Tip leaf supersede uses the
9+
//! `upsert_raw_weight_tip` SECURITY DEFINER helper (migration 0017).
910
//!
1011
//! # D18
1112
//!

crates/db/src/store.rs

Lines changed: 35 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
//! Typed persistence for the gateway's append-only tables.
22
//!
3-
//! `raw_weight_snapshot` and `epoch_bundle` are `SELECT`/`INSERT` only for the
4-
//! application role, so every helper here is an insert or a read — never an
5-
//! update. All uniqueness and shape invariants are enforced by the schema
6-
//! (`0001_init.sql`, `0002_epoch_bundle_revision.sql`); the Rust side only
7-
//! feeds them and interprets the conflicts they raise.
3+
//! `epoch_bundle` and `peer_root_statement` stay `SELECT`/`INSERT` only for
4+
//! the application role. `raw_weight_snapshot` inserts go through
5+
//! [`insert_raw_weight`] / tip supersede via the `upsert_raw_weight_tip`
6+
//! SECURITY DEFINER helper (no direct `UPDATE` grant on the table). Bundle
7+
//! reseal appends a new `epoch_bundle.revision`. Schema invariants live in
8+
//! `0001_init.sql`, `0002_epoch_bundle_revision.sql`,
9+
//! `0017_raw_weight_tip_supersede.sql`.
810
911
use sqlx::PgPool;
1012
use uuid::Uuid;
@@ -67,11 +69,14 @@ pub struct RawWeightRecord {
6769
pub signature: Vec<u8>,
6870
}
6971

70-
/// Append one raw-weight leaf.
72+
/// Insert or tip-supersede one raw-weight leaf.
7173
///
72-
/// Returns `Ok(None)` when `(challenge_id, epoch, miner_hotkey)` is already
73-
/// stored — `raw_weight_snapshot_challenge_epoch_miner_unique` is what makes a
74-
/// retried submission a conflict instead of a duplicate.
74+
/// Returns `Ok(Some(id))` when a row was inserted or replaced because
75+
/// `payload_digest` changed. Returns `Ok(None)` when the unique key already
76+
/// holds an identical digest (idempotent replay → HTTP 409).
77+
///
78+
/// Tip supersede runs via `upsert_raw_weight_tip` so `base_app` never needs a
79+
/// direct `UPDATE` grant on `raw_weight_snapshot`.
7580
///
7681
/// # Errors
7782
///
@@ -81,28 +86,28 @@ pub async fn insert_raw_weight(
8186
pool: &PgPool,
8287
row: &NewRawWeight<'_>,
8388
) -> Result<Option<Uuid>, DbError> {
84-
let id = sqlx::query_scalar!(
85-
r#"
86-
INSERT INTO raw_weight_snapshot
87-
(id, challenge_id, epoch, miner_hotkey, kind, score, absence_reason,
88-
payload, payload_digest, signature, nonce)
89-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
90-
ON CONFLICT (challenge_id, epoch, miner_hotkey) DO NOTHING
91-
RETURNING id
92-
"#,
93-
row.id,
94-
row.challenge_id,
95-
row.epoch,
96-
row.miner_hotkey,
97-
row.kind,
98-
row.score,
99-
row.absence_reason,
100-
row.payload,
101-
row.payload_digest,
102-
row.signature,
103-
row.nonce,
89+
// Runtime query: return type is `Option<Uuid>` from the tip-supersede
90+
// helper (NULL = identical digest). Avoids regenerating sqlx offline
91+
// metadata for a SECURITY DEFINER function signature.
92+
let id: Option<Uuid> = sqlx::query_scalar(
93+
r"
94+
SELECT upsert_raw_weight_tip(
95+
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11
96+
)
97+
",
10498
)
105-
.fetch_optional(pool)
99+
.bind(row.id)
100+
.bind(row.challenge_id)
101+
.bind(row.epoch)
102+
.bind(row.miner_hotkey)
103+
.bind(row.kind)
104+
.bind(row.score)
105+
.bind(row.absence_reason)
106+
.bind(row.payload)
107+
.bind(row.payload_digest)
108+
.bind(row.signature)
109+
.bind(row.nonce)
110+
.fetch_one(pool)
106111
.await?;
107112
Ok(id)
108113
}

crates/db/tests/gateway_store.rs

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
//!
33
//! Scenarios:
44
//! - S1 happy: raw-weight insert + read back + list + count
5-
//! - S2 edge: duplicate `(challenge_id, epoch, miner_hotkey)` is a conflict, not a second row
5+
//! - S2 edge: identical digest is a conflict; digest change tip-supersedes in place
66
//! - S3 happy: sealed bundle insert, read by epoch / root, and re-seal bumps `revision`
77
//! - S4 regression: schema `CHECK`s still reject a malformed raw weight
88
@@ -169,9 +169,36 @@ async fn s2_duplicate_raw_weight_conflicts() {
169169
)
170170
.await
171171
.expect("retry must not error");
172-
assert!(retry.is_none(), "unique key → no second row");
172+
assert!(retry.is_none(), "identical digest → no second row");
173173
assert_eq!(count_raw_weights(pool).await.expect("count"), 1);
174174

175+
// Tip supersede: different digest replaces in place.
176+
let digest2 = vec![9u8; 32];
177+
let payload2 = b"scale-body-v2".to_vec();
178+
let supersede = insert_raw_weight(
179+
pool,
180+
&score_row(
181+
Uuid::new_v4(),
182+
"c1",
183+
1,
184+
"aa",
185+
&payload2,
186+
&digest2,
187+
&sig,
188+
&nonce,
189+
),
190+
)
191+
.await
192+
.expect("supersede");
193+
assert!(supersede.is_some());
194+
assert_eq!(count_raw_weights(pool).await.expect("count"), 1);
195+
let row = get_raw_weight(pool, "c1", 1, "aa")
196+
.await
197+
.expect("get")
198+
.expect("row");
199+
assert_eq!(row.payload, payload2);
200+
assert_eq!(row.payload_digest, digest2);
201+
175202
tp.drop_schema().await.expect("drop");
176203
}
177204

crates/design-challenge-task/src/emit.rs

Lines changed: 26 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
//! Design leaf-emit scheduling (late-tempo filler + catch-up).
1+
//! Design leaf-emit scheduling (late-tempo filler + catch-up + tip re-emit).
22
3-
/// How many blocks before epoch end the NotAttempted filler may run.
3+
/// How many blocks before epoch end the NotAttempted filler may first run
4+
/// when the tip has not yet been emitted this process.
45
///
5-
/// Wider than the historical 48-block window so `base-real-seal` (10 min) still
6-
/// has time to seal after design emits, while leaving most of the epoch for
7-
/// `award_round` to land Score leaves first (first-write-wins).
6+
/// Wider than the historical 48-block window so `base-real-seal` still has
7+
/// time to seal after design emits. Once the tip has been emitted, every
8+
/// emitter tick re-emits so mid-epoch awards tip-supersede gateway leaves.
89
pub const DESIGN_EMIT_LATE_BLOCKS: u64 = 96;
910

1011
/// Planned design leaf emission for one emitter tick.
@@ -26,8 +27,11 @@ const MAX_CATCHUP_EPOCHS: u64 = 16;
2627
/// epoch 1 pins a pruned block and fails with `SubnetOwnerHotkey not found`.
2728
/// - Catch up `last_emitted+1` when behind (capped to [`MAX_CATCHUP_EPOCHS`])
2829
/// so end-of-epoch relabel skips can recover without exceeding prune depth.
29-
/// - Otherwise wait until the last [`DESIGN_EMIT_LATE_BLOCKS`] of the current
30-
/// epoch so admin awards can submit Score leaves first.
30+
/// - **Tip already emitted** (`last_emitted == current`): re-emit every tick so
31+
/// rolling window scores tip-supersede gateway leaves (gateway accepts digest
32+
/// changes; identical digests stay 409-as-ok).
33+
/// - First tip emit in-process: wait until the last [`DESIGN_EMIT_LATE_BLOCKS`]
34+
/// of the current epoch unless cold-start / catch-up already covered it.
3135
#[must_use]
3236
pub fn design_emit_plan(
3337
last_emitted: u64,
@@ -47,8 +51,12 @@ pub fn design_emit_plan(
4751
pin_block: current_last_epoch_block,
4852
});
4953
}
54+
// Tip tracking: re-emit current epoch every tick after the first emit.
5055
if last_emitted >= current_epoch {
51-
return None;
56+
return Some(DesignEmitPlan {
57+
epoch: current_epoch,
58+
pin_block: current_last_epoch_block,
59+
});
5260
}
5361
// Sequential catch-up for skipped epochs (award path / boundary race).
5462
if last_emitted + 1 < current_epoch {
@@ -65,7 +73,7 @@ pub fn design_emit_plan(
6573
pin_block,
6674
});
6775
}
68-
// Current epoch: late-tempo filler only.
76+
// Current epoch not yet emitted this process: late-tempo filler only.
6977
if blocks_since_last_step.saturating_add(DESIGN_EMIT_LATE_BLOCKS) < tempo {
7078
return None;
7179
}
@@ -120,7 +128,14 @@ mod tests {
120128
}
121129

122130
#[test]
123-
fn emit_plan_noop_when_already_emitted_current() {
124-
assert!(design_emit_plan(11, 11, 350, 360, 1000).is_none());
131+
fn emit_plan_reemits_tip_when_already_emitted_current() {
132+
let p = design_emit_plan(11, 11, 10, 360, 1000).unwrap();
133+
assert_eq!(
134+
p,
135+
DesignEmitPlan {
136+
epoch: 11,
137+
pin_block: 1000
138+
}
139+
);
125140
}
126141
}

crates/design-challenge/src/lib.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,14 @@ mod tests {
9797
}
9898

9999
#[test]
100-
fn emit_plan_noop_when_already_emitted_current() {
101-
assert!(design_emit_plan(11, 11, 350, 360, 1000).is_none());
100+
fn emit_plan_reemits_tip_when_already_emitted_current() {
101+
let p = design_emit_plan(11, 11, 10, 360, 1000).unwrap();
102+
assert_eq!(
103+
p,
104+
DesignEmitPlan {
105+
epoch: 11,
106+
pin_block: 1000
107+
}
108+
);
102109
}
103110
}

crates/design-store/src/store.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,27 +1018,36 @@ impl DesignStore for MemoryDesignStore {
10181018
netuid: u16,
10191019
epoch: u64,
10201020
) -> Result<Vec<(String, FinalScore)>, StoreError> {
1021-
let rounds: Vec<u64> = self
1021+
// Match PG `design_scores_for_epoch`: newest rating per miner among
1022+
// rounds with `round.epoch <= target` (rolling window projection).
1023+
let round_epoch: BTreeMap<u64, u64> = self
10221024
.rounds
10231025
.lock()
10241026
.map_err(|_| StoreError::Backend("poison".into()))?
10251027
.values()
1026-
.filter(|r| r.netuid == netuid && r.epoch == epoch)
1027-
.map(|r| r.round_id)
1028+
.filter(|r| r.netuid == netuid && r.epoch <= epoch)
1029+
.map(|r| (r.round_id, r.epoch))
10281030
.collect();
1029-
let mut by: BTreeMap<String, FinalScore> = BTreeMap::new();
1031+
let mut by: BTreeMap<String, (u64, FinalScore)> = BTreeMap::new();
10301032
let ratings = self
10311033
.ratings
10321034
.lock()
10331035
.map_err(|_| StoreError::Backend("poison".into()))?;
10341036
for ((rid, _), row) in ratings.iter() {
1035-
if rounds.contains(rid) {
1036-
if let Some(fs) = &row.final_score {
1037-
by.insert(row.miner_hotkey.clone(), fs.clone());
1037+
if !round_epoch.contains_key(rid) {
1038+
continue;
1039+
}
1040+
let Some(fs) = &row.final_score else {
1041+
continue;
1042+
};
1043+
match by.get(&row.miner_hotkey) {
1044+
Some((prev_rid, _)) if *prev_rid >= *rid => {}
1045+
_ => {
1046+
by.insert(row.miner_hotkey.clone(), (*rid, fs.clone()));
10381047
}
10391048
}
10401049
}
1041-
Ok(by.into_iter().collect())
1050+
Ok(by.into_iter().map(|(hk, (_, fs))| (hk, fs)).collect())
10421051
}
10431052

10441053
async fn set_round_award(&self, award: &RoundAward) -> Result<(), StoreError> {

0 commit comments

Comments
 (0)