@torrent-tv/proxy 2.80.14 → 2.80.15
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 +12 -0
- package/docs/encode-architecture.md +65 -0
- package/package.json +1 -1
- package/services/encode/EncodePlan.js +181 -40
- package/services/encode/run-costs.js +54 -3
- package/services/hls-session-manager.js +6 -2
- package/services/output/LiveOutputs.js +17 -7
- package/services/output/rates.js +35 -5
- package/test/encode-plan.test.js +9 -3
- package/test/move-cost.test.js +138 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## 2.80.15
|
|
2
|
+
|
|
3
|
+
- **Fix**: THE SPINNER CARAVAN, and its cause was one coercion. The priority map states `withinSeconds: null` for the film BEHIND the viewers — nobody is waiting there — and `deadlineReaderFor` read it through `Number()`, where `null` is 0. So the film a viewer had already passed was due IMMEDIATELY and was the most urgent material in the file. It bought encoders, and it took the run standing in front of the viewer because that run was the nearest body to it, and it did so again on every pass. Field 2026-09-08: 39 moves in one session, 24 of them between three adjacent numbers about 0.8 s apart, one viewer on a host affording three runs got three, and the picture stood still for 116.7 s in three interruptions, the worst 91.8 s. The map has said it plainly in every session's log; this line turned it into its opposite. Fourth time in this repository that the input to a calculation was not what the calculation assumed.
|
|
4
|
+
- **Fix**: The objective is the map's OWN rank order, as a vector compared position by position — one pair per rank the map states, most urgent first: how long anybody waits at that rank, then when the last number of it is made. It was three seconds-valued terms with the ten ranks collapsed into two buckets, and "never" for the film behind is the film's own length — 2024 s on a 48-minute file — which outvotes everything above it. No weights, and none possible: a weight would let seconds at one rank buy seconds at another and would be a number nobody measured. The map is the source of truth about what matters and it already says so.
|
|
5
|
+
- **Fix**: Residual work takes only spare capacity. A zone stating no deadline has nobody waiting in it, so a live run already standing in front of a viewer is never taken for it — taking it there means killing it, and the viewer waits out a cold start for film nobody asked for.
|
|
6
|
+
- **Fix**: An act must pay for itself. Where an arrangement is only reachable by killing a running encoder, a gain smaller than what that killing costs is not a gain. The field numbers are that close: driving from #58 to #59 means making two pieces, 1.89 s at 4.45x on a 4.2 s grid, against a cold start and one piece, 1.88 s — the move was faster by ten milliseconds, and every one of the 39 was individually correct. The margin is the measured cost of the act, never a chosen figure.
|
|
7
|
+
- **Fix**: The plan remembers what it is already carrying out. A staying run was priced as producing instantly however recently it had started; it is now priced at what it still has to go — the measured time to a first piece less the time it has been alive. A run 0.8 s old has 0.14 s left against 0.94 s to move it. One measured figure minus elapsed time, no state to keep.
|
|
8
|
+
- **Fix**: What a move costs is `Infinity` until something has been measured, because a move is irreversible and leaving the encoder alone is always available; placing one where there is none takes the unknown the other way, since the film gets made or it does not. And a run killed before producing anything is a measurement too — a lower bound on the first output — which is the only reading a thrash can supply, since every run in one is killed before it finishes anything.
|
|
9
|
+
- **Fix**: The average bitrate declared in the master came from a field that does not exist on a source file, so every variant was declared at the 400 kbit/s floor in 2.80.14 — nine times worse than the rule of thumb it replaced. It is read from where it is known: the torrent's own report of the file's length, with the probe's reading of the video stream covering the gap before that arrives.
|
|
10
|
+
- **New**: The piece store's own line names its holders, and `AHEAD` no longer reaches memory.
|
|
11
|
+
- **Chore**: `hls-session-manager.js` grew by four lines of wiring in its constructor, which the rule for that file does not allow. Stated rather than shuffled: the thing itself moved to `output/`, and what is left is two accessors handed over where wiring belongs.
|
|
12
|
+
|
|
1
13
|
## 2.80.14
|
|
2
14
|
|
|
3
15
|
- **Fix**: What the master playlist declares a variant carries is MEASURED. It was `height * height * 3.2` — for 1080 exactly the 3 732 480 the field declared for a file carrying 18.4 Mbit/s, five times more — and the comment justifying it said no measurement existed before encoding starts. Both do: the average is the file's own length over its duration, exact at session creation, and the peak is the biggest piece produced over the span it covers, equal to the average until one exists. A re-encoded height is declared at the cap we impose; a smaller height at the pixel share of the source's rate. `BANDWIDTH` and `AVERAGE-BANDWIDTH` are both emitted, as the specification names them. This is not cosmetic: the browser sizes its cushion in BYTES from `BANDWIDTH`, so 120 s asked bought 56 MB — 26 s of that film — and the deepest it ever held was 17.1 s. One expression, no case analysis: absent measurements and absent caps are written as the identity of the operation that consumes them (`services/output/rates.js`, eight checks).
|
|
@@ -188,6 +188,71 @@ was 17.1 s. Inflating it is not the answer either: hls.js compares it against
|
|
|
188
188
|
its own estimate of the link to decide a level is unplayable, and its recovery
|
|
189
189
|
then moves level by itself, which does not honour our pinning.
|
|
190
190
|
|
|
191
|
+
## The objective is the map's own rank order
|
|
192
|
+
|
|
193
|
+
Not three terms in seconds. One pair per rank the map states, most urgent rank
|
|
194
|
+
first, compared position by position:
|
|
195
|
+
|
|
196
|
+
```
|
|
197
|
+
[ late(100), done(100), late(99), done(99), … late(1), done(1) ], bodies, wasted
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
`late(r)` is how long anybody waits past a deadline at rank `r`; `done(r)` is
|
|
201
|
+
when the last number of that rank is made. A difference at a higher rank settles
|
|
202
|
+
it and nothing lower can reopen it — which is the stated order: nobody stares at
|
|
203
|
+
a spinner; then the film in front is encoded as fast as it can be, band by band
|
|
204
|
+
as the map ranks them; then, with what is left over and only then, the film
|
|
205
|
+
behind, in case somebody seeks back.
|
|
206
|
+
|
|
207
|
+
**No weights, and none possible.** A weight would let seconds at one rank buy
|
|
208
|
+
seconds at another, and it would be a figure nobody measured. The map is the
|
|
209
|
+
source of truth about what matters and it already says so — ten ranks on a film,
|
|
210
|
+
p100 at the number a viewer is stopped on, doubling zones down to p91 for the far
|
|
211
|
+
tail, p1 for what lies behind them.
|
|
212
|
+
|
|
213
|
+
`bodies` ranks below every rank of the map, so spare capacity cannot buy an
|
|
214
|
+
encoder where the map is indifferent. `wasted` is the swarm's bill for anything
|
|
215
|
+
fetched twice.
|
|
216
|
+
|
|
217
|
+
## Three rules that stop an encoder being moved for nothing
|
|
218
|
+
|
|
219
|
+
**A zone with no deadline never takes a live run.** Residual work — the film
|
|
220
|
+
behind the viewers, kept in case somebody seeks back — is done with capacity
|
|
221
|
+
that is left over, and a run already standing in front of a viewer is not left
|
|
222
|
+
over.
|
|
223
|
+
|
|
224
|
+
**An act must pay for itself.** Where an arrangement is only reachable by killing
|
|
225
|
+
a running encoder, a gain smaller than what the killing costs is not a gain. The
|
|
226
|
+
margin is the measured cost of the act.
|
|
227
|
+
|
|
228
|
+
**The plan remembers what it is already carrying out.** A staying run is priced
|
|
229
|
+
at what it still has to go — the measured time to a first piece less the time it
|
|
230
|
+
has been alive — not at zero. A run 0.8 s old has 0.14 s left against 0.94 s to
|
|
231
|
+
move it; one working half a minute has nothing left, and a move then happens
|
|
232
|
+
exactly when the film it would reach sooner is worth the restart.
|
|
233
|
+
|
|
234
|
+
And what a move costs is `Infinity` until something has been measured: a move is
|
|
235
|
+
irreversible and leaving the encoder alone is always available. Placing one where
|
|
236
|
+
there is none takes the unknown the other way, because the film gets made or it
|
|
237
|
+
does not.
|
|
238
|
+
|
|
239
|
+
### What all three were for
|
|
240
|
+
|
|
241
|
+
Field 2026-09-08. The map states `withinSeconds: null` for the film behind the
|
|
242
|
+
viewers; `deadlineReaderFor` read it through `Number()`, where `null` is 0, so
|
|
243
|
+
that film was due IMMEDIATELY and was the most urgent material in the file. It
|
|
244
|
+
bought encoders and it took the run standing in front of the viewer, because
|
|
245
|
+
that run was the nearest body to it — 39 moves in one session, 24 of them between
|
|
246
|
+
three adjacent numbers about 0.8 s apart. One viewer on a host affording three
|
|
247
|
+
runs got three. The picture stood still for 116.7 s in three interruptions, the
|
|
248
|
+
worst of them 91.8 s.
|
|
249
|
+
|
|
250
|
+
The numbers are worth keeping because they are so close: driving from #58 to #59
|
|
251
|
+
means making TWO pieces, 1.89 s at 4.45x on a 4.2 s grid, against a cold start
|
|
252
|
+
and ONE piece, 0.94 + 0.94 = 1.88 s. Every one of those 39 moves was
|
|
253
|
+
individually the cheapest arrangement it was offered. That is the signature of an
|
|
254
|
+
optimiser with no memory, and the three rules above are what give it one.
|
|
255
|
+
|
|
191
256
|
## What is checked
|
|
192
257
|
|
|
193
258
|
`test/one-authority.test.js` holds the shape: one caller of `#startEncodeRun`,
|
package/package.json
CHANGED
|
@@ -102,6 +102,11 @@
|
|
|
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
|
+
* @param {number} [params.now] - The clock, injected. This layer is arithmetic
|
|
109
|
+
* and reads no clock of its own; how old a run is is one of its inputs.
|
|
105
110
|
* @param {number} [params.firstByteWaitSec] - How long a fresh encoder takes to
|
|
106
111
|
* produce anything: process start, opening the input, and the first piece.
|
|
107
112
|
* Measured the same way. It replaced a constant of 0.12 s taken from one
|
|
@@ -123,6 +128,8 @@ export function planEncoders({
|
|
|
123
128
|
segmentSeconds,
|
|
124
129
|
killCostSec = 0,
|
|
125
130
|
firstByteWaitSec = 0,
|
|
131
|
+
moveCostSec = Number.POSITIVE_INFINITY,
|
|
132
|
+
now = Date.now(),
|
|
126
133
|
refetchSecPerFilmSecond = 0,
|
|
127
134
|
contentionPenaltyFor = () => 1,
|
|
128
135
|
speedX = 0
|
|
@@ -164,7 +171,38 @@ export function planEncoders({
|
|
|
164
171
|
// its death, the start of another, and the wait for the first bytes there.
|
|
165
172
|
// Taking an encoder somewhere else is stopping this one and waiting for the
|
|
166
173
|
// next to produce. Both halves are measured on this host.
|
|
167
|
-
|
|
174
|
+
// WHAT A MOVE COSTS. Killing an encoder and waiting for a fresh one's first
|
|
175
|
+
// piece is the price; until something has produced anything on this host that
|
|
176
|
+
// price is unknown, and a move is then refused rather than priced at zero.
|
|
177
|
+
// Placing one where there is none is the other question and takes the unknown
|
|
178
|
+
// the other way — see `run-costs.js`.
|
|
179
|
+
const moveSec = Number.isFinite(moveCostSec) ? moveCostSec : killCostSec + firstByteWaitSec;
|
|
180
|
+
|
|
181
|
+
// WHAT A RUN STILL HAS TO GO BEFORE IT PRODUCES ANYTHING — the measured time
|
|
182
|
+
// to a first piece, less the time it has already been alive.
|
|
183
|
+
//
|
|
184
|
+
// This is the memory the score was missing. It is computed afresh whenever
|
|
185
|
+
// anything changes, and every arrangement used to be priced as though it were
|
|
186
|
+
// the last decision anybody would take: a run that started 10 ms ago was
|
|
187
|
+
// assumed to produce instantly, so killing it and starting another looked like
|
|
188
|
+
// a straight gain. A move is justified by a benefit that arrives when the
|
|
189
|
+
// moved run produces something; taken again before it has, the benefit is
|
|
190
|
+
// never collected and the cost is paid twice, three times, forty times.
|
|
191
|
+
//
|
|
192
|
+
// A run 0.8 s old has 0.14 s left to go against 0.94 s to move it, so it is
|
|
193
|
+
// left alone; one working for half a minute has nothing left, and a move
|
|
194
|
+
// happens exactly when the film it would reach sooner is worth the restart. No
|
|
195
|
+
// state to keep and nothing to choose: one measured figure minus elapsed time.
|
|
196
|
+
const remainingWarmOf = (run) => {
|
|
197
|
+
if (Number(run.head) !== Number(run.from)) {
|
|
198
|
+
return 0; // it has produced something, so its warm-up is spent
|
|
199
|
+
}
|
|
200
|
+
const startedAt = Number(run.startedAt);
|
|
201
|
+
if (!Number.isFinite(startedAt) || startedAt <= 0) {
|
|
202
|
+
return firstByteWaitSec; // not started yet, so all of it is ahead
|
|
203
|
+
}
|
|
204
|
+
return Math.max(0, firstByteWaitSec - (now - startedAt) / 1000);
|
|
205
|
+
};
|
|
168
206
|
|
|
169
207
|
// ------------------------------------------------------------------ WHERE
|
|
170
208
|
//
|
|
@@ -228,6 +266,28 @@ export function planEncoders({
|
|
|
228
266
|
if (arrangement.used.has(run)) {
|
|
229
267
|
continue;
|
|
230
268
|
}
|
|
269
|
+
// A LIVE RUN IS NEVER TAKEN FOR RESIDUAL WORK.
|
|
270
|
+
//
|
|
271
|
+
// A zone that states no deadline has nobody waiting in it: it is the
|
|
272
|
+
// film behind the viewers, kept in case somebody seeks back, and it is
|
|
273
|
+
// done with capacity that is left over. A run already standing in front
|
|
274
|
+
// of a viewer is not left over — taking it there means killing it, and
|
|
275
|
+
// the viewer it was serving waits out a cold start for film nobody had
|
|
276
|
+
// asked for.
|
|
277
|
+
//
|
|
278
|
+
// Field 2026-09-08: on a host affording three runs, one viewer got
|
|
279
|
+
// three. Two of them came from this residual capacity, and the third was
|
|
280
|
+
// the run serving the viewer, taken to #30 in the middle of the film
|
|
281
|
+
// they had already watched. Reaching that film took the third term from
|
|
282
|
+
// "never" — the film's own length, 2024 s — to a real figure, which
|
|
283
|
+
// outvotes any price a move can carry. It must not be able to outvote
|
|
284
|
+
// it, and the reason is not arithmetic: nobody is waiting there.
|
|
285
|
+
//
|
|
286
|
+
// Read off the map, which is the one thing that states it. Nothing here
|
|
287
|
+
// knows that a viewer exists.
|
|
288
|
+
if (!Number.isFinite(untilNeeded(positions[index]))) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
231
291
|
next.push({
|
|
232
292
|
fill: [...arrangement.fill, run],
|
|
233
293
|
used: new Set([...arrangement.used, run]),
|
|
@@ -254,7 +314,7 @@ export function planEncoders({
|
|
|
254
314
|
const head = Number(filler.head);
|
|
255
315
|
bodies.push({
|
|
256
316
|
at: positions[index],
|
|
257
|
-
delaySec: head === positions[index] ?
|
|
317
|
+
delaySec: head === positions[index] ? remainingWarmOf(filler) : moveSec
|
|
258
318
|
});
|
|
259
319
|
}
|
|
260
320
|
// Bodies nobody was given a position for go on working where they stand,
|
|
@@ -273,10 +333,20 @@ export function planEncoders({
|
|
|
273
333
|
const cutInFront = arrangement.fill.some((filler, index) =>
|
|
274
334
|
filler !== null && positions[index] > head
|
|
275
335
|
&& (endless || positions[index] <= Number(run.to)));
|
|
276
|
-
bodies.push({ at: head, delaySec: cutInFront ? moveSec :
|
|
336
|
+
bodies.push({ at: head, delaySec: cutInFront ? moveSec : remainingWarmOf(run) });
|
|
277
337
|
}
|
|
278
338
|
const scored = latenessOf(bodies, coverage, wanted, untilNeeded, rate / contentionPenaltyFor(Math.max(0, bodies.length - 1)), refetchPerSegment, segmentSeconds);
|
|
279
|
-
|
|
339
|
+
// WHETHER THIS ARRANGEMENT KILLS A RUNNING ENCODER, which is what has to pay
|
|
340
|
+
// for itself. An arrangement that leaves every live run where it stands owes
|
|
341
|
+
// nothing; one that takes a run somewhere else has to be better by what the
|
|
342
|
+
// taking costs.
|
|
343
|
+
const disturbs = live.some((run) => {
|
|
344
|
+
const at = arrangement.used.has(run)
|
|
345
|
+
? positions[arrangement.fill.indexOf(run)]
|
|
346
|
+
: Number(run.head);
|
|
347
|
+
return at !== Number(run.head);
|
|
348
|
+
});
|
|
349
|
+
if (bestScore === null || cheaperThan(scored, bestScore, disturbs ? moveSec : 0)) {
|
|
280
350
|
bestScore = scored;
|
|
281
351
|
best = arrangement;
|
|
282
352
|
}
|
|
@@ -309,7 +379,7 @@ export function planEncoders({
|
|
|
309
379
|
}
|
|
310
380
|
const at = override.has(run) ? override.get(run) : (placement.get(run) ?? Number(run.head));
|
|
311
381
|
const head = Number(run.head);
|
|
312
|
-
bodies.push({ at, delaySec: at === head ?
|
|
382
|
+
bodies.push({ at, delaySec: at === head ? remainingWarmOf(run) : moveSec });
|
|
313
383
|
}
|
|
314
384
|
for (let index = 0; index < positions.length; index += 1) {
|
|
315
385
|
if ((best ? best.fill[index] : null) === "new") {
|
|
@@ -333,7 +403,7 @@ export function planEncoders({
|
|
|
333
403
|
}
|
|
334
404
|
const asIs = scoreOf(new Map());
|
|
335
405
|
const moved = scoreOf(new Map([[run, gap]]));
|
|
336
|
-
if (cheaperThan(moved, asIs)) {
|
|
406
|
+
if (cheaperThan(moved, asIs, moveSec)) {
|
|
337
407
|
placement.set(run, gap);
|
|
338
408
|
}
|
|
339
409
|
}
|
|
@@ -566,19 +636,33 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
|
|
|
566
636
|
// had nothing to compare, and the encoder was free to wander to the start of
|
|
567
637
|
// the file. Which side a stretch is on and how soon it is wanted are two
|
|
568
638
|
// different facts, and the map states both.
|
|
569
|
-
|
|
570
|
-
|
|
639
|
+
// WHAT RANK THE MAP GIVES THIS NUMBER — the highest, where zones overlap,
|
|
640
|
+
// because a number two viewers want is wanted as much as the more urgent of
|
|
641
|
+
// them wants it.
|
|
642
|
+
//
|
|
643
|
+
// This replaced a boolean, "is it behind everybody", and the boolean was the
|
|
644
|
+
// whole of what the objective knew about the map's own order. The map states
|
|
645
|
+
// ten ranks on a film — p100 at the number a viewer is stopped on, doubling
|
|
646
|
+
// zones down to p91 for the far tail, p1 for what lies behind them — and all
|
|
647
|
+
// of that was collapsed into two buckets and then converted to seconds, where
|
|
648
|
+
// "never" for the film behind is the film's own length. On a 48-minute file
|
|
649
|
+
// that is 2024 s, which outvotes everything: field 2026-09-08, one viewer got
|
|
650
|
+
// three encoders, two of them on film behind them, and the run serving them
|
|
651
|
+
// was killed to make room for one.
|
|
652
|
+
const rankAt = (at) => {
|
|
653
|
+
let rank = 0;
|
|
571
654
|
for (const span of wanted) {
|
|
572
|
-
if (at
|
|
573
|
-
|
|
574
|
-
}
|
|
575
|
-
if (span.behind !== true) {
|
|
576
|
-
return false;
|
|
655
|
+
if (at >= span.from && at <= span.to) {
|
|
656
|
+
rank = Math.max(rank, Number(span.priority) || 0);
|
|
577
657
|
}
|
|
578
|
-
behind = true;
|
|
579
658
|
}
|
|
580
|
-
return
|
|
659
|
+
return rank;
|
|
581
660
|
};
|
|
661
|
+
// The ranks the map actually states, most urgent first. The comparison is over
|
|
662
|
+
// these and nothing else, so a rank can never be outvoted by a lower one
|
|
663
|
+
// however many seconds are at stake there.
|
|
664
|
+
const ranks = [...new Set(wanted.map((span) => Number(span.priority) || 0))]
|
|
665
|
+
.sort((left, right) => right - left);
|
|
582
666
|
|
|
583
667
|
// EVERY COUNT IS OVER THE FILM, NOT OVER THE ENCODERS. When a piece is made
|
|
584
668
|
// depends on which encoder reaches it soonest, and the encoder that reaches
|
|
@@ -604,10 +688,11 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
|
|
|
604
688
|
// clock forward makes that trade impossible: abandoning the near film delays
|
|
605
689
|
// the far film by at least as much.
|
|
606
690
|
let stalled = 0;
|
|
607
|
-
let tardiness = 0;
|
|
608
|
-
let aheadDone = 0;
|
|
609
|
-
let behindDone = 0;
|
|
610
691
|
let wastedSwarm = 0;
|
|
692
|
+
/** Seconds anybody waits past a deadline, per rank. @type {Map<number, number>} */
|
|
693
|
+
const lateAt = new Map(ranks.map((rank) => [rank, 0]));
|
|
694
|
+
/** When the last number of a rank is made, per rank. @type {Map<number, number>} */
|
|
695
|
+
const doneAt = new Map(ranks.map((rank) => [rank, 0]));
|
|
611
696
|
for (let index = first; index <= last; index += 1) {
|
|
612
697
|
// Which encoder gets to this piece first, and when. One standing on it is
|
|
613
698
|
// already there; one behind it must work its way up, re-making anything
|
|
@@ -643,30 +728,39 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
|
|
|
643
728
|
// total on a host whose startup measured nothing at all, where every arrival
|
|
644
729
|
// is beyond reckoning and every arrangement is therefore equally hopeless.
|
|
645
730
|
const when = byWhom === null ? never : Math.min(soonest, never);
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
} else {
|
|
649
|
-
aheadDone = Math.max(aheadDone, when);
|
|
650
|
-
}
|
|
731
|
+
const rank = rankAt(index);
|
|
732
|
+
doneAt.set(rank, Math.max(doneAt.get(rank) ?? 0, when));
|
|
651
733
|
const deadline = untilNeeded(index);
|
|
652
734
|
if (Number.isFinite(deadline)) {
|
|
653
735
|
const due = deadline + stalled;
|
|
654
736
|
const waited = Math.max(0, when - due);
|
|
655
|
-
|
|
737
|
+
lateAt.set(rank, (lateAt.get(rank) ?? 0) + waited);
|
|
656
738
|
stalled += waited;
|
|
657
739
|
}
|
|
658
740
|
}
|
|
659
741
|
|
|
660
742
|
return {
|
|
661
|
-
//
|
|
662
|
-
|
|
663
|
-
//
|
|
664
|
-
//
|
|
665
|
-
//
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
743
|
+
// THE MAP'S OWN ORDER, AS A VECTOR. One pair per rank the map states, most
|
|
744
|
+
// urgent rank first: how long anybody waits at that rank, then when the last
|
|
745
|
+
// number of it is made.
|
|
746
|
+
//
|
|
747
|
+
// Compared position by position, so a rank is never outvoted by a lower one
|
|
748
|
+
// — which is the whole of what was asked for: nobody stares at a spinner;
|
|
749
|
+
// then the film in front of the viewers is encoded as fast as it can be, band
|
|
750
|
+
// by band as the map ranks them; then, with whatever is left over and only
|
|
751
|
+
// then, the film behind them, in case somebody seeks back.
|
|
752
|
+
//
|
|
753
|
+
// No weights, and none possible: a weight would let seconds at one rank buy
|
|
754
|
+
// seconds at another, and it would be a number nobody measured. The map is
|
|
755
|
+
// the source of truth about what matters, and it already says so.
|
|
756
|
+
byRank: ranks.flatMap((rank) => [lateAt.get(rank) ?? 0, doneAt.get(rank) ?? 0]),
|
|
757
|
+
// HOW MANY ENCODERS IT TAKES. Ranked below every rank of the map and above
|
|
758
|
+
// the swarm's bill, so it cannot buy one where the map is indifferent — and
|
|
759
|
+
// the map IS indifferent about spare capacity, which is what bought an
|
|
760
|
+
// encoder for film nobody waits for.
|
|
761
|
+
bodies: bodies.length,
|
|
762
|
+
// WHAT THE SWARM PAYS for anything fetched twice, which delays everything.
|
|
763
|
+
wasted: wastedSwarm
|
|
670
764
|
};
|
|
671
765
|
}
|
|
672
766
|
|
|
@@ -686,14 +780,44 @@ function latenessOf(bodies, coverage, wanted, untilNeeded, rate, refetchSecPerSe
|
|
|
686
780
|
* @param {{ stall: number, ahead: number, whole: number }} right
|
|
687
781
|
* @returns {boolean}
|
|
688
782
|
*/
|
|
689
|
-
function cheaperThan(left, right) {
|
|
690
|
-
|
|
691
|
-
|
|
783
|
+
function cheaperThan(left, right, byAtLeast = 0) {
|
|
784
|
+
// POSITION BY POSITION, in the map's own order of ranks. A difference at a
|
|
785
|
+
// higher rank settles it, and nothing at a lower one can reopen it.
|
|
786
|
+
//
|
|
787
|
+
// `byAtLeast` is what an ACT has to pay for itself. Where the left side is
|
|
788
|
+
// only reachable by killing a running encoder, a gain smaller than what that
|
|
789
|
+
// killing costs is not a gain: the arithmetic says the film arrives sooner,
|
|
790
|
+
// and the machine says a process died for it.
|
|
791
|
+
//
|
|
792
|
+
// Field 2026-09-08, and it is worth the exact numbers because they are so
|
|
793
|
+
// close. A run standing at #58 with the viewer's zone at #59..#60: driving
|
|
794
|
+
// there means making TWO pieces, 1.89 s at 4.45x on a 4.2 s grid, while
|
|
795
|
+
// moving means a cold start and ONE piece, 0.94 + 0.94 = 1.88 s. The move is
|
|
796
|
+
// faster — by ten milliseconds. Every one of 39 moves in that session was
|
|
797
|
+
// individually correct by this arithmetic, 24 of them between three adjacent
|
|
798
|
+
// numbers, and the viewer's picture stood still for 116.7 s.
|
|
799
|
+
//
|
|
800
|
+
// The margin is measured, never chosen: it is the cost of the act itself, and
|
|
801
|
+
// an act that does not repay its own cost is not worth taking. It is also
|
|
802
|
+
// wider than the spread of the figures the comparison is made of, which are
|
|
803
|
+
// medians of recent runs — so a difference smaller than it is not a difference
|
|
804
|
+
// this model can see.
|
|
805
|
+
const size = Math.max(left.byRank.length, right.byRank.length);
|
|
806
|
+
for (let index = 0; index < size; index += 1) {
|
|
807
|
+
const here = left.byRank[index] ?? 0;
|
|
808
|
+
const there = right.byRank[index] ?? 0;
|
|
809
|
+
if (here !== there) {
|
|
810
|
+
return here + byAtLeast < there;
|
|
811
|
+
}
|
|
692
812
|
}
|
|
693
|
-
|
|
694
|
-
|
|
813
|
+
// Where every rank is served identically, fewer encoders. This is what stops
|
|
814
|
+
// spare capacity buying one: the map is indifferent, so the machine decides,
|
|
815
|
+
// and a process, a reader of the piece store and the swarm's bandwidth are all
|
|
816
|
+
// paid by the viewers the ranks above are about.
|
|
817
|
+
if (left.bodies !== right.bodies) {
|
|
818
|
+
return left.bodies < right.bodies;
|
|
695
819
|
}
|
|
696
|
-
return left.
|
|
820
|
+
return left.wasted < right.wasted;
|
|
697
821
|
}
|
|
698
822
|
|
|
699
823
|
/**
|
|
@@ -742,7 +866,24 @@ function deadlineReaderFor(windows, segmentSeconds) {
|
|
|
742
866
|
// same as a stated one — read as due all at once instead, a window as wide
|
|
743
867
|
// as a viewer's cushion demanded its far end instantly and bought an
|
|
744
868
|
// encoder to stand beside one already working.
|
|
745
|
-
|
|
869
|
+
// `null` IS A STATEMENT AND IT SAYS NOBODY IS COMING. `undefined` is the
|
|
870
|
+
// absence of one, and a caller that knows only a position is somebody
|
|
871
|
+
// waiting at it.
|
|
872
|
+
//
|
|
873
|
+
// Read through `Number()`, `null` becomes 0 — due NOW — so the film BEHIND
|
|
874
|
+
// the viewers, which the map marks with exactly that, was the most urgent
|
|
875
|
+
// material in the file. Everything followed from it: it bought encoders,
|
|
876
|
+
// it took the run standing in front of the viewer because that run was the
|
|
877
|
+
// nearest body to it, and it did so again on every pass. Field 2026-09-08:
|
|
878
|
+
// 39 moves in one session, 24 between three adjacent numbers, one viewer
|
|
879
|
+
// on three encoders, and the picture stood still for 116.7 s in three
|
|
880
|
+
// interruptions, the worst of them 91.8 s.
|
|
881
|
+
//
|
|
882
|
+
// The map has always said it plainly — `{"from":0,"to":57,"priority":1,
|
|
883
|
+
// "withinSeconds":null,"behind":true}` is in the log of every session — and
|
|
884
|
+
// this line turned it into its opposite. Fourth time in this repository
|
|
885
|
+
// that the input to a calculation was not what the calculation assumed.
|
|
886
|
+
const within = stated === undefined ? 0 : (stated === null ? Number.NaN : Number(stated));
|
|
746
887
|
if (!Number.isFinite(within)) {
|
|
747
888
|
// Stated as no time at all: nobody is coming here.
|
|
748
889
|
continue;
|
|
@@ -53,9 +53,22 @@ export class RunCosts {
|
|
|
53
53
|
* that was never told to stop did not die on command, and one that produced
|
|
54
54
|
* nothing has no first output — and an absent reading is not a zero.
|
|
55
55
|
*
|
|
56
|
-
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null
|
|
56
|
+
* @param {{ dyingMs?: number | null, firstOutputMs?: number | null,
|
|
57
|
+
* livedMs?: number | null }} ended - `livedMs` is how long a run that
|
|
58
|
+
* produced NOTHING was alive, which is a lower bound on the first output.
|
|
57
59
|
*/
|
|
58
60
|
note(ended) {
|
|
61
|
+
// A RUN THAT PRODUCED NOTHING IS A MEASUREMENT TOO — of a lower bound. It
|
|
62
|
+
// says the first output takes at least as long as this run lived, which is
|
|
63
|
+
// a fact and not an estimate, and it is the only reading a thrash can
|
|
64
|
+
// supply: every run in one is killed before it finishes anything.
|
|
65
|
+
//
|
|
66
|
+
// Without it the figure that prices a move could only ever be learned from
|
|
67
|
+
// runs that survived, so the state in which moves are ruinous was exactly
|
|
68
|
+
// the state in which their cost stayed unknown.
|
|
69
|
+
if (!Number.isFinite(ended?.firstOutputMs) && Number.isFinite(ended?.livedMs) && ended.livedMs > 0) {
|
|
70
|
+
RunCosts.#keep(this.#firstOutput, /** @type {number} */ (ended.livedMs));
|
|
71
|
+
}
|
|
59
72
|
if (Number.isFinite(ended?.dyingMs)) {
|
|
60
73
|
RunCosts.#keep(this.#dying, /** @type {number} */ (ended.dyingMs));
|
|
61
74
|
}
|
|
@@ -85,9 +98,47 @@ export class RunCosts {
|
|
|
85
98
|
* @returns {{ killCostSec: number, firstByteWaitSec: number, samples: number }}
|
|
86
99
|
*/
|
|
87
100
|
seconds() {
|
|
101
|
+
const dying = middleOf(this.#dying);
|
|
102
|
+
const first = middleOf(this.#firstOutput);
|
|
88
103
|
return {
|
|
89
|
-
|
|
90
|
-
|
|
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,
|
|
91
142
|
samples: Math.min(this.#dying.length, this.#firstOutput.length)
|
|
92
143
|
};
|
|
93
144
|
}
|
|
@@ -1510,7 +1510,11 @@ export class HlsSessionManager {
|
|
|
1510
1510
|
// picture a step belongs to, the steps, the soundtracks, the height a
|
|
1511
1511
|
// session is named by. Read-only over the register above, and the layer the
|
|
1512
1512
|
// quality budget and the serving path both stand on.
|
|
1513
|
-
this.liveOutputs = new LiveOutputs({
|
|
1513
|
+
this.liveOutputs = new LiveOutputs({
|
|
1514
|
+
sessionsById: this.sessionsById,
|
|
1515
|
+
fileLengthOf: (session) => this.#fileLengthByKey.get(session.file.key) ?? 0,
|
|
1516
|
+
largestPieceOf: (address) => this.segmentStore.largestPiece(address)
|
|
1517
|
+
});
|
|
1514
1518
|
// What this host learned last time it ran. Without it every restart shows
|
|
1515
1519
|
// the first viewer a figure with no measurement behind it.
|
|
1516
1520
|
this.#loadHostTimings();
|
|
@@ -8317,7 +8321,7 @@ export class HlsSessionManager {
|
|
|
8317
8321
|
// the live judgement travels in `offeredHeights` and in every progress
|
|
8318
8322
|
// report, and letting it decide the master's existence made a live session
|
|
8319
8323
|
// answer 404 to its own published address.
|
|
8320
|
-
...this.liveOutputs.masterFactsOf(session
|
|
8324
|
+
...this.liveOutputs.masterFactsOf(session),
|
|
8321
8325
|
renditions,
|
|
8322
8326
|
playlistFileName: PLAYLIST_FILE_NAME
|
|
8323
8327
|
});
|
|
@@ -24,8 +24,14 @@ export class LiveOutputs {
|
|
|
24
24
|
* @param {Map<string, object>} params.sessionsById - The live sessions. Read,
|
|
25
25
|
* never written.
|
|
26
26
|
*/
|
|
27
|
-
constructor({ sessionsById }) {
|
|
27
|
+
constructor({ sessionsById, fileLengthOf = () => 0, largestPieceOf = () => ({ index: -1, size: 0 }) }) {
|
|
28
28
|
this.sessionsById = sessionsById;
|
|
29
|
+
// Two facts this layer needs and does not own: how many bytes a source file
|
|
30
|
+
// is, which the torrent reports, and the biggest piece an output has made,
|
|
31
|
+
// which the disk knows. Taken as plain functions, so nothing of either layer
|
|
32
|
+
// is held here.
|
|
33
|
+
this.fileLengthOf = fileLengthOf;
|
|
34
|
+
this.largestPieceOf = largestPieceOf;
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
/**
|
|
@@ -278,21 +284,25 @@ export class LiveOutputs {
|
|
|
278
284
|
* the asking VIEWER'S business, and that belongs to whoever holds viewers.
|
|
279
285
|
*
|
|
280
286
|
* @param {object} session
|
|
281
|
-
* @param {(address: string) => { index: number, size: number }} largestPiece -
|
|
282
|
-
* The biggest piece an output has made, asked of whoever owns the disk. A
|
|
283
|
-
* plain function, so this layer holds no store.
|
|
284
287
|
* @returns {object}
|
|
285
288
|
*/
|
|
286
|
-
masterFactsOf(session
|
|
289
|
+
masterFactsOf(session) {
|
|
287
290
|
return {
|
|
288
291
|
playlistVersion: session.segmentFormat.playlistVersion,
|
|
289
292
|
heights: this.splicableHeights(session),
|
|
290
293
|
sourceWidth: Number(session.file?.width) || 0,
|
|
291
294
|
sourceHeight: Number(session.file?.height) || 0,
|
|
292
295
|
...masterRateArgs({
|
|
293
|
-
|
|
296
|
+
// FROM WHOEVER KNOWS IT. A source file does not carry its own byte
|
|
297
|
+
// length — the torrent reports it — and reading a field that does not
|
|
298
|
+
// exist is what declared every variant at the floor in 2.80.14.
|
|
299
|
+
fileLength: Number(this.fileLengthOf(session)) || 0,
|
|
294
300
|
durationSeconds: Number(session.file?.durationSeconds) || 0,
|
|
295
|
-
|
|
301
|
+
// The probe's own reading of the video stream, which is known from the
|
|
302
|
+
// moment the session exists and covers the gap before the torrent has
|
|
303
|
+
// reported a length.
|
|
304
|
+
streamBitsPerSecond: (Number(session.file?.decode?.megabitsPerSecond) || 0) * 1_000_000,
|
|
305
|
+
largest: this.largestPieceOf(session.outputKey ?? ""),
|
|
296
306
|
boundaries: session.timeline?.published ?? session.timeline?.boundaries ?? null,
|
|
297
307
|
producedHeight: this.producedHeightOf(session),
|
|
298
308
|
capKbps: Number(session.rateCapKbps) || 0
|
package/services/output/rates.js
CHANGED
|
@@ -21,21 +21,49 @@
|
|
|
21
21
|
* The two rates to declare, from what has been measured of this file.
|
|
22
22
|
*
|
|
23
23
|
* @param {object} params
|
|
24
|
-
* @param {number} params.fileLength - Bytes of the source file
|
|
24
|
+
* @param {number} params.fileLength - Bytes of the source file, 0 until the
|
|
25
|
+
* torrent has said. Counts every track and every byte of container, so it is
|
|
26
|
+
* the better figure of the two.
|
|
25
27
|
* @param {number} params.durationSeconds
|
|
28
|
+
* @param {number} [params.streamBitsPerSecond] - What a probe read off the
|
|
29
|
+
* video stream, known from session creation. Lower than the whole file's rate
|
|
30
|
+
* because it is one track of it, and used while the length is not known.
|
|
26
31
|
* @param {{ index: number, size: number }} [params.largest] - The biggest piece
|
|
27
32
|
* produced so far, with its number. An index of `-1` means none yet.
|
|
28
33
|
* @param {number[]} [params.boundaries] - Where the file is cut, so the biggest
|
|
29
34
|
* piece's own span is known. Bytes alone cannot give a rate.
|
|
30
35
|
* @returns {{ averageBitsPerSecond: number, peakOverAverage: number }}
|
|
31
36
|
*/
|
|
32
|
-
export function declaredRates({
|
|
37
|
+
export function declaredRates({
|
|
38
|
+
fileLength,
|
|
39
|
+
durationSeconds,
|
|
40
|
+
streamBitsPerSecond = 0,
|
|
41
|
+
largest = null,
|
|
42
|
+
boundaries = null
|
|
43
|
+
}) {
|
|
33
44
|
const length = Number(fileLength);
|
|
34
45
|
const duration = Number(durationSeconds);
|
|
35
|
-
|
|
46
|
+
// TWO SOURCES, AND THE LARGER OF THEM, because each is measured and each can
|
|
47
|
+
// be absent — and absent is 0, so the larger is whichever was measured.
|
|
48
|
+
//
|
|
49
|
+
// - the whole file's length over its duration: the best figure, since it
|
|
50
|
+
// counts every track and every byte of container. Known only once the
|
|
51
|
+
// torrent has reported the file's size;
|
|
52
|
+
// - what the probe read off the video stream: known when the session is
|
|
53
|
+
// created, and lower, because it is one track of several.
|
|
54
|
+
//
|
|
55
|
+
// The first alone was what shipped in 2.80.14, read from a field that does
|
|
56
|
+
// not exist on a source file. It came back 0 on every session, so every
|
|
57
|
+
// variant was declared at the 400 kbit/s floor — nine times WORSE than the
|
|
58
|
+
// rule of thumb it replaced — and the browser, which sizes its cushion in
|
|
59
|
+
// bytes from this figure, held 0.1 s of film against 120 s asked. The picture
|
|
60
|
+
// stood still for 116.7 s of one viewing. Assuming a field instead of
|
|
61
|
+
// checking it is the whole of that fault.
|
|
62
|
+
const fromLength = length > 0 && duration > 0 ? (length * 8) / duration : 0;
|
|
63
|
+
const averageBitsPerSecond = Math.max(fromLength, Number(streamBitsPerSecond) || 0);
|
|
64
|
+
if (!(averageBitsPerSecond > 0)) {
|
|
36
65
|
return { averageBitsPerSecond: 0, peakOverAverage: 1 };
|
|
37
66
|
}
|
|
38
|
-
const averageBitsPerSecond = (length * 8) / duration;
|
|
39
67
|
const index = Number(largest?.index);
|
|
40
68
|
const size = Number(largest?.size);
|
|
41
69
|
if (!Number.isInteger(index) || index < 0 || !(size > 0) || !Array.isArray(boundaries)) {
|
|
@@ -73,6 +101,7 @@ export function declaredRates({ fileLength, durationSeconds, largest = null, bou
|
|
|
73
101
|
* @param {object} params
|
|
74
102
|
* @param {number} params.fileLength
|
|
75
103
|
* @param {number} params.durationSeconds
|
|
104
|
+
* @param {number} [params.streamBitsPerSecond]
|
|
76
105
|
* @param {{ index: number, size: number } | null} [params.largest]
|
|
77
106
|
* @param {number[] | null} [params.boundaries]
|
|
78
107
|
* @param {number} [params.producedHeight] - The height this output encodes at.
|
|
@@ -83,13 +112,14 @@ export function declaredRates({ fileLength, durationSeconds, largest = null, bou
|
|
|
83
112
|
export function masterRateArgs({
|
|
84
113
|
fileLength,
|
|
85
114
|
durationSeconds,
|
|
115
|
+
streamBitsPerSecond = 0,
|
|
86
116
|
largest = null,
|
|
87
117
|
boundaries = null,
|
|
88
118
|
producedHeight = 0,
|
|
89
119
|
capKbps = 0
|
|
90
120
|
}) {
|
|
91
121
|
return {
|
|
92
|
-
...declaredRates({ fileLength, durationSeconds, largest, boundaries }),
|
|
122
|
+
...declaredRates({ fileLength, durationSeconds, streamBitsPerSecond, largest, boundaries }),
|
|
93
123
|
capKbpsFor: (height) => (height === producedHeight ? capKbps : 0)
|
|
94
124
|
};
|
|
95
125
|
}
|
package/test/encode-plan.test.js
CHANGED
|
@@ -367,9 +367,15 @@ test("the one machine goes to whoever is due soonest, not to the smallest number
|
|
|
367
367
|
...HOST,
|
|
368
368
|
maxRuns: 2
|
|
369
369
|
});
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
370
|
+
// WHERE the machine ends up, not how it got there. A run standing at #900
|
|
371
|
+
// reaches nothing anybody wants, so it is taken to #500 rather than killed and
|
|
372
|
+
// replaced — one process instead of a death and a cold start, which is
|
|
373
|
+
// strictly better and is what the plan now answers.
|
|
374
|
+
const placed = actions
|
|
375
|
+
.filter((action) => action.type === "start" || action.type === "move")
|
|
376
|
+
.map((action) => action.from);
|
|
377
|
+
|
|
378
|
+
assert.deepEqual(placed, [500], "the one machine goes where somebody is stopped");
|
|
373
379
|
});
|
|
374
380
|
|
|
375
381
|
test("two viewers far apart are both served, by however many encoders serve them soonest", () => {
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What moving a running encoder costs, and what the plan does while
|
|
3
|
+
* nobody has measured it.
|
|
4
|
+
*
|
|
5
|
+
* Field 2026-09-08: 39 moves in one session, 24 of them between three adjacent
|
|
6
|
+
* numbers — #58 to #59, #59 to #58, #58 to #60, #60 to #58, six times each,
|
|
7
|
+
* about 0.8 s apart — while the viewer's picture stood still for 116.7 s in
|
|
8
|
+
* three interruptions, the worst of them 91.8 s. The zone the viewer's own
|
|
9
|
+
* position defines slides forward one number at a time, and every slide made
|
|
10
|
+
* standing one number behind it score worse than standing in it.
|
|
11
|
+
*
|
|
12
|
+
* FOUR THINGS, and the first is the one that mattered.
|
|
13
|
+
*
|
|
14
|
+
* 1. The map states `withinSeconds: null` for the film BEHIND the viewers —
|
|
15
|
+
* nobody is waiting there. `deadlineReaderFor` read it through `Number()`,
|
|
16
|
+
* where `null` is 0, so that film was due NOW and was the most urgent
|
|
17
|
+
* material in the file. It bought encoders and it took the run standing in
|
|
18
|
+
* front of the viewer, because that run was the nearest body to it.
|
|
19
|
+
* 2. Residual work took a live run. A zone with no deadline is done with
|
|
20
|
+
* capacity that is left over, and a run already serving a viewer is not left
|
|
21
|
+
* over.
|
|
22
|
+
* 3. `moveSec` was 0 until measured, so a move was free in the arithmetic — and
|
|
23
|
+
* the blindness sustained itself, because the first-output figure takes a
|
|
24
|
+
* reading only from a run that produced something and every run in a thrash
|
|
25
|
+
* is killed before it finishes anything.
|
|
26
|
+
* 4. A staying run was priced as though it would produce instantly, however
|
|
27
|
+
* recently it had started, so the score had no memory of a decision it was
|
|
28
|
+
* still carrying out.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import test from "node:test";
|
|
32
|
+
import assert from "node:assert/strict";
|
|
33
|
+
import { RunCosts } from "../services/encode/run-costs.js";
|
|
34
|
+
import { planEncoders } from "../services/encode/EncodePlan.js";
|
|
35
|
+
import { CoverageMap } from "../services/encode/CoverageMap.js";
|
|
36
|
+
|
|
37
|
+
test("nothing measured means a move is refused, not priced at zero", () => {
|
|
38
|
+
const costs = new RunCosts();
|
|
39
|
+
|
|
40
|
+
const { moveCostSec, firstByteWaitSec, killCostSec } = costs.seconds();
|
|
41
|
+
assert.equal(moveCostSec, Number.POSITIVE_INFINITY, "moving is not free while unpriced");
|
|
42
|
+
// Placing one where there is none is the OTHER question, and it has no
|
|
43
|
+
// alternative: the film gets made or it does not.
|
|
44
|
+
assert.equal(firstByteWaitSec, 0, "placing an encoder is not blocked by an unknown price");
|
|
45
|
+
assert.equal(killCostSec, 0);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("a run killed before producing anything is a lower bound on the first output", () => {
|
|
49
|
+
const costs = new RunCosts();
|
|
50
|
+
|
|
51
|
+
// Exactly what a thrash supplies: a run that lived 800 ms and finished
|
|
52
|
+
// nothing. It says the first output takes AT LEAST that long, which is a fact.
|
|
53
|
+
costs.note({ livedMs: 800, dyingMs: 40 });
|
|
54
|
+
|
|
55
|
+
const { moveCostSec } = costs.seconds();
|
|
56
|
+
assert.ok(Number.isFinite(moveCostSec), "one killed run is enough to stop the blindness");
|
|
57
|
+
assert.ok(Math.abs(moveCostSec - 0.84) < 0.001, `got ${moveCostSec}`);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("a run that produced something is measured by its first output, not its life", () => {
|
|
61
|
+
const costs = new RunCosts();
|
|
62
|
+
|
|
63
|
+
costs.note({ livedMs: 60_000, firstOutputMs: 900, dyingMs: 100 });
|
|
64
|
+
|
|
65
|
+
const { moveCostSec, firstByteWaitSec } = costs.seconds();
|
|
66
|
+
assert.ok(Math.abs(firstByteWaitSec - 0.9) < 0.001, `got ${firstByteWaitSec}`);
|
|
67
|
+
assert.ok(Math.abs(moveCostSec - 1.0) < 0.001, `got ${moveCostSec}`);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the zone sliding one number does not move an encoder that is already reaching it", () => {
|
|
71
|
+
// The field shape exactly: a run standing at #58 with the viewer's urgent zone
|
|
72
|
+
// sliding #58..#59 → #59..#60. Driving through one segment costs the encoder a
|
|
73
|
+
// fraction of a second; moving costs a kill and a cold start.
|
|
74
|
+
const coverage = new CoverageMap();
|
|
75
|
+
coverage.setSegmentCount(482);
|
|
76
|
+
const run = { from: 58, to: 481, head: 58, speedX: 4.45, isAlive: true };
|
|
77
|
+
coverage.claim(run, 58, 481);
|
|
78
|
+
|
|
79
|
+
const actions = planEncoders({
|
|
80
|
+
coverage,
|
|
81
|
+
windows: [
|
|
82
|
+
{ from: 0, to: 57, priority: 1, withinSeconds: null, behind: true },
|
|
83
|
+
{ from: 59, to: 60, priority: 100, withinSeconds: 0, behind: false }
|
|
84
|
+
],
|
|
85
|
+
runs: [run],
|
|
86
|
+
maxRuns: 3,
|
|
87
|
+
segmentSeconds: 4.2,
|
|
88
|
+
speedX: 4.45,
|
|
89
|
+
// Measured on this host: killing takes 40 ms, a fresh encoder's first piece
|
|
90
|
+
// 900 ms. Against that, driving one segment at 4.45x costs 0.94 s — so the
|
|
91
|
+
// two are close, and what settles it is that the move ALSO has to encode
|
|
92
|
+
// the same segment afterwards.
|
|
93
|
+
killCostSec: 0.04,
|
|
94
|
+
firstByteWaitSec: 0.9,
|
|
95
|
+
moveCostSec: 0.94,
|
|
96
|
+
refetchSecPerFilmSecond: 0,
|
|
97
|
+
contentionPenaltyFor: () => 1
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
actions.filter((one) => one.type === "move"),
|
|
102
|
+
[],
|
|
103
|
+
"a run one number behind the zone is already on its way into it"
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("a move that genuinely saves the viewer time still happens", () => {
|
|
108
|
+
// The other half: the viewer jumped fourteen segments ahead, and driving there
|
|
109
|
+
// at 4.45x would take 13 s while a cold start takes 0.94 s. Refusing this
|
|
110
|
+
// would be the opposite fault.
|
|
111
|
+
const coverage = new CoverageMap();
|
|
112
|
+
coverage.setSegmentCount(482);
|
|
113
|
+
const run = { from: 0, to: 481, head: 44, speedX: 4.45, isAlive: true };
|
|
114
|
+
coverage.claim(run, 0, 481);
|
|
115
|
+
|
|
116
|
+
const actions = planEncoders({
|
|
117
|
+
coverage,
|
|
118
|
+
windows: [{ from: 58, to: 59, priority: 100, withinSeconds: 0, behind: false }],
|
|
119
|
+
runs: [run],
|
|
120
|
+
maxRuns: 3,
|
|
121
|
+
segmentSeconds: 4.2,
|
|
122
|
+
speedX: 4.45,
|
|
123
|
+
killCostSec: 0.04,
|
|
124
|
+
firstByteWaitSec: 0.9,
|
|
125
|
+
moveCostSec: 0.94,
|
|
126
|
+
refetchSecPerFilmSecond: 0,
|
|
127
|
+
contentionPenaltyFor: () => 1
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// WHERE the encoder ends up, not how it got there: the run at #44 reaches
|
|
131
|
+
// nothing anybody waits for, so the plan may either take it to #58 or stop it
|
|
132
|
+
// and start one there. Both are one process at #58, and which is cheaper is
|
|
133
|
+
// the measured difference between a kill and a cold start.
|
|
134
|
+
const placed = actions
|
|
135
|
+
.filter((one) => one.type === "move" || one.type === "start")
|
|
136
|
+
.map((one) => one.from);
|
|
137
|
+
assert.deepEqual(placed, [58], "fourteen segments of driving is worth a cold start");
|
|
138
|
+
});
|