T13: klamp last_vote heuristik ke waktu scan (⊥ di masa depan)

Unstake tanpa revote membuat rasio bobot/stake membesar → estimasi
last_vote melewati now. normalize() kini mengklamp ke now; voters.db
dikembalikan (27 baris, MAX(last_vote) ≤ MAX(scanned_at)). Oracle lolos.
This commit is contained in:
proitlab committed 2026-08-05 10:21:45 +07:00
1 parent 41990af9af
commit eb4f941b37
4 files changed
+17 -3

No files matched your search

+1 -1
View File
@@ -20,7 +20,7 @@ Two tools scan the Vexanium blockchain voters table for accounts whose only vote
- The node sometimes returns `staked` as a string instead of a number — normalize coerces to float (spec §V8).
- The voters table has a sentinel first row (`owner="...........q"`, uint64 0) that must be skipped.
- Authoritative vote weight is `last_vote_weight` (high-precision decimal string) — stored verbatim as TEXT, never float-converted (spec §V6).
- Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI).
- Freshness filter (spec §V13): `last_vote` (estimated last re-vote date) is derived at scan time from the Vexanium weight formula `last_vote_weight = staked_raw × 2^(years since 2000)` → `log2(weight / (staked×10000))`. Voters whose `last_vote` is older than `VEX_STALE_DAYS` (default 28) — or can't be derived (zero/empty weight) — are dropped in `normalize()` and never stored; the dashboard therefore shows only fresh voters (no stale UI). The heuristic over-reads when a voter unstaked without re-voting (weight ÷ smaller stake ⇒ future date), so `normalize()` clamps any `last_vote` past scan time down to `now`.
- The web list shows RANK / AKUN / STAKE (VEX) / VOTE TERAKHIR columns (3 stats cells). Vote weight stays in the DB and `/api/search` JSON but is not rendered as a column.
## Layout
+1 -1
View File
@@ -44,7 +44,7 @@ V9: normalize drops voters where `staked` < 1000 VEX (setelah ÷10000)
V10: dashboard baca `voters.db` → rows sort `staked` DESC (terbesar dulu); ⊥ mutasi DB
V11: search `q` → filter owner substring (LIKE, wildcard-escape, case-insensitive); urut+paging terjaga; `rank` global (⊥ rank-dalam-filter); `?q=` & `/api/search` read-only
V12: produksi web disajikan gunicorn (`dashboard:app`, `gunicorn.conf.py`); `DASH_WORKERS` default 2; `.env` ikut termuat via import `config`
V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); voter dgn `last_vote=None` atau `< now − VEX_STALE_DAYS` dibuang di `normalize` (⊥ disimpan) → DB hanya berisi pemilih segar
V13: `last_vote` = `2000-01-01 + log2(last_vote_weight / (staked×10000)) × 365.25 hari` (None bila bobot/stake nol); estimasi yang melewati `now` (unstake tanpa revote) diklamp ke `now`; voter dgn `last_vote=None` atau `< now − VEX_STALE_DAYS` dibuang di `normalize` (⊥ disimpan) → DB hanya berisi pemilih segar
V15: web list = kolom RANK/AKUN/STAKE (VEX)/VOTE TERAKHIR — ⊥ BOBOT SUARA, ⊥ penanda/stat/filter basi; stats = 3 sel (TOTAL PEMILIH/TOTAL VEX/STAKE TERTINGGI); `/api/search` hasil tetap `{owner,staked,weight,rank,last_vote}` (weight disimpan & di-API, ⊥ dirender)
## §T — Tasks
+4 -1
View File
@@ -118,7 +118,8 @@ def normalize(row, now=None):
"""V4/V5/V6/V13: saring ke baris yang relevan, bidang dinormalisasi.
V13: pemilih dengan last_vote tak diketahui atau basi dibuang di sini —
hanya pemilih segar yang berakhir di DB.
hanya pemilih segar yang berakhir di DB. Estimasi last_vote yang melewati
`now` (unstake tanpa revote → rasio bobot/stake membesar) diklamp ke `now`.
"""
if now is None:
now = datetime.now(timezone.utc).replace(tzinfo=None)
@@ -131,6 +132,8 @@ def normalize(row, now=None):
if staked_vex < MIN_STAKED_VEX:
return None
last_vote = derive_last_vote(row.get('last_vote_weight', ''), staked_vex)
if last_vote is not None and last_vote > now.isoformat(timespec='seconds'):
last_vote = now.isoformat(timespec='seconds') # V13: heuristik ⊥ boleh di masa depan
if last_vote is None or _is_stale(last_vote, now): # V13: basi ⊥ disimpan
return None
return {
+11
View File
@@ -147,6 +147,17 @@ def main():
'staked': 20000000,
'last_vote_weight': _fresh_weight(20000000)})['owner'] == 'new6')
# V13: estimasi yang melewati now (unstake tanpa revote) diklamp ke now
fixed_now = datetime(2026, 8, 5, 12, 0, 0)
t_future = ((fixed_now - gv.WEIGHT_EPOCH).days + 5) / 365.25
fut = gv.normalize({'owner': 'fut9', 'producers': [gv.TARGET_BP],
'staked': 20000000,
'last_vote_weight': f'{20000000 * 2 ** t_future:.17f}'},
now=fixed_now)
check('V13 last_vote masa depan diklamp ke now',
fut is not None
and fut['last_vote'] == fixed_now.isoformat(timespec='seconds'))
# V9: staked < 1000 VEX → dibuang; tepat 1000 → disimpan
check('V9 di bawah 1000 dibuang',
gv.normalize({'owner': 'low9', 'producers': [gv.TARGET_BP],