@optimystic/db-core 0.22.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 (141) hide show
  1. package/README.md +336 -336
  2. package/dist/src/cluster/structs.d.ts +39 -1
  3. package/dist/src/cluster/structs.d.ts.map +1 -1
  4. package/dist/src/cluster/structs.js +24 -0
  5. package/dist/src/cluster/structs.js.map +1 -1
  6. package/dist/src/collection/collection.d.ts +17 -0
  7. package/dist/src/collection/collection.d.ts.map +1 -1
  8. package/dist/src/collection/collection.js +24 -2
  9. package/dist/src/collection/collection.js.map +1 -1
  10. package/dist/src/collections/tree/tree.d.ts +5 -0
  11. package/dist/src/collections/tree/tree.d.ts.map +1 -1
  12. package/dist/src/collections/tree/tree.js +7 -0
  13. package/dist/src/collections/tree/tree.js.map +1 -1
  14. package/dist/src/network/i-peer-network.d.ts +16 -0
  15. package/dist/src/network/i-peer-network.d.ts.map +1 -1
  16. package/dist/src/network/struct.d.ts +39 -2
  17. package/dist/src/network/struct.d.ts.map +1 -1
  18. package/dist/src/network/struct.js +18 -0
  19. package/dist/src/network/struct.js.map +1 -1
  20. package/dist/src/testing/test-transactor.d.ts +95 -8
  21. package/dist/src/testing/test-transactor.d.ts.map +1 -1
  22. package/dist/src/testing/test-transactor.js +121 -8
  23. package/dist/src/testing/test-transactor.js.map +1 -1
  24. package/dist/src/transaction/transaction.d.ts +1 -1
  25. package/dist/src/transaction/transaction.js +1 -1
  26. package/dist/src/transactor/network-transactor.d.ts.map +1 -1
  27. package/dist/src/transactor/network-transactor.js +48 -13
  28. package/dist/src/transactor/network-transactor.js.map +1 -1
  29. package/dist/src/transactor/transactor-source.d.ts.map +1 -1
  30. package/dist/src/transactor/transactor-source.js +25 -2
  31. package/dist/src/transactor/transactor-source.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/cluster/membership.ts +85 -85
  34. package/src/cluster/structs.ts +43 -4
  35. package/src/cohort-topic/addressing.ts +120 -120
  36. package/src/cohort-topic/antidos/bootstrap-evidence-envelope.ts +253 -253
  37. package/src/cohort-topic/antidos/bootstrap-evidence.ts +106 -106
  38. package/src/cohort-topic/antidos/index.ts +5 -5
  39. package/src/cohort-topic/antidos/rate-limiter.ts +210 -210
  40. package/src/cohort-topic/antidos/replay-guard.ts +146 -146
  41. package/src/cohort-topic/antidos/topic-budget.ts +160 -160
  42. package/src/cohort-topic/antiflood/index.ts +2 -2
  43. package/src/cohort-topic/antiflood/invariants.ts +108 -108
  44. package/src/cohort-topic/antiflood/jitter.ts +117 -117
  45. package/src/cohort-topic/coldstart.ts +237 -237
  46. package/src/cohort-topic/dmax.ts +88 -88
  47. package/src/cohort-topic/gossip/bus.ts +254 -254
  48. package/src/cohort-topic/gossip/index.ts +3 -3
  49. package/src/cohort-topic/gossip/records.ts +45 -45
  50. package/src/cohort-topic/gossip/view.ts +91 -91
  51. package/src/cohort-topic/index.ts +20 -20
  52. package/src/cohort-topic/load/barometer.ts +134 -134
  53. package/src/cohort-topic/load/index.ts +1 -1
  54. package/src/cohort-topic/member-engine.ts +430 -430
  55. package/src/cohort-topic/membership/index.ts +3 -3
  56. package/src/cohort-topic/membership/publisher.ts +163 -163
  57. package/src/cohort-topic/membership/source.ts +41 -41
  58. package/src/cohort-topic/membership/verifier.ts +461 -461
  59. package/src/cohort-topic/ports.ts +157 -157
  60. package/src/cohort-topic/promotion.ts +405 -405
  61. package/src/cohort-topic/registration/bytes.ts +37 -37
  62. package/src/cohort-topic/registration/handoff.ts +154 -154
  63. package/src/cohort-topic/registration/index.ts +6 -6
  64. package/src/cohort-topic/registration/renewal.ts +495 -495
  65. package/src/cohort-topic/registration/sharding.ts +61 -61
  66. package/src/cohort-topic/registration/store.ts +81 -81
  67. package/src/cohort-topic/registration/types.ts +91 -91
  68. package/src/cohort-topic/ring-hash.ts +50 -50
  69. package/src/cohort-topic/service.ts +416 -416
  70. package/src/cohort-topic/sig/index.ts +2 -2
  71. package/src/cohort-topic/sig/payloads.ts +59 -59
  72. package/src/cohort-topic/sig/threshold.ts +64 -64
  73. package/src/cohort-topic/tiers.ts +74 -74
  74. package/src/cohort-topic/traffic.ts +233 -233
  75. package/src/cohort-topic/walk.ts +326 -326
  76. package/src/cohort-topic/willingness.ts +237 -237
  77. package/src/cohort-topic/wire/codec.ts +216 -216
  78. package/src/cohort-topic/wire/index.ts +18 -18
  79. package/src/cohort-topic/wire/payloads.ts +126 -126
  80. package/src/cohort-topic/wire/primitives.ts +188 -188
  81. package/src/cohort-topic/wire/types.ts +475 -475
  82. package/src/cohort-topic/wire/validate.ts +512 -512
  83. package/src/collection/collection-type-registry.ts +37 -37
  84. package/src/collection/collection.ts +25 -2
  85. package/src/collections/diary/diary.ts +68 -68
  86. package/src/collections/tree/readme.md +4 -0
  87. package/src/collections/tree/tree.ts +320 -312
  88. package/src/matchmaking/capability-filter.ts +45 -45
  89. package/src/matchmaking/config.ts +98 -98
  90. package/src/matchmaking/index.ts +21 -21
  91. package/src/matchmaking/multi-cohort-seeker.ts +234 -234
  92. package/src/matchmaking/provider.ts +123 -123
  93. package/src/matchmaking/query-eval.ts +105 -105
  94. package/src/matchmaking/seeker-walk.ts +127 -127
  95. package/src/matchmaking/seeker.ts +86 -86
  96. package/src/matchmaking/topic-anchor.ts +90 -90
  97. package/src/matchmaking/voting-quorum.ts +394 -394
  98. package/src/matchmaking/wire.ts +603 -603
  99. package/src/network/i-peer-network.ts +17 -0
  100. package/src/network/stale-failure.ts +43 -43
  101. package/src/network/struct.ts +41 -2
  102. package/src/network/types.ts +37 -37
  103. package/src/reactivity/backfill.ts +220 -220
  104. package/src/reactivity/backpressure.ts +191 -191
  105. package/src/reactivity/checkpoint.ts +308 -308
  106. package/src/reactivity/config.ts +172 -172
  107. package/src/reactivity/dedupe.ts +132 -132
  108. package/src/reactivity/forwarder.ts +87 -87
  109. package/src/reactivity/index.ts +34 -34
  110. package/src/reactivity/notification.ts +123 -123
  111. package/src/reactivity/policy.ts +79 -79
  112. package/src/reactivity/push-state.ts +310 -310
  113. package/src/reactivity/recover.ts +153 -153
  114. package/src/reactivity/replay-buffer.ts +141 -141
  115. package/src/reactivity/resume.ts +549 -549
  116. package/src/reactivity/rotation.ts +415 -415
  117. package/src/reactivity/subscriber.ts +132 -132
  118. package/src/reactivity/subscription.ts +66 -66
  119. package/src/reactivity/topic-anchor.ts +71 -71
  120. package/src/reactivity/verify.ts +73 -73
  121. package/src/reactivity/wire-validate.ts +13 -13
  122. package/src/reactivity/wire.ts +224 -224
  123. package/src/testing/async-wait.ts +65 -65
  124. package/src/testing/index.ts +2 -2
  125. package/src/testing/test-transactor.ts +638 -502
  126. package/src/transaction/errors.ts +91 -91
  127. package/src/transaction/operations-hash.ts +196 -196
  128. package/src/transaction/read-dependency-collector.ts +78 -78
  129. package/src/transaction/transaction.ts +1 -1
  130. package/src/transactor/change-notifier.ts +80 -80
  131. package/src/transactor/index.ts +5 -5
  132. package/src/transactor/network-transactor.ts +49 -14
  133. package/src/transactor/transactor-source.ts +25 -2
  134. package/src/transform/atomic-proxy.ts +92 -92
  135. package/src/transform/helpers.ts +159 -159
  136. package/src/utility/backoff.ts +95 -95
  137. package/src/utility/batch-coordinator.ts +191 -191
  138. package/dist/src/transaction/context.d.ts +0 -60
  139. package/dist/src/transaction/context.d.ts.map +0 -1
  140. package/dist/src/transaction/context.js +0 -91
  141. 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
+ }