@torrent-tv/proxy 2.34.0 → 2.36.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.
@@ -25,6 +25,7 @@ import { mkdtempSync, readdirSync, rmSync, statSync } from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
27
  import { fitDecodeCost } from "./decode-cost-fit.js";
28
+ import { penaltiesFrom } from "./contention.js";
28
29
  import { fileURLToPath } from "node:url";
29
30
  import {
30
31
  parseFfmpegBitrateKbps,
@@ -156,21 +157,18 @@ const ENCODE_BENCHMARK_MAX_PLAUSIBLE_SPEED = 1000;
156
157
  const ENCODE_BENCHMARK_TIMEOUT_MS = 10_000;
157
158
  /** Progress reports arrive line by line. */
158
159
  const NEWLINE = String.fromCharCode(10);
159
- // Require the predicted speed to clear realtime by this much. The benchmarks
160
- // run at startup with an idle CPU; during playback ffmpeg competes with
161
- // in-process WebTorrent (download + hashing) and delivery, so real throughput
162
- // is lower, and the margin covers that plus complex scenes.
163
- //
164
- // It was 1.8 while the prediction counted ENCODING only and was therefore
165
- // several times too optimistic on a re-encode; with the decode term the
166
- // prediction is within ~13 % of measured, so the margin no longer has to stand
167
- // in for a missing term as well as for load.
168
- const PRESET_SPEED_MARGIN = 1.5;
169
- // The bar for a prediction that has NO decode term — a host whose calibration
170
- // clips are missing, or whose fit was rejected. That figure is the one the
171
- // margin was 1.8 for, and lowering it there would make an uncalibrated host
172
- // more permissive than it was before any of this existed.
173
- const ENCODE_ONLY_SPEED_MARGIN = 1.8;
160
+ // Producing one second of video per second of clock. Not a margin and not a
161
+ // choice the definition of keeping up, and the bar when nothing better is
162
+ // known about the supply this step will meet.
163
+ const REALTIME = 1;
164
+ // The bar where decoding CANNOT be priced — no calibration fit, or a source the
165
+ // probe said too little about. This one is not measured and cannot be: the
166
+ // prediction it guards counts encoding only, which on the field host was
167
+ // several times optimistic, and there is no reading on such a host to correct
168
+ // it with. It is left at the figure it has had since before decoding was
169
+ // priced, because lowering it to realtime would make the least-measured hosts
170
+ // the most permissive. Where decoding IS priced, nothing chosen remains.
171
+ const UNPRICED_DECODE_BAR = 1.8;
174
172
 
175
173
  /**
176
174
  * @param {number} targetWidth
@@ -751,6 +749,88 @@ function parseClipCharacteristics(stderr) {
751
749
  * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string }} options
752
750
  * @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
753
751
  */
752
+ /**
753
+ * What a second job costs on this host, measured rather than assumed.
754
+ *
755
+ * The budget adds seconds of work per second of content — this encode, plus
756
+ * that decode, plus what is already committed — and the addon host contradicted
757
+ * that directly on 2026-08-18: decoding ran at 2.10-2.25x alone, 0.79-0.90x
758
+ * with one encoder beside it and 0.56-0.64x with two. The same work costs 2.6×
759
+ * more for having company. Heat is not the cause (the hot idle machine was the
760
+ * fastest reading of all); four cores sharing one path to memory is.
761
+ *
762
+ * So it is measured the way everything else here is: the same clip decoded
763
+ * alone, then decoded again while an encoder of the same clip runs beside it.
764
+ * The ratio is the penalty. The encoder is stopped as soon as the reading is
765
+ * taken, and the whole thing costs one decode plus one short encode.
766
+ *
767
+ * @param {{ ffmpegBin: string, logger?: { info: (m: string) => void, warn: (m: string) => void }, clipsDir?: string, upTo?: number }} options
768
+ * @returns {Promise<Map<number, number> | null>} Penalties by how many other
769
+ * jobs were running, or null when the readings could not be taken.
770
+ */
771
+ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR, upTo = 2 }) {
772
+ const log = logger ?? { info: () => {}, warn: () => {} };
773
+ // The cheapest clip in the set: this measures the MACHINE's behaviour under
774
+ // company, not the clip's own cost, so the smallest one says it soonest.
775
+ const clip = path.join(clipsDir, "cal-h264-480-lo.mp4");
776
+ const startedAt = Date.now();
777
+ const alone = await measureDecodeSlope(ffmpegBin, clip);
778
+ if (!alone) {
779
+ log.warn("hwaccel: contention could not be measured; costs will be added as though jobs were independent");
780
+ return null;
781
+ }
782
+ /** @type {Array<{ others: number, speed: number }>} */
783
+ const beside = [];
784
+ /** @type {import("node:child_process").ChildProcess[]} */
785
+ const load = [];
786
+ try {
787
+ for (let others = 1; others <= Math.max(1, upTo); others += 1) {
788
+ load.push(
789
+ spawn(
790
+ ffmpegBin,
791
+ [
792
+ "-hide_banner", "-loglevel", "error", "-nostats",
793
+ "-stream_loop", "-1", "-i", clip,
794
+ "-an", "-c:v", "libx264", "-preset", "fast", "-f", "null", "-"
795
+ ],
796
+ { stdio: ["ignore", "ignore", "ignore"], windowsHide: true }
797
+ )
798
+ );
799
+ // Let the encoder reach its own speed before reading anything: an encode
800
+ // measured in its first moments is measuring the process starting.
801
+ await new Promise((resolve) => {
802
+ setTimeout(resolve, 2_000);
803
+ });
804
+ const withCompany = await measureDecodeSlope(ffmpegBin, clip);
805
+ if (withCompany) {
806
+ beside.push({ others, speed: withCompany.speed });
807
+ }
808
+ }
809
+ } finally {
810
+ for (const child of load) {
811
+ try {
812
+ child.kill("SIGKILL");
813
+ } catch {
814
+ // Already gone: the reading is what mattered, and nothing else uses it.
815
+ }
816
+ }
817
+ }
818
+ const penalties = penaltiesFrom(alone.speed, beside);
819
+ if (!penalties) {
820
+ log.warn("hwaccel: contention readings said nothing; costs will be added as though jobs were independent");
821
+ return null;
822
+ }
823
+ log.info(
824
+ `hwaccel: a second job costs ${[...penalties.entries()]
825
+ .map(([others, penalty]) => `${penalty.toFixed(2)}x beside ${others}`)
826
+ .join(", ")} ` +
827
+ `(decode alone ${alone.speed.toFixed(2)}x, ` +
828
+ `${beside.map((reading) => `${reading.speed.toFixed(2)}x beside ${reading.others}`).join(", ")}, ` +
829
+ `measured in ${((Date.now() - startedAt) / 1000).toFixed(1)}s)`
830
+ );
831
+ return penalties;
832
+ }
833
+
754
834
  export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
755
835
  const log = logger ?? { info: () => {}, warn: () => {} };
756
836
  const startedAllAt = Date.now();
@@ -981,7 +1061,7 @@ export function predictedRealtimeSpeed({
981
1061
  * The encoder figure is the FASTEST benchmarked preset: it is the best this
982
1062
  * host can do, so a rung it cannot hold cannot be held at any quality setting.
983
1063
  *
984
- * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number }} params
1064
+ * @param {{ benchmark: Array<{ preset: string, pixelsPerSec: number }>, decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, outputPixelsPerSec: number, requiredSpeed?: number | null }} params
985
1065
  * @returns {{ speed: number | null, sustainable: boolean }}
986
1066
  */
987
1067
  export function canSustainOutput({
@@ -990,7 +1070,8 @@ export function canSustainOutput({
990
1070
  source = null,
991
1071
  outputPixelsPerSec,
992
1072
  observedDecodeCostSec = null,
993
- concurrentCostSec = 0
1073
+ concurrentCostSec = 0,
1074
+ requiredSpeed = null
994
1075
  }) {
995
1076
  if (!Array.isArray(benchmark) || benchmark.length === 0) {
996
1077
  // Nothing measured on this host: the budget cannot refuse what it cannot
@@ -1031,11 +1112,45 @@ export function canSustainOutput({
1031
1112
  if (speed === null) {
1032
1113
  return { speed: null, sustainable: true };
1033
1114
  }
1034
- return { speed, sustainable: speed >= PRESET_SPEED_MARGIN };
1115
+ return { speed, sustainable: speed >= speedBar(requiredSpeed) };
1116
+ }
1117
+
1118
+ /**
1119
+ * The speed a step has to reach to be worth offering.
1120
+ *
1121
+ * Realtime is not enough on its own: a step that produces exactly one second
1122
+ * per second never recovers the seconds lost while its reader waits for the
1123
+ * swarm, so it survives its own supply only if what it gains between
1124
+ * interruptions covers what one interruption costs. That is measured per file
1125
+ * and per swarm by the reader — `1 + worst wait / median interval`, in
1126
+ * `supply-margin.js` — and on the field torrent of 2026-08-17 it came to 1.67
1127
+ * against the 1.5 that used to stand here, and to 4.04-8.14 on a torrent whose
1128
+ * swarm no encoder could have kept up with.
1129
+ *
1130
+ * Where that figure does not exist yet — fewer than two interruptions measured
1131
+ * — the bar is realtime. It is the one thing that can be said without
1132
+ * measuring the swarm, and the offer is restated as soon as the reader has
1133
+ * something to say.
1134
+ *
1135
+ * @param {number | null | undefined} requiredSpeed - What this file's own
1136
+ * interruptions demand, when they have been measured.
1137
+ * @returns {number}
1138
+ */
1139
+ export function speedBar(requiredSpeed) {
1140
+ return Number.isFinite(requiredSpeed) && requiredSpeed > REALTIME ? requiredSpeed : REALTIME;
1035
1141
  }
1036
1142
 
1037
- /** The margin a predicted speed must clear to be offered. */
1038
- export const REALTIME_SPEED_MARGIN = PRESET_SPEED_MARGIN;
1143
+ /**
1144
+ * The bar for a cost description — the supply's demand where decoding is
1145
+ * priced, and never below the unpriced-decode bar where it is not.
1146
+ *
1147
+ * @param {{ decodeModel?: object | null, source?: object | null, observedDecodeCostSec?: number | null, requiredSpeed?: number | null }} cost
1148
+ * @returns {number}
1149
+ */
1150
+ function barFor(cost) {
1151
+ const measured = speedBar(cost?.requiredSpeed);
1152
+ return isDecodePriced(cost) ? measured : Math.max(UNPRICED_DECODE_BAR, measured);
1153
+ }
1039
1154
 
1040
1155
  /**
1041
1156
  * Benchmark software libx264 presets on this host. Encodes a short synthetic
@@ -1294,7 +1409,7 @@ async function decodeToRawFrames(ffmpegBin, log) {
1294
1409
  *
1295
1410
  * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1296
1411
  * @param {number} pixelsPerSecNeeded
1297
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1412
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1298
1413
  * @returns {string}
1299
1414
  */
1300
1415
  /**
@@ -1322,8 +1437,7 @@ export function pickSoftwarePreset(benchmark, pixelsPerSecNeeded, cost = {}) {
1322
1437
  const observed = Number.isFinite(cost?.observedDecodeCostSec) && cost.observedDecodeCostSec > 0
1323
1438
  ? cost.observedDecodeCostSec
1324
1439
  : null;
1325
- const priced = isDecodePriced(cost);
1326
- const bar = priced ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1440
+ const bar = barFor(cost);
1327
1441
  // The FIRST entry that clears the bar wins — the list is in quality order, so
1328
1442
  // that is the best picture this host can hold. Every entry is examined rather
1329
1443
  // than the walk stopping at the first miss, because the measurements do not
@@ -1410,9 +1524,9 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1410
1524
  * Choose the software encode settings (resolution + preset) that fit the
1411
1525
  * realtime budget on this host. From the resolution ladder (ceiling downward),
1412
1526
  * pick the HIGHEST rung whose encode throughput — predicted from the startup
1413
- * benchmark's fastest preset — clears realtime × PRESET_SPEED_MARGIN. Then, at
1414
- * that resolution, pick the highest-quality preset that still clears the
1415
- * margin. When even the lowest rung cannot clear it, use the lowest rung with
1527
+ * benchmark's fastest preset — clears the speed this file's own supply
1528
+ * demands (`speedBar`). Then, at that resolution, pick the highest-quality
1529
+ * preset that still clears it. When even the lowest rung cannot clear it, use the lowest rung with
1416
1530
  * the fastest preset (best effort — a smaller picture beats sub-realtime
1417
1531
  * playback at full size). Returns null when no benchmark or ceiling is
1418
1532
  * available (the caller keeps the ceiling resolution and the default preset).
@@ -1420,7 +1534,7 @@ export function buildResolutionLadder(ceilingWidth, ceilingHeight) {
1420
1534
  * @param {Array<{ preset: string, pixelsPerSec: number }>} benchmark - slowest→fastest
1421
1535
  * @param {{ width: number, height: number }} ceiling
1422
1536
  * @param {number} outputFps
1423
- * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null }} [cost]
1537
+ * @param {{ decodeModel?: object | null, source?: { megapixelsPerSecond: number, megabitsPerSecond: number } | null, requiredSpeed?: number | null }} [cost]
1424
1538
  * @returns {{ width: number, height: number, preset: string, ladder: Array<{ width: number, height: number }>, rungIndex: number } | null}
1425
1539
  */
1426
1540
  export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost = {}) {
@@ -1433,7 +1547,7 @@ export function chooseSoftwareEncodeSettings(benchmark, ceiling, outputFps, cost
1433
1547
  return null;
1434
1548
  }
1435
1549
  const fastest = cheapestPresetPixelsPerSec(benchmark); // the cheapest preset's throughput
1436
- const bar = isDecodePriced(cost) ? PRESET_SPEED_MARGIN : ENCODE_ONLY_SPEED_MARGIN;
1550
+ const bar = barFor(cost);
1437
1551
  let chosenIndex = ladder.length - 1; // default: lowest rung (best effort)
1438
1552
  for (let i = 0; i < ladder.length; i += 1) {
1439
1553
  const speed = predictedRealtimeSpeed({
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @file Publishing a value that is learned from repeated readings.
3
+ *
4
+ * Four costs are learned this way — what a file costs to decode, what copying
5
+ * it costs, what a soundtrack costs, what the torrent costs per megabyte — and
6
+ * each has the same two decisions to make: which figure the readings amount to,
7
+ * and when a new figure replaces the published one.
8
+ *
9
+ * The first is the median, because one starved moment must not drag the answer.
10
+ *
11
+ * The second used to be "more than five per cent", a figure nobody measured.
12
+ * It is now the scatter of the readings themselves: a median that has moved
13
+ * further than its own readings disagree with each other has moved for a
14
+ * reason, and one that has not is the same answer read again. Republishing
15
+ * costs something real — every session recomputes its offer, on the path that
16
+ * serves every playlist, init and segment — so the question is worth asking,
17
+ * but it is answered from the measurements rather than from a constant.
18
+ */
19
+
20
+ /**
21
+ * The middle of a set of readings, or null when there are none.
22
+ *
23
+ * @param {number[]} values
24
+ * @returns {number | null}
25
+ */
26
+ export function medianOf(values) {
27
+ const usable = (Array.isArray(values) ? values : []).filter((value) => Number.isFinite(value));
28
+ if (usable.length === 0) {
29
+ return null;
30
+ }
31
+ const sorted = [...usable].sort((left, right) => left - right);
32
+ const middle = Math.floor(sorted.length / 2);
33
+ return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
34
+ }
35
+
36
+ /**
37
+ * How far the readings sit from their own middle — the median of their
38
+ * distances to it.
39
+ *
40
+ * The median distance rather than the mean one, for the same reason the value
41
+ * itself is a median: a single disturbed reading describes the disturbance, not
42
+ * the host.
43
+ *
44
+ * @param {number[]} values
45
+ * @returns {number | null} Null when there is nothing to measure. Zero is a
46
+ * real answer: readings that all agree disagree by nothing.
47
+ */
48
+ export function scatterOf(values) {
49
+ const middle = medianOf(values);
50
+ if (middle === null) {
51
+ return null;
52
+ }
53
+ const distances = (Array.isArray(values) ? values : [])
54
+ .filter((value) => Number.isFinite(value))
55
+ .map((value) => Math.abs(value - middle));
56
+ return medianOf(distances);
57
+ }
58
+
59
+ /**
60
+ * Whether a newly computed median should replace the published one.
61
+ *
62
+ * @param {number | null} published - What every session is answering with now.
63
+ * @param {number} median - What the readings say today.
64
+ * @param {number[]} readings - The readings that median came from.
65
+ * @returns {boolean}
66
+ */
67
+ export function movedBeyondScatter(published, median, readings) {
68
+ if (!Number.isFinite(median)) {
69
+ return false;
70
+ }
71
+ if (!Number.isFinite(published)) {
72
+ return true; // nothing published yet; the first answer is always news
73
+ }
74
+ const scatter = scatterOf(readings);
75
+ if (scatter === null) {
76
+ return true;
77
+ }
78
+ return Math.abs(median - published) > scatter;
79
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * @file What the torrent itself costs this machine, per megabyte it moves.
3
+ *
4
+ * Downloading, verifying every piece and pushing segments down a data channel
5
+ * are work on the same box as the encoder, and they scale with what is moved.
6
+ * The reading is taken only while nothing is encoding, because that is the one
7
+ * moment the spending needs no arithmetic to attribute: what the process uses
8
+ * then is the torrent's.
9
+ *
10
+ * Except that it is not all the torrent's, and that is what this file exists
11
+ * for. Measured on the addon host 2026-08-18, one session reported **145.4 ms
12
+ * per megabyte over 8.7 MB** and **23.1 ms per megabyte over 54 MB** — the same
13
+ * host, the same minute, a sixfold disagreement that follows the size of the
14
+ * interval rather than anything about the torrent. A process with nothing to do
15
+ * still runs its timers, its tunnel and its session sweeps, and that draw does
16
+ * not shrink when fewer megabytes move: divided by a small number it swamps the
17
+ * answer. A minimum-megabytes threshold stood against exactly this and did not
18
+ * hold, because a chosen number was standing in for a measured one.
19
+ *
20
+ * The draw is measurable. An interval in which nothing encodes and the torrents
21
+ * move no bytes at all costs whatever this process spends anyway; subtract that
22
+ * from an interval which did move bytes, and what remains is the torrent's.
23
+ */
24
+
25
+ /**
26
+ * The share of one core this process draws while it has nothing to do.
27
+ *
28
+ * @param {{ cpuSeconds: number, elapsedSeconds: number }} interval - CPU seconds
29
+ * already divided by the number of cores, so the figure is comparable with
30
+ * the wall seconds every other cost in the budget is stated in.
31
+ * @returns {number | null} Null when the interval cannot be divided.
32
+ */
33
+ export function baseDrawFrom({ cpuSeconds, elapsedSeconds }) {
34
+ if (!Number.isFinite(cpuSeconds) || !Number.isFinite(elapsedSeconds) || !(elapsedSeconds > 0)) {
35
+ return null;
36
+ }
37
+ if (!(cpuSeconds >= 0)) {
38
+ return null;
39
+ }
40
+ return cpuSeconds / elapsedSeconds;
41
+ }
42
+
43
+ /**
44
+ * What one megabyte cost, once the draw that would have been spent anyway is
45
+ * taken off.
46
+ *
47
+ * The draw is subtracted, so what is left is only as certain as the draw is.
48
+ * The draw's own readings disagree with each other by a measured amount, and
49
+ * over an interval that disagreement is worth `scatter × elapsed` seconds of
50
+ * CPU: a reading whose remainder is smaller than that measures the wobble in
51
+ * the subtraction rather than the torrent. This is what the removed
52
+ * minimum-megabytes threshold was reaching for, arrived at from the readings
53
+ * instead of from a chosen size — a small interval fails it because little was
54
+ * moved in it, and a large one passes on the same arithmetic.
55
+ *
56
+ * @param {{ cpuSeconds: number, elapsedSeconds: number, megabytes: number, baseDraw: number | null, drawScatter?: number | null }} interval
57
+ * @returns {number | null} Seconds of work per megabyte, or null when this
58
+ * interval cannot say — no base draw measured yet, nothing moved, or the
59
+ * interval spent no more than the draw and its own uncertainty account for.
60
+ */
61
+ export function costPerMegabyteFrom({ cpuSeconds, elapsedSeconds, megabytes, baseDraw, drawScatter = null }) {
62
+ if (!Number.isFinite(cpuSeconds) || !Number.isFinite(elapsedSeconds) || !(elapsedSeconds > 0)) {
63
+ return null;
64
+ }
65
+ if (!Number.isFinite(megabytes) || !(megabytes > 0)) {
66
+ return null;
67
+ }
68
+ if (!Number.isFinite(baseDraw) || !(baseDraw >= 0)) {
69
+ // Nothing measured to subtract. Publishing the whole spending as the
70
+ // torrent's is what produced the 145 ms/MB reading, so this interval says
71
+ // nothing instead.
72
+ return null;
73
+ }
74
+ const attributable = cpuSeconds - baseDraw * elapsedSeconds;
75
+ if (!(attributable > 0)) {
76
+ // The draw accounts for everything this interval spent. Not a discovery
77
+ // that the torrent is free — a reading with nothing left in it.
78
+ return null;
79
+ }
80
+ const uncertainty = Number.isFinite(drawScatter) && drawScatter > 0 ? drawScatter * elapsedSeconds : 0;
81
+ if (!(attributable > uncertainty)) {
82
+ // What is left is inside the disagreement between the draw's own readings.
83
+ // Dividing it by a small number of megabytes is how 145 ms/MB was reported
84
+ // on a host that costs 23.
85
+ return null;
86
+ }
87
+ return attributable / megabytes;
88
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @file The price of a second job, with the readings it was derived from.
3
+ *
4
+ * Measured on the addon host 2026-08-18: decoding the same clip ran at 2.20x
5
+ * alone, 0.85x with one encoder beside it and 0.60x with two. The budget has
6
+ * been adding independent prices, and these say two jobs that each fit alone do
7
+ * not fit together.
8
+ */
9
+
10
+ import assert from "node:assert/strict";
11
+ import test from "node:test";
12
+
13
+ import { contentionPenalty, costWithContention, penaltiesFrom } from "../services/contention.js";
14
+
15
+ /** The addon host's own readings. */
16
+ const ALONE = 2.2;
17
+ const BESIDE = [
18
+ { others: 1, speed: 0.85 },
19
+ { others: 2, speed: 0.6 }
20
+ ];
21
+
22
+ test("the penalties are the ratios of the measured speeds", () => {
23
+ const penalties = penaltiesFrom(ALONE, BESIDE);
24
+
25
+ assert.ok(penalties);
26
+ // 2.2 / 0.85 = 2.59: the same work costs two and a half times more with one
27
+ // encoder beside it.
28
+ assert.ok(Math.abs(penalties.get(1) - 2.588) < 0.01, `${penalties.get(1)}`);
29
+ assert.ok(Math.abs(penalties.get(2) - 3.667) < 0.01, `${penalties.get(2)}`);
30
+ });
31
+
32
+ test("alone on the machine is what the benchmarks measure, so nothing is corrected", () => {
33
+ const penalties = penaltiesFrom(ALONE, BESIDE);
34
+ const answer = contentionPenalty(0, penalties);
35
+ assert.equal(answer.penalty, 1);
36
+ assert.equal(answer.measured, true);
37
+ assert.equal(costWithContention(0.45, 0, penalties), 0.45);
38
+ });
39
+
40
+ test("a cost beside one other job is the measured multiple, not the sum of two prices", () => {
41
+ const penalties = penaltiesFrom(ALONE, BESIDE);
42
+ // Decoding priced at 0.45 s/s alone becomes 1.17 s/s beside an encoder —
43
+ // which is what the field measured (1.18), and what additivity cannot say.
44
+ const corrected = costWithContention(0.45, 1, penalties);
45
+ assert.ok(Math.abs(corrected - 1.165) < 0.01, `${corrected}`);
46
+ });
47
+
48
+ test("beyond the readings it holds the largest instead of extrapolating", () => {
49
+ const penalties = penaltiesFrom(ALONE, BESIDE);
50
+ const four = contentionPenalty(4, penalties);
51
+ assert.equal(four.penalty, penalties.get(2), "two readings say nothing about a fourth job");
52
+ assert.equal(four.from, 2, "and the answer says which reading it came from");
53
+ assert.equal(four.measured, true);
54
+ });
55
+
56
+ test("with nothing measured the prediction is left alone, not guessed at", () => {
57
+ const answer = contentionPenalty(1, null);
58
+ assert.equal(answer.penalty, 1);
59
+ assert.equal(answer.measured, false, "the caller must be able to say the figure is uncorrected");
60
+ assert.equal(costWithContention(0.45, 1, null), 0.45);
61
+ assert.equal(contentionPenalty(1, new Map()).measured, false);
62
+ });
63
+
64
+ test("a machine that got faster for being busier has no penalty to measure", () => {
65
+ // Noise on a fast host, not a discovery. Measured on the developer's desktop,
66
+ // the difference between one and two jobs is inside the scatter.
67
+ const penalties = penaltiesFrom(20, [{ others: 1, speed: 21 }]);
68
+ assert.equal(penalties.get(1), 1);
69
+ });
70
+
71
+ test("readings that are not measurements are left out", () => {
72
+ assert.equal(penaltiesFrom(0, BESIDE), null, "every penalty is relative to the alone reading");
73
+ assert.equal(penaltiesFrom(ALONE, []), null);
74
+ assert.equal(penaltiesFrom(ALONE, [{ others: 0, speed: 2 }]), null, "zero others is not a penalty");
75
+ assert.equal(penaltiesFrom(ALONE, [{ others: 1, speed: Number.NaN }]), null);
76
+ });
@@ -24,7 +24,7 @@ import {
24
24
  canSustainOutput,
25
25
  decodeSpeedFor,
26
26
  predictedRealtimeSpeed,
27
- REALTIME_SPEED_MARGIN
27
+ speedBar
28
28
  } from "../services/hwaccel.js";
29
29
  import { HlsSessionManager, sourceDecodeCharacteristics } from "../services/hls-session-manager.js";
30
30
  import { parseFfmpegBitrateKbps, parseFfmpegVideoDimensions, parseFfmpegVideoFps } from "../services/ffmpeg-banner.js";
@@ -111,7 +111,7 @@ test("with no decode fit the prediction is the encoder alone — what it was bef
111
111
  assert.equal(speed, 5.99, "the old figure, four times the truth on that rung");
112
112
  });
113
113
 
114
- test("a rung is refused when the combined speed is under the margin", () => {
114
+ test("a rung is refused when the combined speed is under the bar", () => {
115
115
  // The addon host's fastest preset, read from its own log: 11.2 Mpx/s.
116
116
  const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
117
117
  const source = MEASURED_FILM;
@@ -123,14 +123,10 @@ test("a rung is refused when the combined speed is under the margin", () => {
123
123
  outputPixelsPerSec: 1280 * 720 * 24
124
124
  });
125
125
  assert.equal(heavy.sustainable, false, "720p needs 22 Mpx/s from an 11.2 Mpx/s host");
126
- assert.ok(heavy.speed < REALTIME_SPEED_MARGIN);
127
-
128
- // And this is the gap the model does NOT close: the 240p rung predicts 1.58x
129
- // and clears a margin of 1.5, while the field measured that same rung at
130
- // 0.388-0.947x under real load — a host simultaneously copying 1080p,
131
- // downloading the torrent and pushing segments. The prediction is honest for
132
- // an idle machine; the margin is what has to carry the load, and 1.5 does not
133
- // carry it. Pinned here so the arithmetic is not rediscovered from a log.
126
+ assert.ok(heavy.speed < 1, `predicted ${heavy.speed.toFixed(2)}x`);
127
+
128
+ // The 240p rung of that film predicts 1.58x on an idle machine, and with
129
+ // nothing known about the swarm the bar is realtime, so it is offered.
134
130
  const light = canSustainOutput({
135
131
  benchmark,
136
132
  decodeModel: ADDON_HOST_MODEL,
@@ -141,6 +137,26 @@ test("a rung is refused when the combined speed is under the margin", () => {
141
137
  assert.equal(light.sustainable, true);
142
138
  });
143
139
 
140
+ test("the bar is what this file's own supply demands, when it has been measured", () => {
141
+ // The field torrent of 2026-08-17: waits of 1.49 s median arriving every
142
+ // 2.22 s demand 1.67x of any step that is to survive them. The same 240p rung
143
+ // predicted at 1.58x clears realtime and does not clear that.
144
+ const benchmark = [{ preset: "ultrafast", pixelsPerSec: 11.2e6 }];
145
+ const rung = {
146
+ benchmark,
147
+ decodeModel: ADDON_HOST_MODEL,
148
+ source: MEASURED_FILM,
149
+ outputPixelsPerSec: 426 * 240 * 24
150
+ };
151
+ assert.equal(canSustainOutput({ ...rung, requiredSpeed: 1.67 }).sustainable, false);
152
+ assert.equal(canSustainOutput({ ...rung, requiredSpeed: 1.2 }).sustainable, true);
153
+ // A swarm that has not been measured cannot raise the bar, and cannot lower
154
+ // it below realtime either.
155
+ assert.equal(speedBar(null), 1);
156
+ assert.equal(speedBar(0.4), 1);
157
+ assert.equal(speedBar(1.67), 1.67);
158
+ });
159
+
144
160
  test("a reading from the running encoder outranks the model of the clips", () => {
145
161
  // The field case, 2026-08-14: the 240p rung of that film ran at 0.95x at its
146
162
  // best on a host whose fastest preset benchmarked at 11.2 Mpx/s. Subtracting
@@ -340,8 +356,24 @@ test("the master playlist drops the rungs the host cannot hold", async (t) => {
340
356
  const stronger = { ...session, id: "dddddddd-eeee-ffff-0000-111111111111", offeredHeightsCache: undefined };
341
357
  manager.sessionsById.set(stronger.id, stronger);
342
358
  const master = manager.buildMasterPlaylist(stronger.id);
343
- assert.ok(master, "1080p copied plus the one rung this host can produce");
359
+ assert.ok(master, "1080p copied plus the rungs this host can produce");
344
360
  const heights = [...master.matchAll(/^v\/(\d+)\/index\.m3u8$/gm)].map((match) => Number(match[1]));
345
- assert.deepEqual(heights, [1080, 240]);
361
+ assert.deepEqual(heights, [1080, 360, 240], "nothing is known about this swarm, so the bar is realtime");
346
362
  assert.deepEqual(manager.offeredHeights(stronger), heights);
363
+
364
+ // The same host, once the reader has measured what this file's supply
365
+ // demands: waits arriving as they did on the field torrent of 2026-08-17 ask
366
+ // 1.67x of any step, and the rungs that only just cleared realtime go.
367
+ const onAThinSwarm = {
368
+ ...stronger,
369
+ id: "eeeeeeee-ffff-0000-1111-222222222222",
370
+ offeredHeightsCache: undefined,
371
+ supplyFigures: { requiredSpeed: 1.67, worstWaitSec: 1.49, medianIntervalSec: 2.22, samples: 12 }
372
+ };
373
+ manager.sessionsById.set(onAThinSwarm.id, onAThinSwarm);
374
+ assert.deepEqual(
375
+ manager.offeredHeights(onAThinSwarm),
376
+ [1080],
377
+ "the copied height costs no encoder and stays; nothing re-encoded survives that supply"
378
+ );
347
379
  });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * @file Adopting a new median only when it has moved further than the readings
3
+ * behind it disagree with each other.
4
+ */
5
+ import assert from "node:assert/strict";
6
+ import { test } from "node:test";
7
+ import { medianOf, movedBeyondScatter, scatterOf } from "../services/learned-median.js";
8
+
9
+ test("the median is the middle reading, and the mean of the two middle ones", () => {
10
+ assert.equal(medianOf([3, 1, 2]), 2);
11
+ assert.equal(medianOf([1, 2, 3, 4]), 2.5);
12
+ assert.equal(medianOf([]), null);
13
+ assert.equal(medianOf([Number.NaN]), null);
14
+ });
15
+
16
+ test("the scatter is the median distance from the middle", () => {
17
+ assert.equal(scatterOf([10, 10, 10]), 0);
18
+ assert.equal(scatterOf([8, 10, 12]), 2);
19
+ assert.equal(scatterOf([]), null);
20
+ });
21
+
22
+ test("the first answer is always adopted", () => {
23
+ assert.equal(movedBeyondScatter(null, 5, [5]), true);
24
+ });
25
+
26
+ test("a move smaller than the readings' own disagreement is the same answer", () => {
27
+ assert.equal(movedBeyondScatter(10, 11, [8, 10, 12]), false);
28
+ });
29
+
30
+ test("a move larger than that disagreement is adopted", () => {
31
+ assert.equal(movedBeyondScatter(10, 13, [8, 10, 12]), true);
32
+ });
33
+
34
+ test("readings that all agree let any change through", () => {
35
+ assert.equal(movedBeyondScatter(10, 10.4, [10, 10, 10]), true);
36
+ assert.equal(movedBeyondScatter(10, 10, [10, 10, 10]), false);
37
+ });