@torrent-tv/proxy 2.80.16 → 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,15 @@
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
+
7
+ ## 2.80.17
8
+
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.
10
+ **The shift is under one segment in every measured configuration** — 0.24 of a piece at the addon host's 1080p penalty — so halving was very nearly right, and this is stated rather than dressed up. What the derivation adds that halving never asked is the other half: two encoders under this host's MEASURED contention against one at full speed. At 1080p the penalty for a second is 1.98, it takes very nearly all of the first's speed, and the objective now refuses it and keeps one; with a free second it still places three.
11
+ - **Chore**: `bodies` in the placement arithmetic is `encoders`. A process is not a body, and in Russian the word reads obscenely.
12
+
1
13
  ## 2.80.16
2
14
 
3
15
  - **Fix**: THE SPINNER CARAVAN, by arithmetic alone, and the two props 2.80.15 carried are gone with it — the threshold that made a move pay for itself, and the exclusion that kept a live run off a deadline-less zone. Both were propping up a comparison that was wrong rather than indifferent. Three quantities were computed wrongly:
@@ -194,7 +194,7 @@ Not three terms in seconds. One pair per rank the map states, most urgent rank
194
194
  first, compared position by position:
195
195
 
196
196
  ```
197
- [ late(100), done(100), late(99), done(99), … late(1), done(1) ], bodies, wasted
197
+ [ late(100), done(100), late(99), done(99), … late(1), done(1) ], encoders, wasted
198
198
  ```
199
199
 
200
200
  `late(r)` is how long anybody waits past a deadline at rank `r`; `done(r)` is
@@ -210,7 +210,7 @@ source of truth about what matters and it already says so — ten ranks on a fil
210
210
  p100 at the number a viewer is stopped on, doubling zones down to p91 for the far
211
211
  tail, p1 for what lies behind them.
212
212
 
213
- `bodies` ranks below every rank of the map, so spare capacity cannot buy an
213
+ `encoders` ranks below every rank of the map, so spare capacity cannot buy an
214
214
  encoder where the map is indifferent. `wasted` is the swarm's bill for anything
215
215
  fetched twice.
216
216
 
@@ -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,11 +278,64 @@ 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.
269
285
 
286
+ ## Where a second encoder joins a stretch
287
+
288
+ Derived, not halved. A stretch of unmade film runs from `from` to `to`; whoever
289
+ is already on it stands at `from` and owes `w` before the piece under it exists;
290
+ a fresh one placed at `x` owes `d` — its start and then a whole piece — and both
291
+ then work at the rate two encoders leave each other, which is measured here.
292
+
293
+ ```
294
+ the one already there closes [from, x-1]: w + (x - 1 - from) / r rises with x
295
+ the fresh one closes [x, to]: d + (to - x) / r falls with x
296
+
297
+ x* = (from + to + 1) / 2 + (d - w) * r / 2
298
+ ```
299
+
300
+ The midpoint, shifted forward by half the difference of what the two owe, in
301
+ pieces. Halving is the special case `d = w`, which holds when both are fresh.
302
+
303
+ **The shift is under one segment in every measured configuration** — 0.24 of a
304
+ piece at the addon host's 1080p contention — so halving was very nearly right,
305
+ and saying otherwise would be dressing it up.
306
+
307
+ What the derivation adds is the question halving never asked: **is a second
308
+ encoder worth having at all?**
309
+
310
+ ```
311
+ one: w + (to - from - 1) / rate
312
+ two: w + (x* - 1 - from) / r
313
+ ```
314
+
315
+ Nothing is proposed where the second does not win. At 1920x1080 the measured
316
+ penalty for a second encoder on the addon host is 1.98 — it takes very nearly
317
+ all of the first's speed — and the objective keeps one; where a second is free it
318
+ places three.
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
+
270
339
  ## What is checked
271
340
 
272
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.16",
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,
@@ -168,29 +164,43 @@ export function planEncoders({
168
164
  const working = live.reduce((best, run) => Math.max(best, run.speedX || 0), 0);
169
165
  const rate = segmentSeconds > 0 ? (working > 0 ? working : speedX) / segmentSeconds : 0;
170
166
  // How long one piece takes AT THE RATE ACTUALLY IN FORCE. Concurrent encoders
171
- // slow each other — measured on this host — so an arrangement's own body count
167
+ // slow each other — measured on this host — so an arrangement's own encoder count
172
168
  // decides it, and the delays below are computed per arrangement for that
173
169
  // reason. Taken from the unpenalised rate instead, a piece looked cheaper the
174
- // more bodies there were, which made extra bodies look free: the plan bought a
170
+ // more encoders there were, which made extra encoders look free: the plan bought a
175
171
  // second encoder where one served, and the arrivals it was compared on were
176
172
  // computed at the slower rate all along.
177
173
  //
178
174
  // `Infinity` where nothing has been measured, which is what "no speed" means
179
175
  // and what makes every arrangement equally hopeless rather than equally free.
180
- const pieceAt = (howManyBodies) => {
181
- const inForce = rate / contentionPenaltyFor(Math.max(0, howManyBodies - 1));
176
+ const pieceAt = (howManyEncoders) => {
177
+ const inForce = rate / contentionPenaltyFor(Math.max(0, howManyEncoders - 1));
182
178
  return inForce > 0 ? 1 / inForce : Number.POSITIVE_INFINITY;
183
179
  };
184
- // What a body costs to take away from where it stands and put somewhere else:
180
+ // What a encoder costs to take away from where it stands and put somewhere else:
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.
@@ -240,18 +250,76 @@ export function planEncoders({
240
250
  // WHAT EACH BODY OWES BEFORE THE PIECE IT STANDS ON EXISTS, priced at the rate
241
251
  // the arrangement itself puts in force.
242
252
  //
243
- // Each body states its own debt where it is created, as a function of what one
244
- // piece costs — because only there is it known what the body IS, and here only
253
+ // Each encoder states its own debt where it is created, as a function of what one
254
+ // piece costs — because only there is it known what the encoder IS, and here only
245
255
  // how many of them there are. So there is nothing to dispatch on: no kind, no
246
256
  // tag, no case analysis. The count is known before any debt is needed, which
247
- // is why this is one pass over the bodies rather than a figure computed once
257
+ // is why this is one pass over the encoders rather than a figure computed once
248
258
  // outside.
249
- const priced = (bodies) => {
250
- const perPiece = pieceAt(bodies.length);
251
- return bodies.map((body) => ({ at: body.at, delaySec: body.owes(perPiece) }));
259
+ const priced = (encoders) => {
260
+ const perPiece = pieceAt(encoders.length);
261
+ return encoders.map((encoder) => ({ at: encoder.at, delaySec: encoder.owes(perPiece) }));
252
262
  };
253
263
 
254
264
 
265
+ // WHERE A SECOND ENCODER MEETS THE ONE ALREADY ON A STRETCH, and whether it is
266
+ // worth having.
267
+ //
268
+ // A stretch of unmade film runs from `from` to `to`. Whoever is already on it
269
+ // stands at `from` and owes `w` before the piece under it exists; a fresh one
270
+ // placed at `x` owes `d` — its start, and then a whole piece — and both then
271
+ // work at the rate two encoders leave each other, which is measured on this
272
+ // host and is not half by assumption.
273
+ //
274
+ // the one already there closes [from, x-1]: w + (x - 1 - from) / r
275
+ // the fresh one closes [x, to]: d + (to - x) / r
276
+ //
277
+ // The first rises with `x` and the second falls, so the stretch is closed
278
+ // soonest where they cross:
279
+ //
280
+ // x* = (from + to + 1) / 2 + (d - w) * r / 2
281
+ //
282
+ // The midpoint, shifted forward by half the difference of what the two owe,
283
+ // expressed in pieces. Halving is the special case `d = w`, which holds when
284
+ // both are fresh — and it was applied to every case, including the common one
285
+ // where `d` is a start plus a piece and `w` is the tail of a piece already
286
+ // being made.
287
+ //
288
+ // AND WHETHER TO SPLIT AT ALL, which halving never asked. Two encoders under
289
+ // contention against one at full speed:
290
+ //
291
+ // one: w + (to - from - 1) / rate
292
+ // two: w + (x* - 1 - from) / r
293
+ //
294
+ // Nothing back where the second does not win. On the addon host at 1920x1080
295
+ // the measured penalty for a second encoder is 1.98 — it takes very nearly
296
+ // all of the first's speed — so there a second almost never pays, and it was
297
+ // being placed regardless.
298
+ const splitAt = (from, to) => {
299
+ const pieces = to - from + 1;
300
+ if (!(pieces > 1) || !(rate > 0)) {
301
+ return null;
302
+ }
303
+ const together = rate / contentionPenaltyFor(live.length);
304
+ if (!(together > 0)) {
305
+ return null;
306
+ }
307
+ const perPieceTogether = 1 / together;
308
+ // What the one already on this stretch owes, or a fresh one's debt where
309
+ // nobody is on it — and then both sides owe the same and the meeting point
310
+ // is the middle, as it should be.
311
+ const onIt = live.find((run) => Number(run.head) === from) ?? null;
312
+ const owedThere = onIt
313
+ ? finishesItsPieceIn(onIt, perPieceTogether)
314
+ : firstByteWaitSec + perPieceTogether;
315
+ const owedFresh = firstByteWaitSec + perPieceTogether;
316
+ const meeting = (from + to + 1) / 2 + ((owedFresh - owedThere) * together) / 2;
317
+ const x = Math.min(to, Math.max(from + 1, Math.round(meeting)));
318
+ const withTwo = owedThere + (x - 1 - from) * perPieceTogether;
319
+ const withOne = owedThere + (pieces - 1) / rate;
320
+ return withTwo < withOne ? x : null;
321
+ };
322
+
255
323
  // ------------------------------------------------------------------ WHERE
256
324
  //
257
325
  // A question about the FILM, and about nothing else: which numbers are
@@ -265,11 +333,12 @@ export function planEncoders({
265
333
  // EVERY LIVE ENCODER IS PRE-PLACED, because that is what "somebody already
266
334
  // gets here in time" means. A number one of them reaches before it is
267
335
  // needed is not a position at all; a number none of them reaches is, and
268
- // needs a body brought to it. There is no third case, and in particular no
336
+ // needs a encoder brought to it. There is no third case, and in particular no
269
337
  // separate question of whether an encoder should drive on or be moved:
270
338
  // driving is simply its arrival, and its arrival is priced in one place.
271
339
  firstGap: gapFinderFor(coverage, new Set(live), rate, segmentSeconds * refetchSecPerFilmSecond),
272
- deadlineAt: untilNeeded
340
+ deadlineAt: untilNeeded,
341
+ splitAt
273
342
  });
274
343
 
275
344
  // -------------------------------------------------------------------- WHO
@@ -277,12 +346,12 @@ export function planEncoders({
277
346
  // ARGMIN OF THE OBJECTIVE, EVALUATED. Not a rule that approximates it.
278
347
  //
279
348
  // Every way of filling the positions is scored by `latenessOf` and the best is
280
- // taken. There are at most a handful of positions and a handful of bodies, so
349
+ // taken. There are at most a handful of positions and a handful of encoders, so
281
350
  // the enumeration is exact: no local rule stands in for the objective, and
282
351
  // none can therefore disagree with another.
283
352
  //
284
353
  // Four such rules were written before this and all four had to go — "place
285
- // where a number is late", "take a body that serves nothing", "take one whose
354
+ // where a number is late", "take a encoder that serves nothing", "take one whose
286
355
  // work is needed later than this", "drive on or move, by cost". Each looked
287
356
  // like a consequence of the model and each approximated it from a different
288
357
  // side, so together they contradicted one another and the answer depended on
@@ -326,21 +395,21 @@ export function planEncoders({
326
395
  let best = null;
327
396
  let bestScore = null;
328
397
  for (const arrangement of arrangements) {
329
- const bodies = [];
398
+ const encoders = [];
330
399
  for (let index = 0; index < positions.length; index += 1) {
331
400
  const filler = arrangement.fill[index];
332
401
  if (filler === null) {
333
402
  continue;
334
403
  }
335
404
  if (filler === "new") {
336
- // A body that does not exist yet owes its own start and then the piece.
337
- bodies.push({ at: positions[index], owes: (piece) => firstByteWaitSec + piece });
405
+ // A encoder that does not exist yet owes its own start and then the piece.
406
+ encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
338
407
  continue;
339
408
  }
340
409
  const head = Number(filler.head);
341
410
  // Left where it stands it owes what is left of the piece under it; taken
342
411
  // somewhere else it owes the killing, the start and a whole piece.
343
- bodies.push({
412
+ encoders.push({
344
413
  at: positions[index],
345
414
  owes: head === positions[index]
346
415
  ? (piece) => finishesItsPieceIn(filler, piece)
@@ -350,7 +419,7 @@ export function planEncoders({
350
419
  // Bodies nobody was given a position for go on working where they stand,
351
420
  // and their coverage counts: the file is encoded whole.
352
421
  //
353
- // A body given no end pays a restart the moment anybody is placed inside the
422
+ // A encoder given no end pays a restart the moment anybody is placed inside the
354
423
  // road it would drive: where a run stops is fixed when its process starts,
355
424
  // so it has to be cut and begun again at its own head. That price was
356
425
  // invisible here, and an arrangement was scored as free when it was not.
@@ -363,14 +432,14 @@ export function planEncoders({
363
432
  const cutInFront = arrangement.fill.some((filler, index) =>
364
433
  filler !== null && positions[index] > head
365
434
  && (endless || positions[index] <= Number(run.to)));
366
- bodies.push({
435
+ encoders.push({
367
436
  at: head,
368
437
  owes: cutInFront
369
438
  ? (piece) => moveSec + piece
370
439
  : (piece) => finishesItsPieceIn(run, piece)
371
440
  });
372
441
  }
373
- const scored = latenessOf(priced(bodies), coverage, wanted, untilNeeded, rate / contentionPenaltyFor(Math.max(0, bodies.length - 1)), refetchPerSegment, segmentSeconds);
442
+ const scored = latenessOf(priced(encoders), coverage, wanted, untilNeeded, rate / contentionPenaltyFor(Math.max(0, encoders.length - 1)), refetchPerSegment, segmentSeconds);
374
443
  if (bestScore === null || cheaperThan(scored, bestScore)) {
375
444
  bestScore = scored;
376
445
  best = arrangement;
@@ -379,13 +448,13 @@ export function planEncoders({
379
448
 
380
449
  // A BODY STANDING ON FILM THAT EXISTS is the one arrangement the enumeration
381
450
  // above cannot reach: the gap in front of it is nobody's deadline, so it is
382
- // never a position, and the body is left to make three hundred pieces a second
383
- // time. Each such body is offered its own first gap and the SAME score decides
451
+ // never a position, and the encoder is left to make three hundred pieces a second
452
+ // time. Each such encoder is offered its own first gap and the SAME score decides
384
453
  // — moving costs a restart on everything downstream, staying costs the repeat.
385
454
  //
386
455
  // Offered one at a time rather than folded into the enumeration because the
387
456
  // enumeration is exponential in the number of positions, and this is called
388
- // again on every piece produced. One extra evaluation per body against
457
+ // again on every piece produced. One extra evaluation per encoder against
389
458
  // several thousand arrangements is the difference between arithmetic and a
390
459
  // stalled proxy.
391
460
  const placement = new Map();
@@ -395,8 +464,8 @@ export function planEncoders({
395
464
  placement.set(filler, positions[index]);
396
465
  }
397
466
  }
398
- const bodiesOf = (override) => {
399
- const bodies = [];
467
+ const encodersOf = (override) => {
468
+ const encoders = [];
400
469
  for (const run of live) {
401
470
  if (override.has(run) && override.get(run) === null) {
402
471
  // Asked what the film looks like WITHOUT this one.
@@ -404,7 +473,7 @@ export function planEncoders({
404
473
  }
405
474
  const at = override.has(run) ? override.get(run) : (placement.get(run) ?? Number(run.head));
406
475
  const head = Number(run.head);
407
- bodies.push({
476
+ encoders.push({
408
477
  at,
409
478
  owes: at === head
410
479
  ? (piece) => finishesItsPieceIn(run, piece)
@@ -413,15 +482,15 @@ export function planEncoders({
413
482
  }
414
483
  for (let index = 0; index < positions.length; index += 1) {
415
484
  if ((best ? best.fill[index] : null) === "new") {
416
- bodies.push({ at: positions[index], owes: (piece) => firstByteWaitSec + piece });
485
+ encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
417
486
  }
418
487
  }
419
- return priced(bodies);
488
+ return priced(encoders);
420
489
  };
421
490
  const scoreOf = (override) => {
422
- const bodies = bodiesOf(override);
423
- return latenessOf(bodies, coverage, wanted, untilNeeded,
424
- rate / contentionPenaltyFor(Math.max(0, bodies.length - 1)), refetchPerSegment, segmentSeconds);
491
+ const encoders = encodersOf(override);
492
+ return latenessOf(encoders, coverage, wanted, untilNeeded,
493
+ rate / contentionPenaltyFor(Math.max(0, encoders.length - 1)), refetchPerSegment, segmentSeconds);
425
494
  };
426
495
  for (const run of live) {
427
496
  if (placement.has(run)) {
@@ -439,16 +508,16 @@ export function planEncoders({
439
508
  }
440
509
 
441
510
  /**
442
- * Would the film be worse off without this body? Asked of the same score.
511
+ * Would the film be worse off without this encoder? Asked of the same score.
443
512
  *
444
513
  * @param {object} run
445
514
  * @returns {boolean}
446
515
  */
447
516
  const worseWithout = (run) => {
448
- const kept = bodiesOf(new Map());
449
- const without = bodiesOf(new Map([[run, null]]));
450
- const scoreOf_ = (bodies) => latenessOf(bodies, coverage, wanted, untilNeeded,
451
- rate / contentionPenaltyFor(Math.max(0, bodies.length - 1)), refetchPerSegment, segmentSeconds);
517
+ const kept = encodersOf(new Map());
518
+ const without = encodersOf(new Map([[run, null]]));
519
+ const scoreOf_ = (encoders) => latenessOf(encoders, coverage, wanted, untilNeeded,
520
+ rate / contentionPenaltyFor(Math.max(0, encoders.length - 1)), refetchPerSegment, segmentSeconds);
452
521
  return cheaperThan(scoreOf_(kept), scoreOf_(without));
453
522
  };
454
523
 
@@ -486,7 +555,7 @@ export function planEncoders({
486
555
  }
487
556
  // It stays where it is — unless holding it changes nothing.
488
557
  //
489
- // A body left over from where a viewer used to be goes on costing the
558
+ // A encoder left over from where a viewer used to be goes on costing the
490
559
  // machine a process while another encoder already reaches everything it
491
560
  // would. The score says so directly: take it away and see. Removing it is
492
561
  // refused the moment it makes anything later or leaves film abandoned, so
@@ -514,7 +583,7 @@ export function planEncoders({
514
583
  if (action.type !== "keep") {
515
584
  continue;
516
585
  }
517
- const without = latenessOf(bodiesOf(new Map([[run, null]])), coverage, wanted, untilNeeded,
586
+ const without = latenessOf(encodersOf(new Map([[run, null]])), coverage, wanted, untilNeeded,
518
587
  rate / contentionPenaltyFor(Math.max(0, live.length - 2)), refetchPerSegment, segmentSeconds);
519
588
  if (cheapestScore === null || cheaperThan(without, cheapestScore)) {
520
589
  cheapestScore = without;
@@ -635,7 +704,7 @@ export function planEncoders({
635
704
  * quantity says how many of one are worth one of the other. Adding them would
636
705
  * mean choosing that exchange rate, which is inventing a number.
637
706
  *
638
- * @param {{ at: number, delaySec: number }[]} bodies - Where each encoder would
707
+ * @param {{ at: number, delaySec: number }[]} encoders - Where each encoder would
639
708
  * stand, and how long before it produces anything there: nothing where it is
640
709
  * already standing, a move or a start otherwise.
641
710
  * @param {import("./CoverageMap.js").CoverageMap} coverage
@@ -649,7 +718,7 @@ export function planEncoders({
649
718
  * @returns {{ stall: number, ahead: number, whole: number }} Three counts of
650
719
  * seconds, compared in that order by {@link cheaperThan}.
651
720
  */
652
- function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSegment, segmentSeconds) {
721
+ function latenessOf(encoders, coverage, wanted, untilNeeded, rate, refetchSecPerSegment, segmentSeconds) {
653
722
  const first = Math.min(...wanted.map((span) => span.from));
654
723
  const last = Math.max(...wanted.map((span) => span.to));
655
724
  // What a number nobody reaches at all counts as. The film's own length is the
@@ -729,8 +798,8 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
729
798
  // already made on the way, which costs its own time and the swarm's.
730
799
  let soonest = Number.POSITIVE_INFINITY;
731
800
  let byWhom = null;
732
- for (const body of bodies) {
733
- if (body.at > index) {
801
+ for (const encoder of encoders) {
802
+ if (encoder.at > index) {
734
803
  continue;
735
804
  }
736
805
  // WHEN THIS BODY REACHES THIS NUMBER. `delaySec` is when it finishes the
@@ -738,8 +807,8 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
738
807
  // to be encoded at `rate`.
739
808
  //
740
809
  // It used to be `(index - at + 1) / rate` on top of the delay, which
741
- // charges every body a whole piece for the one it is already working on. A
742
- // fresh body does start from nothing, so for it that is right — and it is
810
+ // charges every encoder a whole piece for the one it is already working on. A
811
+ // fresh encoder does start from nothing, so for it that is right — and it is
743
812
  // now inside its own delay. A run 0.8 s into a 0.9 s piece does not, and
744
813
  // charging it 0.94 s for that piece is what made moving it look cheaper
745
814
  // than leaving it: from #58 it was priced at 1.89 s to reach #59 against
@@ -747,12 +816,12 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
747
816
  // that is nothing but the double charge. Field 2026-09-08: 39 moves in one
748
817
  // session, 24 of them between three adjacent numbers, and the picture
749
818
  // stood still for 116.7 s.
750
- const arrival = body.delaySec
751
- + (index - body.at) / rate
752
- + coverage.madeBetween(body.at, index) * refetchSecPerSegment;
819
+ const arrival = encoder.delaySec
820
+ + (index - encoder.at) / rate
821
+ + coverage.madeBetween(encoder.at, index) * refetchSecPerSegment;
753
822
  if (arrival < soonest) {
754
823
  soonest = arrival;
755
- byWhom = body;
824
+ byWhom = encoder;
756
825
  }
757
826
  }
758
827
  if (coverage.isReady(index)) {
@@ -802,7 +871,7 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
802
871
  // the swarm's bill, so it cannot buy one where the map is indifferent — and
803
872
  // the map IS indifferent about spare capacity, which is what bought an
804
873
  // encoder for film nobody waits for.
805
- bodies: bodies.length,
874
+ encoders: encoders.length,
806
875
  // WHAT THE SWARM PAYS for anything fetched twice, which delays everything.
807
876
  wasted: wastedSwarm
808
877
  };
@@ -846,8 +915,8 @@ function cheaperThan(left, right) {
846
915
  // indifferent, so the machine decides: a process, a reader of the piece store
847
916
  // and the swarm's bandwidth are all paid by the viewers the ranks above are
848
917
  // about.
849
- if (left.bodies !== right.bodies) {
850
- return left.bodies < right.bodies;
918
+ if (left.encoders !== right.encoders) {
919
+ return left.encoders < right.encoders;
851
920
  }
852
921
  return left.wasted < right.wasted;
853
922
  }
@@ -906,7 +975,7 @@ function deadlineReaderFor(windows, segmentSeconds) {
906
975
  // the viewers, which the map marks with exactly that, was the most urgent
907
976
  // material in the file. Everything followed from it: it bought encoders,
908
977
  // it took the run standing in front of the viewer because that run was the
909
- // nearest body to it, and it did so again on every pass. Field 2026-09-08:
978
+ // nearest encoder to it, and it did so again on every pass. Field 2026-09-08:
910
979
  // 39 moves in one session, 24 between three adjacent numbers, one viewer
911
980
  // on three encoders, and the picture stood still for 116.7 s in three
912
981
  // interruptions, the worst of them 91.8 s.
@@ -1176,7 +1245,20 @@ export function firstUnmetWant(coverage, windows) {
1176
1245
  * stated want is due now.
1177
1246
  * @returns {number[]} Where to start each encoder, ascending.
1178
1247
  */
1179
- export function placeEncoders({ coverage, windows, howMany, firstGap = null, deadlineAt = null }) {
1248
+ export function placeEncoders({
1249
+ coverage,
1250
+ windows,
1251
+ howMany,
1252
+ firstGap = null,
1253
+ deadlineAt = null,
1254
+ // WHERE TWO ENCODERS SHARE A STRETCH, and whether a second is worth having at
1255
+ // all. Derived by whoever holds the measurements — the rates, the contention
1256
+ // penalty, what a start costs — because this function is positional and holds
1257
+ // none of them. The default is the midpoint, which IS the answer when both are
1258
+ // fresh and owe the same, and is what a caller with nothing measured falls
1259
+ // back on.
1260
+ splitAt = (from, to) => from + Math.floor((to - from + 1) / 2)
1261
+ }) {
1180
1262
  if (!(howMany > 0) || windows.length === 0) {
1181
1263
  return [];
1182
1264
  }
@@ -1249,8 +1331,18 @@ export function placeEncoders({ coverage, windows, howMany, firstGap = null, dea
1249
1331
  const to = index === edges.length ? lastOf(windows) : edges[index] - 1;
1250
1332
  const room_ = coverage.unmadeRunFrom(from);
1251
1333
  if (to >= from && room_ > widest) {
1252
- widest = room_;
1253
- widestFrom = from + Math.floor(Math.min(room_, to - from + 1) / 2);
1334
+ // WHERE THE TWO OF THEM MEET, calculated rather than halved. Halving is
1335
+ // the answer only when both encoders are fresh and owe the same, and it
1336
+ // was applied to every case — one already working is partway through a
1337
+ // piece while a new one owes its whole start, so the point where they
1338
+ // finish together lies further on. Nothing back means a second encoder
1339
+ // does not pay for itself here at all: two under this host's measured
1340
+ // contention against one at full speed, a comparison halving never made.
1341
+ const meet = splitAt(from, Math.min(from + room_ - 1, to));
1342
+ if (meet !== null) {
1343
+ widest = room_;
1344
+ widestFrom = meet;
1345
+ }
1254
1346
  }
1255
1347
  }
1256
1348
  if (widestFrom === null) {
@@ -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
+ });