p2p-fret 0.6.0 → 1.0.0-beta.2

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.
Files changed (84) hide show
  1. package/README.md +5 -5
  2. package/dist/src/estimate/size-estimator.d.ts +21 -5
  3. package/dist/src/estimate/size-estimator.js +138 -29
  4. package/dist/src/index.d.ts +31 -14
  5. package/dist/src/index.js +37 -5
  6. package/dist/src/ring/distance.d.ts +28 -1
  7. package/dist/src/ring/distance.js +52 -15
  8. package/dist/src/ring/hash.d.ts +12 -0
  9. package/dist/src/ring/hash.js +25 -6
  10. package/dist/src/rpc/leave.d.ts +6 -2
  11. package/dist/src/rpc/leave.js +32 -44
  12. package/dist/src/rpc/maybe-act.d.ts +16 -4
  13. package/dist/src/rpc/maybe-act.js +31 -34
  14. package/dist/src/rpc/neighbors.d.ts +38 -3
  15. package/dist/src/rpc/neighbors.js +100 -72
  16. package/dist/src/rpc/outcome.d.ts +79 -0
  17. package/dist/src/rpc/outcome.js +1 -0
  18. package/dist/src/rpc/ping.d.ts +13 -4
  19. package/dist/src/rpc/ping.js +59 -61
  20. package/dist/src/rpc/protocols.d.ts +275 -7
  21. package/dist/src/rpc/protocols.js +559 -49
  22. package/dist/src/rpc/request.d.ts +59 -0
  23. package/dist/src/rpc/request.js +200 -0
  24. package/dist/src/rpc/validate.d.ts +189 -0
  25. package/dist/src/rpc/validate.js +394 -0
  26. package/dist/src/selector/next-hop.d.ts +14 -0
  27. package/dist/src/selector/next-hop.js +114 -71
  28. package/dist/src/service/cohort.d.ts +7 -2
  29. package/dist/src/service/cohort.js +29 -14
  30. package/dist/src/service/dedup-cache.d.ts +29 -6
  31. package/dist/src/service/dedup-cache.js +34 -34
  32. package/dist/src/service/fret-service.d.ts +788 -38
  33. package/dist/src/service/fret-service.js +2051 -553
  34. package/dist/src/service/libp2p-fret-service.d.ts +44 -9
  35. package/dist/src/service/libp2p-fret-service.js +62 -18
  36. package/dist/src/service/live-member.d.ts +28 -0
  37. package/dist/src/service/live-member.js +27 -0
  38. package/dist/src/service/payload-heuristic.d.ts +4 -2
  39. package/dist/src/service/payload-heuristic.js +12 -3
  40. package/dist/src/service/peer-discovery.d.ts +74 -8
  41. package/dist/src/service/peer-discovery.js +105 -36
  42. package/dist/src/store/digitree-store.d.ts +140 -6
  43. package/dist/src/store/digitree-store.js +142 -52
  44. package/dist/src/store/relevance.d.ts +40 -1
  45. package/dist/src/store/relevance.js +59 -20
  46. package/dist/src/utils/deadline.d.ts +37 -0
  47. package/dist/src/utils/deadline.js +64 -0
  48. package/dist/src/utils/expiring-map.d.ts +74 -0
  49. package/dist/src/utils/expiring-map.js +129 -0
  50. package/dist/src/utils/pool.d.ts +48 -0
  51. package/dist/src/utils/pool.js +60 -0
  52. package/dist/src/utils/token-bucket.d.ts +15 -0
  53. package/dist/src/utils/token-bucket.js +33 -4
  54. package/package.json +6 -2
  55. package/src/estimate/size-estimator.ts +176 -54
  56. package/src/index.ts +199 -142
  57. package/src/logger.ts +7 -7
  58. package/src/ring/distance.ts +49 -16
  59. package/src/ring/hash.ts +29 -6
  60. package/src/rpc/leave.ts +65 -63
  61. package/src/rpc/maybe-act.ts +58 -49
  62. package/src/rpc/neighbors.ts +111 -69
  63. package/src/rpc/outcome.ts +57 -0
  64. package/src/rpc/ping.ts +95 -83
  65. package/src/rpc/protocols.ts +718 -156
  66. package/src/rpc/request.ts +296 -0
  67. package/src/rpc/validate.ts +403 -0
  68. package/src/selector/next-hop.ts +318 -234
  69. package/src/service/cohort.ts +61 -43
  70. package/src/service/dedup-cache.ts +53 -44
  71. package/src/service/fret-service.ts +2094 -509
  72. package/src/service/libp2p-fret-service.ts +85 -23
  73. package/src/service/live-member.ts +29 -0
  74. package/src/service/payload-heuristic.ts +12 -3
  75. package/src/service/peer-discovery.ts +205 -98
  76. package/src/store/digitree-store.ts +226 -54
  77. package/src/store/relevance.ts +59 -18
  78. package/src/utils/deadline.ts +85 -0
  79. package/src/utils/expiring-map.ts +164 -0
  80. package/src/utils/pool.ts +79 -0
  81. package/src/utils/token-bucket.ts +70 -39
  82. package/dist/src/service/discovery.d.ts +0 -3
  83. package/dist/src/service/discovery.js +0 -15
  84. package/src/service/discovery.ts +0 -16
package/README.md CHANGED
@@ -79,7 +79,7 @@ FRET detects potential partitions using multiple signals:
79
79
 
80
80
  ```typescript
81
81
  // FRET automatically includes size estimates in ping responses
82
- registerPing(node, PROTOCOL_PING, () => {
82
+ await registerPing(node, PROTOCOL_PING, () => {
83
83
  return fretService.getNetworkSizeEstimate();
84
84
  });
85
85
 
@@ -104,7 +104,7 @@ interface FretService {
104
104
  exportTable(): SerializedTable;
105
105
 
106
106
  // Import a previously exported snapshot; returns number of entries loaded
107
- importTable(table: SerializedTable): number;
107
+ importTable(table: SerializedTable): Promise<number>;
108
108
  }
109
109
 
110
110
  interface SerializedTable {
@@ -123,7 +123,7 @@ interface SerializedPeerEntry {
123
123
  accessCount: number;
124
124
  successCount: number;
125
125
  failureCount: number;
126
- avgLatencyMs: number;
126
+ avgLatencyMs: number | null; // null = never measured (absent field reads back as null)
127
127
  metadata?: Record<string, any>;
128
128
  }
129
129
  ```
@@ -144,7 +144,7 @@ await fs.writeFile('fret-table.json', JSON.stringify(table));
144
144
  const saved: SerializedTable = JSON.parse(
145
145
  await fs.readFile('fret-table.json', 'utf-8')
146
146
  );
147
- const count = fret.importTable(saved);
147
+ const count = await fret.importTable(saved);
148
148
  console.log(`Restored ${count} routing entries`);
149
149
 
150
150
  await fret.start();
@@ -153,7 +153,7 @@ await fret.start();
153
153
  ### Behavior
154
154
 
155
155
  - **State reset**: All imported entries get `state: 'disconnected'` regardless of their exported state — connection liveness must be re-established through pings and stabilization.
156
- - **Capacity enforcement**: `importTable` enforces the configured capacity after loading. A table exported from a node with a larger capacity won't exceed the importer's limit.
156
+ - **Capacity enforcement**: `importTable` enforces the configured capacity after loading. A table exported from a node with a larger capacity won't exceed the importer's limit. `importTable` is `async` — enforcement needs the self ring coordinate, which is commonly not yet cached when import runs before `start()` — so always `await` it.
157
157
  - **Stale data safety**: After import, the normal stabilization loop probes peers to update connection states and relevance scores. Unreachable peers are decayed and eventually evicted.
158
158
  - **JSON round-trip safe**: `SerializedTable` survives `JSON.stringify` / `JSON.parse`.
159
159
 
@@ -3,10 +3,26 @@ export type SizeEstimate = {
3
3
  n: number;
4
4
  confidence: number;
5
5
  };
6
+ export interface SizeEstimateOptions {
7
+ /** Restrict the estimate to a subset of the store (e.g. same-network members only). */
8
+ filter?: (e: PeerEntry) => boolean;
9
+ /**
10
+ * Ring coordinate of the local node. When supplied, gaps are drawn from the
11
+ * successor/predecessor window around it — the intended arc-length method. Omitting it
12
+ * selects the degraded whole-store fallback (see {@link estimateSizeAndConfidence}).
13
+ */
14
+ selfCoord?: Uint8Array;
15
+ }
6
16
  /**
7
- * Online network-size estimate from inter-peer ring gaps. An optional `filter` scopes the
8
- * estimate to a subset of the store (e.g. same-network members only) so foreign peers from
9
- * a co-resident network can't inflate `n_est` and the derived cluster span / near-radius.
10
- * Defaults to counting every entry, leaving the standalone/simulator path unchanged.
17
+ * Online network-size estimate from inter-peer ring gaps: `n = 2^256 / gap`.
18
+ *
19
+ * `options.selfCoord` selects the successor/predecessor window as the gap population and is the
20
+ * intended path; omitting it falls back to whole-store gaps (see {@link collectGaps}).
21
+ * `options.filter` scopes the estimate to a subset of the store (e.g. same-network members only)
22
+ * so foreign peers from a co-resident network can't inflate `n` and the derived cluster span /
23
+ * near-radius. It defaults to counting every entry, leaving the standalone/simulator path
24
+ * unchanged.
25
+ *
26
+ * Confidence blends sample count against 2m with the dispersion factor above.
11
27
  */
12
- export declare function estimateSizeAndConfidence(store: DigitreeStore, m: number, filter?: (e: PeerEntry) => boolean): SizeEstimate;
28
+ export declare function estimateSizeAndConfidence(store: DigitreeStore, m: number, options?: SizeEstimateOptions): SizeEstimate;
@@ -1,51 +1,160 @@
1
+ const RING_SIZE = 1n << 256n;
2
+ const HALF_RING = 1n << 255n;
1
3
  function bytesToBigInt(u8) {
2
4
  let v = 0n;
3
5
  for (let i = 0; i < u8.length; i++)
4
6
  v = (v << 8n) | BigInt(u8[i]);
5
7
  return v;
6
8
  }
9
+ function ascending(a, b) {
10
+ return a < b ? -1 : a > b ? 1 : 0;
11
+ }
7
12
  function medianBigInt(values) {
8
13
  if (values.length === 0)
9
14
  return 0n;
10
- const arr = [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
15
+ const arr = [...values].sort(ascending);
11
16
  const mid = Math.floor(arr.length / 2);
12
17
  return arr.length % 2 === 0 ? (arr[mid - 1] + arr[mid]) / 2n : arr[mid];
13
18
  }
19
+ function meanBigInt(values) {
20
+ if (values.length === 0)
21
+ return 0n;
22
+ let sum = 0n;
23
+ for (const v of values)
24
+ sum += v;
25
+ return sum / BigInt(values.length);
26
+ }
27
+ /**
28
+ * Differences between adjacent entries of an ascending list.
29
+ *
30
+ * The wrap-around gap (last back round to first) is deliberately **not** included. A node's
31
+ * knowledge of the ring is never complete, so that gap spans the arc it has not sampled — it is
32
+ * the single largest outlier in the population and says nothing about inter-peer spacing.
33
+ */
34
+ function consecutiveGaps(ascendingValues) {
35
+ const gaps = [];
36
+ for (let i = 1; i < ascendingValues.length; i++)
37
+ gaps.push(ascendingValues[i] - ascendingValues[i - 1]);
38
+ return gaps;
39
+ }
40
+ /**
41
+ * Gaps between adjacent members of the successor/predecessor window around `selfCoord`.
42
+ *
43
+ * Coordinates are re-centred on self as *signed* offsets in (−2^255, 2^255] rather than compared
44
+ * raw, so a window straddling coordinate 0 stays one contiguous run. Sorting raw coordinates
45
+ * would split such a window in two and manufacture an interior gap the size of the rest of the
46
+ * ring — precisely the outlier this method exists to avoid.
47
+ *
48
+ * Self is normally present in the store, and a walk anchored exactly at `selfCoord` returns it
49
+ * as its own first result on *both* sides — so each side is asked for `m + 1` to still yield the
50
+ * m successors and m predecessors the S/P window is defined as, and offsets are collected in a
51
+ * `Set` so self (and any coincident coordinate) contributes once. Asking for plain `m` instead
52
+ * costs two of the 2m gaps. When self is absent from the store the extra slot simply widens the
53
+ * window by one peer per side, which is harmless.
54
+ */
55
+ function windowGaps(store, m, selfCoord, filter) {
56
+ const selfBig = bytesToBigInt(selfCoord);
57
+ const offsets = new Set([0n]);
58
+ const addById = (id) => {
59
+ const entry = store.getById(id);
60
+ if (!entry)
61
+ return;
62
+ let d = (bytesToBigInt(entry.coord) - selfBig) % RING_SIZE;
63
+ if (d < 0n)
64
+ d += RING_SIZE;
65
+ offsets.add(d > HALF_RING ? d - RING_SIZE : d);
66
+ };
67
+ const reach = m + 1;
68
+ for (const id of store.neighborsRight(selfCoord, reach, filter))
69
+ addById(id);
70
+ for (const id of store.neighborsLeft(selfCoord, reach, filter))
71
+ addById(id);
72
+ return consecutiveGaps([...offsets].sort(ascending));
73
+ }
14
74
  /**
15
- * Online network-size estimate from inter-peer ring gaps. An optional `filter` scopes the
16
- * estimate to a subset of the store (e.g. same-network members only) so foreign peers from
17
- * a co-resident network can't inflate `n_est` and the derived cluster span / near-radius.
18
- * Defaults to counting every entry, leaving the standalone/simulator path unchanged.
75
+ * Relative standard error of the mean gap, as a [0,1] sample-quality factor.
76
+ *
77
+ * Gaps on a uniform-random ring are exponentially distributed, so a coefficient of variation
78
+ * near 1 is the *healthy* value rather than a defect. Flooring cv at 1 encodes that prior: a
79
+ * synthetically perfect (evenly spaced) sample cannot claim zero sampling error from a handful
80
+ * of gaps. The factor is then monotone in window size, capping confidence at 0.5 + 0.5·(1 −
81
+ * 1/√G) — 0.875 at G = 16 (a full S/P window at the default m = 8), ~0.65 at G = 2.
82
+ *
83
+ * NOTE: this measures *local* spacing regularity only. A node whose neighbors are all packed
84
+ * into a tiny, evenly-spaced arc — an eclipse, or a very young ring — scores high here while
85
+ * `n` is wildly wrong, because no statistic over the sampled arc can see the arc that was never
86
+ * sampled. The defense is corroboration from peer-reported estimates (`reportNetworkSize` /
87
+ * `calibrateSizeFromSnapshot`), not a better local formula.
19
88
  */
20
- export function estimateSizeAndConfidence(store, m, filter) {
21
- const peers = filter ? store.list().filter(filter) : store.list();
89
+ function dispersionFactor(gaps) {
90
+ const count = gaps.length;
91
+ if (count === 0)
92
+ return 0;
93
+ const values = gaps.map(Number);
94
+ let sum = 0;
95
+ for (const v of values)
96
+ sum += v;
97
+ const mean = sum / count;
98
+ // Degenerate: every sampled peer sits at one coordinate, so there is no spacing to judge.
99
+ if (mean <= 0)
100
+ return 0;
101
+ let squared = 0;
102
+ for (const v of values)
103
+ squared += (v - mean) ** 2;
104
+ const cv = Math.sqrt(squared / count) / mean;
105
+ const rse = Math.max(cv, 1) / Math.sqrt(count);
106
+ return Math.max(0, Math.min(1, 1 - rse));
107
+ }
108
+ /**
109
+ * The gap population the estimate rests on, plus the representative gap derived from it.
110
+ *
111
+ * With `selfCoord` this is the successor/predecessor window and its arithmetic **mean** — the
112
+ * arc-length method from docs/fret.md. Without it, the degraded fallback: every known
113
+ * coordinate, and the **median** to blunt the outliers a whole-store population carries.
114
+ *
115
+ * The fallback is degraded because a node's knowledge is deliberately non-uniform — it knows
116
+ * every peer near itself but only a sparsity-weighted scattering of far ones — so whole-store
117
+ * gaps mix ~2m near-true spacings with a long tail of huge far-peer gaps. Once far peers
118
+ * outnumber near ones (the normal steady state) even the median lands in that tail and `n`
119
+ * collapses by an order of magnitude.
120
+ */
121
+ function collectGaps(store, m, peers, filter, selfCoord) {
122
+ if (selfCoord) {
123
+ const gaps = windowGaps(store, m, selfCoord, filter);
124
+ if (gaps.length > 0)
125
+ return { gaps, representativeGap: meanBigInt(gaps) };
126
+ }
127
+ const gaps = consecutiveGaps(peers.map((p) => bytesToBigInt(p.coord)).sort(ascending));
128
+ return { gaps, representativeGap: medianBigInt(gaps) };
129
+ }
130
+ /**
131
+ * Online network-size estimate from inter-peer ring gaps: `n = 2^256 / gap`.
132
+ *
133
+ * `options.selfCoord` selects the successor/predecessor window as the gap population and is the
134
+ * intended path; omitting it falls back to whole-store gaps (see {@link collectGaps}).
135
+ * `options.filter` scopes the estimate to a subset of the store (e.g. same-network members only)
136
+ * so foreign peers from a co-resident network can't inflate `n` and the derived cluster span /
137
+ * near-radius. It defaults to counting every entry, leaving the standalone/simulator path
138
+ * unchanged.
139
+ *
140
+ * Confidence blends sample count against 2m with the dispersion factor above.
141
+ */
142
+ export function estimateSizeAndConfidence(store, m, options) {
143
+ // NOTE: `list()` walks and materializes every entry, but on the `selfCoord` path only its
144
+ // length is used (for `sizeFactor`) — the gap population comes from the S/P window instead.
145
+ // That is an O(store) allocation per call, and the estimator is called once per inbound
146
+ // maybeAct. Fine at today's capacity (2048) and message rates; if either grows, give the
147
+ // store a `countWhere(filter)` and build the list only for the whole-store fallback.
148
+ const peers = options?.filter ? store.list().filter(options.filter) : store.list();
22
149
  const count = peers.length;
23
150
  if (count === 0)
24
151
  return { n: 0, confidence: 0 };
25
152
  if (count === 1)
26
153
  return { n: 1, confidence: 0.2 };
27
- const ringSize = 1n << 256n;
28
- const coords = peers.map((p) => bytesToBigInt(p.coord)).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
29
- const gaps = [];
30
- for (let i = 1; i < coords.length; i++)
31
- gaps.push(coords[i] - coords[i - 1]);
32
- gaps.push((coords[0] + ringSize) - coords[coords.length - 1]);
33
- // Use median gap to reduce skew from sparse knowledge
34
- const medGap = medianBigInt(gaps);
35
- const safeGap = medGap > 0n ? medGap : ringSize / BigInt(Math.max(1, count));
36
- const nEstBig = ringSize / safeGap;
37
- const nEst = Math.max(1, Math.min(Number(nEstBig), 1_000_000_000));
38
- // Confidence from sample size and gap variance
39
- let minGap = gaps[0];
40
- let maxGap = gaps[0];
41
- for (const g of gaps) {
42
- if (g < minGap)
43
- minGap = g;
44
- if (g > maxGap)
45
- maxGap = g;
46
- }
154
+ const { gaps, representativeGap } = collectGaps(store, m, peers, options?.filter, options?.selfCoord);
155
+ const safeGap = representativeGap > 0n ? representativeGap : RING_SIZE / BigInt(count);
156
+ const nEst = Math.max(1, Math.min(Number(RING_SIZE / safeGap), 1_000_000_000));
47
157
  const sizeFactor = Math.min(1, count / Math.max(1, m * 2));
48
- const varianceFactor = maxGap === 0n ? 0 : Number(minGap) / Number(maxGap);
49
- const confidence = Math.max(0.05, Math.min(1, 0.5 * sizeFactor + 0.5 * varianceFactor));
158
+ const confidence = Math.max(0.05, Math.min(1, 0.5 * sizeFactor + 0.5 * dispersionFactor(gaps)));
50
159
  return { n: nEst, confidence };
51
160
  }
@@ -1,4 +1,6 @@
1
+ import type { Libp2p } from 'libp2p';
1
2
  import type { SerializedPeerEntry, SerializedTable } from './store/digitree-store.js';
3
+ import { FretService as FretServiceClass } from './service/fret-service.js';
2
4
  export type FretMode = 'active' | 'passive';
3
5
  export interface FretConfig {
4
6
  k: number;
@@ -7,6 +9,14 @@ export interface FretConfig {
7
9
  profile: 'edge' | 'core';
8
10
  bootstraps?: string[];
9
11
  networkName?: string;
12
+ /**
13
+ * Consecutive failed contact attempts before a peer is marked `dead` (default 3).
14
+ *
15
+ * A "failed contact" is failure to reach the peer at all — not a peer that answered and
16
+ * refused, which is membership evidence instead. Failures closer together than the service's
17
+ * spacing window count once, so the run is genuinely spread over time.
18
+ */
19
+ deadAfterFailures?: number;
10
20
  }
11
21
  export interface NeighborSnapshotV1 {
12
22
  v: 1;
@@ -22,7 +32,7 @@ export interface NeighborSnapshotV1 {
22
32
  size_estimate?: number;
23
33
  confidence?: number;
24
34
  sig: string;
25
- metadata?: Record<string, any>;
35
+ metadata?: Record<string, unknown>;
26
36
  }
27
37
  export interface RouteAndMaybeActV1 {
28
38
  v: 1;
@@ -92,11 +102,11 @@ export interface FretService {
92
102
  commitCertificate: string;
93
103
  }>;
94
104
  report(evt: ReportEvent): void;
95
- setMetadata(metadata: Record<string, any>): void;
96
- getMetadata(peerId: string): Record<string, any> | undefined;
105
+ setMetadata(metadata: Record<string, unknown>): void;
106
+ getMetadata(peerId: string): Record<string, unknown> | undefined;
97
107
  listPeers(): Array<{
98
108
  id: string;
99
- metadata?: Record<string, any>;
109
+ metadata?: Record<string, unknown>;
100
110
  }>;
101
111
  reportNetworkSize(estimate: number, confidence: number, source?: string): void;
102
112
  getNetworkSizeEstimate(): {
@@ -109,22 +119,29 @@ export interface FretService {
109
119
  setActivityHandler(handler: ActivityHandler): void;
110
120
  iterativeLookup(key: Uint8Array, options: LookupOptions): AsyncGenerator<RouteProgress>;
111
121
  exportTable(): SerializedTable;
112
- importTable(table: SerializedTable): number;
122
+ importTable(table: SerializedTable): Promise<number>;
113
123
  }
114
124
  export type { SerializedPeerEntry, SerializedTable };
115
- export { FretService as FretServiceImpl } from './service/fret-service.js';
116
- export { seedDiscovery } from './service/discovery.js';
117
- export { FretPeerDiscovery, type FretPeerDiscoveryConfig } from './service/peer-discovery.js';
125
+ export { FretServiceClass as FretServiceImpl };
126
+ export { FretPeerDiscovery, type DiscoverySnapshotSource, type FretPeerDiscoveryInput, type FretPeerDiscoveryConfig } from './service/peer-discovery.js';
118
127
  export { Libp2pFretService, fretService } from './service/libp2p-fret-service.js';
119
128
  export { hashKey, hashPeerId } from './ring/hash.js';
120
129
  export type { RingCoord } from './ring/hash.js';
121
- export { xorDistance, clockwiseDistance, minDistance, lexLess } from './ring/distance.js';
130
+ export { clockwiseDistance, minDistance, lexLess } from './ring/distance.js';
122
131
  export { DigitreeStore } from './store/digitree-store.js';
123
- export type { PeerEntry, PeerState, MembershipState } from './store/digitree-store.js';
132
+ export type { PeerEntry, PeerPatch, PeerState, MembershipState, RingCursor, RingWalkPage } from './store/digitree-store.js';
124
133
  export { estimateSizeAndConfidence } from './estimate/size-estimator.js';
125
- export type { SizeEstimate } from './estimate/size-estimator.js';
134
+ export type { SizeEstimate, SizeEstimateOptions } from './estimate/size-estimator.js';
126
135
  export { assembleCohort } from './service/cohort.js';
127
136
  export { shouldIncludePayload, computeNearRadius } from './service/payload-heuristic.js';
128
- export { DedupCache } from './service/dedup-cache.js';
129
- export { validateTimestamp, readAllBounded } from './rpc/protocols.js';
130
- export declare function createFret(node: any, cfg?: Partial<FretConfig>): FretService;
137
+ export { DedupCache, DEDUP_TTL_MS } from './service/dedup-cache.js';
138
+ export { validateTimestamp, sendFramed, readFramed, openRpcStream, releaseRpcStream } from './rpc/protocols.js';
139
+ export { rpcRequest } from './rpc/request.js';
140
+ export type { RpcRequestOptions } from './rpc/request.js';
141
+ export type { RpcOutcome } from './rpc/outcome.js';
142
+ export type { Stream } from '@libp2p/interface';
143
+ export { registerRpcHandler, registerJsonHandler } from './rpc/protocols.js';
144
+ export type { JsonRequestHandlerOpts, JsonReplyOnlyHandlerOpts } from './rpc/protocols.js';
145
+ export { parseRouteAndMaybeAct, parseLeaveNotice, makeSnapshotParser, parsePingResponse, parseNearAnchor, parseMaybeActReply, sanitizeReplacements, parseOrThrow, ReplyRejectedError, isReplyRejectedError, } from './rpc/validate.js';
146
+ export type { Parser, SnapshotCaps } from './rpc/validate.js';
147
+ export declare function createFret(node: Libp2p, cfg?: Partial<FretConfig>): FretService;
package/dist/src/index.js CHANGED
@@ -1,16 +1,48 @@
1
- export { FretService as FretServiceImpl } from './service/fret-service.js';
2
1
  import { FretService as FretServiceClass } from './service/fret-service.js';
3
- export { seedDiscovery } from './service/discovery.js';
2
+ export { FretServiceClass as FretServiceImpl };
4
3
  export { FretPeerDiscovery } from './service/peer-discovery.js';
5
4
  export { Libp2pFretService, fretService } from './service/libp2p-fret-service.js';
6
5
  export { hashKey, hashPeerId } from './ring/hash.js';
7
- export { xorDistance, clockwiseDistance, minDistance, lexLess } from './ring/distance.js';
6
+ export { clockwiseDistance, minDistance, lexLess } from './ring/distance.js';
8
7
  export { DigitreeStore } from './store/digitree-store.js';
9
8
  export { estimateSizeAndConfidence } from './estimate/size-estimator.js';
10
9
  export { assembleCohort } from './service/cohort.js';
11
10
  export { shouldIncludePayload, computeNearRadius } from './service/payload-heuristic.js';
12
- export { DedupCache } from './service/dedup-cache.js';
13
- export { validateTimestamp, readAllBounded } from './rpc/protocols.js';
11
+ export { DedupCache, DEDUP_TTL_MS } from './service/dedup-cache.js';
12
+ // `openRpcStream` and `releaseRpcStream` are one seam: an opened stream must be released, and the
13
+ // release rule (abort once the caller's signal has fired, close otherwise) is the half a consumer
14
+ // gets wrong — a bare `close()` is unbounded against a stalled remote. Exporting only the opener
15
+ // invites a hand-rolled releaser, which is the class of bug this export exists to retire.
16
+ // `sendFramed` and `readFramed` ship together for the same reason: a consumer given only the
17
+ // reader hand-rolls the writer, and the framing has to match.
18
+ export { validateTimestamp, sendFramed, readFramed, openRpcStream, releaseRpcStream } from './rpc/protocols.js';
19
+ // `rpcRequest` is the one owner of the open/write/read/close sequence those four primitives
20
+ // compose, so a consumer sending a request never re-derives the order or the release rule. It
21
+ // never throws for a network outcome — an unreachable peer, a foreign protocol, a truncated
22
+ // reply, a busy answer and a timeout are all variants of the `RpcOutcome` it returns, and only a
23
+ // caller bug (a malformed peer id, an unsatisfiable dial policy) throws. Callers that read the
24
+ // outcome therefore branch on evidence about the peer rather than on error identity.
25
+ export { rpcRequest } from './rpc/request.js';
26
+ // The receive-side mirror of `rpcRequest`: a consumer wrapping its own protocol over the same
27
+ // libp2p node has exactly the drop-vs-abort and stream-release problem these two solve, and the
28
+ // hand-rolled copy is what leaks streams (a handler that throws mid-message leaves the inbound
29
+ // stream open forever, and streams are counted per protocol per connection). `registerRpcHandler`
30
+ // owns the budgeted success close and the synchronous error `abort()`; `registerJsonHandler`
31
+ // stacks the framed-JSON decode + parse on top of it. Their option interfaces ship with them —
32
+ // without those a consumer cannot name what it is passing.
33
+ export { registerRpcHandler, registerJsonHandler } from './rpc/protocols.js';
34
+ // The wire-shape parsers are exported for the same reason `registerRpcHandler` / `rpcRequest` /
35
+ // `openRpcStream` are: a consumer registering its own handler over this node otherwise re-derives
36
+ // the shape rules by hand, and a hand-rolled copy is what drifts. They are pure functions with no
37
+ // service state behind them. The primitives they are spelled with (peer-id parsing, bounded string
38
+ // arrays) stay module-scoped — implementation detail, not surface.
39
+ export { parseRouteAndMaybeAct, parseLeaveNotice, makeSnapshotParser, parsePingResponse, parseNearAnchor, parseMaybeActReply, sanitizeReplacements,
40
+ // The adapter that wires a `Parser` into `rpcRequest`'s `decode`. It ships with the parsers
41
+ // because passing one in raw is a silent bug rather than a compile error: `rpcRequest` has no
42
+ // `undefined` check, so a returned rejection becomes `{ kind: 'ok', value: undefined }` and
43
+ // `T` infers as `Reply | undefined`. Its error type is exported alongside so a consumer can
44
+ // recognise the rejection by identity, not by message text.
45
+ parseOrThrow, ReplyRejectedError, isReplyRejectedError, } from './rpc/validate.js';
14
46
  export function createFret(node, cfg) {
15
47
  return new FretServiceClass(node, cfg);
16
48
  }
@@ -1,4 +1,31 @@
1
- export declare function xorDistance(a: Uint8Array, b: Uint8Array): Uint8Array;
1
+ /**
2
+ * Magnitude compare of two big-endian unsigned integers: `a < b`.
3
+ *
4
+ * Operands are right-aligned — a shorter array is read as zero-padded on the
5
+ * *left*, which is what it is numerically, and matches the arithmetic in
6
+ * `clockwiseDistance`. Comparing from index 0 would instead read a 16-byte
7
+ * value as a 32-byte one scaled by 2^128. Equal-length inputs (every caller
8
+ * today) compare byte-for-byte from the top, unchanged.
9
+ */
2
10
  export declare function lexLess(a: Uint8Array, b: Uint8Array): boolean;
3
11
  export declare function clockwiseDistance(a: Uint8Array, b: Uint8Array): Uint8Array;
12
+ /**
13
+ * True ring distance: the shorter of the two arcs between a and b, on a ring of
14
+ * 2^(8·len). This is the single distance metric FRET uses — routing, payload
15
+ * inclusion, and the relevance sparsity model all measure with it, so the
16
+ * wrap-around point behaves like every other point on the ring. Maximum value
17
+ * is 2^(8·len − 1) (half the ring), not 2^(8·len) − 1.
18
+ */
4
19
  export declare function minDistance(a: Uint8Array, b: Uint8Array): Uint8Array;
20
+ /**
21
+ * Position of a distance magnitude on a log scale, normalized to [0,1]: 0 for a
22
+ * zero distance, rising toward 1 as the leading-zero-bit count falls. Shared by
23
+ * the next-hop cost function and the relevance sparsity model so both read the
24
+ * ring at the same resolution.
25
+ *
26
+ * A `minDistance` result reaches exactly 1 only at the antipode (2^255, whose
27
+ * top bit is set); every other pair lands at 1 − 1/256 or below. The value is
28
+ * consumed as a relative position, so that near-unreachable top step needs no
29
+ * rescaling.
30
+ */
31
+ export declare function normalizedLogMagnitude(dist: Uint8Array): number;
@@ -1,18 +1,17 @@
1
- export function xorDistance(a, b) {
2
- const len = Math.max(a.length, b.length);
3
- const out = new Uint8Array(len);
4
- for (let i = 0; i < len; i++) {
5
- const ai = a[a.length - 1 - i] ?? 0;
6
- const bi = b[b.length - 1 - i] ?? 0;
7
- out[len - 1 - i] = ai ^ bi;
8
- }
9
- return out;
10
- }
1
+ /**
2
+ * Magnitude compare of two big-endian unsigned integers: `a < b`.
3
+ *
4
+ * Operands are right-aligned — a shorter array is read as zero-padded on the
5
+ * *left*, which is what it is numerically, and matches the arithmetic in
6
+ * `clockwiseDistance`. Comparing from index 0 would instead read a 16-byte
7
+ * value as a 32-byte one scaled by 2^128. Equal-length inputs (every caller
8
+ * today) compare byte-for-byte from the top, unchanged.
9
+ */
11
10
  export function lexLess(a, b) {
12
11
  const len = Math.max(a.length, b.length);
13
- for (let i = 0; i < len; i++) {
14
- const av = a[i] ?? 0;
15
- const bv = b[i] ?? 0;
12
+ for (let i = len - 1; i >= 0; i--) {
13
+ const av = a[a.length - 1 - i] ?? 0;
14
+ const bv = b[b.length - 1 - i] ?? 0;
16
15
  if (av < bv)
17
16
  return true;
18
17
  if (av > bv)
@@ -41,7 +40,45 @@ export function clockwiseDistance(a, b) {
41
40
  }
42
41
  return out;
43
42
  }
43
+ /**
44
+ * True ring distance: the shorter of the two arcs between a and b, on a ring of
45
+ * 2^(8·len). This is the single distance metric FRET uses — routing, payload
46
+ * inclusion, and the relevance sparsity model all measure with it, so the
47
+ * wrap-around point behaves like every other point on the ring. Maximum value
48
+ * is 2^(8·len − 1) (half the ring), not 2^(8·len) − 1.
49
+ */
44
50
  export function minDistance(a, b) {
45
- // For routing we use absolute ring distance via XOR by default
46
- return xorDistance(a, b);
51
+ // NOTE: allocates both arcs (2 × 32 bytes) to discard one, on a path walked
52
+ // per routing candidate and per relevance update. Measured cost has not been
53
+ // an issue; if distance ever shows up in a profile, compare the arcs in place
54
+ // (cw < half-ring ⇒ cw is the shorter one) and build only the winner.
55
+ const cw = clockwiseDistance(a, b);
56
+ const ccw = clockwiseDistance(b, a);
57
+ return lexLess(ccw, cw) ? ccw : cw;
58
+ }
59
+ /**
60
+ * Position of a distance magnitude on a log scale, normalized to [0,1]: 0 for a
61
+ * zero distance, rising toward 1 as the leading-zero-bit count falls. Shared by
62
+ * the next-hop cost function and the relevance sparsity model so both read the
63
+ * ring at the same resolution.
64
+ *
65
+ * A `minDistance` result reaches exactly 1 only at the antipode (2^255, whose
66
+ * top bit is set); every other pair lands at 1 − 1/256 or below. The value is
67
+ * consumed as a relative position, so that near-unreachable top step needs no
68
+ * rescaling.
69
+ */
70
+ export function normalizedLogMagnitude(dist) {
71
+ const totalBits = dist.length * 8;
72
+ if (totalBits === 0)
73
+ return 0;
74
+ let lzBits = 0;
75
+ for (const byte of dist) {
76
+ if (byte === 0) {
77
+ lzBits += 8;
78
+ continue;
79
+ }
80
+ lzBits += Math.clz32(byte) - 24; // clz32 counts over 32 bits; drop the 24 above the byte
81
+ break;
82
+ }
83
+ return 1 - lzBits / totalBits;
47
84
  }
@@ -4,6 +4,18 @@ export declare const COORD_BYTES = 32;
4
4
  export type RingCoord = Uint8Array;
5
5
  export declare function hashPeerId(peerId: PeerId): Promise<RingCoord>;
6
6
  export declare function hashKey(key: Uint8Array): Promise<RingCoord>;
7
+ /**
8
+ * Fixed-length 64 hex chars — the encoding the routing store's tree key is built from.
9
+ *
10
+ * This is the hottest pure function in FRET: `DigitreeStore` embeds the hex coordinate in
11
+ * every tree key, so it runs once per store write *and* once per ring walk (each walk seeks
12
+ * by `hex(coord)`), which puts it under neighbor exchange, cohort assembly, next-hop
13
+ * selection and size estimation alike. Formatting each byte with
14
+ * `toString(16).padStart(2, '0')` allocates two throwaway strings per byte — 64 per
15
+ * coordinate — and measured **38% of total CPU** in the N=100 simulation spec. The lookup
16
+ * table is therefore load-bearing rather than a micro-optimization: the output is identical,
17
+ * but the per-byte allocations are gone.
18
+ */
7
19
  export declare function coordToHex(coord: RingCoord): string;
8
20
  export declare function hexToCoord(hex: string): RingCoord;
9
21
  export declare function coordToBase64url(coord: RingCoord): string;
@@ -13,16 +13,31 @@ export async function hashKey(key) {
13
13
  const digest = await sha256.encode(key);
14
14
  return digest;
15
15
  }
16
+ /** Byte value → its fixed-width hex pair. Built once; see {@link coordToHex}. */
17
+ const HEX_PAIR = Array.from({ length: 256 }, (_, b) => b.toString(16).padStart(2, '0'));
18
+ /**
19
+ * Fixed-length 64 hex chars — the encoding the routing store's tree key is built from.
20
+ *
21
+ * This is the hottest pure function in FRET: `DigitreeStore` embeds the hex coordinate in
22
+ * every tree key, so it runs once per store write *and* once per ring walk (each walk seeks
23
+ * by `hex(coord)`), which puts it under neighbor exchange, cohort assembly, next-hop
24
+ * selection and size estimation alike. Formatting each byte with
25
+ * `toString(16).padStart(2, '0')` allocates two throwaway strings per byte — 64 per
26
+ * coordinate — and measured **38% of total CPU** in the N=100 simulation spec. The lookup
27
+ * table is therefore load-bearing rather than a micro-optimization: the output is identical,
28
+ * but the per-byte allocations are gone.
29
+ */
16
30
  export function coordToHex(coord) {
17
- // Fixed-length 64 hex chars
18
31
  let s = '';
19
- for (let i = 0; i < coord.length; i++) {
20
- const b = coord[i].toString(16).padStart(2, '0');
21
- s += b;
22
- }
32
+ for (let i = 0; i < coord.length; i++)
33
+ s += HEX_PAIR[coord[i]];
23
34
  return s;
24
35
  }
36
+ const HEX_COORD_RE = new RegExp(`^[0-9a-fA-F]{${COORD_BYTES * 2}}$`);
25
37
  export function hexToCoord(hex) {
38
+ if (!HEX_COORD_RE.test(hex)) {
39
+ throw new Error(`hexToCoord: expected ${COORD_BYTES * 2} hex chars, got ${JSON.stringify(hex)}`);
40
+ }
26
41
  const out = new Uint8Array(COORD_BYTES);
27
42
  for (let i = 0; i < COORD_BYTES; i++) {
28
43
  out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
@@ -33,5 +48,9 @@ export function coordToBase64url(coord) {
33
48
  return u8ToString(coord, 'base64url');
34
49
  }
35
50
  export function base64urlToCoord(s) {
36
- return u8FromString(s, 'base64url');
51
+ const coord = u8FromString(s, 'base64url');
52
+ if (coord.length !== COORD_BYTES) {
53
+ throw new Error(`base64urlToCoord: expected ${COORD_BYTES} bytes, got ${coord.length}`);
54
+ }
55
+ return coord;
37
56
  }
@@ -1,9 +1,13 @@
1
1
  import type { Libp2p } from 'libp2p';
2
+ import type { RpcOutcome } from './outcome.js';
2
3
  export interface LeaveNoticeV1 {
3
4
  v: 1;
4
5
  from: string;
5
6
  replacements?: string[];
6
7
  timestamp: number;
7
8
  }
8
- export declare function registerLeave(node: Libp2p, onLeave: (notice: LeaveNoticeV1) => Promise<void> | void, protocol?: string): void;
9
- export declare function sendLeave(node: Libp2p, peerIdStr: string, notice: LeaveNoticeV1, protocol?: string): Promise<void>;
9
+ export declare function registerLeave(node: Libp2p, onLeave: (notice: LeaveNoticeV1) => Promise<void> | void, protocol?: string, onIdentityMismatch?: (claimed: string, actual: string) => void, onMalformed?: (reason: 'decode' | 'parse') => void): Promise<void>;
10
+ export declare function sendLeave(node: Libp2p, peerIdStr: string, notice: LeaveNoticeV1, protocol?: string, opts?: {
11
+ signal?: AbortSignal;
12
+ timeoutMs?: number;
13
+ }): Promise<RpcOutcome<undefined>>;