Schneller anfangen, dann hinaufgehen

Es dauerte eine halbe Minute bis zum ersten Bild. Der Grund war nicht die
Leitung, sondern der Server: „Auto\" begann mit acht Megabit, und das heisst
1080p von der ersten Sekunde an. So lange braucht er, um das zu rechnen.

Jetzt wird mit drei Megabit begonnen — also 720p. Das laeuft in wenigen
Sekunden an, sieht einen Augenblick lang weicher aus, und von dort geht es
hinauf, sobald sich zeigt, dass die Leitung mehr traegt.

Dabei steckt eine Falle, die den ganzen Gewinn aufgezehrt haette: der
Server baut seine Leiter aus der Bestellung. Wer mit drei Megabit anfaengt,
bekommt eine Leiter, deren oberste Sprosse drei Megabit ist — und der
billige Hebel bewegt sich nur **innerhalb** dieser Leiter. Aus dem
schnellen Start waere ein Film geworden, der den ganzen Abend in 720p
bleibt.

Hoeher geht es deshalb ueber einen Neuaufbau, und der braucht Regeln: nicht
zu frueh, nicht nach einem Ruckler, nicht ohne Vorrat, nicht oefter als
zweimal. Sie stehen in `UpgradeRule` — reiner Typ, dreizehn Tests, wie beim
Absenken. Und deutlich zurueckhaltender als dort: ein zu spaetes Absenken
bedeutet Ruckeln, ein zu spaetes Hinaufschalten ein etwas weicheres Bild.
Das erste ist ein Fehler, das zweite eine Geschmacksfrage.

Bei Live-TV wird nicht hinaufgegangen: dort kostet jeder Neuaufbau den
Tuner, und ein schaerferes Bild ist diesen Einsatz nicht wert.

`StallLedger` kann jetzt sagen, wann der letzte Ruckler war — das brauchte
die Regel, und „noch nie gestockt\" ist etwas anderes als „vor langer Zeit\".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-29 06:25:15 +02:00
co-authored by Claude Opus 5
parent 57170b20ea
commit 4323c47db6
5 changed files with 422 additions and 5 deletions
@@ -85,6 +85,21 @@ public struct StallLedger: Sendable, Equatable {
.max() ?? .zero
}
/// Wann der letzte Rückler zu Ende war.
///
/// `nil`, wenn es im Fenster keinen gab — und das ist etwas anderes als
/// „vor langer Zeit": wer noch nie gestockt hat, wartet auf keine Frist.
///
/// Gebraucht fürs Hinaufschalten: wer gerade gestockt hat, hat keine
/// Reserve, und ein Neuaufbau nach oben wäre die zweite Unterbrechung
/// binnen einer Minute.
public func lastStall(at now: Duration) -> Duration? {
episodes
.filter { $0.endedAt >= now - window }
.map(\.endedAt)
.max()
}
/// Wirft heraus, was aus dem Fenster gefallen ist.
public mutating func prune(at now: Duration) {
let horizon = now - window
@@ -0,0 +1,140 @@
import Foundation
/// Wann **hinauf** gegangen wird — und wann der teure Weg dafür erlaubt ist.
///
/// **Die Falle, um die es hier geht.** Damit ein Film zügig anläuft, wird mit
/// einer niedrigen Stufe begonnen. Der Server baut seine Leiter aber aus
/// genau dieser Bestellung: wer mit drei Megabit anfängt, bekommt eine
/// Leiter, deren oberste Sprosse drei Megabit ist. Das billige Hinaufschalten
/// über `preferredPeakBitRate` bewegt sich **innerhalb** dieser Leiter — es
/// käme also nie darüber hinaus, und aus dem schnellen Start würde ein Film,
/// der den ganzen Abend in 720p bleibt.
///
/// Höher geht es nur mit einem Neuaufbau: neu aushandeln, mit einer grösseren
/// Grenze. Der kostet Sekunden mit schwarzem Bild — deshalb dieselbe Bauform
/// wie `DowngradeRule`: eine Liste von Bedingungen, die **alle** gelten
/// müssen, kein Schwellwert.
///
/// **Und deutlich zurückhaltender als das Absenken.** Ein zu spätes Absenken
/// bedeutet Ruckeln; ein zu spätes Hinaufschalten bedeutet ein etwas
/// weicheres Bild. Das erste ist ein Fehler, das zweite eine Geschmacksfrage.
public enum UpgradeRule {
/// Wie lange es ruhig laufen muss, bevor überhaupt gefragt wird.
///
/// Lang genug, dass es kein anfängliches Aufatmen ist: der Puffer füllt
/// sich nach dem Start immer, und das sieht zwanzig Sekunden lang nach
/// einer hervorragenden Leitung aus.
public static let settleIn: Duration = .seconds(45)
/// Sperrfrist zwischen zwei Neuaufbauten nach oben.
public static let cooldown: Duration = .seconds(150)
/// Wie lange nach einem Rückler gar nicht hinaufgegangen wird.
///
/// Wer gerade gestockt hat, hat keine Reserve — und ein Neuaufbau nach
/// oben wäre dann die zweite Unterbrechung binnen einer Minute.
public static let afterStall: Duration = .seconds(90)
/// Wie viel Vorrat dasein muss.
///
/// Nicht „es läuft gerade", sondern „es liegt etwas auf der hohen Kante".
/// Nur das trägt einen Neuaufbau.
public static let bufferNeeded: Duration = .seconds(20)
/// Was zum Zeitpunkt der Frage gilt.
public struct Facts: Sendable, Equatable {
/// Eine ausdrücklich gewählte Stufe ist eine Ansage und wird nicht
/// überstimmt — auch nicht nach oben.
public let isAutomatic: Bool
public let condition: StreamCondition
/// Ob die Leiter noch eine Sprosse nach oben hat. Solange ja, ist der
/// **billige** Weg der richtige.
public let hasHigherRung: Bool
/// Ob die eingestellte Obergrenze überhaupt mehr zuliesse.
public let ceilingAllowsMore: Bool
/// Wie viel Vorrat vor der Abspielstelle liegt.
public let bufferedAhead: Duration?
/// Wie lange schon gespielt wird.
public let playedFor: Duration
/// Wie lange der letzte Rückler her ist. `nil` heisst: keiner gewesen.
public let sinceLastStall: Duration?
/// Wie lange der letzte Sprung her ist.
public let sinceLastSeek: Duration?
/// Wie lange der letzte Neuaufbau nach oben her ist.
public let sinceLastUpgrade: Duration?
/// Wie viele es in dieser Wiedergabe schon gab.
public let used: Int
/// Wie viele es höchstens geben darf.
public let budget: Int
public init(
isAutomatic: Bool,
condition: StreamCondition,
hasHigherRung: Bool,
ceilingAllowsMore: Bool,
bufferedAhead: Duration?,
playedFor: Duration,
sinceLastStall: Duration? = nil,
sinceLastSeek: Duration? = nil,
sinceLastUpgrade: Duration? = nil,
used: Int = 0,
budget: Int = 2
) {
self.isAutomatic = isAutomatic
self.condition = condition
self.hasHigherRung = hasHigherRung
self.ceilingAllowsMore = ceilingAllowsMore
self.bufferedAhead = bufferedAhead
self.playedFor = playedFor
self.sinceLastStall = sinceLastStall
self.sinceLastSeek = sinceLastSeek
self.sinceLastUpgrade = sinceLastUpgrade
self.used = used
self.budget = budget
}
}
/// Warum **nicht** hinaufgegangen wird.
///
/// Als Aufzählung statt als `Bool`, aus demselben Grund wie beim
/// Absenken: „es läuft gut und es passiert nichts" ist ohne Begründung
/// von einem Fehler nicht zu unterscheiden.
public enum Verdict: Sendable, Equatable {
case rebuildHigher
/// Eine gewählte Stufe wird nicht überstimmt.
case notAutomatic
/// Die Leiter hat noch eine Sprosse — der billige Weg genügt.
case cheaperPathRemains
/// Die Einstellung lässt nicht mehr zu. Kein Fehler, eine Ansage.
case atCeiling
/// Es läuft nicht ruhig genug.
case notCalmEnough
/// Zu früh nach Start, Sprung oder Rückler.
case tooEarly
/// Sperrfrist läuft noch.
case cooling
/// Verbraucht.
case budgetSpent
}
public static func verdict(_ facts: Facts) -> Verdict {
guard facts.isAutomatic else { return .notAutomatic }
guard facts.ceilingAllowsMore else { return .atCeiling }
// **Erst die Leiter ausreizen.** Solange es eine Sprosse höher gibt,
// kostet das Hinaufgehen kein schwarzes Bild — und wäre ein
// Neuaufbau dafür Verschwendung.
if facts.hasHigherRung { return .cheaperPathRemains }
guard facts.condition == .healthy else { return .notCalmEnough }
guard let vorrat = facts.bufferedAhead, vorrat >= bufferNeeded else {
return .notCalmEnough
}
guard facts.playedFor >= settleIn else { return .tooEarly }
if let seit = facts.sinceLastSeek, seit < DowngradeRule.afterSeek { return .tooEarly }
if let seit = facts.sinceLastStall, seit < afterStall { return .tooEarly }
if let seit = facts.sinceLastUpgrade, seit < cooldown { return .cooling }
guard facts.used < facts.budget else { return .budgetSpent }
return .rebuildHigher
}
}