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