@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 +12 -0
- package/docs/encode-architecture.md +78 -9
- package/package.json +1 -1
- package/services/encode/EncodePlan.js +159 -67
- package/services/encode/run-costs.js +15 -40
- package/services/orchestrators/EncodeOrchestrator.js +16 -0
- package/test/move-cost.test.js +109 -38
- package/test/split-a-stretch.test.js +139 -0
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) ],
|
|
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
|
-
`
|
|
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
|
-
**
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
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
|
@@ -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
|
|
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
|
|
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 = (
|
|
181
|
-
const inForce = rate / contentionPenaltyFor(Math.max(0,
|
|
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
|
|
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
|
|
189
|
-
//
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
//
|
|
193
|
-
|
|
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
|
|
244
|
-
// piece costs — because only there is it known what the
|
|
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
|
|
257
|
+
// is why this is one pass over the encoders rather than a figure computed once
|
|
248
258
|
// outside.
|
|
249
|
-
const priced = (
|
|
250
|
-
const perPiece = pieceAt(
|
|
251
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
337
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
383
|
-
// time. Each such
|
|
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
|
|
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
|
|
399
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
485
|
+
encoders.push({ at: positions[index], owes: (piece) => spawnOverheadSec + piece });
|
|
417
486
|
}
|
|
418
487
|
}
|
|
419
|
-
return priced(
|
|
488
|
+
return priced(encoders);
|
|
420
489
|
};
|
|
421
490
|
const scoreOf = (override) => {
|
|
422
|
-
const
|
|
423
|
-
return latenessOf(
|
|
424
|
-
rate / contentionPenaltyFor(Math.max(0,
|
|
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
|
|
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 =
|
|
449
|
-
const without =
|
|
450
|
-
const scoreOf_ = (
|
|
451
|
-
rate / contentionPenaltyFor(Math.max(0,
|
|
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
|
|
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(
|
|
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 }[]}
|
|
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(
|
|
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
|
|
733
|
-
if (
|
|
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
|
|
742
|
-
// fresh
|
|
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 =
|
|
751
|
-
+ (index -
|
|
752
|
-
+ coverage.madeBetween(
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
850
|
-
return left.
|
|
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
|
|
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({
|
|
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
|
-
|
|
1253
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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)} ` +
|
package/test/move-cost.test.js
CHANGED
|
@@ -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
|
|
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 {
|
|
48
|
-
assert.equal(
|
|
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 {
|
|
63
|
-
assert.ok(
|
|
64
|
-
assert.ok(Math.abs(
|
|
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:
|
|
76
|
+
costs.note({ livedMs: 60_000, firstOutputMs: 1260, dyingMs: 100 });
|
|
71
77
|
|
|
72
|
-
const {
|
|
73
|
-
assert.ok(Math.abs(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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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:
|
|
84
|
-
coverage.claim(run, 58,
|
|
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
|
|
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:
|
|
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
|
-
|
|
109
|
-
[],
|
|
110
|
-
"
|
|
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:
|
|
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
|
+
});
|