Two drafts read whole at the revision in the IETF archive on 10 November 2026, with
draft-gurel-moq-track-switching-01 beside the first. Neither module opens a stream or
is on a session's path: each is what a subscriber, a publisher and a relay compute.
- Subscriber-side track switching (draft-ugur-moq-fast-switching-00). joule-moq's
switching module reads a Switch Set from an MSF catalog's altGroup and
keyframeSpacing, with its GoP Ladders and its Alignment Unit, and refuses a set in
which a spacing is not a multiple of that unit. It gives the Frame Number, the
nearest Switch Point and its Distance, the track chosen under the draft's full
policy and its simpler one, and the End Group in both of its cases. SWITCH_FROM is
an unmerged pull request and is not in draft-ietf-moq-transport-23, and neither are
track-switching-01's altTrackGroup and Switch Track Alias: none is written. A
SwitchPlan states the switch as the two requests -23 has, a SUBSCRIBE of the target
with an absolute-start Location Filter at {targetGroup, 0} and a REQUEST_UPDATE
that ends the current subscription at the End Group, and the module's text says
what the drafts' parameters would add. The release buffer keys both tracks' objects
by Frame Number and presents each once, in order. A switch that would start inside
a Group, and one at a Group the target never publishes, are refused, each with its
own code.
- Probe tracks (draft-gurel-moq-probing-00). joule-moq's probe module parses Dynamic
Probe Names by the draft's grammar with every invalid case of its section 7.1 under
its own reason, resolves a probe as Figure 4 does in exact integer arithmetic, and
lists its objects: Location, payload size, release time, the lowest publisher
priority, LOC's Timestamp property and a payload of zeros. It holds the per-probe,
session and relay-wide limits with Table 5's defaults as a configuration and clamps
nothing, maps every outcome to its error code in both directions with its Retry
Interval, writes and reads the probe catalog (which has no member a limit could be
carried in), reads the session-scoped namespaces and the empty-namespace form, and
measures a receiver's throughput and the trend of its arrival gaps against the send
gaps.
Writing the tests found one thing in the first draft: where the spacings of the
current and the target track do not divide one another, the End Group its section 7
computes can end before the Switch Point, so frames between them would never be
sent. The module computes the End Group as the draft writes it and refuses to plan
such a switch.
Corpora: wai/moqt-conformance/switching/ and wai/moqt-conformance/probe/, each one
vectors.json written by the module's corpus generator, with the drafts' own worked
examples in them (Frame Number 28 and Table 1, both End Groups; Tables 4, 5 and 6
and Figure 5). switching_verify.py and probe_verify.py are the second
implementations, standard library only, sharing no code with the crate. Planted
faults, each refused with its own reason by both: 34 for switching, 70 for probes.
Each verifier changes copies of its corpus and requires every change to be caught,
then leaves out each of its own checks in turn and requires the corpus to catch it.
joule-code-conformance.yml runs both verifiers; wai conform --independent lists
them as moq-switching and moq-probe (moqt-mappings.sh).
Co-Authored-By: Claude <[email protected]>
156 lines
9.4 KiB
Rust
156 lines
9.4 KiB
Rust
//! Track switching and probe tracks (`draft-ugur-moq-fast-switching-00`,
|
|
//! `draft-gurel-moq-probing-00`): the committed corpora
|
|
//! `wai/moqt-conformance/switching/vectors.json` and `wai/moqt-conformance/probe/vectors.json`
|
|
//! regenerate byte for byte, every case and refusal in them holds, the drafts' own worked
|
|
//! examples are in them with the drafts' numbers, and a corpus changed by one value is
|
|
//! caught. The corpora live beside this crate in the monorepo; if one cannot be read the test
|
|
//! fails, it never passes on nothing.
|
|
|
|
use joule_moq::{probe, switching};
|
|
use serde_json::{json, Value};
|
|
|
|
const WAI: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/");
|
|
|
|
fn committed(corpus: &str) -> (String, Value) {
|
|
let text = std::fs::read_to_string(format!("{WAI}moqt-conformance/{corpus}/vectors.json")).unwrap_or_else(|_| panic!("the {corpus} corpus"));
|
|
let doc = serde_json::from_str(&text).expect("JSON");
|
|
(text, doc)
|
|
}
|
|
|
|
fn cases<'a>(doc: &'a Value, section: &str) -> &'a Vec<Value> {
|
|
doc[section].as_array().unwrap_or_else(|| panic!("{section} of the corpus"))
|
|
}
|
|
|
|
#[test]
|
|
fn the_switching_corpus_regenerates_and_holds() {
|
|
let (text, doc) = committed("switching");
|
|
assert!(switching::corpus::generate() == text, "run `cargo run -p joule-moq --example adaptation_vectors -- gen`");
|
|
assert_eq!(switching::corpus::verify(&doc), Vec::<String>::new());
|
|
}
|
|
|
|
#[test]
|
|
fn the_probe_corpus_regenerates_and_holds() {
|
|
let (text, doc) = committed("probe");
|
|
assert!(probe::corpus::generate() == text, "run `cargo run -p joule-moq --example adaptation_vectors -- gen`");
|
|
assert_eq!(probe::corpus::verify(&doc), Vec::<String>::new());
|
|
}
|
|
|
|
/// The numbers the draft prints, held to the corpus as literals: §6.4's Frame Number 28 and
|
|
/// Table 1, and §7's two End Groups.
|
|
#[test]
|
|
fn the_switching_corpus_holds_the_drafts_worked_examples() {
|
|
let (_, doc) = committed("switching");
|
|
let frame = cases(&doc, "frames").iter().find(|c| c["track"] == "720p-long" && c["at"] == json!([13, 3])).expect("§6.4's object");
|
|
assert_eq!(frame["frame"], 28);
|
|
let table: Vec<(String, u64, u64)> = cases(&doc, "switch_points")
|
|
.iter()
|
|
.filter(|c| c["catalog"] == "figure-1" && c["frame"] == 28)
|
|
.map(|c| (c["target"].as_str().expect("a target").to_string(), c["target_group"].as_u64().expect("a group"), c["distance"].as_u64().expect("a distance")))
|
|
.collect();
|
|
assert_eq!(table, vec![("1080p-short".to_string(), 15, 1), ("1080p-medium".to_string(), 15, 1), ("1080p-long".to_string(), 17, 5)]);
|
|
let pick = cases(&doc, "selections").iter().find(|c| c["current"] == "720p-long" && c["to"] == "1080p-short" && c["policy"] == "full").expect("§6.4's selection");
|
|
assert_eq!(pick["chosen"], "1080p-medium");
|
|
let plans = cases(&doc, "plans");
|
|
let first = plans.iter().find(|c| c["catalog"] == "section-7").expect("§7's first example");
|
|
assert_eq!((first["frame"].as_u64(), first["switch_frame"].as_u64(), first["end_group"].as_u64()), (Some(9), Some(13), Some(5)));
|
|
let second = plans.iter().find(|c| c["current"] == "720p-long" && c["target"] == "1080p-medium").expect("§7's second example");
|
|
assert_eq!((second["target_group"].as_u64(), second["end_group"].as_u64()), (Some(15), Some(13)));
|
|
assert_eq!(second["subscribe"], json!({"filter_type": 2, "start": [15, 0]}));
|
|
assert_eq!(second["update"], json!({"filter_type": 3, "start": [5, 0], "end_group_delta": 8}));
|
|
// Every handoff presents consecutive Frame Numbers: no gap and no frame twice.
|
|
for h in cases(&doc, "handoffs") {
|
|
let frames: Vec<u64> = h["released"].as_array().expect("released").iter().map(|r| r[3].as_u64().expect("a frame")).collect();
|
|
assert!(frames.len() > 4 && frames.windows(2).all(|w| w[1] == w[0] + 1), "{}", h["what"]);
|
|
}
|
|
}
|
|
|
|
/// §6's Table 4, and §7.3's Table 6, as the draft prints them.
|
|
#[test]
|
|
fn the_probe_corpus_holds_the_drafts_tables() {
|
|
let (_, doc) = committed("probe");
|
|
let probe = |name: &str| cases(&doc, "probes").iter().find(|c| c["name"] == name).unwrap_or_else(|| panic!("{name}")).clone();
|
|
let paced = probe("d2000-r300000");
|
|
assert_eq!((&paced["shape"], &paced["f"], &paced["s"], &paced["objects"], &paced["b"], &paced["groups"]), (&json!("paced"), &json!([50, 1]), &json!(750), &json!(100), &json!(75_000), &json!(1)));
|
|
let greedy = probe("b500000");
|
|
assert_eq!((&greedy["shape"], &greedy["s"], &greedy["objects"], &greedy["groups"], &greedy["bytes"]), (&json!("greedy"), &json!(16_384), &json!(31), &json!(1), &json!(500_000)));
|
|
assert_eq!(greedy["sample"][2], json!({"index": 30, "group": 0, "object": 30, "len": 8480, "offset_us": null}));
|
|
let video = probe("d2000-f30-g30-k5-r3000000");
|
|
assert_eq!((&video["s"], &video["key_object_bytes"], &video["objects"], &video["groups"]), (&json!(11_029), &json!(55_145), &json!(60), &json!(2)));
|
|
let row = |outcome: &str| cases(&doc, "errors").iter().find(|r| r["outcome"] == outcome).unwrap_or_else(|| panic!("{outcome}")).clone();
|
|
let codes = |outcome: &str| (row(outcome)["downlink"]["name"].as_str().map(str::to_string), row(outcome)["uplink"]["name"].as_str().map(str::to_string));
|
|
let named = |d: &str, u: &str| (Some(d.to_string()), Some(u.to_string()));
|
|
assert_eq!(codes("invalid"), named("DOES_NOT_EXIST", "UNINTERESTED"));
|
|
assert_eq!(codes("forward_state"), (None, Some("UNINTERESTED".to_string())));
|
|
assert_eq!(codes("per_probe_limit"), named("DOES_NOT_EXIST", "UNINTERESTED"));
|
|
assert_eq!(codes("load_limit"), named("EXCESSIVE_LOAD", "EXCESSIVE_LOAD"));
|
|
assert_eq!(codes("unauthorized"), named("UNAUTHORIZED", "UNAUTHORIZED"));
|
|
assert_eq!(codes("other_request"), named("NOT_SUPPORTED", "NOT_SUPPORTED"));
|
|
// Table 5.
|
|
assert_eq!(doc["limits"]["max_duration_ms"], 5000);
|
|
assert_eq!(doc["limits"]["max_bytes"], 10_000_000);
|
|
assert_eq!(doc["limits"]["max_rate_bps"], 50_000_000);
|
|
assert_eq!(doc["limits"]["max_object_bytes"], 1_048_576);
|
|
assert_eq!((&doc["limits"]["session_concurrent"], &doc["limits"]["min_gap_ms"]), (&json!(1), &json!(1000)));
|
|
assert_eq!((&doc["limits"]["session_bytes"], &doc["limits"]["session_window_ms"]), (&json!(50_000_000), &json!(60_000)));
|
|
// Every object of every schedule is at the lowest priority with one property, the Timestamp.
|
|
for s in cases(&doc, "schedules") {
|
|
for o in s["sent"].as_array().expect("sent") {
|
|
assert_eq!(o["priority"], 255);
|
|
assert!(o["properties"].as_str().is_some_and(|p| p.starts_with("10")), "{}", s["what"]);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The planted faults: a corpus one value away from the committed one is caught, whichever
|
|
/// side of a case the value is on.
|
|
#[test]
|
|
fn a_changed_corpus_is_caught() {
|
|
type Edit = (&'static str, fn(&mut Value));
|
|
let (_, doc) = committed("switching");
|
|
let edits: [Edit; 8] = [
|
|
("a Frame Number", |d| d["frames"][0]["frame"] = json!(27)),
|
|
("a target group", |d| d["switch_points"][2]["target_group"] = json!(15)),
|
|
("the track chosen", |d| d["selections"][0]["chosen"] = json!("1080p-short")),
|
|
("an End Group", |d| d["plans"][0]["end_group"] = json!(9)),
|
|
("a frame presented twice", |d| {
|
|
let again = d["handoffs"][0]["released"][3].clone();
|
|
d["handoffs"][0]["released"].as_array_mut().expect("released").insert(4, again);
|
|
}),
|
|
("a spacing made a multiple", |d| d["faults"][0]["catalog"]["tracks"][1]["keyframeSpacing"] = json!(8)),
|
|
("a mid-group start moved to its Group's first object", |d| {
|
|
let at = d["faults"].as_array().expect("faults").iter().position(|f| f["reason"] == "switch_start_mid_group").expect("the fault");
|
|
d["faults"][at]["at"][1] = json!(0);
|
|
}),
|
|
("one reason for another", |d| {
|
|
let at = d["faults"].as_array().expect("faults").iter().position(|f| f["reason"] == "switch_start_not_switch_point").expect("the fault");
|
|
d["faults"][at]["reason"] = json!("switch_start_mid_group");
|
|
}),
|
|
];
|
|
for (what, edit) in edits {
|
|
let mut changed = doc.clone();
|
|
edit(&mut changed);
|
|
assert!(!switching::corpus::verify(&changed).is_empty(), "a change went unnoticed: {what}");
|
|
}
|
|
let (_, doc) = committed("probe");
|
|
let edits: [Edit; 8] = [
|
|
("an object size", |d| d["probes"][0]["s"] = json!(751)),
|
|
("an object count", |d| d["probes"][1]["objects"] = json!(30)),
|
|
("the cut object's length", |d| d["probes"][1]["sample"][2]["len"] = json!(16_384)),
|
|
("a payload that is not zeros", |d| d["schedules"][0]["sent"][1]["payload_sha256"] = json!("00")),
|
|
("an error code", |d| d["errors"][0]["downlink"]["code"] = json!(0x20)),
|
|
("a limit in the catalog", |d| d["catalogs"][0]["catalog"]["maxRate"] = json!(1)),
|
|
("a refused request made acceptable", |d| {
|
|
let at = d["faults"].as_array().expect("faults").iter().position(|f| f["reason"] == "probe_duration_over_limit").expect("the fault");
|
|
d["faults"][at]["name"] = json!("d5000-r300000");
|
|
}),
|
|
("a Retry Interval", |d| {
|
|
let at = d["faults"].as_array().expect("faults").iter().position(|f| f["reason"] == "probe_session_gap").expect("the fault");
|
|
d["faults"][at]["retry_interval"] = json!(0);
|
|
}),
|
|
];
|
|
for (what, edit) in edits {
|
|
let mut changed = doc.clone();
|
|
edit(&mut changed);
|
|
assert!(!probe::corpus::verify(&changed).is_empty(), "a change went unnoticed: {what}");
|
|
}
|
|
}
|