wai: live metering, a room session's units and joules per hop (L9) #317
35 changed files with 10959 additions and 28 deletions
38
.github/workflows/wai-conformance.yml
vendored
38
.github/workflows/wai-conformance.yml
vendored
|
|
@ -84,6 +84,10 @@ on:
|
|||
# tests/timed_text.rs requires this extension's §12 to list the codes the reference
|
||||
# reports, and the registry's wire identifiers test reads its sections.
|
||||
- 'wai/extensions/timed-text.md'
|
||||
# tests/live_metering.rs requires this extension's §9 to list the codes the reference
|
||||
# reports, live_metering_verify.py reads that table, and the registry's wire identifiers
|
||||
# test reads its sections.
|
||||
- 'wai/extensions/live-metering.md'
|
||||
- 'wai/wai-web/**'
|
||||
# The audio provenance check below reads the corpora above with this tool.
|
||||
- 'wai/tools/wai_audio_provenance_check.py'
|
||||
|
|
@ -139,6 +143,10 @@ on:
|
|||
# tests/timed_text.rs requires this extension's §12 to list the codes the reference
|
||||
# reports, and the registry's wire identifiers test reads its sections.
|
||||
- 'wai/extensions/timed-text.md'
|
||||
# tests/live_metering.rs requires this extension's §9 to list the codes the reference
|
||||
# reports, live_metering_verify.py reads that table, and the registry's wire identifiers
|
||||
# test reads its sections.
|
||||
- 'wai/extensions/live-metering.md'
|
||||
- 'wai/wai-web/**'
|
||||
# The audio provenance check below reads the corpora above with this tool.
|
||||
- 'wai/tools/wai_audio_provenance_check.py'
|
||||
|
|
@ -803,7 +811,8 @@ jobs:
|
|||
# live_events_verify.py (the live-events corpus), and live_rooms_verify.py (the
|
||||
# live-rooms corpus), and capture_take_verify.py (the capture-binding corpus), and
|
||||
# live_compose_verify.py (the live-compose corpus), live_record_verify.py (the
|
||||
# live-recording corpus), and timed_text_verify.py (the timed-text corpus).
|
||||
# live-recording corpus), timed_text_verify.py (the timed-text corpus), and
|
||||
# live_metering_verify.py (the live-metering corpus).
|
||||
# Each exits non-zero on any failure or any difference, and the step fails with it.
|
||||
# One platform: what they check is the reading of the text, not the architecture.
|
||||
independent-verifiers:
|
||||
|
|
@ -1032,6 +1041,27 @@ jobs:
|
|||
(cd wai-rs && cargo test --no-default-features --features timed_text --lib timed_text::)
|
||||
(cd wai-rs && cargo test --no-default-features --features timed_text --test timed_text)
|
||||
python3 -I live-conformance/timed_text_verify.py live-conformance/captions
|
||||
# wai.live.metering (extensions/live-metering.md): the reference module's unit tests
|
||||
# (records and ledgers round-trip, the energy rules, the size bound at its limit, a
|
||||
# total of two classes told from a wrong one, funds rounding, the CMCD event-timeline
|
||||
# record and the application/cmcd batch, a ledger entry's audit path) and
|
||||
# tests/live_metering.rs (the committed corpus regenerates byte for byte from its fixed
|
||||
# seeds and the reference relay's recorded record, wai-rs gives every vector its stated
|
||||
# outcome, every reason of §9 refuses a vector, the large audience's relay record is the
|
||||
# lesson's size, and §9 lists exactly the reference's codes); then the corpus's second
|
||||
# implementation, live_metering_verify.py, written from the extension's text and sharing
|
||||
# no code with wai-rs, which verifies each room with live_rooms_verify.py, each take with
|
||||
# capture_take_verify.py and each composition receipt with live_compose_verify.py,
|
||||
# recomputes every unit, total and grant's consumption itself, and requires every
|
||||
# mutated copy of the corpus to fail; standard library only, run isolated (-I).
|
||||
- name: live metering · wai-rs and live_metering_verify.py
|
||||
working-directory: wai
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
(cd wai-rs && cargo test --no-default-features --features live_metering --lib live_metering::)
|
||||
(cd wai-rs && cargo test --no-default-features --features live_metering --test live_metering)
|
||||
python3 -I live-conformance/live_metering_verify.py live-conformance/metering
|
||||
# The quantum corpus's second implementation (quantum-sim extension), integers only:
|
||||
# it derives the circuit/1 phase table by its recurrence and the circuit2 table by an
|
||||
# interval-certified procedure, and requires both literal tables and their digests,
|
||||
|
|
@ -1258,6 +1288,12 @@ jobs:
|
|||
working-directory: wai/wai-rs
|
||||
run: cargo test --lib --no-default-features --features c2pa_emit c2pa_emit
|
||||
|
||||
# A room-session receipt (extensions/live-metering.md §7) in a real signed manifest:
|
||||
# energy_kwh only where one class covers the session whole, the companion always.
|
||||
- name: Emit a room-session receipt, sign, read back, validate
|
||||
working-directory: wai/wai-rs
|
||||
run: cargo test --lib --no-default-features --features c2pa_emit,live_metering c2pa_emit::tests::a_session
|
||||
|
||||
# The feature must stay optional. If `provenance` ever pulls c2pa in, every
|
||||
# consumer that wanted only the binding types inherits a large dependency tree
|
||||
# they did not ask for, and the "off by default" claim in the extension is false.
|
||||
|
|
|
|||
|
|
@ -57,6 +57,32 @@ object framing. The crate's version moves to 0.3.0 because its public API change
|
|||
- `tests/vectors/draft-22/`: a corpus of -22 vectors, good and negative, generated
|
||||
by `tests/draft22_vectors.rs`, and an independent decoder that checks it in CI.
|
||||
|
||||
### Added — `joule-moq-relay`: the per-hop session record of a WAI room
|
||||
|
||||
The relay signs WAI's per-hop session record, `WMH1` (`wai/extensions/live-metering.md`
|
||||
§3), of a room from its session summaries (`metering::hop_record`, `Relay::hop_record`):
|
||||
for each track of the room its sessions carried, the objects and bytes in and out, the
|
||||
objects dropped for their delivery timeout or still queued at the end with those whose
|
||||
receipts it forwarded, the objects refused at the door and deduplicated; its subscriber
|
||||
sessions' time by layer class, from the subscriptions each held; the events it delivered
|
||||
(objects of Object ID above 0 on the room's events tracks); and a ledger of the room grants
|
||||
its sessions ran under, by the token each presented in SETUP (`[[metering.grants]]` in the
|
||||
configuration). Its own energy is the unmetered marker. The record holds nothing per object
|
||||
or per viewer: media tracks are counted one by one, events tracks and every other track of
|
||||
the room together, subscribers by layer class.
|
||||
|
||||
- The session summary carries what the record is made from, and its format is
|
||||
`joule-moq-relay/session-summary/2`: per track, as a publisher, objects and bytes
|
||||
received, refused at the door and deduplicated; per track, as a subscriber, fetched
|
||||
bytes, objects still queued at the end and objects of Object ID above 0; the session's
|
||||
layer spans per room; and the room grant its SETUP token maps to. A datagram's bytes
|
||||
are now counted among a publisher's bytes received.
|
||||
- `tests/room_metering.rs`: two publishers and three subscribers of one room, one
|
||||
subscriber slow, over QUIC and WebTransport; the record verifies under the relay's key
|
||||
and every count in it is the sum of the summaries' counts. Run with
|
||||
`WAI_METERING_FIXTURE_DIR`, the test writes the record and its ledger, which WAI's
|
||||
metering corpus holds as recorded.
|
||||
|
||||
### Added — `joule-moq-relay` 0.1.0: a `draft-ietf-moq-transport-22` relay
|
||||
|
||||
A new crate, library and binary, on `joule-moq`'s codec. Publishers send it tracks,
|
||||
|
|
|
|||
|
|
@ -261,21 +261,56 @@ holds, for the session:
|
|||
streams, objects dropped for their delivery timeout and how many of those were on a
|
||||
track whose receipts track the session also held, subgroups reset after their FIN,
|
||||
objects served by fetch or fill, objects still queued at the end, and the same per
|
||||
track;
|
||||
track, with each track's fetched bytes, objects still queued at the end and objects of
|
||||
Object ID above 0;
|
||||
- the spans of its layer class in each WAI room it subscribed to: the highest layer of the
|
||||
room's media tracks it held, or 8 when it held none of them but other tracks of the room;
|
||||
- as a publisher: objects and bytes received, objects refused at the door, objects
|
||||
deduplicated;
|
||||
deduplicated, and the same per track;
|
||||
- `relay_energy`: `{"acquisition": "unmetered", "joules_micro": 0}`. No meter backs this
|
||||
relay. The 0 is the unmetered marker of WAI `extensions/energy-measurement.md` §2, never
|
||||
a measurement of zero joules, and must not be read or shown as one;
|
||||
- `publisher_signed`: where the door verified an object's receipt, the publisher's signed
|
||||
figure for producing it, summed under the acquisition class its leaf was signed with.
|
||||
These are the publisher's statements about the objects, not the relay's energy.
|
||||
These are the publisher's statements about the objects, not the relay's energy;
|
||||
- `room_grant`, when the token the session presented in SETUP is one `[[metering.grants]]`
|
||||
names: the room grant's digest and its ceilings.
|
||||
|
||||
The summary is relay-scoped telemetry (jwp-receipts §4.2), not a receipt of the publisher's.
|
||||
Its format is `joule-moq-relay/session-summary/1`; the signature covers
|
||||
Its format is `joule-moq-relay/session-summary/2`; the signature covers
|
||||
`joule-moq-relay:session-summary\x01` followed by the summary's JSON bytes, and
|
||||
`SignedSessionSummary::verify` checks it.
|
||||
|
||||
## The per-hop session record
|
||||
|
||||
`Relay::hop_record(room, t0_unix_us)` signs WAI's per-hop session record, `WMH1`
|
||||
(`wai/extensions/live-metering.md` §3), of the room whose id is `room`, over the sessions
|
||||
that ended, on the room clock of the room's `t0`. A track is the room's when its
|
||||
namespace's first field is the room id in hexadecimal (WAI live-rooms §6.1); it is a media
|
||||
track when its namespace ends `audio`, `video` or `screen` and its name is a layer `0` to
|
||||
`7`, an events track when its namespace ends `events` and its name is `0`, and another
|
||||
track of the room otherwise. The record holds:
|
||||
|
||||
- per media track and direction, and for the events tracks and the other tracks together:
|
||||
the objects and bytes received and sent, the objects dropped for their delivery timeout,
|
||||
refused at the door, deduplicated and still queued at the end, and those of the dropped
|
||||
whose receipts the relay forwarded;
|
||||
- its subscriber sessions, their microseconds of each layer class, and the events it
|
||||
delivered to them;
|
||||
- the root of its ledger, one entry for each room grant its sessions ran under (or none, for
|
||||
sessions with no grant): the sessions, the bytes in and out and the subscriber time;
|
||||
- its energy, the unmetered marker.
|
||||
|
||||
As a session closes, the relay ends its subscriptions one at a time, each when its request
|
||||
stream fails; the moments between are classed by what the session still holds, so a
|
||||
session whose media subscriptions end before its receipts or events ones has a span of
|
||||
class 8, typically microseconds, at its end. `HopRecord::to_bytes` is the record and
|
||||
`HopRecord::ledger_bytes` the ledger beside it. The
|
||||
relay connects to no other hop, so every session of it that subscribes is a subscriber
|
||||
session. Nothing in the record is per object or per viewer, so its size depends on the
|
||||
room's published tracks and not on its audience. `tests/room_metering.rs` checks that the
|
||||
record recomputes from the summaries.
|
||||
|
||||
## Configuration
|
||||
|
||||
`joule-moq-relay --config relay.toml`, or `joule-moq-relay --dev` for loopback with a
|
||||
|
|
|
|||
|
|
@ -51,6 +51,15 @@ hold_ms = 2000
|
|||
# key_path = "/etc/joule-moq-relay/summary-seed"
|
||||
# out_path = "/var/log/joule-moq-relay/sessions.jsonl"
|
||||
|
||||
# The room grants tokens carry, for the per-hop session records of WAI rooms (an example;
|
||||
# not a default): a session whose SETUP presents this token runs under this grant.
|
||||
[[metering.grants]]
|
||||
token_sha256 = "1df4b07d05b9b3b1115b4b6b22af319a2f99564a2fa2573a34896063c7513c10"
|
||||
grant = "0000000000000000000000000000000000000000000000000000000000000001"
|
||||
joules_ceiling_micro = 5000000
|
||||
funds_ceiling = 500
|
||||
currency = "EUR"
|
||||
|
||||
[auth]
|
||||
required = false
|
||||
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ pub struct RelayConfig {
|
|||
pub summary: SummaryConfig,
|
||||
#[serde(default)]
|
||||
pub auth: AuthConfig,
|
||||
#[serde(default)]
|
||||
pub metering: MeteringConfig,
|
||||
/// The New Session URI a GOAWAY names (§9.2); empty: reuse the current one.
|
||||
#[serde(default)]
|
||||
pub goaway_uri: String,
|
||||
|
|
@ -279,6 +281,39 @@ pub struct Grant {
|
|||
pub subscribe: bool,
|
||||
}
|
||||
|
||||
/// The room grants the relay's tokens carry (WAI live-rooms §10.1), for its per-hop session
|
||||
/// records (WAI live-metering §3.4): a session whose SETUP presents a token named here runs
|
||||
/// under that grant. The relay holds the grant's digest and ceilings beside the token, as a
|
||||
/// deployment copies them from the grant the room authority signed.
|
||||
#[derive(Clone, Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeteringConfig {
|
||||
#[serde(default)]
|
||||
pub grants: Vec<MeteredGrant>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeteredGrant {
|
||||
/// SHA-256 of the token value, hex, as an authorization grant names it.
|
||||
pub token_sha256: String,
|
||||
/// The room grant's digest, hex (WAI live-rooms §4.2).
|
||||
pub grant: String,
|
||||
/// The grant's joule ceiling, in microjoules; absent for none.
|
||||
#[serde(default = "no_ceiling")]
|
||||
pub joules_ceiling_micro: u64,
|
||||
/// The grant's funds ceiling, in minor units of `currency`; absent for none.
|
||||
#[serde(default = "no_ceiling")]
|
||||
pub funds_ceiling: u64,
|
||||
/// ISO 4217; empty when there is no funds ceiling.
|
||||
#[serde(default)]
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
fn no_ceiling() -> u64 {
|
||||
u64::MAX
|
||||
}
|
||||
|
||||
impl RelayConfig {
|
||||
pub fn from_toml(s: &str) -> Result<Self, String> {
|
||||
toml::from_str(s).map_err(|e| e.to_string())
|
||||
|
|
@ -296,6 +331,7 @@ impl RelayConfig {
|
|||
receipts: ReceiptsConfig::default(),
|
||||
summary: SummaryConfig::default(),
|
||||
auth: AuthConfig::default(),
|
||||
metering: MeteringConfig::default(),
|
||||
goaway_uri: String::new(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@
|
|||
//! - **Session summaries** ([`summary`]): bytes and objects per subscriber, signed by the
|
||||
//! relay at session end; its own energy is reported as unmetered, since no meter backs
|
||||
//! it.
|
||||
//! - **Per-hop session records** ([`metering`]): WAI's `WMH1` record of a room
|
||||
//! (`live-metering.md` §3), signed by the relay from its summaries: per track the
|
||||
//! objects and bytes in and out and the drops by reason, its subscribers' time by layer
|
||||
//! class, the events it delivered, and a ledger of the room grants its sessions ran under.
|
||||
//!
|
||||
//! [`server::RelayServer`] binds the listeners; [`client::Client`] is a small client for
|
||||
//! the tests and for tools.
|
||||
|
|
@ -30,6 +34,7 @@ pub mod cert;
|
|||
pub mod client;
|
||||
pub mod config;
|
||||
pub mod door;
|
||||
pub mod metering;
|
||||
pub mod relay;
|
||||
pub mod sched;
|
||||
pub mod server;
|
||||
|
|
|
|||
457
joule-code/joule-code-rs/crates/joule-moq-relay/src/metering.rs
Normal file
457
joule-code/joule-code-rs/crates/joule-moq-relay/src/metering.rs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
//! The relay's per-hop session record of a room: WAI's `WMH1` (`extensions/live-metering.md`
|
||||
//! §3), signed by the relay from its session summaries.
|
||||
//!
|
||||
//! For each track of the room its sessions carried (a track whose namespace's first field is
|
||||
//! the room id), the objects and bytes in and out, the objects dropped by reason with those
|
||||
//! whose receipts it forwarded; its subscriber sessions' time by layer class, from the
|
||||
//! subscriptions each held; the events it delivered; and a ledger, one entry for each room
|
||||
//! grant its sessions ran under, by the token each presented in SETUP. Its own energy is the
|
||||
//! unmetered marker, since no meter backs this relay. It connects to no other hop, so every
|
||||
//! session of it that subscribes is a subscriber session (§3.3 there).
|
||||
//!
|
||||
//! The record is a batch: it holds no entry per object and none per viewer. Media tracks
|
||||
//! are counted one by one, events tracks and every other track of the room together, and
|
||||
//! subscribers by layer class, so its size does not grow with the audience.
|
||||
|
||||
use crate::summary::{SessionSummary, Tally};
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The record's magic and version (WAI live-metering §3.1).
|
||||
pub const MAGIC: &[u8; 4] = b"WMH1";
|
||||
pub const VERSION: u8 = 1;
|
||||
/// The domain the record's signature covers, before its bytes (§3.5).
|
||||
pub const DOMAIN: &[u8] = b"wai:metering-hop\x01";
|
||||
/// The domain of a ledger entry's leaf (§3.4).
|
||||
pub const LEDGER_DOMAIN: &[u8] = b"wai:metering-ledger\x01";
|
||||
/// The interior node of the ledger's tree (WAI staged-measurement Appendix C).
|
||||
pub const NODE_DOMAIN: &[u8] = b"wai:out-node\x01";
|
||||
/// The relay's role.
|
||||
pub const ROLE_RELAY: u8 = 1;
|
||||
/// The layer classes: layers 0 to 7, and 8 for a session on none of the room's media tracks.
|
||||
pub const CLASSES: usize = 9;
|
||||
pub const NO_MEDIA: u8 = 8;
|
||||
/// A ceiling of none.
|
||||
pub const NO_CEILING: u64 = u64::MAX;
|
||||
|
||||
/// A track entry's kind (§3.2).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Kind {
|
||||
/// A media track: its layer and its digest.
|
||||
Media { layer: u8, digest: [u8; 32] },
|
||||
Events,
|
||||
Other,
|
||||
}
|
||||
|
||||
impl Kind {
|
||||
fn code(&self) -> u8 {
|
||||
match self {
|
||||
Kind::Media { .. } => 1,
|
||||
Kind::Events => 2,
|
||||
Kind::Other => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn track(&self) -> [u8; 32] {
|
||||
match self {
|
||||
Kind::Media { digest, .. } => *digest,
|
||||
_ => [0; 32],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut c = ring::digest::Context::new(&ring::digest::SHA256);
|
||||
for p in parts {
|
||||
c.update(p);
|
||||
}
|
||||
c.finish().as_ref().try_into().expect("32 bytes")
|
||||
}
|
||||
|
||||
/// A track rendered as `draft-ietf-moq-transport-22` §8.8 renders it, read as a track of a
|
||||
/// WAI room (WAI live-rooms §6.1): its room, and whether it is a media track, an events track
|
||||
/// or another track of the room. `None` for a track of no room.
|
||||
pub fn classify(rendered: &str) -> Option<([u8; 32], Kind)> {
|
||||
let (ns, name) = rendered.split_once("--")?;
|
||||
let fields: Vec<&str> = ns.split('-').collect();
|
||||
let room_hex = fields.first()?;
|
||||
if room_hex.len() != 64 || !room_hex.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
|
||||
return None;
|
||||
}
|
||||
let room: [u8; 32] = hex::decode(room_hex).ok()?.try_into().ok()?;
|
||||
let decimal = |s: &str| (s == "0" || (!s.starts_with('0') && !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()))).then(|| s.parse::<u8>().ok()).flatten();
|
||||
let kind = match (fields.len(), fields.get(2).copied(), decimal(name)) {
|
||||
(3, Some("audio" | "video" | "screen"), Some(layer)) if layer <= 7 => Kind::Media { layer, digest: sha256(&[ns.as_bytes(), &[0], name.as_bytes()]) },
|
||||
(3, Some("events"), Some(0)) => Kind::Events,
|
||||
_ => Kind::Other,
|
||||
};
|
||||
Some((room, kind))
|
||||
}
|
||||
|
||||
/// The layer class of a session holding the rendered tracks of one room (§3.3).
|
||||
pub fn layer_class<'a>(tracks: impl Iterator<Item = &'a str>) -> u8 {
|
||||
tracks.filter_map(|t| match classify(t)?.1 {
|
||||
Kind::Media { layer, .. } => Some(layer),
|
||||
_ => None,
|
||||
}).max().unwrap_or(NO_MEDIA)
|
||||
}
|
||||
|
||||
/// One track entry (§3.2).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub kind: u8,
|
||||
pub track: [u8; 32],
|
||||
pub dir: u8,
|
||||
pub objects: u64,
|
||||
pub bytes: u64,
|
||||
pub dropped_timeout: u64,
|
||||
pub dropped_refused: u64,
|
||||
pub dropped_duplicate: u64,
|
||||
pub dropped_closed: u64,
|
||||
pub receipted: u64,
|
||||
}
|
||||
|
||||
/// One ledger entry (§3.4).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct LedgerEntry {
|
||||
pub grant: [u8; 32],
|
||||
pub joules_ceiling: u64,
|
||||
pub funds_ceiling: u64,
|
||||
pub currency: [u8; 3],
|
||||
pub sessions: u32,
|
||||
pub bytes_in: u64,
|
||||
pub bytes_out: u64,
|
||||
pub subscriber_us: [u64; CLASSES],
|
||||
}
|
||||
|
||||
impl LedgerEntry {
|
||||
fn anonymous() -> LedgerEntry {
|
||||
LedgerEntry { grant: [0; 32], joules_ceiling: NO_CEILING, funds_ceiling: NO_CEILING, currency: [0; 3], sessions: 0, bytes_in: 0, bytes_out: 0, subscriber_us: [0; CLASSES] }
|
||||
}
|
||||
|
||||
pub fn encode(&self) -> Vec<u8> {
|
||||
let mut o = Vec::with_capacity(143);
|
||||
o.extend_from_slice(&self.grant);
|
||||
o.extend_from_slice(&self.joules_ceiling.to_le_bytes());
|
||||
o.extend_from_slice(&self.funds_ceiling.to_le_bytes());
|
||||
o.extend_from_slice(&self.currency);
|
||||
o.extend_from_slice(&self.sessions.to_le_bytes());
|
||||
o.extend_from_slice(&self.bytes_in.to_le_bytes());
|
||||
o.extend_from_slice(&self.bytes_out.to_le_bytes());
|
||||
for x in self.subscriber_us {
|
||||
o.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
pub fn leaf(&self) -> [u8; 32] {
|
||||
sha256(&[LEDGER_DOMAIN, &self.encode()])
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 6962's Merkle tree hash over leaf hashes, with the node of §2 there; the root of
|
||||
/// none is 32 zero bytes (§3.4).
|
||||
pub fn root(leaves: &[[u8; 32]]) -> [u8; 32] {
|
||||
match leaves.len() {
|
||||
0 => [0; 32],
|
||||
1 => leaves[0],
|
||||
n => {
|
||||
let mut k = 1;
|
||||
while k * 2 < n {
|
||||
k *= 2;
|
||||
}
|
||||
sha256(&[NODE_DOMAIN, &root(&leaves[..k]), &root(&leaves[k..])])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-hop record, as the relay writes it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HopRecord {
|
||||
pub room: [u8; 32],
|
||||
pub hop: [u8; 32],
|
||||
pub role: u8,
|
||||
pub start: u64,
|
||||
pub end: u64,
|
||||
/// 0 with class tag 0: the unmetered marker.
|
||||
pub joules: u64,
|
||||
pub class_tag: u8,
|
||||
pub entries: Vec<Entry>,
|
||||
pub sessions: u32,
|
||||
pub subscriber_us: [u64; CLASSES],
|
||||
pub events: u64,
|
||||
pub ledger: Vec<LedgerEntry>,
|
||||
pub sig: [u8; 64],
|
||||
}
|
||||
|
||||
impl HopRecord {
|
||||
/// The record's bytes before its signature (§3.1).
|
||||
pub fn body(&self) -> Vec<u8> {
|
||||
let mut o = Vec::with_capacity(281 + 90 * self.entries.len());
|
||||
o.extend_from_slice(MAGIC);
|
||||
o.push(VERSION);
|
||||
o.extend_from_slice(&self.room);
|
||||
o.extend_from_slice(&self.hop);
|
||||
o.push(self.role);
|
||||
o.extend_from_slice(&self.start.to_le_bytes());
|
||||
o.extend_from_slice(&self.end.to_le_bytes());
|
||||
o.extend_from_slice(&self.joules.to_le_bytes());
|
||||
o.push(self.class_tag);
|
||||
o.extend_from_slice(&(self.entries.len() as u16).to_le_bytes());
|
||||
for e in &self.entries {
|
||||
o.push(e.kind);
|
||||
o.extend_from_slice(&e.track);
|
||||
o.push(e.dir);
|
||||
for x in [e.objects, e.bytes, e.dropped_timeout, e.dropped_refused, e.dropped_duplicate, e.dropped_closed, e.receipted] {
|
||||
o.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
}
|
||||
o.extend_from_slice(&self.sessions.to_le_bytes());
|
||||
for x in self.subscriber_us {
|
||||
o.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
o.extend_from_slice(&self.events.to_le_bytes());
|
||||
o.extend_from_slice(&(self.ledger.len() as u32).to_le_bytes());
|
||||
o.extend_from_slice(&self.ledger_root());
|
||||
o
|
||||
}
|
||||
|
||||
pub fn ledger_root(&self) -> [u8; 32] {
|
||||
root(&self.ledger.iter().map(LedgerEntry::leaf).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// The record, signed.
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
let mut o = self.body();
|
||||
o.extend_from_slice(&self.sig);
|
||||
o
|
||||
}
|
||||
|
||||
/// The ledger that travels beside it: its entries, concatenated.
|
||||
pub fn ledger_bytes(&self) -> Vec<u8> {
|
||||
self.ledger.iter().flat_map(LedgerEntry::encode).collect()
|
||||
}
|
||||
|
||||
/// The record's digest: SHA-256 over its bytes before the signature (§3.5).
|
||||
pub fn digest(&self) -> [u8; 32] {
|
||||
sha256(&[&self.body()])
|
||||
}
|
||||
|
||||
fn sign(mut self, key: &SigningKey) -> HopRecord {
|
||||
let mut m = DOMAIN.to_vec();
|
||||
m.extend_from_slice(&self.body());
|
||||
self.sig = key.sign(&m).to_bytes();
|
||||
self
|
||||
}
|
||||
|
||||
/// Check the signature under the key the record names.
|
||||
pub fn verify(&self) -> Result<(), String> {
|
||||
let key = VerifyingKey::from_bytes(&self.hop).map_err(|e| e.to_string())?;
|
||||
let mut m = DOMAIN.to_vec();
|
||||
m.extend_from_slice(&self.body());
|
||||
key.verify(&m, &Signature::from_bytes(&self.sig)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Read a record this module wrote: an unmetered one, as the relay signs.
|
||||
pub fn decode(b: &[u8], ledger: &[u8]) -> Result<HopRecord, String> {
|
||||
let mut p = 0usize;
|
||||
let mut take = |n: usize| -> Result<&[u8], String> {
|
||||
let s = b.get(p..p + n).ok_or("truncated")?;
|
||||
p += n;
|
||||
Ok(s)
|
||||
};
|
||||
let u64_of = |s: &[u8]| u64::from_le_bytes(s.try_into().expect("8"));
|
||||
if take(4)? != MAGIC || take(1)? != [VERSION] {
|
||||
return Err("not a WMH1 record of this version".into());
|
||||
}
|
||||
let room: [u8; 32] = take(32)?.try_into().expect("32");
|
||||
let hop: [u8; 32] = take(32)?.try_into().expect("32");
|
||||
let role = take(1)?[0];
|
||||
let (start, end, joules) = (u64_of(take(8)?), u64_of(take(8)?), u64_of(take(8)?));
|
||||
let class_tag = take(1)?[0];
|
||||
if class_tag != 0 {
|
||||
return Err("this reader reads the unmetered marker alone".into());
|
||||
}
|
||||
let n = u16::from_le_bytes(take(2)?.try_into().expect("2")) as usize;
|
||||
let mut entries = Vec::with_capacity(n);
|
||||
for _ in 0..n {
|
||||
let kind = take(1)?[0];
|
||||
let track: [u8; 32] = take(32)?.try_into().expect("32");
|
||||
let dir = take(1)?[0];
|
||||
let mut x = [0u64; 7];
|
||||
for v in &mut x {
|
||||
*v = u64_of(take(8)?);
|
||||
}
|
||||
entries.push(Entry { kind, track, dir, objects: x[0], bytes: x[1], dropped_timeout: x[2], dropped_refused: x[3], dropped_duplicate: x[4], dropped_closed: x[5], receipted: x[6] });
|
||||
}
|
||||
let sessions = u32::from_le_bytes(take(4)?.try_into().expect("4"));
|
||||
let mut subscriber_us = [0u64; CLASSES];
|
||||
for v in &mut subscriber_us {
|
||||
*v = u64_of(take(8)?);
|
||||
}
|
||||
let events = u64_of(take(8)?);
|
||||
let grants = u32::from_le_bytes(take(4)?.try_into().expect("4")) as usize;
|
||||
let ledger_root: [u8; 32] = take(32)?.try_into().expect("32");
|
||||
let sig: [u8; 64] = take(64)?.try_into().expect("64");
|
||||
if p != b.len() {
|
||||
return Err("bytes after the signature".into());
|
||||
}
|
||||
if ledger.len() != grants * 143 {
|
||||
return Err("a ledger of another length".into());
|
||||
}
|
||||
let ledger: Vec<LedgerEntry> = ledger
|
||||
.chunks(143)
|
||||
.map(|c| {
|
||||
let mut s = [0u64; CLASSES];
|
||||
for (k, v) in s.iter_mut().enumerate() {
|
||||
*v = u64_of(&c[71 + 8 * k..79 + 8 * k]);
|
||||
}
|
||||
LedgerEntry {
|
||||
grant: c[..32].try_into().expect("32"),
|
||||
joules_ceiling: u64_of(&c[32..40]),
|
||||
funds_ceiling: u64_of(&c[40..48]),
|
||||
currency: c[48..51].try_into().expect("3"),
|
||||
sessions: u32::from_le_bytes(c[51..55].try_into().expect("4")),
|
||||
bytes_in: u64_of(&c[55..63]),
|
||||
bytes_out: u64_of(&c[63..71]),
|
||||
subscriber_us: s,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let r = HopRecord { room, hop, role, start, end, joules, class_tag, entries, sessions, subscriber_us, events, ledger, sig };
|
||||
if r.ledger_root() != ledger_root {
|
||||
return Err("the ledger is not the one the record signs".into());
|
||||
}
|
||||
Ok(r)
|
||||
}
|
||||
}
|
||||
|
||||
fn hex32(s: &str) -> Option<[u8; 32]> {
|
||||
hex::decode(s).ok()?.try_into().ok()
|
||||
}
|
||||
|
||||
/// The ledger entry a session's grant opens: the grant its SETUP token maps to, or none.
|
||||
fn ledger_base(t: &Tally) -> LedgerEntry {
|
||||
match &t.room_grant {
|
||||
Some(g) => match hex32(&g.grant) {
|
||||
Some(grant) => {
|
||||
let mut currency = [0u8; 3];
|
||||
if g.currency.len() == 3 {
|
||||
currency.copy_from_slice(g.currency.as_bytes());
|
||||
}
|
||||
LedgerEntry { grant, joules_ceiling: g.joules_ceiling_micro, funds_ceiling: g.funds_ceiling, currency, ..LedgerEntry::anonymous() }
|
||||
}
|
||||
None => LedgerEntry::anonymous(),
|
||||
},
|
||||
None => LedgerEntry::anonymous(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay's record of `room` over the sessions `summaries` describe, on the room clock
|
||||
/// of `t0_unix_us` (live-rooms §3.1's `t0`), signed with `key`. Every count is the sum of the
|
||||
/// summaries' counts, so the record recomputes from them.
|
||||
pub fn hop_record(summaries: &[SessionSummary], room: [u8; 32], t0_unix_us: u64, key: &SigningKey) -> HopRecord {
|
||||
let room_hex = hex::encode(room);
|
||||
let clock = |unix_us: u64| unix_us.saturating_sub(t0_unix_us);
|
||||
let mut entries: BTreeMap<(u8, [u8; 32], u8), Entry> = BTreeMap::new();
|
||||
let mut ledger: BTreeMap<[u8; 32], LedgerEntry> = BTreeMap::new();
|
||||
let (mut start, mut end) = (u64::MAX, 0u64);
|
||||
let (mut sessions, mut subscriber_us, mut events) = (0u32, [0u64; CLASSES], 0u64);
|
||||
for s in summaries {
|
||||
let t = &s.tally;
|
||||
let mut touched = false;
|
||||
let (mut bytes_in, mut bytes_out) = (0u64, 0u64);
|
||||
for (name, x) in &t.subscriber.tracks {
|
||||
let Some((_, kind)) = classify(name).filter(|(r, _)| *r == room) else { continue };
|
||||
touched = true;
|
||||
let e = entries.entry((kind.code(), kind.track(), 2)).or_insert(Entry { kind: kind.code(), track: kind.track(), dir: 2, ..Entry::default() });
|
||||
e.objects += x.objects_forwarded + x.fetch_objects;
|
||||
e.bytes += x.object_bytes_forwarded + x.fetch_bytes;
|
||||
e.dropped_timeout += x.dropped_delivery_timeout;
|
||||
e.dropped_closed += x.undelivered_at_close;
|
||||
e.receipted += x.dropped_receipts_track_subscribed;
|
||||
bytes_out += x.object_bytes_forwarded + x.fetch_bytes;
|
||||
}
|
||||
for (name, x) in &t.publisher.tracks {
|
||||
let Some((_, kind)) = classify(name).filter(|(r, _)| *r == room) else { continue };
|
||||
touched = true;
|
||||
let e = entries.entry((kind.code(), kind.track(), 1)).or_insert(Entry { kind: kind.code(), track: kind.track(), dir: 1, ..Entry::default() });
|
||||
e.objects += x.objects_received;
|
||||
e.bytes += x.object_bytes_received;
|
||||
e.dropped_refused += x.refused_at_door;
|
||||
e.dropped_duplicate += x.deduplicated;
|
||||
bytes_in += x.object_bytes_received;
|
||||
}
|
||||
let spans: Vec<_> = t.subscriber.layer_spans.iter().filter(|x| x.room == room_hex).collect();
|
||||
if !touched && spans.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let base = ledger_base(t);
|
||||
let l = ledger.entry(base.grant).or_insert(base);
|
||||
l.sessions += 1;
|
||||
l.bytes_in += bytes_in;
|
||||
l.bytes_out += bytes_out;
|
||||
start = start.min(clock(s.opened_unix_ms * 1000));
|
||||
end = end.max(clock((s.closed_unix_ms + 1) * 1000));
|
||||
if !spans.is_empty() {
|
||||
sessions += 1;
|
||||
for x in &spans {
|
||||
let us = x.to_unix_us - x.from_unix_us;
|
||||
subscriber_us[x.class as usize] += us;
|
||||
l.subscriber_us[x.class as usize] += us;
|
||||
start = start.min(clock(x.from_unix_us));
|
||||
end = end.max(clock(x.to_unix_us));
|
||||
}
|
||||
events += t.subscriber.tracks.iter().filter(|(n, _)| matches!(classify(n), Some((r, Kind::Events)) if r == room)).map(|(_, x)| x.incremental_objects).sum::<u64>();
|
||||
}
|
||||
}
|
||||
if start >= end {
|
||||
(start, end) = (0, 1);
|
||||
}
|
||||
HopRecord {
|
||||
room,
|
||||
hop: key.verifying_key().to_bytes(),
|
||||
role: ROLE_RELAY,
|
||||
start,
|
||||
end,
|
||||
joules: 0,
|
||||
class_tag: 0,
|
||||
entries: entries.into_values().collect(),
|
||||
sessions,
|
||||
subscriber_us,
|
||||
events,
|
||||
ledger: ledger.into_values().collect(),
|
||||
sig: [0; 64],
|
||||
}
|
||||
.sign(key)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tracks_of_a_room_are_told_apart() {
|
||||
let room = "ab".repeat(32);
|
||||
let owner = "cd".repeat(32);
|
||||
let (r, k) = classify(&format!("{room}-{owner}-video--2")).unwrap();
|
||||
assert_eq!(r, [0xab; 32]);
|
||||
assert!(matches!(k, Kind::Media { layer: 2, .. }));
|
||||
assert_eq!(classify(&format!("{room}-{owner}-events--0")).unwrap().1, Kind::Events);
|
||||
assert_eq!(classify(&format!("{room}-{owner}-video--0.2freceipts")).unwrap().1, Kind::Other);
|
||||
assert_eq!(classify(&format!("{room}-{owner}-video--08")).unwrap().1, Kind::Other);
|
||||
assert!(classify("live-a--cam").is_none());
|
||||
assert_eq!(layer_class([format!("{room}-{owner}-audio--0"), format!("{room}-{owner}-video--1")].iter().map(String::as_str)), 1);
|
||||
assert_eq!(layer_class([format!("{room}-{owner}-events--0")].iter().map(String::as_str)), NO_MEDIA);
|
||||
}
|
||||
|
||||
/// The root is RFC 6962's: two leaves are one node.
|
||||
#[test]
|
||||
fn the_ledger_root() {
|
||||
let (a, b) = ([1u8; 32], [2u8; 32]);
|
||||
assert_eq!(root(&[]), [0; 32]);
|
||||
assert_eq!(root(&[a]), a);
|
||||
assert_eq!(root(&[a, b]), sha256(&[NODE_DOMAIN, &a, &b]));
|
||||
assert_eq!(root(&[a, b, a]), sha256(&[NODE_DOMAIN, &root(&[a, b]), &a]));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,7 @@ use crate::cache::{self, Budget, CachedObject, Insert, TrackCache};
|
|||
use crate::config::RelayConfig;
|
||||
use crate::door::Door;
|
||||
use crate::sched::{Item, LaneKey, LaneKind, ObjMeta, Op, Prio, SchedHandle};
|
||||
use crate::summary::{class_label, SignedSessionSummary, Tally};
|
||||
use crate::summary::{class_label, RoomGrantRef, SignedSessionSummary, Tally};
|
||||
use crate::transport::{BoxSend, SharedConnection, TransportKind};
|
||||
use crate::wire::{ReqTx, UniReader};
|
||||
use bytes::Bytes;
|
||||
|
|
@ -100,6 +100,8 @@ pub struct DownSub {
|
|||
pub params: Mutex<SubParams>,
|
||||
/// A subscription to a receipts track: no delivery timeout applies to it.
|
||||
pub receipts_track: bool,
|
||||
/// The tally of its session, which keeps the layer class it subscribes at.
|
||||
pub tally: Arc<Mutex<Tally>>,
|
||||
pub tx: ReqTx,
|
||||
pub ended: watch::Sender<bool>,
|
||||
}
|
||||
|
|
@ -273,6 +275,10 @@ pub fn now_unix_ms() -> u64 {
|
|||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn now_unix_us() -> u64 {
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_micros() as u64).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn load_key(path: &std::path::Path) -> Result<SigningKey, String> {
|
||||
let raw = std::fs::read(path).map_err(|e| format!("the summary key {}: {e}", path.display()))?;
|
||||
let seed: [u8; 32] = if raw.len() == 32 {
|
||||
|
|
@ -341,6 +347,27 @@ impl Relay {
|
|||
self.inner.summaries.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// The room grant the tokens of a SETUP map to (the configuration's `metering.grants`),
|
||||
/// for the session's tally.
|
||||
pub fn metered_grant(&self, tokens: &[crate::auth::Resolved]) -> Option<RoomGrantRef> {
|
||||
tokens.iter().filter(|(ty, _)| *ty == 0).find_map(|(_, value)| {
|
||||
let h = hex::encode(crate::auth::sha256(value));
|
||||
self.config().metering.grants.iter().find(|g| g.token_sha256.eq_ignore_ascii_case(&h)).map(|g| RoomGrantRef {
|
||||
grant: g.grant.to_ascii_lowercase(),
|
||||
joules_ceiling_micro: g.joules_ceiling_micro,
|
||||
funds_ceiling: g.funds_ceiling,
|
||||
currency: g.currency.clone(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The relay's per-hop session record of `room` over the sessions that ended (WAI
|
||||
/// live-metering §3), on the room clock of `t0_unix_us`, signed with the relay's key.
|
||||
pub fn hop_record(&self, room: [u8; 32], t0_unix_us: u64) -> crate::metering::HopRecord {
|
||||
let summaries: Vec<_> = self.summaries().into_iter().map(|s| s.summary).collect();
|
||||
crate::metering::hop_record(&summaries, room, t0_unix_us, &self.inner.key)
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> RelayStats {
|
||||
let s = &self.inner.stats;
|
||||
let st = self.inner.state.lock().unwrap();
|
||||
|
|
@ -446,7 +473,9 @@ impl Relay {
|
|||
match track.owners.get(&(group, o.subgroup_id)) {
|
||||
Some(owner) if *owner != o.upstream => {
|
||||
if let Some(t) = session_tally {
|
||||
t.lock().unwrap().publisher.deduplicated += 1;
|
||||
let mut t = t.lock().unwrap();
|
||||
t.publisher.deduplicated += 1;
|
||||
t.published(&track.render).deduplicated += 1;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
@ -498,6 +527,7 @@ impl Relay {
|
|||
let meta = ObjMeta {
|
||||
track: sub.render.clone(),
|
||||
payload_len: o.object.payload.len() as u64,
|
||||
object_id: oid,
|
||||
receipts_subscribed: rsessions.contains(&sub.session),
|
||||
fetched: false,
|
||||
signed: o.signed,
|
||||
|
|
@ -578,7 +608,7 @@ impl Relay {
|
|||
let mut out = d.clone();
|
||||
out.track_alias = sub.alias;
|
||||
let prio = Prio { subscriber: p.subscriber_priority, publisher: priority, group_rank: Prio::group_rank(p.order, d.group_id), fill_rank: 1, mode_rank: 0, within: d.object_id };
|
||||
let meta = ObjMeta { track: sub.render.clone(), payload_len: payload.len() as u64, receipts_subscribed: rsessions.contains(&sub.session), fetched: false, signed };
|
||||
let meta = ObjMeta { track: sub.render.clone(), payload_len: payload.len() as u64, object_id: d.object_id, receipts_subscribed: rsessions.contains(&sub.session), fetched: false, signed };
|
||||
sub.sched.push(LaneKey { sub: sub.id, kind: LaneKind::Datagram { group: d.group_id, object: d.object_id } }, Item { op: Op::Datagram { datagram: out }, prio, deadline, meta: Some(meta) });
|
||||
}
|
||||
self.charge_cache(&mut st, track_id, d.group_id, size);
|
||||
|
|
@ -628,7 +658,11 @@ impl Relay {
|
|||
match st.tracks.get_mut(&sub.track) {
|
||||
None => Vec::new(),
|
||||
Some(track) => {
|
||||
let before = track.downs.len();
|
||||
track.downs.retain(|d| d.id != sub.id);
|
||||
if track.downs.len() != before {
|
||||
sub.tally.lock().unwrap().unsubscribed(&sub.render, now_unix_us());
|
||||
}
|
||||
if track.downs.is_empty() {
|
||||
let mut c = Vec::new();
|
||||
track.upstreams.retain(|u| match &u.kind {
|
||||
|
|
@ -712,6 +746,7 @@ impl Relay {
|
|||
/// End a downstream subscription: after its queue drains, PUBLISH_DONE with the streams
|
||||
/// it opened, then a FIN (§6.4.2.2, §9.9).
|
||||
pub fn end_down(sub: Arc<DownSub>, status: PublishDoneCode) {
|
||||
sub.tally.lock().unwrap().unsubscribed(&sub.render, now_unix_us());
|
||||
let _ = sub.ended.send(true);
|
||||
tokio::spawn(async move {
|
||||
let n = sub.sched.drained(sub.id).await;
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ pub struct ObjMeta {
|
|||
/// The track, rendered (§8.8).
|
||||
pub track: Arc<str>,
|
||||
pub payload_len: u64,
|
||||
/// Its Object ID: above 0, on an MSF event timeline, an incremental update.
|
||||
pub object_id: u64,
|
||||
/// The session also subscribes to this track's receipts track.
|
||||
pub receipts_subscribed: bool,
|
||||
/// A fetch or fill object.
|
||||
|
|
@ -613,7 +615,9 @@ impl Sched {
|
|||
t.subscriber.wire_bytes_sent += wire;
|
||||
if m.fetched {
|
||||
t.subscriber.fetch_objects += 1;
|
||||
t.track(&m.track).fetch_objects += 1;
|
||||
let tr = t.track(&m.track);
|
||||
tr.fetch_objects += 1;
|
||||
tr.fetch_bytes += m.payload_len;
|
||||
} else {
|
||||
t.subscriber.objects_forwarded += 1;
|
||||
t.subscriber.object_bytes_forwarded += m.payload_len;
|
||||
|
|
@ -621,6 +625,9 @@ impl Sched {
|
|||
tr.objects_forwarded += 1;
|
||||
tr.object_bytes_forwarded += m.payload_len;
|
||||
}
|
||||
if m.object_id > 0 {
|
||||
t.track(&m.track).incremental_objects += 1;
|
||||
}
|
||||
if let Some((j, class)) = m.signed {
|
||||
let f = t.publisher_signed.entry(class.to_string()).or_default();
|
||||
f.objects += 1;
|
||||
|
|
@ -730,7 +737,13 @@ impl Sched {
|
|||
|
||||
fn close(&mut self) {
|
||||
let left: u64 = self.lanes.values().map(|l| l.queue.iter().filter(|(_, i)| i.meta.is_some()).count() as u64).sum();
|
||||
self.tally.lock().unwrap().subscriber.undelivered_at_close += left;
|
||||
{
|
||||
let mut t = self.tally.lock().unwrap();
|
||||
t.subscriber.undelivered_at_close += left;
|
||||
for m in self.lanes.values().flat_map(|l| l.queue.iter().filter_map(|(_, i)| i.meta.as_ref())) {
|
||||
t.track(&m.track).undelivered_at_close += 1;
|
||||
}
|
||||
}
|
||||
for lane in self.lanes.values_mut() {
|
||||
if let Some(s) = lane.send.as_mut() {
|
||||
s.reset(StreamResetCode::SESSION_CLOSED.0);
|
||||
|
|
|
|||
|
|
@ -199,7 +199,8 @@ pub async fn serve(relay: Relay, conn: SharedConnection) {
|
|||
}
|
||||
Err(_) => return,
|
||||
};
|
||||
let tally = Arc::new(Mutex::new(Tally::default()));
|
||||
let opening = Tally { room_grant: relay.metered_grant(&setup_tokens), ..Tally::default() };
|
||||
let tally = Arc::new(Mutex::new(opening));
|
||||
let sched = sched::spawn(conn.clone(), cfg.limits.write_concurrency, tally.clone());
|
||||
let ctx = Arc::new(SessionCtx {
|
||||
id,
|
||||
|
|
@ -312,8 +313,16 @@ async fn datagram_loop(relay: Relay, ctx: Arc<SessionCtx>) {
|
|||
None => continue,
|
||||
};
|
||||
{
|
||||
let len = match &d.content {
|
||||
joule_moq::draft22::data::DatagramContent::Payload(p) => p.len() as u64,
|
||||
_ => 0,
|
||||
};
|
||||
let mut t = ctx.tally.lock().unwrap();
|
||||
t.publisher.objects_received += 1;
|
||||
t.publisher.object_bytes_received += len;
|
||||
let tr = t.published(&joule_moq::draft22::names::render(&key.0, Some(&key.1)));
|
||||
tr.objects_received += 1;
|
||||
tr.object_bytes_received += len;
|
||||
}
|
||||
relay.inner.stats.objects_in.fetch_add(1, Ordering::Relaxed);
|
||||
if relay.inner.door.checks(&key.0) && relay.inner.door.media_of(&key.1).is_none() {
|
||||
|
|
@ -339,7 +348,11 @@ async fn datagram_loop(relay: Relay, ctx: Arc<SessionCtx>) {
|
|||
|
||||
fn refused(relay: &Relay, ctx: &SessionCtx, key: &TrackKey, group: u64, object: u64, why: &str) {
|
||||
relay.inner.stats.refused_at_door.fetch_add(1, Ordering::Relaxed);
|
||||
ctx.tally.lock().unwrap().publisher.refused_at_door += 1;
|
||||
{
|
||||
let mut t = ctx.tally.lock().unwrap();
|
||||
t.publisher.refused_at_door += 1;
|
||||
t.published(&joule_moq::draft22::names::render(&key.0, Some(&key.1))).refused_at_door += 1;
|
||||
}
|
||||
tracing::warn!(
|
||||
target: "joule_moq_relay::door",
|
||||
session = ctx.id,
|
||||
|
|
@ -409,6 +422,7 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc<SessionCtx>, h: joule_moq::dra
|
|||
};
|
||||
let door_on = relay.inner.door.checks(&key.0);
|
||||
let media_of = relay.inner.door.media_of(&key.1);
|
||||
let rendered = joule_moq::draft22::names::render(&key.0, Some(&key.1));
|
||||
let (mut object_timeout, mut subgroup_timeout) = {
|
||||
let st = relay.inner.state.lock().unwrap();
|
||||
st.tracks.get(&key).map_or((0, 0), |t| (t.pub_object_timeout, t.pub_subgroup_timeout))
|
||||
|
|
@ -439,6 +453,9 @@ async fn upstream_subgroup(relay: Relay, ctx: Arc<SessionCtx>, h: joule_moq::dra
|
|||
let mut t = ctx.tally.lock().unwrap();
|
||||
t.publisher.objects_received += 1;
|
||||
t.publisher.object_bytes_received += o.payload.len() as u64;
|
||||
let tr = t.published(&rendered);
|
||||
tr.objects_received += 1;
|
||||
tr.object_bytes_received += o.payload.len() as u64;
|
||||
}
|
||||
relay.inner.stats.objects_in.fetch_add(1, Ordering::Relaxed);
|
||||
let mut signed = None;
|
||||
|
|
@ -778,6 +795,7 @@ async fn subscribe(relay: Relay, ctx: Arc<SessionCtx>, r: TrackRequest, tx: ReqT
|
|||
sched: ctx.sched.clone(),
|
||||
params: Mutex::new(p),
|
||||
receipts_track: is_receipts,
|
||||
tally: ctx.tally.clone(),
|
||||
tx: tx.clone(),
|
||||
ended: watch::channel(false).0,
|
||||
});
|
||||
|
|
@ -794,6 +812,7 @@ async fn subscribe(relay: Relay, ctx: Arc<SessionCtx>, r: TrackRequest, tx: ReqT
|
|||
let Some((sub, ok)) = made else {
|
||||
return reject(&tx, RequestErrorCode::DOES_NOT_EXIST, "the publisher left").await;
|
||||
};
|
||||
ctx.tally.lock().unwrap().subscribed(&sub.render, crate::relay::now_unix_us());
|
||||
let mut ended = sub.ended.subscribe();
|
||||
if tx.send(&ControlMessage::SubscribeOk(ok)).await.is_err() {
|
||||
relay.remove_down(&sub);
|
||||
|
|
@ -951,7 +970,7 @@ fn queue_from_cache(relay: &Relay, sched: &SchedHandle, key: &TrackKey, start: L
|
|||
payload: payload.to_vec(),
|
||||
});
|
||||
let p = Prio { subscriber: prio, publisher: publisher_priority, group_rank: Prio::group_rank(order, g), fill_rank: if fill { 0 } else { 1 }, mode_rank: 1, within: o };
|
||||
let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, receipts_subscribed: false, fetched: true, signed: None };
|
||||
let meta = ObjMeta { track: render.clone(), payload_len: payload.len() as u64, object_id: o, receipts_subscribed: false, fetched: true, signed: None };
|
||||
sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta: Some(meta) });
|
||||
n += 1;
|
||||
}
|
||||
|
|
@ -1044,7 +1063,7 @@ async fn upstream_fetch(
|
|||
FetchEntry::Object(f) => (f.publisher_priority, f.group_id, f.object_id, f.payload.len() as u64),
|
||||
FetchEntry::EndOfRange { location, .. } => (u8::MAX, location.group, location.object, 0),
|
||||
};
|
||||
let meta = matches!(entry, FetchEntry::Object(_)).then(|| ObjMeta { track: render.clone(), payload_len: len, receipts_subscribed: false, fetched: true, signed: None });
|
||||
let meta = matches!(entry, FetchEntry::Object(_)).then(|| ObjMeta { track: render.clone(), payload_len: len, object_id: o, receipts_subscribed: false, fetched: true, signed: None });
|
||||
let p = Prio { subscriber: prio, publisher: pp, group_rank: Prio::group_rank(order, g), fill_rank: if fill { 0 } else { 1 }, mode_rank: 1, within: o };
|
||||
sched.push(lane, Item { op: Op::FetchEntry { request_id, order, entry }, prio: p, deadline: None, meta });
|
||||
}
|
||||
|
|
@ -1391,7 +1410,12 @@ async fn cleanup(relay: &Relay, ctx: &Arc<SessionCtx>, why: TransportError) {
|
|||
TransportError::Closed { code, by_peer: true, .. } if *code != u64::MAX => Some(*code),
|
||||
_ => *ctx.close_code.lock().unwrap(),
|
||||
};
|
||||
let tally = ctx.tally.lock().unwrap().clone();
|
||||
let closed_us = crate::relay::now_unix_us();
|
||||
let tally = {
|
||||
let mut t = ctx.tally.lock().unwrap();
|
||||
t.close_spans(closed_us);
|
||||
t.clone()
|
||||
};
|
||||
let summary = SessionSummary {
|
||||
format: FORMAT.into(),
|
||||
relay_key: hex::encode(relay.summary_key().to_bytes()),
|
||||
|
|
@ -1400,7 +1424,7 @@ async fn cleanup(relay: &Relay, ctx: &Arc<SessionCtx>, why: TransportError) {
|
|||
remote: ctx.remote.clone(),
|
||||
draft: String::from_utf8_lossy(joule_moq::draft22::ALPN).into_owned(),
|
||||
opened_unix_ms: ctx.opened_unix_ms,
|
||||
closed_unix_ms: now_unix_ms(),
|
||||
closed_unix_ms: closed_us / 1000,
|
||||
close_code,
|
||||
relay_energy: RelayEnergy::unmetered(),
|
||||
tally,
|
||||
|
|
|
|||
|
|
@ -9,13 +9,19 @@
|
|||
//! `energy-measurement.md` §2). Where the door verified an object's receipt, the
|
||||
//! publisher's signed figure for producing that object is summed under the class it was
|
||||
//! signed with, and labelled as the publisher's: it is not the relay's energy.
|
||||
//!
|
||||
//! A summary also keeps what WAI's per-hop session record is made from (WAI
|
||||
//! `live-metering.md` §3): per track the objects received, refused and deduplicated, per
|
||||
//! track the objects of Object ID above 0 forwarded and those still queued at the end, the
|
||||
//! spans of each layer class the session subscribed at, and the room grant its token maps
|
||||
//! to. [`crate::metering::hop_record`] signs that record from the summaries.
|
||||
|
||||
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The format name a summary carries.
|
||||
pub const FORMAT: &str = "joule-moq-relay/session-summary/1";
|
||||
pub const FORMAT: &str = "joule-moq-relay/session-summary/2";
|
||||
/// The domain the relay's signature covers, before the summary's bytes.
|
||||
pub const SIGNING_DOMAIN: &[u8] = b"joule-moq-relay:session-summary\x01";
|
||||
|
||||
|
|
@ -35,6 +41,37 @@ pub struct TrackTally {
|
|||
pub subgroups_reset_after_fin: u64,
|
||||
/// Objects served on a fetch or fill stream.
|
||||
pub fetch_objects: u64,
|
||||
/// Their payload bytes.
|
||||
#[serde(default)]
|
||||
pub fetch_bytes: u64,
|
||||
/// Objects of this track still queued when the session ended.
|
||||
#[serde(default)]
|
||||
pub undelivered_at_close: u64,
|
||||
/// Objects forwarded whose Object ID is above 0: on an MSF event timeline, its
|
||||
/// incremental updates, one event each (WAI live-events §8.1).
|
||||
#[serde(default)]
|
||||
pub incremental_objects: u64,
|
||||
}
|
||||
|
||||
/// One span of a subscriber session's layer class in a room (WAI live-metering §3.3): the
|
||||
/// highest layer of the room's media tracks it was subscribed to, 0 to 7, or 8 when it was
|
||||
/// subscribed to tracks of the room but to none of its media tracks.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LayerSpan {
|
||||
/// The room id, 64 lowercase hexadecimal digits.
|
||||
pub room: String,
|
||||
pub class: u8,
|
||||
pub from_unix_us: u64,
|
||||
pub to_unix_us: u64,
|
||||
}
|
||||
|
||||
/// The subscriptions a session holds to one room's tracks, while it holds them.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct OpenRoom {
|
||||
/// Each track held, rendered, with the number of subscriptions to it.
|
||||
pub tracks: BTreeMap<String, u32>,
|
||||
pub class: u8,
|
||||
pub since_unix_us: u64,
|
||||
}
|
||||
|
||||
/// What a session received as a subscriber.
|
||||
|
|
@ -50,6 +87,18 @@ pub struct SubscriberTally {
|
|||
/// Objects still queued when the session ended.
|
||||
pub undelivered_at_close: u64,
|
||||
pub tracks: BTreeMap<String, TrackTally>,
|
||||
/// The spans of its layer class in each room it subscribed to (WAI live-metering §3.3).
|
||||
#[serde(default)]
|
||||
pub layer_spans: Vec<LayerSpan>,
|
||||
}
|
||||
|
||||
/// One track as one upstream session sent it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PublisherTrackTally {
|
||||
pub objects_received: u64,
|
||||
pub object_bytes_received: u64,
|
||||
pub refused_at_door: u64,
|
||||
pub deduplicated: u64,
|
||||
}
|
||||
|
||||
/// What a session sent as a publisher.
|
||||
|
|
@ -61,6 +110,24 @@ pub struct PublisherTally {
|
|||
pub refused_at_door: u64,
|
||||
/// Objects already received from another publisher of the track (§7.3).
|
||||
pub deduplicated: u64,
|
||||
/// The same, per track, rendered (§8.8).
|
||||
#[serde(default)]
|
||||
pub tracks: BTreeMap<String, PublisherTrackTally>,
|
||||
}
|
||||
|
||||
/// The room grant a session runs under, as the token it presented in SETUP maps it (WAI
|
||||
/// live-rooms §10.1): the grant's digest and its ceilings. The relay holds these beside the
|
||||
/// token; the grant itself is the room authority's record.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct RoomGrantRef {
|
||||
/// The grant's digest, 64 lowercase hexadecimal digits.
|
||||
pub grant: String,
|
||||
/// In microjoules; 2^64 − 1 for none.
|
||||
pub joules_ceiling_micro: u64,
|
||||
/// In minor units of `currency`; 2^64 − 1 for none.
|
||||
pub funds_ceiling: u64,
|
||||
/// ISO 4217, or empty for none.
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
/// Publisher-signed production figures of objects delivered to the session, summed
|
||||
|
|
@ -78,12 +145,82 @@ pub struct Tally {
|
|||
pub publisher: PublisherTally,
|
||||
/// Keyed by acquisition class label (`unmetered` for the marker).
|
||||
pub publisher_signed: BTreeMap<String, SignedFigure>,
|
||||
/// The room grant the session's SETUP token maps to, where the configuration maps one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub room_grant: Option<RoomGrantRef>,
|
||||
/// The subscriptions the session holds to each room's tracks, by room; not part of the
|
||||
/// summary.
|
||||
#[serde(skip)]
|
||||
pub open_rooms: BTreeMap<String, OpenRoom>,
|
||||
}
|
||||
|
||||
impl Tally {
|
||||
pub fn track(&mut self, name: &str) -> &mut TrackTally {
|
||||
self.subscriber.tracks.entry(name.to_string()).or_default()
|
||||
}
|
||||
|
||||
/// A publisher's track, rendered.
|
||||
pub fn published(&mut self, name: &str) -> &mut PublisherTrackTally {
|
||||
self.publisher.tracks.entry(name.to_string()).or_default()
|
||||
}
|
||||
|
||||
/// The session subscribed to the track rendered `name` at `now`.
|
||||
pub fn subscribed(&mut self, name: &str, now_unix_us: u64) {
|
||||
let Some((room, _)) = crate::metering::classify(name) else { return };
|
||||
let room = hex::encode(room);
|
||||
let open = self.open_rooms.entry(room.clone()).or_insert_with(|| OpenRoom { since_unix_us: now_unix_us, class: u8::MAX, ..OpenRoom::default() });
|
||||
*open.tracks.entry(name.to_string()).or_default() += 1;
|
||||
self.reclass(&room, now_unix_us);
|
||||
}
|
||||
|
||||
/// The session ended a subscription to the track rendered `name` at `now`.
|
||||
pub fn unsubscribed(&mut self, name: &str, now_unix_us: u64) {
|
||||
let Some((room, _)) = crate::metering::classify(name) else { return };
|
||||
let room = hex::encode(room);
|
||||
let Some(open) = self.open_rooms.get_mut(&room) else { return };
|
||||
if let Some(n) = open.tracks.get_mut(name) {
|
||||
*n -= 1;
|
||||
if *n == 0 {
|
||||
open.tracks.remove(name);
|
||||
}
|
||||
}
|
||||
self.reclass(&room, now_unix_us);
|
||||
}
|
||||
|
||||
/// Every span still open closes at `now`: the session ended.
|
||||
pub fn close_spans(&mut self, now_unix_us: u64) {
|
||||
let rooms: Vec<String> = self.open_rooms.keys().cloned().collect();
|
||||
for room in rooms {
|
||||
if let Some(open) = self.open_rooms.remove(&room) {
|
||||
self.push_span(&room, open.class, open.since_unix_us, now_unix_us);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_span(&mut self, room: &str, class: u8, from: u64, to: u64) {
|
||||
if class != u8::MAX && to > from {
|
||||
self.subscriber.layer_spans.push(LayerSpan { room: room.to_string(), class, from_unix_us: from, to_unix_us: to });
|
||||
}
|
||||
}
|
||||
|
||||
/// The room's class after a change: a span ends and another begins when it changed, and
|
||||
/// the room is left when nothing of it is held.
|
||||
fn reclass(&mut self, room: &str, now: u64) {
|
||||
let Some(open) = self.open_rooms.get(room) else { return };
|
||||
let (old, since) = (open.class, open.since_unix_us);
|
||||
if open.tracks.is_empty() {
|
||||
self.open_rooms.remove(room);
|
||||
self.push_span(room, old, since, now);
|
||||
return;
|
||||
}
|
||||
let new = crate::metering::layer_class(open.tracks.keys().map(String::as_str));
|
||||
if new != old {
|
||||
self.push_span(room, old, since, now);
|
||||
let open = self.open_rooms.get_mut(room).expect("held");
|
||||
open.class = new;
|
||||
open.since_unix_us = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The relay's own energy for the session, as its acquisition class allows.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,282 @@
|
|||
//! The relay's per-hop session record of a room (WAI `live-metering.md` §3, §8): two
|
||||
//! publishers and three subscribers of one WAI room, one subscriber slow, and the record the
|
||||
//! relay signs from its session summaries recomputes from their counts.
|
||||
//!
|
||||
//! The room is the corpus's `relay` room (`wai/live-conformance/metering/`): its id, the
|
||||
//! principals' keys and their grants are the ones `wai_live_metering_vectors relay-room`
|
||||
//! prints, every key SHA-256 of capture binding's corpus label and the principal's index,
|
||||
//! and the relay signs with the room's clock key. Tracks are named as live-rooms §6.1 names
|
||||
//! them: each publisher's video, layer 0, with its receipts track, and the first
|
||||
//! publisher's events track. The second publisher's video has a 100 ms delivery timeout,
|
||||
//! and the slow subscriber reads nothing until the load has arrived and a second and a
|
||||
//! half more, so some of its objects are dropped at this hop while their receipts still
|
||||
//! reach it. Every subscriber reads its streams concurrently, as slow_subscriber.rs's do
|
||||
//! (README, "A subscriber that does not read"). The subscribers' sessions end before the
|
||||
//! publishers'. As a session closes, the relay ends its subscriptions one at a time, and a
|
||||
//! moment after its last media subscription ended and before its receipts or events one
|
||||
//! did is time on no media track, class 8: the test requires such time to fall within the
|
||||
//! session's close, and no time above layer 0 at all.
|
||||
//!
|
||||
//! With `WAI_METERING_FIXTURE_DIR` set, the test also writes the record and its ledger there
|
||||
//! as `relay-record.wmh` and `relay-ledger.wml`: the corpus holds the pair one run wrote.
|
||||
|
||||
mod common;
|
||||
|
||||
use common::*;
|
||||
use joule_moq::draft22::data::{SubgroupHeader, SubgroupId, SubgroupObject};
|
||||
use joule_moq::draft22::{Parameter, Parameters, Token};
|
||||
use joule_moq_relay::client::ClientOptions;
|
||||
use joule_moq_relay::config::MeteredGrant;
|
||||
use joule_moq_relay::metering::{classify, HopRecord, Kind, CLASSES};
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
/// The corpus room's id and `t0` (2026-10-18T00:00:00Z).
|
||||
const ROOM: &str = "89a7be137d7f3c51d15b0d4ece614d60743d7542b7693c96ac7633793e9c102f";
|
||||
const T0_US: u64 = 1_792_281_600_000_000;
|
||||
/// Each principal's index under the corpus's key label, its grant's digest and ceilings.
|
||||
const GRANTS: [(u8, &str, u64, u64, &str); 5] = [
|
||||
(30, "510124baa39d99a07d9d6575698f70183759e3da8b458f8edb09f09215e14318", u64::MAX, u64::MAX, ""),
|
||||
(31, "4fc2bb567c1e950b92bb2dd6796df52eb67f3e5c2e5753b3ee9bae483600f17e", u64::MAX, u64::MAX, ""),
|
||||
(32, "52786b7331441935c2143544988bccb4a4db6f5c76d734292a843a0b5e20e83d", 5_000_000, 500, "EUR"),
|
||||
(33, "fec5bc703c31e2600d5a51cceb45eb2e5011ec2c1890f8dc5b96fd683ef85362", u64::MAX, u64::MAX, ""),
|
||||
(34, "6b4272b140a49c75d1dbedfca74343ad6c13a8a9d3a20429e7d457f30d2afd82", u64::MAX, u64::MAX, ""),
|
||||
];
|
||||
const RELAY: u8 = 1;
|
||||
const GROUPS: u64 = 30;
|
||||
const EVENT_GROUPS: u64 = 6;
|
||||
const STALL: Duration = Duration::from_millis(1500);
|
||||
|
||||
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||||
let mut c = ring::digest::Context::new(&ring::digest::SHA256);
|
||||
for p in parts {
|
||||
c.update(p);
|
||||
}
|
||||
c.finish().as_ref().try_into().unwrap()
|
||||
}
|
||||
|
||||
/// A principal's key: SHA-256 of capture binding's corpus label and its index.
|
||||
fn key(i: u8) -> ed25519_dalek::SigningKey {
|
||||
ed25519_dalek::SigningKey::from_bytes(&sha256(&[b"wai:capture-take/corpus/key\x00", &[i]]))
|
||||
}
|
||||
|
||||
fn pk_hex(i: u8) -> String {
|
||||
hex::encode(key(i).verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
fn token(i: u8) -> Vec<u8> {
|
||||
format!("room-metering-token-{i}").into_bytes()
|
||||
}
|
||||
|
||||
fn opts(i: u8) -> ClientOptions {
|
||||
ClientOptions { tokens: vec![Token::UseValue { token_type: 0, value: token(i) }], ..ClientOptions::default() }
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn two_publishers_three_subscribers_one_slow_and_the_record_recomputes() {
|
||||
tokio::time::timeout(Duration::from_secs(180), run()).await.expect("the test finishes in time");
|
||||
}
|
||||
|
||||
async fn run() {
|
||||
let seed_path = std::env::temp_dir().join(format!("joule-moq-relay-room-metering-{}.seed", std::process::id()));
|
||||
std::fs::write(&seed_path, hex::encode(sha256(&[b"wai:capture-take/corpus/key\x00", &[RELAY]]))).unwrap();
|
||||
let server = relay(|c| {
|
||||
c.limits.quic_send_window_bytes = 64 * 1024;
|
||||
c.summary.key_path = Some(seed_path.clone());
|
||||
c.metering.grants = GRANTS
|
||||
.iter()
|
||||
.map(|&(i, g, jc, fc, cur)| MeteredGrant {
|
||||
token_sha256: hex::encode(sha256(&[&token(i)])),
|
||||
grant: g.into(),
|
||||
joules_ceiling_micro: jc,
|
||||
funds_ceiling: fc,
|
||||
currency: cur.into(),
|
||||
})
|
||||
.collect();
|
||||
})
|
||||
.await;
|
||||
let _ = std::fs::remove_file(&seed_path);
|
||||
assert_eq!(server.relay().summary_key().to_bytes(), key(RELAY).verifying_key().to_bytes(), "the relay signs with the room's clock key");
|
||||
let (ka, kb) = (key(30), key(31));
|
||||
let video = |i: u8, alias: u64, receipt_alias: u64, prio: u8| Tracks::new(ns(&[ROOM, &pk_hex(i), "video"]), "0", alias, receipt_alias, prio);
|
||||
let a = video(30, 10, 1, 0);
|
||||
let b = video(31, 20, 2, 200);
|
||||
let events_ns = ns(&[ROOM, &pk_hex(30), "events"]);
|
||||
|
||||
let (pa, _ea) = connect(&server, Via::Quic, opts(30)).await;
|
||||
let (pb, _eb) = connect(&server, Via::Quic, opts(31)).await;
|
||||
let _pa = publish_tracks(&pa, &a, None).await;
|
||||
let _pb = publish_tracks(&pb, &b, Some(100)).await;
|
||||
let _pe = pa.publish(events_ns.clone(), name("0"), 30, Parameters::new(), track_props(64, None)).await.expect("PUBLISH the events track");
|
||||
|
||||
let (s1, _e1) = connect(&server, Via::Quic, opts(32)).await;
|
||||
let (s2, _e2) = connect(&server, Via::WebTransport, opts(33)).await;
|
||||
let mut small = quinn::TransportConfig::default();
|
||||
small.stream_receive_window(quinn::VarInt::from_u32(32 * 1024));
|
||||
small.receive_window(quinn::VarInt::from_u32(96 * 1024));
|
||||
let (s3, _e3) = connect_with(&server, Via::Quic, ClientOptions { lazy_data: true, ..opts(34) }, Some(small)).await;
|
||||
let next = || Parameters::new().with(Parameter::LocationFilter(joule_moq::draft22::LocationFilter::NextObject));
|
||||
let mut views = Vec::new();
|
||||
for s in [&s1, &s2, &s3] {
|
||||
let va = subscribe_both(s, &a, next()).await;
|
||||
let vb = subscribe_both(s, &b, next()).await;
|
||||
let (ve, etx, erx) = s.subscribe(events_ns.clone(), name("0"), next()).await.expect("SUBSCRIBE the events track");
|
||||
views.push((va, vb, ve.track_alias, (etx, erx)));
|
||||
}
|
||||
|
||||
let (published_tx, published_rx) = tokio::sync::oneshot::channel::<()>();
|
||||
let publish = async {
|
||||
for g in 0..GROUPS {
|
||||
publish_group(&pa, &ka, &a, g, &payload("a", g, 900)).await;
|
||||
publish_group(&pb, &kb, &b, g, &payload("b", g, 24 * 1024)).await;
|
||||
if g < EVENT_GROUPS {
|
||||
// A group of an MSF event timeline: the join set, then two events.
|
||||
let h = SubgroupHeader { track_alias: 30, group_id: g, subgroup_id: SubgroupId::Zero, publisher_priority: Some(64), properties: false, end_of_group: true, first_object: true };
|
||||
let objects: Vec<SubgroupObject> = [b"[]".to_vec(), format!("[\"event {g}.1\"]").into_bytes(), format!("[\"event {g}.2\"]").into_bytes()]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(k, p)| SubgroupObject::normal(k as u64, p))
|
||||
.collect();
|
||||
pa.send_subgroup(h, &objects).await.expect("an events group");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(15)).await;
|
||||
}
|
||||
let _ = published_tx.send(());
|
||||
};
|
||||
let whole = |got: &[Got], v: &(Subscribed, Subscribed, u64, _)| {
|
||||
let n = |al: u64| got.iter().filter(|x| x.header.track_alias == al && x.reset.is_none()).count() as u64;
|
||||
n(v.0.media_alias) == GROUPS && n(v.0.receipts_alias) == GROUPS && n(v.1.receipts_alias) == GROUPS && n(v.2) == EVENT_GROUPS
|
||||
};
|
||||
// Each reads until A's objects, every receipt and every event group are whole, then
|
||||
// until a second passes with nothing new, so every B stream is read or seen reset.
|
||||
let fast = |i: usize| {
|
||||
let (s, v) = (if i == 0 { &s1 } else { &s2 }, &views[i]);
|
||||
async move { collect_concurrent(s, STEP * 2, Duration::from_secs(1), |got| whole(got, v)).await }
|
||||
};
|
||||
let slow = async {
|
||||
let _ = tokio::time::timeout(STEP * 2, published_rx).await;
|
||||
tokio::time::sleep(STALL).await;
|
||||
collect_concurrent(&s3, STEP * 2, Duration::from_secs(1), |got| whole(got, &views[2])).await
|
||||
};
|
||||
let ((), g1, g2, g3) = tokio::join!(publish, fast(0), fast(1), slow);
|
||||
for (g, v) in [(&g1, &views[0]), (&g2, &views[1]), (&g3, &views[2])] {
|
||||
assert!(whole(g, v), "every A object, every receipt and every event group reached each subscriber");
|
||||
}
|
||||
|
||||
let relay = server.relay().clone();
|
||||
let closing = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_micros() as u64;
|
||||
for s in [&s1, &s2, &s3] {
|
||||
s.close(0, "done");
|
||||
}
|
||||
assert!(until(STEP, || relay.summaries().len() >= 3).await, "the subscribers' three summaries, before a publisher leaves");
|
||||
pa.close(0, "done");
|
||||
pb.close(0, "done");
|
||||
assert!(until(STEP, || relay.summaries().len() >= 5).await, "five summaries");
|
||||
let room: [u8; 32] = hex::decode(ROOM).unwrap().try_into().unwrap();
|
||||
let rec = relay.hop_record(room, T0_US);
|
||||
let back = HopRecord::decode(&rec.to_bytes(), &rec.ledger_bytes()).expect("the record reads back");
|
||||
assert_eq!(back, rec);
|
||||
rec.verify().expect("the record verifies under the relay's key");
|
||||
assert_eq!(rec.hop, key(RELAY).verifying_key().to_bytes());
|
||||
assert_eq!((rec.joules, rec.class_tag), (0, 0), "the unmetered marker: no meter backs this relay");
|
||||
assert_eq!(rec.to_bytes().len(), 281 + 90 * rec.entries.len(), "no class evidence, and nothing per object or per viewer");
|
||||
assert!(rec.start < rec.end);
|
||||
|
||||
// The record recomputes from the summaries, summed here track by track.
|
||||
let summaries: Vec<_> = relay.summaries().into_iter().map(|s| s.summary).collect();
|
||||
let mut want: BTreeMap<(u8, [u8; 32], u8), [u64; 7]> = BTreeMap::new();
|
||||
let kind_of = |n: &str| match classify(n) {
|
||||
Some((r, k)) if r == room => Some(match k {
|
||||
Kind::Media { digest, .. } => (1u8, digest),
|
||||
Kind::Events => (2, [0; 32]),
|
||||
Kind::Other => (3, [0; 32]),
|
||||
}),
|
||||
_ => None,
|
||||
};
|
||||
let (mut events, mut sub_us) = (0u64, 0u64);
|
||||
for s in &summaries {
|
||||
for (n, t) in &s.tally.subscriber.tracks {
|
||||
let Some((k, d)) = kind_of(n) else { continue };
|
||||
let e = want.entry((k, d, 2)).or_default();
|
||||
e[0] += t.objects_forwarded + t.fetch_objects;
|
||||
e[1] += t.object_bytes_forwarded + t.fetch_bytes;
|
||||
e[2] += t.dropped_delivery_timeout;
|
||||
e[5] += t.undelivered_at_close;
|
||||
e[6] += t.dropped_receipts_track_subscribed;
|
||||
if k == 2 {
|
||||
events += t.incremental_objects;
|
||||
}
|
||||
}
|
||||
for (n, t) in &s.tally.publisher.tracks {
|
||||
let Some((k, d)) = kind_of(n) else { continue };
|
||||
let e = want.entry((k, d, 1)).or_default();
|
||||
e[0] += t.objects_received;
|
||||
e[1] += t.object_bytes_received;
|
||||
e[3] += t.refused_at_door;
|
||||
e[4] += t.deduplicated;
|
||||
}
|
||||
sub_us += s.tally.subscriber.layer_spans.iter().filter(|x| x.room == ROOM).map(|x| x.to_unix_us - x.from_unix_us).sum::<u64>();
|
||||
}
|
||||
let got: BTreeMap<(u8, [u8; 32], u8), [u64; 7]> = rec
|
||||
.entries
|
||||
.iter()
|
||||
.map(|e| ((e.kind, e.track, e.dir), [e.objects, e.bytes, e.dropped_timeout, e.dropped_refused, e.dropped_duplicate, e.dropped_closed, e.receipted]))
|
||||
.collect();
|
||||
assert_eq!(got, want, "each track entry is the sum of the summaries' counts");
|
||||
let digest_of = |i: u8| sha256(&[format!("{ROOM}-{}-video", pk_hex(i)).as_bytes(), &[0], b"0"]);
|
||||
let (da, db) = (digest_of(30), digest_of(31));
|
||||
assert_eq!(got[&(1, da, 1)][0], GROUPS, "the relay received each of A's objects once");
|
||||
assert_eq!(got[&(1, db, 1)][0], GROUPS);
|
||||
let b_out = got[&(1, db, 2)];
|
||||
assert!(b_out[2] > 0, "the slow subscriber lost some of B's objects to their delivery timeout");
|
||||
assert_eq!(b_out[6], b_out[2], "the receipts of every dropped object were forwarded");
|
||||
assert_eq!(b_out[0] + b_out[2] + b_out[5], 3 * GROUPS, "each of B's objects to each subscriber sent or dropped");
|
||||
assert_eq!(got[&(1, da, 2)][0], 3 * GROUPS, "A's track, with no timeout, reached all three whole");
|
||||
assert_eq!(rec.events, events);
|
||||
assert_eq!(rec.events, 3 * EVENT_GROUPS * 2, "two events a group, to each subscriber; the join sets are not events");
|
||||
assert_eq!(rec.sessions, 3, "three subscriber sessions; a publisher's session subscribes to nothing");
|
||||
assert_eq!(rec.subscriber_us.iter().sum::<u64>(), sub_us);
|
||||
// Each subscriber session's layer spans, relative to the instant the test began closing
|
||||
// the subscribers, for a report on failure.
|
||||
let spans: Vec<(u64, u8, u64, u64)> = summaries
|
||||
.iter()
|
||||
.flat_map(|s| s.tally.subscriber.layer_spans.iter().filter(|x| x.room == ROOM).map(move |x| (s.session, x.class, x.from_unix_us, x.to_unix_us)))
|
||||
.collect();
|
||||
let report = || {
|
||||
spans
|
||||
.iter()
|
||||
.map(|(id, c, a, z)| format!("session {id}: class {c} from {} to {} us after closing began", *a as i64 - closing as i64, *z as i64 - closing as i64))
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ")
|
||||
};
|
||||
if spans.iter().any(|x| x.1 != 0) {
|
||||
eprintln!("TEARDOWN-CLASS: time outside class 0: {}", report());
|
||||
}
|
||||
assert!(rec.subscriber_us[0] > 0, "every subscriber held layer 0 of the room's video: {}", report());
|
||||
assert!(rec.subscriber_us[1..8].iter().all(|&x| x == 0), "no subscriber held a layer above 0: {}", report());
|
||||
assert!(
|
||||
spans.iter().filter(|x| x.1 != 0).all(|x| x.2 >= closing),
|
||||
"time on no media track only while a session closed, its subscriptions ending one by one: {}",
|
||||
report()
|
||||
);
|
||||
|
||||
// The ledger: one entry for each grant, from the token each session presented.
|
||||
let mut grants: Vec<&str> = GRANTS.iter().map(|g| g.1).collect();
|
||||
grants.sort();
|
||||
assert_eq!(rec.ledger.iter().map(|e| hex::encode(e.grant)).collect::<Vec<_>>(), grants);
|
||||
let s1_entry = rec.ledger.iter().find(|e| hex::encode(e.grant) == GRANTS[2].1).unwrap();
|
||||
assert_eq!((s1_entry.joules_ceiling, s1_entry.funds_ceiling, &s1_entry.currency), (5_000_000, 500, b"EUR"));
|
||||
for i in 0..CLASSES {
|
||||
assert_eq!(rec.ledger.iter().map(|e| e.subscriber_us[i]).sum::<u64>(), rec.subscriber_us[i]);
|
||||
}
|
||||
assert!(rec.ledger.iter().all(|e| e.sessions == 1));
|
||||
let ina: u64 = got.iter().filter(|(k, _)| k.2 == 1).map(|(_, v)| v[1]).sum();
|
||||
assert_eq!(rec.ledger.iter().map(|e| e.bytes_in).sum::<u64>(), ina, "the bytes in are the publishers'");
|
||||
|
||||
if let Ok(dir) = std::env::var("WAI_METERING_FIXTURE_DIR") {
|
||||
let d = std::path::Path::new(&dir);
|
||||
std::fs::write(d.join("relay-record.wmh"), rec.to_bytes()).unwrap();
|
||||
std::fs::write(d.join("relay-ledger.wml"), rec.ledger_bytes()).unwrap();
|
||||
}
|
||||
server.shutdown().await;
|
||||
}
|
||||
|
|
@ -6,6 +6,54 @@ wire format may change and such changes are recorded here.
|
|||
|
||||
## v0.4.0 — current
|
||||
|
||||
### `wai.live.metering`: a room session's units and joules per hop
|
||||
|
||||
- **The extension** ([live-metering](extensions/live-metering.md)) registers the record
|
||||
`wai.live.metering`. Every hop that carried a room session, a relay, a compositor or an
|
||||
ingest gateway, signs one **per-hop session record**, `WMH1`: per track and direction the
|
||||
objects and bytes it carried and the objects it dropped by reason (delivery timeout,
|
||||
refused at the door, duplicate, queued at the end) with those whose receipts it
|
||||
forwarded; its subscriber sessions' time by layer class; the events it delivered; its own
|
||||
energy with the acquisition class of energy-measurement, or the unmetered marker; and the
|
||||
root of a **ledger**, one entry for each grant its sessions ran under, with that grant's
|
||||
joule and funds ceilings. Media tracks are counted one by one, events tracks and every
|
||||
other track of the room together, subscribers by layer class, so a record is at most
|
||||
552 + 180 × (*M* + 2) bytes, *M* the media tracks the room published, whatever the
|
||||
audience; a record past that is refused before it is read.
|
||||
- **The room-session receipt**, `WMR1`, signed by the room authority or by a key the
|
||||
origin's key log lists with the new authority `live/meter` (and `energy/<class>` for each
|
||||
class it totals): a key-log authority rather than a grant power, since a receipt states
|
||||
every principal's consumption and a grant's powers are live-rooms' closed set. It names
|
||||
the per-hop records, the takes and the composition receipts it aggregates and states
|
||||
publisher-seconds, subscriber-seconds by layer class, composition-seconds, events sent
|
||||
and delivered and recorded-seconds (the capture each device committed while capturing),
|
||||
every hop's joules with its class, one total per class and never one across classes
|
||||
(refused `classes_summed`), and each grant's consumption: its units, a share of each
|
||||
metered hop's figure by the bytes it moved, labelled with that figure's class, its funds
|
||||
under a stated tariff, and its verdicts against its ceilings, committed by a consumption
|
||||
root and every grant past a ceiling named. A verifier recomputes every number from the
|
||||
records it names; eighteen refusal reasons.
|
||||
- **QoE.** Clients' delivery-telemetry observations, as CMCD version 2 Event-mode reports,
|
||||
ride the CMCD event timeline of `draft-wilaw-moq-cmcd-event-timeline-00` (`T` the
|
||||
report's `ts`, values as strings, `sf` and the custom keys not carried), or CTA-5004-B's
|
||||
`application/cmcd` batch, whose line syntax the reports already have. Only a cell of at
|
||||
least fifty sessions enters the receipt, suppressed below that; the observations never
|
||||
leave the session and the room's service.
|
||||
- **C2PA.** `c2pa_emit` (with `live_metering`) writes a receipt into a real signed manifest:
|
||||
`c2pa.environmental-sustainability` with `energy_kwh` only where one class covers the
|
||||
session whole, GHG and water only when the caller supplies them with their method, and
|
||||
always the companion `science.transaction.wai.live.metering` with the totals by class.
|
||||
- **The reference relay** signs the per-hop record of a room from its session summaries
|
||||
(joule-code's CHANGELOG), and the corpus holds a record it signed in its test.
|
||||
- **Reference and corpus.** `wai-rs` feature `live_metering` (`src/live_metering.rs`;
|
||||
`capture_take::find_take` exposes capture-binding §9.2 to it); `live-conformance/metering/`,
|
||||
written from fixed seeds and the relay's recorded record by `wai_live_metering_vectors`, and
|
||||
checked by `tests/live_metering.rs` and by `live-conformance/live_metering_verify.py`, a
|
||||
second implementation that shares no code with `wai-rs`, verifies rooms, takes and
|
||||
composition receipts with their corpora's own second implementations, recomputes every
|
||||
unit, total and consumption entry, and requires every mutated copy of the corpus to fail.
|
||||
The registry lists the record and the magics `WMH1` and `WMR1`.
|
||||
|
||||
### wai-quantum 0.6.4: noisy Clifford circuits by Pauli frames, the state vector on the GPU, and the density matrix by superoperators
|
||||
|
||||
Additive. Every pinned digest is unchanged.
|
||||
|
|
|
|||
|
|
@ -40,8 +40,8 @@ Every registered string has one kind.
|
|||
- **record** — a signed record a WAI tool emits and verifies (a receipt kind:
|
||||
`wai.asset.provenance`, the quantum-operations receipts, the delivery records
|
||||
`wai.video.manifest` and its siblings; the live records `wai.live.events`,
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose` and `wai.live.recording`; timed text,
|
||||
`wai.text.timed`).
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording` and
|
||||
`wai.live.metering`; timed text, `wai.text.timed`).
|
||||
It names no payload format.
|
||||
|
||||
A derivation or record string is not a payload capability, so it is not
|
||||
|
|
@ -196,6 +196,7 @@ and a role are fixed once registered.
|
|||
| `wai.image.png` | payload | registered | image | standalone | royalty-free | standard-defined | floor | §5 Mandatory floor |
|
||||
| `wai.live.compose` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.events` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.metering` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.recording` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.live.room` | record | registered | — | — | — | — | — | §5 Interactive, temporal & computed content |
|
||||
| `wai.mesh.draco` | payload | registered | volumetric | standalone | royalty-free | standard-unclassified | — | §5 Immersive audio, cinema, volumetric, haptics, HDR |
|
||||
|
|
@ -300,6 +301,8 @@ and a role are fixed once registered.
|
|||
| `WEP1` | magic | defined | live-events §5 |
|
||||
| `WEV1` | magic | defined | live-events §3.1 |
|
||||
| `WHP1` | magic | defined | http-delivery §6.2 |
|
||||
| `WMH1` | magic | defined | live-metering §3.1 |
|
||||
| `WMR1` | magic | defined | live-metering §4.1 |
|
||||
| `WRD1` | magic | defined | live-rooms §3.1 |
|
||||
| `WRG1` | magic | defined | live-rooms §4.1 |
|
||||
| `WRM1` | magic | defined | live-recording §4.1 |
|
||||
|
|
|
|||
21
wai/SPEC.md
21
wai/SPEC.md
|
|
@ -592,8 +592,8 @@ kind:
|
|||
`wai.asset.provenance`, `wai.quantum.calibration`, `wai.quantum.job`, and the
|
||||
delivery records `wai.video.manifest`, `wai.video.ssai`, `wai.video.keys`,
|
||||
`wai.video.package` and `wai.video.channel`; and the live records `wai.live.events`,
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose` and `wai.live.recording`, and timed
|
||||
text, `wai.text.timed`), which names no payload format.
|
||||
`wai.live.room`, `wai.capture.take`, `wai.live.compose`, `wai.live.recording` and
|
||||
`wai.live.metering`, and timed text, `wai.text.timed`), which names no payload format.
|
||||
|
||||
Only payload, companion and refinement capabilities have a media class, a
|
||||
fidelity tier, a replicate class and a role. A derivation or record string is not a payload
|
||||
|
|
@ -940,6 +940,23 @@ The event's origin and model digest are the disclosure label, carried through ev
|
|||
IMSC1 attributes) or, where a target cannot hold it, in a sidecar:
|
||||
[`extensions/timed-text.md`](extensions/timed-text.md).
|
||||
|
||||
And for what a room session **cost** — `wai.live.metering`: every hop that carried the session
|
||||
(a relay, a compositor, an ingest gateway) signs one per-hop session record, `WMH1`: per track
|
||||
and direction the objects and bytes it carried and the objects it dropped by reason, with those
|
||||
whose receipts it forwarded; its subscriber sessions' time by layer class; the events it
|
||||
delivered; its own energy with its acquisition class, or the unmetered marker; and the root of a
|
||||
ledger of the grants its sessions ran under, with their ceilings. The record holds nothing per
|
||||
object or per viewer, so its size is bounded by what the room published, whatever the audience.
|
||||
The room authority, or a key its origin's key log lists with `live/meter`, signs the room-session
|
||||
receipt, `WMR1`, naming the records, the takes and the composition receipts it aggregates and
|
||||
stating publisher-seconds, subscriber-seconds by layer class, composition-seconds, events sent
|
||||
and delivered and recorded-seconds, every hop's joules with its class and a total per class,
|
||||
never one across classes, and each grant's consumption against its joule and funds ceilings;
|
||||
a verifier recomputes every number from the records. Clients' QoE observations ride the CMCD
|
||||
event timeline, and only a cell of at least fifty sessions reaches the receipt. The receipt maps
|
||||
onto C2PA's `c2pa.environmental-sustainability` assertion where one class covers the session
|
||||
whole: [`extensions/live-metering.md`](extensions/live-metering.md).
|
||||
|
||||
### Payloads of the WAI-native integer codecs
|
||||
|
||||
This revision pins a payload format to each WAI-native integer codec whose row named
|
||||
|
|
|
|||
|
|
@ -3115,6 +3115,8 @@ A confirmation needs every one of them:
|
|||
| a module grant (§7.9) | `module/decoder` or `module/reconstruct`, by its `form` |
|
||||
| a room descriptor or room grant ([live-rooms](live-rooms.md) §3, §4) | `live/room` |
|
||||
| an upload receipt of a take ([capture-binding](capture-binding.md) §6) | `capture/store` |
|
||||
| a per-hop session record ([live-metering](live-metering.md) §3) | `live/relay`, `live/compositor` or `live/ingest`, by its role; and `energy/<class>` for its figure's class where it is metered |
|
||||
| a room-session receipt (live-metering §4) signed by a key other than the room authority | `live/meter`; and, from any signer, `energy/<class>` for each class it totals |
|
||||
|
||||
A class registered in §2.4.4 registers its authority here with it, and a
|
||||
family that needs a narrower authority than `receipt` registers one here.
|
||||
|
|
|
|||
734
wai/extensions/live-metering.md
Normal file
734
wai/extensions/live-metering.md
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
# WAI Extension: Live Metering
|
||||
|
||||
> Status: Draft. Registers the record `wai.live.metering`: what a room session cost, in
|
||||
> units and in joules, recomputable from what each hop signed. Every hop that carried the
|
||||
> session (a relay, a compositor, an ingest gateway) signs one **per-hop session record**,
|
||||
> `WMH1`, per room session: per track and direction the objects and bytes it carried and the
|
||||
> objects it dropped by reason, its subscriber sessions' time by layer class, its own energy
|
||||
> with the acquisition class of [energy-measurement](energy-measurement.md), and a ledger
|
||||
> root over the grants its sessions ran under. The record is a batch: it holds no entry per
|
||||
> object and none per viewer, and its size is bounded by what the room published, whatever
|
||||
> the audience. The room authority, or a key its origin's key log lists for metering, signs
|
||||
> the **room-session receipt**, `WMR1`, which names the per-hop records, the takes and the
|
||||
> composition receipts it aggregates, and states the session's units (publisher-seconds,
|
||||
> subscriber-seconds by layer class, composition-seconds, events sent and delivered,
|
||||
> recorded-seconds), the joules of every hop with its class, a total per class and never
|
||||
> one across classes, and each grant's consumption against its joule and funds ceilings.
|
||||
> Every number in the receipt is recomputed by its verifier from the records it names.
|
||||
> Clients add quality-of-experience observations, CMCD version 2 Event-mode reports carried
|
||||
> on the CMCD event timeline, of which only an aggregate over enough sessions enters the
|
||||
> receipt. The receipt maps onto C2PA 2.4's `c2pa.environmental-sustainability` assertion.
|
||||
> No new transport. The reference implementation is the `live_metering` module of `wai-rs`
|
||||
> (feature `live_metering`); the reference relay, `joule-moq-relay`, signs a per-hop record
|
||||
> from its session summaries; the corpus is
|
||||
> [`live-conformance/metering/`](../live-conformance/metering/), and its second
|
||||
> implementation is `live-conformance/live_metering_verify.py`.
|
||||
> Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119/8174.
|
||||
>
|
||||
> Texts read for this revision: CTA-5004-B (CMCD version 2: Event mode, its batch body and
|
||||
> the media type `application/cmcd`); `draft-wilaw-moq-cmcd-event-timeline-00` and
|
||||
> `draft-ietf-moq-msf-01`, Internet-Drafts not past Working Group Last Call; C2PA 2.4
|
||||
> §18.26; RFC 6962 §2.1; RFC 8032; RFC 8941 and RFC 9651; FIPS 180-4. A section number of
|
||||
> one of those drafts in this text is that revision's.
|
||||
|
||||
## 1. Scope
|
||||
|
||||
In scope: the per-hop session record, its track entries, its subscriber time by layer
|
||||
class, its ledger, its size bound and its signature (§3); the room-session receipt, the
|
||||
key that may sign it, its units, its joules per hop and per class, and its ceilings (§4);
|
||||
verification (§5); quality-of-experience observations, how they are carried and what of
|
||||
them reaches the receipt (§6); the mapping onto C2PA (§7); and what the reference relay
|
||||
emits (§8).
|
||||
|
||||
Out of scope: a transport; how a hop measures its energy, which
|
||||
[energy-measurement](energy-measurement.md) governs; how a deployment prices a unit, of
|
||||
which this extension fixes only how a stated tariff is applied (§4.6); billing and payment;
|
||||
and how a meter enforces a ceiling while the session runs, which [live-rooms](live-rooms.md)
|
||||
§4.5 leaves to the deployment and this extension does not change (§4.6).
|
||||
|
||||
Relationship to the core spec: `wai.live.metering` is a **record** ([SPEC](../SPEC.md) §5):
|
||||
a signed record a WAI tool emits and verifies. It names no payload format and is not
|
||||
dispatchable.
|
||||
|
||||
## 2. Terms
|
||||
|
||||
- **Room**, **room clock**, **principal**, **event**, **chain**, **grant**, **grant in
|
||||
effect**, **room key**, **merged order**, **slot**, **layer**: as in live-events §2 and
|
||||
live-rooms §2, §6.3, §9.2.
|
||||
- **Room session**: the span of room clock a receipt states, [`start`, `end`), in
|
||||
microseconds.
|
||||
- **Hop**: a party that carries the session's objects and signs a record of it, named by
|
||||
its Ed25519 key (RFC 8032). Its **role** is 1 relay, 2 compositor ([live-compose](live-compose.md) §7)
|
||||
or 3 ingest gateway.
|
||||
- **Track of the room**: a track whose Track Namespace's first field is the room id as 64
|
||||
lowercase hexadecimal digits (live-rooms §6.1). A **media track** is one whose namespace
|
||||
is the room, an owner and `audio`, `video` or `screen`, and whose name is a layer from `0`
|
||||
to `7`; an **events track** one whose namespace ends `events` and whose name is `0`
|
||||
(live-events §8.1); every other track of the room is an **other** track (receipts tracks,
|
||||
catalogs, CMCD tracks among them).
|
||||
- **Track digest**: live-events §3.1's anchor `track`: SHA-256 (FIPS 180-4) over the
|
||||
track's namespace as live-rooms §6.2 renders it, one zero byte, and its name.
|
||||
- **Session** of a hop: one transport session a peer holds with it (a MOQT session, a
|
||||
WebRTC peer connection). A **subscriber session** is one that subscribes to a track of the
|
||||
room and that is not another hop of the session.
|
||||
- **Unmetered marker**: a zero figure with class tag 0 (energy-measurement §2), never a
|
||||
figure of zero joules.
|
||||
- *u64*, *u32*, *u16*, *u8*: little-endian unsigned integers of that many bits. A digest is
|
||||
SHA-256, 32 bytes. **Node**: staged-measurement Appendix C's `SHA-256("wai:out-node\x01" ‖
|
||||
left ‖ right)`. The **root** of a list of leaf hashes is RFC 6962 §2.1's `MTH` with that
|
||||
node; the root of no leaf is 32 zero bytes.
|
||||
|
||||
## 3. The per-hop session record
|
||||
|
||||
### 3.1 Layout
|
||||
|
||||
Each hop signs one record per room session, `WMH1`, when its part in the session ends:
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic` | 4 | `WMH1` (ASCII) |
|
||||
| `version` | 1 | 1 in this revision |
|
||||
| `room` | 32 | the room id |
|
||||
| `hop` | 32 | the hop's key, which signs the record |
|
||||
| `role` | 1 | 1 relay, 2 compositor, 3 ingest gateway |
|
||||
| `start`, `end` | 8 each | u64: the span on the room clock the record covers, [`start`, `end`) |
|
||||
| `joules` | 8 | u64: the hop's own energy over the span, in microjoules; 0 is the unmetered marker |
|
||||
| `class` | 1 + … | its acquisition class: energy-measurement §2.7's tag byte, then that tag's evidence |
|
||||
| `tracks` | 2 + 90 × *n* | a u16 count *n*, then each track entry (§3.2) |
|
||||
| `sessions` | 4 | u32: the subscriber sessions it served in the span |
|
||||
| `subscriber_us` | 72 | nine u64: the subscriber-microseconds of layer classes 0 to 8 (§3.3) |
|
||||
| `events` | 8 | u64: the events it delivered to subscriber sessions (§3.3) |
|
||||
| `grants` | 4 | u32: the entries of its ledger (§3.4) |
|
||||
| `ledger` | 32 | the ledger's root (§3.4) |
|
||||
| `sig` | 64 | §3.5 |
|
||||
|
||||
### 3.2 Track entries
|
||||
|
||||
A track entry is 90 bytes:
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `kind` | 1 | 1 a media track; 2 every events track of the room, together; 3 every other track of the room, together |
|
||||
| `track` | 32 | for kind 1, the track digest; for kinds 2 and 3, 32 zero bytes |
|
||||
| `dir` | 1 | 1 in: what the hop received; 2 out: what it sent |
|
||||
| `objects` | 8 | u64. In: every object it received on the track, those it dropped included. Out: every object it sent, live or fetched |
|
||||
| `bytes` | 8 | u64: those objects' payload bytes |
|
||||
| `dropped_timeout` | 8 | u64, out: objects not sent because their delivery timeout passed at this hop (MOQT §5.2), the rest of a subgroup reset for it included |
|
||||
| `dropped_refused` | 8 | u64, in: objects refused at the door, their receipt not verifying (jwp-receipts §4.1) |
|
||||
| `dropped_duplicate` | 8 | u64, in: objects another upstream of the track had already delivered |
|
||||
| `dropped_closed` | 8 | u64, out: objects still queued when their session ended |
|
||||
| `receipted` | 8 | u64, out: of the objects dropped, those whose receipts the hop forwarded to the same session on the track's receipts track (jwp-receipts §4.4) |
|
||||
|
||||
Media tracks are counted one by one, since a room publishes few of them. Events tracks are
|
||||
counted together, since every principal of the room, an audience member who chats among
|
||||
them, has one, and so are other tracks: a record holds no entry that grows with the
|
||||
audience (§3.7).
|
||||
|
||||
### 3.3 Subscribers and layer classes
|
||||
|
||||
The **layer class** of a subscriber session at an instant is the highest layer of the
|
||||
room's media tracks it is subscribed to then, 0 to 7; or 8 when it is subscribed to tracks
|
||||
of the room but to none of its media tracks (an events-only viewer). `subscriber_us[c]` is
|
||||
the sum, over the hop's subscriber sessions, of the room-clock microseconds within the span
|
||||
during which the session was of class *c*. A session is a subscriber session from its first
|
||||
subscription to a track of the room until it holds none; `sessions` counts those of the
|
||||
span. A session's class follows the subscriptions it holds at each instant, including as
|
||||
it closes: a hop that ends a closing session's subscriptions one at a time classes the
|
||||
moments between by what is still held, class 8 once its last media subscription has ended
|
||||
and while another of the room's tracks is still held.
|
||||
|
||||
`events` counts the objects of Object ID 1 or above the hop delivered on the room's events
|
||||
tracks to subscriber sessions: on an MSF event timeline each is one event (live-events
|
||||
§8.1), while object 0 of a group repeats the join set and is not counted.
|
||||
|
||||
A session another hop holds with this one (a relay that subscribes upstream for its own
|
||||
subscribers, a compositor's subscriptions to the inputs it composes) is a **hop link**: its
|
||||
objects are counted in the track entries, and it is not a subscriber session. So a
|
||||
subscriber is counted at exactly one hop, the one that serves it, and a room's subscriber
|
||||
time sums over its hops. A compositor is a principal of the room under its own grant, and
|
||||
its subscriptions at a relay are that relay's subscriber sessions.
|
||||
|
||||
### 3.4 The ledger
|
||||
|
||||
The hop's **ledger** is the list of its entries, one for each grant its sessions in the span
|
||||
ran under, in strictly ascending order of `grant`. An entry is 143 bytes:
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `grant` | 32 | the grant's digest (live-rooms §4.2); 32 zero bytes for sessions under no grant, such as a viewer admitted by Privacy Pass (live-rooms §10.4) |
|
||||
| `joules_ceiling` | 8 | u64: the grant's `joules_ceiling`; 2^64 − 1 for none, and for the zero grant |
|
||||
| `funds_ceiling` | 8 | u64: the grant's `funds_ceiling`; 2^64 − 1 for none |
|
||||
| `currency` | 3 | the grant's `currency`; three zero bytes for none |
|
||||
| `sessions` | 4 | u32: the hop's sessions under the grant, as publisher or subscriber |
|
||||
| `bytes_in` | 8 | u64: the payload bytes the hop received from them on tracks of the room |
|
||||
| `bytes_out` | 8 | u64: the payload bytes it sent to them on tracks of the room |
|
||||
| `subscriber_us` | 72 | nine u64: their subscriber-microseconds by layer class (§3.3) |
|
||||
|
||||
A hop knows the grant a session runs under from the token it was admitted with, which maps
|
||||
the grant's claims (live-rooms §10): the hop holds the grant beside the token, and the
|
||||
ceilings are the grant's. Each entry's leaf is SHA-256 over the 20 bytes
|
||||
`wai:metering-ledger` (ASCII) and 0x01, followed by the entry; `ledger` is the root of the
|
||||
entries' leaves in order, and `grants` their number. The ledger travels beside the record,
|
||||
unsigned; the record signs its root, so the ledger can only be the one its hop signed, and
|
||||
one entry can be shown to the principal it concerns with its RFC 6962 audit path, without
|
||||
the others.
|
||||
|
||||
### 3.5 Digest and signature
|
||||
|
||||
The **digest** of a record is SHA-256 over its bytes before `sig`. Its **signature** is
|
||||
Ed25519 by `hop` over the 17 bytes `wai:metering-hop` (ASCII) and 0x01, followed by the bytes
|
||||
the digest covers, verified strictly, as [SPEC](../SPEC.md) §7 *Signatures* requires: a key
|
||||
that fails step 1 or 2 is `bad_key`, and a signature that fails a later step is
|
||||
`bad_signature`.
|
||||
|
||||
### 3.6 Reading and rules
|
||||
|
||||
A reader reads exactly the bytes it is given as one record, in the order of §3.1, as
|
||||
live-events §3.3 reads a record with `WMH1` for `WEV1` (`truncated`, `malformed`,
|
||||
`unknown_version`). The class's evidence is read as energy-measurement §2.7 writes it; for
|
||||
tag 5 a reference length above 255 is `malformed`. It then refuses a record that breaks any
|
||||
of these rules as `energy_invalid`:
|
||||
|
||||
- the tag is 0 exactly when `joules` is 0;
|
||||
- the tag is 0, 2 (`ModelBased`), 3 (`Estimator`), 4 (`OnChipCounter`) or 5
|
||||
(`CalibratedInstrument`): the legacy tag 1 (`HwShunt`) is never emitted (energy-measurement
|
||||
§2.6);
|
||||
- for tags 4 and 5, `relative_ppm` and `window_us` are above 0, the figure meets the
|
||||
resolution floor (`joules` × `relative_ppm` ≥ 288 676, energy-measurement §2.3), tag 4's
|
||||
filtering byte is 0, 1 or 2, and tag 5's reference is UTF-8 (RFC 3629) and not only white
|
||||
space;
|
||||
|
||||
and as `record_invalid` one that breaks any of these:
|
||||
|
||||
- `role` is 1, 2 or 3; `start` is below `end`;
|
||||
- the track entries are in strictly ascending order of (`kind`, `track`, `dir`), each
|
||||
`kind` 1 to 3 and each `dir` 1 or 2; a media entry's `track` is not zero and another
|
||||
entry's is;
|
||||
- an entry in has `dropped_timeout`, `dropped_closed` and `receipted` 0, and
|
||||
`dropped_refused` + `dropped_duplicate` at most `objects`; an entry out has
|
||||
`dropped_refused` and `dropped_duplicate` 0, and `receipted` at most `dropped_timeout` +
|
||||
`dropped_closed`;
|
||||
- with `sessions` 0, every `subscriber_us` and `events` are 0;
|
||||
- `ledger` is 32 zero bytes exactly when `grants` is 0.
|
||||
|
||||
A ledger is read as entries of 143 bytes, its length a multiple of 143, in strictly
|
||||
ascending order of `grant`, each `currency` three zero bytes exactly when its
|
||||
`funds_ceiling` is 2^64 − 1 and three capitals `A`–`Z` otherwise, and the zero grant with
|
||||
both ceilings 2^64 − 1: otherwise `ledger_invalid`. Its number of entries is checked
|
||||
against the record's `grants` when the record is verified (§5.1 step 4).
|
||||
|
||||
### 3.7 The size bound
|
||||
|
||||
Let *M* be the number of distinct media tracks the room published at some instant of the
|
||||
room session (§5.3). A record is then at most
|
||||
|
||||
B(M) = 552 + 180 × (M + 2)
|
||||
|
||||
bytes, and one longer is refused `record_too_large` before it is read. 552 is the record's
|
||||
fixed fields, 281 bytes, with the longest class evidence, 271 bytes (tag 5 with a reference
|
||||
of 255 bytes); and a record has at most two entries for each media track (in and out) and
|
||||
two each for the events and other tracks together, 2 × (*M* + 2) entries of 90 bytes.
|
||||
|
||||
The bound does not depend on the audience. A session with three viewers and one with a
|
||||
million, carried by the same publishers, give records of one size: subscribers are summed
|
||||
into nine layer classes and their grants into the ledger, which the record signs by its
|
||||
root. A hop's ledger grows with the grants it served; the record, which is what travels
|
||||
upstream and into the receipt, does not. Receipts batch per group upstream in the same way
|
||||
(jwp-receipts §2.3): a relay forwards a group receipt, not one per viewer per object.
|
||||
|
||||
### 3.8 What a hop counts
|
||||
|
||||
A hop's record covers one room session, every session it held with a track of the room in
|
||||
the span. A hop that signs more than one record in a session (a long session flushed in
|
||||
parts) gives them spans that do not overlap and cuts them at group boundaries: an object is
|
||||
counted in the record whose span holds the arrival of its group's first object at the hop.
|
||||
|
||||
## 4. The room-session receipt
|
||||
|
||||
### 4.1 Layout
|
||||
|
||||
When the session is over, the room authority, or a metering key (§4.3), signs the receipt,
|
||||
`WMR1`:
|
||||
|
||||
| field | size | meaning |
|
||||
|---|---|---|
|
||||
| `magic` | 4 | `WMR1` (ASCII) |
|
||||
| `version` | 1 | 1 in this revision |
|
||||
| `room` | 32 | the room id |
|
||||
| `signer` | 32 | the key that signs the receipt |
|
||||
| `start`, `end` | 8 each | u64: the room session, [`start`, `end`) |
|
||||
| `hops` | 1 + … | a u8 count, then for each per-hop record it aggregates, in strictly ascending order of `record`: `record` (32, the record's digest), `hop` (32), `role` (u8), `joules` (u64) and `class` (1 + …), restated from the record |
|
||||
| `takes` | 1 + 32 × *k* | a u8 count, then the digest of each take manifest it aggregates ([capture-binding](capture-binding.md) §7.1), strictly ascending |
|
||||
| `compose` | 4 + 32 | u32: the composition receipts it aggregates ([live-compose](live-compose.md) §8); then their **list digest**, SHA-256 over their digests in ascending order, concatenated (32 zero bytes when none) |
|
||||
| `qoe` | 4 + 32 | u32: the session logs of QoE observations it aggregates (§6); then the SHA-256 of the logs document (32 zero bytes when none) |
|
||||
| `units` | 120 | fifteen u64: `publisher_us`, `subscriber_us` for layer classes 0 to 8, `sessions`, `composition_us`, `events_sent`, `events_delivered`, `recorded_us` (§4.4) |
|
||||
| `unmetered` | 1 | u8: the hop entries whose figure is the unmetered marker |
|
||||
| `totals` | 1 + 13 × *m* | a u8 count *m*, then for each acquisition class among the hop entries: `tag` (u8), `figures` (u32, the hop entries of that class) and `joules` (u64, their sum), in strictly ascending order of `tag` (§4.5) |
|
||||
| `tariff` | 3 + 52 | `currency` (3; zero for no tariff), then thirteen u32 prices in millionths of a minor unit: per second of `publisher_us`, of `subscriber_us` for each layer class 0 to 8, of `composition_us` and of `recorded_us`, and per event sent (§4.6) |
|
||||
| `ceilings` | 4 + 32 + 4 + 33 × *p* | u32: the grants of the session (§4.6); the **consumption root** (32); then a u32 count *p* and each grant past a ceiling: `grant` (32) and `flags` (u8: bit 0 past its joule ceiling, bit 1 past its funds ceiling), in strictly ascending order of `grant` |
|
||||
| `qoe_cell` | 49 | the QoE aggregate (§4.7): `suppressed` (u8), `started`, `start_failed`, `exit_before_start` (u32 each), `stalls`, `stall_ms`, `engaged_ms` (u64 each), `vst_count` (u32), `vst_ms` (u64) |
|
||||
| `sig` | 64 | §4.2 |
|
||||
|
||||
### 4.2 Digest, signature and rules
|
||||
|
||||
The **digest** of a receipt is SHA-256 over its bytes before `sig`; its **signature** is
|
||||
Ed25519 by `signer` over the 21 bytes `wai:metering-session` (ASCII) and 0x01, followed by
|
||||
the bytes the digest covers, verified strictly (SPEC §7 *Signatures*).
|
||||
|
||||
A reader reads exactly the bytes it is given, as §3.6 reads a record with `WMR1` for `WMH1`
|
||||
(`truncated`, `malformed`, `unknown_version`), and refuses as `classes_summed` a receipt
|
||||
whose `totals` names tag 0: a total over figures of no one class, which this layout has no
|
||||
other way to state. It refuses as `session_invalid` a receipt that breaks any of these:
|
||||
|
||||
- `start` is below `end`;
|
||||
- each list is in the strictly ascending order §4.1 gives it; each hop entry's `role` is 1,
|
||||
2 or 3, and its class reads and meets §3.6's `energy_invalid` rules; each total's `tag` is
|
||||
2 to 5 and its `figures` above 0;
|
||||
- `compose`'s digest is zero exactly when its count is, and so is `qoe`'s;
|
||||
- with no tariff (`currency` zero) every price is 0; otherwise `currency` is three capitals
|
||||
`A`–`Z`;
|
||||
- each past grant's `flags` is 1, 2 or 3;
|
||||
- `suppressed` is 0 or 1, and with 1 every other field of the cell is 0.
|
||||
|
||||
### 4.3 Who signs: the metering authority
|
||||
|
||||
The receipt is signed by the room authority (live-rooms §3.1), or by a **metering key**: a
|
||||
key the room's origin's key log lists, not revoked, with the authority `live/meter` in its
|
||||
`may_sign` (jwp-receipts §7.5). Where the room names an origin, the signer's key-log entry,
|
||||
the room authority's included, **MUST** also hold `energy/` followed by the class label of
|
||||
each class the receipt totals, as jwp-receipts §7.5 requires of every signer that restates
|
||||
a figure with its class. A room that names no origin has no key log, and only its authority
|
||||
signs its receipts.
|
||||
|
||||
The metering power is a key-log authority and not one of a grant's powers (live-rooms
|
||||
§3.3), for three reasons:
|
||||
|
||||
1. **A receipt states every principal's consumption.** A participant's power to meter would
|
||||
let one participant sign the bill of another, the authority's own ceilings included.
|
||||
Metering is the operator's act, as storing a take is (`capture/store`), and the operator's
|
||||
keys live in the origin's key log.
|
||||
2. **Revocation and trust come with it.** A metering key is revoked through the same log as
|
||||
the room authority and the hops (§5.2), and a relying party anchors it as it anchors any
|
||||
origin key (jwp-receipts §7.4), with the energy authorities its figures need.
|
||||
3. **A grant's powers are a closed set of four bits**, which live-rooms §3.4 holds a
|
||||
descriptor and a grant to; this extension does not change them.
|
||||
|
||||
A signer that is neither is refused `signer_not_authorised`; a metering key whose entry is
|
||||
`revoked` is refused `signer_revoked`.
|
||||
|
||||
### 4.4 Units
|
||||
|
||||
Every unit is an integer of room-clock microseconds or a count, over the room session
|
||||
[`start`, `end`), and each is recomputed from the records the receipt names:
|
||||
|
||||
- **`publisher_us`**, publisher-microseconds: over every principal, the microseconds of the
|
||||
session during which it **publishes** (live-rooms §7.3: an own track published, or a place
|
||||
in a slot, live or standby), by the lifecycle replay of §5.3.
|
||||
- **`subscriber_us`** for layer classes 0 to 8, and **`sessions`**: the sums over the hop
|
||||
records of their `subscriber_us` and their `sessions` (§3.3).
|
||||
- **`composition_us`**, composition-microseconds: over each composed track, the receipts of
|
||||
one compositor, one media kind and one output track, sorted by `t`: the sum of
|
||||
*t*<sub>*j*+1</sub> − *t*<sub>*j*</sub> over consecutive receipts at most 1 000 000 µs
|
||||
apart. A composed track whose outputs stop for more than a second did not compose in
|
||||
between, and a lone receipt adds nothing, so the figure is never more than the time the
|
||||
composition ran.
|
||||
- **`events_sent`**: the records the room verifier accepted (live-rooms §9.2), each once by
|
||||
its digest, whose `t` is in the session: every principal's, the room keys' lifecycle
|
||||
events included.
|
||||
- **`events_delivered`**: the sum over the hop records of their `events` (§3.3).
|
||||
- **`recorded_us`**, recorded-microseconds: over each take, the **committed capture**: with
|
||||
*o* the offset of its first held commitment and *s* the `size` of its last (capture-binding
|
||||
§9.2, §9.3), the sum over the manifest's chunks of index below *s* of the part of [`start`
|
||||
+ *o*, `end` + *o*), each chunk's capture range on the room clock, within the session. A
|
||||
take with no held commitment records nothing. What a device captured and did not commit
|
||||
while capturing is not counted.
|
||||
|
||||
### 4.5 Joules per hop and per class
|
||||
|
||||
Each hop entry restates its record's `hop`, `role`, `joules` and `class`, so the receipt
|
||||
states the energy of every hop with the class it was obtained under. `unmetered` counts the
|
||||
entries whose figure is the unmetered marker; they are in no total, and a reader **MUST
|
||||
NOT** present them as zero joules (energy-measurement §2).
|
||||
|
||||
The **totals** are one per class: for each tag among the metered entries, the number of
|
||||
entries of that tag and the sum of their `joules`. Nothing in the receipt sums figures of
|
||||
two classes. A `ModelBased` figure added to an `OnChipCounter` figure is neither a counter
|
||||
reading nor a model's output, and a total of the two, labelled with either class, would
|
||||
present a model as a measurement or a measurement as a model. So a receipt whose total
|
||||
names tag 0 is refused `classes_summed` (§4.2), and so is one whose total for a class is the
|
||||
sum of the figures of that class and of at least one other while not the sum of its own:
|
||||
the total of class *X* equals Σ<sub>*c* ∈ *S*</sub> *T<sub>c</sub>* for some set *S* of the
|
||||
receipt's classes that holds *X* and another, where *T<sub>c</sub>* is the sum of the
|
||||
figures of class *c*, and does not equal *T<sub>X</sub>*. Any other total that does not
|
||||
recompute is `unit_mismatch`.
|
||||
|
||||
The receipt's energy covers the session **whole** when `unmetered` is 0 and it has at least
|
||||
one hop entry, and **partially** otherwise (energy-binding §4's `Whole` and `Partial`). A
|
||||
partial total **MUST NOT** be presented as the session's energy.
|
||||
|
||||
### 4.6 Ceilings
|
||||
|
||||
**The grants of the session** are every grant a hop ledger names, the zero grant included,
|
||||
and every grant that was in effect for a present principal at some instant of the session
|
||||
(§5.3), in strictly ascending order of digest. For each, its **consumption** is:
|
||||
|
||||
- `publisher_us`: the publisher-microseconds of principals while it was their grant in
|
||||
effect;
|
||||
- `subscriber_us`, by layer class: the sums of its ledger entries' over the hop records;
|
||||
- `composition_us`: the composition-microseconds of §4.4 whose earlier receipt's compositor
|
||||
had it in effect at that receipt's `t`;
|
||||
- `recorded_us`: the committed capture of the takes whose first held commitment was verified
|
||||
under it (live-rooms §9.2);
|
||||
- `events_sent`: the records of §4.4's count verified under it;
|
||||
- **attributed joules**, by class: each metered hop record's figure *J* is shared among its
|
||||
ledger entries by the bytes they moved: an entry with *b* = `bytes_in` + `bytes_out` of a
|
||||
record whose entries move *B* bytes in all receives ⌊*J* × *b* / *B*⌋ µJ of that record's
|
||||
class, nothing when *B* is 0, and the remainder is attributed to no grant. An attributed
|
||||
share is an allocation of a hop's figure, not a measurement of the grant's use, and keeps
|
||||
the figure's class; it is **unmetered** for the grant when an unmetered record's entry for
|
||||
it moved bytes;
|
||||
- **funds**, where the tariff's `currency` is the grant's: ⌈*N* / 10^12⌉ minor units of it,
|
||||
with *N* = `publisher_us` × *p*<sub>pub</sub> + Σ<sub>*c*</sub> `subscriber_us`[*c*] ×
|
||||
*p*<sub>sub,*c*</sub> + `composition_us` × *p*<sub>comp</sub> + `recorded_us` ×
|
||||
*p*<sub>rec</sub> + `events_sent` × *p*<sub>ev</sub> × 10^6, at most 2^64 − 2; 2^64 − 1
|
||||
states that no funds were computed (no tariff, or another currency).
|
||||
|
||||
Its verdicts, against its ceilings, each the first row of the table below that applies,
|
||||
taken in the order none, past, unmetered, partial, within for the joule ceiling, and none,
|
||||
no tariff, past, within for the funds ceiling:
|
||||
|
||||
| verdict | joule ceiling | funds ceiling |
|
||||
|---|---|---|
|
||||
| 0 none | `joules_ceiling` is 2^64 − 1 | `funds_ceiling` is 2^64 − 1 |
|
||||
| 1 within | every hop that moved its bytes was metered, at least one did, and the attributed joules of every class together are at most the ceiling | the funds are at most the ceiling |
|
||||
| 2 past | the attributed joules of every class together are above the ceiling | the funds are above the ceiling |
|
||||
| 3 partial / no tariff | within on what was metered, and an unmetered hop moved its bytes | no funds were computed |
|
||||
| 4 unmetered | no metered hop moved its bytes | — |
|
||||
|
||||
A joule ceiling is one number, and holding a use to it adds what each hop spent, whatever
|
||||
its class. The verdict is that comparison; the sum it compares is stated nowhere as a
|
||||
figure, and the attributed joules stay per class.
|
||||
|
||||
A grant's **consumption entry** is 198 bytes: `grant` (32), `joules_ceiling` (8),
|
||||
`funds_ceiling` (8), `currency` (3), `publisher_us` (8), `subscriber_us` (9 × 8),
|
||||
`composition_us` (8), `recorded_us` (8), `events_sent` (8), the attributed joules of tags 2,
|
||||
3, 4 and 5 (4 × 8), `unmetered` (u8: 1 when its share is unmetered at some hop), `funds` (8),
|
||||
and the joule and funds verdicts (u8 each). Its leaf is SHA-256 over the 25 bytes
|
||||
`wai:metering-consumption` (ASCII) and 0x01, followed by the entry; the **consumption root**
|
||||
is the root of the grants' leaves in order, and the receipt's `ceilings` states their number,
|
||||
the root and every grant with a verdict of 2. Every principal can be shown its own entry with
|
||||
its audit path against the receipt, and nobody else's.
|
||||
|
||||
**Past a ceiling.** live-rooms §4.5 fixes what a ceiling is: the energy and the funds a
|
||||
grant's use may spend, which a meter holds the use to. How a meter holds it while the session
|
||||
runs is the deployment's, and this extension changes neither the ceiling nor that. A meter
|
||||
that ends a use past its ceiling does so through the room's own records: the clock removes
|
||||
the principal (`leave`, reason 2) or the authority exchanges its grant (live-rooms §4.6). A
|
||||
receipt records what the session did, not what it was allowed: a use past its ceiling is
|
||||
counted whole, and the receipt names its grant among those past.
|
||||
|
||||
### 4.7 The QoE cell
|
||||
|
||||
The cell aggregates the session logs of §6 into one cell. With fewer than 50 logs it is
|
||||
**suppressed** (delivery-telemetry §8.4: suppressed, not rounded): `suppressed` is 1 and
|
||||
every other field 0. Otherwise `suppressed` is 0, and over the logs' session metrics
|
||||
(delivery-telemetry §7.2): `started`, `start_failed` and `exit_before_start` count the logs
|
||||
for which each holds; `stalls`, `stall_ms` and `engaged_ms` are sums; `vst_count` counts the
|
||||
logs with a video start time and `vst_ms` sums those times. A cell of fewer than 50 logs that
|
||||
is not suppressed is refused `qoe_not_suppressed`. The cell is the signer's aggregate of
|
||||
statements nobody signed (delivery-telemetry §1.1) and **MUST NOT** be presented as evidence
|
||||
of what a viewer saw; it carries no joule figure (§1.2 there).
|
||||
|
||||
## 5. Verification
|
||||
|
||||
### 5.1 Inputs and steps
|
||||
|
||||
A receipt verifier is given the room (a descriptor, grants, the head of the authority's key
|
||||
log or none, and the chains, as live-rooms §9.1), the receipt, the per-hop records with their
|
||||
ledgers, the composition descriptors and receipts, the take manifests, and the logs document
|
||||
or none. It runs these steps and stops at the first refusal, which it reports with the
|
||||
reason and where:
|
||||
|
||||
1. **The room** (live-rooms §9): its refusal, at `room`, with the room's own place. A gap in
|
||||
a room key's chain is `lifecycle_incomplete` (live-compose §4.1), at `room`: the
|
||||
lifecycle, and with it every unit, is unknown after it. A gap in another principal's
|
||||
chain is not a refusal: the units count what the room verifier holds.
|
||||
2. **The receipt** (§4.2), at `receipt`: `truncated`, `malformed`, `unknown_version`,
|
||||
`classes_summed`, `session_invalid`; `wrong_room` when its `room` is not the room id;
|
||||
`bad_key`, `bad_signature`.
|
||||
3. **The signer** (§4.3), at `receipt`: `signer_not_authorised`, `signer_revoked`.
|
||||
4. **Each hop entry**, in order, at `hop` and the entry's index:
|
||||
1. a record whose digest is the entry's `record` is given, with a ledger: otherwise
|
||||
`record_missing`. (The digest of given bytes is SHA-256 over all but their last 64.)
|
||||
2. Its length is at most B(M) (§3.7): otherwise `record_too_large`.
|
||||
3. It reads (§3.6): `truncated`, `malformed`, `unknown_version`, `energy_invalid`,
|
||||
`record_invalid`; its `room` is the room id (`wrong_room`); its signature verifies
|
||||
(`bad_key`, `bad_signature`).
|
||||
4. The entry's `hop`, `role`, `joules` and `class` are the record's: otherwise
|
||||
`unit_mismatch`.
|
||||
5. The hop may sign for its role (§5.2): `hop_not_authorised`, `hop_revoked`.
|
||||
6. Its span is within the session: `start` ≤ its `start` and its `end` ≤ `end`. Otherwise
|
||||
`span_outside_session`.
|
||||
7. Each media entry's `track` is a track the room published in the session (§5.3):
|
||||
otherwise `track_unknown`.
|
||||
8. Its ledger reads (§3.6, `ledger_invalid`); has `grants` entries and the root `ledger`;
|
||||
and each entry names the zero grant or a grant of the room, with that grant's ceilings
|
||||
and currency. Otherwise `ledger_mismatch`.
|
||||
9. Its `subscriber_us` are the sums of its ledger's, class by class: otherwise
|
||||
`unit_mismatch`.
|
||||
5. **Each take**, in the receipt's order, at `take` and its index: a manifest with that
|
||||
digest is given (`record_missing`); it reads and verifies (capture-binding §7.2, with
|
||||
that section's reasons); and it is in the room as capture-binding §9.2 finds it, with that
|
||||
section's reasons.
|
||||
6. **The composition receipts**, at `compose`: the receipts given are as many as `compose`
|
||||
states and have its list digest, otherwise `record_missing`; then each, in ascending
|
||||
order of digest, at its index: it reads (live-compose §8.1, with that section's
|
||||
reasons), the composition descriptor it names is given (`record_missing`), it verifies
|
||||
against the room and that descriptor (live-compose §8.3, with that section's reasons),
|
||||
and its `t` is in the session (`span_outside_session`).
|
||||
7. **The logs**, at `qoe`: when `qoe` states logs, a logs document whose SHA-256 is its
|
||||
digest is given (`record_missing`); it reads (§6.1, `qoe_invalid`); and it holds as many
|
||||
logs as `qoe` states (`unit_mismatch`).
|
||||
8. **The units** (§4.4), at `units`: `unit_mismatch`.
|
||||
9. **The energy** (§4.5), at `energy`: `unmetered` is the number of unmetered hop entries;
|
||||
then each stated total, in order, names a class among the hop entries' and states that
|
||||
class's sum, or else is `classes_summed` when the sum it states is one §4.5 describes
|
||||
and `unit_mismatch` otherwise, and states that class's number of figures; and the totals
|
||||
name every class of the metered entries. Otherwise `unit_mismatch`.
|
||||
10. **The QoE cell** (§4.7), at `qoe`: `qoe_not_suppressed`, then `unit_mismatch`.
|
||||
11. **The ceilings** (§4.6), at `ceilings`: the number of grants, the consumption root and
|
||||
the grants past a ceiling recompute; otherwise `ceiling_mismatch`.
|
||||
|
||||
A receipt that passes is **accepted**: every unit, every hop's figure, every total and every
|
||||
grant's consumption is what the records it names give.
|
||||
|
||||
### 5.2 Who may sign a hop record
|
||||
|
||||
Where the room names an origin, the hop's key has an entry in the head given for the room
|
||||
(live-rooms §5) whose `may_sign` holds the authority of its role, `live/relay`,
|
||||
`live/compositor` or `live/ingest`, and, for a metered figure, `energy/` followed by the
|
||||
label of its class (jwp-receipts §7.5): otherwise `hop_not_authorised`. An entry whose
|
||||
`status` is `revoked` is `hop_revoked`. A hardware class's energy authority is confirmed only
|
||||
under a pinned anchor (jwp-receipts §7.5, *Pinned-only authorities*); the verifier is a
|
||||
function of the head its caller anchored, and the caller states which anchor that was.
|
||||
|
||||
Where the room names no origin, a hop's key is a room key (live-rooms §2): otherwise
|
||||
`hop_not_authorised`.
|
||||
|
||||
### 5.3 The room's replay
|
||||
|
||||
Over the records the room verifier accepted, in the merged order (live-rooms §9.2), the room
|
||||
keys' lifecycle events are applied as live-rooms §7.2 applies them, each taking effect at its
|
||||
`t`: the room at an instant *x* is the state after every lifecycle event whose `t` is at most
|
||||
*x* (live-compose §4.1). Between two consecutive instants at which lifecycle events fall, the
|
||||
state is constant. From it:
|
||||
|
||||
- **who publishes**, and so `publisher_us`: a principal publishes while it has an own track
|
||||
published or stands in a slot, live or standby;
|
||||
- **the grant in effect** of each present principal, and so the grants of the session;
|
||||
- **the media tracks published**, and so *M* (§3.7) and `track_unknown`: at each instant of
|
||||
the session, the tracks of each own track's layers 0 to `layers` − 1 (owner the principal),
|
||||
and of each slot's live publisher's layers (owner `_` and the slot's name), each as its
|
||||
track digest. A standby's media is not delivered and adds no track.
|
||||
|
||||
### 5.4 Outcome
|
||||
|
||||
- **refuse**: the reason and where (§5.1).
|
||||
- **accept**: the receipt's digest, the session's units, each hop record's digest, size and
|
||||
bound, the totals and the coverage, each grant's consumption entry, the consumption root,
|
||||
and the QoE cell.
|
||||
|
||||
## 6. Quality-of-experience observations
|
||||
|
||||
### 6.1 Observations and their logs
|
||||
|
||||
A client of the room reports its experience with the observations of
|
||||
[delivery-telemetry](delivery-telemetry.md) §2, unchanged: `start`, `present`, `stall-start`,
|
||||
`stall-end`, `end` and the rest, one session's in `sn` order (§7.1 there). Their Event-mode
|
||||
reports are CMCD version 2 (CTA-5004-B), as delivery-telemetry §3 maps them. A **session
|
||||
log** is one session's observations; the **logs document** is a JSON array of logs, each a
|
||||
non-empty JSON array of observations, as UTF-8 bytes; it reads when every observation
|
||||
reads (delivery-telemetry §2.4) and every log is a log (§7.1 there), and is refused
|
||||
`qoe_invalid` otherwise. The receipt names the document by its SHA-256 and states the number
|
||||
of logs; the cell (§4.7) is computed from their session metrics (delivery-telemetry §7.2).
|
||||
|
||||
### 6.2 On the CMCD event timeline
|
||||
|
||||
Over MoQ, a client publishes its reports on a CMCD track of
|
||||
`draft-wilaw-moq-cmcd-event-timeline-00`: an MSF event timeline (MSF §8) whose catalog entry
|
||||
is in the catalog's `publishTracks`, with `packaging` `eventtimeline`, `eventType`
|
||||
`urn:cta:cmcd:2026` and a `cmcdConfig` (that draft's §4.2, §4.3). The room's catalog names
|
||||
the track; its namespace's first field is the room id, so it is a track of the room, counted
|
||||
among the other tracks (§3.2). Each record of the timeline is one Event-mode report:
|
||||
|
||||
- an observation that delivery-telemetry §3 reports in Request mode, a `response` (its
|
||||
`e=rr`, which that draft's §3 bars) and a `present` that is not the session's first are
|
||||
not carried;
|
||||
- the record's wallclock index `T` is the report's `ts`, and `ts` is not in its `data` (that
|
||||
draft's §4.2.1);
|
||||
- `data` holds the report's members that the draft's §3 permits and the catalog's
|
||||
`cmcdConfig` enables, `v` always, each value a string: an Integer's decimal digits, a
|
||||
Token's characters, a String's characters without its quotes, an Inner List as RFC 9651
|
||||
serializes it. `sf` and the custom keys of delivery-telemetry §4 are not among the keys
|
||||
that draft permits and are not carried;
|
||||
- where the catalog's track name asks for a player identifier (that draft's §5), the client
|
||||
uses the observation's `sid`, fresh per session (delivery-telemetry §8.1), and never a
|
||||
principal's key or any identifier that outlives the session.
|
||||
|
||||
The draft's own example writes Integers as JSON numbers; its normative text says that every
|
||||
value is a string, and this revision follows the text. The reference writes the record with
|
||||
`live_metering::cmcd_timeline_record`.
|
||||
|
||||
### 6.3 Over HTTP: `application/cmcd`
|
||||
|
||||
Over HTTP, a client sends its Event-mode reports to the room's service as CTA-5004-B's batch:
|
||||
an HTTP POST whose body has the media type `application/cmcd`, one report a line, the lines
|
||||
separated by one line feed and the last not followed by one, each line a report in the
|
||||
`key=value` syntax with its keys in alphabetical order and no URL encoding. A report of
|
||||
delivery-telemetry §3 is such a line already: an RFC 9651 Dictionary with its keys in
|
||||
bytewise order, with no optional white space, every Event-mode report carrying `e`, `ts` and
|
||||
`v`. CTA-5004-B cites RFC 8941, which RFC 9651 obsoletes; the reports use no type RFC 9651
|
||||
added (Date, Display String), so a line is the same bytes under either. The reference writes
|
||||
a batch with `live_metering::cmcd_batch_body`.
|
||||
|
||||
### 6.4 Privacy
|
||||
|
||||
- **What reaches the receipt**: the number of logs, the digest of the document, and the cell
|
||||
of §4.7, suppressed below 50 sessions. Nothing per session.
|
||||
- **What never leaves the session and the room's service**: the observations themselves,
|
||||
their `sid`, their timestamps, the objects, URLs and timings they name, and each session's
|
||||
metrics. A client sends them only to the room's own service (delivery-telemetry §8.1,
|
||||
§8.3); a relay carries a CMCD track only to the subscribers the room's service names and
|
||||
never fans it out to the room's participants. The digest in the receipt commits to the
|
||||
document without revealing it: a party holding the document can check the cell, and one
|
||||
that does not learns the cell alone.
|
||||
- An observation names no principal; a client's `sid` is fresh per session and derived from
|
||||
no identifier, so the logs of one viewer in two sessions do not link, and a log cannot be
|
||||
matched to a grant. The cell is not per grant and not per layer class, so no cell is
|
||||
smaller than the session.
|
||||
|
||||
## 7. C2PA
|
||||
|
||||
A room-session receipt maps onto C2PA 2.4's `c2pa.environmental-sustainability` assertion
|
||||
(§18.26) in a manifest of an asset of the session, such as its recording
|
||||
([live-recording](live-recording.md)) or its composite, under
|
||||
[energy-binding](energy-binding.md)'s rules:
|
||||
|
||||
- **Energy.** The standard assertion holds one figure, `energy_kwh`. It is written only when
|
||||
the receipt's energy is whole (§4.5) and every hop entry is of one class: then
|
||||
`energy_kwh` is that class's total, derived (1 µJ is 2.8 × 10^-13 kWh, energy-binding §4),
|
||||
and `measurementMethod` names this extension, the class and the hop count. With several
|
||||
classes or an unmetered hop, no standard assertion is written: one figure would sum
|
||||
classes or present a part as the whole.
|
||||
- **GHG and water.** `carbon_kgco2e` and `water_litres` are written only when the emitter's
|
||||
caller supplies them, each with the method that gave it, which the companion carries as
|
||||
`carbonMethod` and `waterMethod`; the standard assertion is then written whether or not it
|
||||
holds `energy_kwh`. The receipt holds neither figure, and an emitter **MUST NOT** derive
|
||||
them from the energy and a grid or water factor (energy-binding §4).
|
||||
- **The companion.** Beside it, never instead, the emitter writes the companion assertion
|
||||
`science.transaction.wai.live.metering`: the receipt's digest, the room id, the session
|
||||
span, the coverage, `unmetered`, the totals by class label, the units, and the number of
|
||||
grants with the consumption root. It is written whether or not the standard assertion is,
|
||||
since it is the only place the classes are stated. A reader refuses a standard assertion
|
||||
with no companion, and an `energy_kwh` beside a companion that states several classes or
|
||||
a partial total.
|
||||
|
||||
The reference implements this in `c2pa_emit` (feature `c2pa_emit` with `live_metering`):
|
||||
`SessionManifest` writes the assertions into a real signed C2PA manifest of a given asset,
|
||||
with the session's recording or the receipt as its parent ingredient and the action
|
||||
`c2pa.converted`, every assertion created; `recover_session` reads them back, applying the
|
||||
reader's rules above, and `RecoveredSession::describes` checks them against the receipt.
|
||||
|
||||
## 8. The reference relay
|
||||
|
||||
`joule-moq-relay` keeps a signed session summary of every MOQT session it serves, and signs a
|
||||
per-hop record of a room from those summaries (`metering::hop_record`): for each track of the
|
||||
room its sessions carried, the objects and bytes in and out, the drops by reason with the
|
||||
receipts forwarded, its subscriber sessions' time by layer class from the subscriptions each
|
||||
held, the events it delivered, and a ledger from the room grants its tokens map to. Its own
|
||||
energy is the unmetered marker, since no meter backs it. It connects to no other hop, so
|
||||
every session it serves that subscribes is a subscriber session. Its record recomputes from
|
||||
its summaries, which its own test checks, and the corpus holds a record it signed in that
|
||||
test (§10).
|
||||
|
||||
## 9. Reasons
|
||||
|
||||
The codes this extension adds, each with where it is raised:
|
||||
|
||||
| reason | raised at |
|
||||
|---|---|
|
||||
| `session_invalid` | §4.2, §5.1 step 2 |
|
||||
| `signer_not_authorised` | §4.3, §5.1 step 3 |
|
||||
| `signer_revoked` | §4.3, §5.1 step 3 |
|
||||
| `record_missing` | §5.1 steps 4 to 7 |
|
||||
| `record_too_large` | §3.7, §5.1 step 4 |
|
||||
| `energy_invalid` | §3.6, §4.2 |
|
||||
| `record_invalid` | §3.6 |
|
||||
| `hop_not_authorised` | §5.2 |
|
||||
| `hop_revoked` | §5.2 |
|
||||
| `span_outside_session` | §5.1 steps 4 and 6 |
|
||||
| `track_unknown` | §5.1 step 4 |
|
||||
| `ledger_invalid` | §3.6 |
|
||||
| `ledger_mismatch` | §5.1 step 4 |
|
||||
| `unit_mismatch` | §4.4, §4.5, §4.7, §5.1 |
|
||||
| `classes_summed` | §4.2, §4.5 |
|
||||
| `ceiling_mismatch` | §4.6, §5.1 step 11 |
|
||||
| `qoe_invalid` | §6.1 |
|
||||
| `qoe_not_suppressed` | §4.7 |
|
||||
|
||||
A verifier of this extension also raises `truncated`, `malformed` and `unknown_version` for a
|
||||
record or a receipt that does not read; `bad_key` and `bad_signature` for their signatures;
|
||||
`wrong_room` for one of another room; every code a room verifier raises (live-rooms §11 and
|
||||
the live-events codes it lists), at `room`; `lifecycle_incomplete` (live-compose §10), at
|
||||
`room`; capture-binding §12's codes for a take, at `take`; and live-compose §10's for a
|
||||
composition receipt, at `compose`.
|
||||
|
||||
## 10. Conformance
|
||||
|
||||
[`live-conformance/metering/`](../live-conformance/metering/) holds the corpus, in one file,
|
||||
`vectors.json`, written from fixed seeds by `wai_live_metering_vectors gen`, and the record
|
||||
the reference relay signed in its test, `relay-record.wmh` with its ledger
|
||||
`relay-ledger.wml`, which the generator reads. It has rooms, and **vectors**, each a room, a
|
||||
receipt and what it names, with the outcome §5.4 gives them:
|
||||
|
||||
- **Accepted**: a lesson room with a relay, a compositor and two recorded takes, its receipt
|
||||
whole; mixed acquisition classes (a relay's counter reading, a compositor's model, an ingest
|
||||
gateway's estimate) with one total per class; a viewer's grant past its joule ceiling and
|
||||
another past its funds ceiling, named in the receipt; a large audience, ten thousand
|
||||
subscriber sessions whose relay record is the size of the lesson's, with a QoE cell of more
|
||||
than fifty logs; and the reference relay's own record, signed in its test, in a receipt
|
||||
over the room its test publishes into.
|
||||
- **Refused**: each reason of §9 at least once, each vector a control of one defect, and the
|
||||
codes a receipt, a record, a take, a composition receipt and a room raise from the
|
||||
extensions this one reads.
|
||||
|
||||
A conforming verifier gives every vector the outcome, the reason, the place and the facts
|
||||
the corpus states. `wai_live_metering_vectors verify` and the test `tests/live_metering.rs`
|
||||
check that the file regenerates byte for byte, that `wai-rs` gives every vector its outcome,
|
||||
that every reason of §9 refuses a vector, and that §9 lists exactly the reference's codes.
|
||||
`live-conformance/live_metering_verify.py` is the second implementation, written from this
|
||||
text: it shares no code with `wai-rs`, verifies signatures with WAI's strict rule written
|
||||
from RFC 8032 (`claim-conformance/_ed25519.py`), verifies each room, take and composition
|
||||
receipt with the corpora's own second implementations (`live_rooms_verify.py`,
|
||||
`capture_take_verify.py`, `live_compose_verify.py`), recomputes every unit, every total and
|
||||
every grant's consumption itself, gives every vector the same outcome, and requires every
|
||||
mutated copy of the corpus to fail.
|
||||
|
||||
## 11. Security and privacy considerations
|
||||
|
||||
- Every count a receipt states rests on a signature: a hop's on its record, a device's on
|
||||
its take, a compositor's on its receipts, a principal's on its events. A meter that
|
||||
inflates a unit is refused, since the verifier recomputes it; a hop that inflates its own
|
||||
counts is caught only where another record contradicts it (an upstream relay's out against
|
||||
a downstream one's in), and the receipt makes both comparable.
|
||||
- A hop's figure is its own statement, and its class says how it was obtained
|
||||
(energy-measurement §5): the receipt makes the figures attributable and their sums
|
||||
recomputable, not true. An attributed share is an allocation by bytes and says so.
|
||||
- The record's bound keeps what a hop sends upstream independent of the audience, so metering
|
||||
a large audience costs a relay tree what metering a small one does.
|
||||
- A ledger entry names a grant and its use; the ledger and the consumption entries are given
|
||||
to the parties that verify the receipt, and each principal can be shown its own entry
|
||||
alone, by its audit path. A grant names its principal (live-rooms §13), so a deployment
|
||||
that publishes a receipt publishes its root and counts, not its ledgers.
|
||||
- QoE observations are unsigned (delivery-telemetry §1.1); the receipt's cell is suppressed
|
||||
below 50 sessions and never carries a session's data (§6.4).
|
||||
|
|
@ -11,13 +11,16 @@ and the independent verifier of each, beside it.
|
|||
| [`compose/`](compose/) | [live-compose](../extensions/live-compose.md) (`wai.live.compose`) | `live_compose_verify.py` |
|
||||
| [`recordings/`](recordings/) | [live-recording](../extensions/live-recording.md) (`wai.live.recording`) | `live_record_verify.py` |
|
||||
| [`captions/`](captions/) | [timed-text](../extensions/timed-text.md) (`wai.text.timed`) | `timed_text_verify.py` |
|
||||
| [`metering/`](metering/) | [live-metering](../extensions/live-metering.md) (`wai.live.metering`) | `live_metering_verify.py` |
|
||||
|
||||
**Provenance.** Generated here: `wai_live_events_vectors gen` writes `events/`,
|
||||
`wai_live_room_vectors gen` writes `rooms/`, `wai_capture_take_vectors gen` writes
|
||||
`takes/`, `wai_live_compose_vectors gen` writes `compose/`, `wai_live_record_vectors gen`
|
||||
writes `recordings/` and `wai_timed_text_vectors gen` writes `captions/`, each from records
|
||||
authored in code, from fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`,
|
||||
`compose/README.md`, `recordings/README.md`, `captions/README.md`). No recorded data.
|
||||
writes `recordings/`, `wai_timed_text_vectors gen` writes `captions/` and
|
||||
`wai_live_metering_vectors gen` writes `metering/`, each from records authored in code, from
|
||||
fixed seeds (`events/README.md`, `rooms/README.md`, `takes/README.md`, `compose/README.md`,
|
||||
`recordings/README.md`, `captions/README.md`, `metering/README.md`). One recorded record:
|
||||
`metering/relay-record.wmh` and its ledger, which the reference relay signed in its test.
|
||||
|
||||
Each verifier is written from its extension's text, shares no code with `wai-rs`, and is
|
||||
run by `.github/workflows/wai-conformance.yml` beside the reference's own check:
|
||||
|
|
@ -29,4 +32,5 @@ python3 capture_take_verify.py takes
|
|||
python3 live_compose_verify.py compose
|
||||
python3 live_record_verify.py recordings
|
||||
python3 timed_text_verify.py captions
|
||||
python3 live_metering_verify.py metering
|
||||
```
|
||||
|
|
|
|||
986
wai/live-conformance/live_metering_verify.py
Normal file
986
wai/live-conformance/live_metering_verify.py
Normal file
|
|
@ -0,0 +1,986 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Independent verifier of the live-metering corpus (extensions/live-metering.md).
|
||||
|
||||
Written from the extension's text. It shares no code with wai-rs, which generates the
|
||||
corpus, and never runs it. It checks Ed25519 signatures with
|
||||
../claim-conformance/_ed25519.py, WAI's strict rule (SPEC §7 *Signatures*) written from
|
||||
RFC 8032 with the standard library. Each room is verified by the live-rooms corpus's own
|
||||
second implementation, live_rooms_verify.py (live-rooms §9); each take by
|
||||
capture_take_verify.py (capture-binding §7.2, §9.2); each composition receipt by
|
||||
live_compose_verify.py (live-compose §8.3). Everything else is here, from the text:
|
||||
|
||||
- the per-hop session record (§3): read, its class, its rules, its size bound, its ledger
|
||||
and the ledger's root;
|
||||
- the room-session receipt (§4): read, its rules, who may sign it;
|
||||
- the room's replay (§5.3): who publishes, the grants in effect, the media tracks
|
||||
published;
|
||||
- the units (§4.4), the joules per hop and per class (§4.5), each grant's consumption and
|
||||
its verdicts (§4.6), and the QoE cell (§4.7) from the logs' session metrics
|
||||
(delivery-telemetry §7), all recomputed with Python's integers.
|
||||
|
||||
For every vector it requires the outcome, reason and place, and the digest, units,
|
||||
records, totals, coverage, consumption entries, root, grants past a ceiling and QoE cell
|
||||
the corpus states; of the corpus, that every reason of §9 refuses a vector and that §9
|
||||
lists exactly those reasons. Then it mutates copies of the corpus, one defect each, and
|
||||
requires every copy to fail.
|
||||
|
||||
python3 live_metering_verify.py <live-conformance/metering>
|
||||
|
||||
Exit status 0 only if every vector verifies and every mutated copy fails. Python 3.8 or
|
||||
later; standard library only.
|
||||
"""
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
sys.path.insert(0, os.path.join(HERE, "..", "claim-conformance"))
|
||||
import live_events_verify as LEV # noqa: E402
|
||||
import live_rooms_verify as LRV # noqa: E402
|
||||
import capture_take_verify as CT # noqa: E402
|
||||
import live_compose_verify as LCV # noqa: E402
|
||||
import _ed25519 as ED # noqa: E402
|
||||
|
||||
Refuse = LEV.Refuse
|
||||
|
||||
HOP_MAGIC = b"WMH1"
|
||||
SESSION_MAGIC = b"WMR1"
|
||||
HOP_DOMAIN = b"wai:metering-hop\x01"
|
||||
SESSION_DOMAIN = b"wai:metering-session\x01"
|
||||
LEDGER_DOMAIN = b"wai:metering-ledger\x01"
|
||||
CONSUMPTION_DOMAIN = b"wai:metering-consumption\x01"
|
||||
NODE_DOMAIN = b"wai:out-node\x01"
|
||||
NO_CEILING = 2 ** 64 - 1
|
||||
NO_FUNDS = 2 ** 64 - 1
|
||||
CLASSES = 9
|
||||
GAP_US = 1000000
|
||||
QOE_MIN = 50
|
||||
FLOOR = 288676
|
||||
LABELS = {2: "ModelBased", 3: "Estimator", 4: "OnChipCounter", 5: "CalibratedInstrument"}
|
||||
HOP_AUTHORITY = {1: "live/relay", 2: "live/compositor", 3: "live/ingest"}
|
||||
METER_AUTHORITY = "live/meter"
|
||||
ZERO = bytes(32)
|
||||
JOULES_VERDICT = ["none", "within", "past", "partial", "unmetered"]
|
||||
FUNDS_VERDICT = ["none", "within", "past", "no_tariff"]
|
||||
LIFECYCLE = range(0x10, 0x17)
|
||||
|
||||
REASONS = [
|
||||
"session_invalid", "signer_not_authorised", "signer_revoked", "record_missing", "record_too_large",
|
||||
"energy_invalid", "record_invalid", "hop_not_authorised", "hop_revoked", "span_outside_session",
|
||||
"track_unknown", "ledger_invalid", "ledger_mismatch", "unit_mismatch", "classes_summed",
|
||||
"ceiling_mismatch", "qoe_invalid", "qoe_not_suppressed",
|
||||
]
|
||||
|
||||
|
||||
class Stop(Exception):
|
||||
def __init__(self, reason, at):
|
||||
super().__init__(reason)
|
||||
self.reason, self.at = reason, at
|
||||
|
||||
|
||||
def sha(*parts):
|
||||
h = hashlib.sha256()
|
||||
for p in parts:
|
||||
h.update(p)
|
||||
return h.digest()
|
||||
|
||||
|
||||
def bound(m):
|
||||
"""§3.7: 552 + 180 × (M + 2)."""
|
||||
return 552 + 180 * (m + 2)
|
||||
|
||||
|
||||
def given_digest(b):
|
||||
"""§5.1 step 4: SHA-256 over all but the last 64 bytes."""
|
||||
return sha(b[:max(len(b) - 64, 0)])
|
||||
|
||||
|
||||
def node(a, b):
|
||||
return sha(NODE_DOMAIN, a, b)
|
||||
|
||||
|
||||
def mth(leaves):
|
||||
"""RFC 6962 §2.1, with the node of staged-measurement Appendix C; zero for none."""
|
||||
if not leaves:
|
||||
return ZERO
|
||||
if len(leaves) == 1:
|
||||
return leaves[0]
|
||||
k = 1
|
||||
while k * 2 < len(leaves):
|
||||
k *= 2
|
||||
return node(mth(leaves[:k]), mth(leaves[k:]))
|
||||
|
||||
|
||||
class R:
|
||||
"""A cursor; running out is `truncated`."""
|
||||
|
||||
def __init__(self, b, p=0):
|
||||
self.b, self.p = b, p
|
||||
|
||||
def take(self, n):
|
||||
if self.p + n > len(self.b):
|
||||
raise Refuse("truncated")
|
||||
s = self.b[self.p:self.p + n]
|
||||
self.p += n
|
||||
return s
|
||||
|
||||
def u8(self):
|
||||
return self.take(1)[0]
|
||||
|
||||
def u16(self):
|
||||
return struct.unpack("<H", self.take(2))[0]
|
||||
|
||||
def u32(self):
|
||||
return struct.unpack("<I", self.take(4))[0]
|
||||
|
||||
def u64(self):
|
||||
return struct.unpack("<Q", self.take(8))[0]
|
||||
|
||||
def be32(self):
|
||||
return struct.unpack(">I", self.take(4))[0]
|
||||
|
||||
def be64(self):
|
||||
return struct.unpack(">Q", self.take(8))[0]
|
||||
|
||||
def done(self):
|
||||
return self.p == len(self.b)
|
||||
|
||||
|
||||
def read_head(b, magic):
|
||||
if len(b) < 4:
|
||||
raise Refuse("truncated")
|
||||
if b[:4] != magic:
|
||||
raise Refuse("malformed")
|
||||
if len(b) < 5:
|
||||
raise Refuse("truncated")
|
||||
if b[4] != 1:
|
||||
raise Refuse("unknown_version")
|
||||
|
||||
|
||||
INVALID = "invalid"
|
||||
|
||||
|
||||
def read_class(r):
|
||||
"""energy-measurement §2.7's tag and evidence: None (tag 0), a tuple, or INVALID."""
|
||||
tag = r.u8()
|
||||
if tag == 0:
|
||||
return None
|
||||
if tag in (2, 3):
|
||||
return (tag,)
|
||||
if tag == 4:
|
||||
ppm, window, f = r.be32(), r.be64(), r.u8()
|
||||
return (4, ppm, window, f) if ppm > 0 and window > 0 and f in (0, 1, 2) else INVALID
|
||||
if tag == 5:
|
||||
ppm, window, n = r.be32(), r.be64(), r.be32()
|
||||
if n > 255:
|
||||
raise Refuse("malformed")
|
||||
ref = r.take(n)
|
||||
try:
|
||||
text = ref.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return INVALID
|
||||
return (5, ppm, window, text) if ppm > 0 and window > 0 and text.strip() != "" else INVALID
|
||||
return INVALID
|
||||
|
||||
|
||||
def energy_ok(joules, cls):
|
||||
"""§3.6's `energy_invalid` rules."""
|
||||
if cls == INVALID:
|
||||
return False
|
||||
if cls is None:
|
||||
return joules == 0
|
||||
if joules == 0:
|
||||
return False
|
||||
return cls[0] not in (4, 5) or joules * cls[1] >= FLOOR
|
||||
|
||||
|
||||
def check_sig(key, domain, body, sig):
|
||||
bad = ED.verify(key, domain + body, sig)
|
||||
if bad:
|
||||
raise Refuse(bad)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §3 the per-hop record
|
||||
|
||||
def read_hop(b, room):
|
||||
"""§3.6: the record, its class, its rules, its room, its signature."""
|
||||
read_head(b, HOP_MAGIC)
|
||||
r = R(b, 5)
|
||||
x = {"room": r.take(32), "hop": r.take(32), "role": r.u8(), "start": r.u64(), "end": r.u64(), "joules": r.u64()}
|
||||
x["class"] = read_class(r)
|
||||
x["tracks"] = []
|
||||
for _ in range(r.u16()):
|
||||
e = {"kind": r.u8(), "track": r.take(32), "dir": r.u8()}
|
||||
for k in ("objects", "bytes", "timeout", "refused", "duplicate", "closed", "receipted"):
|
||||
e[k] = r.u64()
|
||||
x["tracks"].append(e)
|
||||
x["sessions"] = r.u32()
|
||||
x["sub"] = [r.u64() for _ in range(CLASSES)]
|
||||
x["events"], x["grants"], x["ledger"] = r.u64(), r.u32(), r.take(32)
|
||||
sig = r.take(64)
|
||||
if not r.done():
|
||||
raise Refuse("malformed")
|
||||
if not energy_ok(x["joules"], x["class"]):
|
||||
raise Refuse("energy_invalid")
|
||||
order = [(e["kind"], e["track"], e["dir"]) for e in x["tracks"]]
|
||||
|
||||
def entry_ok(e):
|
||||
if e["kind"] == 1:
|
||||
kind_ok = e["track"] != ZERO
|
||||
elif e["kind"] in (2, 3):
|
||||
kind_ok = e["track"] == ZERO
|
||||
else:
|
||||
return False
|
||||
if e["dir"] == 1:
|
||||
return kind_ok and e["timeout"] == 0 and e["closed"] == 0 and e["receipted"] == 0 \
|
||||
and e["refused"] + e["duplicate"] <= e["objects"]
|
||||
if e["dir"] == 2:
|
||||
return kind_ok and e["refused"] == 0 and e["duplicate"] == 0 and e["receipted"] <= e["timeout"] + e["closed"]
|
||||
return False
|
||||
rules = (x["role"] in (1, 2, 3) and x["start"] < x["end"] and all(a < z for a, z in zip(order, order[1:]))
|
||||
and all(entry_ok(e) for e in x["tracks"])
|
||||
and (x["sessions"] != 0 or (not any(x["sub"]) and x["events"] == 0))
|
||||
and ((x["grants"] == 0) == (x["ledger"] == ZERO)))
|
||||
if not rules:
|
||||
raise Refuse("record_invalid")
|
||||
if x["room"] != room:
|
||||
raise Refuse("wrong_room")
|
||||
check_sig(x["hop"], HOP_DOMAIN, b[:-64], sig)
|
||||
x["digest"] = sha(b[:-64])
|
||||
return x
|
||||
|
||||
|
||||
def read_ledger(b):
|
||||
"""§3.6: the ledger's entries, or Refuse("ledger_invalid")."""
|
||||
if len(b) % 143:
|
||||
raise Refuse("ledger_invalid")
|
||||
out = []
|
||||
for k in range(0, len(b), 143):
|
||||
c = b[k:k + 143]
|
||||
e = {"raw": c, "grant": c[:32], "jc": int.from_bytes(c[32:40], "little"), "fc": int.from_bytes(c[40:48], "little"),
|
||||
"currency": c[48:51], "sessions": int.from_bytes(c[51:55], "little"),
|
||||
"in": int.from_bytes(c[55:63], "little"), "out": int.from_bytes(c[63:71], "little"),
|
||||
"sub": [int.from_bytes(c[71 + 8 * i:79 + 8 * i], "little") for i in range(CLASSES)]}
|
||||
currency_ok = e["currency"] == b"\x00\x00\x00" if e["fc"] == NO_CEILING else all(0x41 <= x <= 0x5A for x in e["currency"])
|
||||
zero_ok = e["grant"] != ZERO or (e["jc"] == NO_CEILING and e["fc"] == NO_CEILING)
|
||||
if not currency_ok or not zero_ok or (out and out[-1]["grant"] >= e["grant"]):
|
||||
raise Refuse("ledger_invalid")
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
|
||||
def ledger_root(entries):
|
||||
return mth([sha(LEDGER_DOMAIN, e["raw"]) for e in entries])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §4 the receipt
|
||||
|
||||
def read_receipt(b, room):
|
||||
"""§4.2: the receipt, `classes_summed` for a total of tag 0, its rules, its room, its signature."""
|
||||
read_head(b, SESSION_MAGIC)
|
||||
r = R(b, 5)
|
||||
x = {"room": r.take(32), "signer": r.take(32), "start": r.u64(), "end": r.u64()}
|
||||
x["hops"], classes_ok = [], True
|
||||
for _ in range(r.u8()):
|
||||
h = {"record": r.take(32), "hop": r.take(32), "role": r.u8(), "joules": r.u64()}
|
||||
h["class"] = read_class(r)
|
||||
classes_ok = classes_ok and energy_ok(h["joules"], h["class"])
|
||||
x["hops"].append(h)
|
||||
x["takes"] = [r.take(32) for _ in range(r.u8())]
|
||||
x["compose_count"], x["compose_digest"] = r.u32(), r.take(32)
|
||||
x["qoe_count"], x["qoe_digest"] = r.u32(), r.take(32)
|
||||
x["units"] = units_of([r.u64() for _ in range(15)])
|
||||
x["unmetered"] = r.u8()
|
||||
x["totals"] = [(r.u8(), r.u32(), r.u64()) for _ in range(r.u8())]
|
||||
x["currency"] = r.take(3)
|
||||
x["prices"] = [r.u32() for _ in range(13)]
|
||||
x["grants"], x["root"] = r.u32(), r.take(32)
|
||||
x["past"] = [(r.take(32), r.u8()) for _ in range(r.u32())]
|
||||
cell = [r.u8()] + [r.u32() for _ in range(3)] + [r.u64() for _ in range(3)] + [r.u32(), r.u64()]
|
||||
x["cell"] = cell_dict(cell)
|
||||
sig = r.take(64)
|
||||
if not r.done():
|
||||
raise Refuse("malformed")
|
||||
if any(t[0] == 0 for t in x["totals"]):
|
||||
raise Refuse("classes_summed")
|
||||
tariff_ok = all(p == 0 for p in x["prices"]) if x["currency"] == b"\x00\x00\x00" else all(0x41 <= c <= 0x5A for c in x["currency"])
|
||||
recs, takes, tags, past = [h["record"] for h in x["hops"]], x["takes"], [t[0] for t in x["totals"]], [p[0] for p in x["past"]]
|
||||
rules = (x["start"] < x["end"] and all(a < z for a, z in zip(recs, recs[1:])) and all(h["role"] in (1, 2, 3) for h in x["hops"])
|
||||
and classes_ok and all(a < z for a, z in zip(takes, takes[1:]))
|
||||
and ((x["compose_count"] == 0) == (x["compose_digest"] == ZERO))
|
||||
and ((x["qoe_count"] == 0) == (x["qoe_digest"] == ZERO))
|
||||
and all(a < z for a, z in zip(tags, tags[1:])) and all(2 <= t[0] <= 5 and t[1] > 0 for t in x["totals"])
|
||||
and tariff_ok and all(a < z for a, z in zip(past, past[1:])) and all(f in (1, 2, 3) for _, f in x["past"])
|
||||
and (cell[0] == 0 or (cell[0] == 1 and not any(cell[1:]))))
|
||||
if not rules:
|
||||
raise Refuse("session_invalid")
|
||||
if x["room"] != room:
|
||||
raise Refuse("wrong_room")
|
||||
check_sig(x["signer"], SESSION_DOMAIN, b[:-64], sig)
|
||||
x["digest"] = sha(b[:-64])
|
||||
return x
|
||||
|
||||
|
||||
def units_of(v):
|
||||
return {"publisher_us": v[0], "subscriber_us": v[1:10], "sessions": v[10], "composition_us": v[11],
|
||||
"events_sent": v[12], "events_delivered": v[13], "recorded_us": v[14]}
|
||||
|
||||
|
||||
def cell_dict(c):
|
||||
return {"suppressed": c[0] == 1, "started": c[1], "start_failed": c[2], "exit_before_start": c[3], "stalls": c[4],
|
||||
"stall_ms": c[5], "engaged_ms": c[6], "vst_count": c[7], "vst_ms": c[8]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §5.3 the room
|
||||
|
||||
EMPTY = {"present": {}, "own": {}, "slots": None}
|
||||
|
||||
|
||||
class Ctx:
|
||||
"""A room that verified: its descriptor, grants, key log, accepted records and replay."""
|
||||
|
||||
def __init__(self, room):
|
||||
descriptor = bytes.fromhex(room["descriptor"])
|
||||
grants = [bytes.fromhex(g) for g in room["grants"]]
|
||||
head = room["key_log_head"]
|
||||
self.chains = [[bytes.fromhex(x) for x in c] for c in room["chains"]]
|
||||
out = LRV.verify_room(descriptor, grants, head, self.chains)
|
||||
if out["outcome"] == "refuse":
|
||||
raise Stop(out["reason"], ["room"] + out["at"])
|
||||
self.d = LRV.read_descriptor(descriptor)
|
||||
self.id = self.d["room"]
|
||||
if out["outcome"] == "gap" and any(LRV.is_room_key(self.d, bytes.fromhex(g["principal"])) for g in out["gaps"]):
|
||||
raise Stop("lifecycle_incomplete", ["room"])
|
||||
self.gaps = out.get("gaps", [])
|
||||
held, _ = LRV.check_grants(self.d, grants)
|
||||
self.grants = {g["digest"]: g for g in held}
|
||||
self.log = json.loads(head) if self.d["origin"] != b"" else None
|
||||
self.view = LCV.view_room(room)
|
||||
self.seen, digests, instants = [], set(), set()
|
||||
for c, i, g in out["order"]:
|
||||
ev = LEV.read_record(self.chains[c][i])
|
||||
if ev["digest"] in digests:
|
||||
continue
|
||||
digests.add(ev["digest"])
|
||||
self.seen.append({"chain": c, "record": i, "ev": ev, "grant": bytes.fromhex(g) if g else None})
|
||||
if LRV.is_room_key(self.d, ev["principal"]) and ev["kind"] in LIFECYCLE:
|
||||
instants.add(ev["t"])
|
||||
empty = {"present": {}, "own": {}, "slots": [{"live": None, "standby": None} for _ in self.d["slots"]]}
|
||||
self.segments = [[0, empty]]
|
||||
for x in sorted(instants):
|
||||
st = self.view.at(x)
|
||||
if x == 0:
|
||||
self.segments[0][1] = st
|
||||
else:
|
||||
self.segments.append([x, st])
|
||||
|
||||
def over(self, s, e):
|
||||
out = []
|
||||
for k, (a, st) in enumerate(self.segments):
|
||||
z = self.segments[k + 1][0] if k + 1 < len(self.segments) else 2 ** 64
|
||||
lo, hi = max(a, s), min(z, e)
|
||||
if lo < hi:
|
||||
out.append((st, lo, hi))
|
||||
return out
|
||||
|
||||
def at(self, x):
|
||||
return next(st for a, st in reversed(self.segments) if a <= x)
|
||||
|
||||
def published(self, st):
|
||||
out = set()
|
||||
for (p, m), (_, layers) in st["own"].items():
|
||||
out |= {LCV.track_digest(self.id, p.hex(), m, str(n)) for n in range(layers)}
|
||||
for slot, s in zip(self.d["slots"], st["slots"]):
|
||||
if s["live"]:
|
||||
out |= {LCV.track_digest(self.id, "_" + slot["name"].decode(), slot["media"], str(n)) for n in range(s["live"][2])}
|
||||
return out
|
||||
|
||||
def ceilings(self, g):
|
||||
if g == ZERO:
|
||||
return (NO_CEILING, NO_CEILING, b"\x00\x00\x00")
|
||||
x = self.grants.get(g)
|
||||
return None if x is None else (x["joules"], x["funds"], x["currency"])
|
||||
|
||||
def entry(self, key):
|
||||
if self.log is None:
|
||||
return None
|
||||
return next((e for e in self.log["keys"] if e["key"] == key.hex()), None)
|
||||
|
||||
|
||||
def publishers(st):
|
||||
out = {p for p, _ in st["own"]}
|
||||
for s in st["slots"]:
|
||||
out |= {h[0] for h in (s["live"], s["standby"]) if h}
|
||||
return out
|
||||
|
||||
|
||||
def hop_authorised(ctx, x):
|
||||
"""§5.2."""
|
||||
if ctx.d["origin"] == b"":
|
||||
if not LRV.is_room_key(ctx.d, x["hop"]):
|
||||
raise Refuse("hop_not_authorised")
|
||||
return
|
||||
e = ctx.entry(x["hop"])
|
||||
if e is None:
|
||||
raise Refuse("hop_not_authorised")
|
||||
need = [HOP_AUTHORITY[x["role"]]] + (["energy/" + LABELS[x["class"][0]]] if x["class"] else [])
|
||||
if not all(a in e["may_sign"] for a in need):
|
||||
raise Refuse("hop_not_authorised")
|
||||
if e["status"] == "revoked":
|
||||
raise Refuse("hop_revoked")
|
||||
|
||||
|
||||
def signer_authorised(ctx, rc):
|
||||
"""§4.3."""
|
||||
d = ctx.d
|
||||
if d["origin"] == b"":
|
||||
if rc["signer"] != d["authority"]:
|
||||
raise Refuse("signer_not_authorised")
|
||||
return
|
||||
e = ctx.entry(rc["signer"])
|
||||
if e is None or not (rc["signer"] == d["authority"] or METER_AUTHORITY in e["may_sign"]):
|
||||
raise Refuse("signer_not_authorised")
|
||||
if not all("energy/" + LABELS[t[0]] in e["may_sign"] for t in rc["totals"]):
|
||||
raise Refuse("signer_not_authorised")
|
||||
if e["status"] == "revoked":
|
||||
raise Refuse("signer_revoked")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- delivery-telemetry §2, §7
|
||||
|
||||
EVENTS = {"request", "response", "start", "present", "switch", "layer-change", "stall-start", "stall-end", "frame-drop",
|
||||
"capable", "abandon", "end"}
|
||||
MEMBERS = {"v", "sid", "sn", "ts_ms", "event", "cid", "st", "sf", "object", "present", "switch", "layer_change", "capable",
|
||||
"cohort", "metrics", "claim"}
|
||||
WITH = {"object": ("request", "response"), "present": ("present",), "switch": ("switch",),
|
||||
"layer_change": ("layer-change",), "capable": ("capable",)}
|
||||
|
||||
|
||||
def is_int(v):
|
||||
return isinstance(v, int) and not isinstance(v, bool) and 0 <= v <= 2 ** 53 - 1
|
||||
|
||||
|
||||
def read_observation(o):
|
||||
"""delivery-telemetry §2.1 and §2.4, as far as the session metrics read an observation."""
|
||||
if not isinstance(o, dict) or set(o) != MEMBERS or o["v"] != 1 or isinstance(o["v"], bool):
|
||||
raise Refuse("qoe_invalid")
|
||||
if not isinstance(o["sid"], str) or not re.fullmatch(r"[0-9a-f]{32}", o["sid"]):
|
||||
raise Refuse("qoe_invalid")
|
||||
if not is_int(o["sn"]) or not (o["ts_ms"] is None or is_int(o["ts_ms"])) or o["event"] not in EVENTS:
|
||||
raise Refuse("qoe_invalid")
|
||||
if o["st"] not in ("v", "l", "ll") or o["sf"] not in ("o", "h", "d"):
|
||||
raise Refuse("qoe_invalid")
|
||||
for k, events in WITH.items():
|
||||
if (o[k] is not None) != (o["event"] in events):
|
||||
raise Refuse("qoe_invalid")
|
||||
p = o["present"]
|
||||
if p is not None:
|
||||
if not isinstance(p, dict) or set(p) != {"unit", "layers", "first", "late_ms", "decode_us"}:
|
||||
raise Refuse("qoe_invalid")
|
||||
if not is_int(p["unit"]) or not is_int(p["layers"]) or p["layers"] == 0 or not isinstance(p["first"], bool):
|
||||
raise Refuse("qoe_invalid")
|
||||
if o["claim"] is not None and o["event"] != "end":
|
||||
raise Refuse("qoe_invalid")
|
||||
event_mode = o["event"] != "request" and (o["event"] != "present" or p["first"])
|
||||
if event_mode and o["ts_ms"] is None:
|
||||
raise Refuse("qoe_invalid")
|
||||
return o
|
||||
|
||||
|
||||
def session_metrics(log):
|
||||
"""delivery-telemetry §7: one log's metrics, or Refuse("qoe_invalid") for one that is not a log (§7.1)."""
|
||||
m = {"started": False, "vst": None, "exit_before_start": False, "start_failed": False, "stalls": 0, "stall_ms": 0, "engaged_ms": 0}
|
||||
start = present = stall = None
|
||||
last, ended = 0, False
|
||||
for i, o in enumerate(log):
|
||||
if o["sid"] != log[0]["sid"] or (i > 0 and o["sn"] <= log[i - 1]["sn"]) or ended:
|
||||
raise Refuse("qoe_invalid")
|
||||
t = o["ts_ms"]
|
||||
if t is not None:
|
||||
if t < last:
|
||||
raise Refuse("qoe_invalid")
|
||||
last = t
|
||||
e = o["event"]
|
||||
if e == "start" and start is None:
|
||||
m["started"], start = True, t
|
||||
elif e == "present":
|
||||
if o["present"]["first"]:
|
||||
if present is not None:
|
||||
raise Refuse("qoe_invalid")
|
||||
present = t
|
||||
m["vst"] = t - min(start, t) if start is not None and t is not None else None
|
||||
elif e == "stall-start" and present is not None and stall is None:
|
||||
stall = t
|
||||
elif e == "stall-end":
|
||||
if stall is not None and t is not None:
|
||||
m["stalls"] += 1
|
||||
m["stall_ms"] += t - stall
|
||||
stall = None
|
||||
elif e in ("abandon", "end"):
|
||||
ended = True
|
||||
if stall is not None:
|
||||
m["stalls"] += 1
|
||||
m["stall_ms"] += t - stall
|
||||
stall = None
|
||||
if present is not None:
|
||||
m["engaged_ms"] = t - present
|
||||
elif e == "abandon":
|
||||
m["exit_before_start"] = True
|
||||
else:
|
||||
m["start_failed"] = m["started"]
|
||||
return m
|
||||
|
||||
|
||||
def read_logs(b):
|
||||
"""§6.1: each log's metrics."""
|
||||
try:
|
||||
doc = json.loads(b.decode("utf-8"))
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
raise Refuse("qoe_invalid")
|
||||
if not isinstance(doc, list):
|
||||
raise Refuse("qoe_invalid")
|
||||
out = []
|
||||
for log in doc:
|
||||
if not isinstance(log, list) or not log:
|
||||
raise Refuse("qoe_invalid")
|
||||
out.append(session_metrics([read_observation(o) for o in log]))
|
||||
return out
|
||||
|
||||
|
||||
def cell_of(ms):
|
||||
"""§4.7."""
|
||||
if len(ms) < QOE_MIN:
|
||||
return cell_dict([1] + [0] * 8)
|
||||
return cell_dict([0, sum(m["started"] for m in ms), sum(m["start_failed"] for m in ms), sum(m["exit_before_start"] for m in ms),
|
||||
sum(m["stalls"] for m in ms), sum(m["stall_ms"] for m in ms), sum(m["engaged_ms"] for m in ms),
|
||||
sum(m["vst"] is not None for m in ms), sum(m["vst"] for m in ms if m["vst"] is not None)])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- §5 verification
|
||||
|
||||
def u64s(*v):
|
||||
return b"".join(struct.pack("<Q", x) for x in v)
|
||||
|
||||
|
||||
def evaluate(ctx, s, e, hops, takes, compositions, descriptors, logs, prices, currency):
|
||||
"""§5.1 steps 4 to 7 and the computations of §4: what the session gives, or Stop."""
|
||||
room = ctx.id
|
||||
published = set()
|
||||
for st, _, _ in ctx.over(s, e):
|
||||
published |= ctx.published(st)
|
||||
m = len(published)
|
||||
b_ = bound(m)
|
||||
records = []
|
||||
for i, (given, entry) in enumerate(hops):
|
||||
at = ["hop", i]
|
||||
try:
|
||||
if given is None:
|
||||
raise Refuse("record_missing")
|
||||
rb, lb = given
|
||||
if len(rb) > b_:
|
||||
raise Refuse("record_too_large")
|
||||
x = read_hop(rb, room)
|
||||
if entry is not None and (entry["hop"], entry["role"], entry["joules"], entry["class"]) != (x["hop"], x["role"], x["joules"], x["class"]):
|
||||
raise Refuse("unit_mismatch")
|
||||
hop_authorised(ctx, x)
|
||||
if x["start"] < s or x["end"] > e:
|
||||
raise Refuse("span_outside_session")
|
||||
if any(t["kind"] == 1 and t["track"] not in published for t in x["tracks"]):
|
||||
raise Refuse("track_unknown")
|
||||
led = read_ledger(lb)
|
||||
if len(led) != x["grants"] or ledger_root(led) != x["ledger"] \
|
||||
or any(ctx.ceilings(l["grant"]) != (l["jc"], l["fc"], l["currency"]) for l in led):
|
||||
raise Refuse("ledger_mismatch")
|
||||
if any(sum(l["sub"][c] for l in led) != x["sub"][c] for c in range(CLASSES)):
|
||||
raise Refuse("unit_mismatch")
|
||||
except Refuse as err:
|
||||
raise Stop(err.reason, at)
|
||||
x["ledger_entries"], x["size"], x["bound"] = led, len(rb), b_
|
||||
records.append(x)
|
||||
take_digests, recorded = [], []
|
||||
for i, given in enumerate(takes):
|
||||
try:
|
||||
if given is None:
|
||||
raise Refuse("record_missing")
|
||||
mf = CT.read_manifest(given, room)
|
||||
_, _, _, commits = CT.find(ctx.d, ctx.grants, ctx.seen, ctx.gaps, mf)
|
||||
except Refuse as err:
|
||||
raise Stop(err.reason, ["take", i])
|
||||
except CT.Stop as err:
|
||||
raise Stop(err.reason, ["take", i])
|
||||
take_digests.append(sha(given[:-64]))
|
||||
us = 0
|
||||
if commits:
|
||||
o, size = commits[0][1]["offset"], commits[-1][1]["size"]
|
||||
for c in mf["chunks"][:min(size, len(mf["chunks"]))]:
|
||||
us += max(min(c["end"] + o, e) - max(c["start"] + o, s), 0)
|
||||
recorded.append((us, commits[0][0]["grant"] if commits else None))
|
||||
digests = [given_digest(b) for b in compositions]
|
||||
outputs = {}
|
||||
for i, b in enumerate(compositions):
|
||||
try:
|
||||
rc = LCV.read_receipt(b)
|
||||
dbytes = next((d for d in descriptors if given_digest(d) == rc["descriptor"]), None)
|
||||
if dbytes is None:
|
||||
raise Refuse("record_missing")
|
||||
out = LCV.verify_receipt(ctx.view, dbytes, b)
|
||||
if out["outcome"] == "refuse":
|
||||
raise Refuse(out["reason"])
|
||||
if rc["t"] < s or rc["t"] >= e:
|
||||
raise Refuse("span_outside_session")
|
||||
except Refuse as err:
|
||||
raise Stop(err.reason, ["compose", i])
|
||||
outputs.setdefault((rc["compositor"], rc["media"], rc["output"]["track"]), []).append((rc["t"], rc["compositor"]))
|
||||
metrics = []
|
||||
if logs is not None:
|
||||
try:
|
||||
metrics = read_logs(logs)
|
||||
except Refuse as err:
|
||||
raise Stop(err.reason, ["qoe"])
|
||||
# §4.4 the units and §4.6 the consumption.
|
||||
units = {"publisher_us": 0, "subscriber_us": [0] * CLASSES, "sessions": 0, "composition_us": 0, "events_sent": 0,
|
||||
"events_delivered": 0, "recorded_us": 0}
|
||||
per = {}
|
||||
|
||||
def grant(g):
|
||||
if g not in per:
|
||||
jc, fc, cur = ctx.ceilings(g) or (NO_CEILING, NO_CEILING, b"\x00\x00\x00")
|
||||
per[g] = {"grant": g, "jc": jc, "fc": fc, "currency": cur, "pub": 0, "sub": [0] * CLASSES, "comp": 0, "rec": 0,
|
||||
"ev": 0, "attr": [0, 0, 0, 0], "unmetered": False, "metered": False}
|
||||
return per[g]
|
||||
for st, a, z in ctx.over(s, e):
|
||||
for p in publishers(st):
|
||||
units["publisher_us"] += z - a
|
||||
if p in st["present"]:
|
||||
grant(st["present"][p])["pub"] += z - a
|
||||
for g in st["present"].values():
|
||||
grant(g)
|
||||
for x in records:
|
||||
for c in range(CLASSES):
|
||||
units["subscriber_us"][c] += x["sub"][c]
|
||||
units["sessions"] += x["sessions"]
|
||||
units["events_delivered"] += x["events"]
|
||||
moved = sum(l["in"] + l["out"] for l in x["ledger_entries"])
|
||||
for l in x["ledger_entries"]:
|
||||
g = grant(l["grant"])
|
||||
for c in range(CLASSES):
|
||||
g["sub"][c] += l["sub"][c]
|
||||
b = l["in"] + l["out"]
|
||||
if b == 0:
|
||||
continue
|
||||
if x["class"] is None:
|
||||
g["unmetered"] = True
|
||||
else:
|
||||
g["attr"][x["class"][0] - 2] += x["joules"] * b // moved
|
||||
g["metered"] = True
|
||||
for runs in outputs.values():
|
||||
runs.sort()
|
||||
for (t1, comp), (t2, _) in zip(runs, runs[1:]):
|
||||
if t2 - t1 <= GAP_US:
|
||||
units["composition_us"] += t2 - t1
|
||||
g = ctx.at(t1)["present"].get(comp)
|
||||
if g is not None:
|
||||
grant(g)["comp"] += t2 - t1
|
||||
for x in ctx.seen:
|
||||
if s <= x["ev"]["t"] < e:
|
||||
units["events_sent"] += 1
|
||||
if x["grant"] is not None:
|
||||
grant(x["grant"])["ev"] += 1
|
||||
for us, g in recorded:
|
||||
units["recorded_us"] += us
|
||||
if g is not None:
|
||||
grant(g)["rec"] += us
|
||||
totals = {}
|
||||
for x in records:
|
||||
if x["class"] is not None:
|
||||
f, j = totals.get(x["class"][0], (0, 0))
|
||||
totals[x["class"][0]] = (f + 1, j + x["joules"])
|
||||
consumption = []
|
||||
for g in sorted(per):
|
||||
c = per[g]
|
||||
attributed = sum(c["attr"])
|
||||
if c["jc"] == NO_CEILING:
|
||||
jv = 0
|
||||
elif attributed > c["jc"]:
|
||||
jv = 2
|
||||
elif not c["metered"]:
|
||||
jv = 4
|
||||
elif c["unmetered"]:
|
||||
jv = 3
|
||||
else:
|
||||
jv = 1
|
||||
priced = currency != b"\x00\x00\x00" and currency == c["currency"]
|
||||
funds = NO_FUNDS
|
||||
if priced:
|
||||
n = c["pub"] * prices[0] + sum(c["sub"][k] * prices[1 + k] for k in range(CLASSES)) + c["comp"] * prices[10] \
|
||||
+ c["rec"] * prices[11] + c["ev"] * prices[12] * 10 ** 6
|
||||
funds = min(-(-n // 10 ** 12), NO_FUNDS - 1)
|
||||
if c["fc"] == NO_CEILING:
|
||||
fv = 0
|
||||
elif not priced:
|
||||
fv = 3
|
||||
elif funds > c["fc"]:
|
||||
fv = 2
|
||||
else:
|
||||
fv = 1
|
||||
raw = (g + u64s(c["jc"], c["fc"]) + c["currency"] + u64s(c["pub"], *c["sub"], c["comp"], c["rec"], c["ev"], *c["attr"])
|
||||
+ bytes([1 if c["unmetered"] else 0]) + u64s(funds) + bytes([jv, fv]))
|
||||
assert len(raw) == 198
|
||||
c.update(funds=funds, jv=jv, fv=fv, leaf=sha(CONSUMPTION_DOMAIN, raw))
|
||||
consumption.append(c)
|
||||
return {"records": records, "m": m, "units": units, "totals": totals, "takes": take_digests, "compose_digests": digests,
|
||||
"metrics": metrics, "consumption": consumption}
|
||||
|
||||
|
||||
def list_digest(digests):
|
||||
return sha(b"".join(digests)) if digests else ZERO
|
||||
|
||||
|
||||
def check_totals(rc, comp):
|
||||
"""§5.1 step 9."""
|
||||
unmetered = sum(1 for x in comp["records"] if x["class"] is None)
|
||||
if rc["unmetered"] != unmetered:
|
||||
raise Refuse("unit_mismatch")
|
||||
tt = comp["totals"]
|
||||
for tag, figures, joules in rc["totals"]:
|
||||
if tag not in tt:
|
||||
raise Refuse("unit_mismatch")
|
||||
if joules != tt[tag][1]:
|
||||
others = [c for c in tt if c != tag]
|
||||
summed = any(tt[tag][1] + sum(tt[o][1] for k, o in enumerate(others) if mask >> k & 1) == joules
|
||||
for mask in range(1, 1 << len(others)))
|
||||
raise Refuse("classes_summed" if summed else "unit_mismatch")
|
||||
if figures != tt[tag][0]:
|
||||
raise Refuse("unit_mismatch")
|
||||
if len(rc["totals"]) != len(tt):
|
||||
raise Refuse("unit_mismatch")
|
||||
|
||||
|
||||
def verify_vector(v, doc, cache):
|
||||
"""§5: the outcome the vector should state."""
|
||||
try:
|
||||
ctx = cache.ctx(doc["rooms"][v["room"]])
|
||||
except Stop as st:
|
||||
return {"outcome": "refuse", "reason": st.reason, "at": st.at}
|
||||
given_hops = [(bytes.fromhex(h["record"]), bytes.fromhex(h["ledger"])) for h in v["hops"]]
|
||||
descriptors = [bytes.fromhex(x) for x in v["descriptors"]]
|
||||
compositions = [bytes.fromhex(x) for x in v["compositions"]]
|
||||
takes = [bytes.fromhex(x) for x in v["takes"]]
|
||||
logs = v["logs"].encode("utf-8") if v["logs"] is not None else None
|
||||
try:
|
||||
rc = read_receipt(bytes.fromhex(v["receipt"]), ctx.id)
|
||||
signer_authorised(ctx, rc)
|
||||
except Refuse as err:
|
||||
return {"outcome": "refuse", "reason": err.reason, "at": ["receipt"]}
|
||||
hops = [(next(((r, l) for r, l in given_hops if given_digest(r) == h["record"]), None), h) for h in rc["hops"]]
|
||||
named_takes = [next((t for t in takes if given_digest(t) == d), None) for d in rc["takes"]]
|
||||
comps = sorted(compositions, key=given_digest)
|
||||
s, e = rc["start"], rc["end"]
|
||||
try:
|
||||
if len(comps) != rc["compose_count"] or list_digest([given_digest(b) for b in comps]) != rc["compose_digest"]:
|
||||
evaluate(ctx, s, e, hops, named_takes, [], descriptors, None, rc["prices"], rc["currency"])
|
||||
raise Stop("record_missing", ["compose"])
|
||||
named_logs = None
|
||||
if rc["qoe_count"] != 0:
|
||||
if logs is None or sha(logs) != rc["qoe_digest"]:
|
||||
evaluate(ctx, s, e, hops, named_takes, comps, descriptors, None, rc["prices"], rc["currency"])
|
||||
raise Stop("record_missing", ["qoe"])
|
||||
named_logs = logs
|
||||
comp = evaluate(ctx, s, e, hops, named_takes, comps, descriptors, named_logs, rc["prices"], rc["currency"])
|
||||
if len(comp["metrics"]) != rc["qoe_count"]:
|
||||
raise Stop("unit_mismatch", ["qoe"])
|
||||
if comp["units"] != rc["units"]:
|
||||
raise Stop("unit_mismatch", ["units"])
|
||||
try:
|
||||
check_totals(rc, comp)
|
||||
except Refuse as err:
|
||||
raise Stop(err.reason, ["energy"])
|
||||
cell = cell_of(comp["metrics"])
|
||||
if len(comp["metrics"]) < QOE_MIN and not rc["cell"]["suppressed"]:
|
||||
raise Stop("qoe_not_suppressed", ["qoe"])
|
||||
if cell != rc["cell"]:
|
||||
raise Stop("unit_mismatch", ["qoe"])
|
||||
root = mth([c["leaf"] for c in comp["consumption"]])
|
||||
past = [(c["grant"], (c["jv"] == 2) | ((c["fv"] == 2) << 1)) for c in comp["consumption"] if c["jv"] == 2 or c["fv"] == 2]
|
||||
if len(comp["consumption"]) != rc["grants"] or root != rc["root"] or past != rc["past"]:
|
||||
raise Stop("ceiling_mismatch", ["ceilings"])
|
||||
except Stop as st:
|
||||
return {"outcome": "refuse", "reason": st.reason, "at": st.at}
|
||||
whole = not any(x["class"] is None for x in comp["records"]) and len(comp["records"]) > 0
|
||||
|
||||
def ceiling(x):
|
||||
return None if x == NO_CEILING else x
|
||||
return {
|
||||
"outcome": "accept",
|
||||
"digest": rc["digest"].hex(),
|
||||
"media_tracks": comp["m"],
|
||||
"records": [{"digest": x["digest"].hex(), "size": x["size"], "bound": x["bound"]} for x in comp["records"]],
|
||||
"units": comp["units"],
|
||||
"unmetered": rc["unmetered"],
|
||||
"totals": [{"class": LABELS[t], "figures": f, "joules": j} for t, (f, j) in sorted(comp["totals"].items())],
|
||||
"coverage": "whole" if whole else "partial",
|
||||
"consumption": [{
|
||||
"grant": c["grant"].hex(), "joules_ceiling": ceiling(c["jc"]), "funds_ceiling": ceiling(c["fc"]),
|
||||
"currency": None if c["currency"] == b"\x00\x00\x00" else c["currency"].decode(),
|
||||
"publisher_us": c["pub"], "subscriber_us": c["sub"], "composition_us": c["comp"], "recorded_us": c["rec"],
|
||||
"events_sent": c["ev"], "attributed": {LABELS[t]: c["attr"][t - 2] for t in (2, 3, 4, 5)},
|
||||
"unmetered": c["unmetered"], "funds": None if c["funds"] == NO_FUNDS else c["funds"],
|
||||
"joules_verdict": JOULES_VERDICT[c["jv"]], "funds_verdict": FUNDS_VERDICT[c["fv"]], "leaf": c["leaf"].hex(),
|
||||
} for c in comp["consumption"]],
|
||||
"consumption_root": root.hex(),
|
||||
"past": [{"grant": g.hex(), "flags": f} for g, f in past],
|
||||
"qoe_cell": cell,
|
||||
}
|
||||
|
||||
|
||||
class Cache:
|
||||
"""Each room of the corpus verified once, by its content."""
|
||||
|
||||
def __init__(self):
|
||||
self.rooms = {}
|
||||
|
||||
def ctx(self, room):
|
||||
k = json.dumps(room, sort_keys=True)
|
||||
if k not in self.rooms:
|
||||
try:
|
||||
self.rooms[k] = Ctx(room)
|
||||
except Stop as st:
|
||||
self.rooms[k] = st
|
||||
got = self.rooms[k]
|
||||
if isinstance(got, Stop):
|
||||
raise got
|
||||
return got
|
||||
|
||||
|
||||
OUTCOME_KEYS = ("outcome", "reason", "at", "digest", "media_tracks", "records", "units", "unmetered", "totals", "coverage",
|
||||
"consumption", "consumption_root", "past", "qoe_cell")
|
||||
|
||||
|
||||
def check(doc, cache, quiet=False, only=None):
|
||||
fails = []
|
||||
for v in doc["vectors"]:
|
||||
if only is not None and v["name"] not in only:
|
||||
continue
|
||||
try:
|
||||
got = verify_vector(v, doc, cache)
|
||||
except Exception as e: # a crash on a mutated copy is a failure to read it
|
||||
got, problems = None, ["the vector does not verify: %s: %s" % (type(e).__name__, e)]
|
||||
else:
|
||||
problems = [k for k in OUTCOME_KEYS if v.get(k) != got.get(k)]
|
||||
if not quiet:
|
||||
what = "?" if got is None else got["outcome"] + (" " + got["reason"] if got["outcome"] == "refuse" else "")
|
||||
print(("PASS" if not problems else "FAIL") + " vector %s: %s" % (v["name"], what))
|
||||
for k in problems:
|
||||
fails.append("vector %s: %s is %s, the corpus states %s" % (
|
||||
v["name"], k, json.dumps(got.get(k))[:200] if got else k, json.dumps(v.get(k))[:200]))
|
||||
return fails
|
||||
|
||||
|
||||
def flip(h, at, bit=0x01):
|
||||
b = bytearray(bytes.fromhex(h))
|
||||
b[at] ^= bit
|
||||
return b.hex()
|
||||
|
||||
|
||||
def mutations(doc):
|
||||
"""Copies of the corpus, each with one defect a conforming verifier must catch, and the vectors it touches."""
|
||||
idx = {v["name"]: i for i, v in enumerate(doc["vectors"])}
|
||||
|
||||
def vec(d, name):
|
||||
return d["vectors"][idx[name]]
|
||||
|
||||
def mutate(label, names, f):
|
||||
d = copy.deepcopy(doc)
|
||||
f(d)
|
||||
return label, names, d
|
||||
|
||||
def relay_hop(d, name):
|
||||
relay = doc["principals"]["relay"]
|
||||
return next(h for h in vec(d, name)["hops"] if h["record"][74:138] == relay)
|
||||
|
||||
def drop_join(d):
|
||||
clock = doc["principals"]["relay"]
|
||||
chain = next(c for c in d["rooms"]["lesson"]["chains"] if LEV.read_record(bytes.fromhex(c[0]))["principal"].hex() == clock)
|
||||
chain.pop(4)
|
||||
|
||||
def cons(d, name, k):
|
||||
return vec(d, name)["consumption"][k]
|
||||
|
||||
lesson_units = ["lesson", "signed_by_authority"]
|
||||
return [
|
||||
mutate("lesson: a bit of the receipt's signature", ["lesson"], lambda d: vec(d, "lesson").update(receipt=flip(vec(d, "lesson")["receipt"], len(vec(d, "lesson")["receipt"]) // 2 - 1))),
|
||||
mutate("lesson: publisher-seconds stated one more", ["lesson"], lambda d: vec(d, "lesson")["units"].update(publisher_us=vec(d, "lesson")["units"]["publisher_us"] + 1)),
|
||||
mutate("lesson: recorded-seconds stated one less", ["lesson"], lambda d: vec(d, "lesson")["units"].update(recorded_us=vec(d, "lesson")["units"]["recorded_us"] - 1)),
|
||||
mutate("lesson: a byte of the relay's record", ["lesson"], lambda d: relay_hop(d, "lesson").update(record=flip(relay_hop(d, "lesson")["record"], 200))),
|
||||
mutate("lesson: a byte of the relay's ledger", ["lesson"], lambda d: relay_hop(d, "lesson").update(ledger=flip(relay_hop(d, "lesson")["ledger"], 60))),
|
||||
mutate("lesson: a composition receipt not given", ["lesson"], lambda d: vec(d, "lesson")["compositions"].pop()),
|
||||
mutate("lesson: a take not given", ["lesson"], lambda d: vec(d, "lesson")["takes"].pop()),
|
||||
mutate("lesson: a log not given", ["lesson"], lambda d: vec(d, "lesson").update(logs=None)),
|
||||
mutate("lesson: a stated verdict", ["lesson"], lambda d: cons(d, "lesson", 2).update(joules_verdict="past")),
|
||||
mutate("lesson: a stated attributed share", ["lesson"], lambda d: cons(d, "lesson", 3)["attributed"].update(OnChipCounter=1)),
|
||||
mutate("lesson: the stated coverage", ["lesson"], lambda d: vec(d, "lesson").update(coverage="partial")),
|
||||
mutate("lesson: a record's stated bound", ["lesson"], lambda d: vec(d, "lesson")["records"][0].update(bound=1)),
|
||||
mutate("mixed_classes: two totals stated as one class", ["mixed_classes"], lambda d: vec(d, "mixed_classes")["totals"][0].update(**{"class": "OnChipCounter"})),
|
||||
mutate("ceilings_past: a grant past stated within", ["ceilings_past"], lambda d: vec(d, "ceilings_past").update(past=vec(d, "ceilings_past")["past"][:1])),
|
||||
mutate("large_audience: the QoE cell stated suppressed", ["large_audience"], lambda d: vec(d, "large_audience")["qoe_cell"].update(suppressed=True)),
|
||||
mutate("large_audience: an observation's sn", ["large_audience"], lambda d: vec(d, "large_audience").update(logs=vec(d, "large_audience")["logs"].replace('"sn":1,', '"sn":0,', 1))),
|
||||
mutate("relay_recorded: a byte of the relay's own record", ["relay_recorded"], lambda d: vec(d, "relay_recorded")["hops"][0].update(record=flip(vec(d, "relay_recorded")["hops"][0]["record"], 120))),
|
||||
mutate("relay_recorded: subscriber-seconds stated other", ["relay_recorded"], lambda d: vec(d, "relay_recorded")["units"]["subscriber_us"].__setitem__(0, 1)),
|
||||
mutate("track_unknown: stated record_invalid", ["track_unknown"], lambda d: vec(d, "track_unknown").update(reason="record_invalid")),
|
||||
mutate("classes_summed: stated unit_mismatch", ["classes_summed"], lambda d: vec(d, "classes_summed").update(reason="unit_mismatch")),
|
||||
mutate("record_too_large: stated at another hop", ["record_too_large"], lambda d: vec(d, "record_too_large").update(at=["hop", 0])),
|
||||
mutate("lesson room: a lifecycle record not delivered", lesson_units, drop_join),
|
||||
mutate("lesson room: a byte of a grant", lesson_units, lambda d: d["rooms"]["lesson"]["grants"].__setitem__(4, flip(d["rooms"]["lesson"]["grants"][4], 230))),
|
||||
]
|
||||
|
||||
|
||||
def reasons_listed():
|
||||
"""The codes §9's table lists, in order."""
|
||||
with open(os.path.join(HERE, "..", "extensions", "live-metering.md"), encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
at = text.index("\n## 9. Reasons")
|
||||
end = text.find("\n## ", at + 1)
|
||||
section = text[at:end if end != -1 else len(text)]
|
||||
return [m.group(1) for m in re.finditer(r"^\| `([a-z_]+)` \|", section, re.M)]
|
||||
|
||||
|
||||
def main(corpus_dir):
|
||||
fails = []
|
||||
st = ED._self_test()
|
||||
print(("PASS" if not st else "FAIL") + " Ed25519 strict rule self-test " + ",".join(st))
|
||||
if st:
|
||||
fails.append("ed25519 self-test")
|
||||
with open(os.path.join(corpus_dir, "vectors.json"), encoding="utf-8") as f:
|
||||
doc = json.load(f)
|
||||
cache = Cache()
|
||||
fails += check(doc, cache)
|
||||
seen = {v.get("reason") for v in doc["vectors"]}
|
||||
missing = [r for r in REASONS if r not in seen]
|
||||
if missing:
|
||||
fails.append("no vector is refused %s" % ", ".join(missing))
|
||||
print("%s every reason of §9 refuses a vector" % ("PASS" if not missing else "FAIL"))
|
||||
listed = reasons_listed()
|
||||
if listed != REASONS:
|
||||
fails.append("§9 lists %s" % listed)
|
||||
print("%s §9 lists exactly the reasons this verifier knows" % ("PASS" if listed == REASONS else "FAIL"))
|
||||
for label, names, mutated in mutations(doc):
|
||||
caught = check(mutated, Cache() if label.startswith("lesson room") else cache, quiet=True, only=set(names))
|
||||
print(("PASS" if caught else "FAIL") + " mutated copy fails: %s%s" % (label, " (%s)" % caught[0][:120] if caught else ""))
|
||||
if not caught:
|
||||
fails.append("a mutated copy verifies: %s" % label)
|
||||
for f in fails:
|
||||
print("FAIL " + f)
|
||||
print(("PASS" if not fails else "FAIL") + " live-metering corpus: %d vectors" % len(doc["vectors"]))
|
||||
return 1 if fails else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("usage: live_metering_verify.py <live-conformance/metering>", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
123
wai/live-conformance/metering/README.md
Normal file
123
wai/live-conformance/metering/README.md
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
# live-conformance/metering — live metering
|
||||
|
||||
The corpus of [`extensions/live-metering.md`](../../extensions/live-metering.md) §10: rooms,
|
||||
and vectors, each a room-session receipt (`WMR1`) with what it names (the per-hop session
|
||||
records, `WMH1`, each with its ledger; the composition descriptors and receipts, `WCD1` and
|
||||
`WCR1`; the take manifests, `WTM1`; and the logs document of QoE observations), and the
|
||||
outcome a verifier gives it.
|
||||
|
||||
**Metering equivalence.** For each vector, a conforming receipt verifier given its room (by
|
||||
name, from `rooms`: the descriptor, the grants, the key-log head and the chains, each
|
||||
hexadecimal), its `receipt`, `hops` (each `record` and `ledger`), `descriptors`,
|
||||
`compositions`, `takes` and `logs` (the document's text, or `null`) reaches the outcome the
|
||||
vector states (§5.4):
|
||||
|
||||
- `refuse`: the `reason` and where, `at`: `["room"]` or `["room", …]` with the room
|
||||
verifier's own place (live-rooms §9.1), `["receipt"]`, `["hop", i]` for the receipt's hop
|
||||
entry `i`, `["take", i]`, `["compose"]` for the list of composition receipts or
|
||||
`["compose", i]` for the receipt `i` in ascending order of digest, `["qoe"]`, `["units"]`,
|
||||
`["energy"]` or `["ceilings"]`;
|
||||
- `accept`: the receipt's `digest`; the number of media tracks the room published in the
|
||||
session (`media_tracks`, the *M* of §3.7); each hop record's `digest`, `size` and size
|
||||
`bound`; the `units`; the `unmetered` hop entries; the `totals`, one per class; the
|
||||
`coverage`, `whole` or `partial`; each grant's `consumption` entry (its ceilings, its units,
|
||||
its attributed joules by class, whether its share is `unmetered`, its `funds`, its two
|
||||
verdicts and its `leaf`); the `consumption_root`; the grants `past` a ceiling with their
|
||||
flags; and the `qoe_cell`.
|
||||
|
||||
A verifier that reaches another outcome, reason, place or fact does not conform.
|
||||
|
||||
**Provenance.** Generated here: `wai_live_metering_vectors gen` writes `vectors.json` from
|
||||
sessions authored in code, from fixed seeds, by capture binding's corpus rules: each key is
|
||||
SHA-256 of that corpus's fixed label and the principal's index (the principals it shares with
|
||||
that corpus and with live recording's are the same principals), every time is a constant
|
||||
(the lesson room's `t0` is 2026-10-16T00:00:00Z), every chunk's bytes are SHA-256 output under
|
||||
a fixed label, and Ed25519 signing is deterministic (RFC 8032), so the file is the same bytes
|
||||
on every machine. The lesson's per-hop records are aggregated from sessions written in code
|
||||
by the rules a hop counts by (§3), and stand for what a relay, a compositor and an ingest
|
||||
gateway would count; their joule figures are constants and were measured by nothing. The
|
||||
key-log heads are sealed by the reference's key log under a fixed root; the origin is an
|
||||
`.example` name (RFC 2606). The generator asserts that each vector reaches the outcome it was
|
||||
written to have before it writes it.
|
||||
|
||||
**Recorded data, one record.** `relay-record.wmh` and its ledger `relay-ledger.wml` were signed
|
||||
by `joule-moq-relay` itself, in its test `tests/room_metering.rs` (two publishers and three
|
||||
subscribers of the `relay` room over native QUIC and WebTransport, one subscriber slow, each
|
||||
reading its streams concurrently), run once on 2026-10-18 with `WAI_METERING_FIXTURE_DIR`
|
||||
set; the relay's key is the room's clock key, under the same label. Its counts, its span
|
||||
(3.196 s of room clock from 11:45:35.966 UTC)
|
||||
and its subscriber time are what that run measured on loopback, so a second run gives other
|
||||
bytes: the pair is committed as recorded and read by the generator, which writes the `relay`
|
||||
room's lifecycle around the record's span. The relay meters no energy, and the record says so
|
||||
with the unmetered marker. The `relay` room's grants run thirteen days from its `t0`, so a
|
||||
pair recorded after 2026-10-31 would fall outside them.
|
||||
|
||||
## Layout
|
||||
|
||||
One file, `vectors.json`, beside the recorded pair:
|
||||
|
||||
- `principals`: the keys, by name (informative).
|
||||
- `rooms`: `lesson`, a lesson room (a director with the record power, a performer's device and
|
||||
an accompanist's capturing two takes, a compositor laying the room out at one frame a
|
||||
second, three viewers, a key log listing the room authority, the store, the relay, the
|
||||
compositor, an ingest gateway and a metering key, and a revoked metering key and relay key);
|
||||
`lesson_ceilings`, the same with the second viewer's joule ceiling at 1 000 µJ and the third
|
||||
viewer's funds ceiling at 0; `lesson_gap`, the clock's `join` of the third viewer not
|
||||
delivered; `lesson_bad_chat`, the viewer's chat carrying a bad signature; and `relay`, the
|
||||
reference relay's test room, with no origin.
|
||||
- `vectors`: each with `name`, `about`, `room`, `receipt`, `hops`, `descriptors`,
|
||||
`compositions`, `takes`, `logs`, `outcome`, and as above `reason` and `at`, or the facts.
|
||||
|
||||
The vectors:
|
||||
|
||||
- accepted: `lesson` (a relay and a compositor read from an on-chip counter, two takes, ten
|
||||
composition receipts, three viewers' logs and a suppressed QoE cell, under a tariff in
|
||||
euros; signed by the metering key), `signed_by_authority`, `mixed_classes` (a relay's counter
|
||||
reading, a compositor's model and an ingest gateway's estimate, one total per class),
|
||||
`ceilings_past` (the second viewer past its joule ceiling, the third past its funds
|
||||
ceiling), `large_audience` (ten thousand more subscriber sessions with no grant: the relay's
|
||||
record is the lesson's size; sixty logs publish the QoE cell) and `relay_recorded` (the
|
||||
reference relay's record, unmetered, so the energy is partial);
|
||||
- refused, one defect each: the receipt (`receipt_bad_signature`, `receipt_truncated`,
|
||||
`receipt_wrong_room`, `session_invalid`, `signer_not_authorised`, `signer_revoked`,
|
||||
`total_of_no_class`); the hop records (`record_missing`, `record_too_large`,
|
||||
`record_invalid`, `energy_invalid`, `record_wrong_room`, `record_bad_signature`,
|
||||
`entry_restated_wrong`, `hop_not_authorised`, `hop_class_not_authorised`, `hop_revoked`,
|
||||
`span_outside_session`, `track_unknown`, `ledger_invalid`, `ledger_ceiling_mismatch`,
|
||||
`ledger_root_mismatch`, `hop_units_mismatch`); the takes, compositions and logs
|
||||
(`take_missing`, `take_unknown`, `composition_missing`, `composition_refused`,
|
||||
`composition_outside_session`, `logs_missing`, `qoe_invalid`, `qoe_not_suppressed`); the
|
||||
units, energy and ceilings (`units_mismatch`, `recorded_mismatch`, `classes_summed`,
|
||||
`total_mismatch`, `ceiling_mismatch`, `consumption_root_mismatch`); and the room
|
||||
(`room_refused`, `lifecycle_incomplete`).
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
cd ../../wai-rs
|
||||
cargo run --no-default-features --features live_metering --bin wai_live_metering_vectors -- verify ../live-conformance/metering
|
||||
cargo run --no-default-features --features live_metering --bin wai_live_metering_vectors -- gen ../live-conformance/metering # regenerate
|
||||
cargo test --no-default-features --features live_metering --test live_metering
|
||||
cd ../live-conformance
|
||||
python3 live_metering_verify.py metering
|
||||
```
|
||||
|
||||
`wai_live_metering_vectors verify` and `tests/live_metering.rs` require the file to regenerate
|
||||
byte for byte and `wai-rs` to give every vector its outcome; the test also requires every
|
||||
reason of §9 to refuse a vector, the accepted vectors to be the ones above, the large
|
||||
audience's relay record to be the lesson's size within the same bound, and §9 to list exactly
|
||||
the reasons the reference reports. `live_metering_verify.py` is the second implementation,
|
||||
written from the extension's text and sharing no code with `wai-rs`: it verifies each room
|
||||
with `live_rooms_verify.py`, each take with `capture_take_verify.py` and each composition
|
||||
receipt with `live_compose_verify.py`, recomputes every unit, every total, every grant's
|
||||
consumption and the QoE cell itself, gives every vector the same outcome, requires the same
|
||||
coverage, and then mutates copies of the corpus, one defect each, each of which must fail.
|
||||
Pass = an exit status of 0 from each.
|
||||
|
||||
To record the relay's pair again (it changes the corpus), run the relay's test with
|
||||
`WAI_METERING_FIXTURE_DIR` set to this directory, then `gen`:
|
||||
|
||||
```
|
||||
cd ../../../joule-code/joule-code-rs
|
||||
WAI_METERING_FIXTURE_DIR=$PWD/../../wai/live-conformance/metering cargo test -p joule-moq-relay --test room_metering
|
||||
```
|
||||
BIN
wai/live-conformance/metering/relay-ledger.wml
Normal file
BIN
wai/live-conformance/metering/relay-ledger.wml
Normal file
Binary file not shown.
BIN
wai/live-conformance/metering/relay-record.wmh
Normal file
BIN
wai/live-conformance/metering/relay-record.wmh
Normal file
Binary file not shown.
3971
wai/live-conformance/metering/vectors.json
Normal file
3971
wai/live-conformance/metering/vectors.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -616,6 +616,20 @@
|
|||
"edition": null,
|
||||
"registered": "2026-10-16"
|
||||
},
|
||||
{
|
||||
"capability": "wai.live.metering",
|
||||
"kind": "record",
|
||||
"status": "registered",
|
||||
"media": null,
|
||||
"role": null,
|
||||
"tier": null,
|
||||
"licence": null,
|
||||
"replicate": null,
|
||||
"sets": [],
|
||||
"spec_section": "§5 Interactive, temporal & computed content",
|
||||
"edition": null,
|
||||
"registered": "2026-10-18"
|
||||
},
|
||||
{
|
||||
"capability": "wai.live.recording",
|
||||
"kind": "record",
|
||||
|
|
@ -1836,6 +1850,20 @@
|
|||
"section": "§6.2",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WMH1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/live-metering.md",
|
||||
"section": "§3.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WMR1",
|
||||
"kind": "magic",
|
||||
"document": "extensions/live-metering.md",
|
||||
"section": "§4.1",
|
||||
"status": "defined"
|
||||
},
|
||||
{
|
||||
"name": "WRD1",
|
||||
"kind": "magic",
|
||||
|
|
|
|||
|
|
@ -536,6 +536,13 @@ wai:wai.live.events a wai:Capability ;
|
|||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-16"^^xsd:date .
|
||||
|
||||
wai:wai.live.metering a wai:Capability ;
|
||||
rdfs:label "wai.live.metering" ;
|
||||
wai:kind wai:kind-record ;
|
||||
wai:status wai:status-registered ;
|
||||
wai:specSection "§5 Interactive, temporal & computed content" ;
|
||||
wai:registeredOn "2026-10-18"^^xsd:date .
|
||||
|
||||
wai:wai.live.recording a wai:Capability ;
|
||||
rdfs:label "wai.live.recording" ;
|
||||
wai:kind wai:kind-record ;
|
||||
|
|
|
|||
|
|
@ -212,6 +212,15 @@ live_compose = ["live_room"]
|
|||
# records whose RFC 6962 range proofs reach a take's commitment, verified without the take.
|
||||
# SHA-256, Ed25519 and the key log only; no new dependency.
|
||||
live_record = ["capture_take"]
|
||||
# Live metering (extensions/live-metering): `wai.live.metering` — the per-hop session record
|
||||
# (WMH1) a relay, compositor or ingest gateway signs, with its ledger of the grants its sessions
|
||||
# ran under and a size bound independent of the audience; the room-session receipt (WMR1) a room
|
||||
# authority or a `live/meter` key signs, its units, joules per hop with their acquisition class and
|
||||
# a total per class, each grant's consumption against its ceilings, and the QoE cell; and the QoE
|
||||
# observations' CMCD event-timeline record and `application/cmcd` batch. It reads rooms, takes and
|
||||
# composition receipts through their modules and the logs through the telemetry module. SHA-256,
|
||||
# Ed25519 and the key log only; no new dependency.
|
||||
live_metering = ["live_compose", "capture_take", "telemetry"]
|
||||
# Timed text and live captions (extensions/timed-text): `wai.text.timed` — the cue model,
|
||||
# live caption revisions (`caption` events of wai.live.events with the WTX1 annotation),
|
||||
# and their exact mappings onto WebVTT documents and segments, the IMSC1 Text Profile,
|
||||
|
|
@ -671,6 +680,11 @@ name = "wai_live_record_vectors"
|
|||
path = "src/bin/wai_live_record_vectors.rs"
|
||||
required-features = ["live_record"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_live_metering_vectors"
|
||||
path = "src/bin/wai_live_metering_vectors.rs"
|
||||
required-features = ["live_metering"]
|
||||
|
||||
[[bin]]
|
||||
name = "wai_timed_text_vectors"
|
||||
path = "src/bin/wai_timed_text_vectors.rs"
|
||||
|
|
@ -1337,6 +1351,11 @@ name = "live_record"
|
|||
path = "tests/live_record.rs"
|
||||
required-features = ["live_record"]
|
||||
|
||||
[[test]]
|
||||
name = "live_metering"
|
||||
path = "tests/live_metering.rs"
|
||||
required-features = ["live_metering"]
|
||||
|
||||
[[test]]
|
||||
name = "timed_text"
|
||||
path = "tests/timed_text.rs"
|
||||
|
|
|
|||
57
wai/wai-rs/src/bin/wai_live_metering_vectors.rs
Normal file
57
wai/wai-rs/src/bin/wai_live_metering_vectors.rs
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
//! Conformance corpus for live metering (`live-conformance/metering/`, extensions/live-metering.md §10).
|
||||
//!
|
||||
//! wai_live_metering_vectors gen <corpus-dir> write vectors.json, from fixed seeds and the
|
||||
//! reference relay's recorded record
|
||||
//! wai_live_metering_vectors verify <corpus-dir> vectors.json regenerates byte for byte, and
|
||||
//! every vector gets the outcome it states
|
||||
//! wai_live_metering_vectors relay-room print what the reference relay's test needs
|
||||
//! of its room: its id, keys and grants
|
||||
//!
|
||||
//! The generator and this verifier are one implementation. The corpus's second
|
||||
//! implementation is `live-conformance/live_metering_verify.py`, written from the extension's
|
||||
//! text and sharing no code with this binary.
|
||||
|
||||
use std::path::Path;
|
||||
use wai::live_metering::corpus;
|
||||
|
||||
fn main() {
|
||||
let a: Vec<String> = std::env::args().collect();
|
||||
match (a.get(1).map(String::as_str), a.get(2)) {
|
||||
(Some("gen"), Some(d)) => {
|
||||
let p = Path::new(d).join("vectors.json");
|
||||
if let Err(e) = std::fs::create_dir_all(d).and_then(|_| std::fs::write(&p, corpus::generate())) {
|
||||
eprintln!("write {}: {e}", p.display());
|
||||
std::process::exit(1);
|
||||
}
|
||||
println!("wrote {}", p.display());
|
||||
}
|
||||
(Some("verify"), Some(d)) => std::process::exit(verify(Path::new(d))),
|
||||
(Some("relay-room"), None) => print!("{}", corpus::relay_room_facts()),
|
||||
_ => {
|
||||
eprintln!("usage: wai_live_metering_vectors gen|verify <corpus-dir> | relay-room");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn verify(dir: &Path) -> i32 {
|
||||
let p = dir.join("vectors.json");
|
||||
let doc = match std::fs::read_to_string(&p) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("read {}: {e}", p.display());
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let mut fail = 0;
|
||||
let same = doc == corpus::generate();
|
||||
println!("{} vectors.json regenerates byte for byte from its seeds", if same { "PASS" } else { "FAIL" });
|
||||
fail += (!same) as i32;
|
||||
let fails = corpus::verify(&doc);
|
||||
for f in &fails {
|
||||
println!("FAIL {f}");
|
||||
}
|
||||
println!("{} every vector gets the outcome the corpus states", if fails.is_empty() { "PASS" } else { "FAIL" });
|
||||
fail += (!fails.is_empty()) as i32;
|
||||
(fail != 0) as i32
|
||||
}
|
||||
|
|
@ -364,6 +364,153 @@ pub fn recover(manifest: &Manifest) -> Result<RecoveredBindings, EmitError> {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live_metering")]
|
||||
pub use session::{recover_session, RecoveredSession, SessionManifest, SuppliedFigure};
|
||||
|
||||
/// A room-session receipt (`extensions/live-metering.md` §7) in a real C2PA manifest: the
|
||||
/// standard `c2pa.environmental-sustainability` assertion where one figure can honestly be
|
||||
/// written, and always the companion `science.transaction.wai.live.metering`.
|
||||
#[cfg(feature = "live_metering")]
|
||||
mod session {
|
||||
use super::*;
|
||||
use crate::live_metering::{hex, tag_label, SessionReceipt};
|
||||
|
||||
/// The companion assertion's label (live-metering §7).
|
||||
pub const COMPANION_LABEL: &str = "science.transaction.wai.live.metering";
|
||||
|
||||
/// A GHG or water figure the caller supplies, with the method that gave it. The receipt
|
||||
/// holds neither, and this module never derives one from the energy (energy-binding §4).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct SuppliedFigure {
|
||||
pub amount: f64,
|
||||
pub method: String,
|
||||
}
|
||||
|
||||
/// The assertions of one room session, ready to be sealed into a manifest of an asset of
|
||||
/// that session: its recording, its composite.
|
||||
pub struct SessionManifest {
|
||||
standard: Option<Value>,
|
||||
companion: Value,
|
||||
}
|
||||
|
||||
impl SessionManifest {
|
||||
/// The assertions of `r`. The standard assertion's `energy_kwh` is written only when
|
||||
/// the receipt's energy is whole and every hop entry is of one class (§4.5): one
|
||||
/// figure of several classes would sum them, and one with an unmetered hop would
|
||||
/// present a part as the whole. Supplied GHG and water figures go into the standard
|
||||
/// assertion, which is then written whether or not it carries energy.
|
||||
pub fn new(r: &SessionReceipt, carbon_kgco2e: Option<SuppliedFigure>, water_litres: Option<SuppliedFigure>) -> SessionManifest {
|
||||
let whole = r.unmetered == 0 && !r.hops.is_empty();
|
||||
let one_class = (r.totals.len() == 1).then(|| r.totals[0]);
|
||||
let mut standard = serde_json::Map::new();
|
||||
if let (true, Some(t)) = (whole, one_class) {
|
||||
// 1 µJ is 1/3.6e12 kWh, a derived interop value (energy-binding §4).
|
||||
standard.insert("energy_kwh".into(), Value::from(t.joules as f64 / 3.6e12));
|
||||
standard.insert(
|
||||
"measurementMethod".into(),
|
||||
Value::from(format!("wai.live.metering: {} hop records, every one {}", t.figures, tag_label(t.tag))),
|
||||
);
|
||||
}
|
||||
let mut companion = serde_json::json!({
|
||||
"receipt": hex(&r.digest()),
|
||||
"room": hex(&r.room),
|
||||
"span": {"start": r.start, "end": r.end},
|
||||
"coverage": if whole { "whole" } else { "partial" },
|
||||
"unmetered": r.unmetered,
|
||||
"totals": r.totals.iter().map(|t| serde_json::json!({"acquisition": tag_label(t.tag), "figures": t.figures, "microjoules": t.joules})).collect::<Vec<_>>(),
|
||||
"units": {
|
||||
"publisherUs": r.units.publisher_us, "subscriberUs": r.units.subscriber_us, "sessions": r.units.sessions,
|
||||
"compositionUs": r.units.composition_us, "eventsSent": r.units.events_sent,
|
||||
"eventsDelivered": r.units.events_delivered, "recordedUs": r.units.recorded_us,
|
||||
},
|
||||
"grants": r.grants,
|
||||
"consumptionRoot": hex(&r.consumption_root),
|
||||
});
|
||||
for (key, method_key, f) in [("carbon_kgco2e", "carbonMethod", carbon_kgco2e), ("water_litres", "waterMethod", water_litres)] {
|
||||
if let Some(f) = f {
|
||||
standard.insert(key.into(), Value::from(f.amount));
|
||||
companion[method_key] = Value::from(f.method);
|
||||
}
|
||||
}
|
||||
SessionManifest { standard: (!standard.is_empty()).then_some(Value::Object(standard)), companion }
|
||||
}
|
||||
|
||||
/// The standard assertion's body, when one is written.
|
||||
pub fn standard(&self) -> Option<&Value> {
|
||||
self.standard.as_ref()
|
||||
}
|
||||
|
||||
/// The companion assertion's body.
|
||||
pub fn companion(&self) -> &Value {
|
||||
&self.companion
|
||||
}
|
||||
|
||||
/// A builder with `parent` (the session's recording, or the receipt itself) as the
|
||||
/// parent ingredient and the assertions attached, every assertion created.
|
||||
pub fn builder(&self, parent: &[u8], parent_format: &str) -> Result<Builder, EmitError> {
|
||||
let mut b = Builder::default();
|
||||
b.set_claim_generator_info(ClaimGeneratorInfo::new("wai"));
|
||||
b.set_intent(BuilderIntent::Edit);
|
||||
b.add_ingredient_from_stream(
|
||||
serde_json::json!({"relationship": "parentOf", "title": "WAI room session", "format": parent_format}).to_string(),
|
||||
parent_format,
|
||||
&mut std::io::Cursor::new(parent.to_vec()),
|
||||
)?;
|
||||
b.add_action(Action::new(c2pa_action::CONVERTED).set_software_agent("wai").set_description("an asset of a WAI room session whose units and energy a room-session receipt states"))?;
|
||||
if let Some(s) = &self.standard {
|
||||
b.add_assertion(EnergyBinding::C2PA_STANDARD_LABEL, s)?;
|
||||
}
|
||||
b.add_assertion(COMPANION_LABEL, &self.companion)?;
|
||||
for a in &mut b.definition.assertions {
|
||||
a.created = true;
|
||||
}
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
/// Sign `source` into `dest`, embedding the manifest. Returns the manifest bytes.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn sign_stream<R, W>(&self, signer: &dyn Signer, parent: &[u8], parent_format: &str, format: &str, source: &mut R, dest: &mut W) -> Result<Vec<u8>, EmitError>
|
||||
where
|
||||
R: Read + Seek + Send,
|
||||
W: Write + Read + Seek + Send,
|
||||
{
|
||||
Ok(self.builder(parent, parent_format)?.sign(signer, format, source, dest)?)
|
||||
}
|
||||
}
|
||||
|
||||
/// A session's assertions recovered from a validated manifest.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RecoveredSession {
|
||||
pub standard: Option<Value>,
|
||||
pub companion: Value,
|
||||
}
|
||||
|
||||
impl RecoveredSession {
|
||||
/// Whether the companion is about this receipt: the receipt's digest, and its totals
|
||||
/// class by class.
|
||||
pub fn describes(&self, r: &SessionReceipt) -> bool {
|
||||
SessionManifest::new(r, None, None).companion.get("totals") == self.companion.get("totals")
|
||||
&& self.companion.get("receipt").and_then(Value::as_str) == Some(hex(&r.digest()).as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover a session's assertions, refusing a standard assertion with no companion and
|
||||
/// an `energy_kwh` beside a companion that states several classes or a partial total.
|
||||
pub fn recover_session(manifest: &Manifest) -> Result<RecoveredSession, EmitError> {
|
||||
let find = |label: &str| manifest.assertions().iter().find(|a| a.label() == label).and_then(|a| a.value().ok().cloned());
|
||||
let standard = find(EnergyBinding::C2PA_STANDARD_LABEL);
|
||||
let Some(companion) = find(COMPANION_LABEL) else {
|
||||
return Err(if standard.is_some() { EmitError::UnaccompaniedStandardAssertion } else { EmitError::NoBinding });
|
||||
};
|
||||
let classes = companion.get("totals").and_then(Value::as_array).map_or(0, Vec::len);
|
||||
let whole = companion.get("coverage").and_then(Value::as_str) == Some("whole");
|
||||
if standard.as_ref().is_some_and(|s| s.get("energy_kwh").is_some()) && !(whole && classes == 1) {
|
||||
return Err(EmitError::Malformed("energy_kwh beside several classes or a partial total"));
|
||||
}
|
||||
Ok(RecoveredSession { standard, companion })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -786,4 +933,90 @@ mod tests {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the room-session receipt (live-metering §7) ---------------------------------
|
||||
|
||||
#[cfg(feature = "live_metering")]
|
||||
fn session_receipt(totals: Vec<crate::live_metering::Total>, unmetered: u8) -> crate::live_metering::SessionReceipt {
|
||||
use crate::live_metering::*;
|
||||
SessionReceipt {
|
||||
room: [7; 32],
|
||||
signer: [8; 32],
|
||||
start: 0,
|
||||
end: 24_000_000,
|
||||
hops: (0..totals.iter().map(|t| t.figures).sum::<u32>() + unmetered as u32)
|
||||
.map(|k| HopEntry { record: [k as u8; 32], hop: [9; 32], role: role::RELAY, joules: 0, class: None })
|
||||
.collect(),
|
||||
takes: vec![],
|
||||
compose_count: 0,
|
||||
compose_digest: [0; 32],
|
||||
qoe_count: 0,
|
||||
qoe_digest: [0; 32],
|
||||
units: Units { publisher_us: 80_500_000, ..Units::default() },
|
||||
unmetered,
|
||||
totals,
|
||||
tariff: Tariff::default(),
|
||||
grants: 8,
|
||||
consumption_root: [3; 32],
|
||||
past: vec![],
|
||||
qoe_cell: QoeCell::suppressed(),
|
||||
sig: [0; 64],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "live_metering")]
|
||||
fn sign_session(m: &SessionManifest, receipt: &[u8], asset: &[u8]) -> Vec<u8> {
|
||||
let signer = untrusted_local_signer().unwrap();
|
||||
let mut src = Cursor::new(asset.to_vec());
|
||||
let mut dst = Cursor::new(Vec::new());
|
||||
m.sign_stream(&signer, receipt, "application/octet-stream", "image/png", &mut src, &mut dst).unwrap();
|
||||
dst.into_inner()
|
||||
}
|
||||
|
||||
/// A session whose hops are all one class, whole: both assertions in a real signed
|
||||
/// manifest, `energy_kwh` derived from the one total, the companion naming the receipt.
|
||||
#[cfg(feature = "live_metering")]
|
||||
#[test]
|
||||
fn a_session_of_one_class_writes_the_standard_assertion() {
|
||||
use crate::live_metering::Total;
|
||||
let r = session_receipt(vec![Total { tag: 4, figures: 2, joules: 60_400_000 }], 0);
|
||||
let m = SessionManifest::new(&r, None, None);
|
||||
let rgb = pixels();
|
||||
let signed = sign_session(&m, &r.to_bytes(), &png(W, H, &rgb));
|
||||
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
|
||||
assert_eq!(reader.validation_state(), ValidationState::Valid, "{:?}", reader.validation_status());
|
||||
let got = recover_session(reader.active_manifest().unwrap()).unwrap();
|
||||
let kwh = got.standard.as_ref().unwrap()["energy_kwh"].as_f64().unwrap();
|
||||
assert!((kwh - 60_400_000.0 / 3.6e12).abs() < 1e-18);
|
||||
assert!(got.standard.as_ref().unwrap()["measurementMethod"].as_str().unwrap().contains("OnChipCounter"));
|
||||
assert!(got.standard.as_ref().unwrap().get("carbon_kgco2e").is_none(), "never invented");
|
||||
assert!(got.describes(&r));
|
||||
assert_eq!(got.companion["coverage"], "whole");
|
||||
}
|
||||
|
||||
/// Several classes, or an unmetered hop: no `energy_kwh`, the companion alone states
|
||||
/// the totals, class by class; a supplied water figure is written with its method.
|
||||
#[cfg(feature = "live_metering")]
|
||||
#[test]
|
||||
fn a_session_of_several_classes_writes_the_companion_alone() {
|
||||
use crate::live_metering::Total;
|
||||
let mixed = session_receipt(vec![Total { tag: 2, figures: 1, joules: 35_000_000 }, Total { tag: 4, figures: 1, joules: 18_400_000 }], 0);
|
||||
let m = SessionManifest::new(&mixed, None, None);
|
||||
assert!(m.standard().is_none(), "one figure would sum two classes");
|
||||
let partial = session_receipt(vec![Total { tag: 4, figures: 1, joules: 18_400_000 }], 1);
|
||||
let water = SuppliedFigure { amount: 0.25, method: "the operator's metered cooling loop".into() };
|
||||
let m = SessionManifest::new(&partial, None, Some(water));
|
||||
let standard = m.standard().unwrap();
|
||||
assert!(standard.get("energy_kwh").is_none(), "a part is not the whole");
|
||||
assert_eq!(standard["water_litres"], 0.25);
|
||||
let rgb = pixels();
|
||||
let signed = sign_session(&m, &partial.to_bytes(), &png(W, H, &rgb));
|
||||
let reader = Reader::from_stream("image/png", Cursor::new(signed)).unwrap();
|
||||
assert_eq!(reader.validation_state(), ValidationState::Valid, "{:?}", reader.validation_status());
|
||||
let got = recover_session(reader.active_manifest().unwrap()).unwrap();
|
||||
assert_eq!(got.companion["coverage"], "partial");
|
||||
assert_eq!(got.companion["waterMethod"], "the operator's metered cooling loop");
|
||||
assert!(got.describes(&partial));
|
||||
assert!(!got.describes(&mixed));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1323,6 +1323,36 @@ pub fn verify_take(i: &TakeInputs) -> TakeRun {
|
|||
run
|
||||
}
|
||||
|
||||
/// One held commitment of a take, as [`find_take`] gives it: its record's digest and `t`,
|
||||
/// its payload, and the grant the room verifier verified it under.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct HeldCommit {
|
||||
pub digest: [u8; 32],
|
||||
pub t: u64,
|
||||
pub commit: Commit,
|
||||
pub grant: Option<[u8; 32]>,
|
||||
}
|
||||
|
||||
/// A take found in its room: the `t` of its start and its held commitments, in order.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct FoundTake {
|
||||
pub start_t: u64,
|
||||
pub held: Vec<HeldCommit>,
|
||||
}
|
||||
|
||||
/// §7.2 and §9.2 over a room the room verifier did not refuse: the manifest read and
|
||||
/// verified, and the take as the room holds it. Live metering (extensions/live-metering.md
|
||||
/// §4.4) counts a take's committed capture from it.
|
||||
pub fn find_take(run: &RoomRun, chains: &[Vec<Vec<u8>>], manifest: &[u8]) -> Result<(Manifest, FoundTake), (Place, Reason)> {
|
||||
let room_id = run.room.expect("a room that verified");
|
||||
let m = Manifest::parse(manifest, &room_id).map_err(|r| (Place::Manifest, r))?;
|
||||
let seen = accepted(run, chains);
|
||||
let f = find(run, &seen, &m)?;
|
||||
let held = f.commits.iter().map(|h| HeldCommit { digest: h.seen.digest, t: h.seen.event.t, commit: h.c.clone(), grant: h.seen.grant }).collect();
|
||||
let found = FoundTake { start_t: f.start.event.t, held };
|
||||
Ok((m, found))
|
||||
}
|
||||
|
||||
fn fail_all() -> Verdicts {
|
||||
let v = Verdict::Fails(Reason::TakeUnknown);
|
||||
Verdicts { single_take: v, aligned: v, complete: v, publishable: [v; 4], stands: false }
|
||||
|
|
|
|||
|
|
@ -214,6 +214,7 @@ pub const CAP_LIVE_ROOM: &str = "wai.live.room";
|
|||
pub const CAP_CAPTURE_TAKE: &str = "wai.capture.take";
|
||||
pub const CAP_LIVE_COMPOSE: &str = "wai.live.compose";
|
||||
pub const CAP_LIVE_RECORDING: &str = "wai.live.recording";
|
||||
pub const CAP_LIVE_METERING: &str = "wai.live.metering";
|
||||
pub const CAP_TEXT_TIMED: &str = "wai.text.timed";
|
||||
|
||||
/// One row per string SPEC §5 registers that is a payload, companion or record:
|
||||
|
|
@ -297,6 +298,7 @@ const ROWS: &[(&str, &str, Option<&str>, &str)] = &[
|
|||
(CAP_CAPTURE_TAKE, S_INTERACTIVE, None, "2026-10-17"),
|
||||
(CAP_LIVE_COMPOSE, S_INTERACTIVE, None, "2026-10-17"),
|
||||
(CAP_LIVE_RECORDING, S_INTERACTIVE, None, "2026-10-17"),
|
||||
(CAP_LIVE_METERING, S_INTERACTIVE, None, "2026-10-18"),
|
||||
(CAP_TEXT_TIMED, S_INTERACTIVE, None, "2026-10-17"),
|
||||
(CAP_AUDIO_IAMF, S_IMMERSIVE, None, "2026-06-17"),
|
||||
(CAP_AUDIO_AC4, S_IMMERSIVE, None, "2026-06-17"),
|
||||
|
|
@ -345,7 +347,7 @@ const RECORDS: &[&str] = &[
|
|||
CAP_ASSET_PROVENANCE, CAP_QUANTUM_CALIBRATION, CAP_QUANTUM_JOB,
|
||||
CAP_VIDEO_MANIFEST, CAP_VIDEO_SSAI, CAP_VIDEO_KEYS, CAP_VIDEO_PACKAGE, CAP_VIDEO_CHANNEL,
|
||||
CAP_LIVE_EVENTS, CAP_LIVE_ROOM, CAP_CAPTURE_TAKE, CAP_LIVE_COMPOSE, CAP_LIVE_RECORDING,
|
||||
CAP_TEXT_TIMED,
|
||||
CAP_LIVE_METERING, CAP_TEXT_TIMED,
|
||||
];
|
||||
|
||||
/// A named identifier SPEC uses that has the shape of a capability string but is
|
||||
|
|
@ -489,6 +491,7 @@ const CAPTURE_BINDING: &str = "extensions/capture-binding.md";
|
|||
const LIVE_COMPOSE: &str = "extensions/live-compose.md";
|
||||
const LIVE_RECORDING: &str = "extensions/live-recording.md";
|
||||
const TIMED_TEXT: &str = "extensions/timed-text.md";
|
||||
const LIVE_METERING: &str = "extensions/live-metering.md";
|
||||
|
||||
const fn wire(name: &'static str, kind: WireKind, document: &'static str, section: &'static str,
|
||||
status: WireStatus) -> WireIdentifier {
|
||||
|
|
@ -498,8 +501,8 @@ const fn wire(name: &'static str, kind: WireKind, document: &'static str, sectio
|
|||
/// The wire identifiers of the container, of its two delivery bindings
|
||||
/// (http-delivery, moq-streaming-format), of the key discovery those bindings serve
|
||||
/// (jwp-receipts §7), of packaging, of live events, of live rooms, of capture binding, of
|
||||
/// live composition, of live recording and of timed text, in document order. The export
|
||||
/// lists them sorted by name and kind.
|
||||
/// live composition, of live recording, of timed text and of live metering, in document
|
||||
/// order. The export lists them sorted by name and kind.
|
||||
pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
||||
wire("WAI1", WireKind::Magic, "SPEC.md", "§2", WireStatus::Defined),
|
||||
wire("WAI2", WireKind::Magic, "SPEC.md", "§7.1", WireStatus::Defined),
|
||||
|
|
@ -564,6 +567,8 @@ pub const WIRE_IDENTIFIERS: &[WireIdentifier] = &[
|
|||
wire("tag:transaction.science,2026:wai/model/", WireKind::Scheme, TIMED_TEXT, "§10.3", WireStatus::Defined),
|
||||
wire("tag:transaction.science,2026:wai/timed-text", WireKind::Scheme, TIMED_TEXT, "§10.4", WireStatus::Defined),
|
||||
wire("science.transaction.wai.timed-text", WireKind::DataId, TIMED_TEXT, "§10.5", WireStatus::Defined),
|
||||
wire("WMH1", WireKind::Magic, LIVE_METERING, "§3.1", WireStatus::Defined),
|
||||
wire("WMR1", WireKind::Magic, LIVE_METERING, "§4.1", WireStatus::Defined),
|
||||
];
|
||||
|
||||
/// A string with the shape of a capability that the reference implementation uses
|
||||
|
|
|
|||
|
|
@ -432,6 +432,16 @@ pub mod live_compose;
|
|||
/// cbindgen:ignore
|
||||
#[cfg(feature = "live_record")]
|
||||
pub mod live_record;
|
||||
// Live metering (extensions/live-metering.md): `wai.live.metering` — the per-hop session record a
|
||||
// relay, compositor or ingest gateway signs, batched per session with a size bound independent of
|
||||
// the audience and a ledger of the grants its sessions ran under; the room-session receipt in units
|
||||
// (publisher-, subscriber-, composition- and recorded-seconds, events sent and delivered), joules per
|
||||
// hop with their acquisition class and a total per class, each grant's consumption against its
|
||||
// ceilings, and the QoE cell. Rust-only, like the other live modules: its constants must not become
|
||||
// macros in include/wai.h.
|
||||
/// cbindgen:ignore
|
||||
#[cfg(feature = "live_metering")]
|
||||
pub mod live_metering;
|
||||
// Timed text and live captions (extensions/timed-text.md): `wai.text.timed` — the cue model
|
||||
// both WebVTT and the IMSC1 Text Profile express, live caption revisions as `caption`
|
||||
// events of `wai.live.events` with the WTX1 annotation, and the exact mappings onto WebVTT
|
||||
|
|
|
|||
2021
wai/wai-rs/src/live_metering.rs
Normal file
2021
wai/wai-rs/src/live_metering.rs
Normal file
File diff suppressed because it is too large
Load diff
1458
wai/wai-rs/src/live_metering/corpus.rs
Normal file
1458
wai/wai-rs/src/live_metering/corpus.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -223,7 +223,7 @@ fn every_decoded_output_states_a_sha256() {
|
|||
("pin-conformance", "parameter-set pins: every digest there is a SHA-256 of a parameter set"),
|
||||
("ops-conformance", "integer operators: every operator, stage and graph vector, the lower_* decodes of the sets under sets/ among them, states its output integers in full or its refusal code, never a digest of them; certificates/ states each lowered set's SHA-256 (a parameter-set identity) and holds the lowered graphs as bytes, compared byte for byte; the output digests a capability decoded through its lowering reproduces (integer-operators §14.4) are stated by that capability's own corpus"),
|
||||
("signer-conformance", "key logs, grants and statuses: signed documents, no decoded output"),
|
||||
("live-conformance", "live events, rooms, takes, compositions, recordings and captions: signed records, commitments, packages, clips, cues and their renderings, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
|
||||
("live-conformance", "live events, rooms, takes, compositions, recordings, captions and metering: signed records, commitments, packages, clips, cues and their renderings, per-hop records, ledgers and session receipts with their units and joules recomputed, and their verdicts, and layouts and mixes stated in full, rectangle for rectangle and sample for sample; nothing decoded against a parameter set"),
|
||||
("http-conformance", "objects and head proofs for transport: no case decodes a payload, and the component a fetch case picks is stated by its SHA-256"),
|
||||
("cache-conformance", "cache rules: a driver, its controls and their reports, with no objects of its own"),
|
||||
("byte-exact-conformance", "goldens over tests/vectors, whose sidecars state the SHA-256"),
|
||||
|
|
|
|||
80
wai/wai-rs/tests/live_metering.rs
Normal file
80
wai/wai-rs/tests/live_metering.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! `wai.live.metering` (extensions/live-metering.md): the committed corpus
|
||||
//! `live-conformance/metering/vectors.json` regenerates byte for byte from its fixed seeds and
|
||||
//! the reference relay's recorded record, every vector in it gets the outcome it states, every
|
||||
//! reason the extension adds refuses a vector, the large audience's relay record is the size of
|
||||
//! the lesson's, and the extension's §9 lists exactly the reasons the reference reports.
|
||||
#![cfg(feature = "live_metering")]
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use wai::live_metering::{corpus, Reason};
|
||||
|
||||
fn wai_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
|
||||
}
|
||||
|
||||
fn read(p: &str) -> String {
|
||||
std::fs::read_to_string(wai_dir().join(p)).unwrap_or_else(|e| panic!("{p}: {e}"))
|
||||
}
|
||||
|
||||
fn committed() -> String {
|
||||
read("live-conformance/metering/vectors.json")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_committed_corpus_regenerates_from_its_seeds() {
|
||||
assert!(committed() == corpus::generate(), "live-conformance/metering/vectors.json differs from `wai_live_metering_vectors gen`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_vector_gets_its_stated_outcome() {
|
||||
let fails = corpus::verify(&committed());
|
||||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||||
}
|
||||
|
||||
/// Every reason this extension adds refuses a vector; the accepted vectors are the ones §10
|
||||
/// names; the large audience's relay record is the lesson's size, within the same bound; the
|
||||
/// mixed vector states one total per class; the ceilings vector names two grants past.
|
||||
#[test]
|
||||
fn the_corpus_covers_every_reason_and_case() {
|
||||
let doc: serde_json::Value = serde_json::from_str(&committed()).unwrap();
|
||||
let vectors = doc["vectors"].as_array().unwrap();
|
||||
let seen: BTreeSet<&str> = vectors.iter().filter_map(|v| v["reason"].as_str()).collect();
|
||||
for r in Reason::ALL {
|
||||
assert!(seen.contains(r.code()), "nothing in the corpus is refused {r}");
|
||||
}
|
||||
let get = |n: &str| vectors.iter().find(|v| v["name"] == n).unwrap_or_else(|| panic!("no vector {n}"));
|
||||
for n in ["lesson", "signed_by_authority", "mixed_classes", "ceilings_past", "large_audience", "relay_recorded"] {
|
||||
assert_eq!(get(n)["outcome"], "accept", "{n}");
|
||||
}
|
||||
let relay_record = |n: &str| {
|
||||
let v = get(n);
|
||||
let relay = &doc["principals"]["relay"];
|
||||
let i = v["hops"].as_array().unwrap().iter().position(|h| h["record"].as_str().unwrap()[74..138] == relay.as_str().unwrap()[..]).unwrap();
|
||||
let rec = v["hops"][i]["record"].as_str().unwrap().len() / 2;
|
||||
v["records"].as_array().unwrap().iter().find(|r| r["size"] == rec).unwrap().clone()
|
||||
};
|
||||
let (lesson, large) = (relay_record("lesson"), relay_record("large_audience"));
|
||||
assert_eq!(lesson["size"], large["size"], "the record does not grow with the audience");
|
||||
assert_eq!(lesson["bound"], large["bound"]);
|
||||
assert!(large["size"].as_u64() <= large["bound"].as_u64());
|
||||
assert!(get("large_audience")["units"]["sessions"].as_u64().unwrap() > 10_000);
|
||||
assert_eq!(get("large_audience")["qoe_cell"]["suppressed"], false);
|
||||
assert_eq!(get("lesson")["qoe_cell"]["suppressed"], true);
|
||||
assert_eq!(get("lesson")["coverage"], "whole");
|
||||
assert_eq!(get("mixed_classes")["totals"].as_array().unwrap().len(), 3);
|
||||
assert_eq!(get("relay_recorded")["coverage"], "partial");
|
||||
let past = get("ceilings_past")["past"].as_array().unwrap();
|
||||
assert_eq!(past.iter().map(|p| p["flags"].as_u64().unwrap()).collect::<BTreeSet<_>>(), BTreeSet::from([1, 2]));
|
||||
}
|
||||
|
||||
/// §9 of the extension lists exactly the codes `Reason::ALL` has, each once, in order.
|
||||
#[test]
|
||||
fn the_extension_lists_the_reasons() {
|
||||
let text = read("extensions/live-metering.md");
|
||||
let at = text.find("\n## 9. Reasons").expect("a §9");
|
||||
let end = text[at + 1..].find("\n## ").map_or(text.len(), |e| at + 1 + e);
|
||||
let listed: Vec<String> = text[at..end].lines().filter(|l| l.starts_with("| `")).filter_map(|l| l.split('`').nth(1).map(String::from)).collect();
|
||||
let want: Vec<String> = Reason::ALL.iter().map(|r| r.code().to_string()).collect();
|
||||
assert_eq!(listed, want, "§9's table and Reason::ALL differ");
|
||||
}
|
||||
Loading…
Reference in a new issue