Skip to main content

mingli_engine/
lib.rs

1//! 编排层:把命理大树当作一张记忆化计算 DAG 来跑。
2//!
3//! - 共享层:一个输入 → 用 [`Moment`] 把公共天文/历法子计算**算一次**。
4//! - fan-out:注册表里每片叶([`CastingEngine`])在该共享上下文上排盘。native 走 rayon 并行;
5//!   wasm32 串行,那里没有可用线程,把 rayon 链进去只是白付 37 KB。
6//! - 统一输出:各叶输出 `serde_json::Value`,便于跨叶对齐比较。
7//!
8//! 本层**不认识任何具体叶**——注册表由调用方注入(见 `mingli-registry`)。
9//! 加一片新叶不需要改动这里的任何一行。
10
11use mingli_contract::{effective_school_id, intents, CastingEngine, IntentSpec, LeafOutput, Moment, Query, QueryKind};
12#[cfg(not(target_arch = "wasm32"))]
13use rayon::prelude::*;
14use serde_json::Value;
15use std::collections::BTreeMap;
16
17/// 注册表:一组待 fan-out 的叶。
18pub type Leaves = [Box<dyn CastingEngine>];
19
20/// 把每片叶映射成一个结果。native 上并行,wasm 上串行。
21///
22/// wasm32 没有可用线程,rayon 在那里只是把串行遍历绕过一层调度器,
23/// 并且**实测在排盘档里占 37 KB**——易经单叶档一共才 197 KB。
24/// native 那边它是真的:整棵树并行 445 µs,其中最慢的一片占 297 µs。
25#[cfg(not(target_arch = "wasm32"))]
26fn map_leaves<T: Send>(reg: &Leaves, f: impl Fn(&Box<dyn CastingEngine>) -> T + Send + Sync) -> Vec<T> {
27    reg.par_iter().map(f).collect()
28}
29
30/// 同上,wasm32 一侧:串行,不引 rayon。
31#[cfg(target_arch = "wasm32")]
32fn map_leaves<T>(reg: &Leaves, f: impl Fn(&Box<dyn CastingEngine>) -> T) -> Vec<T> {
33    reg.iter().map(f).collect()
34}
35
36/// 一个输入 → 共享层算一次 → **并行**排所有叶 → `id → 盘(JSON)`。
37#[must_use]
38pub fn cast_all(reg: &Leaves, q: &Query) -> BTreeMap<String, Value> {
39    let m = shared_moment(q);
40    map_leaves(reg, |e| (e.id().to_string(), e.cast(&m, q))).into_iter().collect()
41}
42
43/// 只算**单片**叶(按 id)——共享层仍只算一次,但仅排该叶(释义/单叶请求用,省去其余叶)。
44/// 未知 id 返回 `None`。
45#[must_use]
46pub fn cast_one(reg: &Leaves, id: &str, q: &Query) -> Option<LeafOutput> {
47    let e = reg.iter().find(|e| e.id() == id)?;
48    let m = shared_moment(q);
49    Some(leaf_output(e.as_ref(), &m, q))
50}
51
52/// 同 [`cast_all`],但保留注册表**顺序**并附带每叶元数据(id/name/family/确定性谱/流派)。
53#[must_use]
54pub fn cast_all_detailed(reg: &Leaves, q: &Query) -> Vec<LeafOutput> {
55    let m = shared_moment(q);
56    map_leaves(reg, |e| leaf_output(e.as_ref(), &m, q))
57}
58
59/// 把一个问局意图路由到具体的叶 id 列表。
60///
61/// 问的是每片叶自己:谁在 [`CastingEngine::answers`] 里认领了这一类,谁就入选。
62/// 从前这里读的是端口层写死的一张「意图 → 叶 id」表,那张表让端口层知道了树上有哪些叶,
63/// 也让「加一片叶只动装配根」在字符串层面不成立——漏改那张表,新叶不入任何路由且不报错。
64///
65/// 次序即注册表次序。feature flag 关掉的叶不在注册表里,自然也不在结果里。
66#[must_use]
67pub fn route(reg: &Leaves, kind: &QueryKind) -> Vec<&'static str> {
68    let want = kind.intent();
69    reg.iter().filter(|e| e.answers().contains(&want)).map(|e| e.id()).collect()
70}
71
72/// 意图清单与各意图当前实际路由到的叶。
73///
74/// [`intents`] 只说这一类问局是什么、要哪些输入原子;「谁来答」要问注册表里的叶。
75/// 两者在这里合成一份,供承接层直接展示。
76#[must_use]
77pub fn intent_catalog(reg: &Leaves) -> Vec<(&'static IntentSpec, Vec<&'static str>)> {
78    intents()
79        .iter()
80        .map(|spec| {
81            let leaves = reg.iter().filter(|e| e.answers().contains(&spec.id)).map(|e| e.id()).collect();
82            (spec, leaves)
83        })
84        .collect()
85}
86
87/// 共享上下文:一次输入只构造一个 [`Moment`],全叶复用(记忆化的落点)。
88fn shared_moment(q: &Query) -> Moment {
89    Moment::new(q.year, q.month, q.day, q.hour, q.minute, q.tz)
90}
91
92fn leaf_output(e: &dyn CastingEngine, m: &Moment, q: &Query) -> LeafOutput {
93    LeafOutput {
94        id: e.id(),
95        name: e.name(),
96        family: e.family(),
97        family_label: e.family().label(),
98        profile: e.profile(),
99        schools: e.schools(),
100        effective_school: effective_school_id(e, q),
101        chart: e.cast(m, q),
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use mingli_contract::{d, s, DetItem, Determinism, Family, SchoolItem};
109
110    /// 假叶甲:带确定性谱与两个流派,用来验元数据透传与流派落默认。
111    #[derive(Debug, Default)]
112    struct Alpha;
113    impl CastingEngine for Alpha {
114        fn id(&self) -> &'static str {
115            "alpha"
116        }
117        fn name(&self) -> &'static str {
118            "假叶甲"
119        }
120        fn family(&self) -> Family {
121            Family::Cyclic
122        }
123        fn cast(&self, m: &Moment, q: &Query) -> Value {
124            serde_json::json!({ "jdn": m.civil_day, "year": q.year })
125        }
126        fn profile(&self) -> &'static [DetItem] {
127            const { &[d("假谱", Determinism::Det, "测试用")] }
128        }
129        fn schools(&self) -> &'static [SchoolItem] {
130            const { &[s("one", "甲流派", true, "默认"), s("two", "乙流派", false, "备选")] }
131        }
132    }
133
134    /// 假叶乙:只实现必需项,用来验 trait 默认(空谱、空流派)。
135    #[derive(Debug, Default)]
136    struct Beta;
137    impl CastingEngine for Beta {
138        fn id(&self) -> &'static str {
139            "beta"
140        }
141        fn name(&self) -> &'static str {
142            "假叶乙"
143        }
144        fn family(&self) -> Family {
145            Family::Sampling
146        }
147        fn cast(&self, _m: &Moment, _q: &Query) -> Value {
148            Value::Null
149        }
150    }
151
152    fn fake_registry() -> Vec<Box<dyn CastingEngine>> {
153        vec![Box::new(Alpha), Box::new(Beta)]
154    }
155
156    fn sample() -> Query {
157        Query {
158            year: 1990,
159            month: 6,
160            day: 15,
161            hour: 14,
162            minute: 30,
163            tz: 8.0,
164            gender: None,
165            latitude: None,
166            longitude: None,
167            seed: None,
168            name: None,
169            schools: BTreeMap::new(),
170        }
171    }
172
173    #[test]
174    fn cast_all_covers_the_injected_registry() {
175        let out = cast_all(&fake_registry(), &sample());
176        assert_eq!(out.len(), 2);
177        assert_eq!(out["alpha"]["year"], 1990);
178        assert_eq!(out["beta"], Value::Null);
179    }
180
181    #[test]
182    fn cast_one_selects_and_rejects_unknown() {
183        let reg = fake_registry();
184        let q = sample();
185        let one = cast_one(&reg, "alpha", &q).expect("alpha 应在注册表内");
186        assert_eq!(one.name, "假叶甲");
187        assert_eq!(one.chart, cast_all(&reg, &q)["alpha"]);
188        assert!(cast_one(&reg, "nope", &q).is_none());
189    }
190
191    #[test]
192    fn detailed_preserves_order_and_carries_metadata() {
193        let out = cast_all_detailed(&fake_registry(), &sample());
194        assert_eq!(out.iter().map(|l| l.id).collect::<Vec<_>>(), ["alpha", "beta"]);
195        assert_eq!(out[0].family_label, "循环群/CRT");
196        assert_eq!(out[0].profile.len(), 1);
197        // 未指定流派 → 落到该叶 default;无流派的叶 → 空串
198        assert_eq!(out[0].effective_school, "one");
199        assert_eq!(out[1].effective_school, "");
200        assert!(out[1].profile.is_empty() && out[1].schools.is_empty());
201    }
202
203    #[test]
204    fn explicit_school_overrides_default() {
205        let mut q = sample();
206        q.schools.insert("alpha".to_string(), "two".to_string());
207        let out = cast_all_detailed(&fake_registry(), &q);
208        assert_eq!(out[0].effective_school, "two");
209    }
210
211    #[test]
212    fn shared_moment_is_computed_once_per_call() {
213        // 同一输入下两次 fan-out 结果一致 —— 共享层是纯函数,可安全复用。
214        let reg = fake_registry();
215        assert_eq!(cast_all(&reg, &sample()), cast_all(&reg, &sample()));
216    }
217
218    #[test]
219    fn route_natal_returns_whole_registry_in_order() {
220        let reg = fake_registry();
221        let ids = route(&reg, &QueryKind::Natal(sample()));
222        assert_eq!(ids, ["alpha", "beta"]);
223    }
224
225    #[test]
226    fn route_non_natal_intersects_with_registry() {
227        // 假注册表里没有真叶,所以任何非 Natal 意图都路由到空集——
228        // 「声明的默认叶 ∩ 实际装配的叶」这条规则本身即被验证。
229        let reg = fake_registry();
230        let kind = QueryKind::Election {
231            window_start: AskTime { year: 2026, month: 1, day: 1, hour: 0, minute: 0, tz: 8.0 },
232            window_end: AskTime { year: 2026, month: 1, day: 8, hour: 0, minute: 0, tz: 8.0 },
233            category: "婚".to_string(),
234        };
235        assert!(route(&reg, &kind).is_empty());
236    }
237
238    use mingli_contract::AskTime;
239}