gate Muninn decode epochs and retry repair

This commit is contained in:
Meta Vens 2026-07-17 07:24:41 +01:00
commit 78ca783276
5 changed files with 89 additions and 28 deletions

View file

@ -452,15 +452,18 @@ The first cut landed in Odin on 2026-07-16: experimental CultLib snapshot
`8965f3c0`, epoch/sequence/edge-ack HID delivery, 100 ms default LAN media
deadline, CultNet `realtime` A/V delivery, bounded queues, expiring/late-aware
repair, and decode-chain-owned keyframe pressure. The production video path now
uses typed V4 Cauchy GF(256) FEC in independent 8-data/8-parity blocks. Each
uses typed V4 Cauchy GF(256) FEC in independent 8-data/4-parity blocks. Each
block schedules data and parity as separate lanes, including fixed protection
for a short tail block. Canonical video and parity use CultNet's unreliable
`realtime` lane; selective repair and IDR recovery remain deadline-bound.
Canonical audio and its parity also use `realtime`; 4+2 FEC, reorder, and
Canonical AAC ADTS audio and its fixed 864-byte parity shards also use
`realtime`; 4+2 FEC, 120 ms reorder, and
concealment own continuity without an ACK/retransmit window. Sender access units enter the
handoff queue independently, and CultMesh catalog publication runs outside the
realtime media loop. The remaining work is the full direct/proxy impairment
matrix, Opus FEC/PLC, and long-duration mixed soak.
realtime media loop. Repair material becomes available when a video frame enters
the queue, and the receiver may issue bounded repair requests at 64/96/128 ms.
The remaining work is duplicate/reconnect acceptance, longer iid runs, and the
long-duration mixed soak.
The socket harness and bounded PCM loss recovery have now landed. Odin's
`cultnet-impair` proxy supplies deterministic seeded loss/burst/reorder/

View file

@ -60,6 +60,37 @@ datagram or move fragmentation above the erasure-code boundary.
scheduler sees it. Capacity-eviction counters proved zero during the clean
acceptance run.
## 2026-07-17 bounded-media field cut
The live path now keeps every protected shard in one datagram, sends AAC ADTS
in fixed 864-byte 4+2 audio blocks, uses 768-byte video shards with 8+4 block
FEC, and begins each decoder epoch at a complete IDR. The sender admits video
to its repair cache when the frame enters the queue, accepts the production
CultNet `realtime` channel, and can service bounded repair requests at
64/96/128 ms in batches of up to eight chunks.
Seeded Raven-to-Starfire evidence:
- iid 1%: 802 of 78,406 datagrams dropped; zero decode drop, video expiry,
audio failure, or audio skip; 1,800 video frames recovered;
- burst-4: 1,436 of 71,910 datagrams dropped; zero expiry or audio skip across
1,800 forwarded frames;
- reorder: 2,872 reorder events; zero expiry or audio skip across 900 frames;
- iid 3%: 2,564 of 85,731 datagrams dropped; three of 5,400 frames expired,
zero audio skip (99.94 percent video delivery);
- burst-8: 190 chunks repaired; one of 1,800 frames expired, zero audio skip;
- 250 ms stall: 420 datagrams withheld, one frame expired, 68 chunks repaired,
an IDR was requested, and another 900 frames arrived without a stuck decoder
or audio skip.
Delay/jitter produced one expiry in 900 frames. Duplicate, reconnect, longer
iid runs, and the 60-minute mixed soak remain open acceptance rows.
Field scar: production video uses the `realtime` channel, but the repair cache
previously admitted only legacy `media` payloads. Synthetic tests built the
legacy shape and certified a repair path that was empty in production. The
production-channel regression test now owns this boundary.
## Completion rule
Moonlight-level performance is reasonably expected only after all local rows

View file

@ -65,6 +65,8 @@ constexpr uint64_t MuninnMediaStaleMs = 1500;
constexpr uint64_t MuninnMediaRestartCooldownMs = 5000;
constexpr uint64_t MuninnReceiverKeyframeRequestCooldownMs = 500;
constexpr uint64_t MuninnReceiverChunkRepairFirstWaitMs = 64;
constexpr uint64_t MuninnReceiverChunkRepairRetryWaitMs = 32;
constexpr uint32_t MuninnReceiverChunkRepairMaxRequests = 3;
constexpr uint64_t MuninnVideoAssemblyExpiryScanMs = 10;
constexpr size_t MuninnExpiredVideoFrameRememberCount = 4096;
constexpr size_t MuninnCompletedVideoFrameRememberCount = 4096;
@ -256,7 +258,9 @@ struct MuninnVideoFrameAssembly {
uint16_t chunk_count = 0;
uint16_t chunks_received = 0;
std::chrono::steady_clock::time_point started_at;
bool repair_requested = false;
uint32_t repair_request_count = 0;
std::chrono::steady_clock::time_point last_repair_requested_at =
std::chrono::steady_clock::time_point::min();
std::map<uint16_t, MuninnVideoFecBlockAssembly> fec_blocks;
std::vector<std::vector<uint8_t>> chunks;
};
@ -1650,6 +1654,15 @@ private:
{
expire_video_assemblies(std::chrono::steady_clock::now(), bridge_sequence);
expire_video_parity_cache(std::chrono::steady_clock::now());
const std::string key =
media.stream_id + ":" + media.session_id + ":" + std::to_string(media.frame_id);
if (waiting_for_video_keyframe_ && !media.keyframe) {
video_assemblies_.erase(key);
video_parity_cache_.erase(key);
remember_completed_video_frame(key);
++video_recovery_dropped_;
return std::nullopt;
}
if (media.kind == MuninnMediaPayload::Kind::VideoParity) {
return assemble_video_parity_payload(media, bridge_sequence);
}
@ -1658,8 +1671,6 @@ private:
log_bridge_pressure("dropped invalid video chunk metadata");
return std::nullopt;
}
const std::string key =
media.stream_id + ":" + media.session_id + ":" + std::to_string(media.frame_id);
if (video_frame_is_settled(key)) {
return std::nullopt;
}
@ -1677,7 +1688,7 @@ private:
assembly.chunk_count = media.chunk_count;
assembly.chunks.resize(media.chunk_count);
assembly.started_at = std::chrono::steady_clock::now();
assembly.repair_requested = false;
assembly.repair_request_count = 0;
apply_cached_video_parity(key, assembly);
}
if (assembly.chunk_count != media.chunk_count ||
@ -1999,11 +2010,18 @@ private:
if (needs_decoder_recovery) {
waiting_for_video_keyframe_ = true;
}
log_video_assembly_expiry(iterator->second, missing_chunk_keys, now);
const bool bootstrap_keyframe = waiting_for_video_keyframe_ && iterator->second.keyframe;
if (!bootstrap_keyframe) {
log_video_assembly_expiry(iterator->second, missing_chunk_keys, now);
}
remember_expired_video_frame(iterator->first);
iterator = video_assemblies_.erase(iterator);
++video_assemblies_expired_;
log_bridge_pressure("expired incomplete video frame assembly");
if (bootstrap_keyframe) {
++video_recovery_dropped_;
} else {
++video_assemblies_expired_;
log_bridge_pressure("expired incomplete video frame assembly");
}
}
}
@ -2046,7 +2064,7 @@ private:
static_cast<unsigned int>(assembly.chunk_count),
missing_chunk_keys.size(),
static_cast<long long>(age_ms),
assembly.repair_requested ? "true" : "false",
assembly.repair_request_count > 0 ? "true" : "false",
assembly.fec_blocks.empty() ? "false" : "true",
fec_state.str().c_str());
}
@ -2058,11 +2076,17 @@ private:
if (assembly.chunks_received >= assembly.chunk_count) {
return;
}
if (assembly.repair_requested) {
if (assembly.repair_request_count >= MuninnReceiverChunkRepairMaxRequests) {
return;
}
const auto repair_wait_ms = std::max<uint32_t>(parts_.gap_wait_ms, MuninnReceiverChunkRepairFirstWaitMs);
if (now - assembly.started_at < std::chrono::milliseconds(repair_wait_ms)) {
const auto first_wait_ms = std::max<uint32_t>(parts_.gap_wait_ms, MuninnReceiverChunkRepairFirstWaitMs);
if (assembly.repair_request_count == 0 &&
now - assembly.started_at < std::chrono::milliseconds(first_wait_ms)) {
return;
}
if (assembly.repair_request_count > 0 &&
now - assembly.last_repair_requested_at <
std::chrono::milliseconds(MuninnReceiverChunkRepairRetryWaitMs)) {
return;
}
const auto missing_chunk_keys = missing_video_chunk_keys(assembly);
@ -2070,7 +2094,8 @@ private:
return;
}
send_receiver_feedback_if_due(assembly, missing_chunk_keys, false, false, bridge_sequence);
assembly.repair_requested = true;
assembly.last_repair_requested_at = now;
++assembly.repair_request_count;
}
void expire_rudp_packet_fragments(std::chrono::steady_clock::time_point now)

View file

@ -342,13 +342,13 @@ The live Raven-to-Starfire program path is Muninn's typed CultNet media body:
```mermaid
flowchart TD
A["Raven D3D11 desktop capture"] --> B["Long-lived NVENC encoder"]
C["Raven WASAPI loopback"] --> D["10 ms float PCM packetizer"]
B --> E["V4 8+8 block FEC and realtime video lane"]
D --> F["4+2 audio FEC and realtime audio lane"]
C["Raven WASAPI loopback"] --> D["AAC-LC / ADTS encoder"]
B --> E["V4 8+4 block FEC and realtime video lane"]
D --> F["Fixed 864-byte 4+2 audio FEC lane"]
E --> G["Starfire OBS Muninn receiver udp/5200"]
F --> G
G --> H["Frame assembly / FEC / IDR feedback"]
G --> I["40 ms audio reorder / concealment"]
G --> I["120 ms audio reorder / concealment"]
H --> J["OBS-local video decoder socket"]
I --> K["OBS-local audio decoder socket"]
```
@ -361,8 +361,9 @@ the provider surface; neither discovery nor the compatibility decoder sockets
own hot media truth.
Invariant: video packets are useful only before their assembly deadline.
Canonical video and V4 parity use unreliable `realtime`; block FEC, selective
repair, and IDR recovery own loss. Canonical audio and 4+2 parity also use
Canonical video and V4 parity use unreliable `realtime`; 8+4 block FEC,
queue-admitted selective repair, and IDR recovery own loss. Canonical AAC audio
and fixed 4+2 parity also use
`realtime`; reorder and bounded concealment own playout continuity. Completed
video frames are tombstoned so late shards cannot
become a second assembly owner. Raven uses direct LAN only and starts through a

View file

@ -89,13 +89,13 @@ invariants:
accepted_design:
- Muninn video FEC is typed as `muninn.media_video_parity_shard.v4`.
Odin divides each access unit into independent blocks of at most eight data
shards and emits eight Cauchy GF(256) parity rows for every block, including
shards and emits four Cauchy GF(256) parity rows for every block, including
a short tail. Data/parity lanes are round-robin scheduled per block. Mimir's
OBS receiver owns bounded block recovery before the assembly deadline,
forwards only the canonical completed frame, and tombstones completed frame
keys so late shards cannot resurrect ghost assemblies. V2/V3 remain legacy
decode shapes and never own V4 behavior. Rust tests exhaust every burst of
one through eight packets at every alignment for chunk counts 2 through 80;
one through four packets at every alignment, plus tail-block burst coverage;
native tests prove the matching receiver. Canonical video and parity now use
CultNet `realtime` without per-packet reliable queues. Repair is selective
and behind fresh media; unrecoverable dependency damage requests a live
@ -122,8 +122,9 @@ accepted_design:
The normal quarter-second scheduled IDR remains independent fallback.
The repeatable bundle includes the OBS experimental FFmpeg dependency
snapshot. Raven now runs the bundle over direct LAN from a hidden scheduled
task; WireGuard is not a runtime or administration path. Real impairment
acceptance remains pending.
task; WireGuard is not a runtime or administration path. Seeded 1% loss,
burst-4, reorder, and bounded stall recovery now pass; longer matrix and
soak acceptance remain pending.
- Muninn input now bounds disconnected unacknowledged button history at 256
edges. Overflow rotates the controller epoch and rebases Sleipnir from the
next complete latest-state frame, rather than dropping an arbitrary press
@ -131,11 +132,11 @@ accepted_design:
256. Full Muninn (174) and Sleipnir (26) suites pass.
- The 2026-07-16 loss-recovery cut is now live on both sides of the Muninn
audio boundary. Odin emits typed `muninn.media_audio_parity_shard.v1`
records using fixed 4+2 GF(256) parity for constant-size 10 ms PCM packets.
records using fixed 4+2 GF(256) parity for padded AAC ADTS packets.
Canonical audio and parity use `realtime`; 4+2 FEC, reorder, and bounded
concealment own continuity without a media ACK/retransmit window.
Mimir's production OBS receiver retains a bounded recent data window,
reconstructs up to two erased packets per block before the 40 ms playout
reconstructs up to two erased packets per block before the 120 ms playout
deadline, and then hands recovered packets to the same reorder queue that
owns normal playout. The FEC cache is not a second timeline. Native tests
exhaust every one- and two-erasure permutation, and the production plugin