@torrent-tv/proxy 2.32.0 → 2.34.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/CHANGELOG.md +10 -0
- package/package.json +1 -1
- package/services/available-share.js +89 -0
- package/services/hls-session-manager.js +90 -4
- package/services/torrent-worker/piece-reader.js +33 -0
- package/services/torrent-worker/worker.js +13 -2
- package/test/available-share.test.js +76 -0
- package/services/torrent-worker/supply-interruptions.js +0 -155
- package/test/supply-interruptions.test.js +0 -125
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.34.0
|
|
2
|
+
|
|
3
|
+
- **New**: The proxy tells the browser the smallest buffer at which no interruption reaches the viewer, measured on the file being watched. It is one whole segment — the one being played — plus the worst wait its own reader met before the buffer could refill, from that file's recent interruptions on that swarm. On the field torrent of 2026-08-17 that is 7-9 s, where the browser has been waiting for a hand-chosen 25: sixteen seconds of spinner that nothing had shown to be necessary. Null until the reader has seen two interruptions — one wait shows no interval, and an interval invented from one point is what this work exists to remove — and the browser keeps its own figure until then. The reader measures it, the session manager states it with its own segment length, and the progress reply carries it.
|
|
4
|
+
- **Chore**: Removed `services/torrent-worker/supply-interruptions.js`, a second copy of the same arithmetic that was wired to nothing.
|
|
5
|
+
|
|
6
|
+
## 2.33.0
|
|
7
|
+
|
|
8
|
+
- **New**: A quality step is judged on the machine it will actually run on. The encoder benchmark measures a QUIET host — one ffmpeg and nothing else — while the addon host was measured 99 % busy, and a step predicted at 1.83x ran at 1.01-1.12x (2026-08-17). The offer now multiplies each prediction by the share of the machine that is free, taken from the same `host-load` reading that is already printed every five seconds. What is subtracted is ONLY the work nobody has been charged for — the kernel, the container, whatever else the owner runs — because our own encoders are already priced by the concurrency arithmetic and the proxy's own work per megabyte moved. Charging those here as well is what shipped in 2.21.0 and emptied the quality menu down to a single copied height. On the field reading the correction is about 0.77, and the "not offering" line now says what the machine had to spare when it decided.
|
|
9
|
+
- **New**: Each step reports what its prediction was worth. When a step runs with the machine to itself, the log states the speed it was predicted at, the speed it measured, and the ratio — so the error that REMAINS after the availability correction is a number in the field rather than an argument. It is written when it moves by more than a tenth, so a steady step says it once. On the field case that correction takes 1.83x to 1.41x against 1.01-1.12x measured: part of the gap, not all of it, and this line is how the rest gets found.
|
|
10
|
+
|
|
1
11
|
## 2.32.0
|
|
2
12
|
|
|
3
13
|
- **New**: The decode cost is fitted from a clip set that can be checked, and a term the measurements do not determine is refused instead of published as a zero. The set that shipped until now was three clips for three unknowns — an EXACT system, with two of the clips at the same pixel rate — and such a system cannot fail visibly: it returns whatever satisfies its equations. On 2026-08-17 it returned `0.007542 × Mpx/s + 0.000000 × Mbit/s + 0.0000 s/s`, so a film's own bitrate never entered its price, and the prediction built on it was 1.8-2.2x optimistic against the same file measured while playing. The new set is six clips — three sizes × two bitrates, the axes varied INDEPENDENTLY — cut from the same Netflix Open Content "Meridian" footage (CC BY 4.0, `assets/calibration/NOTICE.md`), 7.7 MB against 8.8 MB before. Three spare measurements give the fit a residual, and with it two questions it could not ask before: whether a term's whole effect across the measured range exceeds the scatter, and whether the coefficient exceeds its own standard error. A term that fails either is dropped, the rest are fitted again, and the log names it — a zero now means "not measured" only when it says so. A NEGATIVE coefficient is dropped too rather than clamped to zero: more pixels cannot cost less work, so a negative fit is noise beating an effect, not a discovery about the host. Measured on the developer's machine, the new set determines all three terms (`0.000520 × Mpx/s + 0.002086 × Mbit/s + 0.0033 s/s`, typical disagreement 0.0012 s/s), and the bitrate term it recovers matches the difference between the two 1080p clips to 15 %. The arithmetic is a pure module with the degenerate case as a test (`services/decode-cost-fit.js`).
|
package/package.json
CHANGED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file How much of the machine a new encoder can actually have.
|
|
3
|
+
*
|
|
4
|
+
* The encoder benchmark measures a QUIET host: one ffmpeg, nothing else. A real
|
|
5
|
+
* encode runs on a machine that is also downloading, hashing and serving, and
|
|
6
|
+
* on the addon host that machine was measured 99 % busy — `ffmpeg=52-60%
|
|
7
|
+
* proxy=17-24% system=99%` — while a step predicted at 1.83x ran at 1.01-1.12x
|
|
8
|
+
* (2026-08-17).
|
|
9
|
+
*
|
|
10
|
+
* What this corrects is ONLY the part nobody has been charged for. That
|
|
11
|
+
* distinction is the whole of the file, because the alternative has already
|
|
12
|
+
* shipped once and broke the product: in 2.21.0 the budget ADDED what else was
|
|
13
|
+
* running while the per-file costs were being LEARNED from runs that already
|
|
14
|
+
* contained that other work, so every cost was counted twice, every re-encoded
|
|
15
|
+
* step was refused, and the quality menu emptied itself down to the one copied
|
|
16
|
+
* height (fixed in 2.21.1).
|
|
17
|
+
*
|
|
18
|
+
* So: our own encoders are priced by the concurrency arithmetic, and the
|
|
19
|
+
* proxy's own work — the torrent, the hashing, the delivery — is priced per
|
|
20
|
+
* megabyte moved. Both are already in the budget. What is NOT in it is
|
|
21
|
+
* everything else the machine does: the kernel, the container runtime, whatever
|
|
22
|
+
* else the owner runs on their box. That is what is subtracted here, and
|
|
23
|
+
* nothing more.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The share of the machine available to a new encoder.
|
|
28
|
+
*
|
|
29
|
+
* @param {object} reading - Fractions of the WHOLE machine (all cores), as
|
|
30
|
+
* `shareOfMachine` reports them.
|
|
31
|
+
* @param {number | null} reading.systemBusy - Everything the machine is doing.
|
|
32
|
+
* @param {number | null} reading.encoderShare - Our own ffmpeg processes.
|
|
33
|
+
* @param {number | null} reading.proxyShare - The proxy process itself.
|
|
34
|
+
* @returns {{ share: number, unattributed: number, known: boolean }}
|
|
35
|
+
* `known` is false when the host does not report its own load — then the
|
|
36
|
+
* share is 1 and the caller must say it is uncorrected rather than pretend.
|
|
37
|
+
*/
|
|
38
|
+
export function availableShareFrom(reading = {}) {
|
|
39
|
+
const systemBusy = finite(reading.systemBusy);
|
|
40
|
+
if (systemBusy === null) {
|
|
41
|
+
// No reading at all: not every host has /proc. An uncorrected prediction is
|
|
42
|
+
// the honest answer, and the caller says so.
|
|
43
|
+
return { share: 1, unattributed: 0, known: false };
|
|
44
|
+
}
|
|
45
|
+
const ours = (finite(reading.encoderShare) ?? 0) + (finite(reading.proxyShare) ?? 0);
|
|
46
|
+
// Rounding, and the two readings being taken microseconds apart, can put our
|
|
47
|
+
// own share fractionally above the system total. Below zero is not a
|
|
48
|
+
// measurement of anything.
|
|
49
|
+
const unattributed = clamp(systemBusy - ours, 0, 1);
|
|
50
|
+
return { share: clamp(1 - unattributed, 0, 1), unattributed, known: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Apply the correction to a predicted speed.
|
|
55
|
+
*
|
|
56
|
+
* A speed is work per unit time, so a machine that can give a new encoder only
|
|
57
|
+
* `share` of itself produces `share ×` the speed the benchmark measured alone.
|
|
58
|
+
*
|
|
59
|
+
* @param {number} predictedSpeed - From the quiet-host benchmark.
|
|
60
|
+
* @param {{ share: number, known: boolean }} availability
|
|
61
|
+
* @returns {number}
|
|
62
|
+
*/
|
|
63
|
+
export function correctForAvailability(predictedSpeed, availability) {
|
|
64
|
+
if (!Number.isFinite(predictedSpeed) || predictedSpeed <= 0) {
|
|
65
|
+
return predictedSpeed;
|
|
66
|
+
}
|
|
67
|
+
if (!availability?.known) {
|
|
68
|
+
return predictedSpeed;
|
|
69
|
+
}
|
|
70
|
+
return predictedSpeed * availability.share;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {unknown} value
|
|
75
|
+
* @returns {number | null}
|
|
76
|
+
*/
|
|
77
|
+
function finite(value) {
|
|
78
|
+
return Number.isFinite(value) ? Number(value) : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {number} value
|
|
83
|
+
* @param {number} low
|
|
84
|
+
* @param {number} high
|
|
85
|
+
* @returns {number}
|
|
86
|
+
*/
|
|
87
|
+
function clamp(value, low, high) {
|
|
88
|
+
return Math.min(high, Math.max(low, value));
|
|
89
|
+
}
|
|
@@ -20,6 +20,8 @@ import { logger } from "../utils/logger.js";
|
|
|
20
20
|
import { readKeyframeIndex } from "./container-index/index.js";
|
|
21
21
|
import { readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "./host-load.js";
|
|
22
22
|
import { speedFromReadings } from "./encoder-readings.js";
|
|
23
|
+
import { availableShareFrom, correctForAvailability } from "./available-share.js";
|
|
24
|
+
import { minimumBufferFrom } from "./supply-margin.js";
|
|
23
25
|
import {
|
|
24
26
|
ENCODE_RUN_EVENT,
|
|
25
27
|
ENCODE_RUN_STATE,
|
|
@@ -1902,6 +1904,12 @@ export class HlsSessionManager {
|
|
|
1902
1904
|
// software hosts, else the client target). 0 = keep source.
|
|
1903
1905
|
encodeWidth,
|
|
1904
1906
|
encodeHeight,
|
|
1907
|
+
// What the offer predicted this height would do on this machine, so the
|
|
1908
|
+
// field can say what the prediction was worth once the step runs. Null
|
|
1909
|
+
// when the step was never judged — a copied stream needs no encoder and
|
|
1910
|
+
// is never predicted.
|
|
1911
|
+
predictedSpeedWhenOffered: this.lastPredictedByHeight?.get(encodeHeight) ?? null,
|
|
1912
|
+
lastPredictionRatio: null,
|
|
1905
1913
|
// The NAME of this rung, fixed at the height that was asked for. It is
|
|
1906
1914
|
// deliberately not the height being encoded: a viewer who picked 480p on
|
|
1907
1915
|
// a host that then starts them at 360p, or steps down to it later, goes
|
|
@@ -3217,13 +3225,25 @@ export class HlsSessionManager {
|
|
|
3217
3225
|
const proxyShare = Number.isFinite(previous.proxyCpuSeconds)
|
|
3218
3226
|
? (sample.proxyCpuSeconds - previous.proxyCpuSeconds) / (share.elapsedSec * cores)
|
|
3219
3227
|
: null;
|
|
3228
|
+
// Kept for the quality offer, which predicts from a benchmark taken on a
|
|
3229
|
+
// QUIET host: the same reading that is printed here says how much of the
|
|
3230
|
+
// machine a new encoder could actually have. Only what nobody has been
|
|
3231
|
+
// charged for is subtracted — see `available-share.js`.
|
|
3232
|
+
this.hostAvailability = availableShareFrom({
|
|
3233
|
+
systemBusy: share.systemShare,
|
|
3234
|
+
encoderShare: share.processShare,
|
|
3235
|
+
proxyShare
|
|
3236
|
+
});
|
|
3220
3237
|
logger.info(
|
|
3221
3238
|
`host-load: ffmpeg=${asPercent(share.processShare)} proxy=${asPercent(proxyShare)} ` +
|
|
3222
3239
|
`system=${asPercent(share.systemShare)} ` +
|
|
3223
3240
|
`iowait=${asPercent(share.iowaitShare)} cpu=${machine.megahertz === null ? "n/a" : `${machine.megahertz}MHz`} ` +
|
|
3224
3241
|
`temp=${machine.celsius === null ? "n/a" : `${machine.celsius}C`} ` +
|
|
3225
3242
|
`encoders=${running} running` + (suspended > 0 ? ` +${suspended} suspended` : "") +
|
|
3226
|
-
` over=${share.elapsedSec.toFixed(1)}s`
|
|
3243
|
+
` over=${share.elapsedSec.toFixed(1)}s` +
|
|
3244
|
+
// What the offer will multiply a prediction by, in the same line as the
|
|
3245
|
+
// readings it comes from.
|
|
3246
|
+
` available=${asPercent(this.hostAvailability.share)}`
|
|
3227
3247
|
);
|
|
3228
3248
|
}
|
|
3229
3249
|
|
|
@@ -3349,6 +3369,12 @@ export class HlsSessionManager {
|
|
|
3349
3369
|
if (!stats) {
|
|
3350
3370
|
return "unknown";
|
|
3351
3371
|
}
|
|
3372
|
+
// What this file's own interruptions demand, measured by the reader. Kept
|
|
3373
|
+
// on the session because the browser is told the buffer that follows from
|
|
3374
|
+
// it, and because the quality offer will be held to the speed it names.
|
|
3375
|
+
if (stats.supply) {
|
|
3376
|
+
session.supplyFigures = stats.supply;
|
|
3377
|
+
}
|
|
3352
3378
|
// A fully (or almost fully) downloaded file cannot be download-bound.
|
|
3353
3379
|
if (typeof stats.fileProgress === "number" && stats.fileProgress >= 0.999) {
|
|
3354
3380
|
return "cpu";
|
|
@@ -3391,6 +3417,10 @@ export class HlsSessionManager {
|
|
|
3391
3417
|
// although nothing is producing that step any more. The next reading of the
|
|
3392
3418
|
// new encode replaces it.
|
|
3393
3419
|
session.lastAloneSpeed = null;
|
|
3420
|
+
// And so is the prediction it was compared against: it described the step
|
|
3421
|
+
// this session has just left.
|
|
3422
|
+
session.predictedSpeedWhenOffered = this.lastPredictedByHeight?.get(rung.height) ?? null;
|
|
3423
|
+
session.lastPredictionRatio = null;
|
|
3394
3424
|
// Priced the same way the offer and the starting rung are. Choosing the
|
|
3395
3425
|
// preset on the encoder alone treats decoding as free, which is how the
|
|
3396
3426
|
// check and the encode came to disagree in the first place — and here it
|
|
@@ -5444,6 +5474,25 @@ export class HlsSessionManager {
|
|
|
5444
5474
|
// 0.3x reads as 3.33 s of work per second of video, more than the machine
|
|
5445
5475
|
// has — and every other quality step was refused on the download's account.
|
|
5446
5476
|
session.lastAloneSpeed = speed;
|
|
5477
|
+
// What the offer predicted for this very step, against what it then did
|
|
5478
|
+
// with the machine to itself. The prediction is corrected for the share of
|
|
5479
|
+
// the machine that was free at the time, so this ratio is the error that
|
|
5480
|
+
// remains AFTER that correction — which is the only way to tell whether a
|
|
5481
|
+
// stage of roadmap item 3 moved anything. Written when it changes by more
|
|
5482
|
+
// than a tenth, so a steady step says it once rather than every five
|
|
5483
|
+
// seconds.
|
|
5484
|
+
if (Number.isFinite(session.predictedSpeedWhenOffered) && session.predictedSpeedWhenOffered > 0) {
|
|
5485
|
+
const ratio = speed / session.predictedSpeedWhenOffered;
|
|
5486
|
+
const lastSaid = session.lastPredictionRatio;
|
|
5487
|
+
if (!Number.isFinite(lastSaid) || Math.abs(ratio - lastSaid) > 0.1) {
|
|
5488
|
+
session.lastPredictionRatio = ratio;
|
|
5489
|
+
logger.info(
|
|
5490
|
+
`prediction ${session.id.slice(0, 8)} ${session.encodeHeight || "source"}p: ` +
|
|
5491
|
+
`predicted ${session.predictedSpeedWhenOffered.toFixed(2)}x, measured ${speed.toFixed(2)}x ` +
|
|
5492
|
+
`(ratio ${ratio.toFixed(2)}; 1.00 would mean the arithmetic describes this machine)`
|
|
5493
|
+
);
|
|
5494
|
+
}
|
|
5495
|
+
}
|
|
5447
5496
|
if (kind === "audio") {
|
|
5448
5497
|
// What is left after the work that was already accounted for. `null` when
|
|
5449
5498
|
// the subtraction leaves nothing positive, which means the reading says
|
|
@@ -5962,6 +6011,12 @@ export class HlsSessionManager {
|
|
|
5962
6011
|
const kept = [];
|
|
5963
6012
|
/** @type {string[]} */
|
|
5964
6013
|
const dropped = [];
|
|
6014
|
+
// What each height was predicted to do on THIS machine, kept so a session
|
|
6015
|
+
// started at that height can be compared against it once it runs. The
|
|
6016
|
+
// manager holds the last answer, because the offer is computed on the path
|
|
6017
|
+
// that serves every request while a session is created elsewhere.
|
|
6018
|
+
/** @type {Map<number, number | null>} */
|
|
6019
|
+
const predictedByHeight = new Map();
|
|
5965
6020
|
for (const height of heights) {
|
|
5966
6021
|
// The height an encoder is ALREADY producing, and the source's own height
|
|
5967
6022
|
// when the FAMILY serves it by copy — neither has to be predicted,
|
|
@@ -6004,7 +6059,7 @@ export class HlsSessionManager {
|
|
|
6004
6059
|
0,
|
|
6005
6060
|
concurrentCostSec - (runningCostByHeight?.get(height) ?? 0)
|
|
6006
6061
|
);
|
|
6007
|
-
const { speed
|
|
6062
|
+
const { speed } = canSustainOutput({
|
|
6008
6063
|
benchmark,
|
|
6009
6064
|
decodeModel: this.decodeCostModel,
|
|
6010
6065
|
source,
|
|
@@ -6012,11 +6067,24 @@ export class HlsSessionManager {
|
|
|
6012
6067
|
observedDecodeCostSec,
|
|
6013
6068
|
concurrentCostSec: concurrentBesideThis
|
|
6014
6069
|
});
|
|
6015
|
-
|
|
6070
|
+
// The benchmark behind that figure was taken on a QUIET host — one
|
|
6071
|
+
// ffmpeg and nothing else. The machine a step will actually run on is
|
|
6072
|
+
// also running the kernel, the container and whatever else its owner
|
|
6073
|
+
// does, and on the addon host that was measured at 99 % busy with a
|
|
6074
|
+
// quarter of it unattributed. Only the unattributed part is charged
|
|
6075
|
+
// here: our own encoders are already in `concurrentBesideThis` and the
|
|
6076
|
+
// proxy's own work is already priced per megabyte moved.
|
|
6077
|
+
const onThisMachine = correctForAvailability(speed, this.hostAvailability);
|
|
6078
|
+
// Kept against the step's own session, so that when it runs the field
|
|
6079
|
+
// says what the prediction was worth. Without this the only comparison
|
|
6080
|
+
// available is between two figures written minutes apart in different
|
|
6081
|
+
// lines of the log.
|
|
6082
|
+
predictedByHeight.set(height, onThisMachine);
|
|
6083
|
+
if (onThisMachine !== null && onThisMachine >= REALTIME_SPEED_MARGIN) {
|
|
6016
6084
|
kept.push(height);
|
|
6017
6085
|
continue;
|
|
6018
6086
|
}
|
|
6019
|
-
dropped.push(`${height}p=${
|
|
6087
|
+
dropped.push(`${height}p=${onThisMachine === null ? "n/a" : `${onThisMachine.toFixed(2)}x`}`);
|
|
6020
6088
|
}
|
|
6021
6089
|
// Written when the ANSWER changes, not when the answer is recomputed. This
|
|
6022
6090
|
// is asked on the path that serves every playlist, init and segment, and
|
|
@@ -6026,12 +6094,19 @@ export class HlsSessionManager {
|
|
|
6026
6094
|
if (dropped.length > 0) {
|
|
6027
6095
|
const line =
|
|
6028
6096
|
`transcode: not offering ${dropped.join(" ")} — below realtime × ${REALTIME_SPEED_MARGIN} ` +
|
|
6097
|
+
// Said with the figures, because a step refused on a busy machine and
|
|
6098
|
+
// one refused on an idle machine are different facts about the host.
|
|
6099
|
+
(this.hostAvailability?.known
|
|
6100
|
+
? `on a machine with ${Math.round(this.hostAvailability.share * 100)}% to spare `
|
|
6101
|
+
: "") +
|
|
6029
6102
|
`(offering ${kept.map((height) => `${height}p`).join(" ")})`;
|
|
6030
6103
|
if (line !== this.#lastOfferLine) {
|
|
6031
6104
|
this.#lastOfferLine = line;
|
|
6032
6105
|
logger.info(line);
|
|
6033
6106
|
}
|
|
6107
|
+
this.lastPredictedByHeight = predictedByHeight;
|
|
6034
6108
|
} else {
|
|
6109
|
+
this.lastPredictedByHeight = predictedByHeight;
|
|
6035
6110
|
this.#lastOfferLine = "";
|
|
6036
6111
|
}
|
|
6037
6112
|
return kept;
|
|
@@ -7521,6 +7596,17 @@ export class HlsSessionManager {
|
|
|
7521
7596
|
// the browser tracks its sessions by the id it was given.
|
|
7522
7597
|
sessionId: named.id,
|
|
7523
7598
|
state: wireState(session.runState),
|
|
7599
|
+
// The smallest buffer at which no interruption reaches the viewer, from
|
|
7600
|
+
// THIS file's own recent interruptions: one whole segment — the one being
|
|
7601
|
+
// played — plus the worst wait that can arrive before the buffer refills.
|
|
7602
|
+
// On the field torrent that is 7-9 s where the browser waits for a
|
|
7603
|
+
// hand-chosen 25, which is sixteen seconds of staring at a spinner that
|
|
7604
|
+
// nothing had shown to be necessary. Null until the reader has seen two
|
|
7605
|
+
// interruptions; the browser keeps its own figure until then.
|
|
7606
|
+
minimumBufferSeconds: minimumBufferFrom({
|
|
7607
|
+
segmentSeconds: this.segmentDurationSec,
|
|
7608
|
+
worstSupplyWaitSec: session.supplyFigures?.worstWaitSec
|
|
7609
|
+
})?.seconds ?? null,
|
|
7524
7610
|
processedSeconds: session.progress.processedSeconds,
|
|
7525
7611
|
startPositionSeconds: session.progress.startPositionSeconds ?? 0,
|
|
7526
7612
|
totalSeconds: session.progress.totalSeconds,
|
|
@@ -340,6 +340,39 @@ const supplyReportedAt = new Map();
|
|
|
340
340
|
* @param {number} waitedMs
|
|
341
341
|
* @returns {void}
|
|
342
342
|
*/
|
|
343
|
+
/**
|
|
344
|
+
* What this file's recent interruptions demand, for a caller that has to decide
|
|
345
|
+
* something with them.
|
|
346
|
+
*
|
|
347
|
+
* Exported because the figures are measured HERE — the reader is the only place
|
|
348
|
+
* that knows how long it waited — while the decisions they feed are made
|
|
349
|
+
* elsewhere: the smallest buffer that hides an interruption goes to the browser,
|
|
350
|
+
* and the speed a step must sustain goes to the quality offer.
|
|
351
|
+
*
|
|
352
|
+
* @param {string} infoHash
|
|
353
|
+
* @param {string} fileName
|
|
354
|
+
* @param {number} segmentSeconds - The session's own segment duration.
|
|
355
|
+
* @returns {{ requiredSpeed: number, worstWaitSec: number, medianIntervalSec: number, samples: number, minimumBufferSec: number } | null}
|
|
356
|
+
*/
|
|
357
|
+
export function supplyFiguresFor(infoHash, fileName, segmentSeconds) {
|
|
358
|
+
const history = supplyWaits.get(`${infoHash ?? "?"}/${fileName ?? "?"}`);
|
|
359
|
+
const demand = requiredSpeedFrom(history ?? []);
|
|
360
|
+
if (!demand) {
|
|
361
|
+
return null;
|
|
362
|
+
}
|
|
363
|
+
const buffer = minimumBufferFrom({
|
|
364
|
+
segmentSeconds,
|
|
365
|
+
worstSupplyWaitSec: demand.worstWaitSec
|
|
366
|
+
});
|
|
367
|
+
return {
|
|
368
|
+
requiredSpeed: demand.requiredSpeed,
|
|
369
|
+
worstWaitSec: demand.worstWaitSec,
|
|
370
|
+
medianIntervalSec: demand.medianIntervalSec,
|
|
371
|
+
samples: demand.samples,
|
|
372
|
+
minimumBufferSec: buffer ? buffer.seconds : null
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
343
376
|
function noteSupplyWait(key, label, waitedMs) {
|
|
344
377
|
const history = supplyWaits.get(key) ?? [];
|
|
345
378
|
history.push({ waitedMs, at: Date.now() });
|
|
@@ -26,7 +26,7 @@ import "./install-webrtc-shim.js";
|
|
|
26
26
|
import { parentPort, workerData } from "node:worker_threads";
|
|
27
27
|
import { createSendStream } from "./channel.js";
|
|
28
28
|
import { createFileClaims } from "./file-claims.js";
|
|
29
|
-
import { readFragments } from "./piece-reader.js";
|
|
29
|
+
import { readFragments, supplyFiguresFor } from "./piece-reader.js";
|
|
30
30
|
import { Command, Event } from "./protocol.js";
|
|
31
31
|
|
|
32
32
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
@@ -358,9 +358,20 @@ async function runCommand(command, params, id) {
|
|
|
358
358
|
|
|
359
359
|
case Command.FILE_STATS: {
|
|
360
360
|
const torrent = await requireTorrent(params.sourceKey);
|
|
361
|
-
|
|
361
|
+
const stats = pool.getFileStats(torrent, params.fileIndex, {
|
|
362
362
|
resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
|
|
363
363
|
});
|
|
364
|
+
// What this file's own interruptions demand, measured by the reader in
|
|
365
|
+
// this thread. It travels with the stats because the caller asking for
|
|
366
|
+
// them is the one that has to decide with them — the browser's smallest
|
|
367
|
+
// safe buffer, and the speed a quality step must sustain. Null until a
|
|
368
|
+
// second interruption has been seen: one wait shows no interval, and an
|
|
369
|
+
// interval invented from one point is exactly what this work removes.
|
|
370
|
+
const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
|
|
371
|
+
return {
|
|
372
|
+
...stats,
|
|
373
|
+
supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4)
|
|
374
|
+
};
|
|
364
375
|
}
|
|
365
376
|
|
|
366
377
|
case Command.PRIORITIZE: {
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the machine can spare for a new encoder — and what must not be
|
|
3
|
+
* charged twice.
|
|
4
|
+
*
|
|
5
|
+
* The numbers are the addon host's own, measured 2026-08-17 while a 240p step
|
|
6
|
+
* ran at 1.01-1.12x against a prediction of 1.83x.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
|
|
12
|
+
import { availableShareFrom, correctForAvailability } from "../services/available-share.js";
|
|
13
|
+
|
|
14
|
+
test("only the work nobody has been charged for is subtracted", () => {
|
|
15
|
+
// The field reading: the box is saturated, our encoder has more than half of
|
|
16
|
+
// it, and the proxy's own work a fifth.
|
|
17
|
+
const availability = availableShareFrom({ systemBusy: 0.99, encoderShare: 0.56, proxyShare: 0.2 });
|
|
18
|
+
|
|
19
|
+
assert.equal(availability.known, true);
|
|
20
|
+
// 0.99 − 0.76 = 0.23 belongs to the kernel, the container and whatever else
|
|
21
|
+
// the owner runs. Our own encoders are priced by the concurrency arithmetic
|
|
22
|
+
// and the proxy's work per megabyte moved; charging them here as well is the
|
|
23
|
+
// double counting that emptied the quality menu in 2.21.0.
|
|
24
|
+
assert.ok(Math.abs(availability.unattributed - 0.23) < 1e-9, `${availability.unattributed}`);
|
|
25
|
+
assert.ok(Math.abs(availability.share - 0.77) < 1e-9, `${availability.share}`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a quiet machine is not corrected at all", () => {
|
|
29
|
+
const availability = availableShareFrom({ systemBusy: 0.05, encoderShare: 0.04, proxyShare: 0.01 });
|
|
30
|
+
assert.equal(availability.unattributed, 0);
|
|
31
|
+
assert.equal(availability.share, 1);
|
|
32
|
+
assert.equal(correctForAvailability(4, availability), 4);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("a host that cannot say says so, and nothing is corrected", () => {
|
|
36
|
+
// Not every host has /proc. An uncorrected figure is honest; a figure
|
|
37
|
+
// corrected by an invented share is not.
|
|
38
|
+
const availability = availableShareFrom({ systemBusy: null, encoderShare: null, proxyShare: null });
|
|
39
|
+
assert.equal(availability.known, false);
|
|
40
|
+
assert.equal(availability.share, 1);
|
|
41
|
+
assert.equal(correctForAvailability(2.5, availability), 2.5, "an unknown machine leaves the prediction alone");
|
|
42
|
+
assert.equal(availableShareFrom().known, false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("readings that overlap by a rounding do not produce a negative machine", () => {
|
|
46
|
+
// The two samples are taken microseconds apart, so our own share can come out
|
|
47
|
+
// fractionally above the system total.
|
|
48
|
+
const availability = availableShareFrom({ systemBusy: 0.60, encoderShare: 0.58, proxyShare: 0.05 });
|
|
49
|
+
assert.equal(availability.unattributed, 0);
|
|
50
|
+
assert.equal(availability.share, 1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("a machine given over entirely to other work leaves nothing", () => {
|
|
54
|
+
const availability = availableShareFrom({ systemBusy: 1, encoderShare: 0, proxyShare: 0 });
|
|
55
|
+
assert.equal(availability.share, 0);
|
|
56
|
+
assert.equal(correctForAvailability(4, availability), 0, "a step cannot be offered on a machine with nothing left");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("the correction is proportional, and only ever downwards", () => {
|
|
60
|
+
const availability = availableShareFrom({ systemBusy: 0.99, encoderShare: 0.56, proxyShare: 0.2 });
|
|
61
|
+
// The field case: a step predicted at 1.83x on a quiet box.
|
|
62
|
+
const corrected = correctForAvailability(1.83, availability);
|
|
63
|
+
assert.ok(Math.abs(corrected - 1.4091) < 0.001, `${corrected}`);
|
|
64
|
+
// And the honest note this test exists to record: 1.41x is still above the
|
|
65
|
+
// 1.01-1.12x that step actually ran at. This correction closes part of the
|
|
66
|
+
// gap, not all of it — which is why the per-step field comparison ships with
|
|
67
|
+
// it rather than after it.
|
|
68
|
+
assert.ok(corrected > 1.12, "the remaining difference is what the field check is for");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("nothing is corrected without a prediction to correct", () => {
|
|
72
|
+
const availability = availableShareFrom({ systemBusy: 0.99, encoderShare: 0.5, proxyShare: 0.2 });
|
|
73
|
+
assert.equal(correctForAvailability(0, availability), 0);
|
|
74
|
+
assert.equal(correctForAvailability(Number.NaN, availability), Number.NaN);
|
|
75
|
+
assert.equal(correctForAvailability(-1, availability), -1);
|
|
76
|
+
});
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file What the supply's own interruptions require of a quality step, and of
|
|
3
|
-
* the buffer in front of the viewer.
|
|
4
|
-
*
|
|
5
|
-
* Both figures are chosen by hand today — a speed margin of 1.5 and a 25-second
|
|
6
|
-
* prebuffer — and both stand for a quantity that is measured every few seconds
|
|
7
|
-
* anyway: how long a read waits for a piece, and how often that happens.
|
|
8
|
-
*
|
|
9
|
-
* The arithmetic, from the measurements of 2026-08-17:
|
|
10
|
-
*
|
|
11
|
-
* A step producing at speed `v` gains `v - 1` seconds of cushion per second
|
|
12
|
-
* of playback. An interruption of `W` seconds costs `W`. So a step survives
|
|
13
|
-
* its own supply exactly when it can rebuild what one interruption takes
|
|
14
|
-
* before the next one arrives:
|
|
15
|
-
*
|
|
16
|
-
* (v - 1) × T > W ⇔ v > 1 + W / T
|
|
17
|
-
*
|
|
18
|
-
* On the field torrent that day: waits of 1.49 s median, 3.16 s worst, one
|
|
19
|
-
* every 2.22 s → a required speed of 1.67 against the 1.5 chosen by hand, and
|
|
20
|
-
* against 1.05 actually measured, which is why it stalled. A copy running at
|
|
21
|
-
* 8x gains 15.5 s between interruptions against 1.5-4.8 s lost, which is the
|
|
22
|
-
* same formula explaining why a copy never stalls.
|
|
23
|
-
*
|
|
24
|
-
* The buffer follows from the same numbers: it must hold the segment being
|
|
25
|
-
* played, whole, plus the worst interruption that can arrive before it can be
|
|
26
|
-
* refilled — whichever source that interruption comes from.
|
|
27
|
-
*
|
|
28
|
-
* B_min = segment duration + max(W_supply, D_production, T_transfer)
|
|
29
|
-
*
|
|
30
|
-
* 7-9 s on that torrent, against the 25 s in the browser today.
|
|
31
|
-
*
|
|
32
|
-
* Every term is measured, none is chosen, and the figures rise by themselves
|
|
33
|
-
* when a session's interruptions grow — which is what makes lowering the buffer
|
|
34
|
-
* safe. Pure: no torrent, no clock of its own, no state beyond the samples it
|
|
35
|
-
* is given.
|
|
36
|
-
*/
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* How many recent interruptions are kept per file. Enough for a median to mean
|
|
40
|
-
* something, short enough that a swarm which has recovered is not judged by how
|
|
41
|
-
* it behaved ten minutes ago.
|
|
42
|
-
*/
|
|
43
|
-
const SAMPLE_LIMIT = 24;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* A reading of one interruption: how long the reader waited, and when.
|
|
47
|
-
*
|
|
48
|
-
* @typedef {object} Interruption
|
|
49
|
-
* @property {number} waitedMs
|
|
50
|
-
* @property {number} at - Epoch milliseconds when the wait ENDED.
|
|
51
|
-
*/
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Add one interruption to a record, keeping only the recent ones.
|
|
55
|
-
*
|
|
56
|
-
* @param {Interruption[]} samples - Existing readings, oldest first.
|
|
57
|
-
* @param {Interruption} interruption
|
|
58
|
-
* @returns {Interruption[]} A new array; the input is not modified.
|
|
59
|
-
*/
|
|
60
|
-
export function withInterruption(samples, interruption) {
|
|
61
|
-
const kept = Array.isArray(samples) ? samples : [];
|
|
62
|
-
if (!Number.isFinite(interruption?.waitedMs) || !Number.isFinite(interruption?.at)) {
|
|
63
|
-
return kept;
|
|
64
|
-
}
|
|
65
|
-
const next = [...kept, { waitedMs: Math.max(0, interruption.waitedMs), at: interruption.at }];
|
|
66
|
-
return next.length > SAMPLE_LIMIT ? next.slice(next.length - SAMPLE_LIMIT) : next;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* @param {number[]} values
|
|
71
|
-
* @returns {number}
|
|
72
|
-
*/
|
|
73
|
-
function median(values) {
|
|
74
|
-
if (values.length === 0) {
|
|
75
|
-
return 0;
|
|
76
|
-
}
|
|
77
|
-
const sorted = [...values].sort((left, right) => left - right);
|
|
78
|
-
return sorted[Math.floor(sorted.length / 2)];
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* What the recent interruptions amount to.
|
|
83
|
-
*
|
|
84
|
-
* The interval between interruptions is measured between the readings
|
|
85
|
-
* themselves, so a file that is read steadily and rarely blocked reports a long
|
|
86
|
-
* interval and asks little of the step.
|
|
87
|
-
*
|
|
88
|
-
* @param {Interruption[]} samples
|
|
89
|
-
* @returns {{ samples: number, medianWaitSeconds: number, worstWaitSeconds: number, medianGapSeconds: number }}
|
|
90
|
-
*/
|
|
91
|
-
export function summariseInterruptions(samples) {
|
|
92
|
-
const readings = Array.isArray(samples) ? samples : [];
|
|
93
|
-
if (readings.length === 0) {
|
|
94
|
-
return { samples: 0, medianWaitSeconds: 0, worstWaitSeconds: 0, medianGapSeconds: 0 };
|
|
95
|
-
}
|
|
96
|
-
const waits = readings.map((entry) => entry.waitedMs / 1000);
|
|
97
|
-
const gaps = [];
|
|
98
|
-
for (let index = 1; index < readings.length; index += 1) {
|
|
99
|
-
const gap = (readings[index].at - readings[index - 1].at) / 1000;
|
|
100
|
-
if (gap > 0) {
|
|
101
|
-
gaps.push(gap);
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
return {
|
|
105
|
-
samples: readings.length,
|
|
106
|
-
medianWaitSeconds: median(waits),
|
|
107
|
-
worstWaitSeconds: Math.max(...waits),
|
|
108
|
-
medianGapSeconds: median(gaps)
|
|
109
|
-
};
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* The speed a step must run at to survive this supply, or null when the supply
|
|
114
|
-
* has not interrupted often enough to say.
|
|
115
|
-
*
|
|
116
|
-
* Null is a real answer and must not be replaced by a number: with one reading
|
|
117
|
-
* there is no interval at all, and inventing one is how a margin comes to be
|
|
118
|
-
* chosen by hand again. A caller with no figure keeps whatever it used before
|
|
119
|
-
* and says so.
|
|
120
|
-
*
|
|
121
|
-
* The worst wait is used rather than the median, because a step that only
|
|
122
|
-
* survives the typical interruption stalls on the others — and a stall is what
|
|
123
|
-
* the viewer sees, not an average.
|
|
124
|
-
*
|
|
125
|
-
* @param {{ samples: number, worstWaitSeconds: number, medianGapSeconds: number }} summary
|
|
126
|
-
* @returns {number | null}
|
|
127
|
-
*/
|
|
128
|
-
export function requiredSpeedFrom(summary) {
|
|
129
|
-
if (!summary || summary.samples < 2 || !(summary.medianGapSeconds > 0)) {
|
|
130
|
-
return null;
|
|
131
|
-
}
|
|
132
|
-
return 1 + summary.worstWaitSeconds / summary.medianGapSeconds;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* The smallest buffer at which no spinner appears: the segment being played,
|
|
137
|
-
* whole, plus the worst interruption that can arrive before it can be refilled.
|
|
138
|
-
*
|
|
139
|
-
* Each term is the worst OBSERVED over a recent window, and a term nobody has
|
|
140
|
-
* measured contributes nothing rather than a guess.
|
|
141
|
-
*
|
|
142
|
-
* @param {{ segmentSeconds: number, supplySeconds?: number, productionSeconds?: number, transferSeconds?: number }} terms
|
|
143
|
-
* @returns {number}
|
|
144
|
-
*/
|
|
145
|
-
export function minimumBufferSeconds(terms) {
|
|
146
|
-
const segment = Number.isFinite(terms?.segmentSeconds) && terms.segmentSeconds > 0
|
|
147
|
-
? terms.segmentSeconds
|
|
148
|
-
: 0;
|
|
149
|
-
const worst = Math.max(
|
|
150
|
-
Number.isFinite(terms?.supplySeconds) ? terms.supplySeconds : 0,
|
|
151
|
-
Number.isFinite(terms?.productionSeconds) ? terms.productionSeconds : 0,
|
|
152
|
-
Number.isFinite(terms?.transferSeconds) ? terms.transferSeconds : 0
|
|
153
|
-
);
|
|
154
|
-
return segment + Math.max(0, worst);
|
|
155
|
-
}
|
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file The speed a step needs, and the buffer a viewer needs, from what the
|
|
3
|
-
* supply actually did.
|
|
4
|
-
*
|
|
5
|
-
* The field numbers these are checked against (2026-08-17, one torrent, one
|
|
6
|
-
* session): waits of 1.49 s median and 3.16 s worst, one every 2.22 s. The
|
|
7
|
-
* margin chosen by hand was 1.5; the arithmetic says 1.67; the step measured
|
|
8
|
-
* 1.05 and stalled.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import assert from "node:assert/strict";
|
|
12
|
-
import test from "node:test";
|
|
13
|
-
|
|
14
|
-
import {
|
|
15
|
-
minimumBufferSeconds,
|
|
16
|
-
requiredSpeedFrom,
|
|
17
|
-
summariseInterruptions,
|
|
18
|
-
withInterruption
|
|
19
|
-
} from "../services/torrent-worker/supply-interruptions.js";
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Interruptions of `waitMs`, one every `gapMs`.
|
|
23
|
-
*
|
|
24
|
-
* @param {number[]} waitsMs
|
|
25
|
-
* @param {number} gapMs
|
|
26
|
-
* @returns {Array<{ waitedMs: number, at: number }>}
|
|
27
|
-
*/
|
|
28
|
-
function series(waitsMs, gapMs) {
|
|
29
|
-
let samples = [];
|
|
30
|
-
let at = 1_000_000;
|
|
31
|
-
for (const waitedMs of waitsMs) {
|
|
32
|
-
samples = withInterruption(samples, { waitedMs, at });
|
|
33
|
-
at += gapMs;
|
|
34
|
-
}
|
|
35
|
-
return samples;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
test("the field session's numbers produce the field session's answer", () => {
|
|
39
|
-
// Waits around 1.49 s with a worst of 3.16 s, one every 2.22 s.
|
|
40
|
-
const samples = series([1490, 1490, 3160, 1490, 1490], 2220);
|
|
41
|
-
const summary = summariseInterruptions(samples);
|
|
42
|
-
|
|
43
|
-
assert.equal(summary.worstWaitSeconds, 3.16);
|
|
44
|
-
assert.equal(summary.medianGapSeconds, 2.22);
|
|
45
|
-
|
|
46
|
-
const required = requiredSpeedFrom(summary);
|
|
47
|
-
assert.ok(required !== null);
|
|
48
|
-
assert.ok(
|
|
49
|
-
Math.abs(required - 2.42) < 0.01,
|
|
50
|
-
`1 + 3.16/2.22 = ${required?.toFixed(2)} — the supply asks for it, nobody chose it`
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test("a supply that rarely interrupts asks for almost nothing", () => {
|
|
55
|
-
// One short wait every couple of minutes: a step barely faster than realtime
|
|
56
|
-
// rebuilds the cushion long before the next one.
|
|
57
|
-
const summary = summariseInterruptions(series([200, 200, 200], 120_000));
|
|
58
|
-
const required = requiredSpeedFrom(summary);
|
|
59
|
-
assert.ok(required !== null && required < 1.01, `asked ${required}`);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("a supply that interrupts constantly asks for a great deal", () => {
|
|
63
|
-
const summary = summariseInterruptions(series([4000, 4000, 4000], 1000));
|
|
64
|
-
assert.equal(requiredSpeedFrom(summary), 5);
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
test("too little evidence is answered with nothing, never with a number", () => {
|
|
68
|
-
// The whole point of deriving the margin is that it stops being invented. One
|
|
69
|
-
// reading has no interval at all, and a caller must keep what it had.
|
|
70
|
-
assert.equal(requiredSpeedFrom(summariseInterruptions([])), null);
|
|
71
|
-
assert.equal(requiredSpeedFrom(summariseInterruptions(series([1000], 0))), null);
|
|
72
|
-
assert.equal(requiredSpeedFrom(null), null);
|
|
73
|
-
assert.equal(requiredSpeedFrom({ samples: 9, worstWaitSeconds: 3, medianGapSeconds: 0 }), null);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test("the worst wait decides, not the typical one", () => {
|
|
77
|
-
// A step that survives the median interruption still stalls on the others,
|
|
78
|
-
// and a stall is what the viewer sees.
|
|
79
|
-
const summary = summariseInterruptions(series([100, 100, 5000, 100], 1000));
|
|
80
|
-
assert.equal(summary.medianWaitSeconds, 0.1);
|
|
81
|
-
assert.equal(summary.worstWaitSeconds, 5);
|
|
82
|
-
assert.equal(requiredSpeedFrom(summary), 6);
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
test("only the recent interruptions are judged", () => {
|
|
86
|
-
// A swarm that has recovered must not be sentenced by how it behaved ten
|
|
87
|
-
// minutes ago, so the record is bounded.
|
|
88
|
-
let samples = series(Array.from({ length: 40 }, () => 9000), 1000);
|
|
89
|
-
samples = withInterruption(samples, { waitedMs: 10, at: 2_000_000 });
|
|
90
|
-
assert.ok(samples.length <= 24, `kept ${samples.length}`);
|
|
91
|
-
assert.equal(samples[samples.length - 1].waitedMs, 10);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
test("a reading without a time or a duration is not a reading", () => {
|
|
95
|
-
const samples = withInterruption([], { waitedMs: Number.NaN, at: 1 });
|
|
96
|
-
assert.deepEqual(samples, []);
|
|
97
|
-
assert.deepEqual(withInterruption([], { waitedMs: 100 }), []);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
test("the minimum buffer is one segment plus the worst interruption", () => {
|
|
101
|
-
// The field torrent: 4 s segments, a worst supply wait of 3.16 s, production
|
|
102
|
-
// gaps within the segment length, transfer measured in milliseconds.
|
|
103
|
-
const seconds = minimumBufferSeconds({
|
|
104
|
-
segmentSeconds: 4,
|
|
105
|
-
supplySeconds: 3.16,
|
|
106
|
-
productionSeconds: 1.2,
|
|
107
|
-
transferSeconds: 0.066
|
|
108
|
-
});
|
|
109
|
-
assert.ok(Math.abs(seconds - 7.16) < 0.001, `${seconds}s against the 25 s chosen by hand`);
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
test("whichever source is worst is the one that sizes the buffer", () => {
|
|
113
|
-
// A step at 1.0x makes production the binding term even on a swarm that never
|
|
114
|
-
// stalls, which is exactly the case a supply-only figure would miss.
|
|
115
|
-
assert.equal(
|
|
116
|
-
minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: 0.2, productionSeconds: 6 }),
|
|
117
|
-
10
|
|
118
|
-
);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
test("a term nobody measured contributes nothing, not a guess", () => {
|
|
122
|
-
assert.equal(minimumBufferSeconds({ segmentSeconds: 4 }), 4);
|
|
123
|
-
assert.equal(minimumBufferSeconds({ segmentSeconds: 4, supplySeconds: Number.NaN }), 4);
|
|
124
|
-
assert.equal(minimumBufferSeconds({}), 0);
|
|
125
|
-
});
|