#463's head carries #452's original history, and with it #438's first research note (docs/research/wai-cross-fabric.md), which names fleet hosts and addresses; #465 replaces that history with one squash so the note never enters main's. So #463 is not merged by its head: its own commits are replayed with cherry-pick -x onto the train's #465 merge (767d2aa7a), the second parent here: - #452's platform-decode commits, which the review's split gave to EN-C and #465 left out:6ad92e2e8,8695bc75f,fed8694f4; - EN-C's fourteen:df5ea962f11b255682ff069acc2244a715b32a8d45aa0a203eae239b6e4bada1ffceb5086c03ba72cce38948da1ca593124507f4cd9ec9533773913964c944. #463's merges of main and of #452 are not replayed; their conflict resolutions (SPEC §7.1 step 2, the CHANGELOG) are reached by the later commits. Checked: the replay's tree equals a three-way merge of #463's head onto the replay's platform-decode commits with #452's head merged into main1a465f23as the base, file for file, except the two files that merge conflicts on (int_kernels_vectors.rs: #465's wgsl line and EN-C's platform-decode line, both kept), and its CHANGELOG is #465's with #463's energy-honesty entry exactly. No address of the fleet appears in the replayed commits. Merging the replay here: CHANGELOG only, #463's entry on top. The registry export is up to date after the merge. Co-Authored-By: Claude Opus 5.5 <[email protected]>
897 lines
36 KiB
Rust
897 lines
36 KiB
Rust
//! Browser sink ([extensions/browser-sink.md]): how a sink that runs in a web page routes an
|
||
//! object to a decoder, evaluates constraint sets, reports energy beside the user agent's
|
||
//! `powerEfficient`, and binds every frame it decodes to the receipted object it came from.
|
||
//!
|
||
//! What this module implements, by section of the extension:
|
||
//!
|
||
//! - §3: the capability report, read whole or refused ([`route_report`]);
|
||
//! - §4: the user agent's decoders ([`UA_ROWS`]), the WebCodecs Codec Registry's rows and the
|
||
//! gate they make ([`CODEC_REGISTRY`], [`registry_row`]), the steps that route an object, and
|
||
//! the routing table;
|
||
//! - §5: constraint sets and their evaluation over capability-constraints §3.1's register
|
||
//! ([`evaluate`], [`REGISTER`]);
|
||
//! - §4.7: the choice among alternatives, in SPEC §4's energy order ([`choose_report`]);
|
||
//! - §6: the energy entry beside `powerEfficient`;
|
||
//! - §7: the content binding, its canonical form and its reading ([`Binding`]), and the tier a
|
||
//! frame holds ([`frame_tier`]);
|
||
//! - §9.2: the side table a page carries bindings in, as a model ([`SideTable`]) the page's own
|
||
//! JavaScript is held to by the corpus;
|
||
//! - §10: the state of the frame on screen ([`frame_state`]).
|
||
//!
|
||
//! Everything here is a pure function of its inputs, with nothing beyond `serde_json` and
|
||
//! BLAKE3, so it builds for wasm32 and the page runs the same code (`wai/wai-live-web`).
|
||
//!
|
||
//! The corpus `live-conformance/browser/` is written by [`corpus::generate`] and checked by
|
||
//! [`corpus::verify`], and by `live-conformance/browser_sink_verify.py`, which shares no code
|
||
//! with this module.
|
||
//!
|
||
//! [extensions/browser-sink.md]: ../../extensions/browser-sink.md
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde_json::{json, Map, Value};
|
||
|
||
use crate::codecs::{self, registry, ReplicateClass};
|
||
|
||
// The corpus generator holds the int_motion payload it wraps; a page never needs it.
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
pub mod corpus;
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|
||
|
||
/// §3: the report's version.
|
||
pub(crate) const REPORT_VERSION: u64 = 1;
|
||
/// §7.1: the algorithm this revision defines, jwp-receipts §2.1's content hash.
|
||
pub const ALG_JWP_OBJECT: &str = "jwp-object-1";
|
||
/// jwp-receipts §3 step 3: the domain of a group receipt's link hash.
|
||
pub const DOMAIN_RECEIPT_LINK: &[u8] = b"moq-jwp:receipt-link\x01";
|
||
/// The largest magnitude §5 reads as an integer, and §6's bound on a count.
|
||
pub(crate) const MAX_SAFE: i64 = (1 << 53) - 1;
|
||
/// §9.2 step 1: the entries a page holds pending per decoder by default.
|
||
pub(crate) const PENDING_BOUND: usize = 64;
|
||
|
||
// ─────────────────────────────── tiers ───────────────────────────────
|
||
|
||
/// §2: a tier, ordered `none` < `entropy-consistency` < `decode-equivalence`.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||
pub enum Tier {
|
||
None,
|
||
EntropyConsistency,
|
||
DecodeEquivalence,
|
||
}
|
||
|
||
impl Tier {
|
||
pub const fn token(self) -> &'static str {
|
||
match self {
|
||
Tier::None => "none",
|
||
Tier::EntropyConsistency => "entropy-consistency",
|
||
Tier::DecodeEquivalence => "decode-equivalence",
|
||
}
|
||
}
|
||
|
||
/// A token read as determinism-tiers §3 reads a pin's: one outside the vocabulary is `none`.
|
||
pub fn read(s: &str) -> Tier {
|
||
match s {
|
||
"decode-equivalence" => Tier::DecodeEquivalence,
|
||
"entropy-consistency" => Tier::EntropyConsistency,
|
||
_ => Tier::None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// §2: the registered tier of a capability, from its replicate class (SPEC §7).
|
||
pub fn registered_tier(capability: &str) -> Tier {
|
||
match codecs::capability_replicate_class(capability) {
|
||
Some(ReplicateClass::StandardDefined | ReplicateClass::NeuralIntegerExact) => Tier::DecodeEquivalence,
|
||
Some(ReplicateClass::NeuralEntropyExact) => Tier::EntropyConsistency,
|
||
_ => Tier::None,
|
||
}
|
||
}
|
||
|
||
/// SPEC §4's *dispatchable*, the predicate §4.3 step 1 applies.
|
||
pub fn dispatchable(capability: &str) -> bool {
|
||
registry::is_dispatchable(capability)
|
||
}
|
||
|
||
// ─────────────────────────────── reasons ───────────────────────────────
|
||
|
||
/// §11: the codes this extension adds.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||
pub enum Reason {
|
||
ReportMalformed,
|
||
NotDispatchable,
|
||
TierExceedsClass,
|
||
ConstraintsUnevaluable,
|
||
ConstraintsUnmet,
|
||
NoUaMapping,
|
||
CodecMismatch,
|
||
CodecUnregistered,
|
||
UaNotAsked,
|
||
UaUnsupported,
|
||
UaInexact,
|
||
UaUnprobed,
|
||
UaProbeMismatch,
|
||
NoHost,
|
||
HostInexact,
|
||
NoWasm,
|
||
NoDecoder,
|
||
NoAlternative,
|
||
ConstraintMalformed,
|
||
ParameterUnregistered,
|
||
EnergyClassInvalid,
|
||
UnmeteredAsZero,
|
||
EnergyCountInvalid,
|
||
EnergyEvidenceMissing,
|
||
BindingMalformed,
|
||
BindingAlgUnknown,
|
||
BindingUnverifiedReference,
|
||
BindingAmbiguous,
|
||
}
|
||
|
||
/// Every code, in §11's order. Private: a public array constant is one the C header
|
||
/// generator would try to export, and none of this module belongs in `include/wai.h`.
|
||
const ALL_REASONS: [Reason; 28] = [
|
||
Reason::ReportMalformed,
|
||
Reason::NotDispatchable,
|
||
Reason::TierExceedsClass,
|
||
Reason::ConstraintsUnevaluable,
|
||
Reason::ConstraintsUnmet,
|
||
Reason::NoUaMapping,
|
||
Reason::CodecMismatch,
|
||
Reason::CodecUnregistered,
|
||
Reason::UaNotAsked,
|
||
Reason::UaUnsupported,
|
||
Reason::UaInexact,
|
||
Reason::UaUnprobed,
|
||
Reason::UaProbeMismatch,
|
||
Reason::NoHost,
|
||
Reason::HostInexact,
|
||
Reason::NoWasm,
|
||
Reason::NoDecoder,
|
||
Reason::NoAlternative,
|
||
Reason::ConstraintMalformed,
|
||
Reason::ParameterUnregistered,
|
||
Reason::EnergyClassInvalid,
|
||
Reason::UnmeteredAsZero,
|
||
Reason::EnergyCountInvalid,
|
||
Reason::EnergyEvidenceMissing,
|
||
Reason::BindingMalformed,
|
||
Reason::BindingAlgUnknown,
|
||
Reason::BindingUnverifiedReference,
|
||
Reason::BindingAmbiguous,
|
||
];
|
||
|
||
impl Reason {
|
||
/// Every code, in §11's order.
|
||
pub fn all() -> &'static [Reason] {
|
||
&ALL_REASONS
|
||
}
|
||
|
||
pub const fn code(self) -> &'static str {
|
||
match self {
|
||
Reason::ReportMalformed => "report_malformed",
|
||
Reason::NotDispatchable => "not_dispatchable",
|
||
Reason::TierExceedsClass => "tier_exceeds_class",
|
||
Reason::ConstraintsUnevaluable => "constraints_unevaluable",
|
||
Reason::ConstraintsUnmet => "constraints_unmet",
|
||
Reason::NoUaMapping => "no_ua_mapping",
|
||
Reason::CodecMismatch => "codec_mismatch",
|
||
Reason::CodecUnregistered => "codec_unregistered",
|
||
Reason::UaNotAsked => "ua_not_asked",
|
||
Reason::UaUnsupported => "ua_unsupported",
|
||
Reason::UaInexact => "ua_inexact",
|
||
Reason::UaUnprobed => "ua_unprobed",
|
||
Reason::UaProbeMismatch => "ua_probe_mismatch",
|
||
Reason::NoHost => "no_host",
|
||
Reason::HostInexact => "host_inexact",
|
||
Reason::NoWasm => "no_wasm",
|
||
Reason::NoDecoder => "no_decoder",
|
||
Reason::NoAlternative => "no_alternative",
|
||
Reason::ConstraintMalformed => "constraint_malformed",
|
||
Reason::ParameterUnregistered => "parameter_unregistered",
|
||
Reason::EnergyClassInvalid => "energy_class_invalid",
|
||
Reason::UnmeteredAsZero => "unmetered_as_zero",
|
||
Reason::EnergyCountInvalid => "energy_count_invalid",
|
||
Reason::EnergyEvidenceMissing => "energy_evidence_missing",
|
||
Reason::BindingMalformed => "binding_malformed",
|
||
Reason::BindingAlgUnknown => "binding_alg_unknown",
|
||
Reason::BindingUnverifiedReference => "binding_unverified_reference",
|
||
Reason::BindingAmbiguous => "binding_ambiguous",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for Reason {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(self.code())
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── §4.1, §4.2: the tables ───────────────────────────
|
||
|
||
/// §4.1: one row of the user agent's decoders.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct UaRow {
|
||
pub capabilities: &'static [&'static str],
|
||
pub decoder: &'static str,
|
||
/// The codec string's forms: one ending `.` is that prefix and at least one more
|
||
/// character; any other is the whole string.
|
||
pub forms: &'static [&'static str],
|
||
pub tier: Tier,
|
||
}
|
||
|
||
/// §4.1's table.
|
||
pub const UA_ROWS: [UaRow; 8] = [
|
||
UaRow { capabilities: &["wai.video.vp9"], decoder: "VideoDecoder", forms: &["vp09."], tier: Tier::DecodeEquivalence },
|
||
UaRow { capabilities: &["wai.video.avc"], decoder: "VideoDecoder", forms: &["avc1.", "avc3."], tier: Tier::DecodeEquivalence },
|
||
UaRow { capabilities: &["wai.video.hevc"], decoder: "VideoDecoder", forms: &["hev1.", "hvc1."], tier: Tier::DecodeEquivalence },
|
||
UaRow { capabilities: &["wai.video.av1", "wai.video.av1.lossless"], decoder: "VideoDecoder", forms: &["av01."], tier: Tier::None },
|
||
UaRow { capabilities: &["wai.video.vp8"], decoder: "VideoDecoder", forms: &["vp8"], tier: Tier::None },
|
||
UaRow { capabilities: &["wai.audio.opus", "wai.audio.opus.packet"], decoder: "AudioDecoder", forms: &["opus"], tier: Tier::None },
|
||
UaRow { capabilities: &["wai.audio.flac"], decoder: "AudioDecoder", forms: &["flac"], tier: Tier::None },
|
||
UaRow { capabilities: &["wai.audio.aac"], decoder: "AudioDecoder", forms: &["mp4a."], tier: Tier::None },
|
||
];
|
||
|
||
/// §4.2: the WebCodecs Codec Registry's rows (W3C Registry Draft, 24 September 2026).
|
||
pub const CODEC_REGISTRY: [(&str, &str); 15] = [
|
||
("video", "av01.*"),
|
||
("video", "avc1.*"),
|
||
("video", "avc3.*"),
|
||
("video", "hev1.*"),
|
||
("video", "hvc1.*"),
|
||
("video", "vp8"),
|
||
("video", "vp09.*"),
|
||
("audio", "flac"),
|
||
("audio", "mp3"),
|
||
("audio", "mp4a.*"),
|
||
("audio", "opus"),
|
||
("audio", "vorbis"),
|
||
("audio", "ulaw"),
|
||
("audio", "alaw"),
|
||
("audio", "pcm-*"),
|
||
];
|
||
|
||
/// §4.2: whether `codec` matches `row`.
|
||
pub fn row_matches(row: &str, codec: &str) -> bool {
|
||
match row.strip_suffix('*') {
|
||
Some(prefix) => codec.len() > prefix.len() && codec.starts_with(prefix),
|
||
None => codec == row,
|
||
}
|
||
}
|
||
|
||
/// §4.2: the registry row a codec string matches, if any.
|
||
pub fn registry_row(codec: &str) -> Option<&'static str> {
|
||
CODEC_REGISTRY.iter().map(|&(_, r)| r).find(|r| row_matches(r, codec))
|
||
}
|
||
|
||
/// §4.1: the row of a capability.
|
||
pub fn ua_row(capability: &str) -> Option<&'static UaRow> {
|
||
UA_ROWS.iter().find(|r| r.capabilities.contains(&capability))
|
||
}
|
||
|
||
fn form_matches(form: &str, codec: &str) -> bool {
|
||
if form.ends_with('.') {
|
||
codec.len() > form.len() && codec.starts_with(form)
|
||
} else {
|
||
codec == form
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────── §5: constraints ───────────────────────────────
|
||
|
||
/// capability-constraints §3.1: the register this revision holds.
|
||
pub const REGISTER: [&str; 10] = [
|
||
"wai:codec",
|
||
"wai:frame_width",
|
||
"wai:frame_height",
|
||
"wai:frame_rate_milli",
|
||
"wai:bit_depth",
|
||
"wai:chroma",
|
||
"wai:sample_rate",
|
||
"wai:channel_count",
|
||
"wai:bitrate",
|
||
"wai:transfer",
|
||
];
|
||
|
||
/// §5: what evaluating a capability's sets gives.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Constraints {
|
||
Unconstrained,
|
||
Satisfied,
|
||
Unsatisfied,
|
||
/// Step 2 or 3, with its code.
|
||
Unevaluable(Reason),
|
||
}
|
||
|
||
impl Constraints {
|
||
pub fn outcome(self) -> &'static str {
|
||
match self {
|
||
Constraints::Unconstrained => "unconstrained",
|
||
Constraints::Satisfied => "satisfied",
|
||
Constraints::Unsatisfied => "unsatisfied",
|
||
Constraints::Unevaluable(_) => "unevaluable",
|
||
}
|
||
}
|
||
|
||
pub fn reason(self) -> Option<Reason> {
|
||
match self {
|
||
Constraints::Unevaluable(r) => Some(r),
|
||
Constraints::Unsatisfied => Some(Reason::ConstraintsUnmet),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// §5: an integer as this text reads one: a JSON number with no fraction and no exponent,
|
||
/// within ±(2^53 − 1). serde_json reads a number with a fraction or an exponent as a float.
|
||
fn integer(v: &Value) -> Option<i64> {
|
||
let i = v.as_number()?.as_i64()?;
|
||
(-MAX_SAFE..=MAX_SAFE).contains(&i).then_some(i)
|
||
}
|
||
|
||
fn is_value(v: &Value) -> bool {
|
||
v.is_string() || integer(v).is_some()
|
||
}
|
||
|
||
fn constraint_well_formed(c: &Value) -> bool {
|
||
let Some(o) = c.as_object() else { return false };
|
||
if o.is_empty() || o.keys().any(|k| !matches!(k.as_str(), "enum" | "minimum" | "maximum")) {
|
||
return false;
|
||
}
|
||
if let Some(e) = o.get("enum") {
|
||
match e.as_array() {
|
||
Some(a) if !a.is_empty() && a.iter().all(is_value) => {}
|
||
_ => return false,
|
||
}
|
||
}
|
||
let lo = match o.get("minimum") {
|
||
Some(v) => match integer(v) {
|
||
Some(i) => Some(i),
|
||
None => return false,
|
||
},
|
||
None => None,
|
||
};
|
||
let hi = match o.get("maximum") {
|
||
Some(v) => match integer(v) {
|
||
Some(i) => Some(i),
|
||
None => return false,
|
||
},
|
||
None => None,
|
||
};
|
||
!matches!((lo, hi), (Some(a), Some(b)) if a > b)
|
||
}
|
||
|
||
fn meets(c: &Map<String, Value>, value: Option<&Value>) -> bool {
|
||
let Some(v) = value.filter(|v| is_value(v)) else { return false };
|
||
if let Some(e) = c.get("enum").and_then(Value::as_array) {
|
||
let hit = e.iter().any(|x| match (x.as_str(), v.as_str()) {
|
||
(Some(a), Some(b)) => a == b,
|
||
(None, None) => integer(x) == integer(v),
|
||
_ => false,
|
||
});
|
||
if !hit {
|
||
return false;
|
||
}
|
||
}
|
||
if let Some(lo) = c.get("minimum").and_then(integer)
|
||
&& integer(v).is_none_or(|i| i < lo)
|
||
{
|
||
return false;
|
||
}
|
||
if let Some(hi) = c.get("maximum").and_then(integer)
|
||
&& integer(v).is_none_or(|i| i > hi)
|
||
{
|
||
return false;
|
||
}
|
||
true
|
||
}
|
||
|
||
/// §5: `evaluate(sets, params)`.
|
||
pub fn evaluate(sets: Option<&Value>, params: &Value) -> Constraints {
|
||
let Some(sets) = sets else { return Constraints::Unconstrained };
|
||
let Some(arr) = sets.as_array().filter(|a| !a.is_empty()) else {
|
||
return Constraints::Unevaluable(Reason::ConstraintMalformed);
|
||
};
|
||
for set in arr {
|
||
let Some(o) = set.as_object() else { return Constraints::Unevaluable(Reason::ConstraintMalformed) };
|
||
if !o.values().all(constraint_well_formed) {
|
||
return Constraints::Unevaluable(Reason::ConstraintMalformed);
|
||
}
|
||
}
|
||
for set in arr {
|
||
if set.as_object().is_some_and(|o| o.keys().any(|k| !REGISTER.contains(&k.as_str()))) {
|
||
return Constraints::Unevaluable(Reason::ParameterUnregistered);
|
||
}
|
||
}
|
||
let declared = params.as_object();
|
||
let satisfied = arr.iter().any(|set| {
|
||
set.as_object()
|
||
.is_some_and(|o| o.iter().all(|(k, c)| c.as_object().is_some_and(|c| meets(c, declared.and_then(|d| d.get(k))))))
|
||
});
|
||
if satisfied {
|
||
Constraints::Satisfied
|
||
} else {
|
||
Constraints::Unsatisfied
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────── §3: the report ───────────────────────────────
|
||
|
||
fn is_bool_or_null(v: Option<&Value>) -> bool {
|
||
matches!(v, Some(Value::Bool(_)) | Some(Value::Null))
|
||
}
|
||
|
||
/// §3: whether a report reads in its shape.
|
||
fn report_reads(r: &Value) -> bool {
|
||
let Some(o) = r.as_object() else { return false };
|
||
if o.get("browser_sink").and_then(Value::as_u64) != Some(REPORT_VERSION) || o.get("browser_sink").is_some_and(|v| !v.is_u64()) {
|
||
return false;
|
||
}
|
||
let Some(ua) = o.get("ua").and_then(Value::as_object) else { return false };
|
||
for a in ua.values() {
|
||
let Some(a) = a.as_object() else { return false };
|
||
if !a.get("supported").is_some_and(Value::is_boolean)
|
||
|| !is_bool_or_null(a.get("smooth"))
|
||
|| !is_bool_or_null(a.get("power_efficient"))
|
||
|| !matches!(a.get("probe").and_then(Value::as_str), Some("match" | "mismatch" | "not_run"))
|
||
{
|
||
return false;
|
||
}
|
||
}
|
||
if !o.get("host").and_then(Value::as_object).is_some_and(|h| h.values().all(Value::is_string)) {
|
||
return false;
|
||
}
|
||
if !o.get("wasm").and_then(Value::as_array).is_some_and(|w| w.iter().all(Value::is_string)) {
|
||
return false;
|
||
}
|
||
if !o.get("constraints").is_some_and(Value::is_object) {
|
||
return false;
|
||
}
|
||
if !o.get("energy").and_then(Value::as_object).is_some_and(|e| e.values().all(Value::is_object)) {
|
||
return false;
|
||
}
|
||
let Some(objects) = o.get("objects").and_then(Value::as_array) else { return false };
|
||
objects.iter().all(|x| {
|
||
x.as_object().is_some_and(|x| {
|
||
x.get("name").is_some_and(Value::is_string)
|
||
&& x.get("capability").is_some_and(Value::is_string)
|
||
&& x.get("codec").is_some_and(|c| c.is_string() || c.is_null())
|
||
&& x.get("need").is_some_and(Value::is_string)
|
||
&& x.get("params").is_some_and(Value::is_object)
|
||
&& x.get("choice").is_none_or(|c| c.as_str().is_some_and(|c| !c.is_empty()))
|
||
})
|
||
})
|
||
}
|
||
|
||
// ─────────────────────────────── §6: energy ───────────────────────────────
|
||
|
||
/// §6: a measurement read, or the code that refuses it: `energy_order::read_measurement`,
|
||
/// the one reader the order uses too.
|
||
fn read_measurement(m: &Value) -> Result<Value, Reason> {
|
||
use crate::energy_order::MeasurementRefusal as R;
|
||
crate::energy_order::read_measurement(m).map_err(|e| match e {
|
||
R::ClassInvalid => Reason::EnergyClassInvalid,
|
||
R::UnmeteredAsZero => Reason::UnmeteredAsZero,
|
||
R::CountInvalid => Reason::EnergyCountInvalid,
|
||
R::EvidenceMissing => Reason::EnergyEvidenceMissing,
|
||
})
|
||
}
|
||
|
||
fn energy_entry(report: &Map<String, Value>, capability: &str, codec: Option<&str>, route: &str) -> Value {
|
||
let power_efficient = if route == "ua" {
|
||
codec.and_then(|c| report["ua"].get(c)).and_then(|a| a.get("power_efficient")).cloned().unwrap_or(Value::Null)
|
||
} else {
|
||
Value::Null
|
||
};
|
||
let (figure, reason) = match (route != "none").then(|| report["energy"].get(format!("{capability} {route}"))).flatten() {
|
||
None => (Value::Null, Value::Null),
|
||
Some(m) => match read_measurement(m) {
|
||
Ok(f) => (f, Value::Null),
|
||
Err(r) => (Value::Null, json!(r.code())),
|
||
},
|
||
};
|
||
json!({ "power_efficient": power_efficient, "figure": figure, "reason": reason })
|
||
}
|
||
|
||
// ─────────────────────────────── §4.3: routing ───────────────────────────────
|
||
|
||
const NOT_REACHED: &str = "not_reached";
|
||
const TAKEN: &str = "taken";
|
||
|
||
/// §4.3 for one object of a report already read.
|
||
fn route_object(report: &Map<String, Value>, o: &Map<String, Value>) -> Value {
|
||
let name = o["name"].clone();
|
||
let capability = o["capability"].as_str().unwrap_or_default();
|
||
let codec = o["codec"].as_str();
|
||
let need = Tier::read(o["need"].as_str().unwrap_or_default());
|
||
let registered = registered_tier(capability);
|
||
let legs_none = || json!({ "ua": NOT_REACHED, "host": NOT_REACHED, "wasm": NOT_REACHED });
|
||
let decided = |reason: Reason| {
|
||
json!({
|
||
"name": name, "route": "none", "reason": reason.code(), "legs": legs_none(), "asked": false,
|
||
"tier": Tier::None.token(), "energy": energy_entry(report, capability, codec, "none"),
|
||
})
|
||
};
|
||
// Steps 1-3.
|
||
if !dispatchable(capability) {
|
||
return decided(Reason::NotDispatchable);
|
||
}
|
||
if need > registered {
|
||
return decided(Reason::TierExceedsClass);
|
||
}
|
||
match evaluate(report["constraints"].get(capability), &o["params"]) {
|
||
Constraints::Unevaluable(_) => return decided(Reason::ConstraintsUnevaluable),
|
||
Constraints::Unsatisfied => return decided(Reason::ConstraintsUnmet),
|
||
_ => {}
|
||
}
|
||
// Step 4: the UA leg.
|
||
let row = ua_row(capability);
|
||
let mut asked = false;
|
||
let ua: Result<Tier, Reason> = (|| {
|
||
let codec = codec.ok_or(Reason::NoUaMapping)?;
|
||
registry_row(codec).ok_or(Reason::CodecUnregistered)?;
|
||
let row = row.ok_or(Reason::NoUaMapping)?;
|
||
if !row.forms.iter().any(|f| form_matches(f, codec)) {
|
||
return Err(Reason::CodecMismatch);
|
||
}
|
||
asked = true;
|
||
let answer = report["ua"].get(codec).ok_or(Reason::UaNotAsked)?;
|
||
if answer["supported"] != Value::Bool(true) {
|
||
return Err(Reason::UaUnsupported);
|
||
}
|
||
if need > row.tier {
|
||
return Err(Reason::UaInexact);
|
||
}
|
||
let probe = answer["probe"].as_str().unwrap_or_default();
|
||
if need == Tier::DecodeEquivalence {
|
||
match probe {
|
||
"match" => {}
|
||
"mismatch" => return Err(Reason::UaProbeMismatch),
|
||
_ => return Err(Reason::UaUnprobed),
|
||
}
|
||
}
|
||
let tier = if probe == "match" { row.tier } else { Tier::None };
|
||
Ok(tier.min(registered))
|
||
})();
|
||
// Step 5: the host leg.
|
||
let host: Result<Tier, Reason> = match report["host"].get(capability).and_then(Value::as_str) {
|
||
None => Err(Reason::NoHost),
|
||
Some(t) if Tier::read(t) < need => Err(Reason::HostInexact),
|
||
Some(t) => Ok(Tier::read(t).min(registered)),
|
||
};
|
||
// Step 6: the wasm leg.
|
||
let in_wasm = report["wasm"].as_array().is_some_and(|w| w.iter().any(|c| c.as_str() == Some(capability)));
|
||
let wasm: Result<Tier, Reason> = if in_wasm { Ok(registered) } else { Err(Reason::NoWasm) };
|
||
// Step 7.
|
||
let leg = |r: &Result<Tier, Reason>| match r {
|
||
Ok(_) => TAKEN,
|
||
Err(e) => e.code(),
|
||
};
|
||
let (route, tier, legs) = match (&ua, &host, &wasm) {
|
||
(Ok(t), _, _) => ("ua", *t, json!({ "ua": TAKEN, "host": NOT_REACHED, "wasm": NOT_REACHED })),
|
||
(Err(_), Ok(t), _) => ("host", *t, json!({ "ua": leg(&ua), "host": TAKEN, "wasm": NOT_REACHED })),
|
||
(Err(_), Err(_), Ok(t)) => ("wasm", *t, json!({ "ua": leg(&ua), "host": leg(&host), "wasm": TAKEN })),
|
||
_ => ("none", Tier::None, json!({ "ua": leg(&ua), "host": leg(&host), "wasm": leg(&wasm) })),
|
||
};
|
||
json!({
|
||
"name": name,
|
||
"route": route,
|
||
"reason": if route == "none" { json!(Reason::NoDecoder.code()) } else { Value::Null },
|
||
"legs": legs,
|
||
"asked": asked,
|
||
"tier": tier.token(),
|
||
"energy": energy_entry(report, capability, codec, route),
|
||
})
|
||
}
|
||
|
||
/// §3 and §4: a report's routing table, one entry an object, in order; or the report refused
|
||
/// whole (`report_malformed`).
|
||
pub fn route_report(report: &Value) -> Result<Vec<Value>, Reason> {
|
||
if !report_reads(report) {
|
||
return Err(Reason::ReportMalformed);
|
||
}
|
||
let r = report.as_object().expect("read");
|
||
Ok(r["objects"].as_array().expect("read").iter().map(|o| route_object(r, o.as_object().expect("read"))).collect())
|
||
}
|
||
|
||
// ─────────────────────────────── §4.7: alternatives ───────────────────────────────
|
||
|
||
/// §4.7: the choice table of a report: for each `choice`, in order of first appearance,
|
||
/// its alternatives in SPEC §4's energy order ([`crate::energy_order`]), the rank of each,
|
||
/// and the one the page decodes; or the report refused whole (`report_malformed`). A report
|
||
/// whose objects carry no `choice` has an empty table.
|
||
///
|
||
/// The order sits inside the routing: it orders the alternatives by their routes, and
|
||
/// changes none. An alternative routed `ua` ranks by the user agent's answer for its codec
|
||
/// string (`supported`, `smooth`, `power_efficient`; a `null` is not a `true`); one routed
|
||
/// `host` or `wasm` has no answer that is about the decoder that runs it, and ranks
|
||
/// `classical`; a learned one ranks `classical` when the report holds a measurement of its
|
||
/// decode under its route's key that qualifies (§6; `energy_order::qualifies`: a hardware
|
||
/// class, coverage `Whole`, its value not read), and `learned-without-budget` otherwise;
|
||
/// one routed `none` comes last, with no rank. Report order within a rank. The chosen
|
||
/// alternative is the first in that order whose route is not `none`.
|
||
pub fn choose_report(report: &Value) -> Result<Vec<Value>, Reason> {
|
||
use crate::energy_order::{DispatchPolicy, PlatformAnswer, Rank};
|
||
let table = route_report(report)?;
|
||
let objects = report["objects"].as_array().expect("read");
|
||
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
|
||
for (i, o) in objects.iter().enumerate() {
|
||
let Some(c) = o.get("choice").and_then(Value::as_str) else { continue };
|
||
match groups.iter_mut().find(|(g, _)| g == c) {
|
||
Some((_, members)) => members.push(i),
|
||
None => groups.push((c.to_string(), vec![i])),
|
||
}
|
||
}
|
||
// The rank of one alternative: `None` for a route of `none`.
|
||
let rank = |i: usize| -> Option<Rank> {
|
||
let route = table[i]["route"].as_str().unwrap_or("none");
|
||
if route == "none" {
|
||
return None;
|
||
}
|
||
let capability = objects[i]["capability"].as_str().unwrap_or_default();
|
||
let answer = (route == "ua").then(|| {
|
||
let a = objects[i]["codec"].as_str().and_then(|c| report["ua"].get(c)).cloned().unwrap_or(Value::Null);
|
||
PlatformAnswer { supported: a["supported"] == Value::Bool(true), smooth: a["smooth"].as_bool(), power_efficient: a["power_efficient"].as_bool() }
|
||
});
|
||
let measured = report["energy"].get(format!("{capability} {route}")).cloned();
|
||
let platform = move |_: &str| answer;
|
||
let figure = move |_: &str| measured.clone();
|
||
Some(DispatchPolicy::energy().with_platform(&platform).with_measured(&figure).rank(capability))
|
||
};
|
||
Ok(groups
|
||
.into_iter()
|
||
.map(|(choice, members)| {
|
||
let mut ordered: Vec<(u8, usize, Option<Rank>)> = members
|
||
.iter()
|
||
.map(|&i| {
|
||
let r = rank(i);
|
||
(r.map_or(u8::MAX, |r| r as u8), i, r)
|
||
})
|
||
.collect();
|
||
ordered.sort_by_key(|&(k, i, _)| (k, i));
|
||
let chosen = ordered.iter().find(|(_, _, r)| r.is_some()).map(|&(_, i, _)| i);
|
||
json!({
|
||
"choice": choice,
|
||
"order": ordered.iter().map(|&(_, i, _)| objects[i]["name"].clone()).collect::<Vec<_>>(),
|
||
"ranks": ordered.iter().map(|(_, _, r)| r.map_or(Value::Null, |r| json!(r.as_str()))).collect::<Vec<_>>(),
|
||
"chosen": chosen.map(|i| objects[i]["name"].clone()).unwrap_or(Value::Null),
|
||
"reason": if chosen.is_some() { Value::Null } else { json!(Reason::NoAlternative.code()) },
|
||
})
|
||
})
|
||
.collect())
|
||
}
|
||
|
||
// ─────────────────────────────── §7: the binding ───────────────────────────────
|
||
|
||
/// jwp-receipts §3 step 3: a group receipt's link hash, over its canonical bytes.
|
||
pub fn link_hash(canonical: &[u8]) -> [u8; 32] {
|
||
let mut h = blake3::Hasher::new();
|
||
h.update(DOMAIN_RECEIPT_LINK);
|
||
h.update(canonical);
|
||
*h.finalize().as_bytes()
|
||
}
|
||
|
||
/// §7.1: a content binding.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Binding {
|
||
pub digest: [u8; 32],
|
||
pub receipt: Option<[u8; 32]>,
|
||
pub tier: Tier,
|
||
}
|
||
|
||
fn hex32(s: &str) -> Option<[u8; 32]> {
|
||
if s.len() != 64 || !s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) {
|
||
return None;
|
||
}
|
||
let mut out = [0u8; 32];
|
||
for (i, o) in out.iter_mut().enumerate() {
|
||
*o = u8::from_str_radix(&s[2 * i..2 * i + 2], 16).ok()?;
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
fn hex(b: &[u8]) -> String {
|
||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||
}
|
||
|
||
impl Binding {
|
||
/// §7.2: the binding of a frame decoded from `object`, the receipted object's bytes as
|
||
/// received, verified (or not) under the group receipt whose link hash is `receipt`.
|
||
pub fn of_object(object: &[u8], receipt: Option<[u8; 32]>, tier: Tier) -> Binding {
|
||
Binding { digest: crate::jwp_hash::content_hash(object), receipt, tier }
|
||
}
|
||
|
||
/// §7.1: the value, its members in order.
|
||
pub fn to_json(&self) -> Value {
|
||
let mut m = Map::new();
|
||
m.insert("alg".into(), json!(ALG_JWP_OBJECT));
|
||
m.insert("digest".into(), json!(hex(&self.digest)));
|
||
if let Some(r) = self.receipt {
|
||
m.insert("receipt".into(), json!(hex(&r)));
|
||
}
|
||
m.insert("tier".into(), json!(self.tier.token()));
|
||
Value::Object(m)
|
||
}
|
||
|
||
/// §7.1: the canonical form.
|
||
pub fn canonical(&self) -> String {
|
||
serde_json::to_string(&self.to_json()).expect("JSON")
|
||
}
|
||
|
||
/// §7.1: a value read, or the code that refuses it.
|
||
pub fn read(v: &Value) -> Result<Binding, Reason> {
|
||
let o = v.as_object().ok_or(Reason::BindingMalformed)?;
|
||
let s = |k: &str| o.get(k).and_then(Value::as_str);
|
||
let (Some(alg), Some(digest), Some(tier)) = (s("alg"), s("digest"), s("tier")) else {
|
||
return Err(Reason::BindingMalformed);
|
||
};
|
||
let digest = hex32(digest).ok_or(Reason::BindingMalformed)?;
|
||
let receipt = match o.get("receipt") {
|
||
None => None,
|
||
Some(r) => Some(r.as_str().and_then(hex32).ok_or(Reason::BindingMalformed)?),
|
||
};
|
||
if alg != ALG_JWP_OBJECT {
|
||
return Err(Reason::BindingAlgUnknown);
|
||
}
|
||
Ok(Binding { digest, receipt, tier: Tier::read(tier) })
|
||
}
|
||
}
|
||
|
||
/// §7.2: the tier a decoded frame's samples hold: the route's tier, lowered to `none` for a
|
||
/// UA decoder's frame whose `format` is null, and for a frame the page altered.
|
||
pub fn frame_tier(route: &str, route_tier: Tier, format_null: bool, altered: bool) -> Tier {
|
||
if altered || (route == "ua" && format_null) {
|
||
Tier::None
|
||
} else {
|
||
route_tier
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────── §10: the frame on screen ───────────────────────────────
|
||
|
||
/// §10: the state of the frame a tile last drew. `binding` is the value the page holds, or
|
||
/// none; the three verdicts are §10's tokens. A token outside them is an input error, not a
|
||
/// state.
|
||
pub fn frame_state(binding: Option<&Value>, object: &str, chain: &str, provenance: &str) -> Result<Value, String> {
|
||
if !matches!(object, "verified" | "refused" | "unverified") {
|
||
return Err(format!("object verdict {object:?}"));
|
||
}
|
||
if !matches!(chain, "linked" | "anchor" | "history" | "held" | "refused") {
|
||
return Err(format!("chain verdict {chain:?}"));
|
||
}
|
||
if !matches!(provenance, "accepted" | "refused" | "missing" | "not_applicable") {
|
||
return Err(format!("provenance verdict {provenance:?}"));
|
||
}
|
||
let read = binding.map(Binding::read);
|
||
let (state, reason, b) = match read {
|
||
None => ("unbound", Value::Null, None),
|
||
Some(Err(r)) => ("unbound", json!(r.code()), None),
|
||
Some(Ok(b)) => {
|
||
let s = if object == "refused" || chain == "refused" || provenance == "refused" {
|
||
"refused"
|
||
} else if object == "unverified" || chain == "held" || provenance == "missing" {
|
||
"pending"
|
||
} else {
|
||
"verified"
|
||
};
|
||
(s, Value::Null, Some(b))
|
||
}
|
||
};
|
||
Ok(json!({
|
||
"state": state,
|
||
"reason": reason,
|
||
"tier": b.as_ref().map(|b| json!(b.tier.token())).unwrap_or(Value::Null),
|
||
"digest": b.as_ref().map(|b| json!(hex(&b.digest))).unwrap_or(Value::Null),
|
||
"receipt": b.as_ref().and_then(|b| b.receipt).map(|r| json!(hex(&r))).unwrap_or(Value::Null),
|
||
}))
|
||
}
|
||
|
||
// ─────────────────────────────── §9.2: the side table ───────────────────────────────
|
||
|
||
#[derive(Clone, Debug)]
|
||
enum Pending {
|
||
Entry(Option<Binding>),
|
||
Struck,
|
||
}
|
||
|
||
/// §9.2 as a model: decoders and frames named by strings, the operations a page performs in
|
||
/// order. The page's JavaScript keeps the same table over live objects; the corpus holds both
|
||
/// to the same sequences.
|
||
#[derive(Clone, Debug)]
|
||
pub struct SideTable {
|
||
bound: usize,
|
||
/// Per decoder, its pending entries by timestamp, in the order they were entered.
|
||
pending: BTreeMap<String, Vec<(i64, Pending)>>,
|
||
frames: BTreeMap<String, Option<Binding>>,
|
||
refusals: Vec<(usize, Reason)>,
|
||
struck: u64,
|
||
}
|
||
|
||
impl SideTable {
|
||
pub fn new(bound: usize) -> SideTable {
|
||
SideTable { bound, pending: BTreeMap::new(), frames: BTreeMap::new(), refusals: Vec::new(), struck: 0 }
|
||
}
|
||
|
||
/// One operation of a sequence (the corpus's form). `at` is its index, for a refusal.
|
||
pub fn apply(&mut self, at: usize, op: &Value) -> Result<(), String> {
|
||
let s = |k: &str| op.get(k).and_then(Value::as_str).map(str::to_owned).ok_or_else(|| format!("op {at}: no {k}"));
|
||
let ts = || op.get("timestamp").and_then(Value::as_i64).ok_or_else(|| format!("op {at}: no timestamp"));
|
||
match op.get("op").and_then(Value::as_str) {
|
||
Some("chunk") => {
|
||
let decoder = s("decoder")?;
|
||
let b = match op.get("binding") {
|
||
None | Some(Value::Null) => None,
|
||
Some(v) => Some(Binding::read(v).map_err(|r| format!("op {at}: binding {r}"))?),
|
||
};
|
||
let t = ts()?;
|
||
let q = self.pending.entry(decoder).or_default();
|
||
if let Some(e) = q.iter_mut().find(|(x, _)| *x == t) {
|
||
if matches!(e.1, Pending::Entry(_)) {
|
||
self.refusals.push((at, Reason::BindingAmbiguous));
|
||
}
|
||
e.1 = Pending::Struck;
|
||
} else {
|
||
q.push((t, Pending::Entry(b)));
|
||
if q.len() > self.bound {
|
||
q.remove(0);
|
||
self.struck += 1;
|
||
}
|
||
}
|
||
}
|
||
Some("output") => {
|
||
let (decoder, frame, t) = (s("decoder")?, s("frame")?, ts()?);
|
||
let q = self.pending.entry(decoder).or_default();
|
||
let b = match q.iter().position(|(x, _)| *x == t) {
|
||
Some(i) => match q.remove(i).1 {
|
||
Pending::Entry(b) => b,
|
||
Pending::Struck => None,
|
||
},
|
||
None => None,
|
||
};
|
||
self.frames.insert(frame, b);
|
||
}
|
||
Some("clone") => {
|
||
let (from, frame) = (s("from")?, s("frame")?);
|
||
let b = self.frames.get(&from).ok_or_else(|| format!("op {at}: clone of a frame not open"))?.clone();
|
||
self.frames.insert(frame, b);
|
||
}
|
||
Some("transfer") => {
|
||
let (from, frame) = (s("from")?, s("frame")?);
|
||
let b = self.frames.remove(&from).ok_or_else(|| format!("op {at}: transfer of a frame not open"))?;
|
||
let with = op.get("with_binding").and_then(Value::as_bool).unwrap_or(false);
|
||
self.frames.insert(frame, if with { b } else { None });
|
||
}
|
||
Some("close") => {
|
||
self.frames.remove(&s("frame")?);
|
||
}
|
||
Some("reset" | "flush") => {
|
||
let n = self.pending.remove(&s("decoder")?).map_or(0, |q| q.len());
|
||
self.struck += n as u64;
|
||
}
|
||
other => return Err(format!("op {at}: {other:?}")),
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// The open frames and the binding each holds, in canonical form or null; the refusals,
|
||
/// and the count of entries struck by a bound, a reset or a flush.
|
||
pub fn result(&self) -> Value {
|
||
let frames: Map<String, Value> =
|
||
self.frames.iter().map(|(k, b)| (k.clone(), b.as_ref().map(|b| json!(b.canonical())).unwrap_or(Value::Null))).collect();
|
||
json!({
|
||
"frames": frames,
|
||
"refusals": self.refusals.iter().map(|(at, r)| json!({ "at": at, "reason": r.code() })).collect::<Vec<_>>(),
|
||
"struck": self.struck,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Run one sequence through a fresh table.
|
||
pub fn run_table(bound: usize, ops: &[Value]) -> Result<Value, String> {
|
||
let mut t = SideTable::new(bound);
|
||
for (i, op) in ops.iter().enumerate() {
|
||
t.apply(i, op)?;
|
||
}
|
||
Ok(t.result())
|
||
}
|