feat(33): structured pump stats + the session planner and its plain-words copy

Slice 2. 588 green, 0 warnings.

- `FramePumpStats` and `FramePump.StatsReported`: the 5 s window the log line already describes, as
  data. `Dropped` is the ENCODER's counter (frames submitted, not accepted); `Stalls`/`AvgRenderMs`
  are the pump's. `FramesRendered` is deliberately not used for classification — a static scene
  reuses the paste cache and renders few frames while completely healthy, so it would read as
  starvation.
- `StepDownPlanner`: one per broadcast, owns the stateful policy, converts stats → sample, exposes the
  landed tier for the restart. It never acts — the VM owns the effects, which is what keeps the ladder
  testable without an encoder.
- `Describe` / `ReportLine`: the sentences a creator sees. Machine cause says LlamaCasty couldn't keep
  up and the connection was not the problem; upload cause blames the upload and promises no further
  thrash. A test asserts the machine copy never leaks "dropped" — mechanism strings stay in the log.

Remaining for the task: VM subscription, the encoder restart at the landed tier, the toast, and
shedding our own capture cost before stepping fps.
This commit is contained in:
2026-11-09 10:06:36 -08:00
parent bc62bd81ec
commit 555ff5c0ac
5 changed files with 225 additions and 1 deletions
+19
View File
@@ -133,6 +133,10 @@ public sealed class FramePump : IDisposable
/// Raised before the pump stops, so the session can relaunch the push instead of ending.</summary>
public event EventHandler<string>? EncoderFailed;
/// <summary>TASK 33: the same 5-second window the stats line reports, structured — so a policy can
/// tell machine starvation from encoder backpressure without parsing prose.</summary>
public event EventHandler<FramePumpStats>? StatsReported;
public FramePump(
Func<Scene?> sceneProvider,
Func<SceneElement, VideoFrame?> frameResolver,
@@ -426,6 +430,21 @@ public sealed class FramePump : IDisposable
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
_timings.Reset();
// Structured twin of the line above, raised before the counters reset. `dropped` is the
// ENCODER's count (ffmpeg blocked); a renderer that duplicates frames is already visible
// as render time + stalls, and FramesRendered is ambiguous by design (paste cache).
StatsReported?.Invoke(this, new FramePumpStats(
WindowSeconds: 5,
FramesSubmitted: statFrames,
FramesRendered: statRenders,
AvgRenderMs: statRenders > 0 ? renderTicks / freq * 1000 / statRenders : 0,
AvgSubmitMs: statFrames > 0 ? submitTicks / freq * 1000 / statFrames : 0,
AvgWaitMs: statRenders > 0 ? waitTicks / freq * 1000 / statRenders : 0,
WorstRenderMs: worstRender / freq * 1000,
WorstSubmitMs: worstSubmit / freq * 1000,
Dropped: dropped,
Stalls: stalls));
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;