@torrent-tv/proxy 2.97.16 → 2.97.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.97.16",
3
+ "version": "2.97.17",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -19,14 +19,15 @@ import { recordViewerReport } from "../../../../services/viewer/report-intake.js
19
19
  *
20
20
  * @param {import("fastify").FastifyRequest} req
21
21
  * @param {import("fastify").FastifyReply} reply
22
- * @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function } }} deps -
22
+ * @param {{ outputs: { get: (id: string) => object | undefined }, viewers: object, renditions: { viewerPlays: Function, noteViewerReported: Function }, quality: { noteViewerReported: Function }, planSoon?: () => void, segmentAt?: (output: object, seconds: number) => number | null }} deps -
23
23
  * The live outputs, the registry of viewers, and the rungs of a picture. The
24
24
  * VIEWER component takes the statement; which rung a stated height is, is the
25
25
  * encoding component's. This route's whole job is turning a request into
26
26
  * those calls and their answer into a status code.
27
27
  * @returns {Promise<void>}
28
28
  */
29
- export async function handleApiTranscodeSessionNetReportPost(req, reply, { outputs, viewers, renditions, quality }) {
29
+ export async function handleApiTranscodeSessionNetReportPost(req, reply,
30
+ { outputs, viewers, renditions, quality, planSoon = () => {}, segmentAt = () => null }) {
30
31
  const sessionId = typeof req.params.sessionId === "string" ? req.params.sessionId : "";
31
32
  const body = req.body && typeof req.body === "object" && !Array.isArray(req.body) ? req.body : {};
32
33
  // WHAT THIS REPORT IS. A statement by one viewer about itself: where the
@@ -100,6 +101,10 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
100
101
  Number.isFinite(positionSeconds) && positionSeconds >= 0 ? positionSeconds : undefined
101
102
  );
102
103
  }
104
+ // Where they stood and whether they were playing or waiting, before this
105
+ // report: what the encoders are planned from (`movedForThePlan`).
106
+ const before = viewers.get?.(consumerId);
107
+ const was = before ? { seconds: before.position?.seconds ?? null, playing: before.playing, waiting: before.waiting } : null;
103
108
  const recorded = recordViewerReport({
104
109
  outputs,
105
110
  viewers,
@@ -127,6 +132,14 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
127
132
  if (!recorded) {
128
133
  return reply.code(404).send({ error: "Transcode session was not found." });
129
134
  }
135
+ // A VIEWER WHO MOVED INTO ANOTHER PIECE, or started or stopped playing or
136
+ // waiting, changed the maps the encoders are placed from; the plan is taken
137
+ // again now. Recorded and not re-planned, a soundtrack the viewer was
138
+ // stopped for waited 59 s behind a picture that had long left the urgent
139
+ // band, because only the end of that picture's run planned again (Home
140
+ // Assistant 2026-10-12, torrent-tv/meta#178).
141
+ const after = viewers.get?.(consumerId);
142
+ if (after && movedForThePlan(was, after, outputs.get(sessionId), segmentAt)) planSoon();
130
143
  // After the statement is recorded, so what is judged is what they just said:
131
144
  // a move of theirs to another limit may no longer be wanted.
132
145
  renditions.noteViewerReported(sessionId, consumerId);
@@ -136,3 +149,23 @@ export async function handleApiTranscodeSessionNetReportPost(req, reply, { outpu
136
149
  void quality.noteViewerReported(sessionId, consumerId);
137
150
  return reply.code(204).send();
138
151
  }
152
+
153
+ /**
154
+ * Whether a report changed what the encoders are planned from: the piece the
155
+ * viewer stands in, or whether they play or wait.
156
+ *
157
+ * @param {{ seconds: number | null, playing: boolean, waiting: boolean } | null} was
158
+ * @param {{ position?: { seconds: number } | null, playing: boolean, waiting: boolean }} now
159
+ * @param {object | undefined} output
160
+ * @param {(output: object, seconds: number) => number | null} segmentAt
161
+ * @returns {boolean}
162
+ */
163
+ export function movedForThePlan(was, now, output, segmentAt) {
164
+ if (!was) return true;
165
+ if (was.playing !== now.playing || was.waiting !== now.waiting) return true;
166
+ const seconds = now.position?.seconds;
167
+ if (!Number.isFinite(seconds)) return false;
168
+ if (!Number.isFinite(was.seconds)) return true;
169
+ if (!output) return false;
170
+ return segmentAt(output, was.seconds) !== segmentAt(output, seconds);
171
+ }
package/server.js CHANGED
@@ -568,9 +568,15 @@ export async function startProxyServer({
568
568
  value = new SegmentInputs({ index, tracks: wanted }).forInterval({ ...interval, mode: track => modes.get(track) });
569
569
  }
570
570
  } catch { value = null; }
571
+ // A zone worked out gives the thread back before the next: worked out
572
+ // in one stretch, the first pass over a film's 4000 zones held the main
573
+ // thread 5-7 s while FFmpeg's first open waited for its answers. What
574
+ // was worked out is kept, so a pass a newer map ends is not lost
575
+ // (torrent-tv/meta#178).
576
+ if (value?.kind === "result") segmentSourceRanges.set(rangesKey, value.ranges);
577
+ await new Promise(resolve => setImmediate(resolve));
571
578
  if (!map.isCurrent()) return [];
572
579
  if (value?.kind === "result") {
573
- segmentSourceRanges.set(rangesKey, value.ranges);
574
580
  convert(value.ranges);
575
581
  continue;
576
582
  }
@@ -1301,7 +1307,12 @@ export async function startProxyServer({
1301
1307
  outputs: outputParts.outputs,
1302
1308
  viewers: outputParts.viewers,
1303
1309
  renditions: outputParts.renditions,
1304
- quality: outputParts.quality
1310
+ quality: outputParts.quality,
1311
+ planSoon: () => outputParts.encodeRuns.planEncodersSoon(),
1312
+ segmentAt: (output, seconds) => {
1313
+ // A cut table not built yet has no pieces to move between.
1314
+ try { return outputParts.outputTimes.segmentIndexForTime(output, seconds); } catch { return null; }
1315
+ }
1305
1316
  })
1306
1317
  );
1307
1318
  app.post("/api/transcode-sessions/:sessionId/fragment-far", async (req, reply) =>
@@ -130,6 +130,17 @@ export class EncodeOrchestrator {
130
130
  * @type {{ urgent: boolean, priority: number, address: string, index: number } | null}
131
131
  */
132
132
  #leading = null;
133
+ /**
134
+ * Outputs whose start the last plan held back behind a more urgent encoder,
135
+ * and the band of the number it would have started at. Looked at again
136
+ * whenever that encoder moves on (`shareTheMachine`), because a closed piece
137
+ * is what lowers its band: placed only by a full plan, a soundtrack the
138
+ * viewer was stopped for waited 59 s behind a picture that had long left
139
+ * the urgent band (Home Assistant 2026-10-12, torrent-tv/meta#178).
140
+ *
141
+ * @type {Map<string, { urgent: boolean, priority: number }>}
142
+ */
143
+ #heldBack = new Map();
133
144
 
134
145
  /** What a stop and a start have cost on this host. */
135
146
  #costs = new RunCosts();
@@ -689,7 +700,10 @@ export class EncodeOrchestrator {
689
700
  shareTheMachine() {
690
701
  const leading = this.#leadingBand();
691
702
  this.#leading = leading;
692
- if (!leading) return;
703
+ if (!leading) {
704
+ this.#placeHeldBack(null);
705
+ return;
706
+ }
693
707
  for (const [address, runs] of this.#runs) {
694
708
  for (const run of runs) {
695
709
  if (!run.isAlive || run.isStopping || typeof run.pause !== "function") continue;
@@ -702,6 +716,22 @@ export class EncodeOrchestrator {
702
716
  }
703
717
  }
704
718
  }
719
+ this.#placeHeldBack(leading);
720
+ }
721
+
722
+ /**
723
+ * Plan again every output whose start was held back behind what is no
724
+ * longer more urgent than it: the start is placed now, by the same plan,
725
+ * rather than at whatever event next re-plans everything.
726
+ *
727
+ * @param {{ urgent: boolean, priority: number } | null} leading
728
+ */
729
+ #placeHeldBack(leading) {
730
+ for (const [address, band] of [...this.#heldBack]) {
731
+ if (leading && compareUrgency(band, leading) > 0) continue;
732
+ this.#heldBack.delete(address);
733
+ this.#reconcileOne(address);
734
+ }
705
735
  }
706
736
 
707
737
  /**
@@ -916,6 +946,8 @@ export class EncodeOrchestrator {
916
946
  // a 159 090-line file, 31 % of the log, all of one wedge.
917
947
  const wanting = firstUnmetWant(coverage, windows);
918
948
  const stillRunning = this.runsOn(address).filter((run) => run.isAlive);
949
+ if (heldBack) this.#heldBack.set(address, this.#bandAt(address, heldBack.from));
950
+ else this.#heldBack.delete(address);
919
951
  if (wanting === null || stillRunning.length > 0) {
920
952
  this.#lastUnmet.delete(address);
921
953
  } else if (heldBack) {
@@ -404,8 +404,19 @@ export class AviContainer extends Container {
404
404
  packets.append(track.trackNumber, { pts: stream.times[previous], duration: Math.max(0, end - stream.times[previous]),
405
405
  keyframe: true, ranges: [[stream.starts[previous], stream.starts[previous] + stream.lengths[previous] - 1]] });
406
406
  };
407
+ // THE THREAD IS GIVEN BACK at each portion of the file the table
408
+ // reaches: a two-hour soundtrack is some 300 000 packets, and built in
409
+ // one stretch it held the main thread with the map's first pass for
410
+ // 5-7 s while FFmpeg's first open waited for its answers
411
+ // (torrent-tv/meta#178).
412
+ let portion = -1;
407
413
  for (let at = 0; at < stream.count; at++) {
408
414
  if (!stream.lengths[at]) continue;
415
+ const reached = Math.floor(stream.starts[at] / this.portionBytes);
416
+ if (Number.isFinite(reached) && reached !== portion) {
417
+ if (portion >= 0) await new Promise(resolve => setImmediate(resolve));
418
+ portion = reached;
419
+ }
409
420
  if (previous >= 0) append(stream.times[at]);
410
421
  previous = at;
411
422
  }
@@ -646,6 +646,14 @@ export class ViewerRequests {
646
646
  processingRanges: liveRuns.map(({ progress }) => ({
647
647
  start: progress.startPositionSeconds, end: progress.processedSeconds
648
648
  })),
649
+ // What a run's start costs this output, and the stretches its live
650
+ // runs were given: the forecast starts a run wherever production meets
651
+ // bytes that were not held when the run before was admitted.
652
+ openSeconds: this.#host.openSecondsOf?.(output) ?? null,
653
+ runSpans: liveRuns.map(run => ({
654
+ start: output.timeline?.publishedStartOf?.(run.from),
655
+ end: Number.isInteger(run.to) && run.to >= run.from ? output.timeline?.publishedStartOf?.(run.to + 1) : Number.POSITIVE_INFINITY
656
+ })),
649
657
  bitsPerMediaSecond: Number.isFinite(observedMbps) && observedMbps > 0 ? observedMbps * 1_000_000 :
650
658
  this.#host.initialOutputBitsPerMediaSecondOf?.(output) ?? 0,
651
659
  readings: trackRates,
@@ -705,7 +713,8 @@ export class ViewerRequests {
705
713
  const forecast = withSubtitleReadiness(this.#host.playbackReadiness.predict(input), subtitle,
706
714
  subtitle ? this.#host.subtitleReadyFor?.(viewer) !== false : true);
707
715
  forecast.operations = outputs.flatMap((output, index) => {
708
- const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings), processedSeconds: tracks[index].processedSeconds };
716
+ const common = { speed: this.#host.playbackReadiness.forecastRate(tracks[index].readings, now, 0, "piece"),
717
+ processedSeconds: tracks[index].processedSeconds };
709
718
  return [
710
719
  ...(output.spec.video ? [{ ...common, track: "video",
711
720
  operation: output.spec.transcodesVideo ? "encode" : "copy",
@@ -855,10 +864,17 @@ export class ViewerRequests {
855
864
  const reading = this.#host.encodeSpeedReadingOf?.(output, now) ?? null;
856
865
  if (Number.isFinite(reading?.speed) && reading.speed > 0 && Number.isFinite(reading.at)) {
857
866
  trend.add(reading.at, reading.speed);
858
- } else {
859
- const projected = Number(this.#host.projectedEncodeSpeedOf?.(output));
867
+ } else if (!trend.snapshot()) {
868
+ // ONLY UNTIL A PIECE IS MEASURED does the prediction stand for the
869
+ // encoder, and it is not kept among the readings. Added on every poll
870
+ // between runs, it put the machine's share of that moment — 1 % at a
871
+ // cold start — on the whole film, and the forecast read 0.03x
872
+ // (torrent-tv/meta#178).
873
+ const projected = Number(this.#host.predictedEncodeSpeedOf?.(output) ?? this.#host.projectedEncodeSpeedOf?.(output));
860
874
  if (Number.isFinite(projected) && projected > 0) {
861
- trend.add(now, projected);
875
+ const seed = new this.#host.playbackReadiness.RateTrend();
876
+ seed.add(now, projected);
877
+ return seed.snapshot();
862
878
  }
863
879
  }
864
880
  return trend.snapshot();
@@ -579,13 +579,16 @@ export function wireOutputs({
579
579
  sourceInputsFor: (output, index) => parts.sourceInputsFor?.(output, index),
580
580
  encodeSpeedReadingOf: (output) => parts.encodeCost.latestSpeedReadingOf(output),
581
581
  projectedEncodeSpeedOf: (output) => parts.encodeCost.projectedSpeedOf(output),
582
+ // The speed the offer predicted for this output when it chose its mode: a
583
+ // forecast with no measured piece yet stands on it rather than on the
584
+ // machine's share of the moment (torrent-tv/meta#178).
585
+ predictedEncodeSpeedOf: (output) => {
586
+ const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
587
+ return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
588
+ },
582
589
  audioDescriptionOf,
583
- processingStartupSecondsOf: (output) => output.spec.carries === "audio-only" ?
584
- audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
585
- codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
586
- transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0 :
587
- Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
588
- parts.encodeCost.projectedSpeedOf(output)),
590
+ openSecondsOf: (output) => runStartSecondsOf(output),
591
+ processingStartupSecondsOf: (output) => runStartSecondsOf(output),
589
592
  initialOutputBitsPerMediaSecondOf: (output) => {
590
593
  const description = audioDescriptionOf(output);
591
594
  const reference = audioReadingFor(audioCalibration, { codec: description?.codec, transcode: false });
@@ -1045,6 +1048,27 @@ export function wireOutputs({
1045
1048
  readFree: freeBytesFor,
1046
1049
  logger
1047
1050
  });
1051
+ /**
1052
+ * What starting a run costs this output before its first piece is encoded:
1053
+ * its own measured open where it has one (`RunCosts.openOf`), else the
1054
+ * host's startup reading of spawn to first piece less that piece's encoding;
1055
+ * for sound, its packaged startup reading.
1056
+ *
1057
+ * @param {object} output
1058
+ * @returns {number}
1059
+ */
1060
+ function runStartSecondsOf(output) {
1061
+ if (output.spec.carries === "audio-only") {
1062
+ return audioReadingFor(audioCalibration, { ...audioDescriptionOf(output),
1063
+ codec: audioDescriptionOf(output)?.codec ?? output.file.media?.audioCodec,
1064
+ transcode: output.spec.audio?.transcode })?.startupSeconds ?? 0;
1065
+ }
1066
+ const open = parts.encodeOrchestrator?.openOf?.(output.outputKey ?? "");
1067
+ if (open?.measured) return open.seconds;
1068
+ return Math.max(0, (startStopCost?.firstByteWaitSec ?? 0) - parts.segmentDurationSec /
1069
+ parts.encodeCost.projectedSpeedOf(output));
1070
+ }
1071
+
1048
1072
  // Realtime-budget monitor: only meaningful for the software encoder with a
1049
1073
  // benchmark (the only path that can pick/step resolution). Cheap no-op scan
1050
1074
  // otherwise.
@@ -49,8 +49,8 @@ const CLIENT_RANGE_START_TOLERANCE_SECONDS = 0.25;
49
49
  * @param {number} horizonSeconds
50
50
  * @returns {number | null}
51
51
  */
52
- export function forecastRate(readings, now = Date.now(), horizonSeconds = 0) {
53
- const curve = rateCurve(readings, now);
52
+ export function forecastRate(readings, now = Date.now(), horizonSeconds = 0, weighting = "last") {
53
+ const curve = rateCurve(readings, now, weighting);
54
54
  if (!curve) {
55
55
  return null;
56
56
  }
@@ -192,7 +192,12 @@ export function predictPlaybackReadiness(input = {}) {
192
192
  [{ start: at, end: add(at, measured ? nearest(fromSeconds(buffered), unit) :
193
193
  rescale(fromSeconds(buffered), unit)), frame: zero }],
194
194
  ready: new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : []),
195
- curve: rateCurve(track.readings, now),
195
+ // Film made over the encoder's working time, over the readings the trend
196
+ // keeps: one reading is one closed piece, so the harmonic mean is that
197
+ // ratio. The last reading alone is one piece's content and machine — a
198
+ // first piece of credits read 6.49x and started a cold AVI on 2.4 s of
199
+ // buffer that the next piece, at 0.57x, did not hold (torrent-tv/meta#178).
200
+ curve: rateCurve(track.readings, now, "piece"),
196
201
  bitsPerMediaSecond: averageBitsPerMediaSecond(track, track.segments,
197
202
  new Set(Array.isArray(track.readySegmentIndices) ? track.readySegmentIndices : [])),
198
203
  segments: segmentsOf[trackIndex]
@@ -211,7 +216,10 @@ export function predictPlaybackReadiness(input = {}) {
211
216
  const serviceKey = typeof source.serviceKey === "string" ? source.serviceKey : id;
212
217
  let service = sourceServices.get(serviceKey);
213
218
  if (!service) {
214
- service = { rate: rateCurve(source.downloadRateReadings, now), finish: 0 };
219
+ // Bytes over time across the readings the trend keeps: the last 5 s of a
220
+ // swarm moved between 0.97 and 5.8 MB/s and was carried to the end of the
221
+ // film each time (torrent-tv/meta#178).
222
+ service = { rate: rateCurve(source.downloadRateReadings, now, "time"), finish: 0 };
215
223
  sourceServices.set(serviceKey, service);
216
224
  }
217
225
  sourceState.set(id, { source, service });
@@ -330,6 +338,22 @@ export function predictPlaybackReadiness(input = {}) {
330
338
  }
331
339
  }
332
340
  const trackFinish = trackState.map(({ track }) => finiteNonNegative(track.startupRemainingSeconds));
341
+ // WHERE A RUN STARTS, AND WHAT IT COSTS. A run is given the stretch whose
342
+ // bytes are held when its input is admitted, and the next one starts after
343
+ // it, when more bytes are held, and opens its input first — 6-10 s on an
344
+ // OpenDML AVI. Charged once at the first start only, a cold AVI made of runs
345
+ // of one to five pieces was forecast as one run (torrent-tv/meta#178). So a
346
+ // piece outside the live runs' stretches whose bytes arrive after the run
347
+ // the schedule last started was admitted begins a run of its own, admitted
348
+ // when the piece before it is made and its bytes are held, then opened.
349
+ // The first run's start is `startupRemainingSeconds`, charged once above; a
350
+ // stretch's bound by memory and by its copy time is not modelled.
351
+ const runStarts = trackState.map(({ track }) => ({
352
+ openSeconds: finiteNonNegative(track.openSeconds),
353
+ spans: (Array.isArray(track.runSpans) ? track.runSpans : [])
354
+ .filter(span => Number.isFinite(span?.start) && span.end > span.start),
355
+ admittedAt: finiteNonNegative(track.startupRemainingSeconds) > 0 ? 0 : null
356
+ }));
333
357
  const sourceByteCoverage = new Map([...sourceByteService].map(([id, ranges]) => [id, numericCoverage(ranges)]));
334
358
  const sourceArrivals = new Map([...sourceByteService].map(([id, ranges]) => [id, indexedArrivals(ranges)]));
335
359
  let transferFinish = 0;
@@ -386,7 +410,15 @@ export function predictPlaybackReadiness(input = {}) {
386
410
  // Production accepts a complete held segment input. It cannot overlap its
387
411
  // own source download; reused input retains its original arrival time.
388
412
  const processing = trackState[trackIndex].curve;
389
- const admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
413
+ let admittedAt = Math.max(trackFinish[trackIndex], sourceArrivalAt);
414
+ if (!job.produced && encodeWork > 0) {
415
+ const run = runStarts[trackIndex];
416
+ const live = run.spans.some(span => segment.startSeconds >= span.start && segment.startSeconds < span.end);
417
+ if (!live && (run.admittedAt === null || sourceArrivalAt > run.admittedAt)) {
418
+ run.admittedAt = admittedAt;
419
+ admittedAt += run.openSeconds;
420
+ }
421
+ }
390
422
  const producedAt = job.produced || encodeWork === 0 ? 0 : processing.finish(encodeWork, admittedAt);
391
423
  trackFinish[trackIndex] = Math.max(trackFinish[trackIndex], producedAt);
392
424
  productions.push({ at: producedAt, trackIndex, segment, requiresSource: sourceArrivalAt > 0 });
@@ -787,11 +819,19 @@ function roundedHalfUp(numerator, denominator) {
787
819
  return 2n * (numerator - floor * denominator) >= denominator ? floor + 1n : floor;
788
820
  }
789
821
 
790
- function rateCurve(readings, now) {
822
+ /**
823
+ * @param {*} readings - A summary (`RateTrend.snapshot`) or the readings themselves.
824
+ * @param {number} now
825
+ * @param {"last" | "piece" | "time"} [weighting] - Which rate the readings
826
+ * stand for: the last one; work per working time where each reading is one
827
+ * piece of film; or quantity per wall time where each reading holds until
828
+ * the next.
829
+ */
830
+ function rateCurve(readings, now, weighting = "last") {
791
831
  if (readings && Number.isFinite(readings.count) && readings.count > 0 &&
792
832
  Number.isFinite(readings.lastAt) && Number.isFinite(readings.lastValue) &&
793
833
  Number.isFinite(readings.meanValue) && Number.isFinite(readings.spanSeconds)) {
794
- return rateCurveFrom(readings, now);
834
+ return rateCurveFrom(readings, weighting);
795
835
  }
796
836
  const ordered = (Array.isArray(readings) ? readings : [])
797
837
  .filter((reading) => Number.isFinite(reading?.at) && Number.isFinite(reading?.value) && reading.value >= 0)
@@ -802,25 +842,38 @@ function rateCurve(readings, now) {
802
842
  return null;
803
843
  }
804
844
 
805
- return rateCurveFrom(summarizeRate(ordered), now);
845
+ return rateCurveFrom(summarizeRate(ordered), weighting);
806
846
  }
807
847
 
808
848
  function summarizeRate(readings) {
809
849
  const latest = readings.at(-1);
850
+ let weighted = 0;
851
+ for (let index = 0; index + 1 < readings.length; index += 1) {
852
+ weighted += readings[index].value * (readings[index + 1].at - readings[index].at);
853
+ }
854
+ const spanMs = latest.at - readings[0].at;
810
855
  return {
811
856
  count: readings.length,
812
857
  lastAt: latest.at,
813
858
  lastValue: latest.value,
814
859
  meanValue: readings.reduce((sum, reading) => sum + reading.value, 0) / readings.length,
815
- spanSeconds: (latest.at - readings[0].at) / 1000
860
+ // Each reading one piece of work: work over the time it took.
861
+ harmonicValue: readings.some(reading => !(reading.value > 0)) ? 0
862
+ : readings.length / readings.reduce((sum, reading) => sum + 1 / reading.value, 0),
863
+ // Each reading holding until the next: quantity over the span.
864
+ timeWeightedValue: spanMs > 0 ? weighted / spanMs : latest.value,
865
+ spanSeconds: spanMs / 1000
816
866
  };
817
867
  }
818
868
 
819
- function rateCurveFrom({ lastValue }) {
820
- // Conditional projection of the current measured service. No exponential
869
+ function rateCurveFrom(summary, weighting = "last") {
870
+ // Conditional projection of the measured service. No exponential
821
871
  // relaxation, smoothing time, slope extrapolation or guessed recovery.
822
872
  // A new measurement changes the integral; polling does not change its rate.
823
- const rate = Math.max(0, lastValue);
873
+ const value = weighting === "piece" && Number.isFinite(summary.harmonicValue) ? summary.harmonicValue
874
+ : weighting === "time" && Number.isFinite(summary.timeWeightedValue) ? summary.timeWeightedValue
875
+ : summary.lastValue;
876
+ const rate = Math.max(0, value);
824
877
  const workBy = (seconds) => rate * Math.max(0, seconds);
825
878
  return {
826
879
  rateAt: () => rate,