On a bearer with no return path a receiver cannot ask for what it missed: it holds an object only if the passes it heard carried enough encoding symbols. bearer-receipts declares the transmission and reconciles it afterwards; nothing stated the plan in between. extensions/carousel.md registers `wai.bearer.carousel` and the jwp-receipts claim class `schedule` (§2.4.16): a publisher's signed plan, beside the group receipt and the instance's transmission claim, naming for each object its forward error correction (RaptorQ, RFC 6330 with its verified erratum 5548; Compact No-Code, RFC 5445) and that code's object transmission information, the repair symbols each pass adds, whether later passes repeat their ESIs or send new ones, and the passes' times and count. Step 4 holds each code's parameters to its bounds and division into source blocks; step 6 holds the entries to the group, its objects' content hashes and, where the bytes are held, their lengths. From it a verifier derives, for a loss bound in parts per million, the pass after which a receiver whose losses stay within it holds each object, for every pattern of loss within the bound: a RaptorQ block at K' + h distinct symbols, the count RFC 6330 §5.8 states its decoding failure bound at, and a symbol a repeating pass missed counted as missed again. It checks a statement that an object or a group is held by an instant, sets aside schedules that contradict each other or whose instance is undeclared or two-way, and a receiver derives the passes it must listen to and how long its radio must be on. The reference is the `carousel` module of wai-rs with the class's body in `claim_receipt::schedule`; the corpus is live-conformance/carousel/ (33 verdicts, 50 claims, 16 plans) with carousel_verify.py as its second implementation and claim_verify.py's new `carousel` mode for steps 1-4. tests/carousel.rs checks K' and Partition[] against an independent RFC 6330 implementation (a dev-dependency) for every block size, and decodes each RaptorQ object a case holds available from what a receiver within the bound holds. A receipt index served over HTTP now checks a schedule claim against its group and listed objects. Co-Authored-By: Claude Opus 5.5 <[email protected]>
177 lines
8.5 KiB
Rust
177 lines
8.5 KiB
Rust
//! `wai.bearer.carousel` (extensions/carousel.md): the committed corpus `live-conformance/carousel/`
|
||
//! regenerates byte for byte from its fixed seeds, holds no other file, every case in it gets the
|
||
//! outcome it states, §9 lists exactly the reasons the reference reports, jwp-receipts §3.1 lists
|
||
//! the class codes at their steps, and Appendix A is the reference's table of K′. Against an
|
||
//! independent implementation of RFC 6330: the table of K′ and the partition agree for every block
|
||
//! size, and each RaptorQ object of the corpus that a case holds available at a pass decodes from
|
||
//! what a receiver within its loss bound holds by that pass's end.
|
||
#![cfg(feature = "carousel")]
|
||
|
||
use std::collections::BTreeMap;
|
||
use std::path::PathBuf;
|
||
|
||
use raptorq::{Decoder, Encoder, EncodingPacket, ObjectTransmissionInformation};
|
||
use serde_json::Value;
|
||
use wai::carousel::{corpus, k_prime, missed, CODES, K_PRIME};
|
||
use wai::claim_receipt::schedule::{partition, K_PRIME_MAX};
|
||
use wai::claim_receipt::{Esi, Oti, ScheduleEntry};
|
||
|
||
fn wai_dir() -> PathBuf {
|
||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||
}
|
||
|
||
fn committed() -> BTreeMap<String, String> {
|
||
let dir = wai_dir().join("live-conformance/carousel");
|
||
std::fs::read_dir(&dir)
|
||
.expect("the committed corpus")
|
||
.flatten()
|
||
.map(|e| (e.file_name().to_string_lossy().into_owned(), e.path()))
|
||
.filter(|(n, _)| n != "README.md")
|
||
.map(|(n, p)| (n, std::fs::read_to_string(p).expect("a text file")))
|
||
.collect()
|
||
}
|
||
|
||
fn extension() -> String {
|
||
std::fs::read_to_string(wai_dir().join("extensions/carousel.md")).expect("the extension")
|
||
}
|
||
|
||
#[test]
|
||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||
let files = committed();
|
||
let generated: BTreeMap<String, String> = corpus::generate().into_iter().collect();
|
||
let extra: Vec<&String> = files.keys().filter(|k| !generated.contains_key(*k)).collect();
|
||
let missing: Vec<&String> = generated.keys().filter(|k| !files.contains_key(*k)).collect();
|
||
let differ: Vec<&String> = generated.iter().filter(|(k, v)| files.get(*k).is_some_and(|f| f != *v)).map(|(k, _)| k).collect();
|
||
assert!(extra.is_empty() && missing.is_empty() && differ.is_empty(), "extra {extra:?}, missing {missing:?}, differ {differ:?}: run `wai_carousel_vectors gen`");
|
||
}
|
||
|
||
#[test]
|
||
fn every_case_gets_its_stated_outcome() {
|
||
let fails = corpus::verify(&committed());
|
||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||
}
|
||
|
||
/// §9 of the extension lists exactly the codes `CODES` has, each once, in order.
|
||
#[test]
|
||
fn the_extension_lists_the_reasons() {
|
||
let text = extension();
|
||
let at = text.find("\n## 9. Reasons").expect("a §9");
|
||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||
let listed: Vec<String> =
|
||
text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
||
let want: Vec<String> = CODES.iter().map(|c| c.to_string()).collect();
|
||
assert_eq!(listed, want, "§9's table and CODES differ");
|
||
}
|
||
|
||
/// jwp-receipts §3.1's table lists the class's codes: its rules at step 4, its checks against the
|
||
/// group and its objects at step 6.
|
||
#[test]
|
||
fn jwp_receipts_lists_the_class_codes() {
|
||
let text = std::fs::read_to_string(wai_dir().join("extensions/jwp-receipts.md")).expect("jwp-receipts");
|
||
for code in CODES.iter().filter(|c| c.starts_with("schedule_") && **c != "schedule_contradicted" && **c != "schedule_on_bidirectional") {
|
||
let step = if ["schedule_not_scheduled", "schedule_objects_malformed", "schedule_oti_invalid", "schedule_symbols_invalid", "schedule_passes_invalid"].contains(code) {
|
||
4
|
||
} else {
|
||
6
|
||
};
|
||
assert!(text.contains(&format!("| {step} | `{code}` |")), "jwp-receipts §3.1 does not list {code} at step {step}");
|
||
}
|
||
}
|
||
|
||
/// Appendix A holds exactly `K_PRIME`, in order.
|
||
#[test]
|
||
fn appendix_a_is_the_table_of_k_prime() {
|
||
let text = extension();
|
||
let a = text.find("<!-- k-prime:begin -->").expect("the table's start");
|
||
let b = text.find("<!-- k-prime:end -->").expect("the table's end");
|
||
let listed: Vec<u32> = text[a..b].split(|c: char| !c.is_ascii_digit()).filter(|s| !s.is_empty()).map(|s| s.parse().expect("a number")).collect();
|
||
assert_eq!(listed, K_PRIME.to_vec());
|
||
}
|
||
|
||
/// The table and the partition agree with an independent implementation of RFC 6330 for every
|
||
/// block size it admits.
|
||
#[test]
|
||
fn k_prime_and_partition_agree_with_an_independent_rfc_6330() {
|
||
for k in 1..=K_PRIME_MAX as u32 {
|
||
assert_eq!(k_prime(u64::from(k)), Some(u64::from(raptorq::extended_source_block_symbols(k))), "K' of {k}");
|
||
}
|
||
for (i, j) in [(50, 3), (14, 1), (5, 1), (56_403 * 255, 255), (16, 2), (15, 2), (1, 1)] {
|
||
let (il, is, jl, js) = partition(i, j);
|
||
assert_eq!((il as u32, is as u32, jl as u32, js as u32), raptorq::partition(i as u32, j as u32), "Partition[{i}, {j}]");
|
||
}
|
||
}
|
||
|
||
fn hex(s: &str) -> Vec<u8> {
|
||
(0..s.len()).step_by(2).map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("hex")).collect()
|
||
}
|
||
|
||
/// What a receiver within `loss_ppm` holds of `e`'s object by the end of pass `pass`, fed to an
|
||
/// independent RFC 6330 decoder until it decodes: in each pass, of each block's m symbols, the
|
||
/// first ⌊L · m / 10^6⌋ in ESI order are lost (in the first pass, source symbols, so the
|
||
/// decoder must decode). Under `repeat` a later pass is taken to lose the same symbols again,
|
||
/// so only the first is fed.
|
||
fn receive(e: &ScheduleEntry, data: &[u8], loss_ppm: u64, pass: u64) -> Option<Vec<u8>> {
|
||
let Oti::RaptorQ { transfer_length, symbol_size, source_blocks, sub_blocks, alignment } = e.oti else { return None };
|
||
let config = ObjectTransmissionInformation::new(transfer_length, symbol_size as u16, source_blocks as u8, sub_blocks as u16, alignment as u8);
|
||
let encoder = Encoder::new(data, config);
|
||
let mut decoder = Decoder::new(config);
|
||
let passes = if e.esi == Esi::Advance { pass } else { 1 };
|
||
for block in encoder.get_block_encoders() {
|
||
let source = block.source_packets();
|
||
let k = source.len() as u64;
|
||
let m = k + e.repair;
|
||
let x = missed(loss_ppm, m) as usize;
|
||
for j in 1..=passes {
|
||
let start = if e.esi == Esi::Advance { (j - 1) * m } else { 0 };
|
||
let mut sent: Vec<EncodingPacket> = (start..(start + m).min(k)).map(|esi| source[esi as usize].clone()).collect();
|
||
let repair_from = start.max(k);
|
||
if start + m > repair_from {
|
||
sent.extend(block.repair_packets((repair_from - k) as u32, (start + m - repair_from) as u32));
|
||
}
|
||
assert_eq!(sent.len() as u64, m);
|
||
for p in sent.into_iter().skip(x) {
|
||
if let Some(out) = decoder.decode(p) {
|
||
return Some(out);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
#[test]
|
||
fn each_raptorq_object_decodes_from_what_a_receiver_within_its_bound_holds() {
|
||
let files = committed();
|
||
let doc: Value = serde_json::from_str(&files["vectors.json"]).expect("the corpus");
|
||
let mut decoded = 0;
|
||
for c in doc["verdicts"].as_array().expect("verdict cases") {
|
||
let Some(lines) = c["outcome"]["lines"].as_array() else { continue };
|
||
let loss = c["loss_ppm"].as_u64().expect("a loss bound");
|
||
let schedules: Vec<Value> = c["claims"]
|
||
.as_array()
|
||
.expect("claims")
|
||
.iter()
|
||
.map(|w| serde_json::from_str::<Value>(w.as_str().expect("a claim")).expect("json"))
|
||
.filter(|v| v["class"] == "schedule")
|
||
.collect();
|
||
for line in lines {
|
||
let Some(pass) = line["pass"].as_u64() else { continue };
|
||
let leaf = line["leaf_index"].as_u64().expect("a leaf") as usize;
|
||
let Some(data) = c["objects"][leaf]["envelope"].as_str().map(hex) else { continue };
|
||
let entry = schedules
|
||
.iter()
|
||
.filter(|s| s["body"]["bearer_id"] == line["bearer_id"])
|
||
.flat_map(|s| s["body"]["objects"].as_array().expect("entries").iter())
|
||
.find(|o| o["leaf_index"] == line["leaf_index"])
|
||
.map(|o| ScheduleEntry::from_value(o).expect("an entry that stands"))
|
||
.expect("the entry of a line");
|
||
if !matches!(entry.oti, Oti::RaptorQ { .. }) {
|
||
continue;
|
||
}
|
||
let got = receive(&entry, &data, loss, pass);
|
||
assert_eq!(got.as_deref(), Some(&data[..]), "{} object {leaf} on {} by pass {pass}", c["name"], line["bearer_id"]);
|
||
decoded += 1;
|
||
}
|
||
}
|
||
assert!(decoded >= 10, "only {decoded} objects decoded");
|
||
}
|