@optimystic/db-core 0.21.0 → 0.24.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.
Files changed (185) hide show
  1. package/README.md +336 -336
  2. package/dist/src/btree/btree.d.ts +2 -1
  3. package/dist/src/btree/btree.d.ts.map +1 -1
  4. package/dist/src/btree/btree.js +1 -1
  5. package/dist/src/btree/btree.js.map +1 -1
  6. package/dist/src/chain/chain.d.ts +1 -1
  7. package/dist/src/chain/chain.d.ts.map +1 -1
  8. package/dist/src/chain/chain.js +1 -1
  9. package/dist/src/chain/chain.js.map +1 -1
  10. package/dist/src/cluster/structs.d.ts +39 -1
  11. package/dist/src/cluster/structs.d.ts.map +1 -1
  12. package/dist/src/cluster/structs.js +24 -0
  13. package/dist/src/cluster/structs.js.map +1 -1
  14. package/dist/src/collection/collection.d.ts +20 -1
  15. package/dist/src/collection/collection.d.ts.map +1 -1
  16. package/dist/src/collection/collection.js +31 -4
  17. package/dist/src/collection/collection.js.map +1 -1
  18. package/dist/src/collections/diary/diary.d.ts.map +1 -1
  19. package/dist/src/collections/diary/diary.js +2 -1
  20. package/dist/src/collections/diary/diary.js.map +1 -1
  21. package/dist/src/collections/diary/struct.js +1 -1
  22. package/dist/src/collections/diary/struct.js.map +1 -1
  23. package/dist/src/collections/tree/collection-trunk.js +1 -1
  24. package/dist/src/collections/tree/collection-trunk.js.map +1 -1
  25. package/dist/src/collections/tree/struct.d.ts +1 -1
  26. package/dist/src/collections/tree/struct.d.ts.map +1 -1
  27. package/dist/src/collections/tree/struct.js +2 -1
  28. package/dist/src/collections/tree/struct.js.map +1 -1
  29. package/dist/src/collections/tree/tree.d.ts +5 -0
  30. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  31. package/dist/src/collections/tree/tree.js +7 -0
  32. package/dist/src/collections/tree/tree.js.map +1 -1
  33. package/dist/src/log/log.d.ts +1 -1
  34. package/dist/src/log/log.d.ts.map +1 -1
  35. package/dist/src/log/log.js +2 -2
  36. package/dist/src/log/log.js.map +1 -1
  37. package/dist/src/network/i-peer-network.d.ts +16 -0
  38. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  39. package/dist/src/network/struct.d.ts +39 -2
  40. package/dist/src/network/struct.d.ts.map +1 -1
  41. package/dist/src/network/struct.js +18 -0
  42. package/dist/src/network/struct.js.map +1 -1
  43. package/dist/src/testing/test-transactor.d.ts +95 -8
  44. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  45. package/dist/src/testing/test-transactor.js +133 -12
  46. package/dist/src/testing/test-transactor.js.map +1 -1
  47. package/dist/src/transaction/coordinator.d.ts.map +1 -1
  48. package/dist/src/transaction/coordinator.js +2 -1
  49. package/dist/src/transaction/coordinator.js.map +1 -1
  50. package/dist/src/transaction/transaction.d.ts +1 -1
  51. package/dist/src/transaction/transaction.js +1 -1
  52. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  53. package/dist/src/transactor/network-transactor.js +57 -18
  54. package/dist/src/transactor/network-transactor.js.map +1 -1
  55. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  56. package/dist/src/transactor/transactor-source.js +25 -2
  57. package/dist/src/transactor/transactor-source.js.map +1 -1
  58. package/dist/src/transform/cache-source.js +1 -1
  59. package/dist/src/transform/cache-source.js.map +1 -1
  60. package/dist/src/transform/helpers.d.ts +6 -1
  61. package/dist/src/transform/helpers.d.ts.map +1 -1
  62. package/dist/src/transform/helpers.js +7 -6
  63. package/dist/src/transform/helpers.js.map +1 -1
  64. package/dist/src/transform/tracker.d.ts.map +1 -1
  65. package/dist/src/transform/tracker.js +2 -1
  66. package/dist/src/transform/tracker.js.map +1 -1
  67. package/package.json +1 -1
  68. package/src/btree/btree.ts +2 -1
  69. package/src/chain/chain.ts +2 -1
  70. package/src/cluster/membership.ts +85 -85
  71. package/src/cluster/structs.ts +43 -4
  72. package/src/cohort-topic/addressing.ts +120 -120
  73. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  74. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  75. package/src/cohort-topic/antidos/index.ts +5 -5
  76. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  77. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  78. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  79. package/src/cohort-topic/antiflood/index.ts +2 -2
  80. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  81. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  82. package/src/cohort-topic/coldstart.ts +237 -237
  83. package/src/cohort-topic/dmax.ts +88 -88
  84. package/src/cohort-topic/gossip/bus.ts +254 -254
  85. package/src/cohort-topic/gossip/index.ts +3 -3
  86. package/src/cohort-topic/gossip/records.ts +45 -45
  87. package/src/cohort-topic/gossip/view.ts +91 -91
  88. package/src/cohort-topic/index.ts +20 -20
  89. package/src/cohort-topic/load/barometer.ts +134 -134
  90. package/src/cohort-topic/load/index.ts +1 -1
  91. package/src/cohort-topic/member-engine.ts +430 -430
  92. package/src/cohort-topic/membership/index.ts +3 -3
  93. package/src/cohort-topic/membership/publisher.ts +163 -163
  94. package/src/cohort-topic/membership/source.ts +41 -41
  95. package/src/cohort-topic/membership/verifier.ts +461 -461
  96. package/src/cohort-topic/ports.ts +157 -157
  97. package/src/cohort-topic/promotion.ts +405 -405
  98. package/src/cohort-topic/registration/bytes.ts +37 -37
  99. package/src/cohort-topic/registration/handoff.ts +154 -154
  100. package/src/cohort-topic/registration/index.ts +6 -6
  101. package/src/cohort-topic/registration/renewal.ts +495 -495
  102. package/src/cohort-topic/registration/sharding.ts +61 -61
  103. package/src/cohort-topic/registration/store.ts +81 -81
  104. package/src/cohort-topic/registration/types.ts +91 -91
  105. package/src/cohort-topic/ring-hash.ts +50 -50
  106. package/src/cohort-topic/service.ts +416 -416
  107. package/src/cohort-topic/sig/index.ts +2 -2
  108. package/src/cohort-topic/sig/payloads.ts +59 -59
  109. package/src/cohort-topic/sig/threshold.ts +64 -64
  110. package/src/cohort-topic/tiers.ts +74 -74
  111. package/src/cohort-topic/traffic.ts +233 -233
  112. package/src/cohort-topic/walk.ts +326 -326
  113. package/src/cohort-topic/willingness.ts +237 -237
  114. package/src/cohort-topic/wire/codec.ts +216 -216
  115. package/src/cohort-topic/wire/index.ts +18 -18
  116. package/src/cohort-topic/wire/payloads.ts +126 -126
  117. package/src/cohort-topic/wire/primitives.ts +188 -188
  118. package/src/cohort-topic/wire/types.ts +475 -475
  119. package/src/cohort-topic/wire/validate.ts +512 -512
  120. package/src/collection/collection-type-registry.ts +37 -37
  121. package/src/collection/collection.ts +32 -4
  122. package/src/collections/diary/diary.ts +68 -67
  123. package/src/collections/diary/struct.ts +1 -1
  124. package/src/collections/tree/collection-trunk.ts +1 -1
  125. package/src/collections/tree/readme.md +4 -0
  126. package/src/collections/tree/struct.ts +3 -1
  127. package/src/collections/tree/tree.ts +320 -312
  128. package/src/log/log.ts +2 -2
  129. package/src/matchmaking/capability-filter.ts +45 -45
  130. package/src/matchmaking/config.ts +98 -98
  131. package/src/matchmaking/index.ts +21 -21
  132. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  133. package/src/matchmaking/provider.ts +123 -123
  134. package/src/matchmaking/query-eval.ts +105 -105
  135. package/src/matchmaking/seeker-walk.ts +127 -127
  136. package/src/matchmaking/seeker.ts +86 -86
  137. package/src/matchmaking/topic-anchor.ts +90 -90
  138. package/src/matchmaking/voting-quorum.ts +394 -394
  139. package/src/matchmaking/wire.ts +603 -603
  140. package/src/network/i-peer-network.ts +17 -0
  141. package/src/network/stale-failure.ts +43 -43
  142. package/src/network/struct.ts +41 -2
  143. package/src/network/types.ts +37 -37
  144. package/src/reactivity/backfill.ts +220 -220
  145. package/src/reactivity/backpressure.ts +191 -191
  146. package/src/reactivity/checkpoint.ts +308 -308
  147. package/src/reactivity/config.ts +172 -172
  148. package/src/reactivity/dedupe.ts +132 -132
  149. package/src/reactivity/forwarder.ts +87 -87
  150. package/src/reactivity/index.ts +34 -34
  151. package/src/reactivity/notification.ts +123 -123
  152. package/src/reactivity/policy.ts +79 -79
  153. package/src/reactivity/push-state.ts +310 -310
  154. package/src/reactivity/recover.ts +153 -153
  155. package/src/reactivity/replay-buffer.ts +141 -141
  156. package/src/reactivity/resume.ts +549 -549
  157. package/src/reactivity/rotation.ts +415 -415
  158. package/src/reactivity/subscriber.ts +132 -132
  159. package/src/reactivity/subscription.ts +66 -66
  160. package/src/reactivity/topic-anchor.ts +71 -71
  161. package/src/reactivity/verify.ts +73 -73
  162. package/src/reactivity/wire-validate.ts +13 -13
  163. package/src/reactivity/wire.ts +224 -224
  164. package/src/testing/async-wait.ts +65 -65
  165. package/src/testing/index.ts +2 -2
  166. package/src/testing/test-transactor.ts +638 -489
  167. package/src/transaction/coordinator.ts +2 -1
  168. package/src/transaction/errors.ts +91 -91
  169. package/src/transaction/operations-hash.ts +196 -196
  170. package/src/transaction/read-dependency-collector.ts +78 -78
  171. package/src/transaction/transaction.ts +1 -1
  172. package/src/transactor/change-notifier.ts +80 -80
  173. package/src/transactor/index.ts +5 -5
  174. package/src/transactor/network-transactor.ts +58 -19
  175. package/src/transactor/transactor-source.ts +25 -2
  176. package/src/transform/atomic-proxy.ts +92 -92
  177. package/src/transform/cache-source.ts +1 -1
  178. package/src/transform/helpers.ts +159 -158
  179. package/src/transform/tracker.ts +2 -1
  180. package/src/utility/backoff.ts +95 -95
  181. package/src/utility/batch-coordinator.ts +191 -191
  182. package/dist/src/transaction/context.d.ts +0 -60
  183. package/dist/src/transaction/context.d.ts.map +0 -1
  184. package/dist/src/transaction/context.js +0 -91
  185. package/dist/src/transaction/context.js.map +0 -1
@@ -1,237 +1,237 @@
1
- /**
2
- * Cohort-topic substrate — per-member willingness / admission control.
3
- *
4
- * Transcribed from `docs/cohort-topic.md` §Willingness and §Tier ladder, folded back from the
5
- * simulator-validated `packages/substrate-simulator/src/{willingness,backoff}.ts`. When FRET's
6
- * `RouteAndMaybeAct` lands a `RegisterV1` on a member, that member runs this check and returns one
7
- * of three outcomes:
8
- *
9
- * - **{@link AcceptedOutcome}** — the routed member is willing; it becomes `primary`.
10
- * - **{@link UnwillingMemberOutcome}** — the routed member is personally unwilling, but the gossiped
11
- * willingness vector says siblings will serve this tier; the caller retries a named sibling at the
12
- * *same* coord (spatial move within the cohort).
13
- * - **{@link UnwillingCohortOutcome}** — fewer than a quorum of members are willing to serve the
14
- * tier, so the cohort declines the tier entirely; the caller backs off in *time* (no spatial
15
- * move — see §Why the caller doesn't walk on UnwillingCohort). The `retryAfterMs` follows the
16
- * capped-doubling curve in {@link backoffRetryMs}.
17
- *
18
- * A member's *live* willingness for a tier is: its profile serves the tier, the tier's load bucket
19
- * is below `overloadBucket`, **and** it is under its per-tier primary-topic budget. The coarse 1-bit
20
- * gossiped willingness vector ({@link selfWillingnessBits}) carries only profile-∧-load — the budget
21
- * is a per-registration gate applied live at the routed member. (Resolved open question: willingness
22
- * stays at **1 bit per tier** — no finer T3 gradations; the load bucket already supplies coarse load.)
23
- */
24
-
25
- import { createLoadBarometer, DEFAULT_OVERLOAD_BUCKET, type LoadBarometerState } from "./load/barometer.js";
26
- import type { CohortView } from "./gossip/view.js";
27
- import { b64urlToBytes } from "./wire/codec.js";
28
- import type { RegisterV1 } from "./wire/types.js";
29
- import { ALL_TIERS, type NodeProfile, type Tier } from "./tiers.js";
30
-
31
- /** The routed member is willing and will serve as `primary`. */
32
- export interface AcceptedOutcome {
33
- readonly kind: "accepted";
34
- }
35
-
36
- /** Routed member unwilling; these siblings (raw peer-id bytes) will serve — retry one at the same coord. */
37
- export interface UnwillingMemberOutcome {
38
- readonly kind: "unwilling_member";
39
- readonly candidateMembers: Uint8Array[];
40
- }
41
-
42
- /** No quorum of willing members; back off `retryAfterMs` in time and retry from `d_max`. */
43
- export interface UnwillingCohortOutcome {
44
- readonly kind: "unwilling_cohort";
45
- readonly retryAfterMs: number;
46
- }
47
-
48
- export type WillingnessOutcome = AcceptedOutcome | UnwillingMemberOutcome | UnwillingCohortOutcome;
49
-
50
- /** Per-member willingness / admission check. */
51
- export interface WillingnessCheck {
52
- /** Classify `reg` routed onto this member, given the member's static profile and the clock. */
53
- evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome;
54
- }
55
-
56
- // --- willingness-vector bit packing (folded back from simulator `willingnessBits`) ---
57
-
58
- /** Test bit `tier` (T0 = bit 0 … T3 = bit 3) of a packed 4-bit willingness vector. */
59
- export function tierBit(bits: number, tier: Tier): boolean {
60
- return (bits & (1 << tier)) !== 0;
61
- }
62
-
63
- /** Pack a per-tier predicate into a 4-bit willingness vector (bit `t` set iff `willing(t)`). */
64
- export function packWillingnessBits(willing: (tier: Tier) => boolean): number {
65
- let bits = 0;
66
- for (const t of ALL_TIERS) {
67
- if (willing(t)) bits |= 1 << t;
68
- }
69
- return bits;
70
- }
71
-
72
- /** The packed vector as a single hex nibble — the `CohortGossipV1.willingnessBits` wire form. */
73
- export function willingnessBitsHex(bits: number): string {
74
- return (bits & 0xf).toString(16);
75
- }
76
-
77
- /**
78
- * This member's gossiped (coarse) willingness vector: bit `t` set iff the profile serves `t` and the
79
- * tier is not shed under load. The per-tier primary-topic budget is deliberately **not** folded in
80
- * — it is a live per-registration gate, not a gossiped signal (§Willingness: the gossiped vector is
81
- * "one bit per tier per member… refreshed every gossip round").
82
- */
83
- export function selfWillingnessBits(self: NodeProfile, barometer: LoadBarometerState): number {
84
- return packWillingnessBits((t) => self.willingTiers.has(t) && barometer.loadWilling(t));
85
- }
86
-
87
- // --- exponential UnwillingCohort back-off (settled params, docs §Willingness L260-263) ---
88
-
89
- export interface BackoffConfig {
90
- /** First-rejection delay (ms). Default 1000. */
91
- readonly baseMs: number;
92
- /** Geometric growth per rejection. Default 2 (doubling). */
93
- readonly factor: number;
94
- /** Hard ceiling on a single delay (ms). Default 60000. */
95
- readonly capMs: number;
96
- }
97
-
98
- /** Settled back-off curve (`docs/cohort-topic.md` §Willingness): `base = 1 s`, `factor = 2`, `cap = 60 s`. */
99
- export const DEFAULT_BACKOFF_CONFIG: BackoffConfig = {
100
- baseMs: 1000,
101
- factor: 2,
102
- capMs: 60_000,
103
- };
104
-
105
- /**
106
- * `retryAfter` for the `attempt`-th rejection (0-based): `⌊base · factor^attempt⌋` capped at `capMs`.
107
- * The capped doubling bounds the rejections a participant suffers across an overload window at
108
- * `O(log(window/base))` (≤ ~6 to span 60 s) rather than the `window/base` a fixed interval incurs.
109
- * As survivors of a burst back off geometrically, offered load sheds and accepted/sec holds at the
110
- * willing-quorum capacity without a cascade.
111
- */
112
- export function backoffRetryMs(attempt: number, config: BackoffConfig = DEFAULT_BACKOFF_CONFIG): number {
113
- if (!Number.isInteger(attempt) || attempt < 0) {
114
- throw new RangeError(`attempt must be a non-negative integer, got ${attempt}`);
115
- }
116
- const raw = config.baseMs * Math.pow(config.factor, attempt);
117
- return Math.min(Math.floor(raw), config.capMs);
118
- }
119
-
120
- // --- willingness check ---
121
-
122
- export interface WillingnessConfig {
123
- /** Cohort size `k` (drives the default quorum). Default 16. */
124
- cohortSize?: number;
125
- /**
126
- * Members that must be willing to serve a tier for the cohort to take it on. Default: strict
127
- * majority of `cohortSize` (`⌊k/2⌋ + 1`). Not pinned by `docs/cohort-topic.md` — §Tier ladder
128
- * only requires "a quorum"; configurable here, revisitable by the Edge/Core policy ticket.
129
- */
130
- quorum?: number;
131
- /** Load bucket at/above which a tier is shed. Default {@link DEFAULT_OVERLOAD_BUCKET} (6). */
132
- overloadBucket?: number;
133
- /**
134
- * Max topics this member may be `primary` for at a single tier. Default 2048 (`topics_max`).
135
- * The doc names the per-member per-tier budget but pins no number; configurable here.
136
- */
137
- maxPrimaryTopicsPerTier?: number;
138
- /** Back-off curve for `unwilling_cohort` retries. Default {@link DEFAULT_BACKOFF_CONFIG}. */
139
- backoff?: BackoffConfig;
140
- }
141
-
142
- export interface WillingnessDeps {
143
- /** This member's own per-tier load barometer (live willingness uses its buckets). */
144
- barometer: LoadBarometerState;
145
- /** Merged per-member gossip view — the sibling willingness vectors. */
146
- view: CohortView;
147
- /** This member's own id, base64url (the `fromMember` key) — excluded from the sibling scan. */
148
- selfMember: string;
149
- /** Count of topics this member is already `primary` for at `tier` (the budget gate input). */
150
- primaryTopicCount: (tier: Tier) => number;
151
- /**
152
- * Rejection-attempt count for `reg`, driving the `retryAfter` curve. The anti-DoS rate limiter
153
- * owns the counter; the *curve* lives here. Default `() => 0` (first-rejection delay).
154
- */
155
- attempts?: (reg: RegisterV1) => number;
156
- config?: WillingnessConfig;
157
- }
158
-
159
- /** Default quorum: strict majority of the cohort. */
160
- export function defaultQuorum(cohortSize: number): number {
161
- return Math.floor(cohortSize / 2) + 1;
162
- }
163
-
164
- class GossipWillingnessCheck implements WillingnessCheck {
165
- private readonly quorum: number;
166
- private readonly overloadBucket: number;
167
- private readonly maxPrimaryTopicsPerTier: number;
168
- private readonly backoff: BackoffConfig;
169
- private readonly attempts: (reg: RegisterV1) => number;
170
-
171
- constructor(private readonly deps: WillingnessDeps) {
172
- const cfg = deps.config ?? {};
173
- const cohortSize = cfg.cohortSize ?? 16;
174
- this.quorum = cfg.quorum ?? defaultQuorum(cohortSize);
175
- this.overloadBucket = cfg.overloadBucket ?? DEFAULT_OVERLOAD_BUCKET;
176
- this.maxPrimaryTopicsPerTier = cfg.maxPrimaryTopicsPerTier ?? 2048;
177
- this.backoff = cfg.backoff ?? DEFAULT_BACKOFF_CONFIG;
178
- this.attempts = deps.attempts ?? ((): number => 0);
179
- }
180
-
181
- evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome {
182
- const tier = reg.tier as Tier;
183
- if (!ALL_TIERS.includes(tier)) {
184
- // An op tier outside T0..T3 is not serviceable; treat as a cohort-level decline.
185
- return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
186
- }
187
-
188
- const selfWilling = this.selfLiveWilling(self, tier);
189
- const siblings = this.willingSiblings(tier);
190
- const willingCount = siblings.length + (selfWilling ? 1 : 0);
191
-
192
- // Quorum gate (§Tier ladder): the cohort takes on tier-T duties only if a quorum of members
193
- // is willing; otherwise registrations get UnwillingCohort and the caller backs off in time.
194
- if (willingCount < this.quorum) {
195
- return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
196
- }
197
- if (selfWilling) {
198
- return { kind: "accepted" };
199
- }
200
- // Quorum holds and self is unwilling → some sibling will serve.
201
- return { kind: "unwilling_member", candidateMembers: siblings.map((m) => b64urlToBytes(m)) };
202
- }
203
-
204
- /** Live willingness of *this* member for `tier`: profile ∧ load-under-threshold ∧ under budget. */
205
- private selfLiveWilling(self: NodeProfile, tier: Tier): boolean {
206
- return (
207
- self.willingTiers.has(tier) &&
208
- this.deps.barometer.bucket(tier) < this.overloadBucket &&
209
- this.deps.primaryTopicCount(tier) < this.maxPrimaryTopicsPerTier
210
- );
211
- }
212
-
213
- /** Sibling member keys (base64url) whose *gossiped* willingness vector serves `tier`. */
214
- private willingSiblings(tier: Tier): string[] {
215
- const out: string[] = [];
216
- for (const [member, contribution] of this.deps.view.all()) {
217
- if (member === this.deps.selfMember) continue; // self counted via live willingness
218
- if (tierBit(contribution.willingness, tier)) {
219
- out.push(member);
220
- }
221
- }
222
- return out;
223
- }
224
- }
225
-
226
- /** Build a {@link WillingnessCheck} over the injected barometer + gossip view + budget source. */
227
- export function createWillingnessCheck(deps: WillingnessDeps): WillingnessCheck {
228
- return new GossipWillingnessCheck(deps);
229
- }
230
-
231
- /** Convenience: a willingness check with a fresh idle barometer (tests / single-tier callers). */
232
- export function createWillingnessCheckWithIdleBarometer(
233
- deps: Omit<WillingnessDeps, "barometer">,
234
- ): { check: WillingnessCheck; barometer: LoadBarometerState } {
235
- const barometer = createLoadBarometer({ overloadBucket: deps.config?.overloadBucket });
236
- return { check: createWillingnessCheck({ ...deps, barometer }), barometer };
237
- }
1
+ /**
2
+ * Cohort-topic substrate — per-member willingness / admission control.
3
+ *
4
+ * Transcribed from `docs/cohort-topic.md` §Willingness and §Tier ladder, folded back from the
5
+ * simulator-validated `packages/substrate-simulator/src/{willingness,backoff}.ts`. When FRET's
6
+ * `RouteAndMaybeAct` lands a `RegisterV1` on a member, that member runs this check and returns one
7
+ * of three outcomes:
8
+ *
9
+ * - **{@link AcceptedOutcome}** — the routed member is willing; it becomes `primary`.
10
+ * - **{@link UnwillingMemberOutcome}** — the routed member is personally unwilling, but the gossiped
11
+ * willingness vector says siblings will serve this tier; the caller retries a named sibling at the
12
+ * *same* coord (spatial move within the cohort).
13
+ * - **{@link UnwillingCohortOutcome}** — fewer than a quorum of members are willing to serve the
14
+ * tier, so the cohort declines the tier entirely; the caller backs off in *time* (no spatial
15
+ * move — see §Why the caller doesn't walk on UnwillingCohort). The `retryAfterMs` follows the
16
+ * capped-doubling curve in {@link backoffRetryMs}.
17
+ *
18
+ * A member's *live* willingness for a tier is: its profile serves the tier, the tier's load bucket
19
+ * is below `overloadBucket`, **and** it is under its per-tier primary-topic budget. The coarse 1-bit
20
+ * gossiped willingness vector ({@link selfWillingnessBits}) carries only profile-∧-load — the budget
21
+ * is a per-registration gate applied live at the routed member. (Resolved open question: willingness
22
+ * stays at **1 bit per tier** — no finer T3 gradations; the load bucket already supplies coarse load.)
23
+ */
24
+
25
+ import { createLoadBarometer, DEFAULT_OVERLOAD_BUCKET, type LoadBarometerState } from "./load/barometer.js";
26
+ import type { CohortView } from "./gossip/view.js";
27
+ import { b64urlToBytes } from "./wire/codec.js";
28
+ import type { RegisterV1 } from "./wire/types.js";
29
+ import { ALL_TIERS, type NodeProfile, type Tier } from "./tiers.js";
30
+
31
+ /** The routed member is willing and will serve as `primary`. */
32
+ export interface AcceptedOutcome {
33
+ readonly kind: "accepted";
34
+ }
35
+
36
+ /** Routed member unwilling; these siblings (raw peer-id bytes) will serve — retry one at the same coord. */
37
+ export interface UnwillingMemberOutcome {
38
+ readonly kind: "unwilling_member";
39
+ readonly candidateMembers: Uint8Array[];
40
+ }
41
+
42
+ /** No quorum of willing members; back off `retryAfterMs` in time and retry from `d_max`. */
43
+ export interface UnwillingCohortOutcome {
44
+ readonly kind: "unwilling_cohort";
45
+ readonly retryAfterMs: number;
46
+ }
47
+
48
+ export type WillingnessOutcome = AcceptedOutcome | UnwillingMemberOutcome | UnwillingCohortOutcome;
49
+
50
+ /** Per-member willingness / admission check. */
51
+ export interface WillingnessCheck {
52
+ /** Classify `reg` routed onto this member, given the member's static profile and the clock. */
53
+ evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome;
54
+ }
55
+
56
+ // --- willingness-vector bit packing (folded back from simulator `willingnessBits`) ---
57
+
58
+ /** Test bit `tier` (T0 = bit 0 … T3 = bit 3) of a packed 4-bit willingness vector. */
59
+ export function tierBit(bits: number, tier: Tier): boolean {
60
+ return (bits & (1 << tier)) !== 0;
61
+ }
62
+
63
+ /** Pack a per-tier predicate into a 4-bit willingness vector (bit `t` set iff `willing(t)`). */
64
+ export function packWillingnessBits(willing: (tier: Tier) => boolean): number {
65
+ let bits = 0;
66
+ for (const t of ALL_TIERS) {
67
+ if (willing(t)) bits |= 1 << t;
68
+ }
69
+ return bits;
70
+ }
71
+
72
+ /** The packed vector as a single hex nibble — the `CohortGossipV1.willingnessBits` wire form. */
73
+ export function willingnessBitsHex(bits: number): string {
74
+ return (bits & 0xf).toString(16);
75
+ }
76
+
77
+ /**
78
+ * This member's gossiped (coarse) willingness vector: bit `t` set iff the profile serves `t` and the
79
+ * tier is not shed under load. The per-tier primary-topic budget is deliberately **not** folded in
80
+ * — it is a live per-registration gate, not a gossiped signal (§Willingness: the gossiped vector is
81
+ * "one bit per tier per member… refreshed every gossip round").
82
+ */
83
+ export function selfWillingnessBits(self: NodeProfile, barometer: LoadBarometerState): number {
84
+ return packWillingnessBits((t) => self.willingTiers.has(t) && barometer.loadWilling(t));
85
+ }
86
+
87
+ // --- exponential UnwillingCohort back-off (settled params, docs §Willingness L260-263) ---
88
+
89
+ export interface BackoffConfig {
90
+ /** First-rejection delay (ms). Default 1000. */
91
+ readonly baseMs: number;
92
+ /** Geometric growth per rejection. Default 2 (doubling). */
93
+ readonly factor: number;
94
+ /** Hard ceiling on a single delay (ms). Default 60000. */
95
+ readonly capMs: number;
96
+ }
97
+
98
+ /** Settled back-off curve (`docs/cohort-topic.md` §Willingness): `base = 1 s`, `factor = 2`, `cap = 60 s`. */
99
+ export const DEFAULT_BACKOFF_CONFIG: BackoffConfig = {
100
+ baseMs: 1000,
101
+ factor: 2,
102
+ capMs: 60_000,
103
+ };
104
+
105
+ /**
106
+ * `retryAfter` for the `attempt`-th rejection (0-based): `⌊base · factor^attempt⌋` capped at `capMs`.
107
+ * The capped doubling bounds the rejections a participant suffers across an overload window at
108
+ * `O(log(window/base))` (≤ ~6 to span 60 s) rather than the `window/base` a fixed interval incurs.
109
+ * As survivors of a burst back off geometrically, offered load sheds and accepted/sec holds at the
110
+ * willing-quorum capacity without a cascade.
111
+ */
112
+ export function backoffRetryMs(attempt: number, config: BackoffConfig = DEFAULT_BACKOFF_CONFIG): number {
113
+ if (!Number.isInteger(attempt) || attempt < 0) {
114
+ throw new RangeError(`attempt must be a non-negative integer, got ${attempt}`);
115
+ }
116
+ const raw = config.baseMs * Math.pow(config.factor, attempt);
117
+ return Math.min(Math.floor(raw), config.capMs);
118
+ }
119
+
120
+ // --- willingness check ---
121
+
122
+ export interface WillingnessConfig {
123
+ /** Cohort size `k` (drives the default quorum). Default 16. */
124
+ cohortSize?: number;
125
+ /**
126
+ * Members that must be willing to serve a tier for the cohort to take it on. Default: strict
127
+ * majority of `cohortSize` (`⌊k/2⌋ + 1`). Not pinned by `docs/cohort-topic.md` — §Tier ladder
128
+ * only requires "a quorum"; configurable here, revisitable by the Edge/Core policy ticket.
129
+ */
130
+ quorum?: number;
131
+ /** Load bucket at/above which a tier is shed. Default {@link DEFAULT_OVERLOAD_BUCKET} (6). */
132
+ overloadBucket?: number;
133
+ /**
134
+ * Max topics this member may be `primary` for at a single tier. Default 2048 (`topics_max`).
135
+ * The doc names the per-member per-tier budget but pins no number; configurable here.
136
+ */
137
+ maxPrimaryTopicsPerTier?: number;
138
+ /** Back-off curve for `unwilling_cohort` retries. Default {@link DEFAULT_BACKOFF_CONFIG}. */
139
+ backoff?: BackoffConfig;
140
+ }
141
+
142
+ export interface WillingnessDeps {
143
+ /** This member's own per-tier load barometer (live willingness uses its buckets). */
144
+ barometer: LoadBarometerState;
145
+ /** Merged per-member gossip view — the sibling willingness vectors. */
146
+ view: CohortView;
147
+ /** This member's own id, base64url (the `fromMember` key) — excluded from the sibling scan. */
148
+ selfMember: string;
149
+ /** Count of topics this member is already `primary` for at `tier` (the budget gate input). */
150
+ primaryTopicCount: (tier: Tier) => number;
151
+ /**
152
+ * Rejection-attempt count for `reg`, driving the `retryAfter` curve. The anti-DoS rate limiter
153
+ * owns the counter; the *curve* lives here. Default `() => 0` (first-rejection delay).
154
+ */
155
+ attempts?: (reg: RegisterV1) => number;
156
+ config?: WillingnessConfig;
157
+ }
158
+
159
+ /** Default quorum: strict majority of the cohort. */
160
+ export function defaultQuorum(cohortSize: number): number {
161
+ return Math.floor(cohortSize / 2) + 1;
162
+ }
163
+
164
+ class GossipWillingnessCheck implements WillingnessCheck {
165
+ private readonly quorum: number;
166
+ private readonly overloadBucket: number;
167
+ private readonly maxPrimaryTopicsPerTier: number;
168
+ private readonly backoff: BackoffConfig;
169
+ private readonly attempts: (reg: RegisterV1) => number;
170
+
171
+ constructor(private readonly deps: WillingnessDeps) {
172
+ const cfg = deps.config ?? {};
173
+ const cohortSize = cfg.cohortSize ?? 16;
174
+ this.quorum = cfg.quorum ?? defaultQuorum(cohortSize);
175
+ this.overloadBucket = cfg.overloadBucket ?? DEFAULT_OVERLOAD_BUCKET;
176
+ this.maxPrimaryTopicsPerTier = cfg.maxPrimaryTopicsPerTier ?? 2048;
177
+ this.backoff = cfg.backoff ?? DEFAULT_BACKOFF_CONFIG;
178
+ this.attempts = deps.attempts ?? ((): number => 0);
179
+ }
180
+
181
+ evaluate(reg: RegisterV1, self: NodeProfile, now: number): WillingnessOutcome {
182
+ const tier = reg.tier as Tier;
183
+ if (!ALL_TIERS.includes(tier)) {
184
+ // An op tier outside T0..T3 is not serviceable; treat as a cohort-level decline.
185
+ return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
186
+ }
187
+
188
+ const selfWilling = this.selfLiveWilling(self, tier);
189
+ const siblings = this.willingSiblings(tier);
190
+ const willingCount = siblings.length + (selfWilling ? 1 : 0);
191
+
192
+ // Quorum gate (§Tier ladder): the cohort takes on tier-T duties only if a quorum of members
193
+ // is willing; otherwise registrations get UnwillingCohort and the caller backs off in time.
194
+ if (willingCount < this.quorum) {
195
+ return { kind: "unwilling_cohort", retryAfterMs: backoffRetryMs(this.attempts(reg), this.backoff) };
196
+ }
197
+ if (selfWilling) {
198
+ return { kind: "accepted" };
199
+ }
200
+ // Quorum holds and self is unwilling → some sibling will serve.
201
+ return { kind: "unwilling_member", candidateMembers: siblings.map((m) => b64urlToBytes(m)) };
202
+ }
203
+
204
+ /** Live willingness of *this* member for `tier`: profile ∧ load-under-threshold ∧ under budget. */
205
+ private selfLiveWilling(self: NodeProfile, tier: Tier): boolean {
206
+ return (
207
+ self.willingTiers.has(tier) &&
208
+ this.deps.barometer.bucket(tier) < this.overloadBucket &&
209
+ this.deps.primaryTopicCount(tier) < this.maxPrimaryTopicsPerTier
210
+ );
211
+ }
212
+
213
+ /** Sibling member keys (base64url) whose *gossiped* willingness vector serves `tier`. */
214
+ private willingSiblings(tier: Tier): string[] {
215
+ const out: string[] = [];
216
+ for (const [member, contribution] of this.deps.view.all()) {
217
+ if (member === this.deps.selfMember) continue; // self counted via live willingness
218
+ if (tierBit(contribution.willingness, tier)) {
219
+ out.push(member);
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+ }
225
+
226
+ /** Build a {@link WillingnessCheck} over the injected barometer + gossip view + budget source. */
227
+ export function createWillingnessCheck(deps: WillingnessDeps): WillingnessCheck {
228
+ return new GossipWillingnessCheck(deps);
229
+ }
230
+
231
+ /** Convenience: a willingness check with a fresh idle barometer (tests / single-tier callers). */
232
+ export function createWillingnessCheckWithIdleBarometer(
233
+ deps: Omit<WillingnessDeps, "barometer">,
234
+ ): { check: WillingnessCheck; barometer: LoadBarometerState } {
235
+ const barometer = createLoadBarometer({ overloadBucket: deps.config?.overloadBucket });
236
+ return { check: createWillingnessCheck({ ...deps, barometer }), barometer };
237
+ }