From eb4f941b3724cd27de6ebdbfecfedaa6f0293fdf Mon Sep 17 00:00:00 2001 From: dsutanto Date: Wed, 5 Aug 2026 10:21:45 +0700 Subject: [PATCH] =?UTF-8?q?T13:=20klamp=20last=5Fvote=20heuristik=20ke=20w?= =?UTF-8?q?aktu=20scan=20(=E2=8A=A5=20di=20masa=20depan)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AGENTS.md | 2 +- SPEC.md | 2 +- get_voters.py | 5 ++++- test_get_voters.py | 11 +++++++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2726915..388bcc8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/SPEC.md b/SPEC.md index 571f6e2..1543cfd 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 diff --git a/get_voters.py b/get_voters.py index daf5e06..52028a1 100644 --- a/get_voters.py +++ b/get_voters.py @@ -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 { diff --git a/test_get_voters.py b/test_get_voters.py index eab72dc..894b799 100644 --- a/test_get_voters.py +++ b/test_get_voters.py @@ -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],