diff --git a/.github/workflows/joule-code-conformance.yml b/.github/workflows/joule-code-conformance.yml index ea684ae56..11aba64d2 100644 --- a/.github/workflows/joule-code-conformance.yml +++ b/.github/workflows/joule-code-conformance.yml @@ -33,6 +33,9 @@ on: # jwp-conformance corpus, and run every leaf, group and object vector in it: # a change there must re-run them. - 'wai/jwp-conformance/**' + # joule-moq's tests/staged_moq_trace.rs runs the MoQ trace of + # wai/staged-conformance/moq/ over the envelopes of one of its cases. + - 'wai/staged-conformance/**' - '.github/workflows/joule-code-conformance.yml' push: branches: @@ -43,6 +46,9 @@ on: # jwp-conformance corpus, and run every leaf, group and object vector in it: # a change there must re-run them. - 'wai/jwp-conformance/**' + # joule-moq's tests/staged_moq_trace.rs runs the MoQ trace of + # wai/staged-conformance/moq/ over the envelopes of one of its cases. + - 'wai/staged-conformance/**' - '.github/workflows/joule-code-conformance.yml' jobs: @@ -105,8 +111,10 @@ jobs: set -e out="$(cargo run -q -p joule-code-conformance -- verify ../conformance/v1)" echo "$out" - # The pack must pass with zero failures. - echo "$out" | grep -Eq '0 failed' || { echo "::error::conformance pack reported failures"; exit 1; } + # The pack must pass with zero failures. The summary line reads + # "N passed, F failed, S skipped"; anchor on it, so that "10 failed" can never + # read as "0 failed", and require at least one pass, so an empty pack fails. + echo "$out" | grep -Eq '^[1-9][0-9]* passed, 0 failed, ' || { echo "::error::conformance pack reported failures"; exit 1; } - name: L3 JouleClaw binding example builds + tests working-directory: joule-code/examples/l3-jouleclaw diff --git a/.github/workflows/wai-conformance.yml b/.github/workflows/wai-conformance.yml index 1c3397df4..e45d98694 100644 --- a/.github/workflows/wai-conformance.yml +++ b/.github/workflows/wai-conformance.yml @@ -57,7 +57,8 @@ on: # that this extension's §4.2 and A.6 list the codes the sink reports. - 'wai/extensions/staged-delivery.md' # A library test (codecs::registry::tests::wire_identifiers_are_in_their_documents) - # checks every wire identifier against the section of these texts it names. + # checks every wire identifier against the section of these texts it names, and + # staged::moq::tests::moq_streaming_format_lists_the_codes checks §4.1's codes. - 'wai/extensions/http-delivery.md' - 'wai/extensions/moq-streaming-format.md' - 'wai/extensions/jwp-receipts.md' @@ -87,7 +88,8 @@ on: # that this extension's §4.2 and A.6 list the codes the sink reports. - 'wai/extensions/staged-delivery.md' # A library test (codecs::registry::tests::wire_identifiers_are_in_their_documents) - # checks every wire identifier against the section of these texts it names. + # checks every wire identifier against the section of these texts it names, and + # staged::moq::tests::moq_streaming_format_lists_the_codes checks §4.1's codes. - 'wai/extensions/http-delivery.md' - 'wai/extensions/moq-streaming-format.md' - 'wai/extensions/jwp-receipts.md' @@ -277,6 +279,15 @@ jobs: - name: The wai http commands working-directory: wai/wai-rs run: cargo test --no-default-features --features http --test cli_http + # tests/staged_moq.rs runs the MoQ trace of staged-conformance/moq/ (moq-streaming-format + # §4.1) with signed receipts: a chain for each track, a receipt held for its parent and + # admitted when it arrives, the relay's per-hop delivery timeout, dropped objects checked + # by inclusion and named by the relay's delivery-gap claims, and each base's stage claim, + # which seals and verifies; the subscriber of two tracks holds exactly their chains. + # Cargo.toml declares it with required-features = ["staged", "video_int", "receipt_claims"]. + - name: Staged tracks over MoQ, with signed receipts + working-directory: wai/wai-rs + run: cargo test --no-default-features --features staged,video_int,receipt_claims --test staged_moq # The machine-readable registry (wai/registry/, wai/vocab/v2.*, the table in # wai/REGISTRY.md) is generated from wai-rs/src/codecs/registry.rs. Regenerate it in # memory and fail on any difference; the message says how to regenerate. One leg: the diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs index 8765279b6..c7cc76984 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/lib.rs @@ -16,7 +16,11 @@ //! - Endpoint trait surface ([`endpoint`]) //! - **JWP receipt extension** — batched per-group signing with Merkle inclusion //! proofs per object, identity-bound publication, joule attribution ([`receipt`]) -//! - In-memory mock for testing without QUIC ([`mock`]) +//! - In-memory mock for testing without QUIC ([`mock`]), whose relay also forwards +//! staged tracks on a caller-driven clock: per-hop delivery timeouts, capping by +//! subscription, and the receipts of dropped objects forwarded all the same +//! - Staged tracks, one per layer of a WAI stage set ([`staged`]), and receipt chains +//! kept per publisher key and track ([`ReceiptChains`]) //! //! ## What this crate intentionally does not ship (yet) //! @@ -37,8 +41,7 @@ //! //! Every endpoint role in this crate produces or consumes JWP-signed receipts //! by construction. There is no "raw" object path that bypasses receipt -//! generation. This is the load-bearing differentiator vs other MoQ -//! implementations. +//! generation. pub mod endpoint; pub mod error; @@ -46,6 +49,7 @@ pub mod messages; pub mod mock; pub mod receipt; pub mod session; +pub mod staged; pub mod track; pub mod wai_dispatch; pub mod wire; @@ -62,8 +66,9 @@ pub use messages::{ SubscribeMessage, SubscribeOk, }; pub use receipt::{ - compose_group_class, GroupReceipt, ObjectReceipt, ReceiptVerifier, ReceiptBuilder, - MerkleProof, VerifiedGroup, + compose_group_class, Admission, Anchoring, BoundRefused, ChainEvent, ChainRefusal, GroupReceipt, HoldBound, ObjectReceipt, + ReceiptChains, ReceiptVerifier, ReceiptBuilder, ReceiptsTrack, MerkleProof, VerifiedGroup, CHAIN_HELD_LINKS, CHAIN_OPEN_GAPS, + MAX_HELD_GROUPS, }; pub use session::{MoqSession, SessionRole, SessionState}; pub use track::{ diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs b/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs index ccc19ff81..05a86db40 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/messages.rs @@ -84,6 +84,12 @@ pub struct SubscribeMessage { /// the subscriber accepts every layer the publisher emits. Always /// `None` for non-MRL tracks; older subscribers that don't include /// this field on the wire are decoded as `None`. + /// + /// **Superseded for tracks of packaging `wai`** (WAI staged-delivery §13, + /// moq-streaming-format §4.1): a WAI stage set carries each layer on a track of + /// its own, and a subscriber caps its stage by the layer tracks it subscribes to; a + /// relay reads no envelope and no header to cap layers. The field keeps its wire + /// form for the tracks that still use it. #[serde(default)] pub max_mrl_layer: Option, } @@ -123,6 +129,13 @@ pub struct SubscribeOk { /// For tracks that do not carry MRL-structured content, both fields /// default to `0`. Existing receipts and wire-format consumers are /// unaffected by this addition (`#[serde(default)]`). +/// +/// **Superseded for objects that are WAI envelopes** (WAI staged-delivery §13, +/// moq-streaming-format §4.1). A WAI layer is its own `WAI1` envelope, carried on a +/// track for its layer index, and its index is the envelope's `refines.layer`, which +/// sits inside the envelope and so inside its content hash. A sink reads the layer from +/// there and ignores `mrl_layer`; [`crate::staged::check_mrl_agrees`] only compares the +/// two. The fields keep their wire form. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ObjectHeader { pub track_alias: TrackAlias, @@ -135,6 +148,7 @@ pub struct ObjectHeader { pub payload_len: u64, /// Matryoshka layer index for layered-codec tracks. `0` = coarsest /// base layer; higher values are refinements. `0` for non-MRL tracks. + /// Not read for a WAI envelope with `refines` (see the type's documentation). #[serde(default)] pub mrl_layer: u8, /// Total number of layers in this group's matryoshka stream. `0` for diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs index 2b729cdac..53dfc27ad 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/mock.rs @@ -6,18 +6,27 @@ //! //! Receipts produced here are *real* — Ed25519-signed group roots, BLAKE3 //! Merkle trees, end-to-end verifiable. Only the transport is mocked. +//! +//! [`MockRelay`] also forwards staged tracks (WAI `extensions/moq-streaming-format.md` +//! §4.1) on a clock its caller drives: a delivery timeout per track or per object, +//! applied at the relay's own hop from the moment an object reaches it (MOQT §5.2); a +//! simulated time each object waits at the relay; each subscriber's stage capped by the +//! tracks it subscribes to, never by reading an envelope; one object to a group on a live +//! staged track; and the receipts track, whose bundles reach every subscriber that asked +//! for receipts whatever became of the objects they cover. [`RelayStats`] counts what it +//! forwarded and dropped. Nothing sleeps: time is what the caller says it is. use crate::endpoint::{ DeliveredObject, GroupHandle, MoqEndpoint, Publisher, Relay, Subscriber, SubscriptionHandle, }; -use crate::error::MoqResult; +use crate::error::{MoqError, MoqResult}; use crate::messages::ObjectHeader; use crate::receipt::{GroupReceipt, ObjectReceipt, ReceiptBuilder}; use crate::track::{GroupId, ObjectId, ObjectStatus, Track, TrackAlias, TrackName, TrackNamespace}; use async_trait::async_trait; use bytes::Bytes; use ed25519_dalek::SigningKey; -use std::collections::HashMap; +use std::collections::{BTreeSet, HashMap}; use std::sync::{ atomic::{AtomicU64, Ordering}, Arc, Mutex, @@ -32,7 +41,7 @@ pub struct MockFabric { next_alias: AtomicU64, } -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] struct TrackKey { namespace: TrackNamespace, track_name: TrackName, @@ -298,6 +307,117 @@ pub struct MockRelay { pub fabric: Arc, joules_consumed: AtomicU64, next_handle: AtomicU64, + staged: Mutex, +} + +/// How the relay treats one track it forwards. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RelayTrackConfig { + /// The track's `OBJECT_DELIVERY_TIMEOUT` as its publisher set it (a track property, + /// MOQT §10.2), in milliseconds; 0 is none. An object may carry its own + /// ([`MockRelay::ingest`]), which MOQT reads on a subgroup's first object. + pub delivery_timeout_ms: u64, + /// A live staged track (moq-streaming-format §4.1): each group carries exactly one + /// object. + pub staged_live: bool, + /// The track's receipts track, in its namespace (`wai_receipts_track`): the relay + /// forwards that track as it forwards any, and uses the association only to count + /// which dropped objects' receipts it forwarded ([`RelayStats`]). + pub receipts_track: Option, +} + +/// What the relay forwarded and dropped, counted per subscriber delivery. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RelayStats { + /// Objects sent to a subscriber. + pub forwarded: u64, + /// Objects not sent to a subscriber because their delivery timeout passed at this hop. + pub dropped_on_timeout: u64, + /// Of those, the ones whose receipts the relay sent that subscriber, on their track's + /// receipts track (WAI jwp-receipts §4.1), counted when the receipts were sent and + /// keyed by the object's track, group and object id. + pub receipts_forwarded_for_dropped: u64, +} + +/// One group's receipts, as one object of a receipts track carries them: the group +/// receipt and the object receipts of one group of the receipts track's media track. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ReceiptBundle { + pub group: GroupReceipt, + pub objects: Vec, +} + +/// Something the relay sent, and when, on the caller's clock. +#[derive(Clone, Debug)] +pub struct Sent { + pub item: T, + pub sent_ms: u64, +} + +/// A subscriber of staged tracks at the relay: the objects of the media tracks it +/// subscribed to, and the receipts of the receipts tracks it subscribed to. +pub struct StagedSubscription { + pub objects: mpsc::UnboundedReceiver>, + pub receipts: Option>>, +} + +/// An object of a media track: its track, group and object id. +type ObjectKey = (TrackKey, GroupId, ObjectId); + +struct StagedSub { + tracks: BTreeSet, + receipts_tracks: BTreeSet, + /// Its own `OBJECT_DELIVERY_TIMEOUT` (MOQT §9.20.4); 0 none. + timeout_ms: u64, + objects: mpsc::UnboundedSender>, + receipts: Option>>, + /// The objects dropped for it, the objects whose receipts it was sent, and the + /// dropped objects whose receipts it was sent (each counted once). + dropped: BTreeSet, + receipted: BTreeSet, + counted: BTreeSet, +} + +impl StagedSub { + /// Count a dropped object whose receipts reached this subscriber, once. + fn count(&mut self, key: &ObjectKey) -> u64 { + if self.dropped.contains(key) && self.receipted.contains(key) && self.counted.insert(key.clone()) { + 1 + } else { + 0 + } + } +} + +struct Queued { + key: TrackKey, + object: DeliveredObject, + received_ms: u64, + send_ms: u64, + timeout_ms: Option, + seq: u64, +} + +#[derive(Default)] +struct Staged { + tracks: HashMap, + subs: Vec, + queue: Vec, + groups: BTreeSet<(TrackKey, GroupId)>, + forwarded: u64, + dropped_on_timeout: u64, + receipts_forwarded_for_dropped: u64, + seq: u64, +} + +/// The timeout MOQT applies (§5.2): the smaller of the publisher's and the +/// subscriber's where both are set, either where one is; 0 none. +fn effective_timeout(publisher_ms: u64, subscriber_ms: u64) -> u64 { + match (publisher_ms, subscriber_ms) { + (0, s) => s, + (p, 0) => p, + (p, s) => p.min(s), + } } impl MockRelay { @@ -306,6 +426,150 @@ impl MockRelay { fabric, joules_consumed: AtomicU64::new(0), next_handle: AtomicU64::new(0), + staged: Mutex::new(Staged::default()), + } + } + + /// Set how the relay treats a staged track. + pub fn configure_track(&self, namespace: &TrackNamespace, track_name: &TrackName, cfg: RelayTrackConfig) { + let key = TrackKey { namespace: namespace.clone(), track_name: track_name.clone() }; + self.staged.lock().unwrap().tracks.insert(key, cfg); + } + + /// A subscriber of the media `tracks` and of the `receipts_tracks`, with its own + /// delivery timeout for the media tracks (0 none). Its stage, and the receipts it + /// receives, are capped by the tracks it names here: the relay sends it nothing of + /// another track, and reads no envelope. + pub fn subscribe_tracks( + &self, + tracks: &[(TrackNamespace, TrackName)], + delivery_timeout_ms: u64, + receipts_tracks: &[(TrackNamespace, TrackName)], + ) -> StagedSubscription { + let (otx, orx) = mpsc::unbounded_channel(); + let (rtx, rrx) = if receipts_tracks.is_empty() { + (None, None) + } else { + let (t, r) = mpsc::unbounded_channel(); + (Some(t), Some(r)) + }; + let key = |(n, t): &(TrackNamespace, TrackName)| TrackKey { namespace: n.clone(), track_name: t.clone() }; + self.staged.lock().unwrap().subs.push(StagedSub { + tracks: tracks.iter().map(key).collect(), + receipts_tracks: receipts_tracks.iter().map(key).collect(), + timeout_ms: delivery_timeout_ms, + objects: otx, + receipts: rtx, + dropped: BTreeSet::new(), + receipted: BTreeSet::new(), + counted: BTreeSet::new(), + }); + StagedSubscription { objects: orx, receipts: rrx } + } + + /// An object of a staged track reaches the relay at `received_ms` (its last header + /// byte, MOQT §5.2), and will wait `queue_ms` before the relay passes it on. Its own + /// delivery timeout, where it carries one, replaces the track's. On a live staged + /// track a group carries one object: a second object of a group, or an object other + /// than the first, is refused. A send time past the end of the caller's clock is + /// refused too: it would be charged a wait it never had. + pub fn ingest( + &self, + namespace: &TrackNamespace, + track_name: &TrackName, + object: DeliveredObject, + received_ms: u64, + queue_ms: u64, + object_timeout_ms: Option, + ) -> MoqResult<()> { + let key = TrackKey { namespace: namespace.clone(), track_name: track_name.clone() }; + let send_ms = received_ms + .checked_add(queue_ms) + .ok_or_else(|| MoqError::InvalidSessionState(format!("a send time past the clock ({received_ms} + {queue_ms})")))?; + let mut st = self.staged.lock().unwrap(); + let staged_live = st.tracks.get(&key).is_some_and(|c| c.staged_live); + if staged_live && (object.header.object_id != ObjectId(0) || !st.groups.insert((key.clone(), object.header.group_id))) { + return Err(MoqError::InvalidSessionState(format!( + "a live staged track carries one object to a group (group {}, object {})", + object.header.group_id.0, object.header.object_id.0 + ))); + } + st.seq += 1; + let seq = st.seq; + st.queue.push(Queued { key, object, received_ms, send_ms, timeout_ms: object_timeout_ms, seq }); + Ok(()) + } + + /// One object of a receipts track reaches the relay at `received_ms`. A receipts + /// track has no delivery timeout (moq-streaming-format §4.1), and the relay reads + /// nothing in it to forward it: it goes at once to every subscriber of that receipts + /// track, whatever became of the objects it covers. + pub fn ingest_receipts(&self, namespace: &TrackNamespace, receipts_track: &TrackName, bundle: ReceiptBundle, received_ms: u64) { + let rkey = TrackKey { namespace: namespace.clone(), track_name: receipts_track.clone() }; + let mut st = self.staged.lock().unwrap(); + // For counting only: the media track whose receipts track this is, and the keys of + // the objects the bundle covers. + let media = st.tracks.iter().find(|(k, c)| k.namespace == rkey.namespace && c.receipts_track.as_ref() == Some(&rkey.track_name)).map(|(k, _)| k.clone()); + let keys: Vec = match &media { + Some(m) => bundle.objects.iter().map(|o| (m.clone(), o.group_id, o.object_id)).collect(), + None => vec![], + }; + let mut counted = 0; + for sub in st.subs.iter_mut().filter(|s| s.receipts_tracks.contains(&rkey)) { + if let Some(tx) = &sub.receipts { + let _ = tx.send(Sent { item: bundle.clone(), sent_ms: received_ms }); + for k in &keys { + sub.receipted.insert(k.clone()); + counted += sub.count(k); + } + } + } + st.receipts_forwarded_for_dropped += counted; + } + + /// Pass on every queued object due by `now_ms`, in the order they fall due. For each + /// subscriber of its track, the delivery timeout in force (the object's or the + /// track's, and the subscriber's, the smaller, MOQT §5.2) is checked against the time + /// the object spent at this hop: past it, the relay does not send it to that + /// subscriber. The timer starts at this hop, so an object that waited upstream is not + /// charged for it here. + pub fn advance(&self, now_ms: u64) { + let mut st = self.staged.lock().unwrap(); + let mut due: Vec = Vec::new(); + let mut keep = Vec::new(); + for q in st.queue.drain(..) { + if q.send_ms <= now_ms { due.push(q) } else { keep.push(q) } + } + st.queue = keep; + due.sort_by_key(|q| (q.send_ms, q.seq)); + for q in due { + let publisher = q.timeout_ms.unwrap_or_else(|| st.tracks.get(&q.key).map_or(0, |c| c.delivery_timeout_ms)); + let key: ObjectKey = (q.key.clone(), q.object.header.group_id, q.object.header.object_id); + let (mut forwarded, mut dropped, mut counted) = (0, 0, 0); + for sub in st.subs.iter_mut().filter(|s| s.tracks.contains(&q.key)) { + let t = effective_timeout(publisher, sub.timeout_ms); + if t != 0 && q.send_ms - q.received_ms > t { + sub.dropped.insert(key.clone()); + counted += sub.count(&key); + dropped += 1; + } else { + let _ = sub.objects.send(Sent { item: q.object.clone(), sent_ms: q.send_ms }); + forwarded += 1; + } + } + st.forwarded += forwarded; + st.dropped_on_timeout += dropped; + st.receipts_forwarded_for_dropped += counted; + } + } + + /// What the relay has forwarded and dropped so far. + pub fn relay_stats(&self) -> RelayStats { + let st = self.staged.lock().unwrap(); + RelayStats { + forwarded: st.forwarded, + dropped_on_timeout: st.dropped_on_timeout, + receipts_forwarded_for_dropped: st.receipts_forwarded_for_dropped, } } @@ -410,6 +674,162 @@ mod tests { } } + fn object(track: u64, group: u64, payload: &'static [u8]) -> DeliveredObject { + let hash = ObjectReceipt::hash_payload(payload); + DeliveredObject { + header: ObjectHeader { + track_alias: TrackAlias(track), + group_id: GroupId(group), + object_id: ObjectId(0), + publisher_priority: 96, + status: ObjectStatus::Normal, + payload_len: payload.len() as u64, + mrl_layer: 0, + mrl_total_layers: 0, + wai_envelope: true, + }, + payload: Bytes::from_static(payload), + receipt: Some(ObjectReceipt { + track_alias: TrackAlias(track), + group_id: GroupId(group), + object_id: ObjectId(0), + content_hash: hash, + joules_micro: 0, + origin: "o".into(), + merkle_proof: Default::default(), + energy_class: None, + }), + } + } + + fn bundle_for(key: &SigningKey, o: &DeliveredObject) -> ReceiptBundle { + let r = o.receipt.clone().unwrap(); + let mut b = ReceiptBuilder::new(r.track_alias, r.group_id); + b.add_object(r.content_hash, 0, "o", "t"); + ReceiptBundle { group: b.finalize(key, "p", "t"), objects: vec![r] } + } + + fn cfg(timeout: u64, receipts: &str) -> RelayTrackConfig { + RelayTrackConfig { delivery_timeout_ms: timeout, staged_live: true, receipts_track: Some(TrackName::from_str(receipts)) } + } + + /// The delivery timeout runs at the relay's hop, from the object's arrival there: an + /// object that waits exactly its timeout is sent, one that waits longer is not, and + /// its receipts reach the subscriber of its receipts track all the same. A subscriber + /// of the base track and its receipts track alone gets nothing of the layer track, its + /// objects or its receipts. A live staged group carries one object. + #[test] + fn staged_relay_times_out_at_its_hop_and_keeps_receipts() { + let ns = TrackNamespace::from_strs(["example"]); + let (base, layer) = (TrackName::from_str("base"), TrackName::from_str("L1")); + let (base_r, layer_r) = (TrackName::from_str("base/receipts"), TrackName::from_str("L1/receipts")); + let relay = MockRelay::new(Arc::new(MockFabric::new())); + relay.configure_track(&ns, &layer, cfg(50, "L1/receipts")); + relay.configure_track(&ns, &base, cfg(0, "base/receipts")); + let all = [(ns.clone(), base.clone()), (ns.clone(), layer.clone())]; + let all_r = [(ns.clone(), base_r.clone()), (ns.clone(), layer_r.clone())]; + let mut full = relay.subscribe_tracks(&all, 0, &all_r); + let mut capped = relay.subscribe_tracks(&all[..1], 0, &all_r[..1]); + let key = SigningKey::from_bytes(&[3; 32]); + for (g, wait) in [(0u64, 50u64), (1, 51)] { + let o = object(8, g, if g == 0 { b"layer-0" } else { b"layer-1" }); + relay.ingest_receipts(&ns, &layer_r, bundle_for(&key, &o), 1000 + g); + // An upstream delay before 1000 + g is not charged at this hop. + relay.ingest(&ns, &layer, o.clone(), 1000 + g, wait, None).unwrap(); + assert!(relay.ingest(&ns, &layer, o, 1000 + g, wait, None).is_err(), "one object to a live group"); + } + let b = object(7, 0, b"base"); + relay.ingest_receipts(&ns, &base_r, bundle_for(&key, &b), 900); + relay.ingest(&ns, &base, b, 900, 0, None).unwrap(); + relay.advance(2000); + let got: Vec = std::iter::from_fn(|| full.objects.try_recv().ok()).map(|s| s.item.header.group_id.0 * 10 + s.item.header.track_alias.0).collect(); + assert_eq!(got, vec![7, 8], "the base, and the layer that waited exactly its timeout"); + let receipts = std::iter::from_fn(|| full.receipts.as_mut().unwrap().try_recv().ok()).count(); + assert_eq!(receipts, 3, "the dropped object's receipts arrive all the same"); + let capped_got: Vec = std::iter::from_fn(|| capped.objects.try_recv().ok()).map(|s| s.item.header.track_alias.0).collect(); + assert_eq!(capped_got, vec![7], "capped by its subscription"); + let capped_r: Vec = std::iter::from_fn(|| capped.receipts.as_mut().unwrap().try_recv().ok()).map(|s| s.item.group.track_alias.0).collect(); + assert_eq!(capped_r, vec![7], "and so are its receipts: none of the layer's"); + assert_eq!(relay.relay_stats(), RelayStats { forwarded: 3, dropped_on_timeout: 1, receipts_forwarded_for_dropped: 1 }); + // A subscriber's own, smaller timeout takes effect (MOQT §5.2). + let relay = MockRelay::new(Arc::new(MockFabric::new())); + relay.configure_track(&ns, &layer, RelayTrackConfig { staged_live: false, ..cfg(50, "L1/receipts") }); + let mut strict = relay.subscribe_tracks(&[(ns.clone(), layer.clone())], 10, &[]); + relay.ingest(&ns, &layer, object(8, 0, b"x"), 0, 20, None).unwrap(); + relay.advance(100); + assert!(strict.objects.try_recv().is_err()); + assert_eq!(relay.relay_stats().dropped_on_timeout, 1); + assert_eq!(relay.relay_stats().receipts_forwarded_for_dropped, 0, "it asked for no receipts"); + } + + /// P-J1a: two dropped objects of one track whose envelopes are the same bytes, in two + /// groups: both receipts reach the subscriber, and both are counted, each by its + /// track, group and object. + #[test] + fn relay_stats_count_dropped_objects_by_position() { + let ns = TrackNamespace::from_strs(["example"]); + let (l1, l1r) = (TrackName::from_str("L1"), TrackName::from_str("L1/receipts")); + let relay = MockRelay::new(Arc::new(MockFabric::new())); + relay.configure_track(&ns, &l1, cfg(10, "L1/receipts")); + let mut sub = relay.subscribe_tracks(&[(ns.clone(), l1.clone())], 0, &[(ns.clone(), l1r.clone())]); + let key = SigningKey::from_bytes(&[3; 32]); + for g in 0..2u64 { + let o = object(8, g, b"same-envelope"); + relay.ingest_receipts(&ns, &l1r, bundle_for(&key, &o), 100 + g); + relay.ingest(&ns, &l1, o, 100 + g, 50, None).unwrap(); + } + relay.advance(1_000); + let receipts = std::iter::from_fn(|| sub.receipts.as_mut().unwrap().try_recv().ok()).count(); + assert_eq!(receipts, 2); + assert_eq!(relay.relay_stats(), RelayStats { forwarded: 0, dropped_on_timeout: 2, receipts_forwarded_for_dropped: 2 }); + } + + /// P-J1b: an object dropped whose receipts the relay never had, beside an object of + /// another track with the same bytes whose receipts it forwarded: the dropped object's + /// receipts are not counted as forwarded. + #[test] + fn relay_stats_count_only_receipts_sent() { + let ns = TrackNamespace::from_strs(["example"]); + let (base, l1) = (TrackName::from_str("base"), TrackName::from_str("L1")); + let (base_r, l1r) = (TrackName::from_str("base/receipts"), TrackName::from_str("L1/receipts")); + let relay = MockRelay::new(Arc::new(MockFabric::new())); + relay.configure_track(&ns, &base, cfg(0, "base/receipts")); + relay.configure_track(&ns, &l1, cfg(10, "L1/receipts")); + let mut sub = relay.subscribe_tracks( + &[(ns.clone(), base.clone()), (ns.clone(), l1.clone())], + 0, + &[(ns.clone(), base_r.clone()), (ns.clone(), l1r.clone())], + ); + let key = SigningKey::from_bytes(&[3; 32]); + let b = object(7, 0, b"X"); + relay.ingest_receipts(&ns, &base_r, bundle_for(&key, &b), 100); + relay.ingest(&ns, &base, b, 100, 0, None).unwrap(); + relay.ingest(&ns, &l1, object(8, 0, b"X"), 100, 50, None).unwrap(); + relay.advance(1_000); + let bundles: Vec = std::iter::from_fn(|| sub.receipts.as_mut().unwrap().try_recv().ok()).map(|x| x.item.group.track_alias.0).collect(); + assert_eq!(bundles, vec![7]); + assert_eq!(relay.relay_stats(), RelayStats { forwarded: 1, dropped_on_timeout: 1, receipts_forwarded_for_dropped: 0 }); + } + + /// P-J4: a send time past the end of the caller's clock is refused, never saturated + /// into a shorter wait that passes a timeout it should not. + #[test] + fn a_send_time_past_the_clock_is_refused() { + let ns = TrackNamespace::from_strs(["example"]); + let l1 = TrackName::from_str("L1"); + let relay = MockRelay::new(Arc::new(MockFabric::new())); + relay.configure_track(&ns, &l1, cfg(50, "L1/receipts")); + let mut sub = relay.subscribe_tracks(&[(ns.clone(), l1.clone())], 0, &[]); + assert!(relay.ingest(&ns, &l1, object(8, 0, b"x"), u64::MAX - 10, 100, None).is_err()); + relay.advance(u64::MAX); + assert!(sub.objects.try_recv().is_err()); + assert_eq!(relay.relay_stats(), RelayStats::default()); + // At the very end of the clock, with the wait in range, the timeout still runs. + relay.ingest(&ns, &l1, object(8, 1, b"y"), u64::MAX - 100, 100, None).unwrap(); + relay.advance(u64::MAX); + assert_eq!(relay.relay_stats().dropped_on_timeout, 1); + } + #[tokio::test] async fn untrusted_publisher_blocked_by_subscriber() { let fabric = Arc::new(MockFabric::new()); diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs index 2c2b0e5d8..31473818d 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/receipt.rs @@ -87,6 +87,17 @@ pub struct ObjectReceipt { } impl ObjectReceipt { + /// Carried over MoQ (WAI moq-streaming-format §4.1): the receipt is for the object of + /// MOQT group `group` and Object ID `object` on the media track whose receipts carry + /// `track_alias` — its `track_alias`, `group_id` and `object_id` are those — or it is + /// refused ([`ChainRefusal::MoqtMismatch`]). + pub fn check_moqt(&self, track_alias: TrackAlias, group: GroupId, object: ObjectId) -> Result<(), ChainRefusal> { + if self.track_alias != track_alias || self.group_id != group || self.object_id != object { + return Err(ChainRefusal::MoqtMismatch); + } + Ok(()) + } + /// The object's Merkle leaf, in the form its label gives. pub fn leaf(&self) -> [u8; 32] { match &self.energy_class { @@ -734,6 +745,593 @@ pub struct VerifiedGroup { pub group_id: GroupId, } +/// Why [`ReceiptChains::admit`] did not admit a group (WAI jwp-receipts §3 step 3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChainRefusal { + /// Its key, signature or label fails [`ReceiptVerifier::verify_group`] (WAI + /// jwp-receipts §3 steps 1–2): it is neither held, nor admitted, nor an anchor. + Unverified, + /// Carried over MoQ, a receipt whose key or `track_alias` is not its receipts track's, + /// or whose `group_id` or `object_id` is not the MOQT Group ID or Object ID of the + /// object it is for (WAI moq-streaming-format §4.1): refused before its signature is + /// checked, and not held. + MoqtMismatch, + /// A parent that names no group of the chain the verifier holds. Recoverable: the + /// verifier holds the group and offers it again once its parent arrives; it never + /// accepts it silently. + ParentNotHeld, + /// With the chain's head held, a `null` parent (a second chain head), or a parent that + /// is another group of the chain (a fork). + ChainBreak, + /// A legacy group (its parent link is unsigned) whose `group_id` does not exceed its + /// chain head's; or a group that would re-anchor a chain behind its head. + GroupIdNotIncreasing, + /// A group held for its parent past the verifier's stated bound (WAI jwp-receipts §3 + /// step 3, *Holding*): held live, refused when its chain re-anchors; held as history, + /// refused alone. + ParentNotRecovered, +} + +/// How a group was admitted to its chain (WAI jwp-receipts §3 step 3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Admission { + /// It links to the chain's head, or starts the chain with a `null` parent. + Linked, + /// It anchors the chain whatever its parent: the first group of a chain the verifier + /// joined, or the next group after a re-anchor. Its parent is not verified. + Anchor, + /// A group inside a gap an anchor left: it links forward from the last group admitted + /// before the gap, or back from the earliest group admitted after it. + History, + /// A group the chain admitted before, other than its head, offered again with the + /// same bytes: nothing changes. MOQT can deliver one object twice, by a subscription + /// and a fill (MOQT §3.4). + AlreadyAdmitted, +} + +/// Where a chain was anchored: the anchor's `group_id`, whether it began a joined chain, +/// and after a re-anchor the `group_id` of the last group admitted before it. What lies +/// between is reported, never verified, until history fills it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Anchoring { + pub anchor: u64, + pub joined: bool, + pub after: Option, +} + +/// How many earlier groups of one chain [`ReceiptChains`] keeps the link hashes of, to +/// tell a fork from a gap; past it, the oldest are forgotten (WAI jwp-receipts §3 step 3). +pub const CHAIN_HELD_LINKS: usize = 65_536; + +/// The most groups a [`ReceiptChains`] holds for missing parents, across all its chains +/// and of both kinds, whatever bound it is given; [`ReceiptChains::for_moq`] refuses a +/// bound with any count above it. +pub const MAX_HELD_GROUPS: usize = 65_536; + +/// How many gaps one chain keeps open for history; past it, the oldest is closed. +pub const CHAIN_OPEN_GAPS: usize = 64; + +/// The bound a subscriber states on what it holds for missing parents (WAI jwp-receipts §3 +/// step 3, *Holding*): a count of groups held live on each chain, past which the chain +/// re-anchors; a count held as history on each chain, inside the gaps its anchors left, +/// past which the lowest is refused; a count across every chain; and optionally a time +/// from each held group's arrival on its clock. Whatever the counts say, a +/// [`ReceiptChains`] holds at most [`MAX_HELD_GROUPS`] in all. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HoldBound { + pub max_groups: usize, + pub max_history: usize, + pub max_total: usize, + pub max_age_ms: Option, +} + +impl HoldBound { + /// A bound as a subscriber states it in JSON: `max_groups`, `max_history` and + /// `max_total`, each an integer, and `max_age_ms`, an integer or absent. `None` where + /// a count is absent or not an integer: such a statement states no bound. + pub fn from_json(v: &serde_json::Value) -> Option { + let count = |k: &str| v.get(k).and_then(serde_json::Value::as_u64).and_then(|n| usize::try_from(n).ok()); + let max_age_ms = match v.get("max_age_ms") { + None | Some(serde_json::Value::Null) => None, + Some(a) => Some(a.as_u64()?), + }; + Some(HoldBound { max_groups: count("max_groups")?, max_history: count("max_history")?, max_total: count("max_total")?, max_age_ms }) + } +} + +/// Why [`ReceiptChains::for_moq`] refused a bound: a count above [`MAX_HELD_GROUPS`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoundRefused { + pub count: usize, +} + +/// A receipts track (WAI moq-streaming-format §4.1): the key its publisher signs with, and +/// the `wai_receipt_alias` of its media track, which every receipt on it carries. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReceiptsTrack { + pub publisher_key: [u8; 32], + pub track_alias: TrackAlias, +} + +/// What later became of a group the verifier held: its chain, `group_id`, link hash and +/// outcome. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChainEvent { + pub publisher_key: [u8; 32], + pub track_alias: TrackAlias, + pub group_id: u64, + pub link: [u8; 32], + pub outcome: Result, +} + +/// A group held for a missing parent, its signature verified: when it arrived, the order +/// it arrived in, and whether it is held as history (inside a gap) or live. +#[derive(Clone, Debug)] +struct Held { + group: GroupReceipt, + at_ms: u64, + seq: u64, + history: bool, +} + +/// What an anchor skipped: below it, the last group admitted before the anchor (its link +/// hash and `group_id`; none behind a join), from which the gap fills forward; above it, +/// the earliest group admitted at or after the anchor and the parent its signature names, +/// from which it fills backward. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Gap { + low: Option<([u8; 32], u64)>, + high_id: u64, + high_parent: Option<[u8; 32]>, +} + +impl Gap { + fn spans(&self, group_id: u64) -> bool { + group_id < self.high_id && self.low.is_none_or(|(_, l)| group_id > l) + } +} + +/// A digest of every byte of a group as it is carried, its unsigned members among them: +/// two groups with one digest are the same group. +fn bytes_digest(g: &GroupReceipt) -> [u8; 32] { + let mut h = blake3::Hasher::new(); + h.update(b"joule-moq:group-bytes\x01"); + h.update(&serde_json::to_vec(g).expect("a group serializes")); + h.finalize().into() +} + +/// The parent a group's signature covers: a labelled group's, never a legacy one's. +fn signed_parent(g: &GroupReceipt) -> Option<[u8; 32]> { + if g.energy_class.is_some() { + g.parent_receipt_hash + } else { + None + } +} + +#[derive(Clone, Debug, Default)] +struct Chain { + /// The head's link hash and `group_id`. + head: Option<([u8; 32], u64)>, + /// The link hashes of the groups admitted, each with the digest of the bytes it was + /// admitted with, the most recent [`CHAIN_HELD_LINKS`], oldest first in + /// `links_order`. A legacy group's link does not cover its parent or its coverage, so + /// the digest tells the group admitted from another with the same link. + links: std::collections::BTreeMap<[u8; 32], [u8; 32]>, + links_order: std::collections::VecDeque<[u8; 32]>, + /// Groups held for a parent, by link hash; the same by the parent they name; those + /// held as history by `group_id` and arrival. + held: std::collections::BTreeMap<[u8; 32], Held>, + by_parent: std::collections::BTreeMap<[u8; 32], std::collections::BTreeSet<[u8; 32]>>, + history: std::collections::BTreeSet<(u64, u64, [u8; 32])>, + reanchor: bool, + anchors: Vec, + /// The gaps its anchors left that are still open, oldest first. + gaps: std::collections::VecDeque, +} + +impl Chain { + fn remember(&mut self, link: [u8; 32], digest: [u8; 32]) { + self.remember_within(link, digest, CHAIN_HELD_LINKS); + } + + fn remember_within(&mut self, link: [u8; 32], digest: [u8; 32], cap: usize) { + if !self.links.contains_key(&link) { + self.links.insert(link, digest); + self.links_order.push_back(link); + while self.links_order.len() > cap { + if let Some(old) = self.links_order.pop_front() { + self.links.remove(&old); + } + } + } + } + + fn fill(&self, group: &GroupReceipt, link: &[u8; 32]) -> Option<(usize, bool, bool)> { + self.gaps.iter().enumerate().find_map(|(i, g)| { + if !g.spans(group.group_id.0) { + return None; + } + let forward = g.low.is_some_and(|(l, _)| group.parent_receipt_hash == Some(l)); + let backward = g.high_parent == Some(*link); + (forward || backward).then_some((i, forward, backward)) + }) + } + + fn live_held(&self) -> usize { + self.held.len() - self.history.len() + } + + fn put(&mut self, link: [u8; 32], h: Held) { + if h.history { + self.history.insert((h.group.group_id.0, h.seq, link)); + } + if let Some(p) = h.group.parent_receipt_hash { + self.by_parent.entry(p).or_default().insert(link); + } + self.held.insert(link, h); + } + + fn take(&mut self, link: &[u8; 32]) -> Option { + let h = self.held.remove(link)?; + if h.history { + self.history.remove(&(h.group.group_id.0, h.seq, *link)); + } + if let Some(p) = h.group.parent_receipt_hash { + if let Some(s) = self.by_parent.get_mut(&p) { + s.remove(link); + if s.is_empty() { + self.by_parent.remove(&p); + } + } + } + Some(h) + } +} + +/// A subscriber's receipt chains (WAI jwp-receipts §3 step 3, §6.1): one for each +/// publisher key and `track_alias`, its head the last group admitted on it. A group of +/// one track is never another track's predecessor, so a subscriber admits the groups of +/// the tracks it subscribes to without the receipts of those it does not: a stage set +/// carries each layer on a track of its own (WAI moq-streaming-format §4.1). +/// +/// It verifies each group's key, signature and label ([`ReceiptVerifier::verify_group`], +/// any key) before anything else, once: a group it has admitted or holds is known by its +/// link hash, which covers the signed bytes and the signature. A group that fails is +/// [`ChainRefusal::Unverified`], and never held, admitted or anchored. Optionally it +/// recovers (WAI jwp-receipts §3 step 3): it holds a group whose parent it lacks and +/// admits it when the parent arrives, within a stated [`HoldBound`], past which it refuses +/// what it holds live and re-anchors the chain at its next group; a subscriber that joins +/// mid-stream anchors each chain at its first group; and each anchor leaves a gap, which a +/// group linking to either end of it fills as history, in any order. +#[derive(Clone, Debug, Default)] +pub struct ReceiptChains { + chains: std::collections::BTreeMap<([u8; 32], u64), Chain>, + join: bool, + hold: Option, + events: Vec, + total_held: usize, + arrivals: u64, + verifications: u64, + reoffers: u64, +} + +impl ReceiptChains { + pub fn new() -> Self { + Self::default() + } + + /// Joining: each chain it holds nothing of is anchored at its first group, whatever + /// that group's parent. + pub fn with_join(mut self) -> Self { + self.join = true; + self + } + + /// Holding: a group whose parent the chain lacks is held, and admitted when the + /// parent is, or, inside a gap, when it links to either end; past `bound`, every group + /// held live for its chain is refused ([`ChainRefusal::ParentNotRecovered`]) and the + /// chain re-anchors, and the lowest group held as history is refused. At most + /// [`MAX_HELD_GROUPS`] are held in all. + pub fn with_hold(mut self, bound: HoldBound) -> Self { + self.hold = Some(bound); + self + } + + /// A MoQ subscriber's verifier (WAI moq-streaming-format §4.1): holding under `bound`, + /// whose counts are each at most [`MAX_HELD_GROUPS`]; a larger count is refused, and a + /// statement without counts gives no bound ([`HoldBound::from_json`]). It admits each + /// receipt with [`ReceiptChains::admit_on`]. + pub fn for_moq(bound: HoldBound) -> Result { + for count in [bound.max_groups, bound.max_history, bound.max_total] { + if count > MAX_HELD_GROUPS { + return Err(BoundRefused { count }); + } + } + Ok(Self::new().with_hold(bound)) + } + + /// How `group`, verified, would be admitted now, or why not. + fn decide(&self, group: &GroupReceipt, link: &[u8; 32]) -> Result { + let chain = self.chains.get(&(group.publisher_key, group.track_alias.0)); + let head = chain.and_then(|c| c.head); + if head.is_some_and(|(h, _)| h == *link) { + return Ok(Admission::Linked); + } + if chain.is_some_and(|c| c.fill(group, link).is_some()) { + return Ok(Admission::History); + } + let linked = match (head, group.parent_receipt_hash) { + (None, None) => Ok(()), + (None, Some(_)) => Err(ChainRefusal::ParentNotHeld), + (Some(_), None) => Err(ChainRefusal::ChainBreak), + (Some((h, _)), Some(p)) if p == h => Ok(()), + (Some(_), Some(p)) => Err(if chain.is_some_and(|c| c.links.contains_key(&p)) { ChainRefusal::ChainBreak } else { ChainRefusal::ParentNotHeld }), + } + .and_then(|()| { + if group.energy_class.is_none() && head.is_some_and(|(_, id)| group.group_id.0 <= id) { + Err(ChainRefusal::GroupIdNotIncreasing) + } else { + Ok(()) + } + }); + let anchoring = chain.is_some_and(|c| c.reanchor) || (self.join && head.is_none()); + match linked { + Ok(()) => Ok(Admission::Linked), + Err(_) if anchoring => { + if head.is_some_and(|(_, id)| group.group_id.0 <= id) { + Err(ChainRefusal::GroupIdNotIncreasing) + } else { + Ok(Admission::Anchor) + } + } + Err(e) => Err(e), + } + } + + fn install(&mut self, group: &GroupReceipt, link: [u8; 32], how: Admission) { + let c = self.chains.entry((group.publisher_key, group.track_alias.0)).or_default(); + if c.head.is_some_and(|(h, _)| h == link) { + // The head again, in bytes its signature does not cover: nothing changes. + return; + } + c.remember(link, bytes_digest(group)); + match how { + Admission::History => { + let Some((i, forward, backward)) = c.fill(group, &link) else { return }; + if forward && backward { + c.gaps.remove(i); + } else if forward { + c.gaps[i].low = Some((link, group.group_id.0)); + } else { + c.gaps[i].high_id = group.group_id.0; + c.gaps[i].high_parent = signed_parent(group); + if c.gaps[i].low.is_none() && c.gaps[i].high_parent.is_none() { + c.gaps.remove(i); + } + } + } + Admission::Anchor => { + let low = c.head; + c.anchors.push(Anchoring { anchor: group.group_id.0, joined: c.head.is_none(), after: low.map(|(_, id)| id) }); + let gap = Gap { low, high_id: group.group_id.0, high_parent: signed_parent(group) }; + if gap.low.is_some() || gap.high_parent.is_some() { + c.gaps.push_back(gap); + while c.gaps.len() > CHAIN_OPEN_GAPS { + c.gaps.pop_front(); + } + } + c.head = Some((link, group.group_id.0)); + c.reanchor = false; + } + Admission::Linked => { + c.head = Some((link, group.group_id.0)); + c.reanchor = false; + } + Admission::AlreadyAdmitted => {} + } + } + + fn hold_group(&mut self, group: &GroupReceipt, link: [u8; 32], now_ms: u64, bound: HoldBound) -> Result { + let key = (group.publisher_key, group.track_alias.0); + let total_cap = bound.max_total.min(MAX_HELD_GROUPS); + let c = self.chains.entry(key).or_default(); + if c.held.contains_key(&link) { + return Err(ChainRefusal::ParentNotHeld); + } + let history = c.gaps.iter().any(|g| g.spans(group.group_id.0)); + if history { + if c.history.len() >= bound.max_history || self.total_held >= total_cap { + match c.history.first().copied() { + Some((id, _, lowest)) if id < group.group_id.0 => self.refuse_held(key, &lowest), + _ => return Err(ChainRefusal::ParentNotRecovered), + } + } + } else if c.live_held() >= bound.max_groups || self.total_held >= total_cap { + self.give_up(key); + return self.decide(group, &link); + } + self.arrivals += 1; + let h = Held { group: group.clone(), at_ms: now_ms, seq: self.arrivals, history }; + self.chains.entry(key).or_default().put(link, h); + self.total_held += 1; + Err(ChainRefusal::ParentNotHeld) + } + + fn refuse_held(&mut self, key: ([u8; 32], u64), link: &[u8; 32]) { + if let Some(h) = self.chains.get_mut(&key).and_then(|c| c.take(link)) { + self.total_held -= 1; + self.events.push(ChainEvent { + publisher_key: key.0, + track_alias: TrackAlias(key.1), + group_id: h.group.group_id.0, + link: *link, + outcome: Err(ChainRefusal::ParentNotRecovered), + }); + } + } + + /// After `first` was admitted, offer again the held groups that link to it — those + /// whose parent is its link hash, and the one whose link hash is its parent — and in + /// turn those that link to each of them; among those of one admitted group, by + /// `group_id`, then arrival. + fn release(&mut self, first: &GroupReceipt, first_link: [u8; 32]) { + let key = (first.publisher_key, first.track_alias.0); + let mut work = std::collections::VecDeque::from([(first.parent_receipt_hash, first_link)]); + while let Some((parent, link)) = work.pop_front() { + let Some(c) = self.chains.get(&key) else { return }; + let mut next: Vec<(u64, u64, [u8; 32])> = + c.by_parent.get(&link).into_iter().flatten().filter_map(|l| c.held.get(l).map(|h| (h.group.group_id.0, h.seq, *l))).collect(); + if let Some(h) = parent.and_then(|p| c.held.get(&p).map(|h| (h.group.group_id.0, h.seq, p))) { + next.push(h); + } + next.sort_unstable(); + next.dedup(); + for (_, _, l) in next { + let Some(h) = self.chains.get_mut(&key).and_then(|c| c.take(&l)) else { continue }; + self.total_held -= 1; + self.reoffers += 1; + match self.decide(&h.group, &l) { + Err(ChainRefusal::ParentNotHeld) => { + self.chains.entry(key).or_default().put(l, h); + self.total_held += 1; + } + r => { + if let Ok(how) = r { + self.install(&h.group, l, how); + work.push_back((h.group.parent_receipt_hash, l)); + } + self.events.push(ChainEvent { publisher_key: key.0, track_alias: TrackAlias(key.1), group_id: h.group.group_id.0, link: l, outcome: r }); + } + } + } + } + } + + fn give_up(&mut self, key: ([u8; 32], u64)) { + let Some(c) = self.chains.get_mut(&key) else { return }; + c.reanchor = true; + let mut live: Vec<(u64, u64, [u8; 32])> = c.held.iter().filter(|(_, h)| !h.history).map(|(l, h)| (h.group.group_id.0, h.seq, *l)).collect(); + live.sort_unstable(); + for (_, _, l) in live { + self.refuse_held(key, &l); + } + } + + /// WAI jwp-receipts §3 step 3 for `group`, arriving at `now_ms` on the subscriber's + /// clock, after its key, signature and label (once). With holding, a group whose + /// parent the chain lacks is held (and refused here [`ChainRefusal::ParentNotHeld`]); + /// see [`ReceiptChains::with_hold`] for what passing the bound does. A group admitted + /// releases what was held behind it ([`ReceiptChains::take_events`]); the head offered + /// again changes nothing. + pub fn admit(&mut self, group: &GroupReceipt, now_ms: u64) -> Result { + let key = (group.publisher_key, group.track_alias.0); + let link = group.link_hash(); + let digest = bytes_digest(group); + // A group admitted before, the same bytes: the head again, or another group again. + // Known by its link alone it is not: a legacy group's link leaves its parent and + // its coverage out. + let known = match self.chains.get(&key) { + Some(c) if c.links.get(&link) == Some(&digest) => { + return Ok(if c.head.is_some_and(|(h, _)| h == link) { Admission::Linked } else { Admission::AlreadyAdmitted }); + } + Some(c) => c.held.get(&link).is_some_and(|h| h.group == *group), + None => false, + }; + if !known { + self.verifications += 1; + ReceiptVerifier::new().verify_group(group).map_err(|_| ChainRefusal::Unverified)?; + } + let mut r = self.decide(group, &link); + if let (Err(ChainRefusal::ParentNotHeld), Some(bound)) = (r, self.hold) { + r = self.hold_group(group, link, now_ms, bound); + } + if let Ok(how) = r { + self.install(group, link, how); + self.release(group, link); + } + r + } + + /// [`ReceiptChains::admit`] for a group receipt carried on `track` as the object of + /// MOQT group `moqt_group` (WAI moq-streaming-format §4.1): one whose key or + /// `track_alias` is not the track's, or whose `group_id` is not that group's, is + /// refused ([`ChainRefusal::MoqtMismatch`]) before its signature is checked, and + /// nothing of it is held. + pub fn admit_on(&mut self, track: &ReceiptsTrack, moqt_group: GroupId, group: &GroupReceipt, now_ms: u64) -> Result { + if group.publisher_key != track.publisher_key || group.track_alias != track.track_alias || group.group_id != moqt_group { + return Err(ChainRefusal::MoqtMismatch); + } + self.admit(group, now_ms) + } + + /// [`ReceiptChains::admit`] at time 0, its admission dropped. + pub fn accept(&mut self, group: &GroupReceipt) -> Result<(), ChainRefusal> { + self.admit(group, 0).map(|_| ()) + } + + /// With a time in the hold bound, every group held more than `max_age_ms` before + /// `now_ms` is refused: a chain holding such a group live refuses all it holds live + /// and re-anchors; one held as history is refused alone. + pub fn expire(&mut self, now_ms: u64) { + let Some(age) = self.hold.and_then(|b| b.max_age_ms) else { return }; + let keys: Vec<([u8; 32], u64)> = self.chains.keys().copied().collect(); + for k in keys { + let Some(c) = self.chains.get(&k) else { continue }; + let stale = |h: &Held| now_ms.saturating_sub(h.at_ms) > age; + let live_stale = c.held.values().any(|h| !h.history && stale(h)); + let history: Vec<[u8; 32]> = c.history.iter().filter(|(_, _, l)| c.held.get(l).is_some_and(stale)).map(|(_, _, l)| *l).collect(); + if live_stale { + self.give_up(k); + } + for l in history { + self.refuse_held(k, &l); + } + } + } + + /// What became, since the last call, of the groups held for a parent. + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Every anchor of the chain of (`publisher_key`, `track_alias`), in order. + pub fn anchors(&self, publisher_key: &[u8; 32], track_alias: TrackAlias) -> &[Anchoring] { + self.chains.get(&(*publisher_key, track_alias.0)).map_or(&[], |c| &c.anchors) + } + + /// How many groups the chain of (`publisher_key`, `track_alias`) holds for a parent, + /// live and as history. + pub fn held(&self, publisher_key: &[u8; 32], track_alias: TrackAlias) -> usize { + self.chains.get(&(*publisher_key, track_alias.0)).map_or(0, |c| c.held.len()) + } + + /// How many groups are held across every chain. + pub fn held_total(&self) -> usize { + self.total_held + } + + /// How many group signatures [`ReceiptChains::admit`] has verified. + pub fn verifications(&self) -> u64 { + self.verifications + } + + /// How many times a held group has been offered again. + pub fn reoffers(&self) -> u64 { + self.reoffers + } + + /// How many chains have a head. + pub fn chains(&self) -> usize { + self.chains.values().filter(|c| c.head.is_some()).count() + } + + /// The link hash of the head of the chain of (`publisher_key`, `track_alias`). + pub fn head(&self, publisher_key: &[u8; 32], track_alias: TrackAlias) -> Option<[u8; 32]> { + self.chains.get(&(*publisher_key, track_alias.0)).and_then(|c| c.head.map(|(l, _)| l)) + } +} + impl Default for ReceiptVerifier { fn default() -> Self { Self::new() @@ -991,6 +1589,370 @@ mod tests { (b, objects) } + /// One chain per key and track: interleaved tracks each link; a gap is held for its + /// parent; a second head and a fork break the chain; a legacy group must move forward. + #[test] + fn receipt_chains_are_kept_per_key_and_track() { + let key = test_signing_key(0x21); + let group = |track: u64, id: u64, parent: Option<[u8; 32]>| { + let mut b = ReceiptBuilder::new(TrackAlias(track), GroupId(id)); + b.add_labelled_object(h(id as u8 + 1), 0, "o", "t", vec![0]).unwrap(); + b.finalize_labelled(&key, "p", "t", parent).unwrap() + }; + let (a0, b0) = (group(7, 0, None), group(8, 0, None)); + let a1 = group(7, 1, Some(a0.link_hash())); + let b1 = group(8, 1, Some(b0.link_hash())); + let a2 = group(7, 2, Some(a1.link_hash())); + let mut c = ReceiptChains::new(); + for g in [&a0, &b0, &a1, &b1] { + assert_eq!(c.accept(g), Ok(()), "interleaved tracks each link"); + } + assert_eq!(c.chains(), 2); + assert_eq!(c.accept(&a1), Ok(()), "the head offered again passes"); + let mut gap = ReceiptChains::new(); + assert_eq!(gap.accept(&a0), Ok(())); + assert_eq!(gap.accept(&a2), Err(ChainRefusal::ParentNotHeld), "a gap is held, not accepted"); + assert_eq!(gap.accept(&a1), Ok(())); + assert_eq!(gap.accept(&a2), Ok(()), "admitted once its parent is"); + assert_eq!(c.accept(&group(7, 5, None)), Err(ChainRefusal::ChainBreak), "a second head"); + assert_eq!(c.accept(&group(7, 6, Some(a0.link_hash()))), Err(ChainRefusal::ChainBreak), "a fork"); + assert_eq!(ReceiptChains::new().accept(&a1), Err(ChainRefusal::ParentNotHeld)); + // A legacy group's link is unsigned: its group_id must exceed the head's. + let legacy = |id: u64, parent: Option<[u8; 32]>| { + let mut b = ReceiptBuilder::new(TrackAlias(9), GroupId(id)); + b.add_object(h(1), 1, "o", "t"); + let mut g = b.finalize(&key, "p", "t"); + g.parent_receipt_hash = parent; + g + }; + let mut l = ReceiptChains::new(); + let l3 = legacy(3, None); + assert_eq!(l.accept(&l3), Ok(())); + assert_eq!(l.accept(&legacy(2, Some(l3.link_hash()))), Err(ChainRefusal::GroupIdNotIncreasing)); + assert_eq!(l.accept(&legacy(4, Some(l3.link_hash()))), Ok(())); + } + + fn labelled_run(key: &SigningKey, n: u64) -> Vec { + let mut out: Vec = Vec::new(); + for g in 0..n { + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(g)); + b.add_labelled_object([g as u8 + 1; 32], 0, "o", "t", vec![0]).unwrap(); + let parent = out.last().map(GroupReceipt::link_hash); + out.push(b.finalize_labelled(key, "p", "t", parent).unwrap()); + } + out + } + + /// P-J2: one receipts-track object lost. Without holding, every later group of the + /// track waits for a parent that never comes, however often it is offered. With a + /// count bound of 2, 4 and 5 are held; 6 passes the bound, so they are refused and + /// the chain re-anchors at 6, reported with the group before the gap, and the track + /// resumes. The lost group and the refused ones, fetched later, are history. A time + /// bound gives up the same way when the held groups age past it. + #[test] + fn a_lost_receipt_recovers_by_re_anchoring() { + let key = test_signing_key(0x21); + let pk = key.verifying_key().to_bytes(); + let c = labelled_run(&key, 10); + let order: Vec<&GroupReceipt> = [0, 1, 2, 4, 5, 6, 7, 8, 9].iter().map(|&i| &c[i]).collect(); + let mut stall = ReceiptChains::new(); + for _ in 0..3 { + for g in &order[3..] { + assert_eq!(stall.accept(g), Err(ChainRefusal::ParentNotHeld)); + } + } + let mut chains = ReceiptChains::new().with_hold(HoldBound { max_groups: 2, max_history: 16, max_total: 64, max_age_ms: None }); + let got: Vec> = order.iter().map(|g| chains.admit(g, 0)).collect(); + use Admission::*; + assert_eq!( + got, + [Ok(Linked), Ok(Linked), Ok(Linked), Err(ChainRefusal::ParentNotHeld), Err(ChainRefusal::ParentNotHeld), Ok(Anchor), Ok(Linked), Ok(Linked), Ok(Linked)] + ); + let refused: Vec<(u64, Result)> = chains.take_events().into_iter().map(|e| (e.group_id, e.outcome)).collect(); + assert_eq!(refused, [(4, Err(ChainRefusal::ParentNotRecovered)), (5, Err(ChainRefusal::ParentNotRecovered))]); + assert_eq!(chains.anchors(&pk, TrackAlias(8)), &[Anchoring { anchor: 6, joined: false, after: Some(2) }]); + assert_eq!(chains.head(&pk, TrackAlias(8)), Some(c[9].link_hash()), "the track resumed"); + for i in [5, 4, 3] { + assert_eq!(chains.admit(&c[i], 0), Ok(History)); + } + let mut timed = ReceiptChains::new().with_hold(HoldBound { max_groups: 100, max_history: 16, max_total: 256, max_age_ms: Some(50) }); + for (i, at) in [(0, 0), (1, 5), (2, 8), (4, 10), (5, 20)] { + let _ = timed.admit(&c[i], at); + } + timed.expire(60); + assert_eq!(timed.held(&pk, TrackAlias(8)), 2, "within the bound at 60"); + timed.expire(61); + assert_eq!(timed.held(&pk, TrackAlias(8)), 0); + assert_eq!(timed.admit(&c[6], 70), Ok(Anchor)); + // A parent that arrives within the bound releases what waits on it. + let mut fetched = ReceiptChains::new().with_hold(HoldBound { max_groups: 4, max_history: 16, max_total: 64, max_age_ms: None }); + for i in [0, 1, 2, 4, 5] { + let _ = fetched.admit(&c[i], 0); + } + assert_eq!(fetched.admit(&c[3], 0), Ok(Linked)); + let released: Vec = fetched.take_events().into_iter().filter(|e| e.outcome == Ok(Linked)).map(|e| e.group_id).collect(); + assert_eq!(released, [4, 5]); + } + + /// P-J3: a subscriber joining mid-stream. Without joining it admits nothing; joining, + /// it anchors at its first group, admits the rest, and admits what links back to its + /// labelled anchor as history. + #[test] + fn a_late_joiner_admits() { + let key = test_signing_key(0x22); + let pk = key.verifying_key().to_bytes(); + let c = labelled_run(&key, 6); + let mut plain = ReceiptChains::new(); + assert!(c[2..].iter().all(|g| plain.accept(g) == Err(ChainRefusal::ParentNotHeld))); + let mut joined = ReceiptChains::new().with_join(); + assert_eq!(joined.admit(&c[2], 0), Ok(Admission::Anchor)); + for g in &c[3..] { + assert_eq!(joined.admit(g, 0), Ok(Admission::Linked)); + } + assert_eq!(joined.anchors(&pk, TrackAlias(8)), &[Anchoring { anchor: 2, joined: true, after: None }]); + assert_eq!(joined.admit(&c[1], 0), Ok(Admission::History)); + assert_eq!(joined.admit(&c[0], 0), Ok(Admission::History)); + } + + /// `n` chained labelled groups of `track` under `key`, objects numbered from `first`. + fn run_on(key: &SigningKey, track: u64, n: u64, first: u8) -> Vec { + let mut out: Vec = Vec::new(); + for g in 0..n { + let mut b = ReceiptBuilder::new(TrackAlias(track), GroupId(g)); + b.add_labelled_object([first.wrapping_add(g as u8); 32], 0, "o", "t", vec![0]).unwrap(); + let parent = out.last().map(GroupReceipt::link_hash); + out.push(b.finalize_labelled(key, "p", "t", parent).unwrap()); + } + out + } + + fn bound(live: usize, history: usize, total: usize) -> HoldBound { + HoldBound { max_groups: live, max_history: history, max_total: total, max_age_ms: None } + } + + /// NEW 1, the review's two sequences. After a re-anchor, the gap is fetched in + /// ascending order, as MOQT delivers a FETCH: group 3 links forward from 2 and is + /// history, never a fork, and so is each group after it. A joiner's fill arrives in + /// ascending order too: under a live bound of 4, groups 10 to 19 are all admitted, + /// held as history and released back from the anchor. + #[test] + fn a_gap_fills_in_the_order_moqt_delivers_it() { + use Admission::*; + let key = test_signing_key(0x23); + let pk = key.verifying_key().to_bytes(); + let c = run_on(&key, 8, 12, 60); + let mut v = ReceiptChains::new().with_hold(bound(4, 8, 64)); + for i in [0, 1, 2] { + assert_eq!(v.admit(&c[i], 0), Ok(Linked)); + } + for i in [4, 5, 6, 7] { + assert_eq!(v.admit(&c[i], 0), Err(ChainRefusal::ParentNotHeld)); + } + assert_eq!(v.admit(&c[8], 0), Ok(Anchor)); + assert_eq!(v.take_events().len(), 4); + for i in [3, 4, 5, 6, 7] { + assert_eq!(v.admit(&c[i], 0), Ok(History), "group {i}, fetched in ascending order"); + } + assert_eq!(v.held(&pk, TrackAlias(8)), 0); + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(3)); + b.add_labelled_object([0xEE; 32], 0, "o", "t", vec![0]).unwrap(); + let fork = b.finalize_labelled(&key, "p", "t", Some(c[2].link_hash())).unwrap(); + assert_eq!(v.admit(&fork, 0), Err(ChainRefusal::ChainBreak), "the gap is closed: a second child of 2 is a fork"); + + let f = run_on(&key, 9, 22, 120); + let mut j = ReceiptChains::new().with_join().with_hold(bound(4, 16, 64)); + assert_eq!(j.admit(&f[20], 0), Ok(Anchor)); + for g in &f[10..19] { + assert_eq!(j.admit(g, 0), Err(ChainRefusal::ParentNotHeld), "held as history, outside the live bound"); + } + assert_eq!(j.admit(&f[19], 0), Ok(History)); + let released: Vec<(u64, Result)> = j.take_events().into_iter().map(|e| (e.group_id, e.outcome)).collect(); + assert_eq!(released, (10..19).rev().map(|i| (i, Ok(History))).collect::>()); + assert_eq!(j.held_total(), 0); + assert_eq!(j.admit(&f[21], 0), Ok(Linked)); + } + + /// NEW 7: the chains verify each group's key, signature and label themselves, before + /// anything else: a forged group is never held, admitted or anchored, whether the + /// verifier joins or has re-anchored. + #[test] + fn a_forged_group_never_anchors() { + let key = test_signing_key(0x24); + let pk = key.verifying_key().to_bytes(); + let c = run_on(&key, 8, 6, 10); + let forge = |g: &GroupReceipt| { + let mut f = g.clone(); + f.signature[0] ^= 1; + f + }; + let mut joined = ReceiptChains::new().with_join().with_hold(bound(0, 4, 16)); + assert_eq!(joined.admit(&forge(&c[2]), 0), Err(ChainRefusal::Unverified)); + assert_eq!((joined.chains(), joined.held_total()), (0, 0), "nothing anchored, nothing held"); + assert_eq!(joined.admit(&c[2], 0), Ok(Admission::Anchor)); + // A bound of 0: any gap re-anchors at once, but a forged group does not. + assert_eq!(joined.admit(&forge(&c[4]), 0), Err(ChainRefusal::Unverified)); + assert_eq!(joined.anchors(&pk, TrackAlias(8)).len(), 1); + assert_eq!(joined.admit(&c[4], 0), Ok(Admission::Anchor)); + let mut wrong_label = c[5].clone(); + wrong_label.energy_coverage = 7; + assert_eq!(joined.admit(&wrong_label, 0), Err(ChainRefusal::Unverified)); + assert_eq!(joined.admit(&c[5], 0), Ok(Admission::Linked)); + } + + /// NEW 2: what is held is bounded in all, each group is verified once, a held group is + /// offered again only when a group it links to is admitted, and a chain keeps the + /// most recent `CHAIN_HELD_LINKS` links. A MoQ subscriber states finite counts at most + /// `MAX_HELD_GROUPS`; a statement without one states no bound. + #[test] + fn the_hold_is_bounded_and_each_group_verified_once() { + let mut v = ReceiptChains::new().with_hold(bound(4, 4, 50)); + let mut most = 0; + for k in 0..100u8 { + let key = test_signing_key(k.wrapping_add(0x80)); + for g in 1..5u64 { + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(g)); + b.add_labelled_object([g as u8; 32], 0, "o", "t", vec![0]).unwrap(); + let _ = v.admit(&b.finalize_labelled(&key, "p", "t", Some([g as u8; 32])).unwrap(), 0); + assert!(v.held_total() <= 50); + most = most.max(v.held_total()); + } + } + assert_eq!(most, 50, "100 keys, 4 orphans each: the total holds 50 at most, and reaches it"); + + assert_eq!(ReceiptChains::for_moq(bound(usize::MAX, 4, 16)).err(), Some(BoundRefused { count: usize::MAX })); + assert_eq!(ReceiptChains::for_moq(bound(4, 4, MAX_HELD_GROUPS + 1)).err(), Some(BoundRefused { count: MAX_HELD_GROUPS + 1 })); + assert!(ReceiptChains::for_moq(bound(MAX_HELD_GROUPS, MAX_HELD_GROUPS, MAX_HELD_GROUPS)).is_ok()); + let stated = serde_json::json!({"max_groups": 4, "max_history": 16, "max_total": 64}); + assert_eq!(HoldBound::from_json(&stated), Some(bound(4, 16, 64))); + assert_eq!(HoldBound::from_json(&serde_json::json!({"max_history": 16, "max_total": 64})), None, "no live count"); + assert_eq!(HoldBound::from_json(&serde_json::json!({"max_age_ms": 500})), None, "a time alone"); + + let key = test_signing_key(0x25); + let c = run_on(&key, 8, 8, 30); + let mut w = ReceiptChains::for_moq(bound(500, 16, 1000)).unwrap(); + assert_eq!(w.admit(&c[0], 0), Ok(Admission::Linked)); + for g in 0..300u64 { + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(1000 + g)); + b.add_labelled_object([(g % 251) as u8; 32], 0, "o", "t", vec![0]).unwrap(); + assert_eq!(w.admit(&b.finalize_labelled(&key, "p", "t", Some([1; 32])).unwrap(), 0), Err(ChainRefusal::ParentNotHeld)); + } + assert_eq!(w.verifications(), 301); + for _ in 0..10 { + assert_eq!(w.admit(&c[0], 0), Ok(Admission::Linked)); + } + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(1000)); + b.add_labelled_object([0; 32], 0, "o", "t", vec![0]).unwrap(); + let orphan = b.finalize_labelled(&key, "p", "t", Some([1; 32])).unwrap(); + assert_eq!(w.admit(&orphan, 0), Err(ChainRefusal::ParentNotHeld), "a held group offered again: still held"); + assert_eq!(w.verifications(), 301, "and not verified again"); + assert_eq!(w.admit(&c[1], 0), Ok(Admission::Linked)); + assert_eq!((w.verifications(), w.reoffers()), (302, 0), "the head again verifies nothing; a group no held one links to offers none again"); + for g in &c[3..8] { + let _ = w.admit(g, 0); + } + assert_eq!(w.admit(&c[2], 0), Ok(Admission::Linked)); + assert_eq!((w.verifications(), w.reoffers()), (308, 5), "3 to 7 released, each verified once, offered again once"); + + let mut chain = Chain::default(); + for i in 0..5u8 { + chain.remember_within([i; 32], [0; 32], 3); + } + assert_eq!(chain.links.keys().copied().collect::>(), vec![[2; 32], [3; 32], [4; 32]], "the oldest forgotten past the cap"); + assert_eq!(CHAIN_HELD_LINKS, 65_536, "the cap WAI's reference keeps"); + } + + /// NEW 2 and NEW 4: carried over MoQ, a receipt whose key or alias is not its receipts + /// track's, or that names another MOQT group or object, is refused before its + /// signature is checked, and nothing of it is held. + #[test] + fn a_receipt_off_its_track_is_refused_unheld() { + let key = test_signing_key(0x26); + let pk = key.verifying_key().to_bytes(); + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(5)); + b.add_labelled_object([5; 32], 0, "o", "t", vec![0]).unwrap(); + let proof = b.proof_for(0).unwrap(); + let g = b.finalize_labelled(&key, "p", "t", Some([3; 32])).unwrap(); + let track = ReceiptsTrack { publisher_key: pk, track_alias: TrackAlias(8) }; + let mut v = ReceiptChains::for_moq(bound(4, 4, 16)).unwrap(); + assert_eq!(v.admit_on(&ReceiptsTrack { publisher_key: [9; 32], ..track }, GroupId(5), &g, 0), Err(ChainRefusal::MoqtMismatch)); + assert_eq!(v.admit_on(&ReceiptsTrack { track_alias: TrackAlias(9), ..track }, GroupId(5), &g, 0), Err(ChainRefusal::MoqtMismatch)); + assert_eq!(v.admit_on(&track, GroupId(6), &g, 0), Err(ChainRefusal::MoqtMismatch)); + assert_eq!((v.held_total(), v.verifications(), v.chains()), (0, 0, 0)); + assert_eq!(v.admit_on(&track, GroupId(5), &g, 0), Err(ChainRefusal::ParentNotHeld), "its own: held"); + let o = ObjectReceipt { + track_alias: TrackAlias(8), + group_id: GroupId(5), + object_id: ObjectId(0), + content_hash: [5; 32], + joules_micro: 0, + origin: "o".into(), + merkle_proof: proof, + energy_class: Some(vec![0]), + }; + assert_eq!(o.check_moqt(TrackAlias(8), GroupId(5), ObjectId(0)), Ok(())); + assert_eq!(o.check_moqt(TrackAlias(8), GroupId(5), ObjectId(1)), Err(ChainRefusal::MoqtMismatch)); + assert_eq!(o.check_moqt(TrackAlias(8), GroupId(4), ObjectId(0)), Err(ChainRefusal::MoqtMismatch)); + assert_eq!(o.check_moqt(TrackAlias(9), GroupId(5), ObjectId(0)), Err(ChainRefusal::MoqtMismatch)); + } + + /// Round 4, item 1: a group admitted before, offered again with the same bytes after + /// the head moved on (a subscription and a fill deliver one object twice, MOQT + /// §3.4), changes nothing and is never a fork; a joiner's anchor delivered again + /// changes nothing, and history behind it is still admitted. + #[test] + fn an_admitted_group_offered_again_changes_nothing() { + use Admission::*; + let key = test_signing_key(0x27); + let pk = key.verifying_key().to_bytes(); + let c = run_on(&key, 8, 8, 150); + let mut v = ReceiptChains::new().with_hold(bound(2, 16, 64)); + for g in &c[..7] { + assert_eq!(v.admit(g, 0), Ok(Linked)); + } + let before = v.verifications(); + assert_eq!(v.admit(&c[3], 0), Ok(AlreadyAdmitted), "group 3 again after head 6"); + assert_eq!(v.admit(&c[6], 0), Ok(Linked), "the head again"); + assert_eq!(v.verifications(), before, "nothing verified again"); + assert_eq!(v.head(&pk, TrackAlias(8)), Some(c[6].link_hash()), "the head unmoved"); + assert_eq!(v.admit(&c[7], 0), Ok(Linked)); + + let mut j = ReceiptChains::new().with_join().with_hold(bound(2, 16, 64)); + let got: Vec> = [5, 6, 7, 5, 4].iter().map(|&i| j.admit(&c[i], 0)).collect(); + assert_eq!(got, [Ok(Anchor), Ok(Linked), Ok(Linked), Ok(AlreadyAdmitted), Ok(History)]); + } + + /// Round 4, item 2: a legacy group's link hash covers neither its parent nor its + /// coverage, so a group with the link of one admitted is the same group only when its + /// bytes are. A variant with an unsigned coverage is verified, and refused; one with + /// its parent rewritten is the head again, and changes nothing. + #[test] + fn a_legacy_variant_is_not_the_group_admitted() { + let key = test_signing_key(0x31); + let pk = key.verifying_key().to_bytes(); + let legacy = |id: u64, parent: Option<[u8; 32]>| { + let mut b = ReceiptBuilder::new(TrackAlias(8), GroupId(id)); + b.add_object([id as u8 + 1; 32], 0, "o", "t"); + let mut g = b.finalize(&key, "p", "t"); + g.parent_receipt_hash = parent; + g + }; + let g0 = legacy(0, None); + let g1 = legacy(1, Some(g0.link_hash())); + let mut chains = ReceiptChains::new().with_hold(bound(4, 4, 8)); + assert_eq!(chains.admit(&g0, 0), Ok(Admission::Linked)); + assert_eq!(chains.admit(&g1, 0), Ok(Admission::Linked)); + let coverage = |g: &GroupReceipt| GroupReceipt { energy_coverage: 1, ..g.clone() }; + assert_eq!(coverage(&g1).link_hash(), g1.link_hash(), "the legacy link leaves the coverage out"); + assert_eq!(chains.admit(&coverage(&g1), 0), Err(ChainRefusal::Unverified), "the head's bytes changed: verified, and refused"); + assert_eq!(chains.admit(&coverage(&g0), 0), Err(ChainRefusal::Unverified), "an earlier group's too"); + assert_eq!(chains.admit(&g0, 0), Ok(Admission::AlreadyAdmitted), "the group itself again"); + let reparented = GroupReceipt { parent_receipt_hash: Some([7; 32]), ..g1.clone() }; + assert_eq!(chains.admit(&reparented, 0), Ok(Admission::Linked), "the head's link: the head again"); + assert_eq!(chains.head(&pk, TrackAlias(8)), Some(g1.link_hash())); + let next = legacy(2, Some(g1.link_hash())); + assert_eq!(chains.admit(&next, 0), Ok(Admission::Linked), "nothing changed: the next group links"); + } + #[test] fn leaves_match_the_wai_corpus() { let (_, o) = mixed(); diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/staged.rs b/joule-code/joule-code-rs/crates/joule-moq/src/staged.rs new file mode 100644 index 000000000..c26364020 --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/src/staged.rs @@ -0,0 +1,232 @@ +//! Staged tracks: a WAI stage set carried one track per layer (WAI +//! `extensions/moq-streaming-format.md` §4.1, `extensions/staged-delivery.md`). +//! +//! A stage set is a base and refinement layers, each its own `WAI1` envelope, each +//! bound to what it refines by content hash. On MoQ every layer index has a track of +//! its own: a subscriber chooses its stage by the tracks it subscribes to, and a relay +//! forwards tracks without reading an envelope. This module holds what the transport +//! layer needs of that, and no codec: +//! +//! - [`StagedTrack`], the `wai_staged` member of a track's catalog entry; +//! - [`refines_of`] and [`layer_from_envelope`], which read a layer's index and what it +//! refines from the envelope itself (its `refines`, inside the envelope and so inside +//! its content hash), for a sink; +//! - [`check_mrl_agrees`], which compares the prototype header fields +//! [`ObjectHeader::mrl_layer`](crate::messages::ObjectHeader::mrl_layer) and +//! `mrl_total_layers` with the envelope. Those fields are superseded for objects of +//! packaging `wai` (staged-delivery §13): a sink reads `refines.layer` and ignores the +//! header, and a relay caps nothing by them; +//! - [`live_object_rules_ok`], §4.1's rule for a live staged track: one object to a +//! group, in a subgroup of its own. +//! +//! The relay side, delivery timeouts applied at the relay's hop and the receipts of what +//! it drops forwarded all the same, is [`crate::mock::MockRelay`]'s staged forwarding. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::messages::ObjectHeader; +use crate::wai_dispatch::WAI1_MAGIC; + +/// The `wai_staged` member of a track's catalog entry (moq-streaming-format §4.1). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct StagedTrack { + /// The set's name, the same on every track of the set. + pub set: String, + /// 0 on a base track; `k` on a track that carries layer `k`. + pub layer: u8, + /// On a layer track of a live set, the time before a unit's presentation by which + /// the layer must arrive to be applied to it; `None` on a base track. Read only + /// while the set is live. + pub lead_ms: Option, +} + +impl StagedTrack { + /// The member of a catalog entry (an MSF track object), if it has one that reads: + /// a non-empty `set`, a `layer` from 0 to 255, `lead_ms` null or an integer below + /// 2^53. A member it does not define, an earlier `layers` among them, is ignored. + /// A set's extent is the tracks present (moq-streaming-format §4.1). + pub fn from_catalog_entry(entry: &Value) -> Option { + let w = entry.get("wai_staged")?.as_object()?; + let set = w.get("set")?.as_str().filter(|s| !s.is_empty())?.to_string(); + let layer = w.get("layer")?.as_u64().filter(|n| *n <= 255)? as u8; + let lead_ms = match w.get("lead_ms")? { + Value::Null => None, + v => Some(v.as_u64().filter(|n| *n < (1 << 53))?), + }; + Some(StagedTrack { set, layer, lead_ms }) + } +} + +/// What a layer envelope's `refines` says it refines (staged-delivery §3). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LayerRef { + /// The content hash of the set's base. + pub base: [u8; 32], + /// The stage the layer brings each unit it covers to, 1–255. + pub layer: u8, + /// The units it covers, `(from, count)`; `None` for every unit of the base. + pub span: Option<(u32, u32)>, +} + +/// The manifest of a `WAI1` envelope (WAI SPEC §2: the magic, a little-endian `u32` +/// length, the manifest JSON), read as JSON; `None` for anything else. +fn manifest(envelope: &[u8]) -> Option { + if envelope.len() < 8 || envelope[..4] != WAI1_MAGIC { + return None; + } + let len = u32::from_le_bytes(envelope[4..8].try_into().ok()?) as usize; + let json = envelope.get(8..8usize.checked_add(len)?)?; + serde_json::from_slice(json).ok() +} + +fn hash32(v: &Value) -> Option<[u8; 32]> { + let s = v.as_str()?.as_bytes(); + if s.len() != 64 { + return None; + } + let nib = |c: u8| match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + _ => None, + }; + let mut out = [0u8; 32]; + for (i, o) in out.iter_mut().enumerate() { + *o = (nib(s[2 * i])? << 4) | nib(s[2 * i + 1])?; + } + Some(out) +} + +/// What a `WAI1` envelope's `refines` says it refines, where it carries one that reads: +/// a base hash of 64 lowercase hex digits, a layer from 1 to 255, and a span (absent or +/// `null` for every unit) of a `from` and a `count` of at least 1. `None` for a base, for +/// anything that is not a `WAI1`, and for a `refines` that does not read; staged-delivery +/// §3 holds the rest of its rules, which a sink checks (§4.1). +pub fn refines_of(envelope: &[u8]) -> Option { + let m = manifest(envelope)?; + let r = m.get("refines")?.as_object()?; + let base = hash32(r.get("base")?)?; + let layer = r.get("layer")?.as_u64().filter(|l| (1..=255).contains(l))? as u8; + let span = match r.get("span") { + None | Some(Value::Null) => None, + Some(s) => { + let from = u32::try_from(s.get("from")?.as_u64()?).ok()?; + let count = u32::try_from(s.get("count")?.as_u64()?).ok().filter(|c| *c >= 1)?; + Some((from, count)) + } + }; + Some(LayerRef { base, layer, span }) +} + +/// The stage an envelope brings its units to: `refines.layer` for a layer, 0 for a +/// `WAI1` with no `refines` (a base); `None` for anything that is not a `WAI1`, or whose +/// `refines` does not read. +pub fn layer_from_envelope(envelope: &[u8]) -> Option { + let m = manifest(envelope)?; + match m.get("refines") { + None => Some(0), + Some(_) => refines_of(envelope).map(|r| r.layer), + } +} + +/// Whether a prototype header's layer agrees with the envelope it rides with: `None` +/// where the header states no layer (`mrl_total_layers` 0) or the payload states none +/// ([`layer_from_envelope`]); otherwise whether `mrl_layer` is the envelope's layer. A +/// sink reads the layer from the envelope whatever this says (staged-delivery §13); a +/// disagreement is evidence of a stale or rewritten header, never a reason to apply a +/// layer at another stage. +pub fn check_mrl_agrees(header: &ObjectHeader, envelope: &[u8]) -> Option { + if header.mrl_total_layers == 0 { + return None; + } + layer_from_envelope(envelope).map(|l| l == header.mrl_layer) +} + +/// moq-streaming-format §4.1 on a live staged track: each group carries exactly one +/// object, in a subgroup of its own or as a datagram. +pub fn live_object_rules_ok(group_objects: u32, own_subgroup_or_datagram: bool) -> bool { + group_objects == 1 && own_subgroup_or_datagram +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::track::{GroupId, ObjectId, ObjectStatus, TrackAlias}; + + fn envelope(manifest: &str) -> Vec { + let mut e = WAI1_MAGIC.to_vec(); + e.extend_from_slice(&(manifest.len() as u32).to_le_bytes()); + e.extend_from_slice(manifest.as_bytes()); + e.extend_from_slice(&3u32.to_le_bytes()); + e.extend_from_slice(b"abc"); + e + } + + fn header(mrl_layer: u8, mrl_total_layers: u8) -> ObjectHeader { + ObjectHeader { + track_alias: TrackAlias(8), + group_id: GroupId(0), + object_id: ObjectId(0), + publisher_priority: 96, + status: ObjectStatus::Normal, + payload_len: 0, + mrl_layer, + mrl_total_layers, + wai_envelope: true, + } + } + + const BASE: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + + #[test] + fn the_layer_is_read_from_the_envelope() { + let l2 = envelope(&format!(r#"{{"wai":"1.0","refines":{{"base":"{BASE}","prev":"{BASE}","layer":2,"span":{{"from":4,"count":1}}}}}}"#)); + assert_eq!(refines_of(&l2), Some(LayerRef { base: [0x11; 32], layer: 2, span: Some((4, 1)) })); + assert_eq!(layer_from_envelope(&l2), Some(2)); + let whole = envelope(&format!(r#"{{"refines":{{"base":"{BASE}","prev":"{BASE}","layer":1,"span":null}}}}"#)); + assert_eq!(refines_of(&whole).unwrap().span, None); + let base = envelope(r#"{"wai":"1.0"}"#); + assert_eq!((refines_of(&base), layer_from_envelope(&base)), (None, Some(0))); + for bad in [ + format!(r#"{{"refines":{{"base":"{BASE}","layer":0}}}}"#), + format!(r#"{{"refines":{{"base":"{BASE}","layer":256}}}}"#), + r#"{"refines":{"base":"11","layer":1}}"#.to_string(), + format!(r#"{{"refines":{{"base":"{}","layer":1}}}}"#, BASE.to_uppercase().replace('1', "A")), + format!(r#"{{"refines":{{"base":"{BASE}","layer":1,"span":{{"from":0,"count":0}}}}}}"#), + ] { + let e = envelope(&bad); + assert_eq!((refines_of(&e), layer_from_envelope(&e)), (None, None), "{bad}"); + } + assert_eq!(layer_from_envelope(b"WAI2...."), None); + assert_eq!(layer_from_envelope(b"WAI1"), None); + } + + /// The prototype header is compared, never obeyed (staged-delivery §13). + #[test] + fn the_prototype_header_is_only_compared() { + let l1 = envelope(&format!(r#"{{"refines":{{"base":"{BASE}","prev":"{BASE}","layer":1}}}}"#)); + assert_eq!(check_mrl_agrees(&header(0, 0), &l1), None, "no layer in the header"); + assert_eq!(check_mrl_agrees(&header(1, 3), &l1), Some(true)); + assert_eq!(check_mrl_agrees(&header(2, 3), &l1), Some(false)); + assert_eq!(check_mrl_agrees(&header(2, 3), b"not an envelope"), None); + } + + #[test] + fn staged_member_and_live_rules() { + let e = serde_json::json!({"name": "cam1/L1", "wai_staged": {"set": "cam1", "layer": 1, "layers": 2, "lead_ms": 20, "later": 1}}); + assert_eq!(StagedTrack::from_catalog_entry(&e), Some(StagedTrack { set: "cam1".into(), layer: 1, lead_ms: Some(20) }), "an earlier `layers` is ignored"); + let b = serde_json::json!({"wai_staged": {"set": "cam1", "layer": 0, "lead_ms": null}}); + assert_eq!(StagedTrack::from_catalog_entry(&b).unwrap().lead_ms, None); + for bad in [ + serde_json::json!({}), + serde_json::json!({"wai_staged": {"set": "", "layer": 0, "lead_ms": null}}), + serde_json::json!({"wai_staged": {"set": "a", "layer": 300, "lead_ms": null}}), + serde_json::json!({"wai_staged": {"set": "a", "layer": 0}}), + ] { + assert_eq!(StagedTrack::from_catalog_entry(&bad), None, "{bad}"); + } + assert!(live_object_rules_ok(1, true)); + assert!(!live_object_rules_ok(2, true)); + assert!(!live_object_rules_ok(1, false)); + } +} diff --git a/joule-code/joule-code-rs/crates/joule-moq/src/wai_dispatch.rs b/joule-code/joule-code-rs/crates/joule-moq/src/wai_dispatch.rs index d9d2a8597..b2ae95bd5 100644 --- a/joule-code/joule-code-rs/crates/joule-moq/src/wai_dispatch.rs +++ b/joule-code/joule-code-rs/crates/joule-moq/src/wai_dispatch.rs @@ -45,40 +45,48 @@ pub const TEXT_ZSTD: &str = "wai.text.zstd"; // ── Recommended modern set (every conforming sink SHOULD implement) ── -/// JPEG-XL image — ISO/IEC 18181. Lossless + lossy, modern SOTA. +/// JPEG XL image (ISO/IEC 18181), lossless and lossy. pub const IMAGE_JXL: &str = "wai.image.jxl"; -/// AVIF image — ISO/IEC 23000-22 (AV1-based). Lossy SOTA. +/// AVIF image (ISO/IEC 23000-22, AV1 intra coding), lossy. pub const IMAGE_AVIF: &str = "wai.image.avif"; -/// JPEG image — ITU-T T.81. Legacy compatibility. +/// JPEG image (ITU-T T.81). pub const IMAGE_JPEG: &str = "wai.image.jpeg"; -/// Opus audio — RFC 6716. Lossy SOTA. +/// Opus audio (RFC 6716), lossy. pub const AUDIO_OPUS: &str = "wai.audio.opus"; -/// AV1 video — AOMedia. Lossy SOTA. +/// AV1 video (AOMedia Video 1), lossy. pub const VIDEO_AV1: &str = "wai.video.av1"; -/// AV1 video with `quantizer=0` — AOMedia. YUV-lossless. +/// AV1 video with `quantizer=0` (AOMedia Video 1): lossless in YUV. pub const VIDEO_AV1_LOSSLESS: &str = "wai.video.av1.lossless"; -/// XZ/LZMA2 compressed bytes. Maximum classical text ratio. +/// XZ (LZMA2) compressed bytes. pub const TEXT_XZ: &str = "wai.text.xz"; // ── Neural capabilities (OPTIONAL; sink advertises if its ML runtime supports it) ── -/// Meta EnCodec @ 32 kHz, 4 codebooks. ~3 kbps acceptable music. +/// A learned audio codec at 32 kHz with four residual codebooks. pub const NEURAL_ENCODEC32: &str = "wai.neural.encodec32"; -/// Descript Audio Codec @ 44.1 kHz. 6 kbps near-transparent music. +/// A learned audio codec at 44.1 kHz with residual vector quantisation. pub const NEURAL_DAC: &str = "wai.neural.dac"; -/// Kyutai Mimi @ 12.5 Hz frame rate. ~1.1 kbps real-time speech. +/// A learned speech codec at a 12.5 Hz frame rate. pub const NEURAL_MIMI: &str = "wai.neural.mimi"; -/// WavTokenizer single-codebook VQ. 0.9 kbps; beats DAC @ 9 kbps on UTMOS. +/// A learned audio codec with a single codebook. pub const NEURAL_WAVTOKENIZER: &str = "wai.neural.wavtokenizer"; -/// bmshj2018-factorized + rANS (CompressAI). ~80× vs raw RGB at q=3. +/// A learned image codec (the bmshj2018 factorized prior) with rANS entropy coding. pub const NEURAL_BMSHJ2018: &str = "wai.neural.bmshj2018"; -/// Per-frame bmshj2018. Browser-decodable on WebGPU. +/// The bmshj2018 image codec applied frame by frame; decodable in a browser on WebGPU. pub const NEURAL_VIDEO_BMSHJ2018: &str = "wai.neural.video_bmshj2018"; // ── CommunicationOS-internal extensions (NON-WAI namespace) ── /// Matryoshka layered PCM audio (CommunicationOS-internal). Deterministic, /// codebook-free; lives in the `comms.*` namespace per `PLAN.md` D1. +/// +/// The value keeps its form. Its idea, a PCM ladder in which each layer +/// resamples the stage below and adds a residual, is registered in WAI as +/// `wai.audio.int_refine` with the `rate` predictor (WAI staged-delivery §6.1, +/// Appendix A.4), a refinement-only capability over an exact base, one layer to +/// an object, one track to a layer (moq-streaming-format §4.1). Whether this +/// `comms.*` string is retired in its favour is a registry decision, not this +/// crate's. pub const LAYERED_PCM: &str = "comms.audio.layered-pcm"; /// All known capability strings — registered WAI capabilities + the diff --git a/joule-code/joule-code-rs/crates/joule-moq/tests/staged_moq_trace.rs b/joule-code/joule-code-rs/crates/joule-moq/tests/staged_moq_trace.rs new file mode 100644 index 000000000..4b3d7d42b --- /dev/null +++ b/joule-code/joule-code-rs/crates/joule-moq/tests/staged_moq_trace.rs @@ -0,0 +1,435 @@ +//! A WAI stage set over joule-moq's relay: the trace of WAI's +//! `staged-conformance/moq/trace.json` (moq-streaming-format §4.1), run as a third +//! implementation beside WAI's reference and its independent verifier. +//! +//! The trace carries a set's base, layer 1 and layer 2 objects, one to a group, each +//! track under its own `track_alias`, through one relay to four subscribers: one of every +//! track; one of the base and layer 1 tracks only; one of those two whose relay drops +//! one receipts-track object, and which fetches the receipts its re-anchor skipped +//! (answered from the receipts the relay holds); and one of every track that joins +//! after the first GOP. +//! Here: +//! +//! - the publisher signs one labelled group per object, a chain for each track; +//! - [`MockRelay`] applies each object's delivery timeout at its own hop, from the +//! object's arrival there, on a clock this test drives (nothing sleeps); it drops the +//! layer 2 of two GOPs, forwards frame 5's layer 1 within its timeout, and sends every +//! receipt bundle on the receipts track; +//! - each subscriber's chains are a MoQ subscriber's ([`ReceiptChains::for_moq`]), under +//! the bound it states, joining where it joins: they verify each group's signature, +//! tie each receipt to its receipts track and MOQT object ([`ReceiptChains::admit_on`], +//! [`ObjectReceipt::check_moqt`]), keep a chain per key and track, hold a receipt whose +//! parent has not arrived, and past the bound refuse what they hold and re-anchor; the +//! subscriber checks a delivered object over its bytes and a dropped one by inclusion +//! only, and works out the stage it presents at each clock position with its own rule: +//! a unit reaches stage *k* when, for every layer up to *k*, an object of that layer +//! covering it was taken (arrived, its receipt verified) at least that layer's lead +//! before the unit's presentation. A layer whose receipt is refused is never taken. +//! +//! Each object's fate and arrival, when each receipt is verified or refused, the stages +//! presented, the deadline misses, each layer object's status and evidence and the +//! relay's counts must be the trace's. The subscriber of two tracks holds exactly their +//! two chains. +//! +//! The trace lives beside this crate in the monorepo; if it cannot be read the test +//! fails, it never passes on nothing. + +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::Arc; + +use bytes::Bytes; +use ed25519_dalek::SigningKey; +use joule_moq::mock::{MockFabric, MockRelay, ReceiptBundle, RelayTrackConfig}; +use joule_moq::staged::{layer_from_envelope, refines_of, StagedTrack}; +use joule_moq::{ + Admission, ChainRefusal, GroupId, HoldBound, ObjectHeader, ObjectId, ObjectReceipt, ObjectStatus, ReceiptBuilder, ReceiptChains, ReceiptVerifier, + ReceiptsTrack, TrackAlias, TrackName, TrackNamespace, +}; +use joule_moq::endpoint::DeliveredObject; +use serde_json::Value; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../../../wai/staged-conformance"); +const T: &str = "2026-10-06T00:00:00Z"; + +fn read(rel: &str) -> Value { + let b = std::fs::read(format!("{DIR}/{rel}")).unwrap_or_else(|e| panic!("{rel}: {e}")); + serde_json::from_slice(&b).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn u(v: &Value) -> u64 { + v.as_u64().unwrap_or_else(|| panic!("an integer, not {v}")) +} + +struct Obj { + track: String, + layer: u8, + alias: u64, + group: u64, + object_id: u64, + bytes: Vec, + hash: [u8; 32], + base: usize, + units: (u64, u64), + publish_ms: u64, + timeout_ms: u64, + up_ms: u64, + queue_ms: u64, + down_ms: u64, +} + +#[test] +fn staged_tracks_drop_on_timeout_keep_receipts() { + let trace = read("moq/trace.json"); + let catalog = read("moq/catalog.json"); + let ns = TrackNamespace::from_strs(["example", "cam1"]); + + // The trace's tracks are the catalog's: layer, lead and receipt alias. + let entries: BTreeMap<&str, &Value> = catalog["tracks"].as_array().unwrap().iter().map(|t| (t["name"].as_str().unwrap(), t)).collect(); + let mut lead: BTreeMap = BTreeMap::new(); + let mut by_name: BTreeMap = BTreeMap::new(); + let mut receipts_of: BTreeMap = BTreeMap::new(); + for t in trace["tracks"].as_array().unwrap() { + let name = t["name"].as_str().unwrap(); + let staged = StagedTrack::from_catalog_entry(entries[name]).expect("a staged entry"); + assert_eq!(u64::from(staged.layer), u(&t["layer"])); + assert_eq!(staged.lead_ms, t["lead_ms"].as_u64()); + assert_eq!(entries[name]["wai_receipt_alias"], t["receipt_alias"]); + assert_eq!(entries[name]["wai_receipts_track"], t["receipts_track"], "each track names its own receipts track"); + receipts_of.insert(name.to_string(), t["receipts_track"].as_str().unwrap().to_string()); + if let Some(l) = staged.lead_ms { + lead.insert(staged.layer, l); + } + by_name.insert(name.to_string(), (staged.layer, u(&t["receipt_alias"]))); + } + let clock = &trace["clock"]; + let presentation = |unit: u64| u(&clock["t0_ms"]) + unit * u(&clock["unit_ms"]) + u(&clock["target_latency_ms"]); + + // The envelopes. Each says, inside itself, which layer it is and which base it refines. + let case = trace["case"].as_str().unwrap(); + let objs: Vec = trace["objects"] + .as_array() + .unwrap() + .iter() + .map(|o| { + let bytes = std::fs::read(format!("{DIR}/cases/{case}/{}", o["file"].as_str().unwrap())).unwrap(); + let hash = ObjectReceipt::hash_payload(&bytes); + assert_eq!(hx(&hash), o["object"].as_str().unwrap(), "the trace names this object"); + let track = o["track"].as_str().unwrap().to_string(); + let (layer, alias) = by_name[&track]; + assert_eq!(layer_from_envelope(&bytes), Some(layer), "the envelope's own layer is its track's"); + Obj { + track, + layer, + alias, + group: u(&o["group"]), + object_id: u(&o["object_id"]), + bytes, + hash, + base: u(&o["base"]) as usize, + units: (u(&o["units"]["from"]), u(&o["units"]["count"])), + publish_ms: u(&o["publish_ms"]), + timeout_ms: u(&o["timeout_ms"]), + up_ms: u(&o["up_ms"]), + queue_ms: u(&o["queue_ms"]), + down_ms: u(&o["down_ms"]), + } + }) + .collect(); + let base_hash: Vec<[u8; 32]> = objs.iter().filter(|o| o.layer == 0).map(|o| o.hash).collect(); + let first_unit: Vec = objs.iter().filter(|o| o.layer == 0).map(|o| o.units.0).collect(); + for o in objs.iter().filter(|o| o.layer > 0) { + let r = refines_of(&o.bytes).expect("a layer"); + assert_eq!(r.base, base_hash[o.base], "it refines the base the trace says"); + let span = r.span.map_or((0, 4), |(f, c)| (u64::from(f), u64::from(c))); + assert_eq!((first_unit[o.base] + span.0, span.1), o.units, "its span is the trace's units"); + } + + // The publisher: one labelled group per object, each track its own chain. + let key = SigningKey::from_bytes(&[0x51; 32]); + let mut parent: BTreeMap = BTreeMap::new(); + let mut signed: BTreeMap<(u64, u64), (ReceiptBundle, DeliveredObject)> = BTreeMap::new(); + let mut order: Vec<&Obj> = objs.iter().collect(); + order.sort_by_key(|o| (o.alias, o.group)); + for o in order { + let mut b = ReceiptBuilder::new(TrackAlias(o.alias), GroupId(o.group)); + b.add_labelled_object(o.hash, 0, "origin.example", T, vec![0]).unwrap(); + let proof = b.proof_for(0).unwrap(); + let group = b.finalize_labelled(&key, "did:example:publisher", T, parent.get(&o.alias).copied()).unwrap(); + parent.insert(o.alias, group.link_hash()); + let receipt = ObjectReceipt { + track_alias: TrackAlias(o.alias), + group_id: GroupId(o.group), + object_id: ObjectId(o.object_id), + content_hash: o.hash, + joules_micro: 0, + origin: "origin.example".into(), + merkle_proof: proof, + energy_class: Some(vec![0]), + }; + let header = ObjectHeader { + track_alias: TrackAlias(o.alias), + group_id: GroupId(o.group), + object_id: ObjectId(o.object_id), + publisher_priority: 0, + status: ObjectStatus::Normal, + payload_len: o.bytes.len() as u64, + mrl_layer: 0, + mrl_total_layers: 0, + wai_envelope: true, + }; + let delivered = DeliveredObject { header, payload: Bytes::from(o.bytes.clone()), receipt: Some(receipt.clone()) }; + signed.insert((o.alias, o.group), (ReceiptBundle { group, objects: vec![receipt] }, delivered)); + } + + // The relay, its tracks live and staged, and the subscribers. + let relay = MockRelay::new(Arc::new(MockFabric::new())); + for name in by_name.keys() { + let receipts_track = Some(TrackName::from_str(&receipts_of[name])); + relay.configure_track(&ns, &TrackName::from_str(name), RelayTrackConfig { delivery_timeout_ms: 0, staged_live: true, receipts_track }); + } + let subscribers = trace["subscribers"].as_array().unwrap(); + let subscribe = |s: &Value| { + let names: Vec<&str> = s["tracks"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect(); + let tracks: Vec<(TrackNamespace, TrackName)> = names.iter().map(|t| (ns.clone(), TrackName::from_str(t))).collect(); + // It subscribes to the receipts tracks of the tracks it subscribes to, and no others. + let receipts: Vec<(TrackNamespace, TrackName)> = + if s["receipts"] == true { names.iter().map(|t| (ns.clone(), TrackName::from_str(&receipts_of[*t]))).collect() } else { vec![] }; + relay.subscribe_tracks(&tracks, u(&s["timeout_ms"]), &receipts) + }; + // A subscriber that joins subscribes when the clock reaches its join; the others now. + let mut subs: Vec> = subscribers.iter().map(|s| s["joins_ms"].is_null().then(|| subscribe(s))).collect(); + + // The clock: everything reaches the relay at its publication plus its upstream time, + // and the relay passes on what is due as the clock moves. + let mut arrivals: Vec<(u64, usize)> = objs.iter().enumerate().map(|(i, o)| (o.publish_ms + o.up_ms, i)).collect(); + arrivals.sort(); + for &(at, i) in &arrivals { + for (si, s) in subscribers.iter().enumerate() { + if let Some(j) = s["joins_ms"].as_u64() { + if subs[si].is_none() && at >= j { + relay.advance(j); + subs[si] = Some(subscribe(s)); + } + } + } + relay.advance(at); + let o = &objs[i]; + let (bundle, delivered) = signed[&(o.alias, o.group)].clone(); + relay.ingest_receipts(&ns, &TrackName::from_str(&receipts_of[&o.track]), bundle, at); + relay.ingest(&ns, &TrackName::from_str(&o.track), delivered.clone(), at, o.queue_ms, Some(o.timeout_ms)).unwrap(); + // A second object in a live staged group is refused. + assert!(relay.ingest(&ns, &TrackName::from_str(&o.track), delivered, at, o.queue_ms, Some(o.timeout_ms)).is_err()); + } + relay.advance(u64::MAX); + + let stats = relay.relay_stats(); + assert_eq!(stats.dropped_on_timeout, u(&trace["expect"]["relay"]["dropped_on_timeout"])); + assert_eq!(stats.receipts_forwarded_for_dropped, u(&trace["expect"]["relay"]["receipts_forwarded_for_dropped"])); + assert!(stats.dropped_on_timeout > 0 && stats.receipts_forwarded_for_dropped == stats.dropped_on_timeout); + + let by_hash: BTreeMap<[u8; 32], usize> = objs.iter().enumerate().map(|(i, o)| (o.hash, i)).collect(); + let by_link: BTreeMap<[u8; 32], usize> = signed.iter().map(|((a, g), (b, _))| (b.group.link_hash(), objs.iter().position(|o| o.alias == *a && o.group == *g).unwrap())).collect(); + let pk = key.verifying_key().to_bytes(); + let verifier = ReceiptVerifier::new(); + for (si, s) in subscribers.iter().enumerate() { + let exp = &trace["expect"]["subscribers"][si]; + let mine: BTreeSet<&str> = s["tracks"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect(); + let aliases: BTreeSet = mine.iter().map(|t| by_name[*t].1).collect(); + // A receipts-track object its relay drops at its discretion (MOQT §5.2) never + // reaches it: this test removes it from what the relay sent. + let lost: BTreeSet<(String, u64)> = s["lost_receipts"].as_array().unwrap().iter().map(|l| (l["track"].as_str().unwrap().to_string(), u(&l["group"]))).collect(); + // What reached it: objects, and the receipts of the tracks it subscribed to, which + // are all the receipts it was sent. + let sub = subs[si].as_mut().expect("every subscriber subscribed"); + let mut events: Vec<(u64, u8, usize)> = Vec::new(); + let mut arrival: BTreeMap = BTreeMap::new(); + while let Ok(x) = sub.objects.try_recv() { + let i = by_hash[&x.item.receipt.as_ref().unwrap().content_hash]; + assert_eq!(&x.item.payload[..], &objs[i].bytes[..], "the relay forwards the envelope unaltered"); + arrival.insert(i, x.sent_ms + objs[i].down_ms); + events.push((x.sent_ms + objs[i].down_ms, 2, i)); + } + let rx = sub.receipts.as_mut().expect("it asked for receipts"); + let mut bundles: BTreeMap = BTreeMap::new(); + let mut reached: BTreeSet = BTreeSet::new(); + while let Ok(x) = rx.try_recv() { + assert!(aliases.contains(&x.item.group.track_alias.0), "a receipts track it did not subscribe to reached it"); + let i = by_hash[&x.item.objects[0].content_hash]; + reached.insert(i); + if lost.contains(&(objs[i].track.clone(), objs[i].group)) { + continue; + } + events.push((x.sent_ms + objs[i].down_ms, 0, i)); + bundles.insert(i, x.item); + } + events.sort(); + // The objects it received: those whose receipts reached it. + let received: Vec = reached.iter().copied().collect(); + + // Its chains: a MoQ subscriber's, under the bound it states, joining where it joins. + let bound = HoldBound::from_json(&s["hold"]).expect("the subscriber states its bound"); + let mut chains = ReceiptChains::for_moq(bound).expect("a bound a MoQ subscriber may state"); + if s["joins_ms"].is_u64() { + chains = chains.with_join(); + } + let mut settled: BTreeMap = BTreeMap::new(); + let mut anchors_seen: BTreeMap = BTreeMap::new(); + let mut waited = 0; + // Receipts in time order: those of the receipts track (0), then those fetched after + // a re-anchor (1), then objects (2), which the chains do not see. + let fetch_ms = s["fetch_ms"].as_u64(); + let mut fetched: BTreeMap = BTreeMap::new(); + let mut queue: std::collections::BinaryHeap> = events.iter().copied().map(std::cmp::Reverse).collect(); + while let Some(std::cmp::Reverse((at, kind, i))) = queue.pop() { + if kind == 2 { + continue; + } + let o = &objs[i]; + let track = ReceiptsTrack { publisher_key: pk, track_alias: TrackAlias(o.alias) }; + if kind == 1 { + // The relay answers the FETCH from the receipts it holds; each fetched + // receipt fills the gap its chain's anchor left. + let bundle = signed[&(o.alias, o.group)].0.clone(); + assert_eq!(bundle.objects[0].check_moqt(TrackAlias(o.alias), GroupId(o.group), ObjectId(o.object_id)), Ok(())); + let ok = chains.admit_on(&track, GroupId(o.group), &bundle.group, at) == Ok(Admission::History); + assert!(chains.take_events().is_empty()); + bundles.insert(i, bundle); + fetched.insert(i, at); + settled.entry(i).or_insert((ok, at)); + continue; + } + let bundle = &bundles[&i]; + assert_eq!(bundle.objects[0].check_moqt(TrackAlias(o.alias), GroupId(o.group), ObjectId(o.object_id)), Ok(()), "the receipt names its MOQT object"); + let mut now: Vec<(usize, bool)> = Vec::new(); + match chains.admit_on(&track, GroupId(o.group), &bundle.group, at) { + Ok(_) => now.push((i, true)), + Err(r) => { + assert_eq!(r, ChainRefusal::ParentNotHeld, "a receipt is held for its parent, never accepted silently"); + waited += 1; + } + } + for ev in chains.take_events() { + now.push((by_link[&ev.link], ev.outcome.is_ok())); + } + // A re-anchor skips the groups between the last group admitted and its anchor: + // refused for want of their receipts, unless the subscriber fetches them. + let all = chains.anchors(&pk, TrackAlias(o.alias)); + for an in &all[anchors_seen.get(&o.alias).copied().unwrap_or(0)..] { + let Some(after) = an.after else { continue }; + let skipped: Vec = received.iter().copied().filter(|&j| objs[j].alias == o.alias && after < objs[j].group && objs[j].group < an.anchor).collect(); + match fetch_ms { + Some(f) => { + now.retain(|(j, _)| !skipped.contains(j)); + queue.extend(skipped.iter().map(|&j| std::cmp::Reverse((at + f, 1, j)))); + } + None => now.extend(skipped.iter().map(|&j| (j, false))), + } + } + anchors_seen.insert(o.alias, all.len()); + for (j, ok) in now { + settled.entry(j).or_insert((ok, at)); + } + } + assert_eq!(chains.held_total(), 0, "every receipt settled"); + assert_eq!(settled.len(), received.len(), "every object it received has its receipt settled"); + assert_eq!(chains.chains(), mine.len(), "one chain for each track it subscribed to"); + if s["name"] == "full" { + assert!(waited > 0, "frame 5's slow receipt holds the layers after it"); + } + if !lost.is_empty() && fetch_ms.is_none() { + assert!(settled.values().any(|(ok, _)| !ok), "the lost receipt's layers are refused"); + } + if !lost.is_empty() && fetch_ms.is_some() { + assert!(!fetched.is_empty() && fetched.keys().all(|i| settled[i].0), "every fetched receipt fills the gap, as history"); + } + + // Each object: delivered and checked over its bytes, or dropped and checked by + // inclusion only, where its receipt verified; the fates, arrivals and when each + // receipt was verified or refused are the trace's. + let mut taken: BTreeMap = BTreeMap::new(); + let mut fates = Vec::new(); + let mut evidence: BTreeMap = BTreeMap::new(); + for &i in &received { + let o = &objs[i]; + let (ok, when) = settled[&i]; + let (v, r) = if ok { (Some(when), None) } else { (None, Some(when)) }; + match arrival.get(&i) { + Some(&a) => { + if ok { + let g = verifier.verify_group(&bundles[&i].group).unwrap(); + verifier.verify_payload(&o.bytes, &bundles[&i].objects[0], &g).expect("delivered, checked over its bytes"); + taken.insert(i, a.max(when)); + } + evidence.insert(i, if ok { "delivered-verified" } else { "none" }); + fates.push((o.track.clone(), o.group, "forwarded".to_string(), Some(a), v, r)); + } + None => { + if ok { + let g = verifier.verify_group(&bundles[&i].group).unwrap(); + verifier.verify_object(&bundles[&i].objects[0], &g).expect("dropped, checked by inclusion"); + } + evidence.insert(i, if ok { "inclusion-verified" } else { "none" }); + fates.push((o.track.clone(), o.group, "dropped".to_string(), None, v, r)); + } + } + } + let stated: Vec<(String, u64, String, Option, Option, Option)> = exp["objects"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["track"].as_str().unwrap().to_string(), u(&f["group"]), f["fate"].as_str().unwrap().to_string(), f["arrival_ms"].as_u64(), f["verified_ms"].as_u64(), f["refused_ms"].as_u64())) + .collect(); + assert_eq!(fates, stated, "{}: fates", s["name"]); + let fetched_at: Vec> = received.iter().map(|i| fetched.get(i).copied()).collect(); + let stated: Vec> = exp["objects"].as_array().unwrap().iter().map(|f| f["fetched_ms"].as_u64()).collect(); + assert_eq!(fetched_at, stated, "{}: which receipts were fetched, and when", s["name"]); + + // The stage at each clock position of the bases it took, by this test's own rule. + let units: Vec = received.iter().filter(|i| objs[**i].layer == 0 && taken.contains_key(i)).flat_map(|&i| objs[i].units.0..objs[i].units.0 + objs[i].units.1).collect(); + let max_layer = objs.iter().filter(|o| mine.contains(o.track.as_str())).map(|o| o.layer).max().unwrap(); + let mut applied: BTreeMap> = BTreeMap::new(); + let mut presented = Vec::new(); + let mut misses = 0; + for unit in units { + let (mut stage, mut expected, mut in_time) = (0u8, 0u8, true); + for layer in 1..=max_layer { + let covering = objs.iter().enumerate().find(|(i, o)| o.layer == layer && received.contains(i) && (o.units.0..o.units.0 + o.units.1).contains(&unit)); + let Some((i, _)) = covering else { break }; + let Some(&t) = taken.get(&i) else { break }; + expected += 1; + in_time = in_time && t + lead[&layer] <= presentation(unit); + if in_time { + stage += 1; + applied.entry(i).or_default().insert(unit); + } + } + misses += u64::from(stage < expected); + presented.push(u64::from(stage)); + } + assert_eq!(presented, exp["presented"].as_array().unwrap().iter().map(u).collect::>(), "{}: presented", s["name"]); + assert_eq!(misses, u(&exp["deadline_misses"]), "{}: deadline misses", s["name"]); + + // What a stage claim says of each layer object. + for st in exp["statuses"].as_array().unwrap() { + let i = by_hash.iter().find(|(h, _)| hx(&h[..]) == st["object"].as_str().unwrap()).map(|(_, i)| *i).unwrap(); + let got = applied.get(&i).map_or(0, |a| a.len() as u64); + let status = if !arrival.contains_key(&i) { + "dropped" + } else if !taken.contains_key(&i) { + "refused" + } else if got == objs[i].units.1 { + "applied" + } else if got > 0 { + "partial" + } else { + "late" + }; + assert_eq!(status, st["status"].as_str().unwrap(), "{}: {}", s["name"], st["object"]); + assert_eq!(evidence[&i], st["evidence"].as_str().unwrap(), "{}: {}", s["name"], st["object"]); + } + } +} diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 9df8c5902..409d82ca6 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,113 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Staged tracks over MoQ: a track per layer, a receipts track per track, delivery timeouts per hop, chains that recover + +- **Re-read against the drafts.** [moq-streaming-format](extensions/moq-streaming-format.md) + names the revisions it was last read against, `draft-ietf-moq-transport-22`, + `draft-ietf-moq-msf-01` and `draft-ietf-moq-cmsf-01`, and cites their sections. The + re-read corrected: MSF's packaging values (`catalog` is a track name, not a value); what + orders delivery (MOQT, subscriber priority first, then publisher priority, MOQT + §5.1.2); how properties are carried (MOQT's track and object properties, of which MSF + defines one); `SUBGROUP_DELIVERY_TIMEOUT`, which runs from the subgroup's FIN to its + acknowledgement, not from an object's arrival; that MSF maps every object to a stream + of its own, so a staged object never travels as a datagram; and that MOQT-22 replaced + the Joining FETCH with fill fetch streams. +- **§4.1 Staged tracks.** A stage set is carried one track per layer: base tracks and a + track for each layer index, each object one whole envelope. Each entry carries + `wai_staged` (`set`, `layer`, `lead_ms`). A set's extent is its tracks, layers 0 to the + highest with a track at each, so an MSF delta update that adds, removes or clones a + track changes it alone; `lead_ms` is read only while the set is live. Tracks are named + by namespace and name (a namespace inherited as MSF says), unique in a catalog, one + namespace to a set; the check needs the catalog track's namespace, and a checker that + does not know it refuses a set that mixes declared and inherited namespaces; `renderGroup` is an integer that fits 64 unsigned bits and + `depends` an array of strings, each naming a track of the catalog in the entry's own + namespace; a layer track depends on a track of every lower layer and on none of its + own or above. A sink checks a set's entries in a fixed order and reports the first + failure (`staged-malformed`, `staged-set-incomplete`, `staged-depends`, + `staged-grouping`, `staged-lead`, `staged-receipts`), treating a failing set's layer + tracks as absent. On a live track each group carries one object in a subgroup of its + own. The base track's publisher priority is first among the set's (lower numbers first), + though a subscriber's own priority comes before it. A layer object's + `OBJECT_DELIVERY_TIMEOUT` is at most its slack to the last unit it covers, settable + per object; a base's never shorter than the time to its last unit. + Timeouts run per hop and never decide lateness; a sink's longer target latency cannot + extend a publisher's timeout. A subscriber caps its stage, and its receipts, by + subscription. +- **Receipts.** `wai_receipt_alias` (§4) is the `track_alias` a track's receipts sign: + MOQT's Track Alias is chosen per session by each publisher on the path, so it is not + that number past the first hop. Every alias in a catalog is an integer, compared as a + number. `wai_receipts_track` names each media track's own **receipts track** + (`wai-receipts`, at the media track's priority, no delivery timeout), whose group *g* + carries the receipts of the media track's group *g*. No two tracks share an alias or a + receipts track: a rendition of a layer, a clone among them, overrides both and has a + chain of its own. Each receipt says which MOQT object it is about — `group_id` the + MOQT Group ID, `object_id` the Object ID, the publisher's key and the track's alias — + and a subscriber refuses one that does not (`moqt_mismatch`, a new jwp-receipts §3.3 + reason) before verifying it, holding nothing of it. A subscriber sets no delivery + timeout on its subscription to a receipts track. A MOQT relay that carries receipts as + opaque payload is not a jwp-receipts §4 relay; the subscriber verifies every receipt + (jwp-receipts §4.1 amended). +- **Recovery.** A missing receipt is recovered by FETCH of its receipts-track group. A + subscriber states its bound in counts: receipts held live for each chain (a time only + beside it, never alone), held as history for each chain, and held across its chains. + Past the live count the held receipts are refused (`parent_not_recovered`, a new + jwp-receipts §3.3 reason) with their layers, the chain re-anchors at its next validly + signed group, and the layers of the groups the anchor skips are refused. A subscriber + that joins mid-stream declares a join and anchors at its first group. Each anchor + leaves a gap, which a group linking to either end fills as history, in any order — the + ascending order of a FETCH or a fill fetch stream included — and never as a fork; + history waits under its own count. A group admitted before, offered again with the + same bytes (MOQT can deliver one object twice, by a subscription and a fill), changes + nothing (`accept again`), never `chain_break`, and its objects verify against it as + against the head. jwp-receipts §3 step 3 defines holding, + re-anchoring, joining and history, and says what an anchor does not prove; §6.1 says + how anchors are reported and why the bounds hold across chains. +- **Other text.** staged-delivery §8, §9 and §13; jwp-receipts §4.4 and §5.9; + http-delivery §10.2 and Appendix C. The registry lists `wai_staged`, + `wai_receipt_alias`, `wai_receipts_track` and `wai-receipts` as wire identifiers. +- **Reference implementation.** `staged::moq` (feature `staged`): `StagedTrack`, + `SetTrack::to_catalog_json`, `receipts_track_json`, `check_catalog` and + `check_catalog_in`, `depends_for`, `live_object_rules_ok`, `priorities_ok`, + `effective_timeout_ms`, `expired_at_hop`, `layer_timeout_ms`, `base_timeout_floor_ms`. + `jwp_delivery::ReceiptChains` gains `with_hold`, `with_join`, `admit`, `expire`, + `take_events` and `anchors`, and for MoQ `for_moq` (every count at most + `MAX_HELD_GROUPS`) and `admit_on`; `HoldBound` states live, history and total counts + (`from_json` refuses a statement without them); `ObjectReceipt::check_moqt`. Each + group's signature is verified once, and a held group is offered again only when a + group it links to is admitted (`verifications`, `reoffers`, `held_total` count them). + Without recovery it behaves as before. `tests/staged_moq.rs` runs the corpus trace + with signed receipts through `for_moq` under each subscriber's stated bound, + per-track chains, dropped objects checked by inclusion and named by the relay's + `delivery-gap` claims, and sealed `stage` claims. +- **Corpora.** `staged-conformance/moq/`: the set's MSF catalog; catalogs around §4.1's + check, among them type cases, namespace inheritance under the catalog track's + namespace, another and none known, MSF delta updates (a clone of a layer refused + unless it overrides its alias and receipts track) and conversion to on demand; and a + trace of the set through one relay to four subscribers, two layer objects dropped on + timeout and one forwarded within its timeout and late, one subscriber's receipt lost, + recovered by re-anchoring and the gap then fetched and filled as history, and one + subscriber joining mid-stream. `rate_port.json`: + the `rate` predictor beside the f64 interpolation of the layered-PCM ladder idea, + equal wherever that form is exact, differing (pinned) where it rounds next to a half. + `jwp-conformance/` gains recovery vectors — among them a gap fetched in ascending + order after a re-anchor, a joiner's ascending fill under a live bound of 4, the + history bound passed, the bound across chains, and a group delivered twice — appended + after every existing one. `staged_verify.py` and `jwp_verify.py` check all of it independently; + `chain_gap_differential.py` lets both chain verifiers recover. +- **joule-moq** (`joule-code/`): `staged` (the `wai_staged` member, the layer read from the + envelope, the prototype `mrl_*` header compared and never obeyed), `ReceiptChains` per + key and track, verifying each group's signature itself and knowing a group admitted + before by its bytes, with the same recovery and bounds, and a `MockRelay` that forwards staged tracks on + a caller-driven clock: per-hop timeouts, capping by subscription, one object to a live + group, a receipts track per media track, `RelayStats` keyed by track, group and object, + and send times past the clock refused. Its test `staged_tracks_drop_on_timeout_keep_receipts` + runs the same trace. +- No existing envelope, receipt or claim byte changes. Two existing jwp-conformance + vectors change their expected outcome: an exact replay of a group the chain admitted + (`chain_head_predecessor_replayed`, and g1 in `chain_legacy_replay_loop`) is now + `accept again`, not `chain_break`. + ### The session claim: what a sink reconstructed - **The `reconstruct-session` class** ([jwp-receipts](extensions/jwp-receipts.md) diff --git a/wai/REGISTRY.md b/wai/REGISTRY.md index 094584e24..987b33608 100644 --- a/wai/REGISTRY.md +++ b/wai/REGISTRY.md @@ -292,6 +292,7 @@ and a role are fixed once registered. | `wai-module-grant` | document-kind | defined | jwp-receipts §7.9 | | `wai-module-grants` | document-kind | defined | http-delivery §8.4 | | `wai-receipt-index` | document-kind | defined | http-delivery §8.2 | +| `wai-receipts` | catalog | defined | moq-streaming-format §4.1 | | `wai1` | codecs-prefix | defined | http-delivery §4.1 | | `wai1` | fourcc | provisional | http-delivery Appendix B | | `wai2` | codecs-prefix | defined | http-delivery §4.1 | @@ -301,5 +302,8 @@ and a role are fixed once registered. | `wai_delivery_timeout_ms` | catalog | defined | packaging §8 | | `wai_determinism` | catalog | defined | moq-streaming-format §4 | | `wai_layer` | catalog | defined | packaging §8 | +| `wai_receipt_alias` | catalog | defined | moq-streaming-format §4 | | `wai_receipts` | catalog | defined | moq-streaming-format §4 | +| `wai_receipts_track` | catalog | defined | moq-streaming-format §4 | +| `wai_staged` | catalog | defined | moq-streaming-format §4.1 | diff --git a/wai/extensions/http-delivery.md b/wai/extensions/http-delivery.md index 67591a039..38dd2506b 100644 --- a/wai/extensions/http-delivery.md +++ b/wai/extensions/http-delivery.md @@ -522,7 +522,8 @@ at which the refinement, once received and verified, can still be applied, which layer is its `lead_ms` before the presentation time of the first unit it covers ([staged-delivery](staged-delivery.md) §8). When the deadline passes, the client abandons the request and presents what it has. This is the HTTP counterpart of the -delivery timeout after which a MoQ relay drops an object; on either binding a +delivery timeout after which a MoQ relay drops an object, which MoQ runs at each hop +([moq-streaming-format](moq-streaming-format.md) §4.1); on either binding a refinement never stalls its base, and a layer that arrives late is not applied to the units that are already due (`deadline-missed`, staged-delivery §8). @@ -843,11 +844,11 @@ class WAIMetaSampleEntry(type) extends MetaDataSampleEntry(type) { WAIConfigu | catalog `wai_capabilities`, exhaustive (§4 there) | `codecs` union (§4.2); `codecs_undeclared` is the same protocol error | | (no floor) | `X-WAI-FLOOR` / DASH `SupplementalProperty` | | `WAI_RECEIPT` property (§5 there) | index item `property` (§8.2) | -| group receipt per group | `rcpt/.json`; a group per segment (on demand) or per segment with its parts (Live-P) | +| group and object receipts, on each track's receipts track (§4.1 there) | `rcpt/.json`; a group per segment (on demand) or per segment with its parts (Live-P) | | a layer object on its own track | a layer object listed by `#WAI-LAYER` (§10.4); its layer is its `refines.layer` either way | -| a subscriber's layer cap | the client's depth policy; nothing is sent to the edge | -| a relay drops layers above the cap, receipts intact | the client does not request them; caches never drop; receipts stay listed (§10.5) | -| the delivery timeout after which a relay drops an object | the client's refinement deadline (§10.2) | +| a subscriber's layer cap: the layer tracks it subscribes to (§4.1 there) | the client's depth policy; nothing is sent to the edge | +| a relay forwards the tracks asked for, drops an object only at a delivery timeout or at its discretion, and forwards its receipts (§4.1, §5 there) | the client does not request them; caches never drop; receipts stay listed (§10.5) | +| the delivery timeout after which a relay drops an object, run at each hop (§4.1 there) | the client's refinement deadline, run at the client (§10.2) | | publisher priority | RFC 9218 urgency (§10.1) | ## Appendix D — Worked example diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 7e7f93b8f..b2fcc8f15 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -1446,6 +1446,13 @@ match the algorithm style of WAI SPEC.md §4 (numbered, terminating). group's key and track, with the **link hash** of a group defined below: - the chain's head offered again passes: another of its objects is being verified; + - a group the verifier admitted to the chain before, offered again with + the same bytes — a legacy group's unsigned parent among them — passes + and changes nothing (`accept again`), never `chain_break`: a carrier + can deliver one object twice, as MOQT does by a subscription and a fill + (MOQT §3.4), and the group's objects are verified against it as against + the head. A legacy group whose parent differs from the one admitted is + not that group, and is checked as below; - with no group of the chain held, a `null` parent starts the chain, and any other parent is `parent_not_held`; - with the chain's head held, a parent that is the head's link hash @@ -1461,6 +1468,66 @@ match the algorithm style of WAI SPEC.md §4 (numbered, terminating). earlier groups of the chain tells them apart, and refuses both either way. + **Holding.** A verifier MAY hold a group that is `parent_not_held` and + offer it again when its chain admits a group it links to — the group its + parent names, or, inside a gap (*History*, below), the group that names it + as parent; a held group that then links is accepted. A verifier that holds + **MUST** state a bound on what it holds: for each chain, a count of groups + held live, a time from each one's arrival, or both; for each chain, a count + of groups held as history, those whose `group_id` lies inside a gap the + chain's anchors left, outside the live count; and a count across all its + chains, of both kinds, since any key makes a chain. When holding a group + live would pass its chain's live bound or the count across chains, or a + group held live passes the time, the verifier refuses every group it + holds live for that chain (`parent_not_recovered`) and re-anchors the + chain. When holding a group as history would pass its chain's history + count or the count across chains, it refuses the group held as history + with the lowest `group_id` below it, or, if there is none, the group + itself (`parent_not_recovered`); the chain does not re-anchor. A verifier + verifies each group's signature once: a group it has admitted or holds is + the same bytes, by its link hash. + + **Re-anchoring.** The next group of a re-anchored chain that passes steps + 1 and 2, the freshness bound of §6.1 where the verifier applies it, and + whose `group_id` exceeds the chain head's, becomes the chain's head + whatever its parent: its **anchor**. Where the bound is a count, the group + whose arrival passes it is that next group. + + **Joining.** A verifier that begins to verify a track after its first + group **MAY** declare that it joins. It then anchors each chain it holds + no group of at the first group of the chain that passes steps 1 and 2 and + the freshness bound where it applies it, whatever its parent (a `null` + parent is the chain's true start, and no anchor). + + **History.** Each anchor leaves a **gap**: below it, the last group + admitted before the anchor (none for a join); above it, the earliest group + admitted at or after the anchor. A group whose `group_id` lies between the + two and that links to either end is accepted as **history**: forward, its + parent is the link hash of the group below; backward, its link hash is the + parent the group above names under its signature. Either way it becomes + that end, link by link, so a gap fills in any order — ascending, as a + fetch delivers it, descending, or from both ends — and once one group + links to both ends the gap is closed. A group that links into a gap is + history, never a fork: `chain_break` stays for a group naming a parent + that already has a child on the chain, or for a second chain head. So a + receipt fetched after its chain re-anchored, or before the group a joiner + anchored at, is admitted. A legacy group's parent is unsigned, so nothing + is accepted backward from a legacy anchor or from a legacy group of the + history; forward, a legacy group links into a gap as it links to a head, + with a `group_id` above the group below. A verifier **MAY** keep a bounded + number of gaps open for each chain and close the oldest past it. + + An anchor is not a link. A join or a re-anchor establishes what the + anchor's signature does — that its key signed this group, at this + `group_id` — and nothing about the groups it skips: those before the anchor + of a join, or between the last group admitted and the anchor of a + re-anchor, may have been published and withheld, or never published. A + verifier **MUST** report a chain it anchored as anchored, with the + anchor's `group_id` and, after a re-anchor, the `group_id` of the last + group it admitted before it, and **MUST NOT** report the groups skipped as + verified. The objects of every group it admits are checked by steps 4–6 as + any others (§6.1). + **A legacy link authenticates nothing.** The legacy payload (§2.3) does not sign `parent_receipt_hash`, and a legacy group's link hash does not cover it, so a relay can rewrite it at will: a legacy group linked to the @@ -1878,6 +1945,7 @@ hand. | §3 step 2 | `signature_verify_failed` | the group signature fails §3.1 step 3's rule over the payload of the group's form | | §3 step 3 | `chain_break` | within the chain of the group's (`signer_pubkey`, `track_alias`), whose head the verifier holds: a `null` parent (a second chain head), or a parent that is another group of the chain the verifier holds (a fork) | | §3 step 3 | `parent_not_held` | a non-null parent that names no group of the chain the verifier holds: requested, never accepted silently | +| §3 step 3 | `parent_not_recovered` | a group held for its parent past the verifier's stated bound: held live, refused when its chain re-anchors; held as history, refused alone | | §3 step 3 | `group_id_not_increasing` | a `group_id` no greater than its chain head's: for a legacy group always, for a labelled one under §6.1's opt-in guard | | §3 step 3 | `group_not_fresh` | under §6.1's opt-in freshness bound, a `group_end` older than the window, further ahead of the verifier's clock than the skew it allows, or not a date-time, in a group whose `group_id` is not below the chain's live edge | | §3 step 4 | `content_hash_mismatch` | the envelope does not hash to `content_hash` | @@ -1889,6 +1957,7 @@ hand. | the group total | `group_coverage_disagrees` | a labelled group's coverage is not the one its objects give | | the group total | `group_uncertainty_below_parts` | a labelled group declares less than the composed `relative_ppm`, or another window | | `WAI_RECEIPT` | `property_invalid`, `property_disagrees` | as [moq-streaming-format](moq-streaming-format.md) §5 | +| carried over MoQ | `moqt_mismatch` | as [moq-streaming-format](moq-streaming-format.md) §4.1: a receipt whose key or `track_alias` is not its receipts track's, or whose `group_id` or `object_id` is not the MOQT Group ID or Object ID of the object it is for; refused before its signature is checked, and not held | --- @@ -1900,6 +1969,18 @@ chain across hops, a conforming relay: ### 4.1. MUST +**Receipts carried as payload.** Where a carrier moves receipts as the +opaque payload of its own objects — the receipts tracks of +[moq-streaming-format](moq-streaming-format.md) §4.1, whose relays read no +payload (MOQT §7.8) — a relay of that carrier forwards them without reading +them. Such a relay is not a relay of this section: it cannot verify a group +before fanning it out, and does not; the subscriber verifies every receipt +itself, by §3, and the carrier's forwarding of the receipts' own objects is +what forwards them. The subscriber ties each receipt to the carrier's object +it came on: a receipt whose key, track or position is not that object's is +refused before anything of it is held (`moqt_mismatch` for MoQ). The duties +below bind a relay that reads the receipts it forwards. + - Forward every group receipt and every object receipt **unaltered** to every subscriber that requested receipts for the corresponding track. Byte-for-byte equality of receipt bytes between ingress and @@ -1986,7 +2067,8 @@ that rides on the object itself, such as `WAI_RECEIPT` ([moq-streaming-format](moq-streaming-format.md) §5), is lost with the object, so a stream that relies on this accounting also carries its receipts where a dropped object does not take them with it (a sidecar, or -a track of its own). +a track of its own: on MoQ, the receipts track of moq-streaming-format +§4.1, on HTTP the receipt index of http-delivery §8.2). ### 4.5. HTTP caches @@ -2214,8 +2296,9 @@ first, it reports `parent_not_held` and does not accept it (`v6_per_track_chain_layer_without_its_first`); and a track 8 group whose parent is a track 7 group names nothing the layer's chain holds, and is `parent_not_held` (`v6_per_track_chain_cross_track_link`). Beside them the corpus of §5.7 holds a -second chain head offered beside a held head, a fork from a held group and a gap -beside the head, a head offered again, another key on the same track, §6.1's guard +track's first group offered again after its second, which changes nothing, a fork +from a held group and a gap beside the head, a head offered again, another key on the +same track, §6.1's guard off, on, and compared within one track; the guard holding a legacy group with it off, a legacy history replayed as new after one rewritten link and its loop, and a labelled replay whose rewritten link fails its signature; receipts chained per key @@ -2227,6 +2310,15 @@ previous group of another track or key, which is not held; and object receipts checked without their envelopes, which are `inclusion-verified` or refused at step 2 or 5. `chain_differential.py` signs chains around these rules and requires both verifiers to agree on each. +The corpus also holds recovery (§3 step 3): children held until their parent is +fetched; a receipt lost, and the chain re-anchoring past it once a count bound is passed, +its held groups refused (`parent_not_recovered`) and regained as history behind the +labelled anchor; a re-anchor refused behind its head; a verifier that joins mid-stream, +at the chain's first group and on a legacy chain; a stale group that may not anchor; a +gap fetched in ascending order after a re-anchor, and a joiner's fill in ascending +order, held as history outside the live bound, within its history bound and past it; +and the bound across chains. `chain_gap_differential.py` lets both verifiers recover on +chains it signs, with ranges fetched again in ascending order. `wai_jwp_vectors` and `jwp_verify.py` give every vector's outcome (§5.7). ### 5.10. Stage and delivery-gap claims (`claim-conformance/`) @@ -2318,6 +2410,18 @@ later time. This extension mitigates replay with two mechanisms: forward progress. In a legacy chain a relay can still drop a group and rewrite the next one's link to hide the gap; only the labelled payload rules that out. +- Anchors (§3 step 3) resume verification without the groups before them, + and prove nothing about those groups. A relay can withhold groups and let + a re-anchor carry the chain past the gap, and can offer a joining verifier + an old group, validly signed, as its anchor. A verifier therefore reports + every anchor and the gap behind it, applies the freshness bound to an + anchor wherever it applies it to the live edge, and accepts a re-anchor + only at a `group_id` above its chain head's, so that a re-anchored chain + still moves forward. History that later fills a gap (§3 step 3) is + admitted only by signed links to the groups on either side, never on + arrival order; and what a verifier holds while it waits is bounded across + all its chains, so a sender of groups under fresh keys or of groups that + never link fills nothing past the bound it states. - Both are per track. A publisher that leaves `track_alias` at `0` has one chain per key, as before. A publisher that chained its groups per key across non-zero tracks, as an earlier reading of §2.4.1 allowed, made diff --git a/wai/extensions/moq-streaming-format.md b/wai/extensions/moq-streaming-format.md index a3629c0bf..3e5077dfb 100644 --- a/wai/extensions/moq-streaming-format.md +++ b/wai/extensions/moq-streaming-format.md @@ -8,16 +8,22 @@ > does not change that. Field semantics and rules over them only — never MOQT's > byte encoding. > Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174. +> +> Drafts read for this revision: `draft-ietf-moq-transport-22` (MOQT), +> `draft-ietf-moq-msf-01` (MSF) and `draft-ietf-moq-cmsf-01` (CMSF), all +> Internet-Drafts of the IETF MoQ working group, none past Working Group Last Call. +> A section number of MOQT, MSF or CMSF in this text is one of those revisions; §6 +> says what happens when they move. ## 1. Why a streaming format MoQ separates two things that other stacks fuse: -- **MOQT** (`draft-ietf-moq-transport`) moves objects, in groups, on tracks. It - does not know what an object means. +- **MOQT** (`draft-ietf-moq-transport`) moves objects, in groups, on tracks, and + orders them by priority. It does not know what an object means. - **MSF** (`draft-ietf-moq-msf`) says what objects mean: it fragments a stream - into objects, declares what a publisher emits in a **catalog**, and sets - prioritisation. + into objects, maps each object to a stream of its own, and declares what a + publisher emits in a **catalog**. WAI already references MoQ for confidentiality (MLS + SFrame; see [confidentiality](confidentiality.md)). That binding treats MoQ as a pipe. This @@ -33,9 +39,9 @@ The two layers agree on the unit without either being changed. ## 2. Precedent: the slot is open, and cheaply MSF's catalog names a format in its `packaging` field, over a value set MSF's own -text fixes (`loc`, `mediatimeline`, `eventtimeline`, `moqlog`, `moqmetrics`, -`catalog`). There is no central streaming-format registry. Read alone, that is -closed. +text fixes (MSF §5.2.4: `loc`, `mediatimeline`, `eventtimeline`, `moqlog`, +`moqmetrics`; the catalog itself is the track named `catalog`, MSF §5). There is +no central streaming-format registry. Read alone, that is closed. `draft-ietf-moq-cmsf` shows it is not. CMSF is a sibling Internet-Draft that declares `packaging` value `cmaf`, stating that it *extends the allowed packaging @@ -72,7 +78,20 @@ fields, the capabilities a sink needs to dispatch what the track carries: | `wai_capabilities` | array of string | Capability strings (WAI SPEC.md §4) that objects on this track may dispatch to. | | `wai_determinism` | string | The weakest tier any object on the track claims — see [determinism-tiers](determinism-tiers.md). Absent means no tier is claimed. | | `wai_receipts` | boolean | Whether objects carry receipts per §5. | +| `wai_receipt_alias` | integer | The `track_alias` the publisher signs in this track's receipts ([jwp-receipts](jwp-receipts.md) §2.4.1), from 0 to 2^53 − 1. | +| `wai_receipts_track` | string | The name, in this track's namespace, of the track that carries this track's receipts (§4.1). | +- `wai_receipt_alias` names the track's receipt chain, which a verifier keeps per + publisher key and `track_alias` (jwp-receipts §3 step 3). MOQT's own Track Alias is + chosen for each session by the publisher of that session, a relay included (MOQT + §3.1.3), so past the first hop it is not the number the receipts sign; a sink + finds a track's chain by this member. It is an integer written as one (SPEC §2: + not `8.0`, not `"8"`), compared as a number; a catalog that carries any other value + for it, on any track, carries no stage set with receipts (§4.1, check 6). Two + tracks of one catalog **MUST NOT** carry the same value: each track that declares + receipts has a chain of its own (§4.1). §4.1 requires it, and + `wai_receipts_track`, on the tracks of a stage set that declare receipts; on + another track both are OPTIONAL. - `wai_capabilities` **MUST** be exhaustive for the track. A publisher that dispatches to a capability it did not declare has broken the catalog's purpose, which is letting a sink decide **before** subscribing whether it can @@ -84,15 +103,258 @@ fields, the capabilities a sink needs to dispatch what the track carries: Declaring the strongest would let a track advertise a guarantee most of its objects do not meet. -These are custom catalog fields under MSF's extension rule; they are namespaced -by their `wai_` prefix and MUST NOT collide with MSF-defined names. +These are custom catalog fields under MSF's extension rule (MSF §5: custom field +names "MUST NOT collide" with MSF's, and a parser ignores a field it does not +understand); they are namespaced by their `wai_` prefix and MUST NOT collide with +MSF-defined names. + +### 4.1 Staged tracks + +A stage set ([staged-delivery](staged-delivery.md)) is carried as **one track per +layer**: a **base track**, which carries the set's bases, and a **layer track** for +each layer index, which carries the layers of that index. Every track keeps §3: each +object is one whole `WAI1` envelope. A subscriber chooses its stage by the tracks it +subscribes to; a relay forwards tracks and never reads an envelope. + +**The catalog.** Each track of a set **MUST** carry, in its catalog entry, + +```json +"wai_staged": { "set": "cam1", "layer": 1, "lead_ms": 40 } +``` + +| member | type | meaning | +|---|---|---| +| `set` | non-empty string | The set's name: the same on every track of the set. | +| `layer` | integer, 0–255 | 0 on a base track; *k* on a track that carries layer *k*. | +| `lead_ms` | integer from 0 to 2^53 − 1, or `null` | On a layer track of a live set, the lead of its layer (staged-delivery §8): the time before a unit's presentation by which the layer must arrive to be applied to it. `null` on a base track. A sink reads it only while the set is live: when `isLive` is false it ignores it, as MSF §5.2.8 has a sink ignore `targetLatency`. | + +A set's extent is the tracks present: its layers are 0 to the highest layer a track +of the set states, each with at least one track. The catalog states no count of +layers, so an MSF delta update (MSF §5.1.6, §5.3), which adds and removes tracks and +changes none it declared before, changes a set's extent by adding or removing a layer +track alone. More than one track **MAY** carry one layer, as renditions of it. Each +rendition is a track of its own, with a receipt chain of its own: a clone (MSF §5.1.6) +inherits every member of its parent but its name, so a clone of a track that declares +receipts **MUST** override `wai_receipt_alias` and `wai_receipts_track` (*Receipts*, +below). + +A layer track's `wai_capabilities` lists the refinement capabilities its envelopes +carry; a sink that does not implement staged delivery finds them unsupported (SPEC +§4) and has no reason to subscribe. A member of `wai_staged` this revision does not +define is ignored, an earlier revision's `layers` among them. A publisher puts on a +track of layer *k* only envelopes whose `refines.layer` is *k*, and on a base track +only envelopes with no `refines`; a sink applies an envelope by its own `refines` +(staged-delivery §4.1, §13), whichever track it came on. + +**Names and types.** A track is named by its namespace and its name (MSF §5.2.2, +§5.2.3); one that declares no namespace inherits the catalog track's (MSF §5.2.2). Two +tracks of a catalog **MUST NOT** share one namespace|name pair, and the tracks of a set +**MUST** share one namespace. The check below needs the catalog track's namespace, +which a sink knows, since it subscribed to the catalog track: a checker that does not +know it compares a track that inherits only with tracks that inherit, and refuses a set +that mixes declared and inherited namespaces (`staged-malformed`). On a track of a set, `renderGroup`, where present, +**MUST** be an integer that fits 64 unsigned bits, written as one (SPEC §2), and is +compared as such: `true`, `1.0` and `"1"` are not `1`. `depends`, where present, +**MUST** be an array of strings. + +**MSF fields.** The tracks of a set **MUST** have the same `isLive` (MSF §5.2.7) and +**MUST** share one `renderGroup` (MSF §5.2.11), which is present on each. On a live +set, MSF already requires one `targetLatency` across a render group (MSF §5.2.8); it +is the target latency staged-delivery §8 adds to a unit's timing, unless the sink +applies its own. Every entry of a track's `depends` (MSF §5.2.14) **MUST** name a track +of the catalog in the track's own namespace, where MSF resolves it. The `depends` of a +track of layer *k* **MUST** name at least one track of the set at each layer below +*k*, and **MUST NOT** name a track of the set at *k* or above; a base track's **MUST +NOT** name a track of the set. An `altGroup` (MSF §5.2.12) **MAY** group the base +tracks of alternative sets, and **MUST NOT** be set on a layer track: a subscriber +picks one track of an alternate group, and a layer is never an alternative to its base +or to another layer. A layer track **MUST NOT** carry `temporalId` or `spatialId` (MSF +§5.2.16, §5.2.17), which number a codec's own layers; a layer's index is +`wai_staged.layer` here, and `refines.layer` in the envelope. + +**Receipts.** Every track of a set that declares `wai_receipts` true **MUST** carry +`wai_receipt_alias` (§4) and `wai_receipts_track`: the name, in its namespace, of its +**receipts track**, of `packaging` `"wai-receipts"`. Each media track has a receipts +track of its own, so each layer has a receipt chain of its own and a subscriber +verifies the tracks it subscribes to without the receipts of those it does not +(jwp-receipts §6.1); and a subscriber subscribes to the receipts tracks of the media +tracks it subscribes to and no others, so the tracks it chooses cap the receipts it +receives as they cap its stage. A track **MUST NOT** carry an alias another track of +the catalog carries, or name a receipts track another track names: a rendition of a +layer carries an alias and names a receipts track of its own. + +- Group *g* of a receipts track carries one object: a JSON array of the group receipt + and the object receipts of group *g* of its media track, in the JSON form of + jwp-receipts §2.4.1 and §2.4.2. A publisher publishes it no later than that group's + first object. +- A receipt is about one MOQT object, and **MUST** say which: the group receipt in + group *g* of a receipts track has `group_id` *g*, the MOQT Group ID of the media + track's group, and each object receipt in it has that `group_id` and, as + `object_id`, the MOQT Object ID of its object. Its `signer_pubkey` is the + publisher's key and its `track_alias` the media track's `wai_receipt_alias`. A + subscriber refuses a receipt that breaks this (`moqt_mismatch`, jwp-receipts §3.3) + before it verifies the receipt's signature, and holds nothing of it. +- A receipts track has no delivery timeout of either kind, and its publisher priority + is its media track's. A subscriber sets no delivery timeout on its subscription to a + receipts track (MOQT §9.20.3, §9.20.4); a receipt it loses anyway it recovers as + below. +- Its objects are receipts, not envelopes, and §3 does not apply to them. The + `WAI_RECEIPT` property (§5) names an object's group receipt but does not carry it. + +**Verifying, recovering and joining.** A subscriber verifies each group receipt as +jwp-receipts §3 does, on the chain of the publisher key and the track's +`wai_receipt_alias`, and admits a group only after its parent (§3 step 3). Each object +of a receipts track travels on a stream of its own (MSF §6), so receipts can arrive out +of order, and a relay can drop one at its discretion (MOQT §5.2). It holds no receipt +of the track's object until it verifies it, and until then applies no layer it carries +(staged-delivery §10.1). + +- A subscriber that subscribed to a receipts track from its first group, and that has + received every object of it, holds the receipt of every object of its media track, + delivered or not, and checks one it did not receive by inclusion (jwp-receipts §3, + *Verified by inclusion*). Where a receipts-track object is missing, the receipts after + it on that track wait for it, within the bound below. +- A receipt whose parent the subscriber lacks is held (`parent_not_held`). The + subscriber recovers the parent by a FETCH (MOQT §3.2) of the receipts track's groups + from its chain's head to the held group: group *g* of the receipts track holds the + receipts of group *g* of its media track. +- A subscriber **MUST** state a bound on what it holds for missing parents + (jwp-receipts §3 step 3, *Holding*), each count a finite number: a count of receipts + held live for each chain, and a time from each one's arrival only beside it, never + alone; a count of receipts held as history for each chain; and a count across all its + chains. Past the live bound it refuses the layers whose receipts it holds, their + receipts refused (`parent_not_recovered`) and the layers refused for want of them + (staged-delivery §10.1), and re-anchors the chain at the next validly signed group of + it that it receives (*Re-anchoring*). The layers of the groups the anchor skips are + refused for want of their receipts, unless history later fills them. +- After a re-anchor, a FETCH of the gap (MOQT §3.2) can deliver its groups in any + order, ascending among them: the verifier admits as history each group that links + forward from the last group admitted before the gap or back from the anchor + (jwp-receipts §3 step 3, *History*), never as a fork, and holds as history, under the + history count, one that links to neither yet. +- A subscriber that joins a live track after its first group (MOQT §3.5) declares a + join: its verifier anchors the chain at the first validly signed group of it that it + receives, and holds the history before it as unverified (jwp-receipts §3 step 3, + *Joining*). Joining at an earlier group (MOQT §3.5: a SUBSCRIBE whose + `FILL_PARAMETERS` select it, its objects delivered on a fill fetch stream, §3.4; + earlier revisions of MOQT called this a Joining FETCH) retrieves earlier receipts. + The fill fetch stream delivers them in ascending order, so each waits for the one + after it: the verifier holds them as history, under the history count and outside + the live one, and admits them, back from the anchor, where their signed links lead to + it. + +A MOQT relay forwards a receipts track as it forwards any track, its objects opaque +payload (MOQT §7.8): it neither reads nor verifies the receipts in it. It is +therefore not a relay that conforms to jwp-receipts §4, which verifies a group's +signature before fanning its objects out; the subscriber verifies every receipt itself +(jwp-receipts §4.1, *Receipts carried as payload*). + +**Checking a catalog.** A sink checks each set's entries in this order, and stops at +the first that fails: + +1. `wai_staged` is an object with the three members above, of their types and in their + ranges; the track's `packaging` is `"wai"` and its `isLive` a boolean; its name is a + string, its namespace absent or a string, and the pair is no other track's; its + `renderGroup`, if present, an integer that fits 64 unsigned bits; its `depends`, if + present, an array of strings; and, where the checker does not know the catalog + track's namespace, either every track of the set declares a namespace or none does + (`staged-malformed`). An entry whose `set` cannot be read belongs to no set, and is + reported for the empty name. +2. The set has a track at each layer from 0 to the highest a track of it states + (`staged-set-incomplete`). +3. Every entry of every track's `depends` names a track of the catalog in the track's + namespace; a track of layer *k* names a track of the set at each layer below *k*, + and none at *k* or above (`staged-depends`). +4. The tracks have one namespace, one `isLive` and one `renderGroup`, present on each, + and no layer track carries `altGroup`, `temporalId` or `spatialId` + (`staged-grouping`). +5. On a live set, `lead_ms` is an integer on every layer track and `null` on every + base track (`staged-lead`). A set that is not live is not checked here. +6. Every `wai_receipt_alias` of the catalog, on any track, is an integer from 0 to + 2^53 − 1; every track of the set that declares `wai_receipts` true carries an alias + and names, in its namespace, a receipts track of `packaging` `"wai-receipts"`; no + other track of the catalog carries its alias; and no other track names its receipts + track (`staged-receipts`). + +A sink treats the layer tracks of a set that fails as absent. Its base tracks are +tracks of packaging `wai` like any other, and the sink **MAY** subscribe to them. + +**Groups and objects.** + +- Group and object ids of different tracks need not align. `refines` names what a + layer refines, and a sink checks it (staged-delivery §4.1); nothing is read from + where a group falls on another track. +- On a live track each group **MUST** carry exactly one object, which travels in a + subgroup of its own (MOQT §2.2): MSF maps every object to a new stream (MSF §6), so + none travels as a datagram. A group of one object is complete when its object is, so + its group receipt is signed before the object is sent, and the latency the receipt + adds to a layer's arrival (staged-delivery §8) is one signature and its carriage, not + a group's duration. + +**Priority.** MOQT schedules first by subscriber priority, the priority a subscriber +gives its request (MOQT §5.1.2, §9.20.7), and only between objects of equal subscriber +priority by publisher priority, the lower number first, 0 the highest (MOQT §5.1.1, +§5.1.2). The base track's publisher priority **MUST** be a number strictly below every +layer track's; the priority of a track of layer *k* **MUST** be no greater than that of +any track of a layer above *k*; and a receipts track's is its media track's. These hold +for each track's `DEFAULT_PUBLISHER_PRIORITY` (MOQT §10.4) and for every subgroup that +sets its own (MOQT §5.1.1). A subscriber's own priorities can still put a layer before +its base: one that wants the publisher's order **SHOULD** give its requests for a set's +tracks one subscriber priority. + +**Delivery timeouts.** + +- A publisher **SHOULD** set a live layer object's `OBJECT_DELIVERY_TIMEOUT` (MOQT + §5.2, §10.2) to no more than its **slack** at publication: the time from publication + to the presentation of the last unit the object covers, less the layer's `lead_ms`, + less the publisher's estimate of the path. MOQT reads a timeout set as an object property on the first + object of a subgroup (MOQT §5.2, §10.2); here that object is its subgroup's only one, + so the publisher **MAY** set each object's timeout on the object, from its own slack. + A slack of zero or less cannot be set, since a timeout of 0 is no timeout (MOQT + §5.2): such an object cannot arrive in time for any unit it covers, and a publisher + **MAY** withhold it. +- A publisher **MUST NOT** set on a base track a delivery timeout of either kind + shorter than the time from a base's publication to the presentation of its last + unit. +- `OBJECT_DELIVERY_TIMEOUT` runs at each hop, from the time the object's last header + byte reaches that hop, and is checked when the hop passes the object to its transport + (MOQT §5.2). `SUBGROUP_DELIVERY_TIMEOUT` starts instead when the subgroup's FIN + arrives, and runs until all its data is acknowledged (MOQT §5.2): on a subgroup of + one object, it bounds that object's delivery after it was sent. Where both the + publisher and the subscriber set a value, MOQT uses the smaller (MOQT §5.2, §9.20.3, + §9.20.4), so a sink whose own target latency is longer than the one a publisher + assumed cannot extend the publisher's timeout. Either timeout releases, hop by hop, + bandwidth that a late layer would waste; neither is an end-to-end deadline: an object + can pass every hop within its timeouts and still arrive late. The rule of + staged-delivery §8 is the only rule that decides whether a layer is late. + +**Capping.** A subscriber caps its stage by subscribing to the base tracks and to the +tracks of layers 1 to *n*, and to their receipts tracks, and to no others. A relay +**MUST NOT** read an envelope to cap layers: it treats every payload as opaque (MOQT +§7.8), and forwards the tracks it is asked for. On a track of packaging `wai` the +prototype header fields of staged-delivery §13 are not read. + +**Example.** The entry of the first layer track of a live set, with its base track +`cam1/base`, and the entry of its receipts track: + +```json +{ "namespace": "example/cam1", "name": "cam1/L1", "packaging": "wai", + "isLive": true, "targetLatency": 300, "role": "video", "renderGroup": 1, + "depends": ["cam1/base"], + "wai_capabilities": ["wai.video.int_refine"], "wai_determinism": "decode-equivalence", + "wai_receipts": true, "wai_receipt_alias": 8, "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { "set": "cam1", "layer": 1, "lead_ms": 20 } } +{ "namespace": "example/cam1", "name": "cam1/L1/receipts", "packaging": "wai-receipts", + "isLive": true } +``` ## 5. The per-object property -MSF §12 defines **MSF Properties** at **Track** and **Object** scope, with an -IANA registry of property types that today holds essentially one entry -(`MSF_COMPRESSION`). CMSF adds track-level catalog fields and defines **no -per-object property**. The object-scope slot is empty. +MOQT carries metadata a relay can see as **Properties** of a track or of an object +(MOQT §8.4), registered in a table MOQT creates, with ranges set aside for an +application's own use. MSF §12 defines its properties this way; the one it defines, +`MSF_COMPRESSION`, is at both scopes. CMSF adds track-level catalog fields and +defines **no per-object property**. None of them carries a receipt. A WAI receipt is already per-object. This extension therefore defines one object-scope property, `WAI_RECEIPT`, whose value is the object's receipt @@ -145,6 +407,17 @@ commitment: claim, a relay that rewrites this property included. - A relay **MUST** preserve this property unchanged, per `jwp-receipts` §4.1. A relay that recomputes it is asserting something it did not measure. +- A relay that does not forward an object, whether because a delivery timeout + passed or at its discretion (MOQT §5.2), **MUST** still forward that object's + receipts to every subscriber that requested receipts for its track (jwp-receipts + §4.1). This property rides on the object and is lost with it; the receipts ride + the track's receipts track (§4.1), which the relay forwards as it forwards any + track, so a subscriber of that receipts track that receives the receipt accounts + for the object as `inclusion-verified` (jwp-receipts §4.4). A receipts-track object + the relay drops in turn is recovered as §4.1 says. A relay that signs a `delivery-gap` claim (jwp-receipts + §2.4.11) does not add it to the publisher's receipts track, whose objects it + forwards unaltered (MOQT §7.8); this extension defines no carriage for such a + claim. - Telemetry about objects on a WAI track is [delivery-telemetry](delivery-telemetry.md)'s. It is unsigned and advisory, and it **MUST NOT** restate this property's figure, class or evidence. @@ -157,9 +430,12 @@ commitment: Stated so it is not re-argued: - It does **not** define, profile, or restate MOQT's wire encoding — not the - object header, not the extension-header byte layout, not the control messages. + object header, not the byte layout of properties, not the control messages. Those are MOQT's, and the property that makes this binding cheap is that WAI - rides a transport instead of defining one. + rides a transport instead of defining one. §4.1 paraphrases MOQT's scheduling + order, its delivery timeouts and its joining (MOQT §5.1, §5.2, §3.5) only to say how + a stage set uses them and which values it sets; it defines none of them, and where a + paraphrase and MOQT differ, MOQT holds. - It does **not** replace CMSF or LOC. A publisher with CMAF-packaged media should use CMSF; this packaging is for tracks whose objects are WAI envelopes. - It does **not** make MoQ WAI's transport. WAI remains carrier-agnostic; this @@ -170,6 +446,9 @@ Stated so it is not re-argued: - It does **not** pin a MOQT draft revision. MOQT is pre-Last-Call; an extension that pinned a moving target would be wrong within a meeting cycle. Where MOQT and this text disagree, MOQT wins and this text is the thing that must change. + The revisions named in the status note are the ones this text was last read + against: where a later revision renumbers or renames what it cites, the + citation is what changes. ## 7. Framing diff --git a/wai/extensions/staged-delivery.md b/wai/extensions/staged-delivery.md index 9ee1a2f87..088a71132 100644 --- a/wai/extensions/staged-delivery.md +++ b/wai/extensions/staged-delivery.md @@ -336,7 +336,9 @@ envelopes of the same `base`, `layer` and producer, and conflict (§4.1 step 7). Under a live schedule, every unit has a presentation time, given by the base's timing and the target latency the sink applies, and every layer has a lead, `lead_ms`: the time before a unit's presentation by which the layer must have arrived, and been -verified under §10.1, to be applied to it. A sink **MUST NOT** delay presenting a unit's +verified under §10.1, to be applied to it. A carriage may declare both: on MoQ, the +tracks' target latency and each layer track's `lead_ms` +([moq-streaming-format](moq-streaming-format.md) §4.1). A sink **MUST NOT** delay presenting a unit's current stage to wait for any layer. A layer that arrived less than `lead_ms` before a unit's presentation time is not applied to that unit (`deadline-missed`). It **MAY** be applied to units of its span that are not yet due. A still image **MAY** be @@ -356,7 +358,9 @@ A layer is a `WAI1` envelope, carried and stored as any envelope is, or a `WAI2` component with `role: "refinement"` (SPEC §7.1, *Refinement components*). A component and its exploded envelope (SPEC §7.1) are the same object for every rule that names an object by content hash, so a layer reaches the same stages whichever way it -travelled. This revision binds no other carriage. +travelled. Two bindings carry each layer as an object of its own: +[moq-streaming-format](moq-streaming-format.md) §4.1, a track for each layer index, +and [http-delivery](http-delivery.md) §10.4, a resource for each layer. ## 10. Receipts and claims @@ -429,7 +433,9 @@ A prototype MoQ binding in this repository carries a layer index in an object header (`mrl_layer`, with `mrl_total_layers` and a subscriber's `max_mrl_layer`). For an object that is a `WAI1` envelope with `refines`, a sink reads the layer from `refines.layer`, which sits inside the envelope and so inside its content hash, and -ignores the header. +ignores the header. On a track of packaging `wai`, a subscriber caps its stage by the +layer tracks it subscribes to, and a relay reads no header or envelope to cap it +([moq-streaming-format](moq-streaming-format.md) §4.1). ## 14. Security considerations diff --git a/wai/jwp-conformance/README.md b/wai/jwp-conformance/README.md index 488d89bcd..18ab55fe3 100644 --- a/wai/jwp-conformance/README.md +++ b/wai/jwp-conformance/README.md @@ -17,7 +17,7 @@ track; and an object whose receipt is checked without its envelope reported as |---|---| | `vectors.json` | The corpus, generated by `wai_jwp_vectors gen` in `wai-rs` (feature `receipt_claims`). `wai_jwp_vectors verify` requires a byte-identical regeneration and every declared outcome. | | `jwp_verify.py` | A second verifier, written from the extension text, that shares no code with `wai-rs`. It takes BLAKE3, jwp-receipts §3.1 step 3's Ed25519 rule and the claim verifier from `../claim-conformance/claim_verify.py`, itself independent of `wai-rs`. | -| `chain_gap_differential.py` | Signs genuine chains of up to 90 groups and delivers them shuffled, withheld and released stale, walked back and forward, with rewritten legacy links and forks the key signs, and requires the two verifiers to agree on every one. | +| `chain_gap_differential.py` | Signs genuine chains of up to 90 groups and delivers them shuffled, withheld and released stale, walked back and forward, with rewritten legacy links and forks the key signs, and, on half of them, to a verifier that joins, holds a bound of groups, or both, with receipts lost outright or the chain joined mid-stream; requires the two verifiers to agree on every one. | | `chain_differential.py` | Signs receipt chains the corpus does not hold — two keys, two tracks, both payloads, genuine links, replays with and without a rewritten link, forks, gaps, links across tracks and keys, bad signatures, `group_id` up, equal and down, `group_end` around the clock, guard and bound on and off — and requires `wai_jwp_vectors verdicts` and `jwp_verify.py verdicts` to agree on every one. | ## Procedures @@ -31,7 +31,7 @@ Each vector names one procedure and declares one outcome. | `object` | a group receipt, an object receipt and the envelope delivered | `accept` (§3 steps 1–5), or a rejection at a §3 step | | `total` | a group receipt and the objects delivered with their envelopes | the total's reading — `unmetered`, `unlabelled`, `weakest` (a legacy group whose every non-zero part is labelled: a class name only), or `signed` (a labelled group's own class, `checked` against every object or `unchecked`) — or a rejection | | `property` | a `WAI_RECEIPT` property, with the object, its group and its envelope, or with an `energy` claim | the property's reading — `unmetered`, `unsigned`, or `signed` with the class and the key that signed it (the group's for a leaf, the claim's for a claim) — or a rejection | -| `chain` | group receipts in the order a verifier receives them, whether §6.1's `group_id` guard is on for labelled groups (it is always on for legacy ones), and §6.1's freshness bound (`now`, `max_age_s`, `max_skew_s`) or `null` | for each group in order, `accept` (§3 steps 1–3 against the chain of its key and track, whose head it then becomes) or a rejection at its §3 step; the verifier holds every group it accepted, so a fork is `chain_break` and a gap `parent_not_held`; a group whose signed `group_id` is below the chain's live edge (the highest of a group that verified and arrived fresh) is exempt from the freshness bound | +| `chain` | group receipts in the order a verifier receives them, whether §6.1's `group_id` guard is on for labelled groups (it is always on for legacy ones), and §6.1's freshness bound (`now`, `max_age_s`, `max_skew_s`) or `null`; optionally `recovery` (§3 step 3): whether the verifier joins, and `max_held`, the count of groups it holds live for each chain's missing parents, or `null` for none; with a count, `max_history`, the count it holds as history for each chain, inside the gaps its anchors left, and `max_total`, the count it holds across every chain | for each group in order, `accept` (§3 steps 1–3 against the chain of its key and track, whose head it then becomes, or the head offered again), `accept again` (a group the chain admitted before, the same bytes, which changes nothing), `accept anchor` (it anchors a joined or re-anchored chain), `accept history` (inside a gap an anchor left, linking forward from the group before it or back from the group after it), or a rejection at its §3 step; the verifier holds every group it accepted, so a fork is `chain_break` and a gap `parent_not_held`; a group whose signed `group_id` is below the chain's live edge (the highest of a group that verified and arrived fresh) is exempt from the freshness bound. With holding, a held group's outcome is what became of it: accepted when its parent arrived, or when it filled a gap, `parent_not_recovered` when a bound was passed, or `parent_not_held` if still held at the end | | `inclusion` | a group receipt and an object receipt, with no envelope | `accept` with the evidence `inclusion-verified` (§3 steps 1, 2 and 5, step 4 not run; §4.4), or a rejection at a §3 step | A rejection names its reason (§3.3), and its §3 step where it has one; the group total @@ -54,15 +54,25 @@ disagree with, a labelled leaf or an `energy` claim, including a claim signed by other than the publisher's, which reads as signed by that key. Chains (vector `v6_per_track_chain` and its neighbours): one key's base track and layer track, three chained labelled groups each, offered alone and interleaved; a track's second group without its first; a parent -on another track; a chain head offered beside a held head, a fork from a held group, a -gap beside the head, and the head offered again; another key on the same track; the +on another track; a track's first group offered again after its second, which changes +nothing, a fork from a held group, a gap beside the head, and the head offered again; another key on the same track; the `group_id` guard off, on, and compared within a track, and always on for a legacy group; a legacy history replayed after one rewritten link, once and in a loop, and a labelled replay; receipts chained per key across tracks; `group_end` at the clock, with an offset, at the window's edge, stale, ahead of the clock, stale with no bound, a stale group behind the live edge, which fills a gap, a gap of seventy groups, and a stale group whose rewritten link earns it nothing; a previous group of another track or key; -and a group whose signature fails, which heads nothing. Objects checked without their envelopes: a dropped layer object and a labelled +and a group whose signature fails, which heads nothing. Recovery (§3 step 3): a parent +fetched after its children, which are held and released; a receipt lost with and without +holding, the chain re-anchoring past it and regaining the held groups and the lost one +as history; a re-anchor that may not move behind its head; a verifier that joins +mid-stream, at the chain's first group, and on a legacy chain, with history behind its +anchor where the anchor is labelled; a stale group that may not anchor; a gap fetched in +ascending order after a re-anchor, each group linking forward from the one before; a +joiner's fill in ascending order, held as history outside the live bound and released +back from the anchor, within its history bound and past it; and the total held across +two tracks, passed by one; a group offered again after the head moved on, and a +joiner's anchor delivered a second time, which change nothing. Objects checked without their envelopes: a dropped layer object and a labelled one, which verify by inclusion, and a forged group, a rewritten content hash and a moved position, which do not. diff --git a/wai/jwp-conformance/chain_gap_differential.py b/wai/jwp-conformance/chain_gap_differential.py index 6819c05c0..4c735b943 100644 --- a/wai/jwp-conformance/chain_gap_differential.py +++ b/wai/jwp-conformance/chain_gap_differential.py @@ -4,8 +4,12 @@ (key, track), delivered shuffled, with re-sends, walk-backs, groups withheld and then released stale, legacy parents rewritten (to the group itself, to a random hash, to a held group), forks the key signs from a held group, and chains long enough (up to 90 -groups) that no count could bound a gap; freshness bound and guard on and off. Keys are -the corpus's published seeds, so a run is reproducible from its seed. Exit status 0 +groups) that no count could bound a gap; freshness bound and guard on and off. On half +the chains the verifier also recovers (§3 step 3): it joins or not, holds a bound of +groups or none, and receipts are lost outright or the chain is joined mid-stream. Keys are +the corpus's published seeds, so a run is reproducible from its seed. Holding states a +history bound and a total beside the live bound, and on some chains a range is fetched +again in ascending order, which fills the gaps anchors left. Exit status 0 only if the two verifiers give the same verdict on every chain. usage: chain_gap_differential.py [--n N] [--seed S]""" @@ -56,8 +60,30 @@ def gen(rng, i): src = rng.choice(chain) out.append(D.make(seed, track, rng.randint(0, n + 2), 10_000 * i + 9000 + len(out), D.link(src), NOW, labelled)) # fork fresh = {"now": NOW, "max_age_s": 60, "max_skew_s": 5} if rng.random() < 0.7 else None - return {"name": f"gap_{i}", "procedure": "chain", "input": {"groups": out, "guard": rng.random() < 0.5, "freshness": fresh}, - "expected": {"outcome": "chain", "results": []}} + inp = {"groups": out, "guard": rng.random() < 0.5, "freshness": fresh} + # Recovery (§3 step 3: holding, re-anchoring, joining), from a generator of its own so + # the chains above are the ones every earlier run drew: on half the chains, a verifier + # that joins or not, holds a bound or nothing; a receipt lost outright; a join after + # the chain's first groups. + rr = random.Random(f"recovery {i} {D.link(chain[0]).hex()}") + if rr.random() < 0.5: + inp["recovery"] = {"join": rr.random() < 0.5, "max_held": rr.choice([0, 1, 2, 3, 5, 8, None])} + if rr.random() < 0.4: + lost = rr.choice(chain) + inp["groups"] = [g for g in inp["groups"] if g is not lost] or inp["groups"] + if inp["recovery"]["join"] and rr.random() < 0.5: + first = rr.randint(1, max(1, n - 1)) + inp["groups"] = [g for g in inp["groups"] if g not in chain[:first]] or inp["groups"] + # Holding states every count: a history bound of its own, and a total. Then, on + # some chains, a range of the chain fetched again in ascending order, as MOQT + # delivers a FETCH or a fill: gap fill after a re-anchor or a join. + if inp["recovery"]["max_held"] is not None: + inp["recovery"]["max_history"] = rr.choice([0, 1, 2, 4, 16, 64]) + inp["recovery"]["max_total"] = rr.choice([1, 3, 8, 64]) + if rr.random() < 0.4: + a = rr.randint(0, n - 1) + inp["groups"] = inp["groups"] + chain[a:rr.randint(a + 1, n)] + return {"name": f"gap_{i}", "procedure": "chain", "input": inp, "expected": {"outcome": "chain", "results": []}} def main(argv): diff --git a/wai/jwp-conformance/jwp_verify.py b/wai/jwp-conformance/jwp_verify.py index f63e77501..ed537bcfb 100644 --- a/wai/jwp-conformance/jwp_verify.py +++ b/wai/jwp-conformance/jwp_verify.py @@ -17,6 +17,7 @@ Ed25519 rule (key decoding, small order, S < L, the cofactorless equation throug Exit status 0 only if every vector reports its declared outcome. """ import json, os, sys +from collections import deque HERE = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance")) @@ -511,6 +512,11 @@ def seconds(t): return v +CHAIN_HELD_LINKS = 65_536 # earlier links a chain keeps, to tell a fork from a gap +MAX_HELD_GROUPS = 65_536 # groups held in all, whatever the bound says +CHAIN_OPEN_GAPS = 64 # gaps a chain keeps open for history + + def chain(inp): """Each group in order through §3 steps 1-3 against its own chain, the one for its (signer_pubkey, track_alias). The head offered again passes. With no head held, a @@ -522,9 +528,42 @@ def chain(inp): group_end outside [now - max_age, now + max_skew] is group_not_fresh, unless the group's signed group_id is below the chain's live edge: the highest group_id of a group that verified and was inside the window (or arrived with no bound), linked or - not. A group that passes becomes the head.""" + not. A group that passes becomes the head. + + With `recovery` (§3 step 3, Holding, Re-anchoring, Joining, History): a verifier that + joins anchors a chain it holds nothing of at its first group that passes steps 1 and 2 + and the freshness bound, whatever its parent. Each anchor leaves a gap: below it, the + last group admitted before the anchor (none for a join); above it, the earliest group + admitted at or after it and the parent that group signs. A group inside a gap whose + parent is the lower end (forward) or whose link is the upper end's signed parent + (backward) is history, and moves that end; one that does both closes the gap. With + `max_held`, `max_history` and `max_total`, a group that is parent_not_held is held: + as history if its group_id lies inside an open gap, live otherwise. Holding it live + past the chain's live count or the total refuses all the chain holds live + (parent_not_recovered) and the chain re-anchors: its next group that passes steps 1 + and 2, the bound, and whose group_id exceeds the head's, anchors it. Holding it as + history past the chain's history count or the total refuses the lowest held as history + below it, or, if there is none, refuses it (parent_not_recovered). A group admitted + releases, in turn, the held groups whose parent is its link and the held group whose + link is its parent, those of one admitted group by group_id then arrival. Each group's + outcome is its last: a held group takes what became of it. + + A group the chain admitted before, other than its head, offered again with the same + parent, is `accept again` and changes nothing; the head offered again is `accept`.""" chains, out = {}, [] guard, fresh = inp["guard"], inp.get("freshness") + rec = inp.get("recovery") or {} + join = rec.get("join") is True + hold = None + if rec.get("max_held") is not None: + def count(k): + v = rec.get(k) + if not isinstance(v, int) or isinstance(v, bool) or v < 0: + raise ValueError(f"recovery.{k}") + return v + hold = (count("max_held"), count("max_history"), count("max_total")) + st = {"total": 0, "seq": 0} + events = [] def in_window(g): if fresh is None: @@ -532,34 +571,218 @@ def chain(inp): now, t = seconds(fresh["now"]), seconds(g["group_end"]) return t is not None and now - fresh["max_age_s"] <= t <= now + fresh["max_skew_s"] + def new_chain(): + return {"head": None, "head_link": None, "links": {}, "links_order": deque(), "edge": None, + "held": {}, "by_parent": {}, "history": set(), "reanchor": False, "gaps": []} + + def plink(g): + p = g["parent_receipt_hash"] + return None if p is None else bytes.fromhex(p) + + def signed_parent(g): + return plink(g) if g["labelled"] else None + + def spans(gap, gid): + return gid < gap["high_id"] and (gap["low"] is None or gid > gap["low"][1]) + + def fill(c, g, lk): + for i, gap in enumerate(c["gaps"]): + if not spans(gap, g["group_id"]): + continue + fwd = gap["low"] is not None and plink(g) == gap["low"][0] + bwd = gap["high_parent"] == lk + if fwd or bwd: + return i, fwd, bwd + return None + + def check_link(g, c, lk): + """§3 step 3 as before recovery: the head again, a link, or a refusal.""" + head, head_link, parent = c["head"], c["head_link"], plink(g) + if head_link == lk: + return + if head_link is None: + if parent is not None: + raise Refuse(3, "parent_not_held") + elif parent is None: + raise Refuse(3, "chain_break") + elif parent != head_link: + raise Refuse(3, "chain_break" if parent in c["links"] else "parent_not_held") + if (guard or not g["labelled"]) and head is not None and g["group_id"] <= head["group_id"]: + raise Refuse(3, "group_id_not_increasing") + behind_edge = c["edge"] is not None and g["group_id"] < c["edge"] + if not behind_edge and not in_window(g): + raise Refuse(3, "group_not_fresh") + + def decide(g, c, lk): + if c["head_link"] == lk: + return "accept" + if fill(c, g, lk) is not None: + return "accept history" + head = c["head"] + anchoring = c["reanchor"] or (join and head is None) + try: + check_link(g, c, lk) + return "accept" + except Refuse as r: + if not (anchoring and r.step == 3): + raise + behind_edge = c["edge"] is not None and g["group_id"] < c["edge"] + if not behind_edge and not in_window(g): + raise Refuse(3, "group_not_fresh") + if head is not None and g["group_id"] <= head["group_id"]: + raise Refuse(3, "group_id_not_increasing") + return "accept anchor" + + def attempt(g, c, lk): + try: + return decide(g, c, lk), None + except Refuse as r: + return None, r + + def install(g, c, lk, how): + if lk not in c["links"]: + c["links"][lk] = plink(g) + c["links_order"].append(lk) + while len(c["links_order"]) > CHAIN_HELD_LINKS: + c["links"].pop(c["links_order"].popleft(), None) + if how == "accept history": + f = fill(c, g, lk) + if f is None: + return + i, fwd, bwd = f + gap = c["gaps"][i] + if fwd and bwd: + del c["gaps"][i] + elif fwd: + gap["low"] = (lk, g["group_id"]) + else: + gap["high_id"], gap["high_parent"] = g["group_id"], signed_parent(g) + if gap["low"] is None and gap["high_parent"] is None: + del c["gaps"][i] + elif how == "accept anchor": + low = None if c["head"] is None else (c["head_link"], c["head"]["group_id"]) + gap = {"low": low, "high_id": g["group_id"], "high_parent": signed_parent(g)} + if low is not None or gap["high_parent"] is not None: + c["gaps"].append(gap) + while len(c["gaps"]) > CHAIN_OPEN_GAPS: + c["gaps"].pop(0) + c["head"], c["head_link"], c["reanchor"] = g, lk, False + else: + c["head"], c["head_link"], c["reanchor"] = g, lk, False + + def put(c, lk, h): + if h["history"]: + c["history"].add((h["g"]["group_id"], h["seq"], lk)) + p = plink(h["g"]) + if p is not None: + c["by_parent"].setdefault(p, set()).add(lk) + c["held"][lk] = h + + def take(c, lk): + h = c["held"].pop(lk, None) + if h is None: + return None + if h["history"]: + c["history"].discard((h["g"]["group_id"], h["seq"], lk)) + p = plink(h["g"]) + if p is not None and p in c["by_parent"]: + c["by_parent"][p].discard(lk) + if not c["by_parent"][p]: + del c["by_parent"][p] + return h + + def refuse_held(c, lk): + if take(c, lk) is not None: + st["total"] -= 1 + events.append((lk, Refuse(3, "parent_not_recovered").label())) + + def give_up(c): + c["reanchor"] = True + for _, _, lk in sorted((h["g"]["group_id"], h["seq"], lk) for lk, h in c["held"].items() if not h["history"]): + refuse_held(c, lk) + + def hold_group(g, c, lk): + live_max, history_max, total_max = hold + cap = min(total_max, MAX_HELD_GROUPS) + if lk in c["held"]: + return None, Refuse(3, "parent_not_held") + history = any(spans(gap, g["group_id"]) for gap in c["gaps"]) + if history: + if len(c["history"]) >= history_max or st["total"] >= cap: + lowest = min(c["history"]) if c["history"] else None + if lowest is not None and lowest[0] < g["group_id"]: + refuse_held(c, lowest[2]) + else: + return None, Refuse(3, "parent_not_recovered") + elif len(c["held"]) - len(c["history"]) >= live_max or st["total"] >= cap: + give_up(c) + return attempt(g, c, lk) + st["seq"] += 1 + put(c, lk, {"g": g, "seq": st["seq"], "history": history}) + st["total"] += 1 + return None, Refuse(3, "parent_not_held") + + def release(c, g, lk): + work = deque([(plink(g), lk)]) + while work: + parent, l = work.popleft() + nxt = [(c["held"][x]["g"]["group_id"], c["held"][x]["seq"], x) for x in c["by_parent"].get(l, ()) if x in c["held"]] + if parent is not None and parent in c["held"]: + h = c["held"][parent] + nxt.append((h["g"]["group_id"], h["seq"], parent)) + for _, _, x in sorted(set(nxt)): + h = take(c, x) + if h is None: + continue + st["total"] -= 1 + how, r = attempt(h["g"], c, x) + if r is not None and r.reason == "parent_not_held": + put(c, x, h) + st["total"] += 1 + continue + if how is not None: + install(h["g"], c, x, how) + work.append((plink(h["g"]), x)) + events.append((x, how if how is not None else r.label())) + + waiting = {} for raw in inp["groups"]: - c, g = None, None try: g = read_group(raw) verify_group(g) - c = chains.setdefault((g["signer_pubkey"], g["track_alias"]), {"head": None, "held": set(), "edge": None}) - head, lk, parent = c["head"], link(g), g["parent_receipt_hash"] - if head is None or link(head) != lk: - if head is None: - if parent is not None: - raise Refuse(3, "parent_not_held") - elif parent is None: - raise Refuse(3, "chain_break") - elif bytes.fromhex(parent) != link(head): - raise Refuse(3, "chain_break" if bytes.fromhex(parent) in c["held"] else "parent_not_held") - if (guard or not g["labelled"]) and head is not None and g["group_id"] <= head["group_id"]: - raise Refuse(3, "group_id_not_increasing") - behind_edge = c["edge"] is not None and g["group_id"] < c["edge"] - if not behind_edge and not in_window(g): - raise Refuse(3, "group_not_fresh") - c["head"] = g - c["held"].add(lk) - out.append("accept") except Refuse as r: out.append(r.label()) - if c is not None and in_window(g): # verified: c is set only after the signature + continue + c = chains.setdefault((g["signer_pubkey"], g["track_alias"]), new_chain()) + lk = link(g) + if c["head_link"] == lk: + out.append("accept") # the head again: nothing changes + continue + if lk in c["links"] and c["links"][lk] == plink(g): + out.append("accept again") # a group admitted before, the same bytes: nothing changes + continue + how, r = attempt(g, c, lk) + held = False + if r is not None and r.reason == "parent_not_held" and hold is not None: + how, r = hold_group(g, c, lk) + held = r is not None and r.reason == "parent_not_held" + if how is not None: + install(g, c, lk, how) + if in_window(g): c["edge"] = g["group_id"] if c["edge"] is None else max(c["edge"], g["group_id"]) - return "chain: " + " / ".join(out) + if how is not None: + release(c, g, lk) + if held: + waiting.setdefault(lk, []).append(len(out)) + out.append(None) + else: + out.append(how if how is not None else r.label()) + for elk, outcome in events: + for w in waiting.pop(elk, []): + out[w] = outcome + events.clear() + held_label = Refuse(3, "parent_not_held").label() + return "chain: " + " / ".join(held_label if o is None else o for o in out) def inclusion_only(inp): @@ -611,6 +834,8 @@ def expected_of(e): return "leaf=" + e["leaf"] if e["outcome"] == "chain": return "chain: " + " / ".join(expected_of(r) for r in e["results"]) + if e["outcome"] == "accept" and "admission" in e: + return "accept " + e["admission"] if e["outcome"] == "accept" and "evidence" in e: return "accept evidence=" + e["evidence"] if e["outcome"] == "accept": diff --git a/wai/jwp-conformance/vectors.json b/wai/jwp-conformance/vectors.json index 2811fce9c..fbabd6faa 100644 --- a/wai/jwp-conformance/vectors.json +++ b/wai/jwp-conformance/vectors.json @@ -4900,7 +4900,7 @@ }, { "name": "chain_head_predecessor_replayed", - "about": "the base track's first group offered again after its second: a chain head beside a held head is a fork", + "about": "the base track's first group offered again after its second, the same bytes: a group the chain admitted before, which changes nothing (accept again), never a second chain head", "procedure": "chain", "input": { "groups": [ @@ -4963,9 +4963,8 @@ "outcome": "accept" }, { - "outcome": "reject", - "step": 3, - "reason": "chain_break" + "outcome": "accept", + "admission": "again" } ] } @@ -5605,7 +5604,7 @@ }, { "name": "chain_legacy_replay_loop", - "about": "the same rewritten g0, then the whole history replayed after it, twice: no replayed group advances the chain; g1 names a group the chain holds behind its head, and g2 is the head offered again", + "about": "the same rewritten g0, then the whole history replayed after it, twice: no replayed group advances the chain; the rewritten g0 is refused, g1 is a group the chain admitted before with the same parent and changes nothing (accept again), and g2 is the head offered again", "procedure": "chain", "input": { "groups": [ @@ -5743,9 +5742,8 @@ "reason": "group_id_not_increasing" }, { - "outcome": "reject", - "step": 3, - "reason": "chain_break" + "outcome": "accept", + "admission": "again" }, { "outcome": "accept" @@ -5756,9 +5754,8 @@ "reason": "group_id_not_increasing" }, { - "outcome": "reject", - "step": 3, - "reason": "chain_break" + "outcome": "accept", + "admission": "again" } ] } @@ -9787,6 +9784,2710 @@ "step": 3, "reason": "parent_not_held" } + }, + { + "name": "chain_hold_fetched_parent", + "about": "holding, a bound of 4: group 3 arrives after 4 and 5 (fetched, or reordered on the receipts track); 4 and 5 are held, and admitted when 3 links", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 6, + "root_hash": "d601dd9ea84765f0ccad0909bab62c3e96ddf6e659d8cb1db9e5f6ebdff74798", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "41a02b1b8ec76b99fc2780c67673236935df5f7484e2316dd4b100a3bd66c1a12ac8b1de6a6ed10c506b93d743ba709838fbec2979c0637a3223ac52c802cf0d", + "parent_receipt_hash": "e0e0f77a8e2fb0c61ebf73f9780c491ec7be6f48d5c9e17d3f1e62b1a17aac2e", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 4, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_hold_released_in_order", + "about": "holding: 3 and 2 held, then 1 links and releases 2, which releases 3", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 5, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_lost_receipt_without_recovery", + "about": "group 3's receipt lost and nothing held: every later group of the track names a parent the chain lacks, and the track stalls", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 6, + "root_hash": "d601dd9ea84765f0ccad0909bab62c3e96ddf6e659d8cb1db9e5f6ebdff74798", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "41a02b1b8ec76b99fc2780c67673236935df5f7484e2316dd4b100a3bd66c1a12ac8b1de6a6ed10c506b93d743ba709838fbec2979c0637a3223ac52c802cf0d", + "parent_receipt_hash": "e0e0f77a8e2fb0c61ebf73f9780c491ec7be6f48d5c9e17d3f1e62b1a17aac2e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 7, + "root_hash": "770978e4067ed6db5d26ca7f4c5953ff37e929434eb8a43fd790a79c5df74d04", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1829bb20f0f94a010b6ba4b57f36a4ebc4abbe50426df3d350f2e8cb23483cfc934ce472127b08bb3c9bef7ce5c96c1d01e0ebc1ad56e253de8de887e3fd010b", + "parent_receipt_hash": "1eac5d2f09a9680dc38155531519c4dc90dd48123b7c21b9783853e1bb20fe35", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 8, + "root_hash": "719a464800bd07b8875dccfb303ba77f09b5f96153c66b866f10d3fe500c93de", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "49b5cb5861d7d9ee66b16c74c618622a08269112d635f55ac45ad0fcd27b774b6d39bb7349edf91efa692ae925c04b8af8c1a6aa799ef93276c5f88287e4b30a", + "parent_receipt_hash": "89ca0c62e13319651b7315efe1ff74b56c7510f6d43997064dc7f3e2cbd659c9", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 9, + "root_hash": "d2e5e0d4c338382d84ac2ce36072b0c9c65b71045e266f7ed5401c2eaccb67ba", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "8b5e0aac1cd5a8346123cc96d13634eb404dcce08ebc8bb48751fb93291b9e2861ad3b43ff365e6bb5acf1d771d9c64a9380e5607a125f061605780d6f37b507", + "parent_receipt_hash": "19240323aa7005992a9c7e324fe7136488866209964a808cf0492cb57f14e926", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + } + ] + } + }, + { + "name": "chain_lost_receipt_reanchors", + "about": "group 3's receipt lost, a bound of 2 held groups: 4 and 5 are held; holding 6 would pass the bound, so 4 and 5 are refused (parent_not_recovered) and the chain re-anchors at 6, and the track resumes", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 6, + "root_hash": "d601dd9ea84765f0ccad0909bab62c3e96ddf6e659d8cb1db9e5f6ebdff74798", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "41a02b1b8ec76b99fc2780c67673236935df5f7484e2316dd4b100a3bd66c1a12ac8b1de6a6ed10c506b93d743ba709838fbec2979c0637a3223ac52c802cf0d", + "parent_receipt_hash": "e0e0f77a8e2fb0c61ebf73f9780c491ec7be6f48d5c9e17d3f1e62b1a17aac2e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 7, + "root_hash": "770978e4067ed6db5d26ca7f4c5953ff37e929434eb8a43fd790a79c5df74d04", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1829bb20f0f94a010b6ba4b57f36a4ebc4abbe50426df3d350f2e8cb23483cfc934ce472127b08bb3c9bef7ce5c96c1d01e0ebc1ad56e253de8de887e3fd010b", + "parent_receipt_hash": "1eac5d2f09a9680dc38155531519c4dc90dd48123b7c21b9783853e1bb20fe35", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 8, + "root_hash": "719a464800bd07b8875dccfb303ba77f09b5f96153c66b866f10d3fe500c93de", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "49b5cb5861d7d9ee66b16c74c618622a08269112d635f55ac45ad0fcd27b774b6d39bb7349edf91efa692ae925c04b8af8c1a6aa799ef93276c5f88287e4b30a", + "parent_receipt_hash": "89ca0c62e13319651b7315efe1ff74b56c7510f6d43997064dc7f3e2cbd659c9", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 9, + "root_hash": "d2e5e0d4c338382d84ac2ce36072b0c9c65b71045e266f7ed5401c2eaccb67ba", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "8b5e0aac1cd5a8346123cc96d13634eb404dcce08ebc8bb48751fb93291b9e2861ad3b43ff365e6bb5acf1d771d9c64a9380e5607a125f061605780d6f37b507", + "parent_receipt_hash": "19240323aa7005992a9c7e324fe7136488866209964a808cf0492cb57f14e926", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 2, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_reanchored_history_regained", + "about": "the same, then 5, 4 and 3 offered again (fetched): each is named by the earliest group the chain holds, and is history behind the labelled anchor", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 6, + "root_hash": "d601dd9ea84765f0ccad0909bab62c3e96ddf6e659d8cb1db9e5f6ebdff74798", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "41a02b1b8ec76b99fc2780c67673236935df5f7484e2316dd4b100a3bd66c1a12ac8b1de6a6ed10c506b93d743ba709838fbec2979c0637a3223ac52c802cf0d", + "parent_receipt_hash": "e0e0f77a8e2fb0c61ebf73f9780c491ec7be6f48d5c9e17d3f1e62b1a17aac2e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 7, + "root_hash": "770978e4067ed6db5d26ca7f4c5953ff37e929434eb8a43fd790a79c5df74d04", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1829bb20f0f94a010b6ba4b57f36a4ebc4abbe50426df3d350f2e8cb23483cfc934ce472127b08bb3c9bef7ce5c96c1d01e0ebc1ad56e253de8de887e3fd010b", + "parent_receipt_hash": "1eac5d2f09a9680dc38155531519c4dc90dd48123b7c21b9783853e1bb20fe35", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 8, + "root_hash": "719a464800bd07b8875dccfb303ba77f09b5f96153c66b866f10d3fe500c93de", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "49b5cb5861d7d9ee66b16c74c618622a08269112d635f55ac45ad0fcd27b774b6d39bb7349edf91efa692ae925c04b8af8c1a6aa799ef93276c5f88287e4b30a", + "parent_receipt_hash": "89ca0c62e13319651b7315efe1ff74b56c7510f6d43997064dc7f3e2cbd659c9", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 9, + "root_hash": "d2e5e0d4c338382d84ac2ce36072b0c9c65b71045e266f7ed5401c2eaccb67ba", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "8b5e0aac1cd5a8346123cc96d13634eb404dcce08ebc8bb48751fb93291b9e2861ad3b43ff365e6bb5acf1d771d9c64a9380e5607a125f061605780d6f37b507", + "parent_receipt_hash": "19240323aa7005992a9c7e324fe7136488866209964a808cf0492cb57f14e926", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 2, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + } + ] + } + }, + { + "name": "chain_reanchor_moves_forward", + "about": "no group held (a bound of 0): a group of id 1 whose parent the chain lacks would re-anchor the chain behind its head, and is refused; a later group of id 7 anchors it", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "f36e6661349856a48e8f115d31f21eaab8579d26926b5b7d3e757bb19118b43a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e14f7c7b79ebe8235a274a0d3a94bf855c2a137c027ba2124e5f57e28bbd2d85fd067f9180113fdbf62b298a4a8fa5d2864ed07470743a379961f366c2df2c0f", + "parent_receipt_hash": "8a0625ecc2b39df1260966795f01a3a3feefea953433130420fa0e41f5f7b6b4", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 7, + "root_hash": "c1e5abab9299133999c8626199269db55d800865e7e7d750a64843ac55d17e34", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cee9dc9f5c0cdaaeeebc92b5cb54763f7a30dd767c06c820d0eeae21cf5d18514494ce495a26856645ca98ec38766f9a20ef13d444f3c74da6fc38a259e71304", + "parent_receipt_hash": "8a0625ecc2b39df1260966795f01a3a3feefea953433130420fa0e41f5f7b6b4", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 0, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "group_id_not_increasing" + }, + { + "outcome": "accept", + "admission": "anchor" + } + ] + } + }, + { + "name": "chain_join_mid_stream", + "about": "a verifier that joins at group 2: anchored there, and every later group links", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": null + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_join_without_joining", + "about": "the same groups to a verifier that does not join: nothing is admitted", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_held" + } + ] + } + }, + { + "name": "chain_join_history_fetched", + "about": "a verifier that joins at group 3, then receives 2, 1 and 0 (joining at an earlier group): each is named by the earliest group held, and is history behind the labelled anchor", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": null + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + } + ] + } + }, + { + "name": "chain_join_from_the_first_group", + "about": "a verifier that joins at the chain's first group: a null parent is the chain's start, and no anchor", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": null + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_join_legacy_no_history", + "about": "a verifier that joins a legacy chain at group 1: anchored; group 0, behind it, cannot be history, since a legacy parent is unsigned, and is a second chain head", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 7, + "group_id": 1, + "root_hash": "8331d9fe4bdffc8fa5fddf5150c242fc88137fb2e005b82f3ebb3cda650f4616", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "11aea3b0d3b9655f822c5ac0752a8960f44a203b401434b88490c217fffaac1d7db50c754597b9095d818bbd44cc4fef32169d829a861a412cbb34cd9efc6e0b", + "parent_receipt_hash": "45e969734e3939cd9ad0a8e395997241caae3ffa4e05e87faf7781daf50fff90" + }, + { + "kind": "group", + "track_alias": 7, + "group_id": 2, + "root_hash": "9df0bc7e1b1c48c3dee25420079e48029b0176ce25ee288db6c6e0e9a21afbf9", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e21dbcbf11666844e8b6761c15e64ea0767517fc5884754d0aee6560ba40ed3db087a144a2e84f51cde4b7acf21c06580b18ee2161f5be10696983278d7bfd03", + "parent_receipt_hash": "b8a69dceed330199fcfffd4a7b694f9a5316eda67082f8175b1c37f9bfacfe73" + }, + { + "kind": "group", + "track_alias": 7, + "group_id": 0, + "root_hash": "78c4bc6e1f021c460e5d8464958c9b0cfecc27dabfa354d8e890ece843f31c36", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b6c26ae1683816971492a4ba43a0d5efcd2d5afb8a41f0ccb8765682db4a8b37a9916b8de36c3f2209ecaabf5867df74ba8dbba39291e3f3465779768191130d", + "parent_receipt_hash": null + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": null + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "chain_break" + } + ] + } + }, + { + "name": "chain_join_stale_anchor", + "about": "a verifier that joins with the freshness bound on: a stale group does not anchor; the next fresh group does", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T11:00:00Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6adffb53108a80752229f6943c42cd654b5a04f466ae00b60b6335638224d41ee433153399fa078f1da8e5147e8e2e6f97bb4eb3de51a65844e49fa565a5a309", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + } + ], + "guard": false, + "freshness": { + "now": "2026-09-30T12:00:01Z", + "max_age_s": 60, + "max_skew_s": 5 + }, + "recovery": { + "join": true, + "max_held": null + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "reject", + "step": 3, + "reason": "group_not_fresh" + }, + { + "outcome": "accept", + "admission": "anchor" + } + ] + } + }, + { + "name": "chain_fetch_after_reanchor_ascending", + "about": "group 3's receipt withheld, a live bound of 4: 4 to 7 are held, 8 passes the bound, they are refused and the chain re-anchors at 8; then the gap is fetched in ascending order, as MOQT delivers a FETCH: 3 links forward from 2, the last group admitted before the gap, and each of 4 to 7 links forward from the one before, 7 also to 8's signed parent: all history, none a fork", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 9, + "group_id": 0, + "root_hash": "036a682a6a152580bd6baf53a132923d609ddea248123ec8fde6ae1911891b47", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6c3250fd7645a2d2acfaa5e65b7ba00873faff2e12468f5080717dda1c95e2db2887fdcaf5444232bf9b27014b387b11b4354866b16b10d790c897d0ab0e740d", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 1, + "root_hash": "27b26c10de48d117a1ac8365fab1cd21eef878c1173ea1e41d9400d85775108a", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "fc96c9521bd072ba6db34028a40f6c996ee1d6e32d9ad873ac66a3930ad6f5c239187556cdff6be37723dfe406e87d0b6184eb1840b165f28b359ce10a277f0d", + "parent_receipt_hash": "90545ae4ccc754f90126a514b26beb950c6895283589b144166895b6771e8558", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 2, + "root_hash": "93ee67c2a386cae60fe89c0128d3ba49f7792e5dfd95b582cd296c0837ed5327", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d11e4dd74b0fdacf444c4c0a0627cb404513ef0837a499414d493468b465e3e05520dee030312f0d3b691072e4d13282aff8f499d6ecc9f75e77a417564d4208", + "parent_receipt_hash": "fefaf98375124b3ba09eecbf6075ef3cabe98d34536d40e6806287bd49f34ad8", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 6, + "root_hash": "d601dd9ea84765f0ccad0909bab62c3e96ddf6e659d8cb1db9e5f6ebdff74798", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "41a02b1b8ec76b99fc2780c67673236935df5f7484e2316dd4b100a3bd66c1a12ac8b1de6a6ed10c506b93d743ba709838fbec2979c0637a3223ac52c802cf0d", + "parent_receipt_hash": "e0e0f77a8e2fb0c61ebf73f9780c491ec7be6f48d5c9e17d3f1e62b1a17aac2e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 7, + "root_hash": "770978e4067ed6db5d26ca7f4c5953ff37e929434eb8a43fd790a79c5df74d04", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1829bb20f0f94a010b6ba4b57f36a4ebc4abbe50426df3d350f2e8cb23483cfc934ce472127b08bb3c9bef7ce5c96c1d01e0ebc1ad56e253de8de887e3fd010b", + "parent_receipt_hash": "1eac5d2f09a9680dc38155531519c4dc90dd48123b7c21b9783853e1bb20fe35", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 8, + "root_hash": "719a464800bd07b8875dccfb303ba77f09b5f96153c66b866f10d3fe500c93de", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "49b5cb5861d7d9ee66b16c74c618622a08269112d635f55ac45ad0fcd27b774b6d39bb7349edf91efa692ae925c04b8af8c1a6aa799ef93276c5f88287e4b30a", + "parent_receipt_hash": "89ca0c62e13319651b7315efe1ff74b56c7510f6d43997064dc7f3e2cbd659c9", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 3, + "root_hash": "ff4e0d10b88988e43641ce8b6a018c607109a2a04971b3db99591f04a9e80a29", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f9fdc180115ad6518b04c98d7eeb27a607b29cb27761ec873ca98155646075813b140fbc33d15cdd10ca2bba9f0bd43b8a7fcc92843abe90af00a8b6761140c", + "parent_receipt_hash": "fdb93fffe0f262da05a9c029bc7f6c6a897bbf55642e83a5820bc719c26ff7a2", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 4, + "root_hash": "4de21d87278abd49827c0c64a375150c390db9df2e73073f89879b066fb522d8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "871932ae7826fbab6d2a19c8c907b03cc04f36ae5f58328e270c739207acd199aaabeb6f471aaae9f65ddb3c1716fd19d5848352ea8a64bdcfc939eeb2b8a90b", + "parent_receipt_hash": "5372f2abef536efc3632de322d46d58c1b4d31bf78a8ac9c4f928f6370458873", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 5, + "root_hash": "285ceb151125a7b08f593bbd3e3ce9ae8b057118e4e210ec77a70d364ab3ad11", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3a876a0b222f02e3a066c512f2b55f50f46a630b0c199d4f6131e51e646a07b2801f21a5da2639b13669ba997cca149cd5ff1453283b6e02de651d5d901cd909", + "parent_receipt_hash": "167ac17b76ccd0bb10dfd535d0a934c3cdbcf5d8b62c1c25d743263550447b73", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 6, + "root_hash": "d601dd9ea84765f0ccad0909bab62c3e96ddf6e659d8cb1db9e5f6ebdff74798", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "41a02b1b8ec76b99fc2780c67673236935df5f7484e2316dd4b100a3bd66c1a12ac8b1de6a6ed10c506b93d743ba709838fbec2979c0637a3223ac52c802cf0d", + "parent_receipt_hash": "e0e0f77a8e2fb0c61ebf73f9780c491ec7be6f48d5c9e17d3f1e62b1a17aac2e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 9, + "group_id": 7, + "root_hash": "770978e4067ed6db5d26ca7f4c5953ff37e929434eb8a43fd790a79c5df74d04", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "1829bb20f0f94a010b6ba4b57f36a4ebc4abbe50426df3d350f2e8cb23483cfc934ce472127b08bb3c9bef7ce5c96c1d01e0ebc1ad56e253de8de887e3fd010b", + "parent_receipt_hash": "1eac5d2f09a9680dc38155531519c4dc90dd48123b7c21b9783853e1bb20fe35", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 4, + "max_history": 8, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + } + ] + } + }, + { + "name": "chain_join_ascending_fill", + "about": "a verifier that joins at group 20 with a live bound of 4 and a history bound of 16, then receives 10 to 19 in ascending order (a fill fetch stream, MOQT §3.4), then 21: 10 to 18 are held as history, outside the live bound; 19 links back from the anchor and releases 18, which releases 17, down to 10: all history", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 10, + "group_id": 20, + "root_hash": "4473f22ba34b76781ddfef087b404dba88f5572f0d92995574ddf6dbdfef2b44", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e064c792826bac9b48efdba05c92e2cc2fa772fd790577b2e59da79542478856561766bb90828357bde17f2fc8b21ba944212ecbcfb602a18f4dd86d200e6c0b", + "parent_receipt_hash": "fab21eb15efd77b6d1ab52e45672516a39b9a6f04af219c91e0221e3f81f5450", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 10, + "root_hash": "4a2aa738b90afb0236c09468b00b939b0a7e6a87f3d0b3a6d9170977411342a8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6ce9f43bd2bc354ba2dcc13a7f0ca8ce48bf30b19302bd57f2a8babdea7a870df31a83947b003f1e8afebc84374de6057052235b4c39a554420a55bd0649830a", + "parent_receipt_hash": "c500bc1c6b0ff9a440d3d47261ab9e81f622e73e26da7b35b2792498579a3698", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 11, + "root_hash": "e3f6b0decc095d2dd44cba69754042afd8d6d8375eb03eb2167a3d24fe70b32b", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9037a34c767d221a6c6b13996390143b06cceb14b572ff05d756ddaf499554108e6a0f0d6a07696b3f8b9e84f9158374e12ea5ccd52e617d952130641ab10104", + "parent_receipt_hash": "3583a231118d337cb415542a2c81aaef6394f071577c120d3bb98b339b763c22", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 12, + "root_hash": "a58adb7b5dd0a70ea81b3eba6fbfb945c5edecb54e42f73749cebb25f4702341", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "bc093dabe716fe3c6865ddbc222f06b707b5c7fec1d5220bbbe6163cfbfe3a855e699c59145fd08978331d6ae189c6a8abb7de0dfb9ba8214792aacd6e59690e", + "parent_receipt_hash": "6bf5e6f2621d8826ab5d057884efcf32abb74459299d778e2a65aa5ab9bf4a64", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 13, + "root_hash": "0c631079e4ff63ba7280c4cb06e2681bbb1df0f83a6ce7ccf746e953762e6d0d", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "2587aae258e63402d5d268a08207c7ac564c1a5efaf3a3bfc4137c0fb8cc35beaa54412bf1e493ce71cd8ba84755fc37ff82ef95261431964609df0781117d00", + "parent_receipt_hash": "33a02700e4a095b6e64d18b5df509be349cc9506d65434683dc6691e9bb7da61", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 14, + "root_hash": "731b3dbb3d79b80663f062d08664e29213315473429a345cde90972cac2e4dba", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "743fc648b3664d59520a62f8d5e5a7b12663f4048c0cf0da55acd93f65198026bafbe3326abafbc9235ad2c7834fcc4fce974c75b52ffb989efbd30e62556002", + "parent_receipt_hash": "6a732c2d425649b1c3657828437e60a6a20aa5b60d95ac09bfb5ac40bfc21f62", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 15, + "root_hash": "34ccf179eb6912fef37d4b443761975ea0631ac6458aa1a1c852b64c1435289e", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b59113b86a64cabb59d669a039973594286ae816e9ad8c92d748c70acc69d6615972802b83037a5ea628c2a465841b2f901e0c4bdb7e2728a282ba964abfaa0d", + "parent_receipt_hash": "1e4b51341ab6f67ab40627c7de3fc02ed9d06738a8ca15e516715e54a66b77db", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 16, + "root_hash": "873daee077b50ac79cc1f92dd237291f3dee3110ccb78abb509b126bde8e2ab0", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "45d5a8506efd70928797bad413ffea18042376872136a1f7664c223be56483a5a14c1ceb7610301d50d3c3f378df83c0952bd94c8d76aaf69509bfed6359d604", + "parent_receipt_hash": "ddefad2cb6957099fe50471cf1a391094d8193ef9a4cc4a3c0efb5f79a79fe9e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 17, + "root_hash": "91bc27d73addf488ecdadb75378143c504bdfa82d895f426f9a3936caa48f85e", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d4077ca3c9cfe596a6c1aa27144940eb202fcf120a46360df4610cb251b6cb60f201234ea1d6d7267090f917bd9db62e1ef42b453890229a85e2e60a7953fd08", + "parent_receipt_hash": "6e116ab945efb8db89f42bb9e4655e049d94882cd7dfb504a78c3ab24c0e953e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 18, + "root_hash": "4abba2616096b76e1726a92b76533fdb7f4e89b4e6c6d8334ed116e4524ffc06", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "0288b5f0d0a6645db98593aa616a0cd81988cd3b35e4cc84cbba09082a98c26a9fb63d99b910024f325eeda31e66939757f473a0b68f39cb3db8f1999f2db906", + "parent_receipt_hash": "d3da6ec82a7b8a239179f0914754474b886ae4c2deec77e87e713411b410ffe9", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 19, + "root_hash": "fed372b06b5a195f6c651b945722aa337fc90ebca47cb75ec68bb00ee7723db4", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "f0c404b8589fc613d5f1b91ac10fb725c50e82dec45f4cfeca3ec52b293dc16febd3975d8b1ca40ef38f6ef669ab40ea341deb72ca36d801338e58ccfc5f5a03", + "parent_receipt_hash": "2d24d9f0aa2580db43bbc5ccd1bf689d48d8d8caba7aa5fd2f10f9f166eea2d5", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 21, + "root_hash": "0a9e006ce445c213c804528d9ccef68e9602187354944d48eb98b0d26e614473", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "dc5ad5c4adb758fdbdb8b849015f3b6e34b331144b1e71c55fa25e8f1185510775dfc78e5fe99f6ed383b259c716cb68e5e3ad3bcceacd6d367f7cd6da427d0f", + "parent_receipt_hash": "aa8203768dfcad4177675fc2bcf31047dfd7a162e6bb5a4a210bdaa51f9bd272", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": 4, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_join_fill_past_history_bound", + "about": "the same with a history bound of 4: holding 14 passes it, and the lowest held below it, 10, is refused; so on up to 18; 19 then releases 18 to 15, the groups nearest the anchor", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 10, + "group_id": 20, + "root_hash": "4473f22ba34b76781ddfef087b404dba88f5572f0d92995574ddf6dbdfef2b44", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e064c792826bac9b48efdba05c92e2cc2fa772fd790577b2e59da79542478856561766bb90828357bde17f2fc8b21ba944212ecbcfb602a18f4dd86d200e6c0b", + "parent_receipt_hash": "fab21eb15efd77b6d1ab52e45672516a39b9a6f04af219c91e0221e3f81f5450", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 10, + "root_hash": "4a2aa738b90afb0236c09468b00b939b0a7e6a87f3d0b3a6d9170977411342a8", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "6ce9f43bd2bc354ba2dcc13a7f0ca8ce48bf30b19302bd57f2a8babdea7a870df31a83947b003f1e8afebc84374de6057052235b4c39a554420a55bd0649830a", + "parent_receipt_hash": "c500bc1c6b0ff9a440d3d47261ab9e81f622e73e26da7b35b2792498579a3698", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 11, + "root_hash": "e3f6b0decc095d2dd44cba69754042afd8d6d8375eb03eb2167a3d24fe70b32b", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9037a34c767d221a6c6b13996390143b06cceb14b572ff05d756ddaf499554108e6a0f0d6a07696b3f8b9e84f9158374e12ea5ccd52e617d952130641ab10104", + "parent_receipt_hash": "3583a231118d337cb415542a2c81aaef6394f071577c120d3bb98b339b763c22", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 12, + "root_hash": "a58adb7b5dd0a70ea81b3eba6fbfb945c5edecb54e42f73749cebb25f4702341", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "bc093dabe716fe3c6865ddbc222f06b707b5c7fec1d5220bbbe6163cfbfe3a855e699c59145fd08978331d6ae189c6a8abb7de0dfb9ba8214792aacd6e59690e", + "parent_receipt_hash": "6bf5e6f2621d8826ab5d057884efcf32abb74459299d778e2a65aa5ab9bf4a64", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 13, + "root_hash": "0c631079e4ff63ba7280c4cb06e2681bbb1df0f83a6ce7ccf746e953762e6d0d", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "2587aae258e63402d5d268a08207c7ac564c1a5efaf3a3bfc4137c0fb8cc35beaa54412bf1e493ce71cd8ba84755fc37ff82ef95261431964609df0781117d00", + "parent_receipt_hash": "33a02700e4a095b6e64d18b5df509be349cc9506d65434683dc6691e9bb7da61", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 14, + "root_hash": "731b3dbb3d79b80663f062d08664e29213315473429a345cde90972cac2e4dba", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "743fc648b3664d59520a62f8d5e5a7b12663f4048c0cf0da55acd93f65198026bafbe3326abafbc9235ad2c7834fcc4fce974c75b52ffb989efbd30e62556002", + "parent_receipt_hash": "6a732c2d425649b1c3657828437e60a6a20aa5b60d95ac09bfb5ac40bfc21f62", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 15, + "root_hash": "34ccf179eb6912fef37d4b443761975ea0631ac6458aa1a1c852b64c1435289e", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b59113b86a64cabb59d669a039973594286ae816e9ad8c92d748c70acc69d6615972802b83037a5ea628c2a465841b2f901e0c4bdb7e2728a282ba964abfaa0d", + "parent_receipt_hash": "1e4b51341ab6f67ab40627c7de3fc02ed9d06738a8ca15e516715e54a66b77db", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 16, + "root_hash": "873daee077b50ac79cc1f92dd237291f3dee3110ccb78abb509b126bde8e2ab0", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "45d5a8506efd70928797bad413ffea18042376872136a1f7664c223be56483a5a14c1ceb7610301d50d3c3f378df83c0952bd94c8d76aaf69509bfed6359d604", + "parent_receipt_hash": "ddefad2cb6957099fe50471cf1a391094d8193ef9a4cc4a3c0efb5f79a79fe9e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 17, + "root_hash": "91bc27d73addf488ecdadb75378143c504bdfa82d895f426f9a3936caa48f85e", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "d4077ca3c9cfe596a6c1aa27144940eb202fcf120a46360df4610cb251b6cb60f201234ea1d6d7267090f917bd9db62e1ef42b453890229a85e2e60a7953fd08", + "parent_receipt_hash": "6e116ab945efb8db89f42bb9e4655e049d94882cd7dfb504a78c3ab24c0e953e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 18, + "root_hash": "4abba2616096b76e1726a92b76533fdb7f4e89b4e6c6d8334ed116e4524ffc06", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "0288b5f0d0a6645db98593aa616a0cd81988cd3b35e4cc84cbba09082a98c26a9fb63d99b910024f325eeda31e66939757f473a0b68f39cb3db8f1999f2db906", + "parent_receipt_hash": "d3da6ec82a7b8a239179f0914754474b886ae4c2deec77e87e713411b410ffe9", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 19, + "root_hash": "fed372b06b5a195f6c651b945722aa337fc90ebca47cb75ec68bb00ee7723db4", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "f0c404b8589fc613d5f1b91ac10fb725c50e82dec45f4cfeca3ec52b293dc16febd3975d8b1ca40ef38f6ef669ab40ea341deb72ca36d801338e58ccfc5f5a03", + "parent_receipt_hash": "2d24d9f0aa2580db43bbc5ccd1bf689d48d8d8caba7aa5fd2f10f9f166eea2d5", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 10, + "group_id": 21, + "root_hash": "0a9e006ce445c213c804528d9ccef68e9602187354944d48eb98b0d26e614473", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "dc5ad5c4adb758fdbdb8b849015f3b6e34b331144b1e71c55fa25e8f1185510775dfc78e5fe99f6ed383b259c716cb68e5e3ad3bcceacd6d367f7cd6da427d0f", + "parent_receipt_hash": "aa8203768dfcad4177675fc2bcf31047dfd7a162e6bb5a4a210bdaa51f9bd272", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": 4, + "max_history": 4, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept", + "admission": "history" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_total_bound_across_chains", + "about": "two tracks, a live bound of 4 on each and 3 held in all: A2, A3 and B2 are held; holding A4 would pass the total, so A's held groups are refused and A re-anchors at A4; B3 is held, and B1 releases B2 and B3", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 11, + "group_id": 0, + "root_hash": "e435e27c4b730eb003bb65bd33953480bad80f54899e22a34556e3f019e64d83", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b9104439469c81217eb16d8cf12059315970188722bcb421977a0db5f32d6ce961637e9ae92cdae41f75ad59e8787e4d99aeb27d3ac58c90cbb7358186002b0c", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 12, + "group_id": 0, + "root_hash": "9e277cc035fee278310538858b5d9e42103fa3df9a5ea1f12d58bf3011227f32", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "692d5be0de5257cbf101c346ef43531c74db98f84b26a34fb52becb2f739e66da22ebdebbb1cce0259262cfddd24f0a5372945cabe987b8b792cc69efb7b7d01", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 11, + "group_id": 2, + "root_hash": "4508e96eeb7454b5ea0be90bf9b7f98d870f1cf1bb91ebe97b67706d655c2ea1", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3bb149d214d5c7211b363ae782bf8db881af33a1f820979667070a38566338b3d12550f9196e8aa2adc7bba7f7678055966c155e09ac11c3d739fa162bf2860c", + "parent_receipt_hash": "3351d9fab67c77da64648326b956427eecfe10a92b085e960a8b4c8e1377151e", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 11, + "group_id": 3, + "root_hash": "b7b32c9d60ba6b50d4c2ee67e7981ea978ff0d80f6b257a7e237a793f660ce36", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "f51fd049653ec1e7bc5e4b26bf52fcb7125c760fb8851a3d8b40ebf3d0e184ad6854d8f2b75345ddfe3ae61475a8a2209ca670c2c689b06285378bacc2b60503", + "parent_receipt_hash": "12cda5b46f39875b85ae9d2bc0508475b7b5ffb81f51b7476fa6d4d5ec84a57b", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 12, + "group_id": 2, + "root_hash": "6535dd69b174d4360cb972537b07a4057e876fbe1b257f66ce3c3334835642ca", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "93185ec5c3a4c9b241ef0b5180b09758e41c42216659a8c72d98e5190deac681399cb749e8247d617ffdde5b6ad97558f6e7c826a8b219c3c0e899b53f57b80e", + "parent_receipt_hash": "920bfb6f1474e6e47f2be183d922356928d0d42c881450e556b0f97761aee73d", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 11, + "group_id": 4, + "root_hash": "810005c8305b7af2be688f6cd91e14c8336d98e656e07102168f22a78d0656be", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "b0d0cd092ce2e5bce02ad0a372b28b29ac279ed07df101cbd6fb90cc86889bf80b8273dff0347644c902c7a3a4b0f79df7632439f1e230d6f38687bf293e160e", + "parent_receipt_hash": "59426d3f84efbf7877b31bc742b2d7723a8a8fe6684ee0694295871bff138949", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 12, + "group_id": 3, + "root_hash": "014587685955e28d83c133045ff4b96d25db82fbf82712a00b67c141530a508f", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "5e742762d32f6a80bcb84be98c88b5bbf1a16b902650945fe2b7ab8ee87bad42a6c745293e785015ee523e6a4903d6bf53f64e02922029b21518957cc9ef8101", + "parent_receipt_hash": "9e2b0b1c291029b596be827b9b82f803fd39d9f084f8894056fafa14092a9f27", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 12, + "group_id": 1, + "root_hash": "6ef36b99a2d85e795ede6f68a473f88a57e367e6968aa918f8684a35a06f38fd", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "3f0f02d323dfe2c49454fcbd36caa8a5a9932f021e02cd057fe896d2eab1c50be07676809a55a053c6ba706b237d20f6b7ea505ae2a1e0999a0c6c9e262fe10c", + "parent_receipt_hash": "05c9471444aed7ca9bb11722fa2e3860c32a2a42f44bb865090796a6740890e3", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 4, + "max_history": 4, + "max_total": 3 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "reject", + "step": 3, + "reason": "parent_not_recovered" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + } + ] + } + }, + { + "name": "chain_duplicate_after_the_head_moved", + "about": "group 3 offered again, the same bytes, after the head moved on to 6, as a subscription and a fill can deliver one object twice (MOQT §3.4): a group the chain admitted before, which changes nothing", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 13, + "group_id": 0, + "root_hash": "84e8a084d51a99defe73ce246c7843f3b7e005326e4af63417e1afb18fa51c0b", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "630917191bef5d09d3e5478229b0eed970b7e82681d876687b36de8697fc3c824ac3b855a7856e31fe8bd04b0c84bc8e85991683445a9414bcdf6e84f8223302", + "parent_receipt_hash": null, + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 1, + "root_hash": "fd4420ef787d0dcfcc401e749abdad5066226c9fcd3e0a19168101c64a8714a0", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "261d6a6a9967d0322c9b82824e2ee2466781ab68b769e226fa2e64446fc522a69c1b045eca011ed49f6d5b931f436d9d4154bd3f9aa664818be58c8e9b3a7902", + "parent_receipt_hash": "e376e499bb4cb14b9a37af6353f5e33142b608e82118f96a4dbeffcc5483745f", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 2, + "root_hash": "8bc9bc5f4806cfb6fda1feea9a441a8eeb9b57e69a21c30092e85c7c241f43a7", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "11bd5aca17554c4bed4943f98bf0147a315dfb372601290e955b7aab4820e05b80cb3e4f729e0292d1212b60cc067d2936bfe8d9a793ca73bc5cd4102408b60e", + "parent_receipt_hash": "bc8dcb786cb89f655e1fdb34cfe8f047a2002c262684a14c7a3f74d617e63221", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 3, + "root_hash": "c525fa8beb5c6d50f94c99d9a8378cf9420084e4ecdf176bab16e598d134cc51", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "c017aa594be38063154bbf71317c31816eb06b63c14fcba78039002adfc06a1bb8cf766ddcbe932025311b8720e6b671e974081bbc6b9b016a4eba552a547a0c", + "parent_receipt_hash": "d75f280fd6b78cad464331b3b760c7a62c309b0f864d335ec6bcd03ce5150b94", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 4, + "root_hash": "ec072c0e694158b0ca95eee1b3f14989e6674d1d8e911f04147e47abd829caec", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e1abe9b94b417636fed5b21d83ad1771ef54e05eaba55f3c68a07f2fb0a879a2f88a495c059d55a1851f8369c549320d75ef4f595a401cd655f4e725691da30d", + "parent_receipt_hash": "3cb4e4ec217afa796bc377bc65345e6e26f20b46fda8287480ef7168de1a8358", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 5, + "root_hash": "8333b1b31bfe80cf1a7a0c01c2a48e0a7ca2383a61b0189ef3a2d67e6b45b642", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cfbdc34bec81c9a45a3cb7a7c7803fa058331c7ba835fb355250ccda36a0213cd4ead2c153592531567cf81917fbb9f8a66e7640ee89ffb8329329766a77730c", + "parent_receipt_hash": "fd17b549ef8d179f0f1bb7f1d52a9fe45cd27db7877e3e3df5afc341b05f7720", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 6, + "root_hash": "a26fe60ecb557f2c32b796c2993a92592fc903333c854e187ade0edca6b1b3af", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f1a89b19ad5a85f3bab96dfd8ab966f946d584ca231550ff022d822ebb0717b3675d57169002b59de9fcbbffa7a5d7649fd26039ed9c6ad4f42b4793a4dc402", + "parent_receipt_hash": "7ca8e696abb9f4191a89ec82bb1a5128c1df2038c8a8c86319bd06e0979af080", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 3, + "root_hash": "c525fa8beb5c6d50f94c99d9a8378cf9420084e4ecdf176bab16e598d134cc51", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "c017aa594be38063154bbf71317c31816eb06b63c14fcba78039002adfc06a1bb8cf766ddcbe932025311b8720e6b671e974081bbc6b9b016a4eba552a547a0c", + "parent_receipt_hash": "d75f280fd6b78cad464331b3b760c7a62c309b0f864d335ec6bcd03ce5150b94", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": false, + "max_held": 2, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept", + "admission": "again" + } + ] + } + }, + { + "name": "chain_join_anchor_delivered_again", + "about": "a joiner anchored at 5, then 6 and 7, then 5 again (its fill delivering the anchor's group a second time) and 4: the anchor again changes nothing, and 4 is history", + "procedure": "chain", + "input": { + "groups": [ + { + "kind": "group", + "track_alias": 13, + "group_id": 5, + "root_hash": "8333b1b31bfe80cf1a7a0c01c2a48e0a7ca2383a61b0189ef3a2d67e6b45b642", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cfbdc34bec81c9a45a3cb7a7c7803fa058331c7ba835fb355250ccda36a0213cd4ead2c153592531567cf81917fbb9f8a66e7640ee89ffb8329329766a77730c", + "parent_receipt_hash": "fd17b549ef8d179f0f1bb7f1d52a9fe45cd27db7877e3e3df5afc341b05f7720", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 6, + "root_hash": "a26fe60ecb557f2c32b796c2993a92592fc903333c854e187ade0edca6b1b3af", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "9f1a89b19ad5a85f3bab96dfd8ab966f946d584ca231550ff022d822ebb0717b3675d57169002b59de9fcbbffa7a5d7649fd26039ed9c6ad4f42b4793a4dc402", + "parent_receipt_hash": "7ca8e696abb9f4191a89ec82bb1a5128c1df2038c8a8c86319bd06e0979af080", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 7, + "root_hash": "58c42e241c3344d37e43e390e96f16c9bb706f625bcf71421cb2f921eda0c1df", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "21977268d5044a53fb1b43e7d31009d26ac3774f9a431720e9c210470a8852aaa3776a4173bbdd6176c1d00cd8471235cc2705dcf8fd7cf510cc92c7f229ef0e", + "parent_receipt_hash": "0dc75cbebe0bb74b0dcd427bd2e175138996d190877c647d90fe9c533715134f", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 5, + "root_hash": "8333b1b31bfe80cf1a7a0c01c2a48e0a7ca2383a61b0189ef3a2d67e6b45b642", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "cfbdc34bec81c9a45a3cb7a7c7803fa058331c7ba835fb355250ccda36a0213cd4ead2c153592531567cf81917fbb9f8a66e7640ee89ffb8329329766a77730c", + "parent_receipt_hash": "fd17b549ef8d179f0f1bb7f1d52a9fe45cd27db7877e3e3df5afc341b05f7720", + "sign_version": 2 + }, + { + "kind": "group", + "track_alias": 13, + "group_id": 4, + "root_hash": "ec072c0e694158b0ca95eee1b3f14989e6674d1d8e911f04147e47abd829caec", + "leaf_count": 1, + "joules_total": 0, + "group_start": "2026-09-30T12:00:00Z", + "group_end": "2026-09-30T12:00:01Z", + "signer_pubkey": "d04ab232742bb4ab3a1368bd4615e4e6d0224ab71a016baf8520a332c9778737", + "signer_id": "did:example:publisher", + "sig": "e1abe9b94b417636fed5b21d83ad1771ef54e05eaba55f3c68a07f2fb0a879a2f88a495c059d55a1851f8369c549320d75ef4f595a401cd655f4e725691da30d", + "parent_receipt_hash": "3cb4e4ec217afa796bc377bc65345e6e26f20b46fda8287480ef7168de1a8358", + "sign_version": 2 + } + ], + "guard": false, + "freshness": null, + "recovery": { + "join": true, + "max_held": 2, + "max_history": 16, + "max_total": 64 + } + }, + "expected": { + "outcome": "chain", + "results": [ + { + "outcome": "accept", + "admission": "anchor" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept" + }, + { + "outcome": "accept", + "admission": "again" + }, + { + "outcome": "accept", + "admission": "history" + } + ] + } } ] } diff --git a/wai/registry/capabilities.json b/wai/registry/capabilities.json index 630c1f138..34cf28d92 100644 --- a/wai/registry/capabilities.json +++ b/wai/registry/capabilities.json @@ -1672,6 +1672,13 @@ "section": "§8.2", "status": "defined" }, + { + "name": "wai-receipts", + "kind": "catalog", + "document": "extensions/moq-streaming-format.md", + "section": "§4.1", + "status": "defined" + }, { "name": "wai1", "kind": "codecs-prefix", @@ -1735,12 +1742,33 @@ "section": "§8", "status": "defined" }, + { + "name": "wai_receipt_alias", + "kind": "catalog", + "document": "extensions/moq-streaming-format.md", + "section": "§4", + "status": "defined" + }, { "name": "wai_receipts", "kind": "catalog", "document": "extensions/moq-streaming-format.md", "section": "§4", "status": "defined" + }, + { + "name": "wai_receipts_track", + "kind": "catalog", + "document": "extensions/moq-streaming-format.md", + "section": "§4", + "status": "defined" + }, + { + "name": "wai_staged", + "kind": "catalog", + "document": "extensions/moq-streaming-format.md", + "section": "§4.1", + "status": "defined" } ] } diff --git a/wai/staged-conformance/README.md b/wai/staged-conformance/README.md index d314a0027..d49a0ea55 100644 --- a/wai/staged-conformance/README.md +++ b/wai/staged-conformance/README.md @@ -49,6 +49,51 @@ receipt for it, so that step 5 requires `delivered-verified` of its layers. - `schedule.json` — arrival traces, and the unit-by-unit presentation and stage a sink gives each (§8), with the count of units presented below the stage their layers would have reached on time. +- `moq/` — the carriage of a stage set over MoQ ([`moq-streaming-format`](../extensions/moq-streaming-format.md) + §4.1). `catalog.json` is the MSF catalog of a live set: its base track, two layer + tracks and the receipts track. `catalogs.json` holds catalogs around §4.1's check — + each rule broken alone, two faults, an entry with no readable set, two sets, MSF delta + updates (a clone of a layer, refused unless it overrides its alias and receipts + track), conversion to on demand, and tracks that inherit the catalog track's + namespace, checked under that namespace, under another and with none known — and, + given the catalog track's namespace each case states, for each set the first rule it + fails. `trace.json` carries the envelopes of + `cases/video_int_motion_gop_perframe/` on those tracks, one object to a group (object + 0, which each receipt names), through one relay to four subscribers: one of every + track; one of the base and layer 1 tracks only; one of those two whose relay drops the + receipts-track object of layer 1's group 6, and which fetches the groups a re-anchor + skips; and one of every track that joins after GOP 0. Each states the bound its verifier holds receipts under. It states each object's publication, its time upstream, queued at the relay and + downstream, and the `OBJECT_DELIVERY_TIMEOUT` its publisher sets (its slack to the + last unit it covers). The relay drops an object whose queue time passes its timeout, + which MOQT starts when the object reaches the relay, and its receipts still arrive on + the receipts track; the layer 2 of two GOPs is dropped, and frame 5's layer 1, queued + within its timeout after a slow upstream path, arrives after its deadline. Its receipt + took the same slow path, and a track's receipts are one chain (jwp-receipts §3 step + 3), so frames 6 and 7's layers wait for it before they are verified. Where group 6's + receipt is lost, groups 7 and 8 are held until holding group 9 would pass the bound + of 2: they are refused and the chain re-anchors at 9. The subscriber fetches the + receipts of 6 to 8, which arrive in ascending order 40 ms later and fill the gap as + history, each linking forward from the one before: 6's layer is then late, 7's and + 8's are in time and applied; 9 to 11 are verified. The joiner anchors each + chain at its first receipt. `expect` gives, for each subscriber, each object's fate + and when its receipt was verified or refused, the steps its sink takes (as a case's, + the base attested and each layer offered once the sink has it and its receipt is + settled), the sink's reports and final units, the stage presented at each clock + position of the bases it received, the deadline misses and the status and evidence a + `stage` claim gives each layer object; and the relay's counts of objects dropped and + of receipts forwarded for them, over every subscriber. joule-moq's relay runs the same trace + (`joule-code/joule-code-rs/crates/joule-moq/tests/staged_moq_trace.rs`), and + `wai-rs/tests/staged_moq.rs` runs it with signed receipts and claims. +- `rate_port.json` — the `rate` predictor of `wai.audio.int_refine` (Appendix A.4), + `rdiv(a·num + (b − a)·ρ, num)`, beside the f64 linear interpolation of the + layered-PCM ladder idea it replaces: `src = (i·den)/num`, `lo = ⌊src⌋`, + `frac = src − lo`, `hi = min(lo + 1, n − 1)`, `a + (b − a)·frac` rounded half away + from zero, written from that description. On integer fixtures (a sine by Bhaskara + I's rational approximation, a triangle, full-scale steps), mono and stereo, at seven + ratios up and down, it states the SHA-256 of the integer predictions and every + sample where the two differ. They agree wherever the phase `(i·den mod num)/num` is a + dyadic fraction, so that the f64 form is exact; they differ only where it is not, by + the f64 form's rounding near a half. - `index.json` — the cases and their final clip hashes. **The content is synthetic**: pictures, frames and PCM computed by integer functions in @@ -76,8 +121,9 @@ Every layer application, in every case, is checked by both. - `wai_staged_vectors verify .` (from `../wai-rs`, features `staged,video_int,audio_int,splat_int`): regenerates every file and requires it byte for byte, replays every case through `staged::StageSink`, decodes the bases it - can and checks their tiers, and replays the schedule. `wai_staged_vectors gen .` - writes the corpus. + can and checks their tiers, replays the schedule, and checks `moq/` from its committed + inputs. `rate_port.json` is checked by regeneration and by the library test + `staged::tests::rate_matches_ported_reference_where_exact`. `wai_staged_vectors gen .` writes the corpus. - `staged_verify.py .`: pure Python with the standard library, sharing no code with `wai-rs`. It reimplements, from the extension and SPEC: a strict reader of envelope JSON (UTF-8 with no byte-order mark, no member named twice, no NaN or Infinity, no @@ -89,7 +135,10 @@ Every layer application, in every case, is checked by both. units, the chain budget, live deadlines on one saturating clock across bases, and receipt evidence; tiers and stage hashes; the PNG and FLAC decodes; the schedule; exploding a `WAI2` component (reading the manifest's members as the text they are) - with the refinement role rules; and what a packer refuses. It takes BLAKE3 from + with the refinement role rules; what a packer refuses; `rate_port.json`, from its own + integer predictor and its own f64 form; and `moq/`: the catalog + check, each timeout as its slack, the priorities, each object's fate at the relay, + and each subscriber's steps replayed through its sink. It takes BLAKE3 from [`../claim-conformance/_blake3.py`](../claim-conformance/_blake3.py) and the rANS decoder and the `WIV1` keyframe-0 decode from [`../int-payload-conformance/int_payload_verify.py`](../int-payload-conformance/int_payload_verify.py), diff --git a/wai/staged-conformance/moq/catalog.json b/wai/staged-conformance/moq/catalog.json new file mode 100644 index 000000000..aa21791bc --- /dev/null +++ b/wai/staged-conformance/moq/catalog.json @@ -0,0 +1,93 @@ +{ + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] +} diff --git a/wai/staged-conformance/moq/catalogs.json b/wai/staged-conformance/moq/catalogs.json new file mode 100644 index 000000000..b2ce66db1 --- /dev/null +++ b/wai/staged-conformance/moq/catalogs.json @@ -0,0 +1,6723 @@ +{ + "format": "wai-staged-moq-catalogs/3", + "about": "moq-streaming-format §4.1, Checking a catalog: each catalog, the namespace of its catalog track as the checker knows it (null where it does not), and for each stage set it declares (by name, ascending), the first rule it fails or null. A track that declares no namespace inherits the catalog track's (MSF §5.2.2); a checker that does not know it refuses a set that mixes declared and inherited namespaces. An entry whose set cannot be read is reported for the empty name.", + "cases": [ + { + "name": "ok", + "about": "the set of the trace: a base track and two layer tracks, each with its receipts track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "layer_out_of_range", + "about": "a layer of 300", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 300, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "layer_not_an_integer", + "about": "a layer written 1.0", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1.0, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "lead_a_string", + "about": "a lead written as a string", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": "20" + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "packaging_not_wai", + "about": "a set track whose packaging is not wai", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "loc", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "is_live_absent", + "about": "a set track without isLive", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "render_group_true", + "about": "renderGroup true on layer 1: not an integer", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": true, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "render_group_float", + "about": "renderGroup 1.0 on layer 1: not an integer", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1.0, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "render_group_past_u64", + "about": "renderGroup 2^64 (read as a number past u64) on every set track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1.8446744073709552e+19, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1.8446744073709552e+19, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1.8446744073709552e+19, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "render_group_u64_max", + "about": "renderGroup 2^64 - 1 on every set track: an integer that fits u64", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 18446744073709551615, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 18446744073709551615, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 18446744073709551615, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "depends_an_object", + "about": "depends an object on layer 1", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": { + "cam1/base": 1 + }, + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "depends_a_string", + "about": "depends a string on layer 1, naming the base", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": "cam1/base", + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "depends_a_number", + "about": "depends 5 on layer 1", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": 5, + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "depends_true_on_base", + "about": "depends true on the base track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "depends": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "depends_a_number_inside", + "about": "depends on layer 1 holds a number beside the base's name", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + 5 + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "namespace_not_a_string", + "about": "a set track's namespace is a number", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": 5, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "duplicate_name", + "about": "the base's receipts track takes layer 1's name in its namespace: one namespace|name pair declared twice", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "layer_1_missing", + "about": "layer 2 with no layer 1 track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-set-incomplete" + } + ] + }, + { + "name": "depends_missing_lower", + "about": "layer 2 does not list a layer 1 track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "depends_lists_higher", + "about": "layer 1 lists layer 2", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L2" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "depends_lists_itself", + "about": "layer 1 lists itself", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "base_depends_on_layer", + "about": "the base lists a layer track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "depends": [ + "cam1/L1" + ] + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "depends_names_an_absent_track", + "about": "layer 1 lists a track the catalog does not declare", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "no-such-track" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "depends_outside_the_set", + "about": "a dependency on a catalog track outside the set", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1", + "audio" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "audio", + "packaging": "loc", + "isLive": true, + "renderGroup": 1 + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "base_in_another_namespace", + "about": "the base in another namespace, and an unrelated loc track of the base's name beside the layers: layer 1's dependency names that track, not the base", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/other", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "loc", + "isLive": true, + "renderGroup": 1 + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "render_group_split", + "about": "layer 2 in another render group", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 2, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "render_group_absent", + "about": "no render group on any track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts" + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "alt_group_on_layer", + "about": "an alternate group on a layer track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "altGroup": 1 + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "alt_group_on_base", + "about": "an alternate group on the base track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "altGroup": 1 + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "temporal_id_on_layer", + "about": "a temporalId on a layer track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "temporalId": 1 + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "spatial_id_on_layer", + "about": "a spatialId on a layer track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "spatialId": 1 + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "is_live_disagrees", + "about": "one track not live", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "two_namespaces", + "about": "a second base track, in another namespace: one set, two namespaces", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/other", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-grouping" + } + ] + }, + { + "name": "lead_missing", + "about": "a live layer track without a lead", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-lead" + } + ] + }, + { + "name": "lead_on_base", + "about": "a lead on the base track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": 5 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-lead" + } + ] + }, + { + "name": "not_live_no_lead", + "about": "a set that is not live, with no lead", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "not_live_lead_ignored", + "about": "a set that is not live, its leads left on: not read", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "alias_shared_across_layers", + "about": "layer 2 takes layer 1's receipt alias", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "alias_missing", + "about": "a track declaring receipts without an alias", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "alias_not_an_integer", + "about": "an alias written as a string", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": "8", + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "alias_float_elsewhere", + "about": "an alias 8.0 on another track (8 on layer 1): not an integer, wherever it is", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "audio", + "packaging": "loc", + "isLive": true, + "wai_receipt_alias": 8.0 + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "alias_shared_with_another_track", + "about": "the alias 8 on a track outside the set", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "audio", + "packaging": "loc", + "isLive": true, + "wai_receipt_alias": 8 + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "receipts_track_missing", + "about": "layer 1's receipts track is not in the catalog", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "receipts_track_not_named", + "about": "a track declaring receipts names no receipts track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "receipts_track_not_receipts", + "about": "layer 1 names as its receipts track a track of another packaging", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/base", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "receipts_track_two_chains", + "about": "layer 2 names layer 1's receipts track: two chains on one receipts track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "no_receipts_declared", + "about": "no track declares receipts, and there is no receipts track", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": false, + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": false, + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": false, + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "delta_remove_top_layer", + "about": "MSF delta: layer 2's track removed (its receipts track left): the set is layers 0 to 1", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "delta_add_layer_3", + "about": "MSF delta: a layer 3 track added, with its own alias and receipts track; the tracks declared before are unchanged", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L3", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1", + "cam1/L2" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 10, + "wai_receipts_track": "cam1/L3/receipts", + "wai_staged": { + "set": "cam1", + "layer": 3, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L3/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "delta_clone_layer_2", + "about": "MSF delta: layer 2 cloned as cam1/L2b, inheriting all but its name, layer 2's alias and receipts track among them: two tracks on one chain, which neither can carry", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2b", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-receipts" + } + ] + }, + { + "name": "delta_clone_layer_2_own_chain", + "about": "MSF delta: layer 2 cloned as cam1/L2b, overriding its alias and its receipts track, which is added: a rendition of layer 2 with a chain of its own", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2b", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 10, + "wai_receipts_track": "cam1/L2b/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2b/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "live_to_on_demand", + "about": "MSF §11.3: every track turned not live with a track duration; leads left on and not read", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "trackDuration": 480 + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "trackDuration": 480 + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": false, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "trackDuration": 480 + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": false, + "trackDuration": 480 + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": false, + "trackDuration": 480 + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": false, + "trackDuration": 480 + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "two_faults", + "about": "an alternate group on layer 1 and no dependency on layer 2: the earlier rule", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "altGroup": 1 + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "set_unreadable", + "about": "an entry whose set cannot be read, beside the set", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1x", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_staged": { + "set": 7, + "layer": 1, + "lead_ms": 20 + } + } + ] + }, + "expect": [ + { + "set": "", + "reason": "staged-malformed" + }, + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "two_sets", + "about": "a second set whose layer 2 track has no layer 1 below it, beside the set", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "mic/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "mic/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 20, + "wai_receipts_track": "mic/base/receipts", + "wai_staged": { + "set": "mic", + "layer": 0, + "lead_ms": null + }, + "depends": [] + }, + { + "namespace": "example/cam1", + "name": "mic/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "mic/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 22, + "wai_receipts_track": "mic/L2/receipts", + "wai_staged": { + "set": "mic", + "layer": 2, + "lead_ms": 10 + }, + "depends": [ + "mic/base" + ] + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + }, + { + "set": "mic", + "reason": "staged-set-incomplete" + } + ] + }, + { + "name": "inherit_layers_known", + "about": "the base declares example/cam1 and every other track inherits the catalog track's, example/cam1: one namespace", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "isLive": true, + "name": "cam1/base/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L1/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L2/receipts", + "packaging": "wai-receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "inherit_layers_other", + "about": "the same under a catalog track in other/ns: the base and the rest are in two namespaces, and the layers' depends name no track of theirs", + "namespace": "other/ns", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "isLive": true, + "name": "cam1/base/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L1/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L2/receipts", + "packaging": "wai-receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-depends" + } + ] + }, + { + "name": "inherit_layers_unknown", + "about": "the same to a checker that does not know the catalog track's namespace: a set that mixes declared and inherited namespaces is refused", + "namespace": null, + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "isLive": true, + "name": "cam1/base/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L1/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L2/receipts", + "packaging": "wai-receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "inherit_twice_known", + "about": "layer 1 again, inheriting example/cam1: the same track twice", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "inherit_twice_unknown", + "about": "the same to a checker that does not know the namespace: a mixed set, refused", + "namespace": null, + "catalog": { + "version": "draft-01", + "tracks": [ + { + "namespace": "example/cam1", + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts", + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts", + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts", + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + } + }, + { + "namespace": "example/cam1", + "name": "cam1/base/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L1/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "namespace": "example/cam1", + "name": "cam1/L2/receipts", + "packaging": "wai-receipts", + "isLive": true + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": "staged-malformed" + } + ] + }, + { + "name": "inherit_all_known", + "about": "every track inherits example/cam1", + "namespace": "example/cam1", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "isLive": true, + "name": "cam1/base/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L1/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L2/receipts", + "packaging": "wai-receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "inherit_all_other", + "about": "every track inherits other/ns", + "namespace": "other/ns", + "catalog": { + "version": "draft-01", + "tracks": [ + { + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "isLive": true, + "name": "cam1/base/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L1/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L2/receipts", + "packaging": "wai-receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + }, + { + "name": "inherit_all_unknown", + "about": "every track inherits, to a checker that does not know the namespace: one namespace, whichever it is", + "namespace": null, + "catalog": { + "version": "draft-01", + "tracks": [ + { + "wai_staged": { + "set": "cam1", + "layer": 0, + "lead_ms": null + }, + "name": "cam1/base", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "wai_capabilities": [ + "wai.video.int_motion" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 7, + "wai_receipts_track": "cam1/base/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 1, + "lead_ms": 20 + }, + "name": "cam1/L1", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 8, + "wai_receipts_track": "cam1/L1/receipts" + }, + { + "wai_staged": { + "set": "cam1", + "layer": 2, + "lead_ms": 30 + }, + "name": "cam1/L2", + "packaging": "wai", + "isLive": true, + "targetLatency": 300, + "role": "video", + "renderGroup": 1, + "depends": [ + "cam1/base", + "cam1/L1" + ], + "wai_capabilities": [ + "wai.video.int_refine" + ], + "wai_determinism": "decode-equivalence", + "wai_receipts": true, + "wai_receipt_alias": 9, + "wai_receipts_track": "cam1/L2/receipts" + }, + { + "isLive": true, + "name": "cam1/base/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L1/receipts", + "packaging": "wai-receipts" + }, + { + "isLive": true, + "name": "cam1/L2/receipts", + "packaging": "wai-receipts" + } + ] + }, + "expect": [ + { + "set": "cam1", + "reason": null + } + ] + } + ] +} diff --git a/wai/staged-conformance/moq/trace.json b/wai/staged-conformance/moq/trace.json new file mode 100644 index 000000000..d0a6db788 --- /dev/null +++ b/wai/staged-conformance/moq/trace.json @@ -0,0 +1,3054 @@ +{ + "format": "wai-staged-moq-trace/3", + "about": "A live stage set over MoQ (moq-streaming-format §4.1), through one relay, to four subscribers: one of every track; one of the base and layer 1 tracks only; one of those two whose relay drops one receipts-track object (layer 1, group 6) at its discretion, and which FETCHes the groups a re-anchor skips (their receipts reach it fetch_ms after the re-anchor, in ascending order); and one of every track that joins at joins_ms, receiving the objects the relay sends and the receipts that reach it from then on. Each object travels upstream to the relay, waits there for its queue time, and travels downstream; the relay drops it when its queue time passes its OBJECT_DELIVERY_TIMEOUT (MOQT §5.2: the timer starts when it reaches the relay). Each track's receipts ride that track's receipts track, which has the track's priority and no timeout and waits nowhere, so each object's receipts arrive at its publication plus its upstream and downstream times, whether or not the object does; a subscriber subscribes to the receipts tracks of the tracks it subscribes to, and to no others. Each receipt is about the object of its MOQT group and object id. A subscriber verifies each track's receipts as one chain (jwp-receipts §3 step 3), in the order they arrive (ties by the order of `objects`): one whose parent it lacks it holds, within the bound it states (`hold`: a count live for each chain, a count of history, a count across its chains); when holding one would pass the bound, it refuses those its chain holds and re-anchors there; the groups the anchor skips it refuses, or, where it fetches them, it admits as history each fetched receipt that links forward from the group below the gap or back from the group above (jwp-receipts §3 step 3, History), and their layers wait for them; a subscriber that joins anchors each chain at its first receipt. A sink applies an object when it has the object and its receipt is verified; one whose receipt is refused it offers with no evidence, and its sink refuses it. `expect` states, for each subscriber, each object's fate and its receipt's (when it arrived live, when it was fetched, when it was verified or refused), the steps its sink takes (those of a staged-conformance case), what the sink reports and holds, the stage it presents at each clock position, its deadline misses, and the status and evidence a stage claim gives each layer object; and the relay's counts, over every subscriber.", + "case": "video_int_motion_gop_perframe", + "catalog": "catalog.json", + "clock": { + "t0_ms": 0, + "unit_ms": 40, + "target_latency_ms": 300 + }, + "path_estimate_ms": 30, + "tracks": [ + { + "name": "cam1/base", + "layer": 0, + "receipt_alias": 7, + "priority": 64, + "lead_ms": null, + "receipts_track": "cam1/base/receipts" + }, + { + "name": "cam1/L1", + "layer": 1, + "receipt_alias": 8, + "priority": 96, + "lead_ms": 20, + "receipts_track": "cam1/L1/receipts" + }, + { + "name": "cam1/L2", + "layer": 2, + "receipt_alias": 9, + "priority": 128, + "lead_ms": 30, + "receipts_track": "cam1/L2/receipts" + } + ], + "objects": [ + { + "track": "cam1/base", + "group": 0, + "object_id": 0, + "file": "base_0.wai", + "object": "a42481ab67a88ed0042aeaad6839f772c6da08e02efc20a7e12d085169590c22", + "base": 0, + "units": { + "from": 0, + "count": 4 + }, + "publish_ms": 130, + "timeout_ms": 0, + "up_ms": 10, + "queue_ms": 0, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 0, + "object_id": 0, + "file": "g0_L1_f0.wai", + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "base": 0, + "units": { + "from": 0, + "count": 1 + }, + "publish_ms": 140, + "timeout_ms": 110, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 1, + "object_id": 0, + "file": "g0_L1_f1.wai", + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "base": 0, + "units": { + "from": 1, + "count": 1 + }, + "publish_ms": 145, + "timeout_ms": 145, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 2, + "object_id": 0, + "file": "g0_L1_f2.wai", + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "base": 0, + "units": { + "from": 2, + "count": 1 + }, + "publish_ms": 150, + "timeout_ms": 180, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 3, + "object_id": 0, + "file": "g0_L1_f3.wai", + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "base": 0, + "units": { + "from": 3, + "count": 1 + }, + "publish_ms": 155, + "timeout_ms": 215, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L2", + "group": 0, + "object_id": 0, + "file": "g0_L2.wai", + "object": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "base": 0, + "units": { + "from": 0, + "count": 4 + }, + "publish_ms": 170, + "timeout_ms": 190, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/base", + "group": 1, + "object_id": 0, + "file": "base_1.wai", + "object": "6d14b8f14808f3ba5a92d5141bcb380ccc097288231f9d91cf36ce199cbf13f4", + "base": 1, + "units": { + "from": 4, + "count": 4 + }, + "publish_ms": 290, + "timeout_ms": 0, + "up_ms": 10, + "queue_ms": 0, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 4, + "object_id": 0, + "file": "g1_L1_f0.wai", + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "base": 1, + "units": { + "from": 4, + "count": 1 + }, + "publish_ms": 300, + "timeout_ms": 110, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 5, + "object_id": 0, + "file": "g1_L1_f1.wai", + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "base": 1, + "units": { + "from": 5, + "count": 1 + }, + "publish_ms": 305, + "timeout_ms": 145, + "up_ms": 150, + "queue_ms": 40, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 6, + "object_id": 0, + "file": "g1_L1_f2.wai", + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "base": 1, + "units": { + "from": 6, + "count": 1 + }, + "publish_ms": 310, + "timeout_ms": 180, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 7, + "object_id": 0, + "file": "g1_L1_f3.wai", + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "base": 1, + "units": { + "from": 7, + "count": 1 + }, + "publish_ms": 315, + "timeout_ms": 215, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L2", + "group": 1, + "object_id": 0, + "file": "g1_L2.wai", + "object": "9783e9954b7c8d9f3ec2a71906e8175a4b8c7f9cd2b513a356f448ede8a69c1b", + "base": 1, + "units": { + "from": 4, + "count": 4 + }, + "publish_ms": 330, + "timeout_ms": 190, + "up_ms": 10, + "queue_ms": 200, + "down_ms": 10 + }, + { + "track": "cam1/base", + "group": 2, + "object_id": 0, + "file": "base_2.wai", + "object": "8124f82b0bd295aea2e4d7622bba87350da346ce2137c97581a15657d15d05c3", + "base": 2, + "units": { + "from": 8, + "count": 4 + }, + "publish_ms": 450, + "timeout_ms": 0, + "up_ms": 10, + "queue_ms": 0, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 8, + "object_id": 0, + "file": "g2_L1_f0.wai", + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "base": 2, + "units": { + "from": 8, + "count": 1 + }, + "publish_ms": 460, + "timeout_ms": 110, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 9, + "object_id": 0, + "file": "g2_L1_f1.wai", + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "base": 2, + "units": { + "from": 9, + "count": 1 + }, + "publish_ms": 465, + "timeout_ms": 145, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 10, + "object_id": 0, + "file": "g2_L1_f2.wai", + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "base": 2, + "units": { + "from": 10, + "count": 1 + }, + "publish_ms": 470, + "timeout_ms": 180, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L1", + "group": 11, + "object_id": 0, + "file": "g2_L1_f3.wai", + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "base": 2, + "units": { + "from": 11, + "count": 1 + }, + "publish_ms": 475, + "timeout_ms": 215, + "up_ms": 10, + "queue_ms": 5, + "down_ms": 10 + }, + { + "track": "cam1/L2", + "group": 2, + "object_id": 0, + "file": "g2_L2.wai", + "object": "211e0a912b9393caa2d957ccd3b9fad5fbf5514204e9b70a89caf248bf696d7c", + "base": 2, + "units": { + "from": 8, + "count": 4 + }, + "publish_ms": 490, + "timeout_ms": 190, + "up_ms": 10, + "queue_ms": 250, + "down_ms": 10 + } + ], + "subscribers": [ + { + "name": "full", + "tracks": [ + "cam1/base", + "cam1/L1", + "cam1/L2" + ], + "timeout_ms": 0, + "receipts": true, + "hold": { + "max_groups": 4, + "max_history": 16, + "max_total": 64 + }, + "joins_ms": null, + "lost_receipts": [], + "fetch_ms": null + }, + { + "name": "capped", + "tracks": [ + "cam1/base", + "cam1/L1" + ], + "timeout_ms": 0, + "receipts": true, + "hold": { + "max_groups": 4, + "max_history": 16, + "max_total": 64 + }, + "joins_ms": null, + "lost_receipts": [], + "fetch_ms": null + }, + { + "name": "lossy", + "tracks": [ + "cam1/base", + "cam1/L1" + ], + "timeout_ms": 0, + "receipts": true, + "hold": { + "max_groups": 2, + "max_history": 16, + "max_total": 64 + }, + "joins_ms": null, + "lost_receipts": [ + { + "track": "cam1/L1", + "group": 6 + } + ], + "fetch_ms": 40 + }, + { + "name": "joiner", + "tracks": [ + "cam1/base", + "cam1/L1", + "cam1/L2" + ], + "timeout_ms": 0, + "receipts": true, + "hold": { + "max_groups": 4, + "max_history": 16, + "max_total": 64 + }, + "joins_ms": 250, + "lost_receipts": [], + "fetch_ms": null + } + ], + "sink": { + "bound": { + "max_envelopes": 16, + "max_age_ms": 1000 + }, + "cost_limit": null, + "budget": 1073741824, + "record_budget": 1048576, + "live": { + "t0_ms": 0, + "unit_ms": 40, + "target_latency_ms": 300, + "lead_ms": [ + 20, + 30 + ] + } + }, + "expect": { + "relay": { + "dropped_on_timeout": 4, + "receipts_forwarded_for_dropped": 4 + }, + "subscribers": [ + { + "name": "full", + "objects": [ + { + "track": "cam1/base", + "group": 0, + "fate": "forwarded", + "arrival_ms": 150, + "receipt_ms": 150, + "fetched_ms": null, + "verified_ms": 150, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 0, + "fate": "forwarded", + "arrival_ms": 165, + "receipt_ms": 160, + "fetched_ms": null, + "verified_ms": 160, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 1, + "fate": "forwarded", + "arrival_ms": 170, + "receipt_ms": 165, + "fetched_ms": null, + "verified_ms": 165, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 2, + "fate": "forwarded", + "arrival_ms": 175, + "receipt_ms": 170, + "fetched_ms": null, + "verified_ms": 170, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 3, + "fate": "forwarded", + "arrival_ms": 180, + "receipt_ms": 175, + "fetched_ms": null, + "verified_ms": 175, + "refused_ms": null + }, + { + "track": "cam1/L2", + "group": 0, + "fate": "forwarded", + "arrival_ms": 195, + "receipt_ms": 190, + "fetched_ms": null, + "verified_ms": 190, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 1, + "fate": "forwarded", + "arrival_ms": 310, + "receipt_ms": 310, + "fetched_ms": null, + "verified_ms": 310, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 4, + "fate": "forwarded", + "arrival_ms": 325, + "receipt_ms": 320, + "fetched_ms": null, + "verified_ms": 320, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 5, + "fate": "forwarded", + "arrival_ms": 505, + "receipt_ms": 465, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 6, + "fate": "forwarded", + "arrival_ms": 335, + "receipt_ms": 330, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 7, + "fate": "forwarded", + "arrival_ms": 340, + "receipt_ms": 335, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L2", + "group": 1, + "fate": "dropped", + "arrival_ms": null, + "receipt_ms": 350, + "fetched_ms": null, + "verified_ms": 350, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 2, + "fate": "forwarded", + "arrival_ms": 470, + "receipt_ms": 470, + "fetched_ms": null, + "verified_ms": 470, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 8, + "fate": "forwarded", + "arrival_ms": 485, + "receipt_ms": 480, + "fetched_ms": null, + "verified_ms": 480, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 9, + "fate": "forwarded", + "arrival_ms": 490, + "receipt_ms": 485, + "fetched_ms": null, + "verified_ms": 485, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 10, + "fate": "forwarded", + "arrival_ms": 495, + "receipt_ms": 490, + "fetched_ms": null, + "verified_ms": 490, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 11, + "fate": "forwarded", + "arrival_ms": 500, + "receipt_ms": 495, + "fetched_ms": null, + "verified_ms": 495, + "refused_ms": null + }, + { + "track": "cam1/L2", + "group": 2, + "fate": "dropped", + "arrival_ms": null, + "receipt_ms": 510, + "fetched_ms": null, + "verified_ms": 510, + "refused_ms": null + } + ], + "steps": [ + { + "op": "base", + "index": 0, + "at_ms": 150 + }, + { + "op": "offer", + "file": "g0_L1_f0.wai", + "at_ms": 165, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f1.wai", + "at_ms": 170, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f2.wai", + "at_ms": 175, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f3.wai", + "at_ms": 180, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L2.wai", + "at_ms": 195, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 1, + "at_ms": 310 + }, + { + "op": "offer", + "file": "g1_L1_f0.wai", + "at_ms": 325, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f2.wai", + "at_ms": 465, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f3.wai", + "at_ms": 465, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 2, + "at_ms": 470 + }, + { + "op": "offer", + "file": "g2_L1_f0.wai", + "at_ms": 485, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f1.wai", + "at_ms": 490, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f2.wai", + "at_ms": 495, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f3.wai", + "at_ms": 500, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f1.wai", + "at_ms": 505, + "evidence": "delivered-verified" + }, + { + "op": "expire", + "at_ms": 1506 + } + ], + "reports": [ + [], + [ + { + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0, + 1, + 2, + 3 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [ + [ + 1, + "deadline-missed" + ] + ] + } + ], + [] + ], + "bases": [ + { + "units": [ + { + "stage": 2, + "producer": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1d6d55c81168fb1caf731b14f1a35eafad389741eadd71687c4515b2d37b3529" + }, + { + "stage": 2, + "producer": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "3a21f4dbaecb8b67d570c9bcc1bdf39b3335a99cb77ad26d3fed1e0dfefa915a" + }, + { + "stage": 2, + "producer": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "cb8d6416bb3f969f666f101536ef557ac4e74c2d3e700983a264c71056304a6f" + }, + { + "stage": 2, + "producer": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "9a95501a0d07b49dc5a8a7cc878ddf4d985e400c746e0ea8e116760f5625ff6d" + } + ], + "clip_sha256": "4423cde72c6a314df503f5698532daa9917673ab871867b0ab5aa07c6d933694", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4e3d200250bd9d3dced1df0030d3991d1be423d290ec07590737f88b18b03d94" + }, + { + "stage": 0, + "producer": "6d14b8f14808f3ba5a92d5141bcb380ccc097288231f9d91cf36ce199cbf13f4", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4b22b0de48130b32cc4be939932bdb27bea1f9d57bb8622f0e60fae14c230394" + }, + { + "stage": 1, + "producer": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "c0d188e7ffa37de21e013ae59e3a1555d83431b0a380165096c9ab12501e4d99" + }, + { + "stage": 1, + "producer": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "9d3b0caf0f7ade60997a6a087a51b12caaadb22981f4f03e99b43cecb56c3cc7" + } + ], + "clip_sha256": "4b1d65b0abbe3c006a0da06480aa094ef9100f488e2f3989bb0a031e32e20e1c", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + }, + { + "stage": 1, + "producer": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + } + ], + "clip_sha256": "1e2b9e16cffc20fb15d943d84d1efb86cbaa76a7b9f81e4425b7f41a7d2c6029", + "n_units": 4 + } + ], + "presented": [ + 2, + 2, + 2, + 2, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "deadline_misses": 1, + "statuses": [ + { + "track": "cam1/L1", + "group": 0, + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 1, + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 2, + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 3, + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L2", + "group": 0, + "object": "a0e853ef2a2e2f71bdb0a5db71f980b1d7b7ae1f935f24a6c293c780e9325955", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 4, + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 5, + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "status": "late", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 6, + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 7, + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L2", + "group": 1, + "object": "9783e9954b7c8d9f3ec2a71906e8175a4b8c7f9cd2b513a356f448ede8a69c1b", + "status": "dropped", + "evidence": "inclusion-verified" + }, + { + "track": "cam1/L1", + "group": 8, + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 9, + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 10, + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 11, + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L2", + "group": 2, + "object": "211e0a912b9393caa2d957ccd3b9fad5fbf5514204e9b70a89caf248bf696d7c", + "status": "dropped", + "evidence": "inclusion-verified" + } + ] + }, + { + "name": "capped", + "objects": [ + { + "track": "cam1/base", + "group": 0, + "fate": "forwarded", + "arrival_ms": 150, + "receipt_ms": 150, + "fetched_ms": null, + "verified_ms": 150, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 0, + "fate": "forwarded", + "arrival_ms": 165, + "receipt_ms": 160, + "fetched_ms": null, + "verified_ms": 160, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 1, + "fate": "forwarded", + "arrival_ms": 170, + "receipt_ms": 165, + "fetched_ms": null, + "verified_ms": 165, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 2, + "fate": "forwarded", + "arrival_ms": 175, + "receipt_ms": 170, + "fetched_ms": null, + "verified_ms": 170, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 3, + "fate": "forwarded", + "arrival_ms": 180, + "receipt_ms": 175, + "fetched_ms": null, + "verified_ms": 175, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 1, + "fate": "forwarded", + "arrival_ms": 310, + "receipt_ms": 310, + "fetched_ms": null, + "verified_ms": 310, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 4, + "fate": "forwarded", + "arrival_ms": 325, + "receipt_ms": 320, + "fetched_ms": null, + "verified_ms": 320, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 5, + "fate": "forwarded", + "arrival_ms": 505, + "receipt_ms": 465, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 6, + "fate": "forwarded", + "arrival_ms": 335, + "receipt_ms": 330, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 7, + "fate": "forwarded", + "arrival_ms": 340, + "receipt_ms": 335, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 2, + "fate": "forwarded", + "arrival_ms": 470, + "receipt_ms": 470, + "fetched_ms": null, + "verified_ms": 470, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 8, + "fate": "forwarded", + "arrival_ms": 485, + "receipt_ms": 480, + "fetched_ms": null, + "verified_ms": 480, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 9, + "fate": "forwarded", + "arrival_ms": 490, + "receipt_ms": 485, + "fetched_ms": null, + "verified_ms": 485, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 10, + "fate": "forwarded", + "arrival_ms": 495, + "receipt_ms": 490, + "fetched_ms": null, + "verified_ms": 490, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 11, + "fate": "forwarded", + "arrival_ms": 500, + "receipt_ms": 495, + "fetched_ms": null, + "verified_ms": 495, + "refused_ms": null + } + ], + "steps": [ + { + "op": "base", + "index": 0, + "at_ms": 150 + }, + { + "op": "offer", + "file": "g0_L1_f0.wai", + "at_ms": 165, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f1.wai", + "at_ms": 170, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f2.wai", + "at_ms": 175, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f3.wai", + "at_ms": 180, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 1, + "at_ms": 310 + }, + { + "op": "offer", + "file": "g1_L1_f0.wai", + "at_ms": 325, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f2.wai", + "at_ms": 465, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f3.wai", + "at_ms": 465, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 2, + "at_ms": 470 + }, + { + "op": "offer", + "file": "g2_L1_f0.wai", + "at_ms": 485, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f1.wai", + "at_ms": 490, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f2.wai", + "at_ms": 495, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f3.wai", + "at_ms": 500, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f1.wai", + "at_ms": 505, + "evidence": "delivered-verified" + }, + { + "op": "expire", + "at_ms": 1506 + } + ], + "reports": [ + [], + [ + { + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [ + [ + 1, + "deadline-missed" + ] + ] + } + ], + [] + ], + "bases": [ + { + "units": [ + { + "stage": 1, + "producer": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "2d4cb67733e7f208144435f383167dd3123f9f9ef9585667b15e340f5bea3a0f" + }, + { + "stage": 1, + "producer": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "642865860d28f530578c634ec2a3aff5aa5bd817709d603fb6145a341476e1cd" + }, + { + "stage": 1, + "producer": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "b867545e63757301f4f26b0731a7c75650b062159d2eb5ca4a90a46de6b90e94" + }, + { + "stage": 1, + "producer": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "03ae8c1f453886e10d06713b9deb8aaab5d89e95ce47a1393715f0b50e1abed4" + } + ], + "clip_sha256": "63ec8c064fcb36c3ab6e73854da46eace11eabf1aa010c0990b36d911ee408a4", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4e3d200250bd9d3dced1df0030d3991d1be423d290ec07590737f88b18b03d94" + }, + { + "stage": 0, + "producer": "6d14b8f14808f3ba5a92d5141bcb380ccc097288231f9d91cf36ce199cbf13f4", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4b22b0de48130b32cc4be939932bdb27bea1f9d57bb8622f0e60fae14c230394" + }, + { + "stage": 1, + "producer": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "c0d188e7ffa37de21e013ae59e3a1555d83431b0a380165096c9ab12501e4d99" + }, + { + "stage": 1, + "producer": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "9d3b0caf0f7ade60997a6a087a51b12caaadb22981f4f03e99b43cecb56c3cc7" + } + ], + "clip_sha256": "4b1d65b0abbe3c006a0da06480aa094ef9100f488e2f3989bb0a031e32e20e1c", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + }, + { + "stage": 1, + "producer": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + } + ], + "clip_sha256": "1e2b9e16cffc20fb15d943d84d1efb86cbaa76a7b9f81e4425b7f41a7d2c6029", + "n_units": 4 + } + ], + "presented": [ + 1, + 1, + 1, + 1, + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "deadline_misses": 1, + "statuses": [ + { + "track": "cam1/L1", + "group": 0, + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 1, + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 2, + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 3, + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 4, + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 5, + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "status": "late", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 6, + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 7, + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 8, + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 9, + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 10, + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 11, + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "status": "applied", + "evidence": "delivered-verified" + } + ] + }, + { + "name": "lossy", + "objects": [ + { + "track": "cam1/base", + "group": 0, + "fate": "forwarded", + "arrival_ms": 150, + "receipt_ms": 150, + "fetched_ms": null, + "verified_ms": 150, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 0, + "fate": "forwarded", + "arrival_ms": 165, + "receipt_ms": 160, + "fetched_ms": null, + "verified_ms": 160, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 1, + "fate": "forwarded", + "arrival_ms": 170, + "receipt_ms": 165, + "fetched_ms": null, + "verified_ms": 165, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 2, + "fate": "forwarded", + "arrival_ms": 175, + "receipt_ms": 170, + "fetched_ms": null, + "verified_ms": 170, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 3, + "fate": "forwarded", + "arrival_ms": 180, + "receipt_ms": 175, + "fetched_ms": null, + "verified_ms": 175, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 1, + "fate": "forwarded", + "arrival_ms": 310, + "receipt_ms": 310, + "fetched_ms": null, + "verified_ms": 310, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 4, + "fate": "forwarded", + "arrival_ms": 325, + "receipt_ms": 320, + "fetched_ms": null, + "verified_ms": 320, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 5, + "fate": "forwarded", + "arrival_ms": 505, + "receipt_ms": 465, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 6, + "fate": "forwarded", + "arrival_ms": 335, + "receipt_ms": null, + "fetched_ms": 525, + "verified_ms": 525, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 7, + "fate": "forwarded", + "arrival_ms": 340, + "receipt_ms": 335, + "fetched_ms": 525, + "verified_ms": 525, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 2, + "fate": "forwarded", + "arrival_ms": 470, + "receipt_ms": 470, + "fetched_ms": null, + "verified_ms": 470, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 8, + "fate": "forwarded", + "arrival_ms": 485, + "receipt_ms": 480, + "fetched_ms": 525, + "verified_ms": 525, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 9, + "fate": "forwarded", + "arrival_ms": 490, + "receipt_ms": 485, + "fetched_ms": null, + "verified_ms": 485, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 10, + "fate": "forwarded", + "arrival_ms": 495, + "receipt_ms": 490, + "fetched_ms": null, + "verified_ms": 490, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 11, + "fate": "forwarded", + "arrival_ms": 500, + "receipt_ms": 495, + "fetched_ms": null, + "verified_ms": 495, + "refused_ms": null + } + ], + "steps": [ + { + "op": "base", + "index": 0, + "at_ms": 150 + }, + { + "op": "offer", + "file": "g0_L1_f0.wai", + "at_ms": 165, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f1.wai", + "at_ms": 170, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f2.wai", + "at_ms": 175, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g0_L1_f3.wai", + "at_ms": 180, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 1, + "at_ms": 310 + }, + { + "op": "offer", + "file": "g1_L1_f0.wai", + "at_ms": 325, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 2, + "at_ms": 470 + }, + { + "op": "offer", + "file": "g2_L1_f1.wai", + "at_ms": 490, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f2.wai", + "at_ms": 495, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f3.wai", + "at_ms": 500, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f1.wai", + "at_ms": 505, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f2.wai", + "at_ms": 525, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f3.wai", + "at_ms": 525, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f0.wai", + "at_ms": 525, + "evidence": "delivered-verified" + }, + { + "op": "expire", + "at_ms": 1526 + } + ], + "reports": [ + [], + [ + { + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [ + [ + 1, + "deadline-missed" + ] + ] + } + ], + [ + { + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [ + [ + 2, + "deadline-missed" + ] + ] + } + ], + [ + { + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [] + ], + "bases": [ + { + "units": [ + { + "stage": 1, + "producer": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "2d4cb67733e7f208144435f383167dd3123f9f9ef9585667b15e340f5bea3a0f" + }, + { + "stage": 1, + "producer": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "642865860d28f530578c634ec2a3aff5aa5bd817709d603fb6145a341476e1cd" + }, + { + "stage": 1, + "producer": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "b867545e63757301f4f26b0731a7c75650b062159d2eb5ca4a90a46de6b90e94" + }, + { + "stage": 1, + "producer": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "03ae8c1f453886e10d06713b9deb8aaab5d89e95ce47a1393715f0b50e1abed4" + } + ], + "clip_sha256": "63ec8c064fcb36c3ab6e73854da46eace11eabf1aa010c0990b36d911ee408a4", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4e3d200250bd9d3dced1df0030d3991d1be423d290ec07590737f88b18b03d94" + }, + { + "stage": 0, + "producer": "6d14b8f14808f3ba5a92d5141bcb380ccc097288231f9d91cf36ce199cbf13f4", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4b22b0de48130b32cc4be939932bdb27bea1f9d57bb8622f0e60fae14c230394" + }, + { + "stage": 0, + "producer": "6d14b8f14808f3ba5a92d5141bcb380ccc097288231f9d91cf36ce199cbf13f4", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "b08998fdd2aac149d3264b23f24f6ca1b0bb19f8f49dd109381f68064a954f23" + }, + { + "stage": 1, + "producer": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "9d3b0caf0f7ade60997a6a087a51b12caaadb22981f4f03e99b43cecb56c3cc7" + } + ], + "clip_sha256": "3583a597f95ef4c2646533460d1baab88def4fd44ea31ae236d8d6b4f619b570", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + }, + { + "stage": 1, + "producer": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + } + ], + "clip_sha256": "1e2b9e16cffc20fb15d943d84d1efb86cbaa76a7b9f81e4425b7f41a7d2c6029", + "n_units": 4 + } + ], + "presented": [ + 1, + 1, + 1, + 1, + 1, + 0, + 0, + 1, + 1, + 1, + 1, + 1 + ], + "deadline_misses": 2, + "statuses": [ + { + "track": "cam1/L1", + "group": 0, + "object": "7b5438a83e6f65c47dfc76670ff630fab31c676c621c9bf7ecccd7b2e0b8f544", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 1, + "object": "0fb395d52e18647ad234e753545c26683479dbb8dfd4cb78af3b9e91fb21cfd3", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 2, + "object": "a25ae4bf15ec37c711dbeefe0d16720c0ef373e523bd358510634b9cff48b7d6", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 3, + "object": "1ad14f1354c4b9ace3440f9e069053298a9b69c6808be3bc2c87259d55a2013a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 4, + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 5, + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "status": "late", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 6, + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "status": "late", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 7, + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 8, + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 9, + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 10, + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 11, + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "status": "applied", + "evidence": "delivered-verified" + } + ] + }, + { + "name": "joiner", + "objects": [ + { + "track": "cam1/base", + "group": 1, + "fate": "forwarded", + "arrival_ms": 310, + "receipt_ms": 310, + "fetched_ms": null, + "verified_ms": 310, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 4, + "fate": "forwarded", + "arrival_ms": 325, + "receipt_ms": 320, + "fetched_ms": null, + "verified_ms": 320, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 5, + "fate": "forwarded", + "arrival_ms": 505, + "receipt_ms": 465, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 6, + "fate": "forwarded", + "arrival_ms": 335, + "receipt_ms": 330, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 7, + "fate": "forwarded", + "arrival_ms": 340, + "receipt_ms": 335, + "fetched_ms": null, + "verified_ms": 465, + "refused_ms": null + }, + { + "track": "cam1/L2", + "group": 1, + "fate": "dropped", + "arrival_ms": null, + "receipt_ms": 350, + "fetched_ms": null, + "verified_ms": 350, + "refused_ms": null + }, + { + "track": "cam1/base", + "group": 2, + "fate": "forwarded", + "arrival_ms": 470, + "receipt_ms": 470, + "fetched_ms": null, + "verified_ms": 470, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 8, + "fate": "forwarded", + "arrival_ms": 485, + "receipt_ms": 480, + "fetched_ms": null, + "verified_ms": 480, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 9, + "fate": "forwarded", + "arrival_ms": 490, + "receipt_ms": 485, + "fetched_ms": null, + "verified_ms": 485, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 10, + "fate": "forwarded", + "arrival_ms": 495, + "receipt_ms": 490, + "fetched_ms": null, + "verified_ms": 490, + "refused_ms": null + }, + { + "track": "cam1/L1", + "group": 11, + "fate": "forwarded", + "arrival_ms": 500, + "receipt_ms": 495, + "fetched_ms": null, + "verified_ms": 495, + "refused_ms": null + }, + { + "track": "cam1/L2", + "group": 2, + "fate": "dropped", + "arrival_ms": null, + "receipt_ms": 510, + "fetched_ms": null, + "verified_ms": 510, + "refused_ms": null + } + ], + "steps": [ + { + "op": "base", + "index": 1, + "at_ms": 310 + }, + { + "op": "offer", + "file": "g1_L1_f0.wai", + "at_ms": 325, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f2.wai", + "at_ms": 465, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f3.wai", + "at_ms": 465, + "evidence": "delivered-verified" + }, + { + "op": "base", + "index": 2, + "at_ms": 470 + }, + { + "op": "offer", + "file": "g2_L1_f0.wai", + "at_ms": 485, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f1.wai", + "at_ms": 490, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f2.wai", + "at_ms": 495, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g2_L1_f3.wai", + "at_ms": 500, + "evidence": "delivered-verified" + }, + { + "op": "offer", + "file": "g1_L1_f1.wai", + "at_ms": 505, + "evidence": "delivered-verified" + }, + { + "op": "expire", + "at_ms": 1506 + } + ], + "reports": [ + [], + [ + { + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [], + [ + { + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 0 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 1 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 2 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [ + 3 + ], + "held": [], + "refused": [] + } + ], + [ + { + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "envelope": null, + "detail": null, + "held_for_base": false, + "applied": [], + "held": [], + "refused": [ + [ + 1, + "deadline-missed" + ] + ] + } + ], + [] + ], + "bases": [ + null, + { + "units": [ + { + "stage": 1, + "producer": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4e3d200250bd9d3dced1df0030d3991d1be423d290ec07590737f88b18b03d94" + }, + { + "stage": 0, + "producer": "6d14b8f14808f3ba5a92d5141bcb380ccc097288231f9d91cf36ce199cbf13f4", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "4b22b0de48130b32cc4be939932bdb27bea1f9d57bb8622f0e60fae14c230394" + }, + { + "stage": 1, + "producer": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "c0d188e7ffa37de21e013ae59e3a1555d83431b0a380165096c9ab12501e4d99" + }, + { + "stage": 1, + "producer": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "9d3b0caf0f7ade60997a6a087a51b12caaadb22981f4f03e99b43cecb56c3cc7" + } + ], + "clip_sha256": "4b1d65b0abbe3c006a0da06480aa094ef9100f488e2f3989bb0a031e32e20e1c", + "n_units": 4 + }, + { + "units": [ + { + "stage": 1, + "producer": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + }, + { + "stage": 1, + "producer": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "1dc54782c519f4ef855fd29f778954236a2553b5bd54acd7aaa730b3f455edbd" + }, + { + "stage": 1, + "producer": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "frozen": false, + "tier": "decode-equivalence", + "shape": [ + 16, + 16, + 3 + ], + "stage_sha256": "92a6877072c68d225e560836dd8a5dd1f968ef4993cb609afcfac98bad5966e1" + } + ], + "clip_sha256": "1e2b9e16cffc20fb15d943d84d1efb86cbaa76a7b9f81e4425b7f41a7d2c6029", + "n_units": 4 + } + ], + "presented": [ + 1, + 0, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "deadline_misses": 1, + "statuses": [ + { + "track": "cam1/L1", + "group": 4, + "object": "d7539bf3a008c747fbbad67acdb94f0afd9a266ba48c699f9e26e12c4a258d37", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 5, + "object": "5311340b1e01c9502d7ad5dcf1f721fae1c08f52806e32babbb6cd049c91cb04", + "status": "late", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 6, + "object": "3b6d310f6c9495503d60b3a65533c845e2c21ccfb961663e130e53a16f8c4968", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 7, + "object": "5a185bd4905e57390f8c52497bb8af932c3270b8e5d5faf8ce1b96aec355c817", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L2", + "group": 1, + "object": "9783e9954b7c8d9f3ec2a71906e8175a4b8c7f9cd2b513a356f448ede8a69c1b", + "status": "dropped", + "evidence": "inclusion-verified" + }, + { + "track": "cam1/L1", + "group": 8, + "object": "81754d656f7b5ed9c371a54619cb08fb1a27a70c571d6df7a004ae164b78534a", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 9, + "object": "0ff40ff3dc8db22c2dde2ee3c282adf16946661736784b8bc22399995cf4bf29", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 10, + "object": "a5efeec13377bf894a150325c794ae42901714bacb4618d3ab371f11df74525c", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L1", + "group": 11, + "object": "7a0bb314dfe1270abc6c290b1d699fd85aa1c5297360222b5cbcf8902d72949f", + "status": "applied", + "evidence": "delivered-verified" + }, + { + "track": "cam1/L2", + "group": 2, + "object": "211e0a912b9393caa2d957ccd3b9fad5fbf5514204e9b70a89caf248bf696d7c", + "status": "dropped", + "evidence": "inclusion-verified" + } + ] + } + ] + } +} diff --git a/wai/staged-conformance/rate_port.json b/wai/staged-conformance/rate_port.json new file mode 100644 index 000000000..b63514771 --- /dev/null +++ b/wai/staged-conformance/rate_port.json @@ -0,0 +1,800 @@ +{ + "format": "wai-staged-rate-port/1", + "about": "The rate predictor of wai.audio.int_refine (staged-delivery Appendix A.4), rdiv(a·num + (b − a)·ρ, num), beside the f64 linear interpolation the layered-PCM ladder idea describes: src = (i·den)/num, lo = ⌊src⌋, frac = src − lo, hi = min(lo + 1, n − 1), a + (b − a)·frac rounded half away from zero. For each fixture (interleaved i16 samples) and ratio (num the output rate, den the source rate): the number of output samples, how many have a dyadic phase (where src, frac and the product are exact in f64, and the two must agree), the SHA-256 of the integer predictions (i16 little-endian, interleaved), and every [sample, channel, integer, f64] where they differ.", + "fixtures": [ + { + "name": "sine_mono", + "channels": 1, + "samples": [ + 0, + 19696, + 31482, + 30566, + 17356, + -2894, + -21889, + -32157, + -29418, + -14887, + 5742, + 23922, + 32585, + 28047, + 12305, + -8531, + -25776, + -32761, + -26465, + -9626, + 11244, + 27439, + 32686, + 24686, + 6866, + -13867, + -28896, + -32358, + -22723, + -4039, + 16383, + 30135, + 31781, + 20592, + 1162, + -18776, + -31144, + -30961, + -18308, + 1741, + 21030, + 31916, + 29905, + 15889, + -4609, + -23129, + -32444, + -28621 + ] + }, + { + "name": "sine_stereo", + "channels": 2, + "samples": [ + 0, + 16383, + 15889, + 9848, + 27747, + -4540, + 32721, + -15283, + 29418, + -13873, + 18776, + -1447, + 3468, + 12153, + -12830, + 16078, + -25776, + 7189, + -32358, + -7443, + -30769, + -16131, + -21463, + -11961, + -6866, + 1734, + 9626, + 14023, + 23529, + 15177, + 31637, + 4265, + 31781, + -10073, + 23922, + -16380, + 10169, + -9619, + -6305, + 4813, + -21030, + 15384, + -30566, + 13719, + -32444, + 1159, + -26125, + -12343, + -13350, + -16021, + 2894, + -6933, + 18308, + 7695, + 29161, + 16179, + 32746, + 11764, + 28047, + -2019, + 16383, + -14169, + 581, + -15067, + -15390, + -3989, + -27439, + 10296, + -32686, + 16373, + -29666, + 9388, + -19239, + -5084, + -4039, + -15480, + 12305, + -13561, + 25420, + -870, + 32262, + 12528, + 30961, + 15958, + 21889, + 6675, + 7423, + -7944, + -9080, + -16222, + -23129, + -11564, + -31482, + 2304, + -31916, + 14310 + ] + }, + { + "name": "triangle_stereo", + "channels": 2, + "samples": [ + -11500, + -11500, + -7571, + -8309, + -3642, + -5119, + 10286, + 10072, + -1785, + -1368, + -1858, + -1334, + 1738, + -1302, + 5666, + 836, + 8261, + 4026, + 8191, + 7217, + 5548, + 8829, + 4619, + 8862, + 4690, + 6209, + 4094, + 4071, + 166, + 4038, + -3761, + 4004, + -5357, + 3444, + -5285, + 255, + -5214, + -2935, + -7857, + -6126, + -7928, + -6159, + -6333, + -6193, + -2405, + -6226, + 1524, + -7010, + 2118, + -6976, + 2048, + -6943, + 2976, + -4805, + 6905, + -1615, + 10833, + 1576, + 8237, + 3187, + 4309, + 3220, + 714, + 3254, + 785, + 4340, + 858, + 7531, + -1071, + 9646, + -5000, + 9086, + -8928, + 5895, + -9190, + 2705, + -6548, + -486, + -3952, + -519, + -4023, + -551, + -4094, + -584, + -1166, + -3248, + 2761, + -6438, + 6023, + -9629, + 5951, + -10447, + 5881, + -7257, + 6857, + -4066 + ] + }, + { + "name": "extremes_mono", + "channels": 1, + "samples": [ + 32767, + 3, + 0, + -32768, + 1, + -2, + -32767, + -32768, + -3, + -1, + 32766, + 32767, + 32767, + 3, + 0, + -32768, + 1, + -2, + -32767, + -32768, + -3, + -1, + 32766, + 32767, + 32767, + 3, + 0, + -32768, + 1, + -2, + -32767, + -32768, + -3, + -1, + 32766, + 32767, + 32767, + 3, + 0, + -32768, + 1, + -2, + -32767, + -32768, + -3, + -1, + 32766, + 32767 + ] + } + ], + "cases": [ + { + "fixture": "sine_mono", + "num": 16000, + "den": 8000, + "n_out": 96, + "dyadic_samples": 96, + "predicted_sha256": "22c6f91b0a1722342a6f9c6b64dccb9d02241d06bef9709a74476e2be956a321", + "differs": [] + }, + { + "fixture": "sine_mono", + "num": 24000, + "den": 8000, + "n_out": 144, + "dyadic_samples": 48, + "predicted_sha256": "98beef47e9cfa2deb2cfef063b53481aaec4784fc735cf39cf3cdc41272c1d1b", + "differs": [] + }, + { + "fixture": "sine_mono", + "num": 48000, + "den": 8000, + "n_out": 288, + "dyadic_samples": 96, + "predicted_sha256": "665020149ef3ecbf673a25feb9a16232423842ad7064f48c00e369da137e111f", + "differs": [ + [ + 109, + 0, + -23659, + -23658 + ], + [ + 131, + 0, + 31812, + 31811 + ], + [ + 145, + 0, + 3411, + 3410 + ], + [ + 149, + 0, + -10412, + -10411 + ], + [ + 233, + 0, + -1601, + -1600 + ], + [ + 271, + 0, + -24682, + -24681 + ] + ] + }, + { + "fixture": "sine_mono", + "num": 48000, + "den": 32000, + "n_out": 72, + "dyadic_samples": 24, + "predicted_sha256": "2424e1fdf8bbedd8b8e2dc4d2903c407b09cdebdefe01f6ec3924ea95808111b", + "differs": [] + }, + { + "fixture": "sine_mono", + "num": 48000, + "den": 44100, + "n_out": 52, + "dyadic_samples": 11, + "predicted_sha256": "96e739e8669f3b42ba08c57804aa535295c6686472f6ab75191dba50cd48c746", + "differs": [] + }, + { + "fixture": "sine_mono", + "num": 44100, + "den": 48000, + "n_out": 44, + "dyadic_samples": 1, + "predicted_sha256": "305a21de8c1152dce875f76a73ce2e446934f31a67b5e021ac8fe8301789c46b", + "differs": [] + }, + { + "fixture": "sine_mono", + "num": 8000, + "den": 16000, + "n_out": 24, + "dyadic_samples": 24, + "predicted_sha256": "3ddeaca4d426648fa0d74b33d1304dedb54e6ee88f8f751460a395758206f75c", + "differs": [] + }, + { + "fixture": "sine_stereo", + "num": 16000, + "den": 8000, + "n_out": 96, + "dyadic_samples": 96, + "predicted_sha256": "0e45aad7bee4a6b030431529335ed370bbc63672e9e59f10b861842fddadb112", + "differs": [] + }, + { + "fixture": "sine_stereo", + "num": 24000, + "den": 8000, + "n_out": 144, + "dyadic_samples": 48, + "predicted_sha256": "307318f4d08c7b4e38583273ca8403445a3a21e4543313c02c5c168a286ece21", + "differs": [] + }, + { + "fixture": "sine_stereo", + "num": 48000, + "den": 8000, + "n_out": 288, + "dyadic_samples": 96, + "predicted_sha256": "5f1340d3d9881efb5928cd1fac1d80889d5606902f07ecedcabebbe1b31fbd99", + "differs": [ + [ + 13, + 1, + -6331, + -6330 + ], + [ + 43, + 1, + 14597, + 14596 + ], + [ + 71, + 1, + -549, + -548 + ], + [ + 121, + 1, + 15107, + 15106 + ], + [ + 167, + 0, + 32149, + 32148 + ], + [ + 199, + 0, + -28314, + -28313 + ], + [ + 265, + 0, + -11422, + -11421 + ] + ] + }, + { + "fixture": "sine_stereo", + "num": 48000, + "den": 32000, + "n_out": 72, + "dyadic_samples": 24, + "predicted_sha256": "75fe33b5bc5c6e5ee15559efd71c2dbe097f984a47d1cafa7a3a53d9fb1670d0", + "differs": [] + }, + { + "fixture": "sine_stereo", + "num": 48000, + "den": 44100, + "n_out": 52, + "dyadic_samples": 11, + "predicted_sha256": "f75ad5832e0deb8a559bac5e723d2417c77993ea946793619879e69378ff4e2b", + "differs": [ + [ + 23, + 1, + 12071, + 12070 + ] + ] + }, + { + "fixture": "sine_stereo", + "num": 44100, + "den": 48000, + "n_out": 44, + "dyadic_samples": 1, + "predicted_sha256": "450f15974eb01de40a9c6984d8eb11c5aea40c20b035c5d9c64fd91d9771b12a", + "differs": [] + }, + { + "fixture": "sine_stereo", + "num": 8000, + "den": 16000, + "n_out": 24, + "dyadic_samples": 24, + "predicted_sha256": "c28d29fd941a7baf424645d0119202f0198d24bc380552d2fbe5f87bf0bb2a66", + "differs": [] + }, + { + "fixture": "triangle_stereo", + "num": 16000, + "den": 8000, + "n_out": 96, + "dyadic_samples": 96, + "predicted_sha256": "7192b8cc52cc8d947d4cfe4683fc543490f8b952da86dd624d96ada5d7d504bd", + "differs": [] + }, + { + "fixture": "triangle_stereo", + "num": 24000, + "den": 8000, + "n_out": 144, + "dyadic_samples": 48, + "predicted_sha256": "6c9c2b970bf09b88dca94cbec52f2bce594716bb0627cc4f0db2600ffcedcfed", + "differs": [] + }, + { + "fixture": "triangle_stereo", + "num": 48000, + "den": 8000, + "n_out": 288, + "dyadic_samples": 96, + "predicted_sha256": "e4b742a4754b18d49be09c4e3ad8bdbd7b87e17dd98e92b0196eb76a42477ae0", + "differs": [ + [ + 47, + 0, + 7829, + 7828 + ], + [ + 59, + 0, + 5989, + 5988 + ], + [ + 85, + 0, + -489, + -488 + ], + [ + 97, + 1, + 2913, + 2912 + ], + [ + 113, + 0, + -7417, + -7416 + ], + [ + 173, + 1, + 2919, + 2918 + ], + [ + 199, + 1, + 7884, + 7883 + ], + [ + 229, + 1, + -492, + -491 + ], + [ + 241, + 1, + -557, + -556 + ] + ] + }, + { + "fixture": "triangle_stereo", + "num": 48000, + "den": 32000, + "n_out": 72, + "dyadic_samples": 24, + "predicted_sha256": "b2831523ce3ddd53efb0872ffa7f68eb95864ed3343c06ce1627d779ebfc586d", + "differs": [] + }, + { + "fixture": "triangle_stereo", + "num": 48000, + "den": 44100, + "n_out": 52, + "dyadic_samples": 11, + "predicted_sha256": "2a4ce7a0dde98f317bccc2ec9bfe62a42ffcc72a664bf0e6d70a6e479c6a87f7", + "differs": [ + [ + 8, + 1, + 1953, + 1952 + ] + ] + }, + { + "fixture": "triangle_stereo", + "num": 44100, + "den": 48000, + "n_out": 44, + "dyadic_samples": 1, + "predicted_sha256": "e028affce449894ef0c1b4a7416e65e393c35beda00db06bdb94a51447cbb22b", + "differs": [] + }, + { + "fixture": "triangle_stereo", + "num": 8000, + "den": 16000, + "n_out": 24, + "dyadic_samples": 24, + "predicted_sha256": "340e6c64d6ea7a81007af76f860121eeb340cfea0a40b467e38435cca15aabc5", + "differs": [] + }, + { + "fixture": "extremes_mono", + "num": 16000, + "den": 8000, + "n_out": 96, + "dyadic_samples": 96, + "predicted_sha256": "afc3e189ff57826f98b7a08db32d44fb661c4ac7b2e6a31c28a6d4e89c0c27a9", + "differs": [] + }, + { + "fixture": "extremes_mono", + "num": 24000, + "den": 8000, + "n_out": 144, + "dyadic_samples": 48, + "predicted_sha256": "476f697739d25ae9c7df4589f32315a21459cb5b0ea58469d24102070ec6f2f0", + "differs": [] + }, + { + "fixture": "extremes_mono", + "num": 48000, + "den": 8000, + "n_out": 288, + "dyadic_samples": 96, + "predicted_sha256": "a93c8973683c80dbeb421ab197eb07385e8acdc58adfe0b9d65b66bea8245ec5", + "differs": [ + [ + 23, + 0, + -5461, + -5460 + ], + [ + 25, + 0, + 1, + 0 + ], + [ + 29, + 0, + -2, + -1 + ], + [ + 83, + 0, + 1, + 0 + ], + [ + 95, + 0, + -5461, + -5460 + ], + [ + 97, + 0, + 1, + 0 + ], + [ + 101, + 0, + -2, + -1 + ], + [ + 151, + 0, + 3, + 2 + ], + [ + 163, + 0, + -27307, + -27306 + ], + [ + 169, + 0, + 1, + 0 + ], + [ + 173, + 0, + -2, + -1 + ], + [ + 227, + 0, + 1, + 0 + ], + [ + 239, + 0, + -5461, + -5460 + ] + ] + }, + { + "fixture": "extremes_mono", + "num": 48000, + "den": 32000, + "n_out": 72, + "dyadic_samples": 24, + "predicted_sha256": "a6c7ba77c69067b22b2dc25e305a1ab8fb5b34db09ea4482b3652310847723bb", + "differs": [] + }, + { + "fixture": "extremes_mono", + "num": 48000, + "den": 44100, + "n_out": 52, + "dyadic_samples": 11, + "predicted_sha256": "e32ba4be86316e582d45334a30e4b9dc4a0a06741b502c30cdb2574045e2dffe", + "differs": [] + }, + { + "fixture": "extremes_mono", + "num": 44100, + "den": 48000, + "n_out": 44, + "dyadic_samples": 1, + "predicted_sha256": "fd92822988f2492030790004e2198ce0070c41d2142779236d09a32c2253b14e", + "differs": [] + }, + { + "fixture": "extremes_mono", + "num": 8000, + "den": 16000, + "n_out": 24, + "dyadic_samples": 24, + "predicted_sha256": "bc745bb6a2cec6673ce2406bc6daeda5e977399030d261075174e7a443b3b0c0", + "differs": [] + } + ] +} diff --git a/wai/staged-conformance/staged_verify.py b/wai/staged-conformance/staged_verify.py index f2c0efa8f..baf8c312b 100644 --- a/wai/staged-conformance/staged_verify.py +++ b/wai/staged-conformance/staged_verify.py @@ -24,7 +24,12 @@ specifications, and implements the rest: subframes, with both CRCs); - WAI2 refinement components (SPEC §7.1): the role checks, envelope manifests, each entry's exploded envelope, and the envelopes a packer must refuse; -- the live schedule's presentation rule (schedule.json). +- the live schedule's presentation rule (schedule.json); +- the rate predictor beside the f64 interpolation it replaces (rate_port.json); +- MoQ carriage (moq-streaming-format §4.1): the catalog check, a layer object's timeout + as its slack, the priorities, and the trace of moq/: each object's fate at a relay by + MOQT's per-hop delivery timeout, each subscriber's sink steps, replayed through this + sink, and the status each layer object comes to. For every case it starts from the case's stage0.bin, checks its SHA-256, decodes the base independently where it can (WIV1 keyframe 0, PNG, FLAC) and compares, derives the base's @@ -34,9 +39,10 @@ stage, producer, frozen flag, tier, shape and stage hash to be the case's. python3 staged_verify.py -Exit status 0 only if every case, every WAI2 file and the schedule verify. +Exit status 0 only if every case, every WAI2 file, the schedule and moq/ verify. """ import hashlib +import heapq import json import math import os @@ -1634,6 +1640,507 @@ def verify_case(cdir, fails): fail(f"base {bi}: the clip hash differs") +# ---------------------------------------------------------------- MoQ (moq-streaming-format §4.1) + +SET_REASONS = ["staged-malformed", "staged-set-incomplete", "staged-depends", "staged-grouping", "staged-lead", + "staged-receipts"] +MAX_CATALOG_INT = (1 << 53) - 1 +U64_LIMIT = 1 << 64 + + +def u64_int(v): + """A JSON integer that fits u64: not a boolean, not a float such as 1.0.""" + return is_int(v) and 0 <= v < U64_LIMIT + + +def staged_member(v): + """`wai_staged` read: (set, layer, lead_ms), or None where it is not the three + members of their types and in their ranges (a member it does not define, an earlier + `layers` among them, is ignored).""" + if not isinstance(v, dict) or not all(k in v for k in ("set", "layer", "lead_ms")): + return None + st, la, lead = v["set"], v["layer"], v["lead_ms"] + if not isinstance(st, str) or st == "": + return None + if not (is_int(la) and 0 <= la <= 255): + return None + if lead is not None and not (is_int(lead) and 0 <= lead <= MAX_CATALOG_INT): + return None + return st, la, lead + + +def identity(t, inherited): + """A track's (namespace, name): its own namespace, or the catalog track's it inherits + (None where unknown). None where its name or namespace is not a string.""" + name = t.get("name") + if not isinstance(name, str): + return None + if "namespace" not in t: + return (inherited, name) + ns = t["namespace"] + return (ns, name) if isinstance(ns, str) else None + + +def depends_of(t): + """`depends` as a list of strings ([] when absent), or None for anything else.""" + if "depends" not in t: + return [] + d = t["depends"] + if not isinstance(d, list) or not all(isinstance(x, str) for x in d): + return None + return d + + +def alias_of(t): + """`wai_receipt_alias`: absent (\"absent\"), an integer in range, or None (malformed).""" + if "wai_receipt_alias" not in t: + return "absent" + a = t["wai_receipt_alias"] + return a if is_int(a) and 0 <= a <= MAX_CATALOG_INT else None + + +def check_set(name, es, cat): + """§4.1's checks of one set's entries, in order: the first reason, or None.""" + tracks, inherited, seen = cat + # 1. Types and identities. With the catalog track's namespace unknown, an inherited + # namespace cannot be compared with a declared one: a set that mixes them is refused. + for e in es: + ident = identity(e, inherited) + if (name == "" or staged_member(e["wai_staged"]) is None or e.get("packaging") != "wai" + or not isinstance(e.get("isLive"), bool) or ident is None or seen.get(ident, 0) != 1 + or ("renderGroup" in e and not u64_int(e["renderGroup"])) or depends_of(e) is None): + return "staged-malformed" + if inherited is None and any("namespace" in e for e in es) and any("namespace" not in e for e in es): + return "staged-malformed" + m = [staged_member(e["wai_staged"]) for e in es] + ids = [identity(e, inherited) for e in es] + # 2. A track at each layer from 0 to the highest present. + layers = {x[1] for x in m} + if any(k not in layers for k in range(max(layers) + 1)): + return "staged-set-incomplete" + # 3. depends. + set_layer = {i: x[1] for i, x in zip(ids, m)} + for e, x, i in zip(es, m, ids): + named = {(i[0], d) for d in depends_of(e)} + if any(n not in seen for n in named): + return "staged-depends" + reached = {set_layer[n] for n in named if n in set_layer} + if any(j not in reached for j in range(x[1])) or any(j >= x[1] for j in reached): + return "staged-depends" + # 4. Grouping. + rg = [e.get("renderGroup") for e in es] + if ("renderGroup" not in es[0] or any(i[0] != ids[0][0] for i in ids) or any(e["isLive"] != es[0]["isLive"] for e in es) + or any("renderGroup" not in e or e["renderGroup"] != rg[0] for e in es)): + return "staged-grouping" + if any(x[1] > 0 and any(k in e for k in ("altGroup", "temporalId", "spatialId")) for x, e in zip(m, es)): + return "staged-grouping" + # 5. Lead, read only on a live set. + if es[0]["isLive"] and any((x[2] is not None) != (x[1] > 0) for x in m): + return "staged-lead" + # 6. Receipts: on each track of the set that declares them, an alias no other track of + # the catalog carries and a receipts track no other track names. A rendition of a layer + # is a track of its own, with its own alias and receipts track. + if any(alias_of(t) is None for t in tracks): + return "staged-receipts" + aliases, named = {}, {} + for t in tracks: + a = alias_of(t) + if a != "absent": + aliases[a] = aliases.get(a, 0) + 1 + r, tid = t.get("wai_receipts_track"), identity(t, inherited) + if isinstance(r, str) and tid is not None: + named[(tid[0], r)] = named.get((tid[0], r), 0) + 1 + for e, i in zip(es, ids): + if e.get("wai_receipts") is not True: + continue + a, r = alias_of(e), e.get("wai_receipts_track") + if a == "absent" or not isinstance(r, str): + return "staged-receipts" + rid = (i[0], r) + if (aliases.get(a) != 1 or named.get(rid) != 1 + or not any(identity(t, inherited) == rid and t.get("packaging") == "wai-receipts" for t in tracks)): + return "staged-receipts" + return None + + +def check_catalog(c, inherited=None): + """§4.1, Checking a catalog: every set, by name, with its first reason or None. A track + that declares no namespace inherits `inherited`, the catalog track's, which the check + needs: where it is None (unknown), a set that mixes declared and inherited namespaces + is refused. An entry whose set cannot be read is reported for the empty name.""" + tracks = c.get("tracks") if isinstance(c, dict) else None + tracks = [t for t in tracks if isinstance(t, dict)] if isinstance(tracks, list) else [] + seen = {} + for t in tracks: + i = identity(t, inherited) + if i is not None: + seen[i] = seen.get(i, 0) + 1 + sets = {} + for t in tracks: + if "wai_staged" in t: + w = t["wai_staged"] + name = w["set"] if isinstance(w, dict) and isinstance(w.get("set"), str) else "" + sets.setdefault(name, []).append(t) + cat = (tracks, inherited, seen) + return [{"set": n, "reason": check_set(n, sets[n], cat)} for n in sorted(sets)] + + +def presentation(clock, unit): + return min(clock["t0_ms"] + unit * clock["unit_ms"] + clock["target_latency_ms"], U64) + + +def slack(clock, last_unit, lead, publish, path): + """§4.1: the most a publisher sets as a layer object's timeout, or None for none.""" + s = presentation(clock, last_unit) - lead - path - publish + return s if s > 0 else None + + +def moq_settle(arrivals, groups_of, order_of, join, max_groups, max_total, fetch_ms): + """A subscriber's verifier over the receipts that reach it (jwp-receipts §3 step 3), + each track's a chain of its groups in order; `arrivals` are (time, order, track, + group), taken by time, then live before fetched, then order. A receipt whose parent is + the head links and releases the held receipts after it; a joiner anchors each chain at + its first receipt; any other live receipt is held, unless holding it would pass + max_groups for its chain or max_total across chains: then the chain's held receipts + are refused, the chain re-anchors at it, and the anchor leaves a gap of the groups it + skipped. Without fetch_ms those are refused then; with it, their receipts are fetched + and reach the subscriber fetch_ms later, ascending, and each that links forward from + the group below the gap or back from the group above is verified as history. Returns + ({(track, group): ("verified" | "refused", time)}, {(track, group): fetch time}); + raises ValueError outside what the trace models.""" + queue = [(t, 0, i, tr, g) for t, i, tr, g in arrivals] + heapq.heapify(queue) + chains, out, fetched, total = {}, {}, {}, 0 + while queue: + t, cls, _, tr, g = heapq.heappop(queue) + order = groups_of[tr] + pos = order.index(g) + parent = order[pos - 1] if pos > 0 else None + child = order[pos + 1] if pos + 1 < len(order) else None + st = chains.setdefault(tr, {"head": None, "held": set(), "gaps": []}) + if cls == 1: + fetched[(tr, g)] = t + at = next((k for k, (lo, hi) in enumerate(st["gaps"]) if (lo is None or lo < g) and g < hi), None) + if at is None: + raise ValueError(f"{tr} group {g}: fetched outside a gap") + lo, hi = st["gaps"][at] + forward = lo is not None and parent == lo + backward = child == hi + if not forward and not backward: + raise ValueError(f"{tr} group {g}: a fetched receipt that links to neither end of its gap") + out[(tr, g)] = ("verified", t) + if forward and backward: + del st["gaps"][at] + elif forward: + st["gaps"][at] = (g, hi) + else: + st["gaps"][at] = (lo, g) + continue + h = st["head"] + if h is None: + links = join or parent is None + elif g <= h: + raise ValueError(f"{tr} group {g}: a receipt behind its chain's head") + else: + links = parent == h + if not links: + if len(st["held"]) < max_groups and total < max_total: + st["held"].add(g) + total += 1 + continue + total -= len(st["held"]) + held, st["held"] = st["held"], set() + if h is not None: + st["gaps"].append((h, g)) + for x in order: + if h < x < g: + if fetch_ms is not None: + heapq.heappush(queue, (t + fetch_ms, 1, order_of[(tr, x)], tr, x)) + else: + out.setdefault((tr, x), ("refused", t)) + for x in held: + if fetch_ms is None or h is None: + out[(tr, x)] = ("refused", t) + if st["head"] is None and parent is not None: + st["gaps"].append((None, g)) + st["head"] = g + out[(tr, g)] = ("verified", t) + while True: + p = order.index(st["head"]) + nxt = order[p + 1] if p + 1 < len(order) else None + if nxt is None or nxt not in st["held"]: + break + st["held"].discard(nxt) + total -= 1 + st["head"] = nxt + out[(tr, nxt)] = ("verified", t) + for tr, st in chains.items(): + if st["held"]: + raise ValueError(f"{tr}: receipts still held at the end of the trace") + return out, fetched + + +def moq_expect(trace, bases, files): + """For each subscriber: each object's fate at the relay (MOQT §5.2, the timer from the + object's arrival there) and its receipt's (moq_settle, under the bound it states), the + steps its sink takes, the reports and final units, the stage presented at each clock + position, the deadline misses and each layer object's status and evidence; and the + relay's counts. A subscriber that joins receives, from its join on, the objects the + relay sends and the receipts that reach the relay.""" + layer_of = {t["name"]: t["layer"] for t in trace["tracks"]} + objs = trace["objects"] + first = {o["base"]: o["units"]["from"] for o in objs if layer_of[o["track"]] == 0} + if any(o["object_id"] != 0 for o in objs): + raise ValueError("a live staged group carries one object, object 0") + groups_of = {} + for o in objs: + groups_of.setdefault(o["track"], []).append(o["group"]) + for v in groups_of.values(): + v.sort() + dropped_total = fwd_receipts = 0 + subs = [] + for sub in trace["subscribers"]: + tracks, receipts = set(sub["tracks"]), sub["receipts"] is True + hold = sub["hold"] + for k in ("max_groups", "max_history", "max_total"): + if not is_int(hold.get(k)) or hold[k] < 0: + raise ValueError(f"{sub['name']}: hold.{k} is not a stated count") + joins = sub["joins_ms"] + lost = {(x["track"], x["group"]) for x in sub["lost_receipts"]} + mine = [] + for i, o in enumerate(objs): + if o["track"] not in tracks: + continue + at_relay = o["publish_ms"] + o["up_ms"] + sent = at_relay + o["queue_ms"] + if joins is not None and at_relay < joins: + if sent >= joins: + raise ValueError("the join falls between an object's receipt and the object") + continue + mine.append((i, o)) + arrivals = sorted((o["publish_ms"] + o["up_ms"] + o["down_ms"], i, o["track"], o["group"]) + for i, o in mine if receipts and (o["track"], o["group"]) not in lost) + order_of = {(o["track"], o["group"]): i for i, o in enumerate(objs)} + fetch_ms = sub["fetch_ms"] + if fetch_ms is not None and (not is_int(fetch_ms) or fetch_ms < 0): + raise ValueError(f"{sub['name']}: fetch_ms is not a time") + settled, fetched = moq_settle(arrivals, groups_of, order_of, joins is not None, hold["max_groups"], + hold["max_total"], fetch_ms) if receipts else ({}, {}) + fates, events, evidence_of = [], [], [] + for _, o in mine: + key = (o["track"], o["group"]) + received = o["publish_ms"] + o["up_ms"] + send = received + o["queue_ms"] + is_lost = key in lost + rcpt = received + o["down_ms"] if receipts and not is_lost else None + fetched_ms = fetched.get(key) + if receipts and key not in settled: + raise ValueError(f"{key}: a receipt never settled") + how, when = settled.get(key, (None, None)) + ver = when if how == "verified" else None + ref = when if how == "refused" else None + p, q = o["timeout_ms"], sub["timeout_ms"] + timeout = q if p == 0 else p if q == 0 else min(p, q) + if timeout != 0 and send - received > timeout: + dropped_total += 1 + fwd_receipts += 1 if receipts and not is_lost else 0 + fates.append({"track": o["track"], "group": o["group"], "fate": "dropped", "arrival_ms": None, + "receipt_ms": rcpt, "fetched_ms": fetched_ms, "verified_ms": ver, "refused_ms": ref}) + evidence_of.append("inclusion-verified" if ver is not None else "none") + continue + arrival = send + o["down_ms"] + fates.append({"track": o["track"], "group": o["group"], "fate": "forwarded", "arrival_ms": arrival, + "receipt_ms": rcpt, "fetched_ms": fetched_ms, "verified_ms": ver, "refused_ms": ref}) + if ver is not None: + at, evidence = max(arrival, ver), "delivered-verified" + elif ref is not None: + at, evidence = max(arrival, ref), "none" + else: + at, evidence = arrival, "none" + evidence_of.append(evidence) + if layer_of[o["track"]] == 0: + if receipts and ver is None: + raise ValueError(f"base {o['base']}: its receipt is refused, outside what the trace models") + st = {"op": "base", "index": o["base"], "at_ms": at} + else: + st = {"op": "offer", "file": o["file"], "at_ms": at, "evidence": evidence} + events.append((at, layer_of[o["track"]], o["group"], st)) + events.sort(key=lambda e: e[:3]) + last = events[-1][0] if events else 0 + steps = [e[3] for e in events] + [{"op": "expire", "at_ms": last + trace["sink"]["bound"]["max_age_ms"] + 1}] + rb = [dict(b, first_unit=first[i], attested=receipts) for i, b in enumerate(bases)] + got, finals = replay({"config": trace["sink"], "steps": steps}, rb, files) + presented = {} + for k, f in enumerate(finals): + for i, u in enumerate(f["units"] if f else []): + presented[first[k] + i] = u["stage"] + applied, refused, whole = {}, {}, set() + for g in got: + for r in (g if isinstance(g, list) else []): + if r["envelope"] is not None: + whole.add(r["object"]) + applied.setdefault(r["object"], set()).update(r["applied"]) + for unit, why in r["refused"]: + refused.setdefault(r["object"], {})[unit] = why + misses, statuses = set(), [] + for (_, o), fate, evidence in zip(mine, fates, evidence_of): + if layer_of[o["track"]] == 0: + continue + h = o["object"] + frm, cnt = o["units"]["from"] - first[o["base"]], o["units"]["count"] + a, why = applied.get(h, set()), refused.get(h, {}) + misses |= {(o["base"], unit) for unit, w in why.items() if w == "deadline-missed" and unit not in a} + if fate["fate"] == "dropped": + status = "dropped" + elif len(a) == cnt: + status = "applied" + elif a: + status = "partial" + elif h not in whole and all(why.get(x) == "deadline-missed" for x in range(frm, frm + cnt)): + status = "late" + else: + status = "refused" + statuses.append({"track": o["track"], "group": o["group"], "object": h, "status": status, + "evidence": evidence}) + subs.append({"name": sub["name"], "objects": fates, "steps": steps, "reports": got, "bases": finals, + "presented": [presented[k] for k in sorted(presented)], "deadline_misses": len(misses), + "statuses": statuses}) + return {"relay": {"dropped_on_timeout": dropped_total, "receipts_forwarded_for_dropped": fwd_receipts}, + "subscribers": subs} + + +def same_reports(a, b): + """Two runs' reports, the order within a step not part of the outcome.""" + if len(a) != len(b): + return False + for g, e in zip(a, b): + if isinstance(g, dict) or isinstance(e, dict): + if g != e: + return False + elif sorted(g, key=lambda r: r["object"]) != sorted(e, key=lambda r: r["object"]): + return False + return True + + +def verify_moq(d, fails): + """moq/: the catalog passes §4.1's check, each catalog case gives its reasons, and the + trace's timeouts, priorities and outcome follow from its inputs and its case.""" + def fail(m): + fails.append(f"moq: {m}") + print(f"FAIL moq: {m}") + md = os.path.join(d, "moq") + cat = json.load(open(os.path.join(md, "catalog.json"))) + if not check_catalog(cat) or any(x["reason"] is not None for x in check_catalog(cat)): + fail("catalog.json fails §4.1") + cases = json.load(open(os.path.join(md, "catalogs.json")))["cases"] + for c in cases: + got = check_catalog(c["catalog"], c["namespace"]) + if got != c["expect"]: + fail(f"catalogs.json {c['name']}: {got} != {c['expect']}") + t = json.load(open(os.path.join(md, "trace.json"))) + pr = [x["priority"] for x in t["tracks"]] + if not (all(pr[0] < x for x in pr[1:]) and all(a <= b for a, b in zip(pr[1:], pr[2:]))): + fail("the tracks' priorities break §4.1") + by_name = {e.get("name"): e for e in cat["tracks"]} + for x in t["tracks"]: + e = by_name.get(x["name"], {}) + if e.get("wai_receipts_track") != x["receipts_track"] or e.get("wai_receipt_alias") != x["receipt_alias"]: + fail(f"track {x['name']} is not the catalog's") + lead = {x["name"]: x["lead_ms"] for x in t["tracks"]} + for o in t["objects"]: + want = 0 if lead[o["track"]] is None else slack(t["clock"], o["units"]["from"] + o["units"]["count"] - 1, + lead[o["track"]], o["publish_ms"], t["path_estimate_ms"]) + if want != o["timeout_ms"]: + fail(f"object {o['track']} {o['group']}: its timeout is not its slack") + case, bases, files = load_case(os.path.join(d, "cases", t["case"])) + by_file = dict(files, **{b["spec"]["file"]: b["env"] for b in bases}) + for o in t["objects"]: + if content_hash(by_file[o["file"]]) != o["object"]: + fail(f"{o['file']} is not the object the trace names") + got = moq_expect(t, bases, files) + exp = t["expect"] + if got["relay"] != exp["relay"]: + fail(f"relay counts {got['relay']} != {exp['relay']}") + for g, e in zip(got["subscribers"], exp["subscribers"]): + for k in ("objects", "steps", "presented", "deadline_misses", "statuses"): + if g[k] != e[k]: + fail(f"subscriber {e['name']}: {k} {g[k]} != {e[k]}") + if not same_reports(g["reports"], e["reports"]): + fail(f"subscriber {e['name']}: the reports differ") + for gb, eb in zip(g["bases"], e["bases"]): + if (gb is None) != (eb is None) or (gb and (gb["units"] != eb["units"] or gb["clip_sha256"] != eb["clip_sha256"])): + fail(f"subscriber {e['name']}: the final units differ") + if len(got["subscribers"]) != len(exp["subscribers"]): + fail("a different number of subscribers") + return len(cases) + + +# ---------------------------------------------------------------- the rate predictor beside its f64 form + + +def round_half_away(x): + """x rounded to the nearest integer, halves away from zero, exactly (|x| < 2^52).""" + if x < 0: + return -round_half_away(-x) + f = math.floor(x) + return f + 1 if x - f >= 0.5 else f + + +def rate_f64(prev, c, num, den, i, ch): + """The f64 linear interpolation the layered-PCM ladder idea describes, written from + that description: src = (i·den)/num, lo = ⌊src⌋, frac = src − lo, + hi = min(lo + 1, n − 1), a + (b − a)·frac rounded half away from zero.""" + n = len(prev) // c + src = (i * den) / num + lo = math.floor(src) + frac = src - lo + hi = min(lo + 1, n - 1) + a, b = float(prev[lo * c + ch]), float(prev[hi * c + ch]) + return round_half_away(a + (b - a) * frac) + + +def rate_int(prev, c, num, den, i, ch): + """Appendix A.4's rate predictor.""" + n = len(prev) // c + s = i * den + lo, rho = s // num, s % num + hi = min(lo + 1, n - 1) + a, b = prev[lo * c + ch], prev[hi * c + ch] + return rdiv(a * num + (b - a) * rho, num) + + +def verify_rate_port(d, fails): + """rate_port.json: the integer predictions hash to the stated SHA-256, they equal the f64 + form wherever the phase is dyadic, and elsewhere differ exactly where it says.""" + r = json.load(open(os.path.join(d, "rate_port.json"))) + fx = {f["name"]: (f["channels"], f["samples"]) for f in r["fixtures"]} + for case in r["cases"]: + c, prev = fx[case["fixture"]] + num, den = case["num"], case["den"] + tag = f"rate_port {case['fixture']} {num}/{den}" + n_out = (len(prev) // c) * num // den + out, differs, dyadic = bytearray(), [], 0 + for i in range(n_out): + phase = (i * den) % num + q = num // math.gcd(phase, num) if phase else 1 + exact = q & (q - 1) == 0 + dyadic += 1 if exact else 0 + for ch in range(c): + p, f = rate_int(prev, c, num, den, i, ch), rate_f64(prev, c, num, den, i, ch) + out += struct.pack(" Vec { fn generate(ctx: &Ctx) -> BTreeMap> { let mut out = BTreeMap::new(); let mut index = Vec::new(); - for c in cases(ctx) { + let all = cases(ctx); + for c in &all { let dir = format!("cases/{}", c.name); let mut bases_json = Vec::new(); for (i, b) in c.bases.iter().enumerate() { @@ -1575,6 +1583,10 @@ fn generate(ctx: &Ctx) -> BTreeMap> { })), ); out.insert("schedule.json".into(), json_bytes(&schedule_json())); + for (rel, bytes) in moq_files(&all) { + out.insert(rel, bytes); + } + out.insert("rate_port.json".into(), json_bytes(&rate_port_json())); out.insert( "index.json".into(), json_bytes(&json!({ @@ -1611,6 +1623,961 @@ fn schedule_json() -> Value { json!({"clock": {"t0_ms": c.t0_ms, "unit_ms": c.unit_ms, "target_latency_ms": c.target_latency_ms}, "traces": out}) } +// ───────────────────────── the rate predictor beside its f64 form ───────────────────────── + +/// A sine in integers, ×32767, by Bhaskara I's rational approximation in degrees: +/// `sin d° ≈ 4d(180 − d) / (40500 − d(180 − d))` on each half-turn. +fn isine(deg: i64) -> i64 { + let d = deg.rem_euclid(360); + let (d, sign) = if d < 180 { (d, 1) } else { (d - 180, -1) }; + sign * 32767 * 4 * d * (180 - d) / (40500 - d * (180 - d)) +} + +/// The fixtures the rate predictor is compared on: name, channels, samples (interleaved). +fn rate_fixtures() -> Vec<(&'static str, u32, Vec)> { + let n = 48i64; + let sine_mono: Vec = (0..n).map(|i| isine(37 * i) as i16).collect(); + let sine_stereo: Vec = (0..n).flat_map(|i| [isine(29 * i) as i16, (isine(53 * i + 90) / 2) as i16]).collect(); + // Full-scale steps of every parity, so that halves fall on both signs. + let pattern: [i16; 12] = [32767, -32768, 0, -1, 1, 32767, -32767, 3, -3, -32768, 32766, -2]; + let extremes: Vec = (0..n as usize).map(|i| pattern[(i * 7) % 12]).collect(); + vec![ + ("sine_mono", 1, sine_mono), + ("sine_stereo", 2, sine_stereo), + ("triangle_stereo", 2, pcm(n as u32, 2, 8000, 3)), + ("extremes_mono", 1, extremes), + ] +} + +/// The rate ratios: (output rate, source rate). +const RATE_RATIOS: [(u32, u32); 7] = [(16_000, 8_000), (24_000, 8_000), (48_000, 8_000), (48_000, 32_000), (48_000, 44_100), (44_100, 48_000), (8_000, 16_000)]; + +/// Linear interpolation in f64, as the layered-PCM ladder idea describes it, written here +/// from that description: `src = (i·den)/num`, `lo = ⌊src⌋`, `frac = src − lo`, +/// `hi = min(lo + 1, n − 1)`, and `a + (b − a)·frac` rounded half away from zero, for +/// each channel. No earlier code is copied. +fn rate_f64(prev: &[i16], c: usize, num: u32, den: u32, i: usize, ch: usize) -> i64 { + let n = prev.len() / c; + let src = (i as u64 * den as u64) as f64 / num as f64; + let lo = src.floor(); + let frac = src - lo; + let lo = lo as usize; + let hi = (lo + 1).min(n - 1); + let (a, b) = (prev[lo * c + ch] as f64, prev[hi * c + ch] as f64); + (a + (b - a) * frac).round() as i64 +} + +/// Whether output sample `i`'s interpolation phase `(i·den mod num)/num` is a dyadic +/// fraction, so that `src`, `frac` and the f64 product are exact and the f64 form must +/// equal the integer one. +fn rate_phase_is_dyadic(i: u64, num: u64, den: u64) -> bool { + let (mut p, mut q) = ((i * den) % num, num); + let g = { + let (mut a, mut b) = (p, q); + while b != 0 { + (a, b) = (b, a % b); + } + a + }; + if p == 0 { + return true; + } + (p, q) = (p / g, q / g); + let _ = p; + q.is_power_of_two() +} + +/// `rate_port.json`: the integer `rate` predictor (staged-delivery Appendix A.4) beside the +/// f64 interpolation it replaces, on every fixture and ratio. +fn rate_port_json() -> Value { + let fixtures = rate_fixtures(); + let mut cases = Vec::new(); + for (name, c, samples) in &fixtures { + let c = *c as usize; + let n = samples.len() / c; + for &(num, den) in &RATE_RATIOS { + let n_out = wai::staged::predict::rate_len(n as u64, num, den).expect("a length") as usize; + let mut bytes = Vec::with_capacity(2 * n_out * c); + let mut differs = Vec::new(); + let mut dyadic = 0u64; + for i in 0..n_out { + let exact = rate_phase_is_dyadic(i as u64, num as u64, den as u64); + dyadic += u64::from(exact); + for ch in 0..c { + let p = wai::staged::predict::rate_at(samples, c, num, den, i, ch); + let f = rate_f64(samples, c, num, den, i, ch); + assert!(!exact || p == f, "{name} {num}/{den} sample {i} channel {ch}: a dyadic phase gives the same prediction"); + if p != f { + differs.push(json!([i, ch, p, f])); + } + bytes.extend_from_slice(&(p as i16).to_le_bytes()); + } + } + cases.push(json!({ + "fixture": name, + "num": num, + "den": den, + "n_out": n_out, + "dyadic_samples": dyadic, + "predicted_sha256": sha(&bytes), + "differs": differs, + })); + } + } + json!({ + "format": "wai-staged-rate-port/1", + "about": "The rate predictor of wai.audio.int_refine (staged-delivery Appendix A.4), rdiv(a·num + (b − a)·ρ, num), beside the f64 linear interpolation the layered-PCM ladder idea describes: src = (i·den)/num, lo = ⌊src⌋, frac = src − lo, hi = min(lo + 1, n − 1), a + (b − a)·frac rounded half away from zero. For each fixture (interleaved i16 samples) and ratio (num the output rate, den the source rate): the number of output samples, how many have a dyadic phase (where src, frac and the product are exact in f64, and the two must agree), the SHA-256 of the integer predictions (i16 little-endian, interleaved), and every [sample, channel, integer, f64] where they differ.", + "fixtures": fixtures.iter().map(|(n, c, s)| json!({"name": n, "channels": c, "samples": s})).collect::>(), + "cases": cases, + }) +} + +// ───────────────────────── MoQ carriage (moq-streaming-format §4.1) ───────────────────────── + +/// The case whose envelopes the MoQ trace carries: three closed-GOP bases of four +/// frames, a layer 1 for each frame and a layer 2 for each GOP. +const MOQ_CASE: &str = "video_int_motion_gop_perframe"; +const MOQ_NAMESPACE: &str = "example/cam1"; +/// The set's tracks: name, layer, the `track_alias` its receipts sign, its MOQT publisher +/// priority (lower is sent first) and its lead. +const MOQ_TRACKS: [(&str, u8, u64, u8, Option); 3] = + [("cam1/base", 0, 7, 64, None), ("cam1/L1", 1, 8, 96, Some(20)), ("cam1/L2", 2, 9, 128, Some(30))]; +/// The publisher's estimate of the path, which its timeouts subtract (§4.1). +const MOQ_PATH_ESTIMATE_MS: u64 = 30; +/// When the joining subscriber subscribes: after every object of GOP 0 has left the relay, +/// before any of GOP 1 reaches it. +const MOQ_JOIN_MS: u64 = 250; +/// How long the lossy subscriber's FETCH of a re-anchored chain's gap takes to bring its +/// receipts: they arrive after unit 6's deadline and before units 7 and 8's. +const MOQ_FETCH_MS: u64 = 40; + +/// A subscriber's stated hold bound (jwp-receipts §3 step 3, moq-streaming-format §4.1): +/// `live` receipts held for each chain, 16 as history, 64 across its chains. +fn moq_hold(live: u64) -> Value { + json!({"max_groups": live, "max_history": 16, "max_total": 64}) +} + +/// The receipts track of a media track: its name, beside the track's. +fn moq_receipts_name(track: &str) -> String { + format!("{track}/receipts") +} + +fn moq_clock() -> UnitClock { + UnitClock { t0_ms: 0, unit_ms: 40, target_latency_ms: 300 } +} + +fn moq_set_tracks() -> Vec { + let names: Vec<&str> = MOQ_TRACKS.iter().map(|t| t.0).collect(); + MOQ_TRACKS + .iter() + .map(|&(name, layer, alias, _, lead)| SetTrack { + namespace: MOQ_NAMESPACE.into(), + name: name.into(), + staged: StagedTrack { set: "cam1".into(), layer, lead_ms: lead }, + is_live: true, + target_latency_ms: Some(moq_clock().target_latency_ms), + role: Some("video".into()), + render_group: Some(1), + depends: depends_for(&names, layer), + capabilities: vec![if layer == 0 { CAP_VIDEO_INT_MOTION } else { CAP_VIDEO_INT_REFINE }.into()], + determinism: Some("decode-equivalence".into()), + receipts: true, + receipt_alias: Some(alias), + receipts_track: Some(moq_receipts_name(name)), + }) + .collect() +} + +/// The set's MSF catalog: the three tracks, each with its receipts track. +fn moq_catalog() -> Value { + let mut tracks: Vec = moq_set_tracks().iter().map(SetTrack::to_catalog_json).collect(); + for t in &MOQ_TRACKS { + tracks.push(receipts_track_json(MOQ_NAMESPACE, &moq_receipts_name(t.0), true)); + } + json!({"version": "draft-01", "tracks": tracks}) +} + +type CatalogEdit = fn(&mut Vec); + +/// Catalogs around §4.1's check: the set's own, one for each rule, the reviewed type +/// and namespace cases, and the set after MSF delta updates (§5.1.6, §5.3) and after +/// conversion to on demand (§11.3). Entries 0–2 are the base, layer 1 and layer 2; +/// 3–5 their receipts tracks. +fn moq_catalog_cases() -> Vec<(&'static str, &'static str, CatalogEdit)> { + fn obj(v: &mut Value) -> &mut serde_json::Map { + v.as_object_mut().expect("an entry") + } + fn layer3(t: &mut Vec) { + let mut l3 = t[2].clone(); + l3["name"] = json!("cam1/L3"); + l3["wai_receipt_alias"] = json!(10); + l3["wai_receipts_track"] = json!("cam1/L3/receipts"); + l3["depends"] = json!(["cam1/base", "cam1/L1", "cam1/L2"]); + l3["wai_staged"]["layer"] = json!(3); + t.push(l3); + t.push(receipts_track_json(MOQ_NAMESPACE, "cam1/L3/receipts", true)); + } + vec![ + ("ok", "the set of the trace: a base track and two layer tracks, each with its receipts track", |_| {}), + // 1. Types and identities. + ("layer_out_of_range", "a layer of 300", |t| t[1]["wai_staged"]["layer"] = json!(300)), + ("layer_not_an_integer", "a layer written 1.0", |t| t[1]["wai_staged"]["layer"] = json!(1.0)), + ("lead_a_string", "a lead written as a string", |t| t[1]["wai_staged"]["lead_ms"] = json!("20")), + ("packaging_not_wai", "a set track whose packaging is not wai", |t| t[1]["packaging"] = json!("loc")), + ("is_live_absent", "a set track without isLive", |t| { + obj(&mut t[2]).remove("isLive"); + }), + ("render_group_true", "renderGroup true on layer 1: not an integer", |t| t[1]["renderGroup"] = json!(true)), + ("render_group_float", "renderGroup 1.0 on layer 1: not an integer", |t| t[1]["renderGroup"] = json!(1.0)), + ("render_group_past_u64", "renderGroup 2^64 (read as a number past u64) on every set track", |t| { + for x in t.iter_mut().take(3) { + x["renderGroup"] = serde_json::from_str("18446744073709551616").expect("a number"); + } + }), + ("render_group_u64_max", "renderGroup 2^64 - 1 on every set track: an integer that fits u64", |t| { + for x in t.iter_mut().take(3) { + x["renderGroup"] = json!(u64::MAX); + } + }), + ("depends_an_object", "depends an object on layer 1", |t| t[1]["depends"] = json!({"cam1/base": 1})), + ("depends_a_string", "depends a string on layer 1, naming the base", |t| t[1]["depends"] = json!("cam1/base")), + ("depends_a_number", "depends 5 on layer 1", |t| t[1]["depends"] = json!(5)), + ("depends_true_on_base", "depends true on the base track", |t| t[0]["depends"] = json!(true)), + ("depends_a_number_inside", "depends on layer 1 holds a number beside the base's name", |t| t[1]["depends"] = json!(["cam1/base", 5])), + ("namespace_not_a_string", "a set track's namespace is a number", |t| t[1]["namespace"] = json!(5)), + ("duplicate_name", "the base's receipts track takes layer 1's name in its namespace: one namespace|name pair declared twice", |t| t[3]["name"] = json!("cam1/L1")), + // 2. Extent. + ("layer_1_missing", "layer 2 with no layer 1 track", |t| { + t.remove(1); + }), + // 3. depends. + ("depends_missing_lower", "layer 2 does not list a layer 1 track", |t| t[2]["depends"] = json!(["cam1/base"])), + ("depends_lists_higher", "layer 1 lists layer 2", |t| t[1]["depends"] = json!(["cam1/base", "cam1/L2"])), + ("depends_lists_itself", "layer 1 lists itself", |t| t[1]["depends"] = json!(["cam1/base", "cam1/L1"])), + ("base_depends_on_layer", "the base lists a layer track", |t| t[0]["depends"] = json!(["cam1/L1"])), + ("depends_names_an_absent_track", "layer 1 lists a track the catalog does not declare", |t| t[1]["depends"] = json!(["cam1/base", "no-such-track"])), + ("depends_outside_the_set", "a dependency on a catalog track outside the set", |t| { + t[2]["depends"] = json!(["cam1/base", "cam1/L1", "audio"]); + t.push(json!({"namespace": MOQ_NAMESPACE, "name": "audio", "packaging": "loc", "isLive": true, "renderGroup": 1})); + }), + ("base_in_another_namespace", "the base in another namespace, and an unrelated loc track of the base's name beside the layers: layer 1's dependency names that track, not the base", |t| { + t[0]["namespace"] = json!("example/other"); + t.push(json!({"namespace": MOQ_NAMESPACE, "name": "cam1/base", "packaging": "loc", "isLive": true, "renderGroup": 1})); + }), + // 4. Grouping. + ("render_group_split", "layer 2 in another render group", |t| t[2]["renderGroup"] = json!(2)), + ("render_group_absent", "no render group on any track", |t| { + for x in t.iter_mut().take(3) { + obj(x).remove("renderGroup"); + } + }), + ("alt_group_on_layer", "an alternate group on a layer track", |t| t[1]["altGroup"] = json!(1)), + ("alt_group_on_base", "an alternate group on the base track", |t| t[0]["altGroup"] = json!(1)), + ("temporal_id_on_layer", "a temporalId on a layer track", |t| t[2]["temporalId"] = json!(1)), + ("spatial_id_on_layer", "a spatialId on a layer track", |t| t[1]["spatialId"] = json!(1)), + ("is_live_disagrees", "one track not live", |t| t[1]["isLive"] = json!(false)), + ("two_namespaces", "a second base track, in another namespace: one set, two namespaces", |t| { + let mut b = t[0].clone(); + b["namespace"] = json!("example/other"); + t.push(b); + }), + // 5. Lead. + ("lead_missing", "a live layer track without a lead", |t| t[1]["wai_staged"]["lead_ms"] = json!(null)), + ("lead_on_base", "a lead on the base track", |t| t[0]["wai_staged"]["lead_ms"] = json!(5)), + ("not_live_no_lead", "a set that is not live, with no lead", |t| { + for x in t.iter_mut().take(3) { + x["isLive"] = json!(false); + x["wai_staged"]["lead_ms"] = json!(null); + } + }), + ("not_live_lead_ignored", "a set that is not live, its leads left on: not read", |t| { + for x in t.iter_mut().take(3) { + x["isLive"] = json!(false); + } + }), + // 6. Receipts. + ("alias_shared_across_layers", "layer 2 takes layer 1's receipt alias", |t| t[2]["wai_receipt_alias"] = json!(8)), + ("alias_missing", "a track declaring receipts without an alias", |t| { + obj(&mut t[1]).remove("wai_receipt_alias"); + }), + ("alias_not_an_integer", "an alias written as a string", |t| t[1]["wai_receipt_alias"] = json!("8")), + ("alias_float_elsewhere", "an alias 8.0 on another track (8 on layer 1): not an integer, wherever it is", |t| { + t.push(json!({"namespace": MOQ_NAMESPACE, "name": "audio", "packaging": "loc", "isLive": true, "wai_receipt_alias": 8.0})); + }), + ("alias_shared_with_another_track", "the alias 8 on a track outside the set", |t| { + t.push(json!({"namespace": MOQ_NAMESPACE, "name": "audio", "packaging": "loc", "isLive": true, "wai_receipt_alias": 8})); + }), + ("receipts_track_missing", "layer 1's receipts track is not in the catalog", |t| { + t.remove(4); + }), + ("receipts_track_not_named", "a track declaring receipts names no receipts track", |t| { + obj(&mut t[1]).remove("wai_receipts_track"); + }), + ("receipts_track_not_receipts", "layer 1 names as its receipts track a track of another packaging", |t| t[1]["wai_receipts_track"] = json!("cam1/base")), + ("receipts_track_two_chains", "layer 2 names layer 1's receipts track: two chains on one receipts track", |t| t[2]["wai_receipts_track"] = json!("cam1/L1/receipts")), + ("no_receipts_declared", "no track declares receipts, and there is no receipts track", |t| { + for x in t.iter_mut().take(3) { + x["wai_receipts"] = json!(false); + obj(x).remove("wai_receipt_alias"); + obj(x).remove("wai_receipts_track"); + } + t.truncate(3); + }), + // MSF delta updates (§5.1.6, §5.3) and conversion to on demand (§11.3). + ("delta_remove_top_layer", "MSF delta: layer 2's track removed (its receipts track left): the set is layers 0 to 1", |t| { + t.remove(2); + }), + ("delta_add_layer_3", "MSF delta: a layer 3 track added, with its own alias and receipts track; the tracks declared before are unchanged", layer3), + ("delta_clone_layer_2", "MSF delta: layer 2 cloned as cam1/L2b, inheriting all but its name, layer 2's alias and receipts track among them: two tracks on one chain, which neither can carry", |t| { + let mut c = t[2].clone(); + c["name"] = json!("cam1/L2b"); + t.push(c); + }), + ("delta_clone_layer_2_own_chain", "MSF delta: layer 2 cloned as cam1/L2b, overriding its alias and its receipts track, which is added: a rendition of layer 2 with a chain of its own", |t| { + let mut c = t[2].clone(); + c["name"] = json!("cam1/L2b"); + c["wai_receipt_alias"] = json!(10); + c["wai_receipts_track"] = json!("cam1/L2b/receipts"); + t.push(c); + t.push(receipts_track_json(MOQ_NAMESPACE, "cam1/L2b/receipts", true)); + }), + ("live_to_on_demand", "MSF §11.3: every track turned not live with a track duration; leads left on and not read", |t| { + for x in t.iter_mut() { + x["isLive"] = json!(false); + x["trackDuration"] = json!(480); + } + }), + // Several sets, and entries of no set. + ("two_faults", "an alternate group on layer 1 and no dependency on layer 2: the earlier rule", |t| { + t[1]["altGroup"] = json!(1); + t[2]["depends"] = json!([]); + }), + ("set_unreadable", "an entry whose set cannot be read, beside the set", |t| { + let mut x = t[1].clone(); + x["name"] = json!("cam1/L1x"); + x["wai_staged"]["set"] = json!(7); + obj(&mut x).remove("wai_receipt_alias"); + obj(&mut x).remove("wai_receipts_track"); + t.push(x); + }), + ("two_sets", "a second set whose layer 2 track has no layer 1 below it, beside the set", |t| { + for (name, layer) in [("mic/base", 0), ("mic/L2", 2)] { + let mut b = t[0].clone(); + b["name"] = json!(name); + b["wai_staged"] = json!({"set": "mic", "layer": layer, "lead_ms": if layer == 0 { Value::Null } else { json!(10) }}); + b["wai_receipt_alias"] = json!(20 + layer); + b["wai_receipts_track"] = json!(format!("{name}/receipts")); + b["depends"] = if layer == 0 { json!([]) } else { json!(["mic/base"]) }; + t.push(receipts_track_json(MOQ_NAMESPACE, &format!("{name}/receipts"), true)); + t.push(b); + } + }), + ] +} + +/// §4.1's check of `c`, by a checker that knows the catalog track's namespace `ns` (or, +/// where `None`, does not). +fn moq_catalog_expect(c: &Value, ns: Option<&str>) -> Value { + Value::Array(check_catalog_in(c, ns).into_iter().map(|(set, r)| json!({"set": set, "reason": r.err().map(SetRefusal::code)})).collect()) +} + +/// Namespace inheritance (MSF §5.2.2): which tracks inherit the catalog track's namespace, +/// and the namespaces a checker is given, `None` where it does not know it. +fn moq_inheritance_cases() -> Vec<(&'static str, &'static str, CatalogEdit, Option<&'static str>)> { + fn layers_inherit(t: &mut Vec) { + for x in t.iter_mut().skip(1) { + x.as_object_mut().expect("an entry").remove("namespace"); + } + } + fn all_inherit(t: &mut Vec) { + for x in t.iter_mut() { + x.as_object_mut().expect("an entry").remove("namespace"); + } + } + fn layer_1_twice(t: &mut Vec) { + let mut d = t[1].clone(); + d.as_object_mut().expect("an entry").remove("namespace"); + t.push(d); + } + vec![ + ("inherit_layers_known", "the base declares example/cam1 and every other track inherits the catalog track's, example/cam1: one namespace", layers_inherit, Some(MOQ_NAMESPACE)), + ("inherit_layers_other", "the same under a catalog track in other/ns: the base and the rest are in two namespaces, and the layers' depends name no track of theirs", layers_inherit, Some("other/ns")), + ("inherit_layers_unknown", "the same to a checker that does not know the catalog track's namespace: a set that mixes declared and inherited namespaces is refused", layers_inherit, None), + ("inherit_twice_known", "layer 1 again, inheriting example/cam1: the same track twice", layer_1_twice, Some(MOQ_NAMESPACE)), + ("inherit_twice_unknown", "the same to a checker that does not know the namespace: a mixed set, refused", layer_1_twice, None), + ("inherit_all_known", "every track inherits example/cam1", all_inherit, Some(MOQ_NAMESPACE)), + ("inherit_all_other", "every track inherits other/ns", all_inherit, Some("other/ns")), + ("inherit_all_unknown", "every track inherits, to a checker that does not know the namespace: one namespace, whichever it is", all_inherit, None), + ] +} + +fn moq_catalogs_json() -> Value { + let case = |name: &str, about: &str, edit: CatalogEdit, ns: Option<&str>| { + let mut c = moq_catalog(); + edit(c["tracks"].as_array_mut().expect("tracks")); + json!({"name": name, "about": about, "namespace": ns, "catalog": c, "expect": moq_catalog_expect(&c, ns)}) + }; + let mut cases: Vec = moq_catalog_cases().into_iter().map(|(name, about, edit)| case(name, about, edit, Some(MOQ_NAMESPACE))).collect(); + cases.extend(moq_inheritance_cases().into_iter().map(|(name, about, edit, ns)| case(name, about, edit, ns))); + json!({ + "format": "wai-staged-moq-catalogs/3", + "about": "moq-streaming-format §4.1, Checking a catalog: each catalog, the namespace of its catalog track as the checker knows it (null where it does not), and for each stage set it declares (by name, ascending), the first rule it fails or null. A track that declares no namespace inherits the catalog track's (MSF §5.2.2); a checker that does not know it refuses a set that mixes declared and inherited namespaces. An entry whose set cannot be read is reported for the empty name.", + "cases": cases, + }) +} + +/// One object of the trace: its track, its group, its file in the case, the base it is +/// or refines, the clock positions it covers, and its path: when it is published, its +/// time upstream to the relay, its time queued at the relay and its time downstream. +struct MoqObject { + track: usize, + group: u64, + file: String, + base: usize, + units: (u64, u64), + publish_ms: u64, + up_ms: u64, + queue_ms: u64, + down_ms: u64, +} + +/// The trace's objects. Each GOP base is published once its frames are coded, a layer 1 +/// for each of its frames after it, and its layer 2 after those. Frame 5's layer 1 takes +/// a slow upstream path and is queued at the relay within its timeout, so it is forwarded +/// and arrives late. The layer 2 of GOPs 1 and 2 is queued past its timeout, and the relay +/// drops it. +fn moq_objects() -> Vec { + let mut v = Vec::new(); + for k in 0..3u64 { + let base = k as usize; + v.push(MoqObject { track: 0, group: k, file: format!("base_{k}.wai"), base, units: (4 * k, 4), publish_ms: 160 * k + 130, up_ms: 10, queue_ms: 0, down_ms: 10 }); + for f in 0..4u64 { + let slow = k == 1 && f == 1; + v.push(MoqObject { + track: 1, + group: 4 * k + f, + file: format!("g{k}_L1_f{f}.wai"), + base, + units: (4 * k + f, 1), + publish_ms: 160 * k + 140 + 5 * f, + up_ms: if slow { 150 } else { 10 }, + queue_ms: if slow { 40 } else { 5 }, + down_ms: 10, + }); + } + v.push(MoqObject { track: 2, group: k, file: format!("g{k}_L2.wai"), base, units: (4 * k, 4), publish_ms: 160 * k + 170, up_ms: 10, queue_ms: [5, 200, 250][base], down_ms: 10 }); + } + v +} + +/// The `OBJECT_DELIVERY_TIMEOUT` the publisher sets on an object (§4.1): none on a base; +/// on a layer, its slack to the last unit it covers. +fn moq_timeout(o: &MoqObject) -> u64 { + match MOQ_TRACKS[o.track].4 { + None => 0, + Some(lead) => layer_timeout_ms(&moq_clock(), o.units.0 + o.units.1 - 1, lead, o.publish_ms, MOQ_PATH_ESTIMATE_MS).expect("every layer of the trace has slack"), + } +} + +fn moq_cfg() -> ChainConfig { + let lead_ms = MOQ_TRACKS[1..].iter().map(|t| t.4.expect("a lead")).collect(); + ChainConfig { live: Some(Live { clock: moq_clock(), lead_ms }), ..cfg() } +} + +/// The trace's inputs: everything `trace.json` states but its `expect`. +fn moq_inputs(case: &Case) -> Value { + let files: BTreeMap<&str, &Vec> = case.files.iter().map(|(n, b)| (n.as_str(), b)).collect(); + let objects: Vec = moq_objects() + .iter() + .map(|o| { + let bytes = if MOQ_TRACKS[o.track].1 == 0 { &case.bases[o.base].envelope } else { files[o.file.as_str()] }; + json!({ + "track": MOQ_TRACKS[o.track].0, + "group": o.group, + "object_id": 0, + "file": o.file, + "object": hex(&chash(bytes)), + "base": o.base, + "units": {"from": o.units.0, "count": o.units.1}, + "publish_ms": o.publish_ms, + "timeout_ms": moq_timeout(o), + "up_ms": o.up_ms, + "queue_ms": o.queue_ms, + "down_ms": o.down_ms, + }) + }) + .collect(); + let c = moq_clock(); + json!({ + "format": "wai-staged-moq-trace/3", + "about": "A live stage set over MoQ (moq-streaming-format §4.1), through one relay, to four subscribers: one of every track; one of the base and layer 1 tracks only; one of those two whose relay drops one receipts-track object (layer 1, group 6) at its discretion, and which FETCHes the groups a re-anchor skips (their receipts reach it fetch_ms after the re-anchor, in ascending order); and one of every track that joins at joins_ms, receiving the objects the relay sends and the receipts that reach it from then on. Each object travels upstream to the relay, waits there for its queue time, and travels downstream; the relay drops it when its queue time passes its OBJECT_DELIVERY_TIMEOUT (MOQT §5.2: the timer starts when it reaches the relay). Each track's receipts ride that track's receipts track, which has the track's priority and no timeout and waits nowhere, so each object's receipts arrive at its publication plus its upstream and downstream times, whether or not the object does; a subscriber subscribes to the receipts tracks of the tracks it subscribes to, and to no others. Each receipt is about the object of its MOQT group and object id. A subscriber verifies each track's receipts as one chain (jwp-receipts §3 step 3), in the order they arrive (ties by the order of `objects`): one whose parent it lacks it holds, within the bound it states (`hold`: a count live for each chain, a count of history, a count across its chains); when holding one would pass the bound, it refuses those its chain holds and re-anchors there; the groups the anchor skips it refuses, or, where it fetches them, it admits as history each fetched receipt that links forward from the group below the gap or back from the group above (jwp-receipts §3 step 3, History), and their layers wait for them; a subscriber that joins anchors each chain at its first receipt. A sink applies an object when it has the object and its receipt is verified; one whose receipt is refused it offers with no evidence, and its sink refuses it. `expect` states, for each subscriber, each object's fate and its receipt's (when it arrived live, when it was fetched, when it was verified or refused), the steps its sink takes (those of a staged-conformance case), what the sink reports and holds, the stage it presents at each clock position, its deadline misses, and the status and evidence a stage claim gives each layer object; and the relay's counts, over every subscriber.", + "case": MOQ_CASE, + "catalog": "catalog.json", + "clock": {"t0_ms": c.t0_ms, "unit_ms": c.unit_ms, "target_latency_ms": c.target_latency_ms}, + "path_estimate_ms": MOQ_PATH_ESTIMATE_MS, + "tracks": MOQ_TRACKS + .iter() + .map(|&(name, layer, alias, prio, lead)| json!({"name": name, "layer": layer, "receipt_alias": alias, "priority": prio, "lead_ms": lead, "receipts_track": moq_receipts_name(name)})) + .collect::>(), + "objects": objects, + "subscribers": [ + {"name": "full", "tracks": ["cam1/base", "cam1/L1", "cam1/L2"], "timeout_ms": 0, "receipts": true, "hold": moq_hold(4), "joins_ms": null, "lost_receipts": [], "fetch_ms": null}, + {"name": "capped", "tracks": ["cam1/base", "cam1/L1"], "timeout_ms": 0, "receipts": true, "hold": moq_hold(4), "joins_ms": null, "lost_receipts": [], "fetch_ms": null}, + {"name": "lossy", "tracks": ["cam1/base", "cam1/L1"], "timeout_ms": 0, "receipts": true, "hold": moq_hold(2), "joins_ms": null, "lost_receipts": [{"track": "cam1/L1", "group": 6}], "fetch_ms": MOQ_FETCH_MS}, + {"name": "joiner", "tracks": ["cam1/base", "cam1/L1", "cam1/L2"], "timeout_ms": 0, "receipts": true, "hold": moq_hold(4), "joins_ms": MOQ_JOIN_MS, "lost_receipts": [], "fetch_ms": null}, + ], + "sink": cfg_json(&moq_cfg()), + }) +} + +/// How a subscriber's verifier settled one receipt: verified, or refused, and when. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Settled { + Verified(u64), + Refused(u64), +} + +/// The receipts a subscriber receives, through its verifier (jwp-receipts §3 step 3), +/// each track's a chain whose groups are its groups in order. `arrivals` are (time, +/// order, track, group), `order` its place in `objects`; the verifier takes them by time, +/// then live before fetched, then order. A receipt whose parent is the chain's head +/// links, and releases the held receipts after it; a joiner anchors each chain at its +/// first receipt; any other live receipt is held, unless holding it would pass +/// `max_groups` for its chain or `max_total` across chains: then the chain's held +/// receipts are refused, the chain re-anchors at it, and the anchor leaves a gap of the +/// groups it skipped. Without `fetch_ms` those groups are refused then. With it, the +/// subscriber FETCHes their receipts (MOQT §3.2), which reach it `fetch_ms` later in +/// ascending order, and each that links forward from the group below the gap, or back +/// from the group above it, is verified as history (jwp-receipts §3 step 3, *History*). +/// A receipt behind its chain's head, a fetched one that links to neither end, or one +/// still held or unsettled at the end, is outside what the trace models. Returns how each +/// receipt settled, and when each fetched receipt arrived. +#[allow(clippy::type_complexity)] +fn moq_settle( + arrivals: &[(u64, usize, String, u64)], + groups_of: &BTreeMap>, + order_of: &BTreeMap<(String, u64), usize>, + join: bool, + max_groups: u64, + max_total: u64, + fetch_ms: Option, +) -> Result<(BTreeMap<(String, u64), Settled>, BTreeMap<(String, u64), u64>), String> { + #[derive(Default)] + struct St { + head: Option, + held: BTreeSet, + /// Open gaps: the group below (none behind a join) and the group above. + gaps: Vec<(Option, u64)>, + } + use std::cmp::Reverse; + // (time, 0 live / 1 fetched, order, track, group) + let mut queue: std::collections::BinaryHeap> = + arrivals.iter().map(|(t, i, tr, g)| Reverse((*t, 0, *i, tr.clone(), *g))).collect(); + let mut chains: BTreeMap = BTreeMap::new(); + let mut out: BTreeMap<(String, u64), Settled> = BTreeMap::new(); + let mut fetched: BTreeMap<(String, u64), u64> = BTreeMap::new(); + let mut total = 0u64; + while let Some(Reverse((t, class, _, tr, g))) = queue.pop() { + let order = groups_of.get(&tr).ok_or_else(|| format!("no track {tr}"))?; + let pos = order.iter().position(|x| *x == g).ok_or_else(|| format!("{tr} has no group {g}"))?; + let parent = pos.checked_sub(1).map(|p| order[p]); + let child = order.get(pos + 1).copied(); + let st = chains.entry(tr.clone()).or_default(); + if class == 1 { + fetched.insert((tr.clone(), g), t); + let at = st.gaps.iter().position(|(lo, hi)| lo.is_none_or(|l| l < g) && g < *hi).ok_or_else(|| format!("{tr} group {g}: fetched outside a gap"))?; + let (lo, hi) = st.gaps[at]; + let forward = lo.is_some() && parent == lo; + let backward = child == Some(hi); + if !forward && !backward { + return Err(format!("{tr} group {g}: a fetched receipt that links to neither end of its gap")); + } + out.insert((tr.clone(), g), Settled::Verified(t)); + if forward && backward { + st.gaps.remove(at); + } else if forward { + st.gaps[at].0 = Some(g); + } else { + st.gaps[at].1 = g; + } + continue; + } + let links = match st.head { + None => join || parent.is_none(), + Some(h) if g <= h => return Err(format!("{tr} group {g}: a receipt behind its chain's head")), + Some(h) => parent == Some(h), + }; + if !links { + if (st.held.len() as u64) < max_groups && total < max_total { + st.held.insert(g); + total += 1; + continue; + } + // The bound is passed: the held receipts are refused and the chain re-anchors + // here, leaving a gap of the groups the anchor skips. + total -= st.held.len() as u64; + let held = std::mem::take(&mut st.held); + if let Some(h) = st.head { + st.gaps.push((Some(h), g)); + for x in order.iter().copied().filter(|x| h < *x && *x < g) { + match fetch_ms { + Some(f) => { + let i = *order_of.get(&(tr.clone(), x)).ok_or("a group the trace lacks")?; + queue.push(Reverse((t.checked_add(f).ok_or("a fetch past the clock")?, 1, i, tr.clone(), x))); + } + None => { + out.entry((tr.clone(), x)).or_insert(Settled::Refused(t)); + } + } + } + } + for x in held { + if fetch_ms.is_none() || st.head.is_none() { + out.insert((tr.clone(), x), Settled::Refused(t)); + } + } + } + if st.head.is_none() && parent.is_some() { + st.gaps.push((None, g)); + } + st.head = Some(g); + out.insert((tr.clone(), g), Settled::Verified(t)); + // Release what waits on the new head, in order. + loop { + let h = st.head.expect("a head"); + let next = order.iter().position(|x| *x == h).and_then(|p| order.get(p + 1)).copied(); + match next { + Some(n) if st.held.remove(&n) => { + total -= 1; + st.head = Some(n); + out.insert((tr.clone(), n), Settled::Verified(t)); + } + _ => break, + } + } + } + if let Some((tr, st)) = chains.iter().find(|(_, st)| !st.held.is_empty()) { + return Err(format!("{tr}: receipts still held at the end of the trace ({:?})", st.held)); + } + Ok((out, fetched)) +} + +/// What the trace's inputs give: for each subscriber, each object's fate at the relay +/// (MOQT §5.2 at the relay's hop) and its receipt's, the steps its sink takes, the sink's +/// reports and final units, the stage presented at each clock position, its deadline +/// misses and the status and evidence of each layer object; and the relay's counts. +/// `bases` and `files` are the case's. +fn moq_expect(inputs: &Value, bases: &[(BaseInfo, Vec)], files: &BTreeMap>) -> Result { + let u = |v: &Value| v.as_u64().ok_or_else(|| format!("not an integer: {v}")); + let add = |a: u64, b: u64| a.checked_add(b).ok_or_else(|| format!("{a} + {b} passes u64")); + let s_of = |v: &Value| v.as_str().map(str::to_string).ok_or_else(|| format!("not a string: {v}")); + let mut layer_of: BTreeMap = BTreeMap::new(); + for t in inputs["tracks"].as_array().ok_or("no tracks")? { + layer_of.insert(s_of(&t["name"])?, u(&t["layer"])?); + } + let layer = |t: &str| layer_of.get(t).copied().ok_or_else(|| format!("no track {t}")); + let objects = inputs["objects"].as_array().ok_or("no objects")?; + let cfg = cfg_from(&inputs["sink"]); + let mut groups_of: BTreeMap> = BTreeMap::new(); + for o in objects { + if u(&o["object_id"])? != 0 { + return Err("a live staged group carries one object, object 0".into()); + } + groups_of.entry(s_of(&o["track"])?).or_default().push(u(&o["group"])?); + } + for g in groups_of.values_mut() { + g.sort_unstable(); + } + let (mut dropped_total, mut forwarded_receipts) = (0u64, 0u64); + let mut subs = Vec::new(); + for s in inputs["subscribers"].as_array().ok_or("no subscribers")? { + let tracks: BTreeSet = s["tracks"].as_array().ok_or("no tracks")?.iter().map(s_of).collect::>()?; + let receipts = s["receipts"] == true; + // The bound it states: every count an integer (moq-streaming-format §4.1). + let count = |k: &str| s["hold"][k].as_u64().ok_or_else(|| format!("{}: hold.{k} is not a stated count", s["name"])); + let (max_groups, _max_history, max_total) = (count("max_groups")?, count("max_history")?, count("max_total")?); + let joins = match &s["joins_ms"] { + Value::Null => None, + j => Some(u(j)?), + }; + let mut lost: BTreeSet<(String, u64)> = BTreeSet::new(); + for l in s["lost_receipts"].as_array().ok_or("no lost_receipts")? { + lost.insert((s_of(&l["track"])?, u(&l["group"])?)); + } + // What reaches it: from its join on, the objects the relay sends and the receipts + // that reach the relay, and of the tracks it subscribed to. + let mut mine: Vec<(usize, &Value)> = Vec::new(); + for (i, o) in objects.iter().enumerate() { + let track = s_of(&o["track"])?; + if !tracks.contains(&track) { + continue; + } + let at_relay = add(u(&o["publish_ms"])?, u(&o["up_ms"])?)?; + let sent = add(at_relay, u(&o["queue_ms"])?)?; + match joins { + Some(j) if at_relay < j && sent >= j => return Err("the join falls between an object's receipt and the object".into()), + Some(j) if at_relay < j => continue, + _ => mine.push((i, o)), + } + } + // Its verifier, over the receipts that reach it, in time order, then the order of + // `objects`. + let mut arrivals: Vec<(u64, usize, String, u64)> = Vec::new(); + for (i, o) in &mine { + let key = (s_of(&o["track"])?, u(&o["group"])?); + if receipts && !lost.contains(&key) { + arrivals.push((add(add(u(&o["publish_ms"])?, u(&o["up_ms"])?)?, u(&o["down_ms"])?)?, *i, key.0, key.1)); + } + } + let fetch_ms = match &s["fetch_ms"] { + Value::Null => None, + f => Some(u(f)?), + }; + let mut order_of: BTreeMap<(String, u64), usize> = BTreeMap::new(); + for (i, o) in objects.iter().enumerate() { + order_of.insert((s_of(&o["track"])?, u(&o["group"])?), i); + } + let (settled, fetched) = + if receipts { moq_settle(&arrivals, &groups_of, &order_of, joins.is_some(), max_groups, max_total, fetch_ms)? } else { Default::default() }; + let mut fates = Vec::new(); + let mut events: Vec<(u64, u64, u64, Step)> = Vec::new(); + let mut first_unit = vec![0u64; bases.len()]; + for o in objects { + let base = usize::try_from(u(&o["base"])?).ok().filter(|b| *b < bases.len()).ok_or("a base the case lacks")?; + if layer(&s_of(&o["track"])?)? == 0 { + first_unit[base] = u(&o["units"]["from"])?; + } + } + let mut evidence_of: Vec<&'static str> = Vec::new(); + for (_, o) in &mine { + let track = s_of(&o["track"])?; + let group = u(&o["group"])?; + let layer = layer(&track)?; + let base = u(&o["base"])? as usize; + let received = add(u(&o["publish_ms"])?, u(&o["up_ms"])?)?; + let send = add(received, u(&o["queue_ms"])?)?; + let is_lost = lost.contains(&(track.clone(), group)); + let receipt_ms = (receipts && !is_lost).then_some(add(received, u(&o["down_ms"])?)?); + let fetched_ms = fetched.get(&(track.clone(), group)).copied(); + let fate_of = settled.get(&(track.clone(), group)).copied(); + if receipts && fate_of.is_none() { + return Err(format!("{track} group {group}: a receipt never settled")); + } + let verified_ms = match fate_of { + Some(Settled::Verified(t)) => Some(t), + _ => None, + }; + let refused_ms = match fate_of { + Some(Settled::Refused(t)) => Some(t), + _ => None, + }; + if expired_at_hop(effective_timeout_ms(u(&o["timeout_ms"])?, u(&s["timeout_ms"])?), received, send) { + dropped_total += 1; + forwarded_receipts += u64::from(receipts && !is_lost); + fates.push(json!({"track": track, "group": group, "fate": "dropped", "arrival_ms": null, "receipt_ms": receipt_ms, "fetched_ms": fetched_ms, "verified_ms": verified_ms, "refused_ms": refused_ms})); + evidence_of.push(if verified_ms.is_some() { "inclusion-verified" } else { "none" }); + continue; + } + let arrival = add(send, u(&o["down_ms"])?)?; + fates.push(json!({"track": track, "group": group, "fate": "forwarded", "arrival_ms": arrival, "receipt_ms": receipt_ms, "fetched_ms": fetched_ms, "verified_ms": verified_ms, "refused_ms": refused_ms})); + // The sink takes the object once it has it and, where it reads receipts, its + // receipt is settled: verified, it is delivered-verified; refused, it is offered + // with no evidence, and refused. + let (at, evidence) = match (verified_ms, refused_ms) { + (Some(v), _) => (arrival.max(v), ReceiptEvidence::DeliveredVerified), + (None, Some(r)) => (arrival.max(r), ReceiptEvidence::None), + (None, None) => (arrival, ReceiptEvidence::None), + }; + evidence_of.push(evidence_token(evidence)); + let file = s_of(&o["file"])?; + let step = if layer == 0 { + if receipts && verified_ms.is_none() { + return Err(format!("base {base}: its receipt is refused, outside what the trace models")); + } + Step::Base { index: base, at_ms: at } + } else { + if !files.contains_key(&file) { + return Err(format!("{file} is not a layer of the case")); + } + Step::Offer { file, at_ms: at, evidence } + }; + events.push((at, layer, group, step)); + } + events.sort_by_key(|e| (e.0, e.1, e.2)); + let last = events.last().map_or(0, |e| e.0); + let mut steps: Vec = events.into_iter().map(|e| e.3).collect(); + steps.push(Step::Expire { at_ms: add(add(last, cfg.bound.max_age_ms)?, 1)? }); + let replay_bases: Vec<(BaseInfo, Vec)> = bases + .iter() + .enumerate() + .map(|(k, (info, units))| (BaseInfo { first_unit: first_unit[k], attested: receipts, ..info.clone() }, units.clone())) + .collect(); + let replay = Replay { bases: replay_bases, files: files.clone(), cfg: cfg.clone(), steps: steps.clone() }; + let (reports, finals) = run(&replay); + // The stage presented at each clock position of the bases it received. + let mut presented: BTreeMap = BTreeMap::new(); + for (k, f) in finals.iter().enumerate() { + for (i, unit) in f["units"].as_array().into_iter().flatten().enumerate() { + presented.insert(add(first_unit[k], i as u64)?, u(&unit["stage"])?); + } + } + // What each layer object came to, from every report about it. + let mut applied: BTreeMap> = BTreeMap::new(); + let mut refused: BTreeMap> = BTreeMap::new(); + let mut refused_whole: BTreeSet = BTreeSet::new(); + for r in reports.iter().filter_map(Value::as_array).flatten() { + let obj = s_of(&r["object"])?; + if !r["envelope"].is_null() { + refused_whole.insert(obj.clone()); + } + for a in r["applied"].as_array().into_iter().flatten() { + applied.entry(obj.clone()).or_default().insert(u(a)?); + } + for x in r["refused"].as_array().into_iter().flatten() { + refused.entry(obj.clone()).or_default().insert(u(&x[0])?, s_of(&x[1])?); + } + } + let mut misses: BTreeSet<(u64, u64)> = BTreeSet::new(); + let mut statuses = Vec::new(); + for (((_, o), fate), evidence) in mine.iter().zip(&fates).zip(&evidence_of) { + if layer(&s_of(&o["track"])?)? == 0 { + continue; + } + let obj = s_of(&o["object"])?; + let base = u(&o["base"])?; + let from = u(&o["units"]["from"])?.checked_sub(first_unit[base as usize]).ok_or("a layer before its base")?; + let count = u(&o["units"]["count"])?; + let none = BTreeSet::new(); + let got = applied.get(&obj).unwrap_or(&none); + let why = refused.get(&obj).cloned().unwrap_or_default(); + for (unit, w) in &why { + if w == "deadline-missed" && !got.contains(unit) { + misses.insert((base, *unit)); + } + } + let status = if fate["fate"] == "dropped" { + "dropped" + } else if got.len() as u64 == count { + "applied" + } else if !got.is_empty() { + "partial" + } else if !refused_whole.contains(&obj) && (from..add(from, count)?).all(|x| why.get(&x).map(String::as_str) == Some("deadline-missed")) { + "late" + } else { + "refused" + }; + statuses.push(json!({"track": o["track"], "group": o["group"], "object": obj, "status": status, "evidence": evidence})); + } + subs.push(json!({ + "name": s["name"], + "objects": fates, + "steps": steps.iter().map(step_json).collect::>(), + "reports": reports, + "bases": finals, + "presented": presented.values().collect::>(), + "deadline_misses": misses.len(), + "statuses": statuses, + })); + } + Ok(json!({"relay": {"dropped_on_timeout": dropped_total, "receipts_forwarded_for_dropped": forwarded_receipts}, "subscribers": subs})) +} + +/// Every file below `moq/`. +fn moq_files(all: &[Case]) -> Vec<(String, Vec)> { + let case = all.iter().find(|c| c.name == MOQ_CASE).expect("the MoQ trace's case"); + let mut trace = moq_inputs(case); + let bases: Vec<(BaseInfo, Vec)> = case.bases.iter().map(|b| (b.info(), b.units.clone())).collect(); + let files: BTreeMap> = case.files.iter().cloned().collect(); + trace["expect"] = moq_expect(&trace, &bases, &files).expect("the trace's own inputs"); + vec![ + ("moq/catalog.json".into(), json_bytes(&moq_catalog())), + ("moq/catalogs.json".into(), json_bytes(&moq_catalogs_json())), + ("moq/trace.json".into(), json_bytes(&trace)), + ] +} + +/// The MoQ files, checked from what is committed: the catalog passes §4.1's check; each +/// catalog case gives its stated reasons; and the trace's timeouts, priorities and expected +/// outcome follow from its inputs and the case's committed envelopes and stage 0. +fn verify_moq(dir: &Path, fail: &mut dyn FnMut(String)) { + let read = |rel: &str| -> Value { std::fs::read(dir.join(rel)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null) }; + let catalog = read("moq/catalog.json"); + if check_catalog(&catalog).iter().any(|(_, r)| r.is_err()) || check_catalog(&catalog).is_empty() { + fail("moq/catalog.json: a set fails moq-streaming-format §4.1".into()); + } + for c in read("moq/catalogs.json")["cases"].as_array().into_iter().flatten() { + if moq_catalog_expect(&c["catalog"], c["namespace"].as_str()) != c["expect"] { + fail(format!("moq/catalogs.json {}: the check gives other reasons", c["name"])); + } + } + let trace = read("moq/trace.json"); + let u = |v: &Value| v.as_u64().unwrap_or(u64::MAX); + let tracks = trace["tracks"].as_array().cloned().unwrap_or_default(); + let prio: Vec> = tracks.iter().map(|t| t["priority"].as_u64().and_then(|p| u8::try_from(p).ok())).collect(); + let prio: Option> = prio.into_iter().collect(); + if !prio.is_some_and(|p| !p.is_empty() && priorities_ok(p[0], &p[1..])) { + fail("moq/trace.json: the tracks' priorities break §4.1".into()); + } + for t in &tracks { + let named = catalog["tracks"].as_array().into_iter().flatten().find(|e| e["name"] == t["name"]); + if named.is_none_or(|e| e["wai_receipts_track"] != t["receipts_track"] || e["wai_receipt_alias"] != t["receipt_alias"]) { + fail(format!("moq/trace.json: track {} is not the catalog's", t["name"])); + } + } + let clock = UnitClock { t0_ms: u(&trace["clock"]["t0_ms"]), unit_ms: u(&trace["clock"]["unit_ms"]), target_latency_ms: u(&trace["clock"]["target_latency_ms"]) }; + let lead: BTreeMap<&str, Option> = tracks.iter().map(|t| (t["name"].as_str().unwrap_or(""), t["lead_ms"].as_u64())).collect(); + for o in trace["objects"].as_array().into_iter().flatten() { + let want = match lead.get(o["track"].as_str().unwrap_or("")).copied().flatten() { + None => Some(0), + Some(l) => u(&o["units"]["from"]) + .checked_add(u(&o["units"]["count"])) + .and_then(|e| e.checked_sub(1)) + .and_then(|last| layer_timeout_ms(&clock, last, l, u(&o["publish_ms"]), u(&trace["path_estimate_ms"]))), + }; + if want != Some(u(&o["timeout_ms"])) { + fail(format!("moq/trace.json: object {} {}: its timeout is not its slack", o["track"], o["group"])); + } + } + // The case's committed bases and envelopes. + let cdir = dir.join("cases").join(MOQ_CASE); + let case: Value = std::fs::read(cdir.join("case.json")).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(Value::Null); + let mut bases = Vec::new(); + for b in case["bases"].as_array().into_iter().flatten() { + let env = std::fs::read(cdir.join(b["file"].as_str().unwrap_or(""))).unwrap_or_default(); + let stage0 = std::fs::read(cdir.join(b["stage0"].as_str().unwrap_or(""))).unwrap_or_default(); + let info = BaseInfo { + content_hash: chash(&env), + capability: b["decoded_via"].as_str().unwrap_or("").into(), + via_fallback: b["via_fallback"] == true, + tier: tier::from_token(b["tier"].as_str().unwrap_or("")), + attested: false, + first_unit: 0, + }; + bases.push((info, units_from_stage0(b, &stage0))); + } + let files: BTreeMap> = case["files"] + .as_array() + .into_iter() + .flatten() + .map(|f| { + let n = f["file"].as_str().unwrap_or("").to_string(); + (n.clone(), std::fs::read(cdir.join(&n)).unwrap_or_default()) + }) + .collect(); + for o in trace["objects"].as_array().into_iter().flatten() { + let name = o["file"].as_str().unwrap_or(""); + let bytes = files.get(name).cloned().or_else(|| std::fs::read(cdir.join(name)).ok()).unwrap_or_default(); + if hex(&chash(&bytes)) != o["object"] { + fail(format!("moq/trace.json: {name} is not the object the trace names")); + } + } + match moq_expect(&trace, &bases, &files) { + Ok(e) if e == trace["expect"] => {} + Ok(_) => fail("moq/trace.json: the inputs do not give the stated outcome".into()), + Err(e) => fail(format!("moq/trace.json: {e}")), + } +} + // ───────────────────────── WAI2 identity (SPEC §7.1) ───────────────────────── /// The `wai2/` files: name → (bytes, the WAI1 sources it packs, if any). @@ -1802,6 +2769,11 @@ fn verify(dir: &Path) -> Result<(), Vec> { committed.push(format!("wai2/{}", f.file_name().to_string_lossy())); } } + if let Ok(rd) = std::fs::read_dir(dir.join("moq")) { + for f in rd.flatten() { + committed.push(format!("moq/{}", f.file_name().to_string_lossy())); + } + } for sub in ["cases"] { let root = dir.join(sub); if let Ok(rd) = std::fs::read_dir(&root) { @@ -1932,6 +2904,8 @@ fn verify(dir: &Path) -> Result<(), Vec> { if sched != schedule_json() { fail("schedule.json: the traces do not replay to the stated presentation".into()); } + // 5. The MoQ carriage. + verify_moq(dir, &mut fail); println!("{n_cases} cases replayed"); if fails.is_empty() { Ok(()) } else { Err(fails) } } diff --git a/wai/wai-rs/src/codecs/registry.rs b/wai/wai-rs/src/codecs/registry.rs index e43e293a3..e7a0133ec 100644 --- a/wai/wai-rs/src/codecs/registry.rs +++ b/wai/wai-rs/src/codecs/registry.rs @@ -500,6 +500,10 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[ wire("wai_capabilities", WireKind::Catalog, MOQ_FORMAT, "§4", WireStatus::Defined), wire("wai_determinism", WireKind::Catalog, MOQ_FORMAT, "§4", WireStatus::Defined), wire("wai_receipts", WireKind::Catalog, MOQ_FORMAT, "§4", WireStatus::Defined), + wire("wai_receipt_alias", WireKind::Catalog, MOQ_FORMAT, "§4", WireStatus::Defined), + wire("wai_receipts_track", WireKind::Catalog, MOQ_FORMAT, "§4", WireStatus::Defined), + wire("wai_staged", WireKind::Catalog, MOQ_FORMAT, "§4.1", WireStatus::Defined), + wire("wai-receipts", WireKind::Catalog, MOQ_FORMAT, "§4.1", WireStatus::Defined), wire("WAI_RECEIPT", WireKind::Catalog, MOQ_FORMAT, "§5", WireStatus::Defined), wire("/.well-known/wai-keys", WireKind::WellKnown, JWP_RECEIPTS, "§7.1", WireStatus::Defined), wire("wai-key-status", WireKind::DocumentKind, JWP_RECEIPTS, "§7.9", WireStatus::Defined), diff --git a/wai/wai-rs/src/jwp_delivery.rs b/wai/wai-rs/src/jwp_delivery.rs index a8f463e2b..c6d9b1007 100644 --- a/wai/wai-rs/src/jwp_delivery.rs +++ b/wai/wai-rs/src/jwp_delivery.rs @@ -88,6 +88,10 @@ pub enum Reason { ChainBreak, /// A non-null parent with no previous group receipt held. ParentNotHeld, + /// A group held for its parent past the verifier's stated bound (§3 step 3, + /// *Holding*): held live, refused when its chain re-anchors; held as history, refused + /// alone. + ParentNotRecovered, /// Under the opt-in guard of §6.1, a `group_id` no greater than that of the head /// of its chain, the one for its (`signer_pubkey`, `track_alias`). GroupIdNotIncreasing, @@ -117,6 +121,10 @@ pub enum Reason { GroupUncertaintyBelowParts, /// A `WAI_RECEIPT` property breaks moq-streaming-format §5. PropertyInvalid, + /// Carried over MoQ (moq-streaming-format §4.1), a receipt whose key or `track_alias` + /// is not its receipts track's, or whose `group_id` or `object_id` is not the MOQT + /// Group ID or Object ID of the object it is for. + MoqtMismatch, /// A property disagrees with a signed statement of the same object. PropertyDisagrees, } @@ -131,6 +139,7 @@ impl Reason { Reason::GroupLabelInvalid => "group_label_invalid", Reason::ChainBreak => "chain_break", Reason::ParentNotHeld => "parent_not_held", + Reason::ParentNotRecovered => "parent_not_recovered", Reason::GroupIdNotIncreasing => "group_id_not_increasing", Reason::GroupNotFresh => "group_not_fresh", Reason::ContentHashMismatch => "content_hash_mismatch", @@ -142,13 +151,14 @@ impl Reason { Reason::GroupCoverageDisagrees => "group_coverage_disagrees", Reason::GroupUncertaintyBelowParts => "group_uncertainty_below_parts", Reason::PropertyInvalid => "property_invalid", + Reason::MoqtMismatch => "moqt_mismatch", Reason::PropertyDisagrees => "property_disagrees", } } } -/// A refusal: the §3 step it happened at — `None` for the group total and for a -/// property, which are not steps of §3 — and why. +/// A refusal: the §3 step it happened at — `None` for the group total, for a property +/// and for a receipt carried for another MoQ object, which are not steps of §3 — and why. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DeliveryError { pub step: Option, @@ -690,13 +700,31 @@ impl ObjectReceipt { /// Step 3 is the caller's: [`ReceiptChains::verify_object`] runs it against the /// chain of the group's (`signer_pubkey`, `track_alias`). pub fn verify_against(&self, group: &GroupReceipt, envelope: Option<&[u8]>) -> Result { - match envelope { - Some(e) => self.accept(group, e).map(|()| Evidence::DeliveredVerified), - None => { - group.verify()?; - self.verify_inclusion(group).map(|()| Evidence::InclusionVerified) - } + group.verify()?; + self.evidence_under(group, envelope) + } + + /// [`ObjectReceipt::verify_against`] under a group whose signature has verified: + /// steps 4 and 5. + fn evidence_under(&self, group: &GroupReceipt, envelope: Option<&[u8]>) -> Result { + if let Some(e) = envelope + && crate::jwp_hash::content_hash(e) != self.content_hash + { + return Err(DeliveryError::at(4, Reason::ContentHashMismatch)); } + self.verify_inclusion(group)?; + Ok(if envelope.is_some() { Evidence::DeliveredVerified } else { Evidence::InclusionVerified }) + } + + /// Carried over MoQ (moq-streaming-format §4.1): the receipt is for the object of MOQT + /// group `moqt_group_id` and Object ID `moqt_object_id` on the media track whose + /// receipts `track` carries — its `track_alias`, `group_id` and `object_id` are those + /// — or it is refused (`moqt_mismatch`). + pub fn check_moqt(&self, track: &ReceiptsTrack, moqt_group_id: u64, moqt_object_id: u64) -> Result<(), DeliveryError> { + if self.track_alias != track.track_alias || self.group_id != moqt_group_id || self.object_id != moqt_object_id { + return Err(DeliveryError { step: None, reason: Reason::MoqtMismatch }); + } + Ok(()) } /// The §2.4.2 JSON form: the table's members in its order, then, for a @@ -847,31 +875,239 @@ impl Freshness { /// before them reads as a gap: refused all the same, for another reason. pub const CHAIN_HELD_LINKS: usize = 65_536; -/// One chain of [`ReceiptChains`]: its head, the link hashes of the groups accepted on -/// it, and its live edge. +/// The most groups a [`ReceiptChains`] holds for missing parents, across all its chains +/// and of both kinds, whatever bound it is given (§3 step 3, *Holding*). +/// [`ReceiptChains::for_moq`] refuses a bound with any count above it. +/// +/// cbindgen:ignore +pub const MAX_HELD_GROUPS: usize = 65_536; + +/// How many gaps one chain keeps open for history (§3 step 3, *History*). Past it, the +/// oldest is closed, and a group that would have filled it is decided as any other. +/// +/// cbindgen:ignore +pub const CHAIN_OPEN_GAPS: usize = 64; + +/// A group held for a missing parent (§3 step 3, *Holding*), its signature verified: when +/// it arrived on the verifier's clock, the order it arrived in, and whether it is held as +/// history (its `group_id` inside a gap an anchor left) or live. +#[derive(Clone, Debug)] +struct Held { + group: GroupReceipt, + at_ms: u64, + seq: u64, + history: bool, +} + +/// What an anchor skipped (§3 step 3, *History*). Below it, the last group admitted +/// before the anchor (`low`: its link hash and `group_id`; absent behind a join), from +/// which the gap fills forward. Above it, the earliest group admitted at or after the +/// anchor (`high_id`) and the parent its signature names (`high_parent`; absent where +/// that parent is unsigned or null), from which it fills backward. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Gap { + low: Option<([u8; 32], u64)>, + high_id: u64, + high_parent: Option<[u8; 32]>, +} + +impl Gap { + /// Whether `group_id` lies strictly between the gap's ends. + fn spans(&self, group_id: u64) -> bool { + group_id < self.high_id && self.low.is_none_or(|(_, l)| group_id > l) + } +} + +/// The parent a group's signature covers: a labelled group's, never a legacy one's. +fn signed_parent(g: &GroupReceipt) -> Option<[u8; 32]> { + if g.label.is_some() { + g.parent_receipt_hash + } else { + None + } +} + +/// One chain of [`ReceiptChains`]: its head, the link hashes of the groups admitted on +/// it, its live edge, what it holds for missing parents, its anchors and the gaps they +/// left. #[derive(Clone, Debug, Default)] struct Chain { head: Option, - held: std::collections::BTreeSet<[u8; 32]>, - held_order: std::collections::VecDeque<[u8; 32]>, + head_link: Option<[u8; 32]>, + /// The link hashes of the groups admitted on the chain, each with the parent it was + /// admitted with, the most recent [`CHAIN_HELD_LINKS`], oldest first in + /// `links_order`. A legacy group's link does not cover its parent, so the parent + /// tells the group admitted from another with the same signed bytes. + links: std::collections::BTreeMap<[u8; 32], Option<[u8; 32]>>, + links_order: std::collections::VecDeque<[u8; 32]>, /// The highest `group_id` of the chain's groups that verified (§3 steps 1–2) and were /// inside the freshness window when they arrived, or arrived with no bound applied, /// whether or not they linked: the live edge (§6.1). `group_id` is signed in both /// payloads, so no rewrite of a link moves it. edge: Option, + /// Groups held for a parent the chain lacks (*Holding*), by link hash; the same groups + /// by the parent they name; and those held as history, by `group_id` and arrival. + held: std::collections::BTreeMap<[u8; 32], Held>, + by_parent: std::collections::BTreeMap<[u8; 32], std::collections::BTreeSet<[u8; 32]>>, + history: std::collections::BTreeSet<(u64, u64, [u8; 32])>, + /// The live bound was passed: the next group of the chain that may anchor it does + /// (*Re-anchoring*). + reanchor: bool, + /// Every anchor of the chain, in order. + anchors: Vec, + /// The gaps its anchors left that are still open, oldest first. + gaps: std::collections::VecDeque, } impl Chain { - fn hold(&mut self, link: [u8; 32]) { - if self.held.insert(link) { - self.held_order.push_back(link); - while self.held_order.len() > CHAIN_HELD_LINKS { - if let Some(old) = self.held_order.pop_front() { - self.held.remove(&old); + fn remember(&mut self, link: [u8; 32], parent: Option<[u8; 32]>) { + if !self.links.contains_key(&link) { + self.links.insert(link, parent); + self.links_order.push_back(link); + while self.links_order.len() > CHAIN_HELD_LINKS { + if let Some(old) = self.links_order.pop_front() { + self.links.remove(&old); } } } } + + /// The open gap `group` fills, oldest first, and from which side: forward, its parent + /// the gap's lower end; backward, its link hash the parent the gap's upper end names. + /// Either way its `group_id` lies inside the gap. + fn fill(&self, group: &GroupReceipt, link: &[u8; 32]) -> Option<(usize, bool, bool)> { + self.gaps.iter().enumerate().find_map(|(i, g)| { + if !g.spans(group.group_id) { + return None; + } + let forward = g.low.is_some_and(|(l, _)| group.parent_receipt_hash == Some(l)); + let backward = g.high_parent == Some(*link); + (forward || backward).then_some((i, forward, backward)) + }) + } + + fn live_held(&self) -> usize { + self.held.len() - self.history.len() + } + + fn put(&mut self, link: [u8; 32], h: Held) { + if h.history { + self.history.insert((h.group.group_id, h.seq, link)); + } + if let Some(p) = h.group.parent_receipt_hash { + self.by_parent.entry(p).or_default().insert(link); + } + self.held.insert(link, h); + } + + fn take(&mut self, link: &[u8; 32]) -> Option { + let h = self.held.remove(link)?; + if h.history { + self.history.remove(&(h.group.group_id, h.seq, *link)); + } + if let Some(p) = h.group.parent_receipt_hash + && let Some(s) = self.by_parent.get_mut(&p) + { + s.remove(link); + if s.is_empty() { + self.by_parent.remove(&p); + } + } + Some(h) + } +} + +/// How a group was admitted to its chain (§3 step 3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Admission { + /// It links to the chain's head, or starts the chain with a `null` parent. + Linked, + /// It anchors the chain whatever its parent: the first group of a chain the verifier + /// joined, or the next group after a re-anchor. Its parent is not verified. + Anchor, + /// A group inside a gap an anchor left (*History*): it links forward from the last + /// group admitted before the gap, or back from the earliest group admitted after it. + History, + /// A group the chain admitted before, other than its head, offered again with the + /// same bytes (its parent too): nothing changes. MOQT can deliver one object twice, + /// by a subscription and a fill (MOQT §3.4). + AlreadyAdmitted, +} + +impl Admission { + /// The verdict word: `accept`, `accept anchor`, `accept history` or `accept again`. + pub fn label(self) -> &'static str { + match self { + Admission::Linked => "accept", + Admission::Anchor => "accept anchor", + Admission::History => "accept history", + Admission::AlreadyAdmitted => "accept again", + } + } +} + +/// Where a chain was anchored (§3 step 3): the anchor's `group_id`, whether the anchor +/// began a joined chain, and, after a re-anchor, the `group_id` of the last group admitted +/// before it. What lies between is reported, never verified, until history fills it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Anchoring { + pub anchor: u64, + pub joined: bool, + pub after: Option, +} + +/// The bound a verifier states on what it holds for missing parents (§3 step 3, +/// *Holding*): a count of groups held live on each chain, past which the chain re-anchors; +/// a count of groups held as history on each chain, inside the gaps its anchors left, +/// past which the lowest is refused; a count across every chain, of both kinds; and +/// optionally a time from each held group's arrival, on the verifier's clock. Whatever +/// the counts say, a [`ReceiptChains`] holds at most [`MAX_HELD_GROUPS`] in all. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HoldBound { + pub max_groups: usize, + pub max_history: usize, + pub max_total: usize, + pub max_age_ms: Option, +} + +impl HoldBound { + /// A bound as a subscriber states it in JSON: `max_groups`, `max_history` and + /// `max_total`, each an integer, and `max_age_ms`, an integer or absent. `None` where + /// a count is absent or is not an integer: such a statement states no bound. + pub fn from_json(v: &Value) -> Option { + let count = |k: &str| v.get(k).and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()); + let max_age_ms = match v.get("max_age_ms") { + None | Some(Value::Null) => None, + Some(a) => Some(a.as_u64()?), + }; + Some(HoldBound { max_groups: count("max_groups")?, max_history: count("max_history")?, max_total: count("max_total")?, max_age_ms }) + } +} + +/// Why [`ReceiptChains::for_moq`] refused a bound: a count above [`MAX_HELD_GROUPS`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct BoundRefused { + pub count: usize, +} + +/// A receipts track (moq-streaming-format §4.1): the key its publisher signs with, and the +/// `wai_receipt_alias` of its media track, which every receipt on it carries as its +/// `track_alias`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ReceiptsTrack { + pub signer_pubkey: [u8; 32], + pub track_alias: u64, +} + +/// What later became of a group the verifier held: the group's chain, `group_id` and link +/// hash, and its outcome. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChainEvent { + pub signer_pubkey: [u8; 32], + pub track_alias: u64, + pub group_id: u64, + pub link: [u8; 32], + pub outcome: Result, } /// The receipt-chain state of §3 step 3 and §6.1: one chain for each @@ -896,15 +1132,37 @@ impl Chain { /// of a group that verified and was inside the window when it arrived. Such a group is /// history, filling a gap behind the edge, in any order and however long the gap. /// +/// With recovery on, before the cases above: a group inside a gap an anchor left that +/// links to either end of it is history (§3 step 3, *History*); and after them, a group +/// that may anchor its chain does. +/// /// A group becomes the head of its chain once it passes, whether or not any of its /// objects arrived: a group whose objects were all dropped (§4.4) still links the next /// one. Its objects are then verified with or without their envelopes /// ([`ReceiptChains::verify_object`]). +/// +/// Each group's signature is verified once: a group the chain has admitted or holds, and +/// so has verified, is known by its link hash, which covers its signed bytes and its +/// signature. #[derive(Clone, Debug, Default)] pub struct ReceiptChains { chains: std::collections::BTreeMap<([u8; 32], u64), Chain>, group_id_guard: bool, freshness: Option, + /// The verifier joined its tracks after their first groups (§3 step 3, *Joining*). + join: bool, + /// What it holds for missing parents, and its bound (*Holding*); `None`, it holds + /// nothing and refuses a gap at once. + hold: Option, + events: Vec, + /// Groups held across every chain. + total_held: usize, + /// Groups held so far: each held group's place in arrival order. + arrivals: u64, + /// Group signatures verified (§3 steps 1–2) by [`ReceiptChains::admit`]. + verifications: u64, + /// Held groups offered again on release. + reoffers: u64, } impl ReceiptChains { @@ -927,6 +1185,40 @@ impl ReceiptChains { self } + /// Joining (§3 step 3): each chain the verifier holds no group of is anchored at the + /// first of its groups that passes steps 1 and 2 and the freshness bound, whatever + /// its parent. + pub fn with_join(mut self) -> ReceiptChains { + self.join = true; + self + } + + /// Holding (§3 step 3): a group that is `parent_not_held` is held, and admitted when + /// its parent is, or, inside a gap, when it links to either end; past `bound`, every + /// group held live for its chain is refused (`parent_not_recovered`) and the chain + /// re-anchors, and the lowest group held as history is refused. However large its + /// counts, no more than [`MAX_HELD_GROUPS`] groups are held in all. + pub fn with_hold(mut self, bound: HoldBound) -> ReceiptChains { + self.hold = Some(bound); + self + } + + /// A MoQ subscriber's verifier (moq-streaming-format §4.1): holding under `bound`, + /// whose counts — live, history and total — are each at most [`MAX_HELD_GROUPS`]; a + /// bound with a larger count is refused. A time alone is no bound here: `bound` + /// always states counts, and a statement that lacks one gives no `HoldBound` + /// ([`HoldBound::from_json`]). A subscriber that joins declares it with + /// [`ReceiptChains::with_join`], and admits each receipt with + /// [`ReceiptChains::admit_on`]. + pub fn for_moq(bound: HoldBound) -> Result { + for count in [bound.max_groups, bound.max_history, bound.max_total] { + if count > MAX_HELD_GROUPS { + return Err(BoundRefused { count }); + } + } + Ok(ReceiptChains::new().with_hold(bound)) + } + /// Set or clear the freshness bound: a verifier that resynchronises from history /// sets it once it reaches the live edge. pub fn set_freshness(&mut self, f: Option) { @@ -951,22 +1243,28 @@ impl ReceiptChains { } /// §3 steps 1–3 for `group` against the chain of its (`signer_pubkey`, - /// `track_alias`), the state unchanged (see the type's documentation for the order). + /// `track_alias`), the state unchanged (see the type's documentation for the order), + /// without recovery. pub fn check(&self, group: &GroupReceipt) -> Result<(), DeliveryError> { group.verify()?; - let link = group.link_hash(); + self.check_link(group, &group.link_hash()) + } + + /// Step 3 of [`ReceiptChains::check`], for a group whose signature verified. + fn check_link(&self, group: &GroupReceipt, link: &[u8; 32]) -> Result<(), DeliveryError> { let chain = self.chains.get(&(group.signer_pubkey, group.track_alias)); let head = chain.and_then(|c| c.head.as_ref()); - if head.is_some_and(|h| h.link_hash() == link) { + let head_link = chain.and_then(|c| c.head_link); + if head_link == Some(*link) { return Ok(()); } - match (head, group.parent_receipt_hash) { + match (head_link, group.parent_receipt_hash) { (None, None) => {} (None, Some(_)) => return Err(DeliveryError::at(3, Reason::ParentNotHeld)), (Some(_), None) => return Err(DeliveryError::at(3, Reason::ChainBreak)), - (Some(h), Some(p)) if p == h.link_hash() => {} + (Some(h), Some(p)) if p == h => {} (Some(_), Some(p)) => { - let fork = chain.is_some_and(|c| c.held.contains(&p)); + let fork = chain.is_some_and(|c| c.links.contains_key(&p)); return Err(DeliveryError::at(3, if fork { Reason::ChainBreak } else { Reason::ParentNotHeld })); } } @@ -987,26 +1285,307 @@ impl ReceiptChains { /// [`ReceiptChains::check`], then the group becomes the head of its chain. Whether /// or not it links, a group that verified and is inside the freshness window (or /// arrived with no bound applied) moves the chain's live edge up to its `group_id`. + /// With joining or holding on, this is [`ReceiptChains::admit`] at time 0. pub fn accept_group(&mut self, group: &GroupReceipt) -> Result<(), DeliveryError> { - let r = self.check(group); - let key = (group.signer_pubkey, group.track_alias); - let live = group.verify().is_ok() && self.freshness.is_none_or(|f| f.admits(&group.group_end)); - if r.is_ok() || live { - let c = self.chains.entry(key).or_default(); - if r.is_ok() { - c.hold(group.link_hash()); + self.admit(group, 0).map(|_| ()) + } + + /// The chains' anchors, in order, for (`signer_pubkey`, `track_alias`): each is + /// reported, and the groups it skips are never verified unless history fills them + /// (§3 step 3). + pub fn anchors(&self, signer_pubkey: &[u8; 32], track_alias: u64) -> &[Anchoring] { + self.chains.get(&(*signer_pubkey, track_alias)).map_or(&[], |c| &c.anchors) + } + + /// How many groups the chain of (`signer_pubkey`, `track_alias`) holds for a parent, + /// live and as history. + pub fn held(&self, signer_pubkey: &[u8; 32], track_alias: u64) -> usize { + self.chains.get(&(*signer_pubkey, track_alias)).map_or(0, |c| c.held.len()) + } + + /// How many groups are held for a parent across every chain. + pub fn held_total(&self) -> usize { + self.total_held + } + + /// How many group signatures [`ReceiptChains::admit`] has verified: one for each + /// group it did not already admit or hold. + pub fn verifications(&self) -> u64 { + self.verifications + } + + /// How many times a held group has been offered again: once each time a group it + /// links to is admitted, and never for a group admitted that none links to. + pub fn reoffers(&self) -> u64 { + self.reoffers + } + + /// What became, since the last call, of the groups held for a parent: admitted when + /// it arrived, or refused (`parent_not_recovered`, or another reason found on release). + pub fn take_events(&mut self) -> Vec { + std::mem::take(&mut self.events) + } + + /// Step 3 with joining, holding and history (§3 step 3), for a group whose signature + /// verified, the state unchanged: how it would be admitted now, or why not. + fn decide(&self, group: &GroupReceipt, link: &[u8; 32]) -> Result { + let chain = self.chains.get(&(group.signer_pubkey, group.track_alias)); + if chain.is_some_and(|c| c.head_link == Some(*link)) { + return Ok(Admission::Linked); + } + if chain.is_some_and(|c| c.fill(group, link).is_some()) { + return Ok(Admission::History); + } + let head = chain.and_then(|c| c.head.as_ref()); + let anchoring = chain.is_some_and(|c| c.reanchor) || (self.join && head.is_none()); + match self.check_link(group, link) { + Ok(()) => Ok(Admission::Linked), + Err(e) if anchoring && e.step == Some(3) => { + let behind_edge = chain.and_then(|c| c.edge).is_some_and(|edge| group.group_id < edge); + if let Some(f) = &self.freshness + && !behind_edge + && !f.admits(&group.group_end) + { + return Err(DeliveryError::at(3, Reason::GroupNotFresh)); + } + if head.is_some_and(|h| group.group_id <= h.group_id) { + return Err(DeliveryError::at(3, Reason::GroupIdNotIncreasing)); + } + Ok(Admission::Anchor) + } + Err(e) => Err(e), + } + } + + /// Admit `group` to its chain as it is decided. + fn install(&mut self, group: &GroupReceipt, link: [u8; 32], how: Admission) { + let c = self.chains.entry((group.signer_pubkey, group.track_alias)).or_default(); + c.remember(link, group.parent_receipt_hash); + match how { + Admission::History => { + let Some((i, forward, backward)) = c.fill(group, &link) else { return }; + if forward && backward { + // The two ends meet: the gap is filled. + c.gaps.remove(i); + } else if forward { + c.gaps[i].low = Some((link, group.group_id)); + } else { + c.gaps[i].high_id = group.group_id; + c.gaps[i].high_parent = signed_parent(group); + if c.gaps[i].low.is_none() && c.gaps[i].high_parent.is_none() { + // Nothing can fill it from either end. + c.gaps.remove(i); + } + } + } + Admission::Anchor => { + let low = c.head.as_ref().zip(c.head_link).map(|(h, l)| (l, h.group_id)); + c.anchors.push(Anchoring { anchor: group.group_id, joined: c.head.is_none(), after: low.map(|(_, id)| id) }); + let gap = Gap { low, high_id: group.group_id, high_parent: signed_parent(group) }; + if gap.low.is_some() || gap.high_parent.is_some() { + c.gaps.push_back(gap); + while c.gaps.len() > CHAIN_OPEN_GAPS { + c.gaps.pop_front(); + } + } c.head = Some(group.clone()); + c.head_link = Some(link); + c.reanchor = false; } - if live { - c.edge = Some(c.edge.map_or(group.group_id, |e| e.max(group.group_id))); + Admission::Linked => { + c.head = Some(group.clone()); + c.head_link = Some(link); + c.reanchor = false; } + Admission::AlreadyAdmitted => {} + } + } + + /// Hold `group`, verified and `parent_not_held`, under `bound`: as history if its + /// `group_id` lies inside an open gap of its chain, live otherwise. Its outcome now: + /// `parent_not_held` while it is held. Holding it as history past the bound refuses + /// the lowest group held as history below it, or, where there is none, refuses it + /// (`parent_not_recovered`). Holding it live past the bound refuses every group its + /// chain holds live and decides it again, the chain re-anchoring. + fn hold_group(&mut self, group: &GroupReceipt, link: [u8; 32], now_ms: u64, bound: HoldBound) -> Result { + let key = (group.signer_pubkey, group.track_alias); + let held = Err(DeliveryError::at(3, Reason::ParentNotHeld)); + let total_cap = bound.max_total.min(MAX_HELD_GROUPS); + let c = self.chains.entry(key).or_default(); + if c.held.contains_key(&link) { + return held; + } + let history = c.gaps.iter().any(|g| g.spans(group.group_id)); + if history { + if c.history.len() >= bound.max_history || self.total_held >= total_cap { + match c.history.first().copied() { + Some((id, _, lowest)) if id < group.group_id => { + self.refuse_held(key, &lowest); + } + _ => return Err(DeliveryError::at(3, Reason::ParentNotRecovered)), + } + } + } else if c.live_held() >= bound.max_groups || self.total_held >= total_cap { + self.give_up(key); + return self.decide(group, &link); + } + self.arrivals += 1; + let h = Held { group: group.clone(), at_ms: now_ms, seq: self.arrivals, history }; + self.chains.entry(key).or_default().put(link, h); + self.total_held += 1; + held + } + + /// Refuse one held group (`parent_not_recovered`). + fn refuse_held(&mut self, key: ([u8; 32], u64), link: &[u8; 32]) { + if let Some(h) = self.chains.get_mut(&key).and_then(|c| c.take(link)) { + self.total_held -= 1; + self.events.push(ChainEvent { + signer_pubkey: key.0, + track_alias: key.1, + group_id: h.group.group_id, + link: *link, + outcome: Err(DeliveryError::at(3, Reason::ParentNotRecovered)), + }); + } + } + + /// After `first` was admitted, offer again the held groups it may have released, and + /// those that each of them releases in turn, in the order they are released: those + /// whose parent is an admitted group's link hash, and the one whose link hash an + /// admitted group names as its parent; among those of one admitted group, by + /// `group_id`, then arrival. Nothing else is offered again: a held group is decided + /// again only when a group it links to is admitted. + fn release(&mut self, first: &GroupReceipt, first_link: [u8; 32]) { + let key = (first.signer_pubkey, first.track_alias); + let mut work = std::collections::VecDeque::from([(first.parent_receipt_hash, first_link)]); + while let Some((parent, link)) = work.pop_front() { + let Some(c) = self.chains.get(&key) else { return }; + let mut next: Vec<(u64, u64, [u8; 32])> = c + .by_parent + .get(&link) + .into_iter() + .flatten() + .filter_map(|l| c.held.get(l).map(|h| (h.group.group_id, h.seq, *l))) + .collect(); + if let Some(h) = parent.and_then(|p| c.held.get(&p).map(|h| (h.group.group_id, h.seq, p))) { + next.push(h); + } + next.sort_unstable(); + next.dedup(); + for (_, _, l) in next { + let Some(h) = self.chains.get_mut(&key).and_then(|c| c.take(&l)) else { continue }; + self.total_held -= 1; + self.reoffers += 1; + match self.decide(&h.group, &l) { + Err(e) if e.reason == Reason::ParentNotHeld => { + self.chains.entry(key).or_default().put(l, h); + self.total_held += 1; + } + r => { + if let Ok(how) = r { + self.install(&h.group, l, how); + work.push_back((h.group.parent_receipt_hash, l)); + } + self.events.push(ChainEvent { signer_pubkey: key.0, track_alias: key.1, group_id: h.group.group_id, link: l, outcome: r }); + } + } + } + } + } + + /// Refuse every group held live for the chain of `key` (`parent_not_recovered`), and + /// have the chain re-anchor. What it holds as history it keeps. + fn give_up(&mut self, key: ([u8; 32], u64)) { + let Some(c) = self.chains.get_mut(&key) else { return }; + c.reanchor = true; + let mut live: Vec<(u64, u64, [u8; 32])> = c.held.iter().filter(|(_, h)| !h.history).map(|(l, h)| (h.group.group_id, h.seq, *l)).collect(); + live.sort_unstable(); + for (_, _, l) in live { + self.refuse_held(key, &l); + } + } + + /// §3 step 3 for `group`, arriving at `now_ms` on the verifier's clock: how it is + /// admitted, or why not. Its signature is verified unless its chain has admitted or + /// holds a group of the same link hash, which covers the signed bytes and the + /// signature. With holding on, a group whose parent the chain lacks is held (and + /// refused here `parent_not_held`; see [`ReceiptChains::with_hold`] for what passing + /// the bound does). A group admitted releases the groups held behind it + /// ([`ReceiptChains::take_events`]). The head offered again, and any group the chain + /// admitted before offered again with the same parent ([`Admission::AlreadyAdmitted`]), + /// change nothing. + pub fn admit(&mut self, group: &GroupReceipt, now_ms: u64) -> Result { + let key = (group.signer_pubkey, group.track_alias); + let link = group.link_hash(); + let known = match self.chains.get(&key) { + Some(c) if c.head_link == Some(link) => return Ok(Admission::Linked), + Some(c) if c.links.get(&link) == Some(&group.parent_receipt_hash) => return Ok(Admission::AlreadyAdmitted), + Some(c) => c.links.contains_key(&link) || c.held.contains_key(&link), + None => false, + }; + if !known { + self.verifications += 1; + group.verify()?; + } + let fresh = self.freshness.is_none_or(|f| f.admits(&group.group_end)); + let mut r = self.decide(group, &link); + if let (Err(e), Some(bound)) = (&r, self.hold) + && e.reason == Reason::ParentNotHeld + { + r = self.hold_group(group, link, now_ms, bound); + } + if let Ok(how) = r { + self.install(group, link, how); + } + if fresh { + let c = self.chains.entry(key).or_default(); + c.edge = Some(c.edge.map_or(group.group_id, |e| e.max(group.group_id))); + } + if r.is_ok() { + self.release(group, link); } r } - /// One object: its group through [`ReceiptChains::accept_group`], then - /// [`ObjectReceipt::verify_against`], over `envelope` where it is given. A group - /// that passes becomes its chain's head even when the object then fails. + /// [`ReceiptChains::admit`] for a group receipt carried on `track` as the object of + /// MOQT group `moqt_group_id` (moq-streaming-format §4.1). One whose key or + /// `track_alias` is not the track's, or whose `group_id` is not that group's, is + /// refused (`moqt_mismatch`) before its signature is verified, and nothing of it is + /// held. + pub fn admit_on(&mut self, track: &ReceiptsTrack, moqt_group_id: u64, group: &GroupReceipt, now_ms: u64) -> Result { + if group.signer_pubkey != track.signer_pubkey || group.track_alias != track.track_alias || group.group_id != moqt_group_id { + return Err(DeliveryError { step: None, reason: Reason::MoqtMismatch }); + } + self.admit(group, now_ms) + } + + /// With a time in the hold bound, every group held more than `max_age_ms` before + /// `now_ms` is refused (`parent_not_recovered`): a chain holding such a group live + /// refuses all it holds live and re-anchors at its next group; one held as history is + /// refused alone. + pub fn expire(&mut self, now_ms: u64) { + let Some(age) = self.hold.and_then(|b| b.max_age_ms) else { return }; + let keys: Vec<([u8; 32], u64)> = self.chains.keys().copied().collect(); + for k in keys { + let Some(c) = self.chains.get(&k) else { continue }; + let stale = |h: &Held| now_ms.saturating_sub(h.at_ms) > age; + let live_stale = c.held.values().any(|h| !h.history && stale(h)); + let history: Vec<[u8; 32]> = c.history.iter().filter(|(_, _, l)| c.held.get(l).is_some_and(stale)).map(|(_, _, l)| *l).collect(); + if live_stale { + self.give_up(k); + } + for l in history { + self.refuse_held(k, &l); + } + } + } + + /// One object: its group through [`ReceiptChains::accept_group`], then §3 steps 4 + /// and 5 ([`ObjectReceipt::verify_against`]), over `envelope` where it is given. The + /// group's signature was verified when its chain admitted it, and is not verified + /// again. A group that passes becomes its chain's head even when the object then + /// fails. pub fn verify_object( &mut self, group: &GroupReceipt, @@ -1014,7 +1593,7 @@ impl ReceiptChains { envelope: Option<&[u8]>, ) -> Result { self.accept_group(group)?; - object.verify_against(group, envelope) + object.evidence_under(group, envelope) } } diff --git a/wai/wai-rs/src/jwp_delivery/corpus.rs b/wai/wai-rs/src/jwp_delivery/corpus.rs index b1860f0de..e3f041c1a 100644 --- a/wai/wai-rs/src/jwp_delivery/corpus.rs +++ b/wai/wai-rs/src/jwp_delivery/corpus.rs @@ -505,9 +505,118 @@ fn vectors() -> Vec { // `group_chain_break`'s earlier input, whose outcome §3 step 3 now gives otherwise. out.push(v("group_parent_not_the_previous", "the parent named is not the previous group held, and no other group is held: requested, never accepted", "group", json!({ "group": gl_json, "previous": labelled_other_previous() }), reject(Some(3), Reason::ParentNotHeld))); + // Last again, so every vector before them keeps its place: holding, re-anchoring + // and joining (§3 step 3). + out.extend(recovery_vectors()); out } +/// `n` chained groups of `track` in the labelled payload from group 0, envelopes from +/// `first`. +fn labelled_chain(track: u64, n: u8, first: u8) -> Vec { + let mut out: Vec = Vec::new(); + for i in 0..n { + let parent = out.last().map(GroupReceipt::link_hash); + out.push(on_track(0x11, track, u64::from(i), first + i, parent, T1)); + } + out +} + +/// A chain to a verifier that recovers: joining or not, and holding under `hold` — the +/// live count, the history count and the total — or not at all. +fn recovering(groups: &[&GroupReceipt], join: bool, hold: Option<(u64, u64, u64)>, freshness: Option) -> Value { + let mut c = chain(groups, false, freshness); + c["recovery"] = match hold { + Some((live, history, total)) => json!({ "join": join, "max_held": live, "max_history": history, "max_total": total }), + None => json!({ "join": join, "max_held": null }), + }; + c +} + +/// Holding, re-anchoring and joining (§3 step 3): a lost receipt-track object, a parent +/// fetched late, a subscriber that joins mid-stream, history behind a labelled anchor. +/// Each group's outcome is its last: a held group's is what became of it. +fn recovery_vectors() -> Vec { + use std::iter::once; + let ok = || json!({ "outcome": "accept" }); + let anchor = || json!({ "outcome": "accept", "admission": "anchor" }); + let history = || json!({ "outcome": "accept", "admission": "history" }); + let again = || json!({ "outcome": "accept", "admission": "again" }); + let p3 = |r: Reason| reject(Some(3), r); + let c = labelled_chain(9, 10, 200); + let g = |i: usize| &c[i]; + let lost3: Vec<&GroupReceipt> = [0, 1, 2, 4, 5, 6, 7, 8, 9].iter().map(|&i| g(i)).collect(); + let legacy: Vec = { + let mut out: Vec = Vec::new(); + for i in 0..3u8 { + let parent = out.last().map(GroupReceipt::link_hash); + out.push(legacy_on_track(7, u64::from(i), 230 + i, parent)); + } + out + }; + let stale = on_track(0x11, 9, 2, 202, Some(g(1).link_hash()), "2026-09-30T11:00:00Z"); + let stranger = on_track(0x11, 9, 0, 240, None, T1); + let behind = on_track(0x11, 9, 1, 241, Some(stranger.link_hash()), T1); + let ahead = on_track(0x11, 9, 7, 242, Some(stranger.link_hash()), T1); + let mut regained = lost3.clone(); + regained.extend([g(5), g(4), g(3)]); + let reanchor_fetch: Vec<&GroupReceipt> = [0, 1, 2, 4, 5, 6, 7, 8, 3, 4, 5, 6, 7].iter().map(|&i| g(i)).collect(); + let fill = labelled_chain(10, 22, 100); + let join_fill: Vec<&GroupReceipt> = once(20).chain(10..20).chain(once(21)).map(|i| &fill[i]).collect(); + let (ta, tb) = (labelled_chain(11, 6, 130), labelled_chain(12, 6, 140)); + let dup = labelled_chain(13, 8, 150); + let after_head_moved: Vec<&GroupReceipt> = (0..7).chain(once(3)).map(|i| &dup[i]).collect(); + let anchor_again: Vec<&GroupReceipt> = [5, 6, 7, 5, 4].iter().map(|&i| &dup[i]).collect(); + let two_tracks: Vec<&GroupReceipt> = vec![&ta[0], &tb[0], &ta[2], &ta[3], &tb[2], &ta[4], &tb[3], &tb[1]]; + vec![ + v("chain_hold_fetched_parent", "holding, a bound of 4: group 3 arrives after 4 and 5 (fetched, or reordered on the receipts track); 4 and 5 are held, and admitted when 3 links", "chain", + recovering(&[g(0), g(1), g(2), g(4), g(5), g(3), g(6)], false, Some((4, 16, 64)), None), results(vec![ok(), ok(), ok(), ok(), ok(), ok(), ok()])), + v("chain_hold_released_in_order", "holding: 3 and 2 held, then 1 links and releases 2, which releases 3", "chain", + recovering(&[g(0), g(3), g(2), g(1)], false, Some((5, 16, 64)), None), results(vec![ok(), ok(), ok(), ok()])), + v("chain_lost_receipt_without_recovery", "group 3's receipt lost and nothing held: every later group of the track names a parent the chain lacks, and the track stalls", "chain", + chain(&lost3, false, None), results(vec![ok(), ok(), ok(), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld)])), + v("chain_lost_receipt_reanchors", "group 3's receipt lost, a bound of 2 held groups: 4 and 5 are held; holding 6 would pass the bound, so 4 and 5 are refused (parent_not_recovered) and the chain re-anchors at 6, and the track resumes", "chain", + recovering(&lost3, false, Some((2, 16, 64)), None), + results(vec![ok(), ok(), ok(), p3(Reason::ParentNotRecovered), p3(Reason::ParentNotRecovered), anchor(), ok(), ok(), ok()])), + v("chain_reanchored_history_regained", "the same, then 5, 4 and 3 offered again (fetched): each is named by the earliest group the chain holds, and is history behind the labelled anchor", "chain", + recovering(®ained, false, Some((2, 16, 64)), None), + results(vec![ok(), ok(), ok(), p3(Reason::ParentNotRecovered), p3(Reason::ParentNotRecovered), anchor(), ok(), ok(), ok(), history(), history(), history()])), + v("chain_reanchor_moves_forward", "no group held (a bound of 0): a group of id 1 whose parent the chain lacks would re-anchor the chain behind its head, and is refused; a later group of id 7 anchors it", "chain", + recovering(&[g(0), g(1), g(2), &behind, &ahead], false, Some((0, 16, 64)), None), + results(vec![ok(), ok(), ok(), p3(Reason::GroupIdNotIncreasing), anchor()])), + v("chain_join_mid_stream", "a verifier that joins at group 2: anchored there, and every later group links", "chain", + recovering(&[g(2), g(3), g(4), g(5)], true, None, None), results(vec![anchor(), ok(), ok(), ok()])), + v("chain_join_without_joining", "the same groups to a verifier that does not join: nothing is admitted", "chain", + chain(&[g(2), g(3), g(4), g(5)], false, None), + results(vec![p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld), p3(Reason::ParentNotHeld)])), + v("chain_join_history_fetched", "a verifier that joins at group 3, then receives 2, 1 and 0 (joining at an earlier group): each is named by the earliest group held, and is history behind the labelled anchor", "chain", + recovering(&[g(3), g(4), g(2), g(1), g(0)], true, None, None), results(vec![anchor(), ok(), history(), history(), history()])), + v("chain_join_from_the_first_group", "a verifier that joins at the chain's first group: a null parent is the chain's start, and no anchor", "chain", + recovering(&[g(0), g(1)], true, None, None), results(vec![ok(), ok()])), + v("chain_join_legacy_no_history", "a verifier that joins a legacy chain at group 1: anchored; group 0, behind it, cannot be history, since a legacy parent is unsigned, and is a second chain head", "chain", + recovering(&[&legacy[1], &legacy[2], &legacy[0]], true, None, None), results(vec![anchor(), ok(), p3(Reason::ChainBreak)])), + v("chain_join_stale_anchor", "a verifier that joins with the freshness bound on: a stale group does not anchor; the next fresh group does", "chain", + recovering(&[&stale, g(3)], true, None, Some(json!({ "now": T1, "max_age_s": 60, "max_skew_s": 5 }))), + results(vec![p3(Reason::GroupNotFresh), anchor()])), + v("chain_fetch_after_reanchor_ascending", "group 3's receipt withheld, a live bound of 4: 4 to 7 are held, 8 passes the bound, they are refused and the chain re-anchors at 8; then the gap is fetched in ascending order, as MOQT delivers a FETCH: 3 links forward from 2, the last group admitted before the gap, and each of 4 to 7 links forward from the one before, 7 also to 8's signed parent: all history, none a fork", "chain", + recovering(&reanchor_fetch, false, Some((4, 8, 64)), None), + results(vec![ok(), ok(), ok(), p3(Reason::ParentNotRecovered), p3(Reason::ParentNotRecovered), p3(Reason::ParentNotRecovered), p3(Reason::ParentNotRecovered), anchor(), history(), history(), history(), history(), history()])), + v("chain_join_ascending_fill", "a verifier that joins at group 20 with a live bound of 4 and a history bound of 16, then receives 10 to 19 in ascending order (a fill fetch stream, MOQT §3.4), then 21: 10 to 18 are held as history, outside the live bound; 19 links back from the anchor and releases 18, which releases 17, down to 10: all history", "chain", + recovering(&join_fill, true, Some((4, 16, 64)), None), + results(once(anchor()).chain((10..20).map(|_| history())).chain(once(ok())).collect())), + v("chain_join_fill_past_history_bound", "the same with a history bound of 4: holding 14 passes it, and the lowest held below it, 10, is refused; so on up to 18; 19 then releases 18 to 15, the groups nearest the anchor", "chain", + recovering(&join_fill, true, Some((4, 4, 64)), None), + results(once(anchor()).chain((10..15).map(|_| p3(Reason::ParentNotRecovered))).chain((15..20).map(|_| history())).chain(once(ok())).collect())), + v("chain_total_bound_across_chains", "two tracks, a live bound of 4 on each and 3 held in all: A2, A3 and B2 are held; holding A4 would pass the total, so A's held groups are refused and A re-anchors at A4; B3 is held, and B1 releases B2 and B3", "chain", + recovering(&two_tracks, false, Some((4, 4, 3)), None), + results(vec![ok(), ok(), p3(Reason::ParentNotRecovered), p3(Reason::ParentNotRecovered), ok(), anchor(), ok(), ok()])), + v("chain_duplicate_after_the_head_moved", "group 3 offered again, the same bytes, after the head moved on to 6, as a subscription and a fill can deliver one object twice (MOQT §3.4): a group the chain admitted before, which changes nothing", "chain", + recovering(&after_head_moved, false, Some((2, 16, 64)), None), results(vec![ok(), ok(), ok(), ok(), ok(), ok(), ok(), again()])), + v("chain_join_anchor_delivered_again", "a joiner anchored at 5, then 6 and 7, then 5 again (its fill delivering the anchor's group a second time) and 4: the anchor again changes nothing, and 4 is history", "chain", + recovering(&anchor_again, true, Some((2, 16, 64)), None), results(vec![anchor(), ok(), ok(), again(), history()])), + ] +} + /// A group of one object on `track`, as a live staged track carries them (one object /// per group), signed by key `signer` and chained to `parent`; envelope `n`. Track 8's /// groups are in the labelled payload, which signs the parent link; track 7's in the @@ -641,8 +750,8 @@ fn chain_vectors() -> Vec { chain(&[b0, b1, l1], false, None), results(vec![ok(), ok(), p3(Reason::ParentNotHeld)])), v("v6_per_track_chain_cross_track_link", "a layer group whose parent is a base group: a link across tracks is no link, and names nothing the layer's chain holds", "chain", chain(&[b0, b1, l0, &cross], false, None), results(vec![ok(), ok(), ok(), p3(Reason::ParentNotHeld)])), - v("chain_head_predecessor_replayed", "the base track's first group offered again after its second: a chain head beside a held head is a fork", "chain", - chain(&[b0, b1, b0], false, None), results(vec![ok(), ok(), p3(Reason::ChainBreak)])), + v("chain_head_predecessor_replayed", "the base track's first group offered again after its second, the same bytes: a group the chain admitted before, which changes nothing (accept again), never a second chain head", "chain", + chain(&[b0, b1, b0], false, None), results(vec![ok(), ok(), json!({ "outcome": "accept", "admission": "again" })])), v("chain_fork_from_a_held_group", "a group whose parent is a group the chain holds that is not its head: a fork", "chain", chain(&[b0, b1, b2, &fork], false, None), results(vec![ok(), ok(), ok(), p3(Reason::ChainBreak)])), v("chain_parent_not_held_beside_head", "a group whose parent is no group the chain holds: a gap, requested, not a fork", "chain", @@ -661,9 +770,9 @@ fn chain_vectors() -> Vec { chain(&[b0, b1, b2, l0], true, None), results(vec![ok(), ok(), ok(), ok()])), v("chain_legacy_replayed_as_new", "a legacy chain g0, g1, g2; then g0 with its unsigned parent rewritten to g2's link hash, which leaves its link hash unchanged; then the publisher's genuine g3. The replay is refused by its signed group_id, guard off, and g3 still links", "chain", chain(&[g0, g1, g2, &g0_again, &g3], false, None), results(vec![ok(), ok(), ok(), p3(Reason::GroupIdNotIncreasing), ok()])), - v("chain_legacy_replay_loop", "the same rewritten g0, then the whole history replayed after it, twice: no replayed group advances the chain; g1 names a group the chain holds behind its head, and g2 is the head offered again", "chain", + v("chain_legacy_replay_loop", "the same rewritten g0, then the whole history replayed after it, twice: no replayed group advances the chain; the rewritten g0 is refused, g1 is a group the chain admitted before with the same parent and changes nothing (accept again), and g2 is the head offered again", "chain", chain(&[g0, g1, g2, &g0_again, g1, g2, &g0_again, g1], false, None), - results(vec![ok(), ok(), ok(), p3(Reason::GroupIdNotIncreasing), p3(Reason::ChainBreak), ok(), p3(Reason::GroupIdNotIncreasing), p3(Reason::ChainBreak)])), + results(vec![ok(), ok(), ok(), p3(Reason::GroupIdNotIncreasing), json!({ "outcome": "accept", "admission": "again" }), ok(), p3(Reason::GroupIdNotIncreasing), json!({ "outcome": "accept", "admission": "again" })])), v("chain_labelled_replay_rewritten", "the labelled chain's first group with its parent rewritten to the head's link: the labelled payload signs the link, so the replay fails its signature", "chain", chain(&[b0, b1, b2, &b0_again, &b3], false, None), results(vec![ok(), ok(), ok(), reject(Some(2), Reason::SignatureVerifyFailed), ok()])), v("chain_issued_per_key_across_tracks", "a key that chained its groups per key across tracks 7 and 8, as an earlier reading of section 2.4.1 allowed: each group that names another track's group names nothing its own chain holds", "chain", @@ -889,14 +998,60 @@ pub fn verdict(v: &Value) -> Result { }); } } - let mut out = Vec::new(); - for g in i.get("groups").and_then(Value::as_array).ok_or("no groups")? { - out.push(match GroupReceipt::from_json(g).and_then(|g| chains.accept_group(&g)) { - Ok(()) => "accept".to_owned(), - Err(e) => err_label(&e), - }); + let mut holding = false; + match i.get("recovery") { + None | Some(Value::Null) => {} + Some(r) => { + if r.get("join").and_then(Value::as_bool).ok_or("recovery.join")? { + chains = chains.with_join(); + } + match r.get("max_held") { + None | Some(Value::Null) => {} + Some(m) => { + // Holding states every count: live, history and total. + let count = |v: Option<&Value>, k: &str| { + v.and_then(Value::as_u64).and_then(|n| usize::try_from(n).ok()).ok_or(format!("recovery.{k}")) + }; + chains = chains.with_hold(HoldBound { + max_groups: count(Some(m), "max_held")?, + max_history: count(r.get("max_history"), "max_history")?, + max_total: count(r.get("max_total"), "max_total")?, + max_age_ms: None, + }); + holding = true; + } + } + } } - Ok(format!("chain: {}", out.join(" / "))) + // Each group's outcome is its last: a group held for its parent takes what later + // became of it, or stays parent_not_held. + let label = |r: &Result| match r { + Ok(a) => a.label().to_owned(), + Err(e) => err_label(e), + }; + let mut out: Vec> = Vec::new(); + let mut waiting: std::collections::BTreeMap<[u8; 32], Vec> = std::collections::BTreeMap::new(); + for g in i.get("groups").and_then(Value::as_array).ok_or("no groups")? { + match GroupReceipt::from_json(g) { + Err(e) => out.push(Some(err_label(&e))), + Ok(g) => { + let r = chains.admit(&g, 0); + if holding && matches!(&r, Err(e) if e.reason == Reason::ParentNotHeld) { + waiting.entry(g.link_hash()).or_default().push(out.len()); + out.push(None); + } else { + out.push(Some(label(&r))); + } + } + } + for ev in chains.take_events() { + for w in waiting.remove(&ev.link).unwrap_or_default() { + out[w] = Some(label(&ev.outcome)); + } + } + } + let held = err_label(&DeliveryError::at(3, Reason::ParentNotHeld)); + Ok(format!("chain: {}", out.into_iter().map(|o| o.unwrap_or_else(|| held.clone())).collect::>().join(" / "))) } Some("inclusion") => { let g = match group("group")? { @@ -936,6 +1091,7 @@ fn one(e: &Value) -> Result { Ok(format!("chain: {}", rs.iter().map(one).collect::, _>>()?.join(" / "))) } "accept" if e.get("evidence").is_some() => Ok(format!("accept evidence={}", s("evidence")?)), + "accept" if e.get("admission").is_some() => Ok(format!("accept {}", s("admission")?)), "accept" => match (e.get("link_hash"), e.get("signing_payload")) { (Some(l), Some(p)) => Ok(format!( "accept link_hash={} signing_payload={}", diff --git a/wai/wai-rs/src/jwp_delivery/tests.rs b/wai/wai-rs/src/jwp_delivery/tests.rs index b8d210102..7ce505609 100644 --- a/wai/wai-rs/src/jwp_delivery/tests.rs +++ b/wai/wai-rs/src/jwp_delivery/tests.rs @@ -679,7 +679,7 @@ fn a_legacy_history_cannot_be_replayed_as_new() { v.accept_group(x).unwrap(); } assert_eq!(v.accept_group(&replay), Err(DeliveryError::at(3, Reason::GroupIdNotIncreasing))); - assert_eq!(v.accept_group(&g[1]), Err(DeliveryError::at(3, Reason::ChainBreak)), "g1 names a group behind the head"); + assert_eq!(v.admit(&g[1], 0), Ok(Admission::AlreadyAdmitted), "g1 again, the same bytes: a group admitted before, which changes nothing"); assert_eq!(v.accept_group(&g[2]), Ok(()), "the head offered again"); assert_eq!(v.head(&g[2].signer_pubkey, 7), Some(&g[2])); assert_eq!(v.accept_group(&next), Ok(())); @@ -836,3 +836,293 @@ fn the_freshness_bound_is_opt_in_and_reads_offsets() { assert_eq!(ReceiptChains::new().check(&stale), Ok(())); assert_eq!(ReceiptChains::new().with_freshness(f).check(&stale), Err(DeliveryError::at(3, Reason::GroupNotFresh))); } + +/// `n` chained groups of track 8 in the labelled payload. +fn labelled_run(n: u8) -> Vec { + let mut out: Vec = Vec::new(); + for i in 0..n { + let parent = out.last().map(GroupReceipt::link_hash); + out.push(on_track_labelled(0x11, 8, u64::from(i), 200 + i, parent, T1)); + } + out +} + +/// P-W1, one receipts-track object lost (§3 step 3, Holding and Re-anchoring). Without +/// holding, every later group of the track waits for a parent that never comes. With a +/// count bound of 2, groups 4 and 5 are held; 6's arrival passes the bound, so they are +/// refused (`parent_not_recovered`) and the chain re-anchors at 6, which is reported with +/// the group before the gap; the track resumes. With a time bound, the same happens when +/// the held groups age past it. +#[test] +fn a_lost_receipt_stalls_unless_the_chain_re_anchors() { + let c = labelled_run(10); + let key = c[0].signer_pubkey; + let order: Vec<&GroupReceipt> = [0, 1, 2, 4, 5, 6, 7, 8, 9].iter().map(|&i| &c[i]).collect(); + let mut stalls = ReceiptChains::new(); + let got: Vec = order.iter().map(|g| stalls.accept_group(g).is_ok()).collect(); + assert_eq!(got, [true, true, true, false, false, false, false, false, false], "without recovery the track stalls"); + + let mut count = ReceiptChains::new().with_hold(HoldBound { max_groups: 2, max_history: 16, max_total: 64, max_age_ms: None }); + let got: Vec> = order.iter().map(|g| count.admit(g, 0).map_err(|e| e.reason)).collect(); + use Admission::*; + assert_eq!(got, [Ok(Linked), Ok(Linked), Ok(Linked), Err(Reason::ParentNotHeld), Err(Reason::ParentNotHeld), Ok(Anchor), Ok(Linked), Ok(Linked), Ok(Linked)]); + let refused: Vec<(u64, Result)> = count.take_events().into_iter().map(|e| (e.group_id, e.outcome.map_err(|x| x.reason))).collect(); + assert_eq!(refused, [(4, Err(Reason::ParentNotRecovered)), (5, Err(Reason::ParentNotRecovered))]); + assert_eq!(count.anchors(&key, 8), &[Anchoring { anchor: 6, joined: false, after: Some(2) }], "the anchor and the gap are reported"); + assert_eq!(count.head(&key, 8).map(|h| h.group_id), Some(9), "the track resumed"); + // The lost parent, fetched late, and the held groups offered again: history behind the + // labelled anchor, back to the group before the gap. + for i in [5, 4, 3] { + assert_eq!(count.admit(&c[i], 0), Ok(History), "group {i}"); + } + + let mut timed = ReceiptChains::new().with_hold(HoldBound { max_groups: 100, max_history: 16, max_total: 256, max_age_ms: Some(50) }); + for (g, at) in [(&c[0], 0), (&c[1], 5), (&c[2], 8)] { + assert_eq!(timed.admit(g, at), Ok(Linked)); + } + assert_eq!(timed.admit(&c[4], 10).map_err(|e| e.reason), Err(Reason::ParentNotHeld)); + assert_eq!(timed.admit(&c[5], 20).map_err(|e| e.reason), Err(Reason::ParentNotHeld)); + timed.expire(60); + assert_eq!(timed.held(&key, 8), 2, "within the bound at 60 (4 arrived at 10)"); + timed.expire(61); + assert_eq!(timed.held(&key, 8), 0); + assert_eq!(timed.take_events().len(), 2, "both refused, parent_not_recovered"); + assert_eq!(timed.admit(&c[6], 70), Ok(Anchor), "and the chain re-anchors at the next group"); + assert_eq!(timed.admit(&c[7], 75), Ok(Linked)); + + // A parent that does arrive within the bound releases what waits on it. + let mut fetched = ReceiptChains::new().with_hold(HoldBound { max_groups: 4, max_history: 16, max_total: 64, max_age_ms: None }); + for i in [0, 1, 2, 4, 5] { + let _ = fetched.admit(&c[i], 0); + } + assert_eq!(fetched.admit(&c[3], 0), Ok(Linked)); + let released: Vec<(u64, Result)> = fetched.take_events().into_iter().map(|e| (e.group_id, e.outcome.map_err(|x| x.reason))).collect(); + assert_eq!(released, [(4, Ok(Linked)), (5, Ok(Linked))]); + assert!(fetched.anchors(&key, 8).is_empty(), "nothing was skipped"); +} + +/// P-W1, a subscriber that joins mid-stream (§3 step 3, Joining): without joining it +/// admits nothing; joining, it anchors at its first group, reports the anchor, and +/// admits what follows and, behind a labelled anchor, what links back to it. +#[test] +fn a_late_joiner_admits_from_its_anchor() { + let c = labelled_run(6); + let key = c[0].signer_pubkey; + let mut plain = ReceiptChains::new(); + assert!(c[2..].iter().all(|g| plain.accept_group(g).map_err(|e| e.reason) == Err(Reason::ParentNotHeld))); + let mut joined = ReceiptChains::new().with_join(); + assert_eq!(joined.admit(&c[2], 0), Ok(Admission::Anchor)); + for g in &c[3..] { + assert_eq!(joined.admit(g, 0), Ok(Admission::Linked)); + } + assert_eq!(joined.anchors(&key, 8), &[Anchoring { anchor: 2, joined: true, after: None }]); + assert_eq!(joined.admit(&c[1], 0), Ok(Admission::History)); + assert_eq!(joined.admit(&c[0], 0), Ok(Admission::History)); + // A joiner given the chain's first group starts it as anyone does. + let mut from_start = ReceiptChains::new().with_join(); + assert_eq!(from_start.admit(&c[0], 0), Ok(Admission::Linked)); + assert!(from_start.anchors(&key, 8).is_empty()); +} + +/// `n` chained groups of `track` in the labelled payload, signed by key `signer`, objects +/// numbered from `first`. +fn run_of(signer: u8, track: u64, n: usize, first: u8) -> Vec { + let mut out: Vec = Vec::new(); + for i in 0..n { + let parent = out.last().map(GroupReceipt::link_hash); + out.push(on_track_labelled(signer, track, i as u64, first.wrapping_add(i as u8), parent, T1)); + } + out +} + +fn bound(live: usize, history: usize, total: usize) -> HoldBound { + HoldBound { max_groups: live, max_history: history, max_total: total, max_age_ms: None } +} + +/// NEW 1: a gap fills in the order MOQT delivers it. After a re-anchor, a FETCH of the gap +/// arrives in ascending order: each group links forward from the last group admitted +/// before the gap, and is history, never a fork. A joiner's fill arrives ascending too: +/// held as history, outside the live bound, and released back from the anchor. Once the +/// two ends meet, the gap is closed: another group naming the group before it is a fork. +#[test] +fn a_gap_fills_in_the_order_moqt_delivers_it() { + use Admission::*; + let c = run_of(0x11, 8, 12, 60); + let key = c[0].signer_pubkey; + let mut v = ReceiptChains::new().with_hold(bound(4, 8, 64)); + for i in [0, 1, 2] { + assert_eq!(v.admit(&c[i], 0), Ok(Linked)); + } + for i in [4, 5, 6, 7] { + assert_eq!(v.admit(&c[i], 0).map_err(|e| e.reason), Err(Reason::ParentNotHeld)); + } + assert_eq!(v.admit(&c[8], 0), Ok(Anchor), "8 passes the live bound of 4"); + assert_eq!(v.take_events().len(), 4, "4 to 7 refused, parent_not_recovered"); + for i in [3, 4, 5, 6, 7] { + assert_eq!(v.admit(&c[i], 0), Ok(History), "group {i}, fetched in ascending order"); + } + assert_eq!(v.held(&key, 8), 0); + let fork = on_track_labelled(0x11, 8, 3, 99, Some(c[2].link_hash()), T1); + assert_eq!(v.admit(&fork, 0).map_err(|e| e.reason), Err(Reason::ChainBreak), "the gap is closed: a second child of 2 is a fork"); + assert_eq!(v.admit(&c[9], 0), Ok(Linked)); + + // A joiner at 20, then the fill 10..19 ascending, under a live bound of 4. + let f = run_of(0x11, 9, 22, 120); + let mut j = ReceiptChains::new().with_join().with_hold(bound(4, 16, 64)); + assert_eq!(j.admit(&f[20], 0), Ok(Anchor)); + for g in &f[10..19] { + assert_eq!(j.admit(g, 0).map_err(|e| e.reason), Err(Reason::ParentNotHeld), "held as history"); + } + assert_eq!(j.held(&key, 9), 9, "past the live bound of 4: history is held under its own"); + assert_eq!(j.admit(&f[19], 0), Ok(History)); + let released: Vec<(u64, Result)> = j.take_events().into_iter().map(|e| (e.group_id, e.outcome.map_err(|x| x.reason))).collect(); + assert_eq!(released, (10..19).rev().map(|i| (i, Ok(History))).collect::>(), "released back from the anchor"); + assert_eq!(j.held_total(), 0); + assert_eq!(j.admit(&f[21], 0), Ok(Linked)); +} + +/// NEW 2: the hold is bounded in all, not only for each chain. Any key makes a chain, so +/// a bound for each chain alone holds as much as keys allow; the total holds no more than +/// it states. A MoQ verifier states finite counts at most `MAX_HELD_GROUPS`, and a +/// statement without one states no bound. +#[test] +fn the_hold_is_bounded_in_all() { + let mut v = ReceiptChains::new().with_hold(bound(4, 4, 100)); + for k in 0..300u16 { + let mut seed = [7u8; 32]; + seed[..2].copy_from_slice(&k.to_be_bytes()); + let sk = SigningKey::from_bytes(&seed); + for g in 1..5u64 { + let mut b = GroupBuilder::default(); + b.add(crate::jwp_hash::content_hash(&env(g as u8)), 0, "origin.example", None); + let (grp, _) = b.finish(&sk, "did:example:publisher", 8, g, T0, T1, Some([g as u8; 32]), true).expect("composes"); + let _ = v.admit(&grp, 0); + } + assert!(v.held_total() <= 100); + } + assert_eq!(v.held_total(), 100, "300 keys, 4 orphans each: the total holds 100"); + + assert_eq!(ReceiptChains::for_moq(bound(usize::MAX, 16, 64)).err(), Some(BoundRefused { count: usize::MAX })); + assert_eq!(ReceiptChains::for_moq(bound(4, MAX_HELD_GROUPS + 1, 64)).err(), Some(BoundRefused { count: MAX_HELD_GROUPS + 1 })); + assert_eq!(ReceiptChains::for_moq(bound(4, 16, MAX_HELD_GROUPS + 1)).err(), Some(BoundRefused { count: MAX_HELD_GROUPS + 1 })); + assert!(ReceiptChains::for_moq(bound(MAX_HELD_GROUPS, MAX_HELD_GROUPS, MAX_HELD_GROUPS)).is_ok()); + let stated = json!({"max_groups": 4, "max_history": 16, "max_total": 64}); + assert_eq!(HoldBound::from_json(&stated), Some(bound(4, 16, 64))); + for k in ["max_groups", "max_history", "max_total"] { + let mut absent = stated.clone(); + absent.as_object_mut().unwrap().remove(k); + assert_eq!(HoldBound::from_json(&absent), None, "{k} absent"); + let mut real = stated.clone(); + real[k] = json!(4.5); + assert_eq!(HoldBound::from_json(&real), None, "{k} not an integer"); + } + assert_eq!(HoldBound::from_json(&json!({"max_age_ms": 500})), None, "a time alone"); +} + +/// NEW 2: a group's signature is verified once, and nothing is offered again unless a +/// group it links to is admitted. Re-offering the head or a held group verifies nothing +/// and releases nothing; a parent that arrives releases what waits on it unverified. +#[test] +fn a_group_is_verified_once() { + let c = run_of(0x11, 8, 8, 30); + let key = c[0].signer_pubkey; + let mut v = ReceiptChains::for_moq(bound(2000, 16, 4096)).unwrap(); + assert_eq!(v.admit(&c[0], 0), Ok(Admission::Linked)); + for g in 0..2000u64 { + let orphan = on_track_labelled(0x11, 8, 1000 + g, (g % 251) as u8, Some([1; 32]), T1); + assert_eq!(v.admit(&orphan, 0).map_err(|e| e.reason), Err(Reason::ParentNotHeld)); + } + assert_eq!((v.held(&key, 8), v.verifications()), (2000, 2001)); + for _ in 0..20 { + assert_eq!(v.admit(&c[0], 0), Ok(Admission::Linked)); + } + let orphan = on_track_labelled(0x11, 8, 1000, 0, Some([1; 32]), T1); + assert_eq!(v.admit(&orphan, 0).map_err(|e| e.reason), Err(Reason::ParentNotHeld), "still held"); + assert_eq!((v.held(&key, 8), v.verifications()), (2000, 2001), "the head and a held group, offered again: nothing verified"); + assert!(v.take_events().is_empty(), "nothing released"); + assert_eq!(v.admit(&c[1], 0), Ok(Admission::Linked)); + assert_eq!(v.reoffers(), 0, "a group admitted that no held group links to offers none of them again"); + + let mut w = ReceiptChains::for_moq(bound(8, 16, 64)).unwrap(); + assert_eq!(w.admit(&c[0], 0), Ok(Admission::Linked)); + for g in &c[2..8] { + let _ = w.admit(g, 0); + } + assert_eq!(w.verifications(), 7); + assert_eq!(w.admit(&c[1], 0), Ok(Admission::Linked)); + assert_eq!(w.take_events().len(), 6, "2 to 7 released"); + assert_eq!(w.reoffers(), 6, "each offered again once, when its parent was admitted"); + assert_eq!(w.verifications(), 8, "each verified once: the released groups were verified when held"); + for g in &c[..8] { + let again = if g.group_id < 7 { Admission::AlreadyAdmitted } else { Admission::Linked }; + assert_eq!(w.admit(g, 0), Ok(again), "an admitted group offered again changes nothing; the head passes"); + } + assert_eq!(w.verifications(), 8, "nor when offered again"); +} + +/// NEW 2 and NEW 4: carried over MoQ, a receipt on a receipts track whose key or alias is +/// not that track's, or that names another MOQT group or object, is refused before its +/// signature is verified, and nothing of it is held. +#[test] +fn a_receipt_for_another_track_or_object_is_refused_unheld() { + let (g, o) = in_payload(0x11, 8, 5, 40, Some([3; 32]), T1, true); + let track = ReceiptsTrack { signer_pubkey: g.signer_pubkey, track_alias: 8 }; + let other_key = in_payload(0x22, 8, 5, 40, Some([3; 32]), T1, true).0; + let other_alias = in_payload(0x11, 9, 5, 40, Some([3; 32]), T1, true).0; + let mut v = ReceiptChains::for_moq(bound(4, 4, 16)).unwrap(); + let mismatch = Err(DeliveryError { step: None, reason: Reason::MoqtMismatch }); + assert_eq!(v.admit_on(&track, 5, &other_key, 0), mismatch); + assert_eq!(v.admit_on(&track, 5, &other_alias, 0), mismatch); + assert_eq!(v.admit_on(&track, 6, &g, 0), mismatch, "group 5's receipt as the object of group 6"); + assert_eq!((v.held_total(), v.verifications(), v.chains()), (0, 0, 0)); + assert_eq!(v.admit_on(&track, 5, &g, 0).map_err(|e| e.reason), Err(Reason::ParentNotHeld), "its own: held"); + assert_eq!(o.check_moqt(&track, 5, 0), Ok(())); + assert_eq!(o.check_moqt(&track, 5, 1), Err(DeliveryError { step: None, reason: Reason::MoqtMismatch })); + assert_eq!(o.check_moqt(&track, 4, 0), Err(DeliveryError { step: None, reason: Reason::MoqtMismatch })); + assert_eq!(o.check_moqt(&ReceiptsTrack { track_alias: 9, ..track }, 5, 0), Err(DeliveryError { step: None, reason: Reason::MoqtMismatch })); +} + +/// Round 4: an admitted group offered again, after the head moved on, changes nothing and +/// is not a fork; its objects verify against it like the head's. The receipts of group g +/// arrive before g's object (moq-streaming-format §4.1), so a verifier checks objects of +/// groups behind its head. +#[test] +fn an_admitted_group_verifies_its_objects_after_the_head_moved() { + let mut groups: Vec<(GroupReceipt, ObjectReceipt, Vec)> = Vec::new(); + for g in 0..4u64 { + let e = env(70 + g as u8); + let mut b = GroupBuilder::default(); + b.add(crate::jwp_hash::content_hash(&e), 0, "origin.example", None); + let parent = groups.last().map(|(p, _, _)| p.link_hash()); + let (gr, mut os) = b.finish(&key(0x11), "did:example:publisher", 8, g, T0, T1, parent, true).expect("composes"); + groups.push((gr, os.remove(0), e)); + } + let mut c = ReceiptChains::for_moq(bound(4, 4, 8)).unwrap(); + for (g, _, _) in &groups { + c.admit(g, 0).unwrap(); + } + let before = c.verifications(); + for (g, o, e) in &groups { + assert_eq!(c.verify_object(g, o, Some(e)), Ok(Evidence::DeliveredVerified), "group {}", g.group_id); + } + assert_eq!(c.admit(&groups[1].0, 0), Ok(Admission::AlreadyAdmitted)); + assert_eq!(c.admit(&groups[3].0, 0), Ok(Admission::Linked), "the head again"); + assert_eq!(c.verifications(), before, "nothing verified again"); + assert_eq!(c.head(&groups[0].0.signer_pubkey, 8).map(|h| h.group_id), Some(3), "the head unmoved"); + // A legacy group with its unsigned parent rewritten is not the group admitted. + let legacy: Vec = { + let mut out: Vec = Vec::new(); + for i in 0..3u8 { + let parent = out.last().map(GroupReceipt::link_hash); + out.push(on_track(0x11, 7, u64::from(i), 80 + i, parent, T1).0); + } + out + }; + let mut l = ReceiptChains::new(); + for g in &legacy { + l.admit(g, 0).unwrap(); + } + assert_eq!(l.admit(&legacy[0], 0), Ok(Admission::AlreadyAdmitted)); + let rewritten = GroupReceipt { parent_receipt_hash: Some(legacy[2].link_hash()), ..legacy[0].clone() }; + assert_eq!(l.admit(&rewritten, 0).map_err(|e| e.reason), Err(Reason::GroupIdNotIncreasing)); +} diff --git a/wai/wai-rs/src/staged/mod.rs b/wai/wai-rs/src/staged/mod.rs index a4c9abc92..f82ed822f 100644 --- a/wai/wai-rs/src/staged/mod.rs +++ b/wai/wai-rs/src/staged/mod.rs @@ -7,11 +7,13 @@ //! output) and applies layers to them per unit. Every outcome is a reason from the //! closed list of §4.2 ([`StageRefusal`]), reported per envelope and per unit //! ([`OfferReport`]). The residual form's payload is [`wir1`]; its predictors are -//! [`predict`]; tiers compose by [`tier`]; the live schedule is [`schedule`]. +//! [`predict`]; tiers compose by [`tier`]; the live schedule is [`schedule`]; the +//! carriage of a set over Media over QUIC is [`moq`]. // staged-delivery §10.2: a `stage` claim from what a sink observed. #[cfg(feature = "receipt_claims")] pub mod claim; +pub mod moq; pub mod predict; pub mod schedule; pub mod tier; diff --git a/wai/wai-rs/src/staged/moq.rs b/wai/wai-rs/src/staged/moq.rs new file mode 100644 index 000000000..01044fbea --- /dev/null +++ b/wai/wai-rs/src/staged/moq.rs @@ -0,0 +1,692 @@ +//! A stage set over Media over QUIC (`extensions/moq-streaming-format.md` §4.1): one +//! track for each layer, declared in an MSF catalog, ordered by MOQT priority, released +//! hop by hop by MOQT delivery timeouts, each with a receipts track of its own. +//! +//! - [`StagedTrack`] is the `wai_staged` catalog member, and [`StagedTrack::to_json`] / +//! [`StagedTrack::from_json`] write and read it. +//! - [`SetTrack`] is what §4.1 reads of one track's catalog entry; [`SetTrack::to_catalog_json`] +//! writes the entry, [`receipts_track_json`] its receipts track's, and [`check_catalog`] +//! runs §4.1's ordered catalog check over a whole catalog, giving each set its first +//! [`SetRefusal`]. +//! - [`depends_for`], [`live_object_rules_ok`] and [`priorities_ok`] are the rules on +//! dependencies, groups and priorities. +//! - [`effective_timeout_ms`] and [`expired_at_hop`] are MOQT's `OBJECT_DELIVERY_TIMEOUT` +//! rule (draft-ietf-moq-transport-22 §5.2) at one hop; [`layer_timeout_ms`] and +//! [`base_timeout_floor_ms`] are §4.1's bounds on the timeouts a publisher sets. +//! +//! Pure functions of their inputs, no I/O: the corpus's MoQ trace +//! (`staged-conformance/moq/`) is replayed the same way here, by joule-moq's relay and +//! by `staged_verify.py`. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{json, Map, Value}; + +use super::schedule::{presentation_ms, UnitClock}; + +/// The `packaging` of a track whose objects are WAI envelopes (moq-streaming-format §3). +pub const PACKAGING: &str = "wai"; +/// The `packaging` of a receipts track (§4.1). +pub const RECEIPTS_PACKAGING: &str = "wai-receipts"; +/// The largest integer a catalog states exactly (2^53 − 1). +/// +/// cbindgen:ignore +pub const MAX_CATALOG_INT: u64 = (1 << 53) - 1; + +/// The `wai_staged` member of a track's catalog entry (§4.1). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StagedTrack { + /// The set's name, the same on every track of the set. + pub set: String, + /// 0 on a base track; `k` on a track that carries layer `k`. + pub layer: u8, + /// On a layer track of a live set, the layer's lead (staged-delivery §8); `None` + /// on a base track. Read only when the set is live. + pub lead_ms: Option, +} + +impl StagedTrack { + /// The member as JSON. + pub fn to_json(&self) -> Value { + json!({"set": self.set, "layer": self.layer, "lead_ms": self.lead_ms}) + } + + /// The member read from JSON: an object with `set`, `layer` and `lead_ms` of their + /// types and in their ranges (a member this revision does not define, an earlier + /// `layers` among them, is ignored), or `None`. + pub fn from_json(v: &Value) -> Option { + let o = v.as_object()?; + let set = o.get("set")?.as_str().filter(|s| !s.is_empty())?.to_string(); + let layer = o.get("layer")?.as_u64().filter(|n| *n <= 255)? as u8; + let lead_ms = match o.get("lead_ms")? { + Value::Null => None, + l => Some(l.as_u64().filter(|n| *n <= MAX_CATALOG_INT)?), + }; + Some(StagedTrack { set, layer, lead_ms }) + } +} + +/// Why a catalog's entries do not carry a stage set (§4.1, *Checking a catalog*), in the +/// order a sink checks them. A sink treats the layer tracks of a set that fails as +/// absent. +/// +/// cbindgen:ignore +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SetRefusal { + StagedMalformed, + SetIncomplete, + Depends, + Grouping, + Lead, + Receipts, +} + +impl SetRefusal { + /// Every reason, in §4.1's order. + /// + /// cbindgen:ignore + pub const ALL: [SetRefusal; 6] = + [Self::StagedMalformed, Self::SetIncomplete, Self::Depends, Self::Grouping, Self::Lead, Self::Receipts]; + + /// The token §4.1 names. + pub fn code(self) -> &'static str { + match self { + Self::StagedMalformed => "staged-malformed", + Self::SetIncomplete => "staged-set-incomplete", + Self::Depends => "staged-depends", + Self::Grouping => "staged-grouping", + Self::Lead => "staged-lead", + Self::Receipts => "staged-receipts", + } + } + + /// The reason whose token is `code`. + pub fn from_code(code: &str) -> Option { + Self::ALL.into_iter().find(|r| r.code() == code) + } +} + +/// What §4.1 reads of one track of a stage set, and writes into its catalog entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SetTrack { + /// MSF `namespace` (§5.2.2). + pub namespace: String, + /// MSF `name` (§5.2.3). + pub name: String, + pub staged: StagedTrack, + /// MSF `isLive` (§5.2.7). + pub is_live: bool, + /// MSF `targetLatency` (§5.2.8), on a live set. + pub target_latency_ms: Option, + /// MSF `role` (§5.2.6). + pub role: Option, + /// MSF `renderGroup` (§5.2.11). + pub render_group: Option, + /// MSF `depends` (§5.2.14). + pub depends: Vec, + /// `wai_capabilities` (moq-streaming-format §4). + pub capabilities: Vec, + /// `wai_determinism`. + pub determinism: Option, + /// `wai_receipts`. + pub receipts: bool, + /// `wai_receipt_alias`: the `track_alias` the track's receipts sign. + pub receipt_alias: Option, + /// `wai_receipts_track`: the name of the track's receipts track, in its namespace. + pub receipts_track: Option, +} + +impl SetTrack { + /// The track's catalog entry, with MSF's field names; an absent optional field is + /// not written. + pub fn to_catalog_json(&self) -> Value { + let mut o = Map::new(); + o.insert("namespace".into(), json!(self.namespace)); + o.insert("name".into(), json!(self.name)); + o.insert("packaging".into(), json!(PACKAGING)); + o.insert("isLive".into(), json!(self.is_live)); + if let Some(t) = self.target_latency_ms { + o.insert("targetLatency".into(), json!(t)); + } + if let Some(r) = &self.role { + o.insert("role".into(), json!(r)); + } + if let Some(g) = self.render_group { + o.insert("renderGroup".into(), json!(g)); + } + if !self.depends.is_empty() { + o.insert("depends".into(), json!(self.depends)); + } + o.insert("wai_capabilities".into(), json!(self.capabilities)); + if let Some(d) = &self.determinism { + o.insert("wai_determinism".into(), json!(d)); + } + o.insert("wai_receipts".into(), json!(self.receipts)); + if let Some(a) = self.receipt_alias { + o.insert("wai_receipt_alias".into(), json!(a)); + } + if let Some(r) = &self.receipts_track { + o.insert("wai_receipts_track".into(), json!(r)); + } + o.insert("wai_staged".into(), self.staged.to_json()); + Value::Object(o) + } +} + +/// A receipts track's catalog entry (§4.1). +pub fn receipts_track_json(namespace: &str, name: &str, is_live: bool) -> Value { + json!({"namespace": namespace, "name": name, "packaging": RECEIPTS_PACKAGING, "isLive": is_live}) +} + +/// The `depends` of the track of `layer` (§4.1): a track of every layer below it. +/// `by_layer[k]` is the name of the set's track of layer `k`. +pub fn depends_for(by_layer: &[&str], layer: u8) -> Vec { + by_layer.iter().take(usize::from(layer)).map(|s| s.to_string()).collect() +} + +/// §4.1 on a live track: a group carries exactly one object, which travels in a +/// subgroup of its own (MSF maps every object to a stream of its own). +pub fn live_object_rules_ok(group_objects: u32, own_subgroup: bool) -> bool { + group_objects == 1 && own_subgroup +} + +/// §4.1's priorities, as MOQT numbers them (lower is sent first, draft-ietf-moq-transport-22 +/// §5.1.1): the base track's number strictly below every layer track's, and each +/// layer's no greater than any layer above it. `layers[k − 1]` is the priority of the +/// track of layer `k`. A receipts track takes its media track's. +pub fn priorities_ok(base: u8, layers: &[u8]) -> bool { + layers.iter().all(|&l| base < l) && layers.windows(2).all(|w| w[0] <= w[1]) +} + +/// The delivery timeout MOQT applies to a subscription (draft-ietf-moq-transport-22 +/// §5.2): the smaller of the publisher's and the subscriber's values where both are set, +/// either where one is; `0` is no timeout. +pub fn effective_timeout_ms(publisher_ms: u64, subscriber_ms: u64) -> u64 { + match (publisher_ms, subscriber_ms) { + (0, s) => s, + (p, 0) => p, + (p, s) => p.min(s), + } +} + +/// MOQT's `OBJECT_DELIVERY_TIMEOUT` at one hop (draft-ietf-moq-transport-22 §5.2): an +/// object whose last header byte reached the hop at `received_ms` and that the hop passes +/// to the transport at `send_ms` is dropped when a timeout is set and the time elapsed +/// exceeds it. The timer starts at each hop, so this decides nothing end to end. A send +/// time before the arrival has spent no time at the hop. +pub fn expired_at_hop(timeout_ms: u64, received_ms: u64, send_ms: u64) -> bool { + timeout_ms != 0 && send_ms.saturating_sub(received_ms) > timeout_ms +} + +/// The most a publisher should set as a live layer object's `OBJECT_DELIVERY_TIMEOUT` +/// (§4.1): its slack at publication, the time from `publish_ms` to the presentation of +/// the last unit it covers (at clock position `last_unit`), less `lead_ms`, less the +/// publisher's estimate of the path. `None` where that is zero or less: no timeout can +/// say it, since 0 is no timeout, and the object cannot arrive in time for any unit. +pub fn layer_timeout_ms(clock: &UnitClock, last_unit: u64, lead_ms: u64, publish_ms: u64, path_ms: u64) -> Option { + let due = presentation_ms(clock, last_unit); + due.checked_sub(lead_ms)?.checked_sub(path_ms)?.checked_sub(publish_ms).filter(|s| *s > 0) +} + +/// The least delivery timeout of either kind a publisher may set on a base track (§4.1): +/// the time from the base's publication to the presentation of its last unit (0 where +/// that unit is due already). +pub fn base_timeout_floor_ms(clock: &UnitClock, last_unit: u64, publish_ms: u64) -> u64 { + presentation_ms(clock, last_unit).saturating_sub(publish_ms) +} + +/// A track's identity in a catalog: its namespace — its own, or the catalog track's it +/// inherits (MSF §5.2.2), `None` where neither is known — and its name. +type Identity = (Option, String); + +/// One track of a stage set, as §4.1's check reads it. +struct Entry { + /// The entry's identity, where its `name` is a string and its `namespace` absent or + /// a string. + id: Option, + /// It declares its own namespace. + declares_ns: bool, + staged: Option, + /// The entry is a well-formed member of its set (§4.1 check 1). + well_formed: bool, + is_live: Option, + render_group: Option, + alt_or_layer_ids: bool, + /// `depends`, an array of strings (absent: empty). + depends: Vec, + receipts: bool, + /// `wai_receipt_alias` as an integer in range, where it reads. + alias: Option, + /// `wai_receipts_track`, where it is a string. + receipts_track: Option, +} + +/// An integer that fits `u64`, written as one (not `1.0`, not a boolean). +fn u64_of(v: &Value) -> Option { + v.as_u64() +} + +/// The identity of a catalog entry, if its name and namespace read. +fn identity(o: &Map, inherited: Option<&str>) -> Option { + let name = o.get("name")?.as_str()?.to_string(); + let ns = match o.get("namespace") { + None => inherited.map(str::to_string), + Some(n) => Some(n.as_str()?.to_string()), + }; + Some((ns, name)) +} + +fn entry(o: &Map, inherited: Option<&str>, duplicated: &BTreeSet) -> Entry { + let staged = o.get("wai_staged").and_then(StagedTrack::from_json); + let id = identity(o, inherited); + let is_live = o.get("isLive").and_then(Value::as_bool); + let render_group_ok = o.get("renderGroup").is_none_or(|g| u64_of(g).is_some()); + let depends = match o.get("depends") { + None => Some(vec![]), + Some(Value::Array(a)) => a.iter().map(|d| d.as_str().map(str::to_string)).collect(), + Some(_) => None, + }; + let well_formed = staged.is_some() + && o.get("packaging").and_then(Value::as_str) == Some(PACKAGING) + && is_live.is_some() + && id.as_ref().is_some_and(|i| !duplicated.contains(i)) + && render_group_ok + && depends.is_some(); + Entry { + id, + declares_ns: o.contains_key("namespace"), + staged, + well_formed, + is_live, + render_group: o.get("renderGroup").and_then(u64_of), + alt_or_layer_ids: o.contains_key("altGroup") || o.contains_key("temporalId") || o.contains_key("spatialId"), + depends: depends.unwrap_or_default(), + receipts: o.get("wai_receipts").and_then(Value::as_bool) == Some(true), + alias: o.get("wai_receipt_alias").and_then(Value::as_u64).filter(|n| *n <= MAX_CATALOG_INT), + receipts_track: o.get("wai_receipts_track").and_then(Value::as_str).map(str::to_string), + } +} + +/// §4.1, *Checking a catalog*, over an MSF catalog (its root object, whose `tracks` lists +/// the entries), by a checker that does not know the namespace of the catalog track. The +/// check needs it (§4.1): without it, a track that declares no namespace is compared only +/// with tracks that declare none, and a set that mixes declared and inherited namespaces +/// is refused (`staged-malformed`). A sink checks with [`check_catalog_in`]. +pub fn check_catalog(catalog: &Value) -> Vec<(String, Result<(), SetRefusal>)> { + check_catalog_in(catalog, None) +} + +/// §4.1, *Checking a catalog*: every stage set the catalog declares, by name in ascending +/// order, with the first rule it fails, or `Ok`. `catalog_namespace` is the namespace of +/// the catalog track, which a track that declares none inherits (MSF §5.2.2). An entry +/// whose `wai_staged.set` cannot be read is reported for the empty name, which no set +/// has. +pub fn check_catalog_in(catalog: &Value, catalog_namespace: Option<&str>) -> Vec<(String, Result<(), SetRefusal>)> { + let tracks: Vec<&Map> = + catalog.get("tracks").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_object).collect()).unwrap_or_default(); + // Every identity the catalog declares, and those it declares twice. + let mut seen: BTreeMap = BTreeMap::new(); + for t in &tracks { + if let Some(i) = identity(t, catalog_namespace) { + *seen.entry(i).or_default() += 1; + } + } + let duplicated: BTreeSet = seen.iter().filter(|(_, n)| **n > 1).map(|(i, _)| i.clone()).collect(); + let mut sets: BTreeMap> = BTreeMap::new(); + for t in &tracks { + if let Some(w) = t.get("wai_staged") { + let set = w.get("set").and_then(Value::as_str).filter(|s| !s.is_empty()).unwrap_or("").to_string(); + sets.entry(set).or_default().push(entry(t, catalog_namespace, &duplicated)); + } + } + let cat = Catalog { tracks: &tracks, inherited: catalog_namespace, seen: &seen }; + sets.into_iter().map(|(set, es)| { + let r = check_set(&set, &es, &cat); + (set, r) + }).collect() +} + +/// What the check reads of the whole catalog. +struct Catalog<'a> { + tracks: &'a [&'a Map], + inherited: Option<&'a str>, + seen: &'a BTreeMap, +} + +fn check_set(set: &str, es: &[Entry], cat: &Catalog<'_>) -> Result<(), SetRefusal> { + // 1. Every entry well formed, and in a set with a name. With the catalog track's + // namespace unknown, an inherited namespace cannot be compared with a declared one: a + // set that mixes them is refused. + if set.is_empty() || es.iter().any(|e| !e.well_formed) { + return Err(SetRefusal::StagedMalformed); + } + if cat.inherited.is_none() && es.iter().any(|e| e.declares_ns) && es.iter().any(|e| !e.declares_ns) { + return Err(SetRefusal::StagedMalformed); + } + let layer = |e: &Entry| e.staged.as_ref().expect("well formed").layer; + let id = |e: &Entry| e.id.clone().expect("well formed"); + // 2. A track at each layer from 0 to the highest present. + let layers: BTreeSet = es.iter().map(layer).collect(); + let top = *layers.iter().next_back().expect("a set has a track"); + if (0..=top).any(|k| !layers.contains(&k)) { + return Err(SetRefusal::SetIncomplete); + } + // 3. depends: each entry a track of the catalog in the entry's namespace; a track of + // every lower layer; none of the set at or above its own. + let set_layer: BTreeMap = es.iter().map(|e| (id(e), layer(e))).collect(); + for e in es { + let (ns, _) = id(e); + let k = layer(e); + let named: BTreeSet = e.depends.iter().map(|d| (ns.clone(), d.clone())).collect(); + if named.iter().any(|n| !cat.seen.contains_key(n)) { + return Err(SetRefusal::Depends); + } + let reached: BTreeSet = named.iter().filter_map(|n| set_layer.get(n).copied()).collect(); + if (0..k).any(|j| !reached.contains(&j)) || reached.iter().any(|&j| j >= k) { + return Err(SetRefusal::Depends); + } + } + // 4. One namespace, one isLive and one renderGroup, present; nothing of an alternate + // or of a codec's own layers on a layer track. + let (ns0, live, group) = (id(&es[0]).0, es[0].is_live, es[0].render_group); + if group.is_none() || es.iter().any(|e| id(e).0 != ns0 || e.is_live != live || e.render_group != group) { + return Err(SetRefusal::Grouping); + } + if es.iter().any(|e| layer(e) > 0 && e.alt_or_layer_ids) { + return Err(SetRefusal::Grouping); + } + // 5. On a live set, a lead on every layer track and none on a base track. A set that + // is not live states no lead that is read. + if live == Some(true) && es.iter().any(|e| e.staged.as_ref().expect("well formed").lead_ms.is_some() != (layer(e) > 0)) { + return Err(SetRefusal::Lead); + } + // 6. Receipts. + check_receipts(es, cat) +} + +/// §4.1 check 6: every `wai_receipt_alias` in the catalog an integer from 0 to 2^53 − 1; +/// on every track of the set that declares receipts, an alias that no other track of the +/// catalog carries, and a receipts track of its namespace that no other track names. A +/// rendition of a layer is a track of its own, with its own alias and receipts track. +fn check_receipts(es: &[Entry], cat: &Catalog<'_>) -> Result<(), SetRefusal> { + let alias_of = |t: &Map| t.get("wai_receipt_alias").map(|a| a.as_u64().filter(|n| *n <= MAX_CATALOG_INT)); + if cat.tracks.iter().any(|t| matches!(alias_of(t), Some(None))) { + return Err(SetRefusal::Receipts); + } + let mut aliases: BTreeMap = BTreeMap::new(); + let mut named: BTreeMap = BTreeMap::new(); + for t in cat.tracks { + if let Some(Some(a)) = alias_of(t) { + *aliases.entry(a).or_default() += 1; + } + if let (Some(r), Some(i)) = (t.get("wai_receipts_track").and_then(Value::as_str), identity(t, cat.inherited)) { + *named.entry((i.0, r.to_string())).or_default() += 1; + } + } + let receipts_packaging = |i: &Identity| { + cat.tracks.iter().any(|t| identity(t, cat.inherited).as_ref() == Some(i) && t.get("packaging").and_then(Value::as_str) == Some(RECEIPTS_PACKAGING)) + }; + for e in es.iter().filter(|e| e.receipts) { + let (Some(a), Some(r)) = (e.alias, &e.receipts_track) else { return Err(SetRefusal::Receipts) }; + let rid: Identity = (e.id.clone().expect("well formed").0, r.clone()); + if aliases.get(&a) != Some(&1) || named.get(&rid) != Some(&1) || !receipts_packaging(&rid) { + return Err(SetRefusal::Receipts); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const NAMES: [&str; 3] = ["cam1/base", "cam1/L1", "cam1/L2"]; + + fn track(layer: u8, lead: Option, alias: u64) -> SetTrack { + let name = NAMES[usize::from(layer)]; + SetTrack { + namespace: "example/cam1".into(), + name: name.into(), + staged: StagedTrack { set: "cam1".into(), layer, lead_ms: lead }, + is_live: true, + target_latency_ms: Some(300), + role: Some("video".into()), + render_group: Some(1), + depends: depends_for(&NAMES, layer), + capabilities: vec![if layer == 0 { "wai.video.int_motion" } else { "wai.video.int_refine" }.into()], + determinism: Some("decode-equivalence".into()), + receipts: true, + receipt_alias: Some(alias), + receipts_track: Some(format!("{name}/receipts")), + } + } + + /// A live set of a base and two layers, each with its receipts track. + fn catalog() -> Value { + let mut tracks: Vec = + vec![track(0, None, 7).to_catalog_json(), track(1, Some(20), 8).to_catalog_json(), track(2, Some(30), 9).to_catalog_json()]; + for n in NAMES { + tracks.push(receipts_track_json("example/cam1", &format!("{n}/receipts"), true)); + } + json!({"version": "draft-01", "tracks": tracks}) + } + + fn reason(c: &Value) -> Vec<(String, Option<&'static str>)> { + check_catalog(c).into_iter().map(|(s, r)| (s, r.err().map(SetRefusal::code))).collect() + } + + fn edit(f: impl FnOnce(&mut Vec)) -> Vec<(String, Option<&'static str>)> { + let mut c = catalog(); + f(c["tracks"].as_array_mut().unwrap()); + reason(&c) + } + + fn one(r: Option<&'static str>) -> Vec<(String, Option<&'static str>)> { + vec![("cam1".into(), r)] + } + + #[test] + fn staged_member_round_trips_and_refuses_each_malformation() { + let t = StagedTrack { set: "cam1".into(), layer: 1, lead_ms: Some(20) }; + assert_eq!(StagedTrack::from_json(&t.to_json()), Some(t.clone())); + let base = StagedTrack { lead_ms: None, layer: 0, ..t }; + assert_eq!(StagedTrack::from_json(&base.to_json()), Some(base)); + for bad in [ + json!(null), + json!({"set": "", "layer": 0, "lead_ms": null}), + json!({"set": 1, "layer": 0, "lead_ms": null}), + json!({"set": "a", "layer": 256, "lead_ms": null}), + json!({"set": "a", "layer": -1, "lead_ms": null}), + json!({"set": "a", "layer": 1.0, "lead_ms": null}), + json!({"set": "a", "layer": 0}), + json!({"set": "a", "layer": 0, "lead_ms": "20"}), + json!({"set": "a", "layer": 0, "lead_ms": MAX_CATALOG_INT + 1}), + ] { + assert_eq!(StagedTrack::from_json(&bad), None, "{bad}"); + } + let extra = json!({"set": "a", "layer": 0, "layers": 9, "lead_ms": null, "future": true}); + assert!(StagedTrack::from_json(&extra).is_some(), "an undefined member, an earlier `layers` among them, is ignored"); + } + + /// §4.1's catalog check: a well-formed set passes, and each rule, broken alone, + /// gives its own reason; a set with two faults gives the earlier one. + #[test] + fn catalog_check_gives_each_rule_its_reason_in_order() { + assert_eq!(reason(&catalog()), one(None)); + // 1. Types and identities. + assert_eq!(edit(|t| { t[1]["wai_staged"]["layer"] = json!(300); }), one(Some("staged-malformed"))); + assert_eq!(edit(|t| { t[1]["packaging"] = json!("loc"); }), one(Some("staged-malformed"))); + assert_eq!(edit(|t| { t[2].as_object_mut().unwrap().remove("isLive"); }), one(Some("staged-malformed"))); + assert_eq!(edit(|t| { t[1]["renderGroup"] = json!(true); }), one(Some("staged-malformed")), "renderGroup is an integer"); + assert_eq!(edit(|t| { t[1]["renderGroup"] = json!(1.0); }), one(Some("staged-malformed"))); + assert_eq!(edit(|t| { t[1]["depends"] = json!("cam1/base"); }), one(Some("staged-malformed")), "depends is an array"); + assert_eq!(edit(|t| { t[1]["depends"] = json!(["cam1/base", 5]); }), one(Some("staged-malformed")), "of strings"); + assert_eq!(edit(|t| { t[1]["namespace"] = json!(5); }), one(Some("staged-malformed"))); + assert_eq!(edit(|t| { t[3]["name"] = json!("cam1/L1"); }), one(Some("staged-malformed")), "a name|namespace pair declared twice"); + // 2. Extent: the tracks present, 0 to the highest, none missing. + assert_eq!(edit(|t| { t.remove(1); }), one(Some("staged-set-incomplete")), "layer 2 with no layer 1"); + assert_eq!(edit(|t| { t.remove(2); }), one(None), "the top layer removed (an MSF delta): the set is 0 to 1"); + // 3. depends. + assert_eq!(edit(|t| { t[2]["depends"] = json!(["cam1/base"]); }), one(Some("staged-depends")), "L2 must list a layer 1 track"); + assert_eq!(edit(|t| { t[1]["depends"] = json!(["cam1/base", "cam1/L2"]); }), one(Some("staged-depends")), "nothing above"); + assert_eq!(edit(|t| { t[0]["depends"] = json!(["cam1/L1"]); }), one(Some("staged-depends")), "the base lists no set track"); + assert_eq!(edit(|t| { t[1]["depends"] = json!(["cam1/base", "nowhere"]); }), one(Some("staged-depends")), "a track the catalog lacks"); + assert_eq!( + edit(|t| { t[0]["namespace"] = json!("example/other"); }), + one(Some("staged-depends")), + "a dependency is resolved in the entry's own namespace" + ); + // 4. Grouping. + assert_eq!(edit(|t| { t[2]["renderGroup"] = json!(2); }), one(Some("staged-grouping"))); + assert_eq!(edit(|t| { for x in t.iter_mut().take(3) { x.as_object_mut().unwrap().remove("renderGroup"); } }), one(Some("staged-grouping"))); + assert_eq!(edit(|t| { t[1]["altGroup"] = json!(1); }), one(Some("staged-grouping"))); + assert_eq!(edit(|t| { t[0]["altGroup"] = json!(1); }), one(None), "an alternate group may group bases"); + assert_eq!(edit(|t| { t[2]["temporalId"] = json!(1); }), one(Some("staged-grouping"))); + assert_eq!(edit(|t| { t[1]["isLive"] = json!(false); }), one(Some("staged-grouping"))); + assert_eq!( + edit(|t| { let mut b = t[0].clone(); b["namespace"] = json!("example/other"); t.push(b); }), + one(Some("staged-grouping")), + "a second base track in another namespace: one set, two namespaces" + ); + // 5. Lead. + assert_eq!(edit(|t| { t[1]["wai_staged"]["lead_ms"] = json!(null); }), one(Some("staged-lead"))); + assert_eq!(edit(|t| { t[0]["wai_staged"]["lead_ms"] = json!(5); }), one(Some("staged-lead"))); + assert_eq!( + edit(|t| { for x in t.iter_mut().take(3) { x["isLive"] = json!(false); x["wai_staged"]["lead_ms"] = json!(null); } }), + one(None), + "a set that is not live states no lead" + ); + assert_eq!( + edit(|t| { for x in t.iter_mut() { x["isLive"] = json!(false); } }), + one(None), + "live to on demand: a lead left on is not read" + ); + // 6. Receipts. + assert_eq!(edit(|t| { t[2]["wai_receipt_alias"] = json!(8); }), one(Some("staged-receipts")), "an alias two layers share"); + assert_eq!(edit(|t| { t[1].as_object_mut().unwrap().remove("wai_receipt_alias"); }), one(Some("staged-receipts"))); + assert_eq!(edit(|t| { t[1].as_object_mut().unwrap().remove("wai_receipts_track"); }), one(Some("staged-receipts"))); + assert_eq!(edit(|t| { t.remove(4); }), one(Some("staged-receipts")), "L1's receipts track missing"); + assert_eq!(edit(|t| { t[2]["wai_receipts_track"] = json!("cam1/L1/receipts"); }), one(Some("staged-receipts")), "two chains on one receipts track"); + assert_eq!(edit(|t| { t.push(json!({"name": "audio", "packaging": "loc", "isLive": true, "wai_receipt_alias": 8.0})); }), one(Some("staged-receipts")), "an alias that is not an integer, anywhere"); + assert_eq!(edit(|t| { t.push(json!({"name": "audio", "packaging": "loc", "isLive": true, "wai_receipt_alias": 8})); }), one(Some("staged-receipts")), "an alias another track shares"); + assert_eq!( + edit(|t| { for x in t.iter_mut().take(3) { x["wai_receipts"] = json!(false); x.as_object_mut().unwrap().remove("wai_receipt_alias"); x.as_object_mut().unwrap().remove("wai_receipts_track"); } t.truncate(3); }), + one(None), + "a set that declares no receipts needs no receipts track" + ); + // A clone of layer 2 (MSF §5.1.6 inherits all but the name) shares its parent's chain, + // which it cannot carry: refused, unless it overrides its alias and receipts track. + assert_eq!(edit(|t| { let mut c = t[2].clone(); c["name"] = json!("cam1/L2b"); t.push(c); }), one(Some("staged-receipts"))); + assert_eq!( + edit(|t| { + let mut c = t[2].clone(); + c["name"] = json!("cam1/L2b"); + c["wai_receipt_alias"] = json!(10); + c["wai_receipts_track"] = json!("cam1/L2b/receipts"); + t.push(c); + t.push(receipts_track_json("example/cam1", "cam1/L2b/receipts", true)); + }), + one(None), + "a rendition with a chain of its own" + ); + // Two faults: the earlier rule is the reason. + assert_eq!(edit(|t| { t[1]["altGroup"] = json!(1); t[2]["depends"] = json!([]); }), one(Some("staged-depends"))); + // An entry with no readable set is reported for the empty name; the set stands. + assert_eq!( + edit(|t| { let mut x = t[1].clone(); x["name"] = json!("cam1/L1x"); x["wai_staged"]["set"] = json!(7); x.as_object_mut().unwrap().remove("wai_receipt_alias"); x.as_object_mut().unwrap().remove("wai_receipts_track"); t.push(x); }), + vec![("".into(), Some("staged-malformed")), ("cam1".into(), None)] + ); + for r in SetRefusal::ALL { + assert_eq!(SetRefusal::from_code(r.code()), Some(r)); + } + } + + /// A track that declares no namespace inherits the catalog track's (MSF §5.2.2), which + /// the check needs: without it, a set that mixes declared and inherited namespaces is + /// refused, whatever the namespaces would turn out to be. + #[test] + fn namespaces_are_inherited_from_the_catalog_track() { + let mut c = catalog(); + for t in c["tracks"].as_array_mut().unwrap() { + t.as_object_mut().unwrap().remove("namespace"); + } + assert_eq!(reason(&c), one(None), "every track inherits: one namespace, whichever it is"); + assert_eq!(check_catalog_in(&c, Some("example/cam1")), vec![("cam1".to_string(), Ok(()))]); + assert_eq!(check_catalog_in(&c, Some("other/ns")), vec![("cam1".to_string(), Ok(()))]); + // One track declaring the namespace the others inherit: the same namespace, known + // only to a checker given the catalog's. + c["tracks"][1]["namespace"] = json!("example/cam1"); + assert_eq!(check_catalog_in(&c, Some("example/cam1")), vec![("cam1".to_string(), Ok(()))]); + assert_eq!(reason(&c), one(Some("staged-malformed")), "unknown: a mixed set is refused"); + assert_eq!(check_catalog_in(&c, Some("other/ns")).into_iter().next().unwrap().1, Err(SetRefusal::Depends), "two namespaces in fact"); + // The same track twice, once declared and once inherited: a duplicate, given the + // catalog's namespace; a mixed set, without it. + let mut d = catalog(); + let mut l1 = d["tracks"][1].clone(); + l1.as_object_mut().unwrap().remove("namespace"); + d["tracks"].as_array_mut().unwrap().push(l1); + assert_eq!(check_catalog_in(&d, Some("example/cam1")), vec![("cam1".to_string(), Err(SetRefusal::StagedMalformed))]); + assert_eq!(reason(&d), one(Some("staged-malformed"))); + } + + #[test] + fn live_objects_priorities_and_depends() { + assert!(live_object_rules_ok(1, true)); + assert!(!live_object_rules_ok(2, true), "one object to a group"); + assert!(!live_object_rules_ok(1, false), "a subgroup of its own"); + assert!(!live_object_rules_ok(0, true)); + assert!(priorities_ok(64, &[96, 128])); + assert!(priorities_ok(64, &[96, 96]), "equal layers"); + assert!(!priorities_ok(96, &[96, 128]), "the base strictly first"); + assert!(!priorities_ok(64, &[128, 96]), "a lower layer no later than a higher one"); + assert!(priorities_ok(0, &[])); + assert_eq!(depends_for(&["b", "l1", "l2"], 0), Vec::::new()); + assert_eq!(depends_for(&["b", "l1", "l2"], 2), vec!["b".to_string(), "l1".to_string()]); + } + + /// MOQT §5.2 at one hop: the timer runs from the object's arrival at the hop, the + /// smaller non-zero value wins, and 0 is no timeout. + #[test] + fn delivery_timeouts_run_per_hop() { + assert_eq!(effective_timeout_ms(0, 0), 0); + assert_eq!(effective_timeout_ms(60, 0), 60); + assert_eq!(effective_timeout_ms(0, 40), 40); + assert_eq!(effective_timeout_ms(60, 40), 40); + assert_eq!(effective_timeout_ms(60, 400), 60, "a longer subscriber value does not extend the publisher's"); + assert!(!expired_at_hop(60, 100, 160), "exactly the timeout is not past it"); + assert!(expired_at_hop(60, 100, 161)); + assert!(!expired_at_hop(0, 100, 10_000), "0 is no timeout"); + // Two hops of 40 ms each pass a 60 ms timeout; end to end they took 80 ms. + assert!(!expired_at_hop(60, 0, 40) && !expired_at_hop(60, 40, 80)); + } + + /// §4.1's bounds: the slack to the last unit covered, never 0; the base's floor. + #[test] + fn publisher_timeout_bounds() { + let c = UnitClock { t0_ms: 0, unit_ms: 40, target_latency_ms: 300 }; + // Last unit 3 is presented at 420; lead 30, path 30, published at 170: 190. + assert_eq!(layer_timeout_ms(&c, 3, 30, 170, 30), Some(190)); + assert_eq!(layer_timeout_ms(&c, 3, 30, 360, 30), None, "no slack at all"); + assert_eq!(layer_timeout_ms(&c, 3, 30, 400, 30), None, "past due"); + assert_eq!(layer_timeout_ms(&c, 0, 400, 0, 0), None, "a lead past the presentation"); + let far = UnitClock { t0_ms: u64::MAX, unit_ms: 0, target_latency_ms: 0 }; + assert_eq!(layer_timeout_ms(&far, 0, 0, 0, 0), Some(u64::MAX), "no cap below what the clock reads"); + assert_eq!(base_timeout_floor_ms(&c, 3, 130), 290); + assert_eq!(base_timeout_floor_ms(&c, 3, 500), 0); + } + + /// The tokens are the ones moq-streaming-format §4.1 lists, in its order. + #[test] + fn moq_streaming_format_lists_the_codes() { + let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../extensions/moq-streaming-format.md")).unwrap(); + let mut at = 0; + for r in SetRefusal::ALL { + let tok = format!("`{}`", r.code()); + let i = text[at..].find(&tok).unwrap_or_else(|| panic!("{tok} is not in §4.1 after the one before it")); + at += i + tok.len(); + } + for name in ["\"wai_staged\"", "`wai_receipt_alias`", "`wai_receipts_track`", "\"wai-receipts\""] { + assert!(text.contains(name), "{name}"); + } + } +} diff --git a/wai/wai-rs/src/staged/tests.rs b/wai/wai-rs/src/staged/tests.rs index f1febc6a9..6bba680f8 100644 --- a/wai/wai-rs/src/staged/tests.rs +++ b/wai/wai-rs/src/staged/tests.rs @@ -133,6 +133,75 @@ fn rate_interpolates_with_rdiv() { assert_eq!(got[5], -4, "-3.5 rounds away from zero to -4"); } +/// Test 15: the rate predictor beside the f64 linear interpolation the layered-PCM ladder +/// idea describes, on the fixtures and ratios of `staged-conformance/rate_port.json`. That +/// form is written again here from its description — `src = (i·den)/num`, `lo = ⌊src⌋`, +/// `frac = src − lo`, `hi = min(lo + 1, n − 1)`, `a + (b − a)·frac` rounded half away +/// from zero — and no earlier code is copied. Wherever the phase `(i·den mod num)/num` is +/// dyadic, `src`, `frac` and the product are exact in f64 and the two must agree; elsewhere +/// the samples where they differ are the ones the file pins, and the integer predictions +/// hash to its SHA-256. +#[test] +fn rate_matches_ported_reference_where_exact() { + use sha2::{Digest, Sha256}; + fn f64_form(prev: &[i16], c: usize, num: u32, den: u32, i: usize, ch: usize) -> i64 { + let n = prev.len() / c; + let src = (i as u64 * den as u64) as f64 / num as f64; + let lo = src.floor(); + let frac = src - lo; + let lo = lo as usize; + let hi = (lo + 1).min(n - 1); + let (a, b) = (prev[lo * c + ch] as f64, prev[hi * c + ch] as f64); + (a + (b - a) * frac).round() as i64 + } + let text = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/../staged-conformance/rate_port.json")).unwrap(); + let d: serde_json::Value = serde_json::from_str(&text).unwrap(); + let fixtures: std::collections::BTreeMap<&str, (usize, Vec)> = d["fixtures"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["name"].as_str().unwrap(), (f["channels"].as_u64().unwrap() as usize, f["samples"].as_array().unwrap().iter().map(|v| v.as_i64().unwrap() as i16).collect()))) + .collect(); + let (mut differing, mut dyadic_checked) = (0, 0); + for case in d["cases"].as_array().unwrap() { + let (c, samples) = &fixtures[case["fixture"].as_str().unwrap()]; + let (num, den) = (case["num"].as_u64().unwrap() as u32, case["den"].as_u64().unwrap() as u32); + let n_out = super::predict::rate_len((samples.len() / c) as u64, num, den).unwrap() as usize; + assert_eq!(n_out as u64, case["n_out"].as_u64().unwrap()); + let mut h = Sha256::new(); + let mut differs = Vec::new(); + for i in 0..n_out { + let phase = (i as u64 * den as u64) % num as u64; + let q = num as u64 / gcd(phase, num as u64); + let dyadic = phase == 0 || q.is_power_of_two(); + for ch in 0..*c { + let p = rate_at(samples, *c, num, den, i, ch); + let f = f64_form(samples, *c, num, den, i, ch); + h.update((p as i16).to_le_bytes()); + if dyadic { + assert_eq!(p, f, "{} {num}/{den} sample {i} channel {ch}: exact in f64, so equal", case["fixture"]); + dyadic_checked += 1; + } else if p != f { + differs.push(json!([i, ch, p, f])); + } + } + } + let got: [u8; 32] = h.finalize().into(); + let hex: String = got.iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hex, case["predicted_sha256"].as_str().unwrap(), "{} {num}/{den}", case["fixture"]); + assert_eq!(serde_json::Value::Array(differs.clone()), case["differs"], "{} {num}/{den}: the samples that differ", case["fixture"]); + differing += differs.len(); + } + assert!(dyadic_checked > 0 && differing > 0, "the comparison covers both kinds of phase"); +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + (a, b) = (b, a % b); + } + a.max(1) +} + /// A valid one-unit RGB8 payload to mutate. fn fixture() -> Vec { let below = picture(2, 2, 1); diff --git a/wai/wai-rs/tests/staged_moq.rs b/wai/wai-rs/tests/staged_moq.rs new file mode 100644 index 000000000..16a900dc4 --- /dev/null +++ b/wai/wai-rs/tests/staged_moq.rs @@ -0,0 +1,535 @@ +//! A stage set over MoQ with signed receipts (`extensions/moq-streaming-format.md` §4.1, +//! `extensions/jwp-receipts.md` §3, §4.1, §2.4.10–§2.4.11), run from the trace of +//! `staged-conformance/moq/trace.json`. +//! +//! The corpus states the trace's inputs — tracks, objects, their paths through one relay, +//! the subscribers — and what follows from them, worked out with the receipts' arrival +//! times alone. This test runs the same trace with the receipts themselves: +//! +//! - the catalog passes §4.1's check, and the trace's tracks are its tracks, with the +//! receipt aliases it declares; +//! - each base decodes through the sink's own path (`staged::decode_base`); +//! - the publisher signs one group per object, each track a chain of its own, in the +//! labelled payload, so each link is signed (jwp-receipts §2.3); +//! - the relay applies MOQT's delivery timeout at its hop (`staged::moq`), drops what +//! outlives it, forwards the receipts of everything, and signs a `delivery-gap` claim +//! for each object it drops, which checks against the object's receipt; +//! - each subscriber's verifier is a MoQ subscriber's (`ReceiptChains::for_moq`), under +//! the bound the subscriber states, joining where the subscriber joins: it ties each +//! receipt to the receipts track and MOQT object it came on (`admit_on`, +//! `check_moqt`), keeps one chain per key and track, holds a receipt whose parent has +//! not arrived and admits it when the parent does, and, when holding one would pass the +//! bound, refuses those it holds and re-anchors; it checks a delivered object over its +//! bytes (`delivered-verified`) and a dropped one by inclusion (`inclusion-verified`); +//! - its sink takes each object once it has it and its receipt is settled — verified, or +//! refused and offered with no evidence, which the sink refuses — and its observers +//! state a `stage` claim for each base it received, which seals and verifies. +//! +//! When each receipt is verified or refused, the steps the sink takes, its reports, its +//! final units, the stage presented at each clock position, the deadline misses, each +//! layer object's status and evidence and the relay's counts must be the trace's. The +//! subscriber of the base and layer 1 tracks alone holds exactly their two chains, though +//! the receipts of every track were signed on one key; the subscriber whose relay drops +//! one receipt recovers by re-anchoring, then fetches the receipts the anchor skipped, +//! which fill the gap as history; the one that joins anchors each chain. + +#![cfg(all(feature = "staged", feature = "video_int", feature = "receipt_claims"))] + +use std::collections::{BTreeMap, BTreeSet}; + +use ed25519_dalek::SigningKey; +use serde_json::{json, Value}; + +use wai::claim_receipt::{verify_claim_json, ClaimBody, ClaimReceipt, DeliveryGapClaim, GapReason, StageEvidence, StageStatus, SubjectType}; +use wai::jwp_delivery::{Admission, Anchoring, Evidence, GroupBuilder, GroupReceipt, HoldBound as ChainHold, ObjectReceipt, Reason, ReceiptChains, ReceiptsTrack}; +use wai::staged::claim::StageObserver; +use wai::staged::moq::{check_catalog, effective_timeout_ms, expired_at_hop, live_object_rules_ok, priorities_ok}; +use wai::staged::schedule::UnitClock; +use wai::staged::{tier, BaseInfo, ChainConfig, HoldBound, Live, OfferReport, ReceiptEvidence, StageSink, UnitBuf}; + +const DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../staged-conformance"); +const T: &str = "2026-10-06T00:00:00Z"; + +fn read(rel: &str) -> Value { + let b = std::fs::read(format!("{DIR}/{rel}")).unwrap_or_else(|e| panic!("{rel}: {e}")); + serde_json::from_slice(&b).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +fn hex(h: &[u8; 32]) -> String { + h.iter().map(|b| format!("{b:02x}")).collect() +} + +fn u(v: &Value) -> u64 { + v.as_u64().unwrap_or_else(|| panic!("an integer, not {v}")) +} + +fn report_json(r: &OfferReport) -> Value { + json!({ + "object": hex(&r.content_hash), + "envelope": r.envelope.map(|x| x.code()), + "detail": r.detail, + "held_for_base": r.held_for_base, + "applied": r.applied, + "held": r.held, + "refused": r.refused.iter().map(|(u, w)| json!([u, w.code()])).collect::>(), + }) +} + +/// Reports of one step in object order: their order within a step is not part of the +/// outcome. +fn sorted(mut v: Vec) -> Vec { + v.sort_by(|a, b| a["object"].as_str().cmp(&b["object"].as_str())); + v +} + +fn cfg_from(v: &Value) -> ChainConfig { + assert!(v["cost_limit"].is_null(), "the trace's sink applies no cost limit"); + ChainConfig { + bound: HoldBound { max_envelopes: u(&v["bound"]["max_envelopes"]) as usize, max_age_ms: u(&v["bound"]["max_age_ms"]) }, + cost_limit: None, + budget: u(&v["budget"]), + record_budget: u(&v["record_budget"]), + live: Some(Live { + clock: UnitClock { t0_ms: u(&v["live"]["t0_ms"]), unit_ms: u(&v["live"]["unit_ms"]), target_latency_ms: u(&v["live"]["target_latency_ms"]) }, + lead_ms: v["live"]["lead_ms"].as_array().unwrap().iter().map(u).collect(), + }), + } +} + +/// One object of the trace, with its bytes and its receipts. +struct Obj { + track: String, + layer: u64, + alias: u64, + group: u64, + object_id: u64, + file: String, + bytes: Vec, + base: usize, + units: (u64, u64), + publish_ms: u64, + timeout_ms: u64, + up_ms: u64, + queue_ms: u64, + down_ms: u64, + receipts: Option<(GroupReceipt, ObjectReceipt)>, +} + +/// What reaches a subscriber, in time order. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Arrival { + /// An object's receipts, on the receipts track: before an object at the same time. + Receipts(usize), + /// An object's receipts, fetched after its chain re-anchored past them (MOQT §3.2): + /// after the receipts track's at the same time, before an object. + Fetched(usize), + Object(usize), +} + +#[test] +fn staged_tracks_drop_on_timeout_keep_receipts() { + let trace = read("moq/trace.json"); + let catalog = read("moq/catalog.json"); + + // The catalog carries one set that passes §4.1, and the trace's tracks are its tracks. + let checked = check_catalog(&catalog); + assert_eq!(checked.len(), 1); + assert_eq!(checked[0], ("cam1".to_string(), Ok(()))); + let entries: BTreeMap<&str, &Value> = catalog["tracks"].as_array().unwrap().iter().map(|t| (t["name"].as_str().unwrap(), t)).collect(); + let mut prio = Vec::new(); + for t in trace["tracks"].as_array().unwrap() { + let e = entries[t["name"].as_str().unwrap()]; + assert_eq!(e["wai_receipt_alias"], t["receipt_alias"], "the receipts sign the alias the catalog declares"); + assert_eq!(e["wai_staged"]["layer"], t["layer"]); + assert_eq!(e["wai_staged"]["lead_ms"], t["lead_ms"]); + assert_eq!(e["wai_receipts_track"], t["receipts_track"], "each track names its own receipts track"); + assert_eq!(entries[t["receipts_track"].as_str().unwrap()]["packaging"], "wai-receipts"); + prio.push(u(&t["priority"]) as u8); + } + assert!(priorities_ok(prio[0], &prio[1..])); + let track_of: BTreeMap<&str, (u64, u64)> = + trace["tracks"].as_array().unwrap().iter().map(|t| (t["name"].as_str().unwrap(), (u(&t["layer"]), u(&t["receipt_alias"])))).collect(); + + // The envelopes, from the case the trace names. + let case = trace["case"].as_str().unwrap(); + let mut objs: Vec = trace["objects"] + .as_array() + .unwrap() + .iter() + .map(|o| { + let track = o["track"].as_str().unwrap().to_string(); + let file = o["file"].as_str().unwrap().to_string(); + let bytes = std::fs::read(format!("{DIR}/cases/{case}/{file}")).unwrap(); + assert_eq!(hex(&wai::jwp_hash::content_hash(&bytes)), o["object"].as_str().unwrap(), "{file}"); + let (layer, alias) = track_of[track.as_str()]; + Obj { + track, + layer, + alias, + group: u(&o["group"]), + object_id: u(&o["object_id"]), + file, + bytes, + base: u(&o["base"]) as usize, + units: (u(&o["units"]["from"]), u(&o["units"]["count"])), + publish_ms: u(&o["publish_ms"]), + timeout_ms: u(&o["timeout_ms"]), + up_ms: u(&o["up_ms"]), + queue_ms: u(&o["queue_ms"]), + down_ms: u(&o["down_ms"]), + receipts: None, + } + }) + .collect(); + + // Each base decodes through the sink's own path; its units start at its clock position. + let mut bases: Vec<(BaseInfo, Vec)> = Vec::new(); + for o in objs.iter().filter(|o| o.layer == 0) { + let (info, units) = wai::staged::decode_base(&o.bytes, &BTreeMap::new(), None, None).expect("the base decodes"); + assert_eq!(bases.len(), o.base); + bases.push((BaseInfo { first_unit: o.units.0, ..info }, units)); + } + + // The publisher: one object to a group (§4.1), one chain for each track, every link + // signed. Groups are signed in each track's order. + let publisher = SigningKey::from_bytes(&[0x51; 32]); + let mut link: BTreeMap = BTreeMap::new(); + let mut order: Vec = (0..objs.len()).collect(); + order.sort_by_key(|&i| (objs[i].alias, objs[i].group)); + for i in order { + let o = &mut objs[i]; + let mut g = GroupBuilder::default(); + g.add(wai::jwp_hash::content_hash(&o.bytes), 0, "origin.example", None); + let (g, mut os) = g.finish(&publisher, "did:example:publisher", o.alias, o.group, T, T, link.get(&o.alias).copied(), true).expect("a group"); + assert!(live_object_rules_ok(os.len() as u32, true)); + link.insert(o.alias, g.link_hash()); + o.receipts = Some((g, os.remove(0))); + } + + let relay = SigningKey::from_bytes(&[0x52; 32]); + let sink_key = SigningKey::from_bytes(&[0x53; 32]); + let pk = publisher.verifying_key().to_bytes(); + let by_link: BTreeMap<[u8; 32], usize> = objs.iter().enumerate().map(|(i, o)| (o.receipts.as_ref().unwrap().0.link_hash(), i)).collect(); + let cfg = cfg_from(&trace["sink"]); + let expect = &trace["expect"]; + let (mut dropped_on_timeout, mut receipts_forwarded_for_dropped) = (0u64, 0u64); + for (si, sub) in trace["subscribers"].as_array().unwrap().iter().enumerate() { + let exp = &expect["subscribers"][si]; + assert_eq!(exp["name"], sub["name"]); + let tracks: BTreeSet<&str> = sub["tracks"].as_array().unwrap().iter().map(|t| t.as_str().unwrap()).collect(); + assert_eq!(sub["receipts"], true, "every subscriber of the trace reads receipts"); + let joins = sub["joins_ms"].as_u64(); + let lost: BTreeSet<(String, u64)> = sub["lost_receipts"].as_array().unwrap().iter().map(|l| (l["track"].as_str().unwrap().to_string(), u(&l["group"]))).collect(); + // From its join on, what reaches the relay reaches it, of the tracks it chose. + let mine: Vec = (0..objs.len()).filter(|&i| tracks.contains(objs[i].track.as_str()) && joins.is_none_or(|j| objs[i].publish_ms + objs[i].up_ms >= j)).collect(); + + // The relay, at its hop (MOQT §5.2): the timer starts when the object reaches it. + // A receipts-track object the relay drops at its discretion never arrives. + let mut arrivals: Vec<(u64, Arrival)> = Vec::new(); + let mut dropped: BTreeSet = BTreeSet::new(); + let mut fates = Vec::new(); + for &i in &mine { + let o = &objs[i]; + let received = o.publish_ms + o.up_ms; + let send = received + o.queue_ms; + let is_lost = lost.contains(&(o.track.clone(), o.group)); + if !is_lost { + arrivals.push((received + o.down_ms, Arrival::Receipts(i))); + } + if expired_at_hop(effective_timeout_ms(o.timeout_ms, u(&sub["timeout_ms"])), received, send) { + dropped.insert(i); + dropped_on_timeout += 1; + let (g, r) = o.receipts.as_ref().unwrap(); + if !is_lost { + // Its receipts are forwarded all the same (jwp-receipts §4.1). + receipts_forwarded_for_dropped += 1; + } + // The relay says why it did not forward it, under its own key. + let gap = ClaimBody::DeliveryGap(DeliveryGapClaim { track_alias: o.alias, group_id: o.group, object_id: o.object_id, reason: GapReason::DeliveryTimeout, observed_at: T.into() }); + let claim = ClaimReceipt::seal(&relay, "did:example:relay", SubjectType::Object, r.content_hash, o.group, T, gap, None).expect("a gap claim seals"); + assert_eq!(claim.check_gap_against(g.track_alias, g.group_id, r.object_id, &r.content_hash), Ok(())); + assert_eq!(verify_claim_json(&claim.to_json()).map(|_| ()), Ok(())); + fates.push((o.track.clone(), o.group, "dropped", None)); + } else { + arrivals.push((send + o.down_ms, Arrival::Object(i))); + fates.push((o.track.clone(), o.group, "forwarded", Some(send + o.down_ms))); + } + } + let stated: Vec<(String, u64, &str, Option)> = exp["objects"] + .as_array() + .unwrap() + .iter() + .map(|f| (f["track"].as_str().unwrap().to_string(), u(&f["group"]), f["fate"].as_str().unwrap(), f["arrival_ms"].as_u64())) + .collect(); + assert_eq!(fates, stated, "{}: the relay's fates", sub["name"]); + arrivals.sort(); + + // The subscriber: its verifier, its sink and an observer for each base. + let bound = ChainHold::from_json(&sub["hold"]).expect("the subscriber states its bound"); + let mut chains = ReceiptChains::for_moq(bound).expect("a bound a MoQ subscriber may state"); + if joins.is_some() { + chains = chains.with_join(); + } + let mut sink = StageSink::new(cfg.clone()); + let mut observers: Vec = bases.iter().map(|(info, _)| { + let len = objs.iter().find(|o| o.layer == 0 && wai::jwp_hash::content_hash(&o.bytes) == info.content_hash).unwrap().bytes.len(); + StageObserver::new(info.content_hash, len as u64, true) + }).collect(); + // Each receipt, once settled: verified (true) or refused (false), and when. + let mut settled: BTreeMap = BTreeMap::new(); + let mut arrived: BTreeSet = BTreeSet::new(); + let mut taken: BTreeSet = BTreeSet::new(); + let mut steps: Vec = Vec::new(); + let mut reports: Vec = Vec::new(); + let mut anchors_seen: BTreeMap = BTreeMap::new(); + let mut held_parent = 0; + // Take every object that has arrived and whose receipt is settled, in the order the + // trace's sink takes them at one instant: by layer, then group. A verified one is + // delivered-verified; a refused one is offered with no evidence, and refused. + let take = |at: u64, objs: &Vec, ready: &mut Vec, settled: &BTreeMap, sink: &mut StageSink, observers: &mut Vec, steps: &mut Vec, reports: &mut Vec| { + ready.sort_by_key(|&i| (objs[i].layer, objs[i].group)); + for &i in ready.iter() { + let o = &objs[i]; + let (g, r) = o.receipts.as_ref().unwrap(); + let verified = settled[&i].0; + let evidence = if verified { + assert_eq!(r.verify_against(g, Some(&o.bytes)), Ok(Evidence::DeliveredVerified), "{}: over its bytes", o.file); + ReceiptEvidence::DeliveredVerified + } else { + ReceiptEvidence::None + }; + let got = if o.layer == 0 { + assert!(verified, "the trace refuses no base's receipt"); + steps.push(json!({"op": "base", "index": o.base, "at_ms": at})); + let (info, units) = bases[o.base].clone(); + sink.add_base(BaseInfo { attested: true, ..info }, units) + } else { + steps.push(json!({"op": "offer", "file": o.file, "at_ms": at, "evidence": evidence.token()})); + let base = bases[o.base].0.content_hash; + let epoch = sink.base_epoch(&base); + observers[o.base].arrived(epoch, &o.bytes, evidence).expect("a layer of its base"); + sink.offer(&o.bytes, at, evidence).expect("a WAI1") + }; + for (k, ob) in observers.iter_mut().enumerate() { + ob.reports(sink.base_epoch(&bases[k].0.content_hash), &got); + } + reports.push(Value::Array(sorted(got.iter().map(report_json).collect()))); + } + ready.clear(); + }; + // Everything that arrives at one instant is handled before the sink takes what is + // ready then, as the trace's sink takes it. + let fetch_ms = sub["fetch_ms"].as_u64(); + let mut fetched: BTreeMap = BTreeMap::new(); + let mut queue: std::collections::BinaryHeap> = arrivals.iter().copied().map(std::cmp::Reverse).collect(); + while let Some(&std::cmp::Reverse((at, _))) = queue.peek() { + let mut ready = Vec::new(); + while let Some(&std::cmp::Reverse((t, a))) = queue.peek() { + if t != at { + break; + } + queue.pop(); + let now: Vec<(usize, bool)> = match a { + Arrival::Object(i) => { + arrived.insert(i); + if settled.contains_key(&i) { + ready.push(i); + } + continue; + } + Arrival::Fetched(i) => { + // A fetched receipt fills the gap its chain's anchor left: it links + // forward from the group below, or back from the group above. + let o = &objs[i]; + let (g, r) = o.receipts.as_ref().unwrap(); + let track = ReceiptsTrack { signer_pubkey: pk, track_alias: o.alias }; + assert_eq!(r.check_moqt(&track, o.group, o.object_id), Ok(())); + fetched.insert(i, at); + let ok = chains.admit_on(&track, o.group, g, at) == Ok(Admission::History); + assert!(chains.take_events().is_empty()); + vec![(i, ok)] + } + Arrival::Receipts(i) => { + let o = &objs[i]; + let (g, r) = o.receipts.as_ref().unwrap(); + let track = ReceiptsTrack { signer_pubkey: pk, track_alias: o.alias }; + // Each receipt is about the MOQT object it rides for (§4.1). + assert_eq!(r.check_moqt(&track, o.group, o.object_id), Ok(())); + let mut now = Vec::new(); + match chains.admit_on(&track, o.group, g, at) { + Ok(_) => now.push((i, true)), + Err(e) => { + assert_eq!(e.reason, Reason::ParentNotHeld, "{}: held for its parent", o.file); + held_parent += 1; + } + } + for ev in chains.take_events() { + now.push((by_link[&ev.link], ev.outcome.is_ok())); + } + // A re-anchor skips the groups between the last group admitted and + // its anchor. Their layers are refused for want of their receipts, + // unless the subscriber fetches those receipts: then they wait. + let all = chains.anchors(&pk, o.alias); + for an in &all[anchors_seen.get(&o.alias).copied().unwrap_or(0)..] { + let Some(after) = an.after else { continue }; + let skipped: Vec = mine.iter().copied().filter(|&j| objs[j].alias == o.alias && after < objs[j].group && objs[j].group < an.anchor).collect(); + match fetch_ms { + Some(f) => { + now.retain(|(j, _)| !skipped.contains(j)); + queue.extend(skipped.iter().map(|&j| std::cmp::Reverse((at + f, Arrival::Fetched(j))))); + } + None => now.extend(skipped.iter().map(|&j| (j, false))), + } + } + anchors_seen.insert(o.alias, all.len()); + now + } + }; + for (j, ok) in now { + if settled.contains_key(&j) { + continue; + } + settled.insert(j, (ok, at)); + if dropped.contains(&j) { + // A dropped object is accounted for by inclusion, where its receipt verified. + let (g, r) = objs[j].receipts.as_ref().unwrap(); + let evidence = if ok { + assert_eq!(r.verify_against(g, None), Ok(Evidence::InclusionVerified)); + ReceiptEvidence::from(Evidence::InclusionVerified) + } else { + ReceiptEvidence::None + }; + let base = bases[objs[j].base].0.content_hash; + let span = Some(((objs[j].units.0 - bases[objs[j].base].0.first_unit) as u32, objs[j].units.1 as u32)); + observers[objs[j].base].dropped(sink.base_epoch(&base), r.content_hash, objs[j].layer as u8, span, evidence); + } else if arrived.contains(&j) { + ready.push(j); + } + } + } + for &i in &ready { + taken.insert(i); + } + take(at, &objs, &mut ready, &settled, &mut sink, &mut observers, &mut steps, &mut reports); + } + assert_eq!(chains.held_total(), 0, "every receipt is settled in the end"); + assert_eq!(settled.len(), mine.len(), "every object it received has its receipt settled"); + assert_eq!(taken.len() + dropped.len(), mine.len(), "every forwarded object is taken"); + // When each receipt was verified or refused: the trace's. + let when: Vec<(Option, Option)> = mine.iter().map(|i| match settled[i] { + (true, t) => (Some(t), None), + (false, t) => (None, Some(t)), + }).collect(); + let stated: Vec<(Option, Option)> = exp["objects"].as_array().unwrap().iter().map(|f| (f["verified_ms"].as_u64(), f["refused_ms"].as_u64())).collect(); + assert_eq!(when, stated, "{}: each receipt verified or refused, and when", sub["name"]); + let fetched_at: Vec> = mine.iter().map(|i| fetched.get(i).copied()).collect(); + let stated: Vec> = exp["objects"].as_array().unwrap().iter().map(|f| f["fetched_ms"].as_u64()).collect(); + assert_eq!(fetched_at, stated, "{}: which receipts were fetched, and when", sub["name"]); + let last = steps.last().map_or(0, |s| u(&s["at_ms"])); + let end = last + cfg.bound.max_age_ms + 1; + steps.push(json!({"op": "expire", "at_ms": end})); + let got = sink.expire(end); + for (k, ob) in observers.iter_mut().enumerate() { + ob.reports(sink.base_epoch(&bases[k].0.content_hash), &got); + } + reports.push(Value::Array(sorted(got.iter().map(report_json).collect()))); + + // The steps the trace states, at the times its arithmetic gives. + assert_eq!(Value::Array(steps), exp["steps"], "{}: the sink's steps", sub["name"]); + let stated: Vec = exp["reports"].as_array().unwrap().iter().map(|s| Value::Array(sorted(s.as_array().unwrap().clone()))).collect(); + assert_eq!(reports, stated, "{}: the sink's reports", sub["name"]); + // The final units and the stage presented at each clock position, for the bases it + // received. + let mut presented: BTreeMap = BTreeMap::new(); + for (k, (info, units)) in bases.iter().enumerate() { + let Some(c) = sink.chain(&info.content_hash) else { + assert!(exp["bases"][k].is_null(), "{}: base {k} never reached it", sub["name"]); + continue; + }; + let finals = json!({ + "units": c.units().iter().enumerate().map(|(i, s)| json!({ + "stage": s.stage, + "producer": hex(&s.producer), + "frozen": s.frozen, + "tier": tier::token(s.tier), + "shape": [s.buf.shape.0, s.buf.shape.1, s.buf.shape.2], + "stage_sha256": c.unit_sha256(i as u32).map(|h| hex(&h)), + })).collect::>(), + "clip_sha256": c.clip_sha256().map(|h| hex(&h)), + "n_units": units.len(), + }); + assert_eq!(finals, exp["bases"][k], "{}: base {k}'s final units", sub["name"]); + for (i, s) in c.units().iter().enumerate() { + presented.insert(info.first_unit + i as u64, u64::from(s.stage)); + } + } + assert_eq!(json!(presented.values().collect::>()), exp["presented"], "{}: presented", sub["name"]); + + // Each base's stage claim: it seals, it verifies, and it gives each layer object + // the status the trace states, with the evidence its receipt gave. + let mut misses = 0; + let mut status: BTreeMap = BTreeMap::new(); + for (k, ob) in observers.iter().enumerate() { + let base = bases[k].0.content_hash; + let Some(chain) = sink.chain(&base) else { continue }; + let claim = ob.claim(chain, sink.base_epoch(&base)); + misses += claim.deadline_misses; + for o in &claim.objects { + status.insert(hex(&o.object), (o.status, o.evidence, o.bytes)); + } + let sealed = ClaimReceipt::seal(&sink_key, "did:example:sink", SubjectType::Object, base, k as u64, T, ClaimBody::Stage(claim), None).expect("the claim seals"); + assert_eq!(verify_claim_json(&sealed.to_json()).map(|_| ()), Ok(())); + } + assert_eq!(misses, u(&exp["deadline_misses"]), "{}: deadline misses", sub["name"]); + let stated: Vec<(String, String, String)> = exp["statuses"] + .as_array() + .unwrap() + .iter() + .map(|s| (s["object"].as_str().unwrap().to_string(), s["status"].as_str().unwrap().to_string(), s["evidence"].as_str().unwrap().to_string())) + .collect(); + assert_eq!(stated.len(), status.len(), "{}: every layer object it received is in a claim", sub["name"]); + for (obj, st, ev) in &stated { + let (s, e, bytes) = status[obj]; + assert_eq!((s.label(), e.label()), (st.as_str(), ev.as_str()), "{}: {obj}", sub["name"]); + if s == StageStatus::Dropped { + assert_eq!(bytes, 0, "a dropped object is accounted for by inclusion, never as delivered"); + assert_ne!(e, StageEvidence::DeliveredVerified); + } + } + + // One chain for each track it subscribed to, and none for the others. + assert_eq!(chains.chains(), tracks.len(), "{}: one chain per track", sub["name"]); + for t in trace["tracks"].as_array().unwrap() { + let held = chains.head(&publisher.verifying_key().to_bytes(), u(&t["receipt_alias"])).is_some(); + assert_eq!(held, tracks.contains(t["name"].as_str().unwrap())); + } + if sub["name"] == "full" { + assert!(held_parent > 0, "the trace holds a receipt for its parent"); + } + // P-W1 through the MoQ path: a lost receipt, recovered by re-anchoring, the gap + // reported; a joiner, anchored on each chain. + let l1 = u(&trace["tracks"][1]["receipt_alias"]); + if !lost.is_empty() && fetch_ms.is_none() { + let refused = settled.values().filter(|(ok, _)| !ok).count(); + assert!(refused > 0, "{}: the lost receipt's layers are refused", sub["name"]); + } + if !lost.is_empty() { + let an = chains.anchors(&pk, l1); + assert!(an.len() == 1 && !an[0].joined && an[0].after.is_some(), "{}: one re-anchor, the gap reported: {an:?}", sub["name"]); + if fetch_ms.is_some() { + assert!(!fetched.is_empty() && fetched.keys().all(|i| settled[i].0), "{}: every fetched receipt fills the gap, as history", sub["name"]); + } + } + if joins.is_some() { + for t in trace["tracks"].as_array().unwrap() { + let an: &[Anchoring] = chains.anchors(&pk, u(&t["receipt_alias"])); + assert!(an.len() == 1 && an[0].joined && an[0].after.is_none(), "{}: each chain anchored at its first receipt", sub["name"]); + } + } + } + assert_eq!(dropped_on_timeout, u(&expect["relay"]["dropped_on_timeout"])); + assert_eq!(receipts_forwarded_for_dropped, u(&expect["relay"]["receipts_forwarded_for_dropped"])); + assert!(dropped_on_timeout > 0 && receipts_forwarded_for_dropped == dropped_on_timeout); + assert!(trace["subscribers"].as_array().unwrap().iter().any(|s| !s["lost_receipts"].as_array().unwrap().is_empty()), "the trace loses a receipt"); + assert!(trace["subscribers"].as_array().unwrap().iter().any(|s| s["joins_ms"].is_u64()), "the trace has a joiner"); +}