@loadmap/sim 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -47,8 +47,15 @@ interface Particle {
47
47
  to: Place;
48
48
  /** Set on a copy made at a fan-out: the branch stage it is heading to. */
49
49
  branch?: string;
50
- /** superseded: merged into a newer unit of its key; unread: a fan-out that made no copy. */
51
- outcome?: 'delivered' | 'dropped' | 'rejected' | 'superseded' | 'unread';
50
+ /** Set on a cache hit heading back to where it came from, answered. */
51
+ back?: boolean;
52
+ /** Which attempt this is: 0 for the first, 1 for the first retry, and so on. */
53
+ attempt?: number;
54
+ /**
55
+ * superseded: merged into a newer unit of its key; unread: a fan-out that made no copy; answered: a cache hit back
56
+ * where it came from.
57
+ */
58
+ outcome?: 'delivered' | 'dropped' | 'rejected' | 'superseded' | 'unread' | 'answered';
52
59
  }
53
60
  interface QueueState {
54
61
  /** Units waiting. */
@@ -91,10 +98,17 @@ export declare class Sim {
91
98
  queue(stage: string, flow: string): QueueState;
92
99
  /** How busy a stage is, 0–1, smoothed: for a workers stage, the share of its workers at work. */
93
100
  busy(stage: string): number;
94
- /** The share a pass stage (arrivals) or a fan-out (copies) rejects because its component is past capacity. */
101
+ /**
102
+ * The share a pass, limit or cache stage (of what reaches it) or a fan-out (of its copies) rejects because its
103
+ * component is past capacity.
104
+ */
95
105
  shed(stage: string): number;
96
- /** How busy worker `i` of a workers stage is, 0–1. */
106
+ /** How busy worker `i` of a workers stage is, 0–1; 0 in a pod an autoscaler has not started. */
97
107
  slotBusy(stage: string, i: number): number;
108
+ /** Units per second actually reaching a stage on the flows' routes, after what stages before it turned away. */
109
+ arriving(stage: string): number;
110
+ /** The pods a workers stage runs: fewer than it draws while an autoscaler holds it small. */
111
+ running(stage: string): number;
98
112
  }
99
113
  export declare function createSim(model: Model, ev: Evaluation, o?: SimOptions): Sim;
100
114
  export type { Fill, Particle, Place, QueueState, SimOptions };
package/dist/index.js CHANGED
@@ -132,37 +132,41 @@ var Sim = class {
132
132
  return false;
133
133
  };
134
134
  this.particles = this.particles.filter((d) => fits(d.from, d.flow) && fits(d.to, d.flow));
135
- for (const s of Object.values(ev.stages)) if (s.kind === "workers") this.#fills.set(s.id, fillOrder(s.pods, s.perPod, s.fill, this.#o.seed));
135
+ this.#fills.clear();
136
136
  }
137
137
  const full = this.#model.thresholds.full;
138
+ const stages = Object.values(ev.stages);
138
139
  this.#shed.clear();
140
+ for (const s of stages) this.#shed.set(s.id, "shed" in s ? s.shed : 0);
139
141
  this.#inflow.clear();
140
- for (const s of Object.values(ev.stages)) {
141
- const u = (s.kind === "pass" || s.kind === "fanout") && s.component ? this.#util(s.component) : null;
142
- this.#shed.set(s.id, u != null && u > full ? 1 - full / u : 0);
143
- }
144
142
  for (const f of this.#model.flowIds) {
145
143
  let rate = ev.flows[f] ?? 0;
146
144
  for (const st of ev.routes[f] ?? []) {
147
145
  this.#inflow.set(`${st}/${f}`, rate);
148
- rate *= 1 - (this.#shed.get(st) ?? 0);
146
+ const s = ev.stages[st];
147
+ if (s.kind !== "fanout") rate *= 1 - (this.#shed.get(st) ?? 0);
148
+ if (s.kind === "cache") rate *= 1 - s.hit;
149
149
  }
150
150
  }
151
151
  this.#capacity.clear();
152
- const routedThrough = (s) => this.#model.flowIds.filter((f) => ev.routes[f]?.includes(s));
153
- for (const s of Object.values(ev.stages)) {
152
+ const atFull = /* @__PURE__ */ new Map();
153
+ for (const s of stages) {
154
154
  const c = s.component ? ev.components[s.component] : void 0;
155
155
  let cap = Number.POSITIVE_INFINITY;
156
156
  if (c && c.util != null && c.full != null && Number.isFinite(c.full)) {
157
157
  if (c.full > 0) {
158
- const at = this.#model.evaluate({
159
- ...ev.values,
160
- load: c.full
161
- });
162
- cap = routedThrough(s.id).reduce((a, f) => a + (at.flows[f] ?? 0), 0);
158
+ let at = atFull.get(c.full);
159
+ if (!at) {
160
+ at = this.#model.evaluate({
161
+ ...ev.values,
162
+ load: c.full
163
+ });
164
+ atFull.set(c.full, at);
165
+ }
166
+ cap = this.#model.flowIds.reduce((a, f) => a + (at.at[s.id]?.[f] ?? 0), 0);
163
167
  } else {
164
- const now = routedThrough(s.id).reduce((a, f) => a + (this.#inflow.get(`${s.id}/${f}`) ?? 0), 0);
165
- cap = c.util > 0 ? now * full / c.util : 0;
168
+ const offered = this.#model.flowIds.reduce((x, f) => x + (ev.at[s.id]?.[f] ?? 0), 0);
169
+ cap = c.util > 0 ? offered * full / c.util : 0;
166
170
  }
167
171
  }
168
172
  this.#capacity.set(s.id, cap);
@@ -188,14 +192,33 @@ var Sim = class {
188
192
  busy(stage) {
189
193
  return this.#busy.get(stage) ?? this.#steadyBusy(stage);
190
194
  }
191
- /** The share a pass stage (arrivals) or a fan-out (copies) rejects because its component is past capacity. */
195
+ /**
196
+ * The share a pass, limit or cache stage (of what reaches it) or a fan-out (of its copies) rejects because its
197
+ * component is past capacity.
198
+ */
192
199
  shed(stage) {
193
200
  return this.#shed.get(stage) ?? 0;
194
201
  }
195
- /** How busy worker `i` of a workers stage is, 0–1. */
202
+ /** How busy worker `i` of a workers stage is, 0–1; 0 in a pod an autoscaler has not started. */
196
203
  slotBusy(stage, i) {
197
- const f = this.#fills.get(stage);
198
- return f ? busyOf(i, f, this.busy(stage)) : 0;
204
+ const s = this.#ev.stages[stage];
205
+ if (s?.kind !== "workers" || i >= s.running * s.perPod) return 0;
206
+ const key = `${stage}/${s.running}`;
207
+ let f = this.#fills.get(key);
208
+ if (!f) {
209
+ f = fillOrder(s.running, s.perPod, s.fill, this.#o.seed);
210
+ this.#fills.set(key, f);
211
+ }
212
+ return busyOf(i, f, this.busy(stage));
213
+ }
214
+ /** Units per second actually reaching a stage on the flows' routes, after what stages before it turned away. */
215
+ arriving(stage) {
216
+ return this.#model.flowIds.reduce((a, f) => a + (this.#inflow.get(`${stage}/${f}`) ?? 0), 0);
217
+ }
218
+ /** The pods a workers stage runs: fewer than it draws while an autoscaler holds it small. */
219
+ running(stage) {
220
+ const s = this.#ev.stages[stage];
221
+ return s?.kind === "workers" ? s.running : 0;
199
222
  }
200
223
  #util(component) {
201
224
  return this.#ev.components[component]?.util ?? null;
@@ -203,7 +226,12 @@ var Sim = class {
203
226
  #steadyBusy(stage) {
204
227
  const s = this.#ev.stages[stage];
205
228
  const u = s?.component ? this.#util(s.component) : null;
206
- return Math.min(1, (u ?? 0) / this.#model.thresholds.full);
229
+ return Math.min(1, (u ?? 0) / this.#model.thresholds.full * this.#scaled(stage));
230
+ }
231
+ /** The workers drawn over the workers running: the busy share of the running ones is that much higher. */
232
+ #scaled(stage) {
233
+ const s = this.#ev.stages[stage];
234
+ return s?.kind === "workers" && s.running > 0 ? s.pods / s.running : 1;
207
235
  }
208
236
  #stepQueues(dt) {
209
237
  const ev = this.#ev;
@@ -278,14 +306,14 @@ var Sim = class {
278
306
  for (const s of Object.values(ev.stages)) {
279
307
  if (s.kind !== "workers") continue;
280
308
  const cap = this.#capacity.get(s.id) ?? Number.POSITIVE_INFINITY;
281
- const now = taken.has(s.id) && Number.isFinite(cap) && cap > 0 ? taken.get(s.id) / (cap * dt) : this.#steadyBusy(s.id);
309
+ const now = taken.has(s.id) && Number.isFinite(cap) && cap > 0 ? taken.get(s.id) / (cap * dt) * this.#scaled(s.id) : this.#steadyBusy(s.id);
282
310
  this.#busy.set(s.id, ema(this.#busy.get(s.id) ?? now, Math.min(1, now), dt));
283
311
  }
284
312
  }
285
313
  #spawnDots(dtReal) {
286
314
  const { minDots, maxDots, legSeconds, maxParticles } = this.#o;
287
315
  for (const f of this.#model.flowIds) {
288
- const rate = this.#ev.flows[f] ?? 0;
316
+ const rate = this.#ev.retry[f]?.rate ?? this.#ev.flows[f] ?? 0;
289
317
  if (rate <= 0) continue;
290
318
  const perSec = Math.min(maxDots, Math.max(minDots, rate / this.dotScale));
291
319
  let acc = (this.#spawn.get(f) ?? 0) + perSec * dtReal;
@@ -307,6 +335,23 @@ var Sim = class {
307
335
  this.#spawn.set(f, acc);
308
336
  }
309
337
  }
338
+ /** A unit turned away, sent again from its source while it has retries left and the budget allows. */
339
+ #retry(d) {
340
+ const r = this.#ev.retry[d.flow];
341
+ const attempt = (d.attempt ?? 0) + 1;
342
+ if (!r || attempt > r.times || this.#r() >= r.retried) return null;
343
+ return {
344
+ id: this.#nextId++,
345
+ flow: d.flow,
346
+ leg: 0,
347
+ phase: "move",
348
+ t: 0,
349
+ dur: this.#o.legSeconds,
350
+ from: { stage: null },
351
+ to: this.#place(this.#ev.routes[d.flow][0], d.flow),
352
+ attempt
353
+ };
354
+ }
310
355
  #moveDots(dtReal) {
311
356
  const ev = this.#ev;
312
357
  const fade = (d, outcome, dur) => {
@@ -327,9 +372,18 @@ var Sim = class {
327
372
  d.to = this.#place(route[d.leg], d.flow);
328
373
  };
329
374
  const born = [];
375
+ const reject = (d) => {
376
+ fade(d, "rejected", .6);
377
+ const again = this.particles.length + born.length < this.#o.maxParticles ? this.#retry(d) : null;
378
+ if (again) born.push(again);
379
+ };
330
380
  for (const d of this.particles) {
331
381
  d.t += dtReal;
332
382
  if (d.t < d.dur) continue;
383
+ if (d.phase === "move" && d.back) {
384
+ fade(d, "answered", .35);
385
+ continue;
386
+ }
333
387
  const stage = ev.stages[d.to.stage ?? ""];
334
388
  if (d.phase === "fade" || !stage) {
335
389
  d.phase = "fade";
@@ -356,8 +410,15 @@ var Sim = class {
356
410
  if (made) fade(d, "delivered", 0);
357
411
  else fade(d, "unread", .6);
358
412
  } else if (d.phase === "move") {
359
- if (stage.kind === "pass" && this.#r() < this.shed(stage.id)) fade(d, "rejected", .6);
360
- else if (stage.kind === "queue") {
413
+ if ((stage.kind === "pass" || stage.kind === "limit" || stage.kind === "cache") && this.#r() < this.shed(stage.id)) reject(d);
414
+ else if (stage.kind === "cache" && this.#r() < stage.hit) {
415
+ const to = d.from;
416
+ d.from = d.to;
417
+ d.to = to;
418
+ d.t = 0;
419
+ d.dur = this.#o.legSeconds;
420
+ d.back = true;
421
+ } else if (stage.kind === "queue") {
361
422
  const lag = this.queue(stage.id, d.flow).lag;
362
423
  d.phase = "hold";
363
424
  d.t = 0;
@@ -389,7 +450,7 @@ var Sim = class {
389
450
  }
390
451
  if (s?.kind === "workers") return {
391
452
  stage,
392
- cell: this.#pickWorker(s.id, s.pods * s.perPod)
453
+ cell: this.#pickWorker(s.id, s.running * s.perPod)
393
454
  };
394
455
  return { stage };
395
456
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loadmap/sim",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "The fluid queue simulation behind loadmap's moving dots: priorities, drop rules, worker fill, seeded randomness.",
5
5
  "keywords": [
6
6
  "loadmap",
@@ -28,7 +28,7 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
- "@loadmap/core": "0.1.0"
31
+ "@loadmap/core": "0.2.0"
32
32
  },
33
33
  "peerDependencies": {},
34
34
  "scripts": {