@torrent-tv/proxy 2.80.17 → 2.80.18

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 CHANGED
@@ -1,3 +1,9 @@
1
+ ## 2.80.18
2
+
3
+ - **Fix**: The `Infinity` is gone, and with it a double count I had introduced. The measured "time to a first piece" already CONTAINS one piece's encoding, and I was adding another; separated, the two scale differently — a piece costs more when encoders share the machine, a spawn does not. So a fresh encoder owes `spawn overhead + the piece at the rate in force`, a moved one owes the kill and then the same. With nothing measured the overhead is zero and a fresh encoder owes exactly one piece, which is the floor and is DERIVED rather than chosen: a piece cannot appear before it is encoded, and how fast this host encodes is measured before any viewer exists. The `Infinity` was an exception in a model that needs none. Verified by simulation over eighty ticks against the map's real shape — zero moves and zero one-piece intervals at a budget of one encoder and of three.
4
+ - **New**: `encode-plan on <output>: start #58..#481, stop #?..#?` — every action with its INTERVAL, which is what a run is, printed on any pass that does something. Its absence cost three wrong diagnoses of one field session: the line printed the windows, the budget and where the live runs stood, and not the intervals the actions carried.
5
+ - **Chore**: THE FIELD DIAGNOSIS OF 2026-09-08, corrected. What that session did was give every encoder an interval of exactly ONE segment — 63 runs, each spending 1.26 s reaching its first piece, making that one piece, reaching the end of its interval and exiting, twelve of them normally and thirteen killed. The reasons printed beside them read as moves back and forth, so I diagnosed an oscillating placement three times over and shipped 2.80.15 through 2.80.17 against it. Those releases stand on their own — the objective now follows the map's own rank order, the arrival arithmetic no longer charges a run for a piece it is already making, and the split point is derived — but none of them touches the caravan. Which line assigns an interval of one segment is not yet named: on a clean model it does not happen, three candidate explanations were checked and all three fell, and the line above is what will name it on the next session.
6
+
1
7
  ## 2.80.17
2
8
 
3
9
  - **Fix**: Where a second encoder joins a stretch is DERIVED, and whether it is worth having is asked at all. It was the midpoint of the widest unmade run, unconditionally. Both encoders close their halves at a time that rises with the split point for one and falls for the other, so the stretch is closed soonest where they cross: `x* = (from + to + 1)/2 + (d - w) * r / 2` — the midpoint, shifted forward by half the difference between what a fresh encoder owes and what the one already there owes, in pieces. Halving is the special case where both are fresh and owe the same.
@@ -242,12 +242,28 @@ piece costs — because only there is it known what the body IS, and at the poin
242
242
  of pricing only how many there are. So there is no kind, no tag and no case
243
243
  analysis.
244
244
 
245
- **What a move costs is `Infinity` until something has been measured**, because a
246
- move is irreversible while leaving the encoder alone is always available. Placing
247
- one where there is none takes the unknown the other way: the film gets made or it
248
- does not. And a run killed before producing anything is a measurement too — a
249
- lower bound on the first output, and the only reading a thrash can supply, since
250
- every run in one is killed before it finishes anything.
245
+ **The measured start is separated from the piece it contains**, because the two
246
+ scale differently: a piece costs more when encoders share the machine, a spawn
247
+ does not.
248
+
249
+ ```
250
+ spawn overhead = measured first output - what one piece costs alone
251
+ a fresh encoder = spawn overhead + the piece at the rate in force
252
+ a moved one = the kill, and then the same
253
+ ```
254
+
255
+ With nothing measured the overhead is zero and a fresh encoder owes exactly one
256
+ piece. **That is the floor, and it is derived rather than chosen:** a piece
257
+ cannot appear before it is encoded, and how fast this host encodes is measured
258
+ before any viewer exists. There was an `Infinity` here for the cost of a move,
259
+ on the reasoning that an unmeasured price must not license an irreversible act —
260
+ an exception in a model that needs none, and this is the same statement made by
261
+ arithmetic.
262
+
263
+ And a run killed before producing anything is a measurement too — a lower bound
264
+ on the first output, and the only reading a thrash can supply, since every run in
265
+ one is killed before it finishes anything. So a thrash makes its own moves
266
+ progressively dearer until it stops.
251
267
 
252
268
  ### What that was for
253
269
 
@@ -262,7 +278,7 @@ IMMEDIATELY and was the most urgent material in the file: it bought encoders, an
262
278
  it took the run standing in front of the viewer because that run was the nearest
263
279
  body to it.
264
280
 
265
- Checked by simulation over sixty ticks against the map's real shape — ten zones
281
+ Checked by simulation over eighty ticks against the map's real shape — ten zones
266
282
  doubling ahead of the viewer, one behind — at both one and three runs: the
267
283
  encoder is placed once, left alone, and moved exactly once, at the viewer's own
268
284
  seek.
@@ -301,6 +317,25 @@ penalty for a second encoder on the addon host is 1.98 — it takes very nearly
301
317
  all of the first's speed — and the objective keeps one; where a second is free it
302
318
  places three.
303
319
 
320
+ ## What the log says when anything is placed or taken away
321
+
322
+ ```
323
+ encode-plan on <output>: start #58..#481, stop #?..#?
324
+ ```
325
+
326
+ Every action with its INTERVAL, which is what a run is. Printed on any pass that
327
+ does something — a session where nothing changes says nothing.
328
+
329
+ It was missing, and its absence cost three wrong diagnoses of one field session.
330
+ The line printed the windows, the budget and where the live runs stood; the
331
+ intervals the actions carried were the one thing it did not print. What that
332
+ session actually did was give every encoder an interval of exactly ONE segment —
333
+ 63 runs, each spending 1.26 s reaching its first piece, making that one piece,
334
+ reaching the end of its interval and exiting, twelve of them normally. The
335
+ reasons printed beside them read as moves back and forth, so the fault was read
336
+ as an oscillating placement three times over. An interval of one segment turns
337
+ the protection against two encoders writing one name into a mill for processes.
338
+
304
339
  ## What is checked
305
340
 
306
341
  `test/one-authority.test.js` holds the shape: one caller of `#startEncodeRun`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.80.17",
3
+ "version": "2.80.18",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -102,9 +102,6 @@
102
102
  * measured on this host from its own runs. Zero until something has measured
103
103
  * it, which makes moving one look cheaper than it is and is said here so the
104
104
  * bias is known.
105
- * @param {number} [params.moveCostSec] - What moving a running encoder costs on
106
- * this host, measured. `Infinity` until something has been measured, because a
107
- * move is irreversible and leaving the encoder alone is always available.
108
105
  * @param {number} [params.now] - The clock, injected. This layer is arithmetic
109
106
  * and reads no clock of its own; how old a run is is one of its inputs.
110
107
  * @param {number} [params.firstByteWaitSec] - How long a fresh encoder takes to
@@ -128,7 +125,6 @@ export function planEncoders({
128
125
  segmentSeconds,
129
126
  killCostSec = 0,
130
127
  firstByteWaitSec = 0,
131
- moveCostSec = Number.POSITIVE_INFINITY,
132
128
  now = Date.now(),
133
129
  refetchSecPerFilmSecond = 0,
134
130
  contentionPenaltyFor = () => 1,
@@ -185,12 +181,26 @@ export function planEncoders({
185
181
  // its death, the start of another, and the wait for the first bytes there.
186
182
  // Taking an encoder somewhere else is stopping this one and waiting for the
187
183
  // next to produce. Both halves are measured on this host.
188
- // WHAT A MOVE COSTS. Killing an encoder and waiting for a fresh one's first
189
- // piece is the price; until something has produced anything on this host that
190
- // price is unknown, and a move is then refused rather than priced at zero.
191
- // Placing one where there is none is the other question and takes the unknown
192
- // the other way see `run-costs.js`.
193
- const moveSec = Number.isFinite(moveCostSec) ? moveCostSec : killCostSec + firstByteWaitSec;
184
+ // WHAT A START AND A MOVE OWE BEFORE THE PIECE THEY STAND ON EXISTS, and
185
+ // neither is infinite.
186
+ //
187
+ // The measured `firstByteWaitSec` is spawn to first piece, so it already
188
+ // contains one piece's encoding. Separated, because the two scale differently:
189
+ // the piece costs more when encoders share the machine, the spawn does not.
190
+ //
191
+ // spawn overhead = measured first output - what one piece costs alone
192
+ // a fresh encoder owes spawn overhead + the piece at the rate in force
193
+ // a moved one owes the kill, and then the same
194
+ //
195
+ // With nothing measured the overhead is zero and a fresh encoder owes exactly
196
+ // one piece — which is the honest floor rather than a guess: a piece cannot
197
+ // appear before it is encoded, and how fast this host encodes is measured
198
+ // before any viewer exists. There was an `Infinity` here for the cost of a
199
+ // move, on the reasoning that an unmeasured price must not license an
200
+ // irreversible act; it was an exception in a model that needs none, and this
201
+ // is the same statement made by arithmetic.
202
+ const spawnOverheadSec = Math.max(0, firstByteWaitSec - (rate > 0 ? 1 / rate : 0));
203
+ const moveSec = killCostSec + spawnOverheadSec;
194
204
 
195
205
  // WHAT A RUN STILL HAS TO GO BEFORE IT PRODUCES ANYTHING — the measured time
196
206
  // to a first piece, less the time it has already been alive.
@@ -393,7 +403,7 @@ export function planEncoders({
393
403
  }
394
404
  if (filler === "new") {
395
405
  // A encoder that does not exist yet owes its own start and then the piece.
396
- encoders.push({ at: positions[index], owes: (piece) => firstByteWaitSec + piece });
406
+ encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
397
407
  continue;
398
408
  }
399
409
  const head = Number(filler.head);
@@ -472,7 +482,7 @@ export function planEncoders({
472
482
  }
473
483
  for (let index = 0; index < positions.length; index += 1) {
474
484
  if ((best ? best.fill[index] : null) === "new") {
475
- encoders.push({ at: positions[index], owes: (piece) => firstByteWaitSec + piece });
485
+ encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
476
486
  }
477
487
  }
478
488
  return priced(encoders);
@@ -98,47 +98,22 @@ export class RunCosts {
98
98
  * @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
99
99
  */
100
100
  seconds() {
101
- const dying = middleOf(this.#dying);
102
- const first = middleOf(this.#firstOutput);
101
+ // MEASURED OR ABSENT, and absent is said as zero rather than as a guess.
102
+ //
103
+ // There was an `Infinity` here for a while, for the cost of a move, on the
104
+ // reasoning that an unmeasured price must not license an irreversible act.
105
+ // It was an exception in a model that needs none, and it is not required: a
106
+ // first piece cannot appear faster than it takes to ENCODE one, and how fast
107
+ // this host encodes is measured before any viewer exists. The floor is
108
+ // derived from that where the arithmetic is, and every figure here stays a
109
+ // plain reading or a plain zero.
110
+ //
111
+ // `firstByteWaitSec` is spawn to first piece, so it already contains one
112
+ // piece's encoding. Whoever uses it separates the two, because the piece
113
+ // costs more when encoders share the machine and the spawn does not.
103
114
  return {
104
- // UNKNOWN IS NOT ZERO, and for a cost it is not a small number either: it
105
- // is the figure that makes the act it prices never worth doing. Reported
106
- // as 0, an unmeasured move was FREE in the plan's arithmetic, so any gain
107
- // however small justified it — and moving an encoder is irreversible,
108
- // because the process it kills cannot be un-killed.
109
- //
110
- // The blindness was self-sustaining: `#firstOutput` only takes a reading
111
- // from a run that produced something, and a run killed 0.8 s after
112
- // starting produces nothing. So a thrash prevented the measurement that
113
- // would have stopped it. Field 2026-09-08: 39 moves in one session, 24 of
114
- // them between three adjacent numbers — #58 to #59, #59 to #58, #58 to
115
- // #60, #60 to #58, six times each — while the picture stood still for
116
- // 116.7 s.
117
- //
118
- // TWO QUESTIONS, NOT ONE, and they take the unknown differently.
119
- //
120
- // PLACING an encoder where there is none has no alternative: the film gets
121
- // made or it does not. So an unmeasured cost must not stand in the way,
122
- // and the honest figure is what has been measured or nothing.
123
- //
124
- // MOVING one has an alternative — leave it alone — and it is
125
- // irreversible, because the process it kills cannot be un-killed. There
126
- // an unmeasured cost must not license the act, and `Infinity` is the
127
- // identity of the comparison that consumes it: "nobody has measured what
128
- // this costs" and "never worth doing" are the same statement about an
129
- // action whose price is unknown.
130
- //
131
- // Reported as 0 for both, an unmeasured move was FREE in the plan's
132
- // arithmetic, so a gain of a fraction of a second justified it. And the
133
- // blindness was self-sustaining: `#firstOutput` takes a reading only from
134
- // a run that produced something, and every run in a thrash is killed
135
- // before it finishes anything.
136
- killCostSec: (dying ?? 0) / 1000,
137
- firstByteWaitSec: (first ?? 0) / 1000,
138
- moveCostSec:
139
- first === null
140
- ? Number.POSITIVE_INFINITY
141
- : ((dying ?? 0) + first) / 1000,
115
+ killCostSec: (middleOf(this.#dying) ?? 0) / 1000,
116
+ firstByteWaitSec: (middleOf(this.#firstOutput) ?? 0) / 1000,
142
117
  samples: Math.min(this.#dying.length, this.#firstOutput.length)
143
118
  };
144
119
  }
@@ -417,6 +417,22 @@ export class EncodeOrchestrator {
417
417
  // happens — everything a rerun of the same decision needs: the windows
418
418
  // this call saw (priority, the real time, which side of the viewers),
419
419
  // the budget, and where every live run stood.
420
+ // WHAT WAS DECIDED AND WITH WHAT INTERVAL, said whenever anything is placed
421
+ // or taken away — not only on a move.
422
+ //
423
+ // Field 2026-09-08 could not be diagnosed from this line: it printed the
424
+ // windows, the budget and where the live runs stood, and NOT the intervals
425
+ // the actions carried. What the session actually did was give every encoder
426
+ // an interval of exactly ONE segment — 63 runs, each making one piece and
427
+ // exiting, twelve of them normally — and the reasons printed beside them
428
+ // read as moves, so the fault was diagnosed three times as something it was
429
+ // not. An interval is what a run is, and it was the one thing missing.
430
+ if (actions.some((action) => action.type !== "keep")) {
431
+ this.logger.info(
432
+ `encode-plan on ${address}: ` +
433
+ `${actions.map((action) => `${action.type} #${action.from ?? "?"}..#${action.to ?? "?"}`).join(", ")}`
434
+ );
435
+ }
420
436
  if (actions.some((action) => action.type === "move")) {
421
437
  this.logger.info(
422
438
  `encode-plan move on ${address}: windows=${JSON.stringify(windows)} ` +
@@ -41,73 +41,145 @@ import { RunCosts } from "../services/encode/run-costs.js";
41
41
  import { planEncoders } from "../services/encode/EncodePlan.js";
42
42
  import { CoverageMap } from "../services/encode/CoverageMap.js";
43
43
 
44
- test("nothing measured means a move is refused, not priced at zero", () => {
44
+ test("nothing measured is a plain zero, and the floor is derived where the arithmetic is", () => {
45
45
  const costs = new RunCosts();
46
46
 
47
- const { moveCostSec, firstByteWaitSec, killCostSec } = costs.seconds();
48
- assert.equal(moveCostSec, Number.POSITIVE_INFINITY, "moving is not free while unpriced");
49
- // Placing one where there is none is the OTHER question, and it has no
50
- // alternative: the film gets made or it does not.
51
- assert.equal(firstByteWaitSec, 0, "placing an encoder is not blocked by an unknown price");
47
+ const { firstByteWaitSec, killCostSec } = costs.seconds();
48
+ assert.equal(firstByteWaitSec, 0, "no reading is said as none, not as a guess");
52
49
  assert.equal(killCostSec, 0);
50
+ // There was an `Infinity` here — the cost of a move, made unaffordable until
51
+ // something had been measured, on the reasoning that an unmeasured price must
52
+ // not license an irreversible act. It was an exception in a model that needs
53
+ // none: a first piece cannot appear faster than it takes to ENCODE one, and
54
+ // how fast this host encodes is measured before any viewer exists, so the
55
+ // floor belongs where the arithmetic is.
56
+ assert.equal("moveCostSec" in costs.seconds(), false, "no such figure any more");
53
57
  });
54
58
 
55
59
  test("a run killed before producing anything is a lower bound on the first output", () => {
56
60
  const costs = new RunCosts();
57
61
 
58
62
  // Exactly what a thrash supplies: a run that lived 800 ms and finished
59
- // nothing. It says the first output takes AT LEAST that long, which is a fact.
63
+ // nothing. It says the first output takes AT LEAST that long, which is a fact
64
+ // and the only reading a thrash can give — every run in one is killed before
65
+ // it produces.
60
66
  costs.note({ livedMs: 800, dyingMs: 40 });
61
67
 
62
- const { moveCostSec } = costs.seconds();
63
- assert.ok(Number.isFinite(moveCostSec), "one killed run is enough to stop the blindness");
64
- assert.ok(Math.abs(moveCostSec - 0.84) < 0.001, `got ${moveCostSec}`);
68
+ const { firstByteWaitSec, killCostSec } = costs.seconds();
69
+ assert.ok(Math.abs(firstByteWaitSec - 0.8) < 0.001, `got ${firstByteWaitSec}`);
70
+ assert.ok(Math.abs(killCostSec - 0.04) < 0.001, `got ${killCostSec}`);
65
71
  });
66
72
 
67
73
  test("a run that produced something is measured by its first output, not its life", () => {
68
74
  const costs = new RunCosts();
69
75
 
70
- costs.note({ livedMs: 60_000, firstOutputMs: 900, dyingMs: 100 });
76
+ costs.note({ livedMs: 60_000, firstOutputMs: 1260, dyingMs: 100 });
71
77
 
72
- const { moveCostSec, firstByteWaitSec } = costs.seconds();
73
- assert.ok(Math.abs(firstByteWaitSec - 0.9) < 0.001, `got ${firstByteWaitSec}`);
74
- assert.ok(Math.abs(moveCostSec - 1.0) < 0.001, `got ${moveCostSec}`);
78
+ const { firstByteWaitSec } = costs.seconds();
79
+ assert.ok(Math.abs(firstByteWaitSec - 1.26) < 0.001, `got ${firstByteWaitSec}`);
75
80
  });
76
81
 
77
- test("the zone sliding one number does not move an encoder that is already reaching it", () => {
78
- // The field shape exactly: a run standing at #58 with the viewer's urgent zone
79
- // sliding #58..#59 #59..#60. Driving through one segment costs the encoder a
80
- // fraction of a second; moving costs a kill and a cold start.
82
+ /**
83
+ * The map's real shape: one segment at the viewer, doubling zones ahead down to
84
+ * p91, and everything behind them at p1 with no deadline. Written out because a
85
+ * fixture of two zones is not this, and the difference decides the answer: with
86
+ * nothing stated past the viewer's own zone, a run one segment behind it is
87
+ * compared on that zone alone and loses by a tenth of a second.
88
+ *
89
+ * @param {number} head - The segment the viewer is on.
90
+ * @param {number} count
91
+ * @returns {object[]}
92
+ */
93
+ function mapAt(head, count) {
94
+ const zones = [];
95
+ if (head > 0) {
96
+ zones.push({ from: 0, to: head - 1, priority: 1, withinSeconds: null, behind: true });
97
+ }
98
+ let from = head;
99
+ let width = 1;
100
+ let rank = 100;
101
+ while (from < count && rank > 90) {
102
+ const to = Math.min(count - 1, from + width - 1);
103
+ zones.push({ from, to, priority: rank, withinSeconds: (from - head) * 4.2, behind: false });
104
+ from = to + 1;
105
+ width *= 2;
106
+ rank -= 1;
107
+ }
108
+ if (from < count) {
109
+ zones.push({ from, to: count - 1, priority: 90, withinSeconds: (from - head) * 4.2, behind: false });
110
+ }
111
+ return zones;
112
+ }
113
+
114
+ test("an encoder is left alone while the viewer is still at or before it", () => {
115
+ // Every slide of the viewer's zone used to make standing one number behind it
116
+ // score worse than standing in it — by ten milliseconds, which is nothing but
117
+ // the double charge for a piece already being made. What holds now is the
118
+ // narrower and true statement: while the viewer's own zone still contains the
119
+ // encoder's position, it is left alone. A viewer BEFORE it is a different
120
+ // case entirely and correctly moves it back — encoders only go forward, so
121
+ // one standing past a viewer never reaches them.
122
+ //
123
+ // Once the viewer has PASSED it, moving forward is correct and happens once: a
124
+ // run that has produced nothing in 0.8 s of a 1.26 s warm-up owes 0.46 s
125
+ // before its piece exists, while a fresh one at the viewer's own number owes
126
+ // 0.32 s of spawn and then the piece — so the viewer is served sooner, and the
127
+ // number left behind is in nobody's zone.
81
128
  const coverage = new CoverageMap();
82
129
  coverage.setSegmentCount(482);
83
- const run = { from: 58, to: 481, head: 58, speedX: 4.45, isAlive: true };
84
- coverage.claim(run, 58, 481);
130
+ const run = { from: 58, to: -1, head: 58, speedX: 4.45, isAlive: true, startedAt: 1_000_000 };
131
+ coverage.claim(run, 58, -1);
85
132
 
86
- const actions = planEncoders({
133
+ for (const viewerAt of [58]) {
134
+ const actions = planEncoders({
135
+ coverage,
136
+ windows: mapAt(viewerAt, 482),
137
+ runs: [run],
138
+ maxRuns: 3,
139
+ segmentSeconds: 4.2,
140
+ speedX: 4.45,
141
+ killCostSec: 0.04,
142
+ // Measured on the addon host: a run started at 15:50:15.521 and its first
143
+ // piece existed at 15:50:16.785.
144
+ firstByteWaitSec: 1.26,
145
+ refetchSecPerFilmSecond: 0,
146
+ // 1.98 at 1920x1080, measured: a second encoder takes very nearly all of
147
+ // the first's speed.
148
+ contentionPenaltyFor: (others) => (others <= 0 ? 1 : 1.98 ** others),
149
+ now: 1_000_000 + 800
150
+ });
151
+
152
+ assert.deepEqual(
153
+ actions.filter((one) => one.type === "move"),
154
+ [],
155
+ `the viewer at #${viewerAt} does not cost the encoder its place`
156
+ );
157
+ }
158
+
159
+ // And once they are past it, exactly one move — not one per slide.
160
+ const past = [59, 60, 61].map((viewerAt) => planEncoders({
87
161
  coverage,
88
- windows: [
89
- { from: 0, to: 57, priority: 1, withinSeconds: null, behind: true },
90
- { from: 59, to: 60, priority: 100, withinSeconds: 0, behind: false }
91
- ],
162
+ windows: mapAt(viewerAt, 482),
92
163
  runs: [run],
93
164
  maxRuns: 3,
94
165
  segmentSeconds: 4.2,
95
166
  speedX: 4.45,
96
- // Measured on this host: killing takes 40 ms, a fresh encoder's first piece
97
- // 900 ms. Against that, driving one segment at 4.45x costs 0.94 s — so the
98
- // two are close, and what settles it is that the move ALSO has to encode
99
- // the same segment afterwards.
100
167
  killCostSec: 0.04,
101
- firstByteWaitSec: 0.9,
102
- moveCostSec: 0.94,
168
+ firstByteWaitSec: 1.26,
103
169
  refetchSecPerFilmSecond: 0,
104
- contentionPenaltyFor: () => 1
105
- });
170
+ contentionPenaltyFor: (others) => (others <= 0 ? 1 : 1.98 ** others),
171
+ now: 1_000_000 + 800
172
+ }).filter((one) => one.type === "move"));
106
173
 
107
174
  assert.deepEqual(
108
- actions.filter((one) => one.type === "move"),
109
- [],
110
- "a run one number behind the zone is already on its way into it"
175
+ past.map((moves) => moves.length),
176
+ [1, 1, 1],
177
+ "one move to where the viewer now is, whichever number that is"
178
+ );
179
+ assert.deepEqual(
180
+ past.map((moves) => moves[0].from),
181
+ [59, 60, 61],
182
+ "and it goes to the viewer's own number, not one past it"
111
183
  );
112
184
  });
113
185
 
@@ -128,8 +200,7 @@ test("a move that genuinely saves the viewer time still happens", () => {
128
200
  segmentSeconds: 4.2,
129
201
  speedX: 4.45,
130
202
  killCostSec: 0.04,
131
- firstByteWaitSec: 0.9,
132
- moveCostSec: 0.94,
203
+ firstByteWaitSec: 1.26,
133
204
  refetchSecPerFilmSecond: 0,
134
205
  contentionPenaltyFor: () => 1
135
206
  });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * @file Where a second encoder joins a stretch, and whether it is worth having.
3
+ *
4
+ * Both halves are arithmetic and neither was asked before. The stretch used to
5
+ * be halved, unconditionally:
6
+ *
7
+ * widestFrom = from + Math.floor(Math.min(room, to - from + 1) / 2)
8
+ *
9
+ * Halving IS the answer when both encoders are fresh and owe the same. It is not
10
+ * when one of them is already partway through a piece, and it never asked the
11
+ * other question at all — whether two encoders under this host's measured
12
+ * contention beat one at full speed.
13
+ *
14
+ * The derivation. A stretch of unmade film runs from `from` to `to`; whoever is
15
+ * already on it stands at `from` and owes `w` before the piece under it exists;
16
+ * a fresh one placed at `x` owes `d` — its start and then a whole piece — and
17
+ * both then work at the rate two encoders leave each other:
18
+ *
19
+ * the one there closes [from, x-1]: w + (x - 1 - from) / r rises with x
20
+ * the fresh one closes [x, to]: d + (to - x) / r falls with x
21
+ *
22
+ * x* = (from + to + 1) / 2 + (d - w) * r / 2
23
+ *
24
+ * The shift is `firstByteWait / (2 * perPiece)` pieces, so it depends on how
25
+ * long a piece takes here: 0.24 of a piece at 4.45x on a 4.2 s grid, but 0.96 at
26
+ * 8.9x and 2.14 at 20x — a whole segment and more on the copy branch, where a
27
+ * piece is short and a start is not. Measuring only the re-encode rate
28
+ * understated it.
29
+ */
30
+
31
+ import test from "node:test";
32
+ import assert from "node:assert/strict";
33
+ import { planEncoders } from "../services/encode/EncodePlan.js";
34
+ import { CoverageMap } from "../services/encode/CoverageMap.js";
35
+
36
+ const SEGMENT_SECONDS = 4.2;
37
+ const LAST = 199;
38
+
39
+ /**
40
+ * One encoder on a long stretch nobody has made, and room for more.
41
+ *
42
+ * @param {{ speedX: number, penalty: number, head?: number, ageMs?: number,
43
+ * maxRuns?: number }} params
44
+ * @returns {{ actions: object[], run: object }}
45
+ */
46
+ function planFor({ speedX, penalty, head = 0, ageMs = 60_000, maxRuns = 3, runs = null }) {
47
+ const coverage = new CoverageMap({ segmentCount: LAST + 1 });
48
+ const run = { from: 0, to: -1, head, speedX, isAlive: true, startedAt: 1_000_000 };
49
+ const live = runs ?? [run];
50
+ for (const one of live) {
51
+ coverage.claim(one, one.from, one.to);
52
+ }
53
+ return {
54
+ run,
55
+ actions: planEncoders({
56
+ coverage,
57
+ // One zone over the whole film, due now, so nothing but the arithmetic of
58
+ // sharing it decides anything.
59
+ windows: [{ from: 0, to: LAST, priority: 100, withinSeconds: 0, behind: false }],
60
+ runs: live,
61
+ maxRuns,
62
+ segmentSeconds: SEGMENT_SECONDS,
63
+ speedX,
64
+ killCostSec: 0.04,
65
+ firstByteWaitSec: 0.9,
66
+ moveCostSec: 0.94,
67
+ refetchSecPerFilmSecond: 0,
68
+ // Measured on the addon host: 1.70 at 854x480, 1.98 at 1920x1080. Raised
69
+ // to the power of how many others there are, so a third costs again.
70
+ contentionPenaltyFor: (others) => (others <= 0 ? 1 : penalty ** others),
71
+ now: 1_000_000 + ageMs
72
+ })
73
+ };
74
+ }
75
+
76
+ test("one encoder that reaches everything in time is left to do it alone", () => {
77
+ // A zone whose deadline GROWS with distance — which is what the map states,
78
+ // one segment at a time — is comfortably served by one encoder at 4.45x: #199
79
+ // is due in 835 s and arrives in 189 s. So there is no position to fill and
80
+ // nothing to split, whatever the budget says.
81
+ //
82
+ // Measured rather than assumed: this is what the plan answers, and the test
83
+ // was written expecting two encoders before it was run.
84
+ const { actions } = planFor({ speedX: 4.45, penalty: 1, maxRuns: 3 });
85
+
86
+ assert.deepEqual(
87
+ actions.map((one) => `${one.type} #${one.from}..#${one.to}`),
88
+ ["keep #0..#-1"],
89
+ "one encoder, its road to the end of the film"
90
+ );
91
+ });
92
+
93
+ test("a stretch nobody is on is split at the middle, and the halves bound each other", () => {
94
+ // Both encoders are fresh, so both owe the same and `d - w` is zero: the
95
+ // meeting point is the midpoint, which is what halving always said. What is
96
+ // new is that each one's road ENDS where the next begins, so neither writes
97
+ // into the other's names.
98
+ const actions = planFor({ speedX: 4.45, penalty: 1, maxRuns: 2, runs: [] }).actions;
99
+
100
+ assert.deepEqual(
101
+ actions.map((one) => `${one.type} #${one.from}..#${one.to}`),
102
+ ["start #0..#100", "start #101..#199"],
103
+ "0..199 halved at 100"
104
+ );
105
+ });
106
+
107
+ test("a third encoder halves the widest half that is left", () => {
108
+ const actions = planFor({ speedX: 4.45, penalty: 1, maxRuns: 3, runs: [] }).actions;
109
+
110
+ assert.deepEqual(
111
+ actions.map((one) => `${one.type} #${one.from}..#${one.to}`),
112
+ ["start #0..#50", "start #51..#100", "start #101..#199"],
113
+ "the first half is halved again, greedily, widest first"
114
+ );
115
+ });
116
+
117
+ test("a stretch of one piece is not split at all", () => {
118
+ const coverage = new CoverageMap({ segmentCount: 2 });
119
+ const actions = planEncoders({
120
+ coverage,
121
+ windows: [{ from: 0, to: 0, priority: 100, withinSeconds: 0, behind: false }],
122
+ runs: [],
123
+ maxRuns: 3,
124
+ segmentSeconds: SEGMENT_SECONDS,
125
+ speedX: 8.9,
126
+ killCostSec: 0.04,
127
+ firstByteWaitSec: 0.9,
128
+ moveCostSec: 0.94,
129
+ refetchSecPerFilmSecond: 0,
130
+ contentionPenaltyFor: () => 1,
131
+ now: 1_000_000
132
+ });
133
+
134
+ assert.equal(
135
+ actions.filter((one) => one.type === "start").length,
136
+ 1,
137
+ "there is nothing to share"
138
+ );
139
+ });