Skip to main content

pagesift_plan/
render.rs

1//! 渲染决策的学习表:每个路径族"渲染有没有增益"的 Beta 计数、族的键,以及候选合并时用的质量分。
2//! 引擎(`pagesift` 的 `engine/planner.rs`)在请求路径上调用它。
3//!
4//! **要不要开浏览器**:每个路径族记一个"渲染带来质量增益"的 Beta 计数。
5//! - 族里还没有数据时照旧按启发式判断(引擎的启发式):先验包 v0 就是 0.81 的规则,冷启动行为不变;
6//! - 有数据后按 Thompson 采样(Russo 等, FnT ML 2018, §3):抽 `p ~ Beta(1 + 增益次数, 1 + 无增益次数)`,
7//!   渲染 ⇔ `p · (1 − Q_http) > E[渲染秒数] · (λ_browser + v_t)`。`Q_http` 是 HTTP 结果的校准质量分,
8//!   `1 − Q_http` 是最多还能补回的质量;`v_t = 1 / 剩余期限` 是时间价值(耗尽整个期限记一个质量单位),
9//!   `λ_browser` 是浏览器的影子价格(`pagesift_stat::decide::shadow_price`,空闲时为 0)。
10//! - "有增益"= 渲染(或合并)结果的字比 HTTP 的多(0.81 的规则),或者质量分更高:质量分只能多算增益,
11//!   不能否决 0.81 会算的增益(它不看篇幅)。
12//!
13//! 先验取 `Beta(1,1)`,不向站点、全局收缩(这里没有用 `pagesift_stat::shrink` 的层级收缩)。
14//!
15//! 计数表按族哈希分片,分片数随核数增长:大机器上不让所有请求抢同一把锁。
16//! 族的键是族名的 BLAKE3(跨进程稳定),整张表可以存进存储、重启后读回([`RenderPlanner::encode`])。
17
18use std::collections::HashMap;
19use std::sync::Mutex;
20
21use pagesift_stat::beta::{BetaCounts, Prior};
22use pagesift_stat::rng::Rng;
23use pagesift_stat::{Reader, Stat, Writer};
24
25/// [协议] 表的编码版本:键或计数的含义一变就加一,旧表整体丢弃
26const TABLE_VERSION: u32 = 1;
27
28/// 一次渲染决策的依据(写进 `metadata.pagesift.plan`)。
29#[derive(Debug, Clone, Copy, PartialEq)]
30pub struct RenderWhy {
31    /// 是否来自学到的后验(否则是冷启动的启发式)。
32    pub learned: bool,
33    pub p: f64,
34    pub value: f64,
35    pub charge: f64,
36}
37
38/// 一次观测的去处(多服务器工人:攒成统计增量报给网关,见 `crate::cluster`):(族的键,有没有增益)。
39pub type PlanObserver = std::sync::Arc<dyn Fn(u64, bool) + Send + Sync>;
40
41pub struct RenderPlanner {
42    shards: Box<[Mutex<HashMap<u64, BetaCounts>>]>,
43    observer: std::sync::OnceLock<PlanObserver>,
44}
45
46impl Default for RenderPlanner {
47    fn default() -> Self {
48        // 分片数取不小于核数的 2 的幂:锁冲突概率随分片数下降,表本身很小
49        let n = std::thread::available_parallelism()
50            .map_or(1, |n| n.get())
51            .next_power_of_two();
52        Self {
53            shards: (0..n).map(|_| Mutex::new(HashMap::new())).collect(),
54            observer: std::sync::OnceLock::new(),
55        }
56    }
57}
58
59thread_local! {
60    static RNG: std::cell::RefCell<Rng> = std::cell::RefCell::new(Rng::from_entropy());
61}
62
63impl RenderPlanner {
64    fn with<R>(&self, family: &str, f: impl FnOnce(&mut HashMap<u64, BetaCounts>, u64) -> R) -> R {
65        let key = family_key(family);
66        let shard = &self.shards[(key as usize) & (self.shards.len() - 1)];
67        f(&mut shard.lock().unwrap_or_else(|e| e.into_inner()), key)
68    }
69
70    /// 是否渲染。`heuristic` 是 0.81 规则的结论;`render_secs` 是预计渲染耗时(秒);
71    /// `remaining_secs` 是这个请求剩下的期限;`browser_price` 是浏览器的影子价格(每标签秒)。
72    pub fn decide(
73        &self,
74        family: &str,
75        heuristic: bool,
76        http_quality: f64,
77        render_secs: f64,
78        remaining_secs: f64,
79        browser_price: f64,
80    ) -> (bool, RenderWhy) {
81        let counts = self.with(family, |m, k| m.get(&k).copied());
82        let value = 1.0 - http_quality.clamp(0.0, 1.0);
83        let charge = render_secs * (browser_price + 1.0 / remaining_secs.max(f64::MIN_POSITIVE));
84        match counts.filter(|c| c.trials() > 0.0) {
85            None => (
86                heuristic,
87                RenderWhy {
88                    learned: false,
89                    p: f64::from(u8::from(heuristic)),
90                    value,
91                    charge,
92                },
93            ),
94            Some(c) => {
95                let p = RNG.with(|r| c.posterior(Prior::UNIFORM).sample(&mut r.borrow_mut()));
96                (
97                    p * value > charge,
98                    RenderWhy {
99                        learned: true,
100                        p,
101                        value,
102                        charge,
103                    },
104                )
105            }
106        }
107    }
108
109    /// 记一次渲染的结果:渲染(或合并)后的质量分是否高于 HTTP 的。
110    pub fn record(&self, family: &str, gain: bool) {
111        let key = self.with(family, |m, k| {
112            m.entry(k).or_default().observe(gain);
113            k
114        });
115        if let Some(o) = self.observer.get() {
116            o(key, gain);
117        }
118    }
119
120    /// 装上观测的去处(只能装一次)。
121    pub fn observe_with(&self, o: PlanObserver) -> bool {
122        self.observer.set(o).is_ok()
123    }
124
125    /// 导入别处学到的计数(网关下发的先验)。本地已经有这个族的观测时不导入:本地的观测已经报给过网关,
126    /// 再加上网关的合并结果会重复计数。
127    pub fn import(&self, key: u64, counts: BetaCounts) {
128        let shard = &self.shards[(key as usize) & (self.shards.len() - 1)];
129        let mut m = shard.lock().unwrap_or_else(|e| e.into_inner());
130        let e = m.entry(key).or_default();
131        if e.trials() == 0.0 {
132            *e = counts;
133        }
134    }
135
136    /// 族里"渲染有增益"的后验均值(`Beta(1,1)` 先验);族里还没有数据时为 `None`。
137    pub fn mean(&self, family: &str) -> Option<f64> {
138        self.with(family, |m, k| m.get(&k).copied())
139            .filter(|c| c.trials() > 0.0)
140            .map(|c| c.posterior(Prior::UNIFORM).mean())
141    }
142
143    /// 整张表编码成字节(引擎把它写进存储)。
144    pub fn encode(&self) -> Vec<u8> {
145        let mut w = Writer::new();
146        w.u32(TABLE_VERSION);
147        let entries: Vec<(u64, BetaCounts)> = self
148            .shards
149            .iter()
150            .flat_map(|s| {
151                let m = s.lock().unwrap_or_else(|e| e.into_inner());
152                m.iter().map(|(k, v)| (*k, *v)).collect::<Vec<_>>()
153            })
154            .collect();
155        w.u64(entries.len() as u64);
156        for (k, v) in &entries {
157            w.u64(*k);
158            v.encode(&mut w);
159        }
160        w.into_inner()
161    }
162
163    /// 读回 [`Self::encode`] 的字节;版本不同或损坏时保持空表(冷启动)。读回的计数与已有计数合并。
164    pub fn decode_merge(&self, buf: &[u8]) {
165        let mut r = Reader::new(buf);
166        let read = |r: &mut Reader<'_>| -> Option<Vec<(u64, BetaCounts)>> {
167            if r.u32().ok()? != TABLE_VERSION {
168                return None;
169            }
170            let n = r.u64().ok()?;
171            (0..n)
172                .map(|_| Some((r.u64().ok()?, BetaCounts::decode(r).ok()?)))
173                .collect()
174        };
175        for (k, v) in read(&mut r).unwrap_or_default() {
176            let shard = &self.shards[(k as usize) & (self.shards.len() - 1)];
177            shard
178                .lock()
179                .unwrap_or_else(|e| e.into_inner())
180                .entry(k)
181                .or_default()
182                .merge(&v);
183        }
184    }
185}
186
187pub fn family_key(family: &str) -> u64 {
188    let h = blake3::hash(family.as_bytes());
189    // [协议] 取 BLAKE3 摘要的前 8 字节作 u64 键
190    let (head, _) = h
191        .as_bytes()
192        .split_first_chunk::<8>()
193        .expect("BLAKE3 digest is 32 bytes");
194    u64::from_le_bytes(*head)
195}
196
197/// 渲染决策用的族:路径族再按"有没有客户端代码"分开。0.81 的规则是静态页的经验不能压过带脚本的页面,
198/// 分开记就不会互相影响。
199pub fn render_family(url: &str, client_code: bool) -> String {
200    format!(
201        "{}#{}",
202        family(url),
203        if client_code { "js" } else { "static" }
204    )
205}
206
207/// 换内核决策的族:同一路径族、从第 `engine` 个内核换到下一个。
208pub fn fallback_family(url: &str, engine: usize) -> String {
209    format!("{}#after{engine}", family(url))
210}
211
212/// 路径族:来源 + 首级路径(与 0.81 `learn.rs` 的划分一致)。
213pub fn family(raw: &str) -> String {
214    let Ok(url) = url::Url::parse(raw) else {
215        return raw.to_string();
216    };
217    let section = url
218        .path()
219        .trim_start_matches('/')
220        .split('/')
221        .next()
222        .unwrap_or("");
223    format!("{}/{}", url.origin().ascii_serialization(), section)
224}
225
226/// 一页提取结果的校准质量分(内置模型和权重);模型没加载上时为 `None`。
227pub fn quality(doc: &pagesift_content::Document, html_bytes: usize) -> Option<f64> {
228    let model = crate::quality::QualityModel::builtin()?;
229    let weights = crate::script::ScriptWeights::builtin();
230    Some(model.predict(&crate::quality::features(doc, html_bytes, weights)))
231}