p2p-fret 1.1.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -48,6 +48,22 @@ export interface FretConfig {
48
48
  * Must be a positive finite number; the constructor throws otherwise.
49
49
  */
50
50
  maxPassiveIntervalMs?: number;
51
+ /**
52
+ * The longest grace, in milliseconds, a peer this node has watched stay up for a long time is
53
+ * given to come back before it is marked `dead` (default 300 000 ms — 5 minutes).
54
+ *
55
+ * A peer is normally marked dead after `deadAfterFailures` spaced failed contacts — a few
56
+ * seconds. A peer with long *proven tenure* (how long this node itself has watched it stay up)
57
+ * is instead marked dead only once its failure run has also lasted `tenure / 12`, capped here:
58
+ * nothing below 10 minutes of tenure, 50 s at 10 minutes, 2.5 minutes at 30, the full cap from
59
+ * an hour on. So a long-running server down for a few minutes of maintenance keeps its place in
60
+ * the ring rather than having the ring re-form around it twice. While in grace the peer is still
61
+ * a live member — routing steers around it by its probe backoff, and cohorts keep it as a
62
+ * member that is merely unavailable. `0` disables grace for every peer. Must be a finite number
63
+ * between 0 and 15 minutes (the tenure gap tolerance — a longer grace could keep a peer "alive"
64
+ * across a silence long enough to restart its session); the constructor throws otherwise.
65
+ */
66
+ maxDeadGraceMs?: number;
51
67
  /**
52
68
  * This node's libp2p private key — the key `node.peerId` was derived from.
53
69
  *
@@ -174,6 +190,13 @@ export interface FretService {
174
190
  id: string;
175
191
  metadata?: Record<string, unknown>;
176
192
  }>;
193
+ /**
194
+ * How long, in ms, this node has itself watched `peerId` stay continuously up — measured from
195
+ * proof of life only, never gossiped (see *Tenure* in docs/fret.md). 0 for a peer the table
196
+ * holds but has never proven alive; `undefined` for one it does not hold. Cohort order does not
197
+ * use it — a higher layer may, e.g. to choose which cohort members to ask first.
198
+ */
199
+ getPeerTenure(peerId: string): number | undefined;
177
200
  reportNetworkSize(estimate: number, confidence: number, source?: string): void;
178
201
  getNetworkSizeEstimate(): {
179
202
  size_estimate: number;
@@ -389,7 +389,7 @@ export declare class FretService implements IFretService, Startable {
389
389
  *
390
390
  * **Scoring never creates** — a peer we hold no entry for is not scored, and the call returns
391
391
  * before the `selfCoord()` await. See {@link applyTouch} for why, and for where creation does
392
- * belong. The trailing `applyMembershipSignal` / `noteProofOfLife` land inside that guard and
392
+ * belong. The `applyMembershipSignal` / `noteProofOfLife` land inside that guard and
393
393
  * so stop running for a missing peer, which loses nothing: both already no-op on one.
394
394
  *
395
395
  * `latencyMs` is optional and must be supplied **only** when the caller timed a round trip
@@ -428,7 +428,16 @@ export declare class FretService implements IFretService, Startable {
428
428
  */
429
429
  private applyContactFailure;
430
430
  /**
431
- * Count one failed contact against `id`, marking it `dead` once the run completes.
431
+ * Count one failed contact against `id`, marking it `dead` once the run completes — and, for a
432
+ * peer with long proven tenure, has also lasted that peer's grace.
433
+ *
434
+ * **The grace** (`deadGraceMs` in `store/tenure.ts`, capped by `maxDeadGraceMs`) is time, not
435
+ * strikes: strikes arrive at the pass rate, so a larger count would stretch seconds, never the
436
+ * minutes a server's maintenance takes. A peer under 10 minutes of tenure has none and is judged
437
+ * exactly as before. Tenure is frozen during the outage (no proof of life arrives), so the grace
438
+ * is stable across the run without being snapshotted at its start. A peer held in grace is still
439
+ * a live member: routing steers around it by its probe backoff, and cohorts keep it as a member
440
+ * that is merely unavailable.
432
441
  *
433
442
  * **Synchronous by construction.** `applySuccess` / `applyFailure` read an entry, await the
434
443
  * self coordinate, then write a value derived before the await — so two concurrent chains
@@ -446,7 +455,7 @@ export declare class FretService implements IFretService, Startable {
446
455
  private applyContactStrike;
447
456
  /**
448
457
  * Record that `id` is demonstrably alive: clear its contact-failure run and, if it was marked
449
- * `dead`, restore it to a live state.
458
+ * `dead`, restore it to a live state — and advance its session tenure (`store/tenure.ts`).
450
459
  *
451
460
  * Called from every site that proves liveness: the RPC sites through {@link noteRpcEvidence},
452
461
  * and a fresh transport connection (`peer:connect`) directly. The counter reset is the
@@ -456,8 +465,15 @@ export declare class FretService implements IFretService, Startable {
456
465
  *
457
466
  * Relevance is deliberately *not* reset to a baseline. `scoreSuccess` already up-ranks on
458
467
  * success, and wiping the health counters would erase the record of a peer that flaps.
468
+ *
469
+ * This is the **only** place tenure advances, so it counts proof of life and nothing else:
470
+ * hearsay (`noteDiscovered`, address hints, the peerStore listeners) never reaches it. The
471
+ * stamp rides the same write as the liveness patch, and a stamp alone is **not** a change —
472
+ * otherwise every RPC from a settled neighbour would snap the idle backoff back to its base.
459
473
  */
460
474
  private noteProofOfLife;
475
+ /** What proof of life changes about `e`'s liveness bookkeeping — empty when already settled. */
476
+ private livenessPatch;
461
477
  /**
462
478
  * The remote *answered on this network's namespaced protocol* — a framed reply came back,
463
479
  * whatever it said: `ok: false`, `busy`, or bytes that would not decode.
@@ -1712,6 +1728,7 @@ export declare class FretService implements IFretService, Startable {
1712
1728
  id: string;
1713
1729
  metadata?: Record<string, unknown>;
1714
1730
  }>;
1731
+ getPeerTenure(peerId: string): number | undefined;
1715
1732
  exportTable(): SerializedTable;
1716
1733
  importTable(table: SerializedTable): Promise<number>;
1717
1734
  /**
@@ -28,6 +28,7 @@ import { NearExchange, snapshotDigest } from './near-exchange.js';
28
28
  import { IdleBackoff } from './idle-backoff.js';
29
29
  import { ringNeighborsBothSides } from '../ring/ring-walk.js';
30
30
  import { createSparsityModel, normalizedLogDistance, sparsityBonus, touch as scoreTouch, recordSuccess as scoreSuccess, recordFailure as scoreFailure, initialRelevance, } from '../store/relevance.js';
31
+ import { deadGraceMs, noteProven, provenTenureMs, TENURE_GAP_TOLERANCE_MS } from '../store/tenure.js';
31
32
  import { createLogger } from '../logger.js';
32
33
  const log = createLogger('service:fret');
33
34
  function isPlainObject(value) {
@@ -118,6 +119,20 @@ function requirePositiveMs(value, fallback, name) {
118
119
  }
119
120
  return value;
120
121
  }
122
+ /**
123
+ * The configured dead-verdict grace ceiling (`FretConfig.maxDeadGraceMs`): a finite number of ms
124
+ * from 0 (the off switch) up to the tenure gap tolerance; absent takes `fallback`. Past the
125
+ * tolerance a grace could hold a peer "not dead" across a silence long enough that its session
126
+ * restarts on return — a contradiction, so it fails here rather than being clamped.
127
+ */
128
+ function requireDeadGraceMs(value, fallback) {
129
+ if (value === undefined)
130
+ return fallback;
131
+ if (!Number.isFinite(value) || value < 0 || value > TENURE_GAP_TOLERANCE_MS) {
132
+ throw new Error(`FretService: maxDeadGraceMs must be a finite number of milliseconds from 0 to ${TENURE_GAP_TOLERANCE_MS}, got ${String(value)}`);
133
+ }
134
+ return value;
135
+ }
121
136
  /**
122
137
  * The peer ids named by configured bootstrap entries: a multiaddr's `p2p` component, otherwise
123
138
  * the entry itself as a bare peer id. An entry that yields no parseable id is logged and skipped
@@ -485,6 +500,13 @@ export class FretService {
485
500
  * sent). A redial cancelled mid-flight by `stop()` counts in neither verdict.
486
501
  */
487
502
  redials: { started: 0, connected: 0, failed: 0 },
503
+ /**
504
+ * Contact strikes that completed a failure run against a live peer but did not mark it
505
+ * `dead`, because the run had not yet lasted the peer's tenure-earned grace (see
506
+ * `applyContactStrike`). One per such strike, so a long-tenured peer that stays silent
507
+ * through its grace counts once per pass until the verdict lands.
508
+ */
509
+ deadGraceHeld: 0,
488
510
  /**
489
511
  * The stabilization loop's idle backoff, as of the last completed pass (see `IdleBackoff`):
490
512
  * `delayMs` is the passive delay the loop armed after it — the base while things change,
@@ -564,6 +586,7 @@ export class FretService {
564
586
  // Validated like `dialTimeoutMs`: a host that set it meant a number. A value at or
565
587
  // below the base interval is the off switch — `IdleBackoff` clamps the cap to the base.
566
588
  maxPassiveIntervalMs: requirePositiveMs(cfg?.maxPassiveIntervalMs, 60_000, 'maxPassiveIntervalMs'),
589
+ maxDeadGraceMs: requireDeadGraceMs(cfg?.maxDeadGraceMs, 300_000),
567
590
  };
568
591
  this.idle = new IdleBackoff({ baseMs: FretService.PASSIVE_INTERVAL_MS, capMs: this.cfg.maxPassiveIntervalMs });
569
592
  // A key that does not derive this node's peer id would seal records no receiver accepts
@@ -771,7 +794,7 @@ export class FretService {
771
794
  *
772
795
  * **Scoring never creates** — a peer we hold no entry for is not scored, and the call returns
773
796
  * before the `selfCoord()` await. See {@link applyTouch} for why, and for where creation does
774
- * belong. The trailing `applyMembershipSignal` / `noteProofOfLife` land inside that guard and
797
+ * belong. The `applyMembershipSignal` / `noteProofOfLife` land inside that guard and
775
798
  * so stop running for a missing peer, which loses nothing: both already no-op on one.
776
799
  *
777
800
  * `latencyMs` is optional and must be supplied **only** when the caller timed a round trip
@@ -789,6 +812,16 @@ export class FretService {
789
812
  * inside the store instead of patching a value derived outside it.
790
813
  */
791
814
  async applySuccess(id, latencyMs) {
815
+ if (!this.store.getById(id))
816
+ return;
817
+ // Every applySuccess call in this service follows a completed RPC over this
818
+ // network's namespaced protocol (ping or maybeAct), which proves the peer
819
+ // serves this network → confirm membership for free off normal traffic…
820
+ this.applyMembershipSignal(id, 'rpc-success');
821
+ // …and proves it is reachable, which clears any contact-failure run and resurrects it if
822
+ // a previous run had marked it dead. Before the score, so the score reads the tenure this
823
+ // very proof extended or restarted — not a session from before a long absence.
824
+ this.noteRpcEvidence(id);
792
825
  const entry = this.store.getById(id);
793
826
  if (!entry)
794
827
  return;
@@ -806,13 +839,6 @@ export class FretService {
806
839
  // has nothing to say about.
807
840
  ...(latencyMs === undefined ? {} : { avgLatencyMs: next.avgLatencyMs }),
808
841
  });
809
- // Every applySuccess call in this service follows a completed RPC over this
810
- // network's namespaced protocol (ping or maybeAct), which proves the peer
811
- // serves this network → confirm membership for free off normal traffic.
812
- this.applyMembershipSignal(id, 'rpc-success');
813
- // …and proves it is reachable, which clears any contact-failure run and resurrects it if
814
- // a previous run had marked it dead.
815
- this.noteRpcEvidence(id);
816
842
  }
817
843
  /**
818
844
  * Record a failed interaction with `id`.
@@ -851,7 +877,16 @@ export class FretService {
851
877
  this.applyContactStrike(id);
852
878
  }
853
879
  /**
854
- * Count one failed contact against `id`, marking it `dead` once the run completes.
880
+ * Count one failed contact against `id`, marking it `dead` once the run completes — and, for a
881
+ * peer with long proven tenure, has also lasted that peer's grace.
882
+ *
883
+ * **The grace** (`deadGraceMs` in `store/tenure.ts`, capped by `maxDeadGraceMs`) is time, not
884
+ * strikes: strikes arrive at the pass rate, so a larger count would stretch seconds, never the
885
+ * minutes a server's maintenance takes. A peer under 10 minutes of tenure has none and is judged
886
+ * exactly as before. Tenure is frozen during the outage (no proof of life arrives), so the grace
887
+ * is stable across the run without being snapshotted at its start. A peer held in grace is still
888
+ * a live member: routing steers around it by its probe backoff, and cohorts keep it as a member
889
+ * that is merely unavailable.
855
890
  *
856
891
  * **Synchronous by construction.** `applySuccess` / `applyFailure` read an entry, await the
857
892
  * self coordinate, then write a value derived before the await — so two concurrent chains
@@ -884,20 +919,30 @@ export class FretService {
884
919
  // Clamped at the threshold so the counter stays bounded for a peer we keep re-probing;
885
920
  // a peer already dead simply stays dead.
886
921
  const failures = Math.min(e.contactFailures + 1, threshold);
887
- const patch = { contactFailures: failures, lastContactFailureAt: now };
888
- if (failures >= threshold && e.state !== 'dead')
922
+ // A run carrying no start stamp (counter written outside this method) reads as long over, so
923
+ // its verdict falls back to the strike count alone — today's rule, never a stuck grace.
924
+ const runStart = e.contactFailures === 0 ? now : e.contactRunStartedAt;
925
+ const completed = failures >= threshold && e.state !== 'dead';
926
+ const toDead = completed && now - runStart >= deadGraceMs(provenTenureMs(e), this.cfg.maxDeadGraceMs);
927
+ const patch = { contactFailures: failures, lastContactFailureAt: now, contactRunStartedAt: runStart };
928
+ if (toDead)
889
929
  patch.state = 'dead';
890
930
  this.store.update(id, patch);
891
- // A strike on a peer that was not already dead progressed a run (or completed one). One
892
- // on a peer already `dead` — the dead arm re-probing it — moves nothing: the peer is backed
893
- // off, there is nothing new to act on, and counting it would reset the delay on every
894
- // dead-arm probe forever.
895
- if (e.state !== 'dead')
896
- this.noteChange(`contact strike ${failures} on ${id}`);
931
+ if (completed && !toDead)
932
+ this.diag.deadGraceHeld++;
933
+ // A change is a strike that progressed the count, or the one that marked the peer dead. A
934
+ // grace-held strike at the clamped threshold moves nothing — it can repeat every pass for
935
+ // minutes, and counting it would pin the idle backoff at its base for the whole grace. So
936
+ // the verdict lands at most one passive interval after the grace expires. A strike on a peer
937
+ // already `dead` (the dead arm re-probing it) is never a change, or every dead-arm probe
938
+ // would reset the delay forever.
939
+ if (e.state !== 'dead' && (failures > e.contactFailures || toDead)) {
940
+ this.noteChange(toDead ? `contact run completed on ${id}` : `contact strike ${failures} on ${id}`);
941
+ }
897
942
  }
898
943
  /**
899
944
  * Record that `id` is demonstrably alive: clear its contact-failure run and, if it was marked
900
- * `dead`, restore it to a live state.
945
+ * `dead`, restore it to a live state — and advance its session tenure (`store/tenure.ts`).
901
946
  *
902
947
  * Called from every site that proves liveness: the RPC sites through {@link noteRpcEvidence},
903
948
  * and a fresh transport connection (`peer:connect`) directly. The counter reset is the
@@ -907,28 +952,45 @@ export class FretService {
907
952
  *
908
953
  * Relevance is deliberately *not* reset to a baseline. `scoreSuccess` already up-ranks on
909
954
  * success, and wiping the health counters would erase the record of a peer that flaps.
955
+ *
956
+ * This is the **only** place tenure advances, so it counts proof of life and nothing else:
957
+ * hearsay (`noteDiscovered`, address hints, the peerStore listeners) never reaches it. The
958
+ * stamp rides the same write as the liveness patch, and a stamp alone is **not** a change —
959
+ * otherwise every RPC from a settled neighbour would snap the idle backoff back to its base.
910
960
  */
911
961
  noteProofOfLife(id) {
912
962
  const e = this.store.getById(id);
913
963
  if (!e)
914
964
  return;
965
+ const patch = this.livenessPatch(e);
966
+ const settled = Object.keys(patch).length === 0;
967
+ // Self never reaches here by RPC; the guard keeps a meaningless tenure off its entry anyway.
968
+ const tenure = id === this.selfIdStr ? undefined : noteProven(e, Date.now());
969
+ if (settled && tenure === undefined)
970
+ return;
971
+ this.store.update(id, { ...patch, ...tenure });
972
+ // Only when the liveness half wrote — a run cleared or a `dead` peer revived. Proof of life
973
+ // from a peer already settled (an idle neighbour's ping) at most stamped tenure, which is
974
+ // not something the next pass could act on.
975
+ if (settled)
976
+ return;
977
+ this.noteChange(patch.state === undefined ? `contact run cleared for ${id}` : `${id} revived`);
978
+ }
979
+ /** What proof of life changes about `e`'s liveness bookkeeping — empty when already settled. */
980
+ livenessPatch(e) {
915
981
  const patch = {};
916
982
  // The spacing stamp is cleared alongside the count, because it only means anything
917
983
  // *within* a run: leaving it behind makes the first failure of the next run land inside
918
984
  // the spacing window of a failure from the run just ended, so that strike is discarded
919
985
  // and the peer gets a free miss after every recovery.
920
- if (e.contactFailures !== 0 || e.lastContactFailureAt !== 0) {
986
+ if (e.contactFailures !== 0 || e.lastContactFailureAt !== 0 || e.contactRunStartedAt !== 0) {
921
987
  patch.contactFailures = 0;
922
988
  patch.lastContactFailureAt = 0;
989
+ patch.contactRunStartedAt = 0;
923
990
  }
924
991
  if (e.state === 'dead')
925
- patch.state = this.isConnected(id) ? 'connected' : 'disconnected';
926
- if (Object.keys(patch).length === 0)
927
- return; // already settled
928
- this.store.update(id, patch);
929
- // Only when it wrote — a run cleared or a `dead` peer revived. Proof of life from a peer
930
- // already settled (an idle neighbour's ping) returned above and is not a change.
931
- this.noteChange(patch.state === undefined ? `contact run cleared for ${id}` : `${id} revived`);
992
+ patch.state = this.isConnected(e.id) ? 'connected' : 'disconnected';
993
+ return patch;
932
994
  }
933
995
  /**
934
996
  * The remote *answered on this network's namespaced protocol* — a framed reply came back,
@@ -2305,6 +2367,9 @@ export class FretService {
2305
2367
  // dead self has no path back short of a restart — the same guard `applyContactStrike` keeps.
2306
2368
  if (id === this.selfIdStr)
2307
2369
  return;
2370
+ // NOTE: immediate whatever the sender's tenure — no dead-verdict grace — because the notice is
2371
+ // the peer's own statement that it is going. A "back in N minutes" leave flavour that earns
2372
+ // grace needs leave authentication first (tickets/backlog/22-debt-leave-authentication.md).
2308
2373
  this.store.update(id, { state: 'dead' });
2309
2374
  }
2310
2375
  /**
@@ -4101,6 +4166,9 @@ export class FretService {
4101
4166
  backoffPenalty: (id) => this.backoff.penalty(id),
4102
4167
  };
4103
4168
  }
4169
+ // NOTE: link quality is the success rate alone; session tenure (`store/tenure.ts`) is available
4170
+ // on the entry but deliberately not folded in. If routes are ever measured failing mid-path on
4171
+ // short-lived hops, weight this by `stabilityFactor(provenTenureMs(entry))`.
4104
4172
  linkQuality(id) {
4105
4173
  const entry = this.store.getById(id);
4106
4174
  if (!entry)
@@ -4425,6 +4493,10 @@ export class FretService {
4425
4493
  metadata: entry.metadata
4426
4494
  }));
4427
4495
  }
4496
+ getPeerTenure(peerId) {
4497
+ const entry = this.store.getById(peerId);
4498
+ return entry ? provenTenureMs(entry) : undefined;
4499
+ }
4428
4500
  exportTable() {
4429
4501
  const now = Date.now();
4430
4502
  return {
@@ -113,6 +113,7 @@ export declare class Libp2pFretService implements Startable, FretService {
113
113
  id: string;
114
114
  metadata?: Record<string, unknown>;
115
115
  }>;
116
+ getPeerTenure(peerId: string): number | undefined;
116
117
  exportTable(): SerializedTable;
117
118
  importTable(table: SerializedTable): Promise<number>;
118
119
  reportNetworkSize(estimate: number, confidence: number, source?: string): void;
@@ -168,7 +168,7 @@ export class Libp2pFretService {
168
168
  // dropping the `return` changes nothing a caller can observe. What it does change is
169
169
  // `test/libp2p-facade-forwarding.spec.ts`, which injects a mock core returning a distinct
170
170
  // sentinel from *every* method and asserts each facade method hands that exact object back —
171
- // one uniform exact-forwarding rule over all 20 members rather than a per-method judgement
171
+ // one uniform exact-forwarding rule over all 21 members rather than a per-method judgement
172
172
  // about which returns are worth checking. Deleting a `return` here fails that spec with
173
173
  // "<method> return identity"; keep them.
174
174
  async ready() {
@@ -190,6 +190,9 @@ export class Libp2pFretService {
190
190
  listPeers() {
191
191
  return this.ensure().listPeers();
192
192
  }
193
+ getPeerTenure(peerId) {
194
+ return this.ensure().getPeerTenure(peerId);
195
+ }
193
196
  exportTable() {
194
197
  return this.ensure().exportTable();
195
198
  }
@@ -59,6 +59,15 @@ export interface PeerEntry {
59
59
  * serialized — it describes a live attempt and says nothing after a restart. 0 = never counted.
60
60
  */
61
61
  lastContactFailureAt: number;
62
+ /**
63
+ * Unix ms of the first strike counted in the current contact-failure run; 0 = no run.
64
+ *
65
+ * The dead-verdict grace (`deadGraceMs` in `store/tenure.ts`) is measured from here: a peer we
66
+ * have watched stay up for a long time is marked `dead` only once its run has both reached the
67
+ * strike threshold *and* lasted that long. Same lifecycle as `lastContactFailureAt` — not
68
+ * serialized, 0 on insert and import, cleared together with the run by proof of life.
69
+ */
70
+ contactRunStartedAt: number;
62
71
  accessCount: number;
63
72
  successCount: number;
64
73
  failureCount: number;
@@ -74,6 +83,20 @@ export interface PeerEntry {
74
83
  * measurements away. Neither mistake is expressible now.
75
84
  */
76
85
  avgLatencyMs: number | null;
86
+ /**
87
+ * Local-clock unix ms at which this peer's current *session* began — the first proof of life
88
+ * of a run in which no two consecutive proofs were further apart than the tenure gap tolerance.
89
+ * 0 = never proven. Paired with {@link lastProvenAliveAt}; proven tenure is the span between
90
+ * them (see `store/tenure.ts`). Like the counters, the store stores and exposes it and never
91
+ * branches on it.
92
+ */
93
+ sessionStartedAt: number;
94
+ /**
95
+ * Local-clock unix ms of the latest proof of life counted toward the current session (stamped
96
+ * at a granularity of about a second, so a burst of RPCs is one write). 0 = never proven.
97
+ * Written only by `FretService.noteProofOfLife`; hearsay never touches it.
98
+ */
99
+ lastProvenAliveAt: number;
77
100
  metadata?: Record<string, unknown>;
78
101
  /**
79
102
  * This peer's most recent **verified** signed address record — the marshaled libp2p
@@ -137,6 +160,8 @@ export interface SerializedPeerEntry {
137
160
  successCount: number;
138
161
  failureCount: number;
139
162
  avgLatencyMs: number | null;
163
+ sessionStartedAt?: number;
164
+ lastProvenAliveAt?: number;
140
165
  metadata?: Record<string, unknown>;
141
166
  addressRecord?: SerializedAddressRecord;
142
167
  }
@@ -209,10 +209,13 @@ export class DigitreeStore {
209
209
  lastNegotiateFailureAt: 0,
210
210
  contactFailures: 0,
211
211
  lastContactFailureAt: 0,
212
+ contactRunStartedAt: 0,
212
213
  accessCount: 0,
213
214
  successCount: 0,
214
215
  failureCount: 0,
215
- avgLatencyMs: null
216
+ avgLatencyMs: null,
217
+ sessionStartedAt: 0,
218
+ lastProvenAliveAt: 0
216
219
  });
217
220
  }
218
221
  update(id, patch) {
@@ -442,6 +445,8 @@ export class DigitreeStore {
442
445
  successCount: e.successCount,
443
446
  failureCount: e.failureCount,
444
447
  avgLatencyMs: e.avgLatencyMs,
448
+ sessionStartedAt: e.sessionStartedAt,
449
+ lastProvenAliveAt: e.lastProvenAliveAt,
445
450
  ...(e.metadata ? { metadata: e.metadata } : {}),
446
451
  ...(e.addressRecord ? { addressRecord: serializeAddressRecord(e.addressRecord) } : {}),
447
452
  }));
@@ -486,6 +491,7 @@ export class DigitreeStore {
486
491
  // on its very next failure.
487
492
  contactFailures: 0,
488
493
  lastContactFailureAt: 0,
494
+ contactRunStartedAt: 0,
489
495
  // NOTE: a snapshot taken before frequency credit was narrowed to proven contact
490
496
  // carries an accessCount that also counted mentions, so a restored entry can be
491
497
  // over-credited. Self-correcting — the next scoring call rewrites relevance from
@@ -499,6 +505,12 @@ export class DigitreeStore {
499
505
  // reads back as a genuine 0 ms — accepted, since the next ping overwrites it and
500
506
  // a coercion would instead discard real 0 ms measurements.
501
507
  avgLatencyMs: s.avgLatencyMs ?? null,
508
+ // Restored rather than reset: tenure is history, and a proof after a restart longer
509
+ // than the gap tolerance starts a fresh session anyway. NOTE: a tampered file can
510
+ // inflate it, exactly as it can inflate the imported relevance and counters — table
511
+ // signing (tickets/backlog/23-debt-routing-table-export-signing.md) closes that class.
512
+ sessionStartedAt: s.sessionStartedAt ?? 0,
513
+ lastProvenAliveAt: s.lastProvenAliveAt ?? 0,
502
514
  ...(s.metadata ? { metadata: s.metadata } : {}),
503
515
  ...(addressRecord ? { addressRecord } : {}),
504
516
  };
@@ -1,4 +1,5 @@
1
1
  import { minDistance, normalizedLogMagnitude } from '../ring/distance.js';
2
+ import { provenTenureMs, stabilityFactor } from './tenure.js';
2
3
  export function createSparsityModel(m = 12, sigma = 0.08, alpha = 0.03, beta = 0.6, sMin = 0.7, sMax = 1.8, eps = 1e-6) {
3
4
  const centers = [];
4
5
  for (let i = 0; i < m; i++)
@@ -86,11 +87,20 @@ function baseRelevance(entry, now) {
86
87
  wFreq * frequencyScore(entry) +
87
88
  wHealth * healthScore(entry));
88
89
  }
90
+ /**
91
+ * `base · S(x) · stability`: every scoring function's final step. Stability comes from the
92
+ * entry's *proven* tenure (`store/tenure.ts`), which is 0 — factor 1 — for a brand-new or hearsay
93
+ * entry, so only a peer this node has itself watched stay up earns the multiplier. A failing peer
94
+ * is scored with its tenure frozen at the last proof, which is what lets a long-lived server under
95
+ * maintenance keep its edge at eviction over a fresh peer with similar counters.
96
+ */
97
+ function weighted(base, entry, x, model) {
98
+ return base * sparsityBonus(model, x) * stabilityFactor(provenTenureMs(entry));
99
+ }
89
100
  export function touch(entry, x, model, now = Date.now()) {
90
101
  observeDistance(model, x);
91
102
  const base = baseRelevance({ ...entry, accessCount: entry.accessCount + 1 }, now);
92
- const bonus = sparsityBonus(model, x);
93
- const relevance = base * bonus;
103
+ const relevance = weighted(base, entry, x, model);
94
104
  return {
95
105
  ...entry,
96
106
  lastAccess: now,
@@ -104,9 +114,7 @@ export function touch(entry, x, model, now = Date.now()) {
104
114
  * a distance we accessed.
105
115
  */
106
116
  export function initialRelevance(entry, x, model, now = Date.now()) {
107
- const base = baseRelevance(entry, now);
108
- const bonus = sparsityBonus(model, x);
109
- return base * bonus;
117
+ return weighted(baseRelevance(entry, now), entry, x, model);
110
118
  }
111
119
  /**
112
120
  * Blend a new latency sample into a peer's running average.
@@ -142,8 +150,7 @@ export function recordSuccess(entry, latencyMs, x, model, now = Date.now()) {
142
150
  observeDistance(model, x);
143
151
  const avgLatencyMs = blendLatency(entry.avgLatencyMs, latencyMs);
144
152
  const base = baseRelevance({ ...entry, avgLatencyMs, successCount: entry.successCount + 1, accessCount: entry.accessCount + 1 }, now);
145
- const bonus = sparsityBonus(model, x);
146
- const relevance = base * bonus;
153
+ const relevance = weighted(base, entry, x, model);
147
154
  return {
148
155
  ...entry,
149
156
  lastAccess: now,
@@ -157,8 +164,7 @@ export function recordFailure(entry, x, model, now = Date.now()) {
157
164
  observeDistance(model, x);
158
165
  // degrade relevance softly
159
166
  const base = baseRelevance({ ...entry, failureCount: entry.failureCount + 1 }, now) * 0.7;
160
- const bonus = sparsityBonus(model, x);
161
- const relevance = base * bonus;
167
+ const relevance = weighted(base, entry, x, model);
162
168
  return {
163
169
  ...entry,
164
170
  lastAccess: now,
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Session tenure: how long this node has *itself* watched a peer stay up, measured from proof of
3
+ * life only — a pure module with no clock of its own (callers pass `now`) and no service
4
+ * dependencies, so the rules are unit-testable without sleeps.
5
+ *
6
+ * A **session** is a run of proofs of life in which no two consecutive proofs are more than
7
+ * {@link TENURE_GAP_TOLERANCE_MS} apart. **Proven tenure** is `lastProvenAliveAt −
8
+ * sessionStartedAt`: the span actually observed, frozen while the peer is silent — it never grows
9
+ * on the clock alone, so a peer that went quiet gets no credit for the time it was away. The gap
10
+ * is measured on our clock, so our own downtime past the tolerance restarts every session too: we
11
+ * cannot vouch for time we did not observe.
12
+ *
13
+ * Nothing here is gossiped. Tenure is what *this* node saw, so no other peer can inflate it — the
14
+ * same rule as frequency credit (`FretService.noteDiscovered`).
15
+ */
16
+ /**
17
+ * The longest silence a session survives. Covers "a few minutes of maintenance" with room to
18
+ * spare, and must stay comfortably above the longest regular contact interval for a peer we
19
+ * actively watch — the near pass at the idle-backoff cap (60 s) and the dead re-probe window
20
+ * (at most 32 s) — or a healthy neighbour's session would break on its own. Not longer: a longer
21
+ * tolerance lets an intermittent peer accumulate tenure across its absences.
22
+ */
23
+ export declare const TENURE_GAP_TOLERANCE_MS: number;
24
+ /** Below this many ms between stamps, a proof refreshes nothing (write coalescing). */
25
+ export declare const TENURE_STAMP_GRANULARITY_MS = 1000;
26
+ /** Tenure at which the stability factor reaches half its maximum bonus. */
27
+ export declare const STABILITY_HALF_TENURE_MS: number;
28
+ /** Maximum relevance multiplier bonus: factor ∈ [1, 1 + STABILITY_MAX_BONUS). */
29
+ export declare const STABILITY_MAX_BONUS = 0.5;
30
+ /** The two tenure stamps a routing-table entry carries — local-clock unix ms, 0 = never proven. */
31
+ export interface TenureFields {
32
+ sessionStartedAt: number;
33
+ lastProvenAliveAt: number;
34
+ }
35
+ /**
36
+ * The patch a proof of life at `now` implies, or `undefined` when nothing needs writing.
37
+ *
38
+ * Continues the current session when the gap since the last proof is within the tolerance
39
+ * (inclusive); otherwise — never proven, too long a silence, or `now` before the last stamp (the
40
+ * clock went backwards, or the table came from a skewed clock) — starts a new one at `now`, so
41
+ * tenure is never negative. A continuing proof less than {@link TENURE_STAMP_GRANULARITY_MS}
42
+ * after the last stamp writes nothing, so a burst of RPCs costs one store write, not one per
43
+ * message.
44
+ */
45
+ export declare function noteProven(cur: TenureFields, now: number): TenureFields | undefined;
46
+ /** Proven tenure in ms: 0 when never proven, and never negative or non-finite. */
47
+ export declare function provenTenureMs(cur: TenureFields): number;
48
+ /**
49
+ * The relevance multiplier tenure earns: `1 + MAX · t / (t + HALF)` — 1 at zero tenure, about
50
+ * 1.038 at 5 minutes, 1.25 at an hour, 1.48 at a day. Monotone and bounded below
51
+ * `1 + STABILITY_MAX_BONUS`, so tenure can tip a close eviction but never dominate the other
52
+ * relevance terms.
53
+ */
54
+ export declare function stabilityFactor(tenureMs: number): number;
55
+ /** Below this proven tenure a peer gets no dead-verdict grace: newcomers keep the fast verdict. */
56
+ export declare const DEAD_GRACE_MIN_TENURE_MS: number;
57
+ /**
58
+ * Grace earned per unit of proven tenure: a peer we have watched stay up for T is given T/12 to
59
+ * come back before it is declared dead. A judgement call, not a measurement — Lindy-shaped: the
60
+ * longer a peer has been up, the longer an absence is still plausibly "brief".
61
+ */
62
+ export declare const DEAD_GRACE_TENURE_RATIO: number;
63
+ /**
64
+ * How long a contact-failure run against a peer with `tenureMs` of proven tenure must last before
65
+ * the peer may be marked `dead`, capped at `capMs` (`FretConfig.maxDeadGraceMs`; 0 disables grace).
66
+ *
67
+ * 0 below {@link DEAD_GRACE_MIN_TENURE_MS}, so a phone that appeared five minutes ago is judged
68
+ * exactly as before; at the default 5-minute cap, 50 s at 10 minutes of tenure, 2.5 minutes at
69
+ * 30, and the full cap from an hour on. Strike count alone cannot do this — strikes arrive at the
70
+ * stabilization pass rate, so a larger count stretches seconds, not minutes.
71
+ */
72
+ export declare function deadGraceMs(tenureMs: number, capMs: number): number;