Skip to content

Commit 80898fd

Browse files
authored
Merge pull request #181 from synonymdev/feat/probe-reset-scores
feat: reset pathfinding scores before probes
2 parents b59266a + a13a025 commit 80898fd

3 files changed

Lines changed: 146 additions & 6 deletions

File tree

docs/mainnet-probe.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ It is orchestrated nightly by the private `bitkit-nightly` repo (`mainnet-probe.
1818
| Variable | Default | Description |
1919
| --- | --- | --- |
2020
| `PROBE_ORDER` | `config` | Order of probes per target: `config` = amounts as listed in target config, `desc` = highest amount first (avoids small probes "warming up" scorer knowledge of the route), `random` = global shuffle of all target+amount pairs. |
21+
| `PROBE_RESET_SCORES` | `false` | When `true`, deletes the persisted pathfinding scores (`scorer` and `external_pathfinding_scores_cache` VSS keys) and restarts the node before probing, so every run starts from a fresh scorer (external scores are re-downloaded on startup). Recommended for scorer A/B experiments; the nightly job enables it by default. Accepts `true/false/1/0/yes/no`. |
22+
| `PROBE_RESET_SCORES_TIMEOUT_SECONDS` | `180` | Timeout for the scores reset devtools command (covers node stop + VSS deletes + node start). |
23+
| `PROBE_SCORES_SYNC_MAX_AGE_S` | `900` | Only with `PROBE_RESET_SCORES=true`: readiness additionally requires the node's last external scores sync to be at most this many seconds old **and** newer than the reset floor reported by the app (captured after the node stop + VSS deletes, before the restart; the sync timestamp persisted in node metrics survives the restart, so only a sync from the rebuilt node proves the scores were re-downloaded). Guards against a failed scorer fetch silently producing a "no scores" run. |
2124
| `PROBE_RETRIES` | `2` | In-test retries per target+amount after a failed probe (total attempts = retries + 1). `0` = single attempt; useful to measure first-attempt success rate. |
2225
| `PROBE_RETRY_DELAY_MS` | `5000` | Delay between probe retries. |
2326
| `PROBE_DELAY_MS` | `10000` | Delay between consecutive probes (different target/amount). |
@@ -47,6 +50,7 @@ Probing starts only after the node reports a healthy state (running, peers conne
4750
| `PROBE_INVOICE_METHOD` | `probeInvoice` | Devtools content-provider method for invoice probes. |
4851
| `PROBE_NODE_METHOD` | `probeNode` | Devtools method for keysend probes. |
4952
| `PROBE_READINESS_METHOD` | `probeReadiness` | Devtools method for the readiness check. |
53+
| `PROBE_RESET_SCORES_METHOD` | `resetScores` | Devtools method for the pathfinding scores reset. |
5054

5155
### Related (set by orchestration)
5256

@@ -95,4 +99,5 @@ Notes:
9599

96100
- The wallet derived from `PROBE_SEED` must already have an open, usable channel with outbound liquidity covering the largest configured probe amount.
97101
- Probes do not move funds; the wallet balance is unchanged by a run.
98-
- For scorer experiments, prefer `PROBE_ORDER=desc PROBE_RETRIES=0` to measure cold first-attempt success rate per amount.
102+
- For scorer experiments, prefer `PROBE_RESET_SCORES=true PROBE_ORDER=desc PROBE_RETRIES=0` to measure cold first-attempt success rate per amount. Without the reset, locally learned scores accumulate in VSS under the probe seed across runs (probe results train the scorer), so consecutive runs are not comparable.
103+
- `PROBE_RESET_SCORES` requires an app build that includes the `resetScores` devtools method (bitkit-android).

test/helpers/probe.ts

Lines changed: 127 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ const DEFAULT_PROBE_FETCH_RETRY_DELAY_MS = 1_000;
5656
const DEFAULT_READINESS_TIMEOUT_MS = 180_000;
5757
const DEFAULT_READINESS_POLL_MS = 5_000;
5858
const DEFAULT_MIN_GRAPH_CHANNELS = 10_000;
59+
const DEFAULT_RESET_SCORES_TIMEOUT_SECONDS = 180;
60+
const DEFAULT_SCORES_SYNC_MAX_AGE_S = 900;
5961

6062
export type ProbeReadiness = {
6163
ready: boolean;
@@ -72,6 +74,7 @@ export type ProbeReadiness = {
7274
graphNodeCount?: number;
7375
graphChannelCount?: number;
7476
latestRgsSyncTimestamp?: number;
77+
latestPathfindingScoresSyncTimestamp?: number;
7578
};
7679

7780
export function resolveProbeTargets(): ProbeTarget[] {
@@ -248,6 +251,75 @@ export function summarizeProbeCommandFailure(raw: string): string {
248251
);
249252
}
250253

254+
export function resolveProbeResetScores(): boolean {
255+
return parseBooleanEnv('PROBE_RESET_SCORES') ?? false;
256+
}
257+
258+
/**
259+
* Resets the persisted pathfinding scores via devtools and returns the
260+
* device-clock epoch seconds to be used as a floor for the scores sync
261+
* timestamp in readiness checks (the sync timestamp persisted in node metrics
262+
* survives the restart, so only a sync strictly newer than the reset proves
263+
* the external scores were re-downloaded). The app reports the floor as the
264+
* moment after the node stop + VSS deletes and before the restart, so any
265+
* newer sync can only come from the rebuilt node; if the app is too old to
266+
* report it, falls back to the device time captured before the reset call.
267+
* The floor uses the device clock because the sync timestamp is also
268+
* device-generated, making the comparison immune to host/device clock skew.
269+
*/
270+
export async function resetPathfindingScores({ logPrefix }: { logPrefix: string }): Promise<number> {
271+
const method = process.env.PROBE_RESET_SCORES_METHOD ?? 'resetScores';
272+
const timeoutSeconds =
273+
parsePositiveIntEnv('PROBE_RESET_SCORES_TIMEOUT_SECONDS') ?? DEFAULT_RESET_SCORES_TIMEOUT_SECONDS;
274+
275+
console.info(`→ [${logPrefix}] Resetting pathfinding scores (timeout ${timeoutSeconds}s)...`);
276+
const fallbackFloorS = getDeviceEpochSeconds();
277+
const raw = runDevToolsCommand(method, {}, timeoutSeconds);
278+
if (!parseProbeCommandSuccess(raw)) {
279+
throw new Error(`Pathfinding scores reset failed: ${summarizeProbeCommandFailure(raw)}`);
280+
}
281+
const deviceResetAtS = parseResetTimestamp(raw);
282+
if (deviceResetAtS === null) {
283+
console.warn(
284+
`→ [${logPrefix}] Reset result has no timestamp (old app build?); using pre-reset device time as scores sync floor`
285+
);
286+
}
287+
const resetFloorS = deviceResetAtS ?? fallbackFloorS;
288+
console.info(`→ [${logPrefix}] Pathfinding scores reset done (floor ${resetFloorS})`);
289+
return resetFloorS;
290+
}
291+
292+
function parseResetTimestamp(raw: string): number | null {
293+
const result = extractContentCallResult(raw);
294+
if (!result) return null;
295+
296+
let parsed: unknown;
297+
try {
298+
parsed = JSON.parse(result);
299+
} catch {
300+
return null;
301+
}
302+
if (typeof parsed !== 'object' || parsed === null) return null;
303+
if (!('timestamp' in parsed)) return null;
304+
305+
const timestamp = parsed.timestamp;
306+
return typeof timestamp === 'number' && Number.isFinite(timestamp) && timestamp > 0
307+
? timestamp
308+
: null;
309+
}
310+
311+
function getDeviceEpochSeconds(): number {
312+
const raw = execFileSync('adb', ['shell', 'date', '+%s'], {
313+
encoding: 'utf8',
314+
timeout: 10_000,
315+
});
316+
const epoch = Number.parseInt(raw.trim(), 10);
317+
if (!Number.isFinite(epoch) || epoch <= 0) {
318+
throw new Error(`Failed to read device epoch time: ${raw.trim() || 'empty output'}`);
319+
}
320+
return epoch;
321+
}
322+
251323
export function runReadinessCommand(): string {
252324
const method = process.env.PROBE_READINESS_METHOD ?? 'probeReadiness';
253325
const command = [
@@ -305,18 +377,39 @@ function summarizeReadinessError(raw: string): string {
305377

306378
export function isProbeReadinessSufficient(
307379
readiness: ProbeReadiness,
308-
minGraphChannels: number
380+
minGraphChannels: number,
381+
maxScoresSyncAgeS: number | null = null,
382+
minScoresSyncTimestamp: number | null = null,
383+
nowS: number = Date.now() / 1000
309384
): boolean {
310385
return (
311386
readiness.ready &&
312387
readiness.nodeRunning &&
313388
readiness.connectedPeers > 0 &&
314389
readiness.usableChannels > 0 &&
315390
readiness.syncHealthy &&
316-
(readiness.graphChannelCount ?? 0) >= minGraphChannels
391+
(readiness.graphChannelCount ?? 0) >= minGraphChannels &&
392+
isScoresSyncFresh(readiness, maxScoresSyncAgeS, minScoresSyncTimestamp, nowS)
317393
);
318394
}
319395

396+
function isScoresSyncFresh(
397+
readiness: ProbeReadiness,
398+
maxAgeS: number | null,
399+
minTimestamp: number | null,
400+
nowS: number = Date.now() / 1000
401+
): boolean {
402+
if (maxAgeS === null) return true;
403+
const timestamp = readiness.latestPathfindingScoresSyncTimestamp;
404+
if (!timestamp) return false;
405+
if (nowS - timestamp > maxAgeS) return false;
406+
// Both timestamps come from the device clock; the floor is captured by the
407+
// app after the node stop + VSS deletes, so any strictly newer sync can
408+
// only come from the rebuilt node (post-reset).
409+
if (minTimestamp !== null && timestamp <= minTimestamp) return false;
410+
return true;
411+
}
412+
320413
export function summarizeProbeReadiness(readiness: ProbeReadiness): string {
321414
return [
322415
`running=${readiness.nodeRunning}`,
@@ -325,25 +418,35 @@ export function summarizeProbeReadiness(readiness: ProbeReadiness): string {
325418
`outboundSats=${readiness.outboundCapacitySats}`,
326419
`graphChannels=${readiness.graphChannelCount ?? 'n/a'}`,
327420
`graphNodes=${readiness.graphNodeCount ?? 'n/a'}`,
421+
`scoresSync=${readiness.latestPathfindingScoresSyncTimestamp ?? 'n/a'}`,
328422
`syncHealthy=${readiness.syncHealthy}`,
329423
`ready=${readiness.ready}`,
330424
].join(' ');
331425
}
332426

333427
type WaitForProbeReadinessOptions = {
334428
logPrefix: string;
429+
requireScoresSync?: boolean;
430+
/** Device-clock epoch seconds; scores sync must be strictly newer than this (the reset floor reported by the app). */
431+
minScoresSyncTimestamp?: number | null;
335432
};
336433

337434
export async function waitForProbeReadiness({
338435
logPrefix,
436+
requireScoresSync = false,
437+
minScoresSyncTimestamp = null,
339438
}: WaitForProbeReadinessOptions): Promise<ProbeReadiness> {
340439
const timeoutMs = parsePositiveIntEnv('PROBE_READINESS_TIMEOUT_MS') ?? DEFAULT_READINESS_TIMEOUT_MS;
341440
const pollMs = parsePositiveIntEnv('PROBE_READINESS_POLL_MS') ?? DEFAULT_READINESS_POLL_MS;
342441
const minGraphChannels =
343442
parseNonNegativeIntEnv('PROBE_MIN_GRAPH_CHANNELS') ?? DEFAULT_MIN_GRAPH_CHANNELS;
443+
const maxScoresSyncAgeS = requireScoresSync
444+
? (parsePositiveIntEnv('PROBE_SCORES_SYNC_MAX_AGE_S') ?? DEFAULT_SCORES_SYNC_MAX_AGE_S)
445+
: null;
446+
const minSyncTimestamp = requireScoresSync ? minScoresSyncTimestamp : null;
344447

345448
console.info(
346-
`→ [${logPrefix}] Waiting for probe readiness (timeout ${timeoutMs / 1000}s, minGraphChannels ${minGraphChannels})...`
449+
`→ [${logPrefix}] Waiting for probe readiness (timeout ${timeoutMs / 1000}s, minGraphChannels ${minGraphChannels}, requireScoresSync ${requireScoresSync})...`
347450
);
348451

349452
const deadline = Date.now() + timeoutMs;
@@ -360,7 +463,10 @@ export async function waitForProbeReadiness({
360463
const readiness = raw ? parseProbeReadiness(raw) : null;
361464
if (readiness) {
362465
lastSummary = summarizeProbeReadiness(readiness);
363-
if (isProbeReadinessSufficient(readiness, minGraphChannels)) {
466+
// Use the device clock for the scores sync age check so it is measured
467+
// against the same clock that produced the sync timestamp.
468+
const nowS = maxScoresSyncAgeS !== null ? getDeviceEpochSeconds() : Date.now() / 1000;
469+
if (isProbeReadinessSufficient(readiness, minGraphChannels, maxScoresSyncAgeS, minSyncTimestamp, nowS)) {
364470
console.info(`→ [${logPrefix}] Probe readiness satisfied: ${lastSummary}`);
365471
return readiness;
366472
}
@@ -409,6 +515,7 @@ export function renderProbeReport(
409515
'',
410516
`Required failures: ${failedRequired.length}`,
411517
`Probe order: ${probeOrderForReport()}`,
518+
`Scores reset: ${scoresResetForReport()}`,
412519
`Readiness at probe start: ${readiness ? summarizeProbeReadiness(readiness) : 'not captured'}`,
413520
'',
414521
'| Target | Type | Amount sats | Required | Fetch | Probe | Retries | Duration ms | Failure |',
@@ -444,6 +551,14 @@ function probeOrderForReport(): string {
444551
}
445552
}
446553

554+
function scoresResetForReport(): string {
555+
try {
556+
return String(resolveProbeResetScores());
557+
} catch {
558+
return `invalid (${process.env.PROBE_RESET_SCORES})`;
559+
}
560+
}
561+
447562
function parseProbeTarget(value: unknown): ProbeTarget {
448563
if (typeof value !== 'object' || value === null) {
449564
throw new Error('Each probe target must be an object');
@@ -560,6 +675,14 @@ export function parseNonNegativeIntEnv(name: string): number | null {
560675
return value;
561676
}
562677

678+
function parseBooleanEnv(name: string): boolean | null {
679+
const raw = process.env[name]?.trim().toLowerCase();
680+
if (!raw) return null;
681+
if (raw === 'true' || raw === '1' || raw === 'yes') return true;
682+
if (raw === 'false' || raw === '0' || raw === 'no') return false;
683+
throw new Error(`Invalid ${name} value: ${raw} (expected true or false)`);
684+
}
685+
563686
function delay(ms: number): Promise<void> {
564687
return new Promise((resolve) => setTimeout(resolve, ms));
565688
}

test/specs/mainnet/probe.e2e.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import {
66
parseNonNegativeIntEnv,
77
parseProbeCommandSuccess,
88
probeModeForTargetType,
9+
resetPathfindingScores,
910
resolveProbeOrder,
11+
resolveProbeResetScores,
1012
resolveProbeTargets,
1113
runProbeInvoiceCommand,
1214
runProbeNodeCommand,
@@ -217,7 +219,17 @@ describe('@probe_mainnet - Lightning probe smoke', () => {
217219
expectAndroidAlert: false,
218220
});
219221
await waitForMainnetWalletReady({ logPrefix: 'Probe' });
220-
readiness = await waitForProbeReadiness({ logPrefix: 'Probe' });
222+
223+
const resetScores = resolveProbeResetScores();
224+
let scoresResetFloorS: number | null = null;
225+
if (resetScores) {
226+
scoresResetFloorS = await resetPathfindingScores({ logPrefix: 'Probe' });
227+
}
228+
readiness = await waitForProbeReadiness({
229+
logPrefix: 'Probe',
230+
requireScoresSync: resetScores,
231+
minScoresSyncTimestamp: scoresResetFloorS,
232+
});
221233

222234
const probeOrder = resolveProbeOrder();
223235
const probes = buildProbeQueue(targets, probeOrder);

0 commit comments

Comments
 (0)