Skip to main content

evorule_reactor/
hash.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2// Copyright (C) 2026 EvoRule Project
3// This file is part of EvoRule, licensed under GNU Affero General Public License v3 or later.
4//! 审计链哈希工具(BLAKE3)
5//!
6//! 这是 evorule 审计链的核心完整性保证机制。
7//!
8//! # 设计
9//! - 使用 `blake3` crate(1.x)计算 256 位哈希
10//! - 序列化采用显式 JSON 格式:确保序列化格式的稳定性,不受 Debug 实现变更影响
11//! - 哈希以十六进制字符串形式返回
12//!
13//! # 哈希链算法
14//! - `prev_hash` 初始为 `"genesis"`
15//! - 对每个 Fact,计算 `content_hash = blake3(fact_to_stable_json(fact))`
16//! - 链哈希:`chain_hash = blake3(prev_hash + content_hash)`
17//! - 更新 `prev_hash = chain_hash`,继续处理下一个 Fact
18//!
19//! # 两套 WAL 合并
20//! 本模块是哈希算法的**单一真相源**(single source of truth)。
21//! - evorule-governance/src/hash.rs re-export 本模块
22//! - evorule-cli/src/hash.rs re-export 本模块
23//! - 通过 `test_cross_validate_with_tier2` 测试保证三方一致
24
25use std::fmt;
26
27use evorule_tcb::JsonValue;
28use tracing::{debug, trace};
29
30use crate::fact::Fact;
31
32/// 哈希计算错误类型
33///
34/// 包含错误消息,便于排查哈希计算过程中的异常。
35#[derive(Debug)]
36pub struct HashError {
37    message: String,
38}
39
40impl HashError {
41    fn new(message: impl Into<String>) -> Self {
42        Self {
43            message: message.into(),
44        }
45    }
46}
47
48impl fmt::Display for HashError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        write!(f, "哈希计算错误: {}", self.message)
51    }
52}
53
54impl std::error::Error for HashError {}
55
56/// 将 `JsonValue` 转换为 `serde_json::Value`
57///
58/// 内部函数,用于 Fact 序列化前的类型转换。
59/// 保证转换过程确定性,不受 JsonValue 内部实现影响。
60fn tcb_to_serde(value: &JsonValue) -> serde_json::Value {
61    match value {
62        JsonValue::Null => serde_json::Value::Null,
63        JsonValue::Bool(b) => serde_json::Value::Bool(*b),
64        JsonValue::Integer(i) => serde_json::Value::Number(serde_json::Number::from(*i)),
65        JsonValue::String(s) => serde_json::Value::String(s.to_string()),
66        JsonValue::Array(arr) => serde_json::Value::Array(arr.iter().map(tcb_to_serde).collect()),
67        JsonValue::Object(obj) => {
68            let mut map = serde_json::Map::new();
69            for (k, v) in obj.iter() {
70                map.insert(k.clone(), tcb_to_serde(v));
71            }
72            serde_json::Value::Object(map)
73        }
74    }
75}
76
77/// 将 Fact 序列化为稳定的 JSON 格式
78///
79/// 使用显式的 JSON 序列化而非依赖 Debug trait,确保:
80/// 1. 序列化格式不受 Rust 版本或 Debug 实现变更影响
81/// 2. 字段顺序固定且可预测(serde_json::Map 基于 BTreeMap,字母序)
82/// 3. 跨版本兼容性有保障
83///
84/// # 参数
85/// - `fact`: 要序列化的事实
86///
87/// # 返回值
88/// - `Ok(serde_json::Value)`: 序列化后的 JSON 值
89/// - `Err(HashError)`: 序列化失败
90// 7 种 Fact 变体扁平 match + 嵌套, 拆函数需共享中间变量。详见 GATE_REFERENCE.md §六(豁免索引)
91#[allow(clippy::cognitive_complexity, clippy::too_many_lines)]
92pub fn fact_to_stable_json(fact: &Fact) -> Result<serde_json::Value, HashError> {
93    let fact_type = fact.type_name();
94    let fact_id = fact.id();
95
96    trace!(
97        事实ID = ?fact_id,
98        事实类型 = %fact_type,
99        "序列化开始"
100    );
101
102    let mut obj = serde_json::Map::new();
103    match fact {
104        Fact::Command { id, instruction } => {
105            trace!(
106                事实ID = ?id,
107                指令大小 = instruction.to_string().len(),
108                "处理命令类型事实"
109            );
110            obj.insert("type".into(), serde_json::Value::String("Command".into()));
111            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
112            obj.insert("instruction".into(), tcb_to_serde(instruction));
113        }
114        Fact::PayloadUpdate { id, path, value } => {
115            trace!(
116                事实ID = ?id,
117                路径 = %path,
118                "处理载荷更新类型事实"
119            );
120            obj.insert(
121                "type".into(),
122                serde_json::Value::String("PayloadUpdate".into()),
123            );
124            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
125            obj.insert("path".into(), serde_json::Value::String(path.clone()));
126            obj.insert("value".into(), tcb_to_serde(value));
127        }
128        Fact::StateTransition {
129            id,
130            cause,
131            new_payload,
132            new_queue,
133        } => {
134            trace!(
135                事实ID = ?id,
136                原因ID = ?cause,
137                队列长度 = new_queue.len(),
138                "处理状态转换类型事实"
139            );
140            obj.insert(
141                "type".into(),
142                serde_json::Value::String("StateTransition".into()),
143            );
144            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
145            obj.insert("cause".into(), serde_json::Value::Number(cause.0.into()));
146            obj.insert("new_payload".into(), tcb_to_serde(new_payload));
147            obj.insert(
148                "new_queue".into(),
149                serde_json::Value::Array(new_queue.iter().map(tcb_to_serde).collect()),
150            );
151        }
152        Fact::IoRequest {
153            id,
154            cause,
155            io_type,
156            params,
157        } => {
158            trace!(
159                事实ID = ?id,
160                原因ID = ?cause,
161                IO类型 = %io_type.as_str(),
162                "处理IO请求类型事实"
163            );
164            obj.insert("type".into(), serde_json::Value::String("IoRequest".into()));
165            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
166            obj.insert("cause".into(), serde_json::Value::Number(cause.0.into()));
167            obj.insert(
168                "io_type".into(),
169                serde_json::Value::String(io_type.as_str().into()),
170            );
171            obj.insert("params".into(), tcb_to_serde(params));
172        }
173        Fact::IoResponse {
174            id,
175            request_id,
176            result,
177            error,
178        } => {
179            trace!(
180                事实ID = ?id,
181                请求ID = ?request_id,
182                是否有错误 = error.is_some(),
183                "处理IO响应类型事实"
184            );
185            obj.insert(
186                "type".into(),
187                serde_json::Value::String("IoResponse".into()),
188            );
189            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
190            obj.insert(
191                "request_id".into(),
192                serde_json::Value::Number(request_id.0.into()),
193            );
194            obj.insert("result".into(), tcb_to_serde(result));
195            obj.insert(
196                "error".into(),
197                error
198                    .as_ref()
199                    .map(|e| serde_json::Value::String(e.clone()))
200                    .unwrap_or(serde_json::Value::Null),
201            );
202        }
203        Fact::Stable { id, final_snapshot } => {
204            trace!(
205                事实ID = ?id,
206                快照大小 = final_snapshot.to_string().len(),
207                "处理稳定状态类型事实"
208            );
209            obj.insert("type".into(), serde_json::Value::String("Stable".into()));
210            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
211            obj.insert("final_snapshot".into(), tcb_to_serde(final_snapshot));
212        }
213        Fact::Error { id, message } => {
214            trace!(
215                事实ID = ?id,
216                消息 = %message,
217                "处理错误类型事实"
218            );
219            obj.insert("type".into(), serde_json::Value::String("Error".into()));
220            obj.insert("id".into(), serde_json::Value::Number(id.0.into()));
221            obj.insert("message".into(), serde_json::Value::String(message.clone()));
222        }
223    }
224
225    let value = serde_json::Value::Object(obj);
226
227    trace!(
228        事实ID = ?fact_id,
229        事实类型 = %fact_type,
230        "序列化完成"
231    );
232
233    Ok(value)
234}
235
236/// 计算内容的 BLAKE3 哈希
237///
238/// 将 `JsonValue` 序列化为确定性字符串后计算哈希。
239/// 使用 `tcb_to_serde` 进行显式 JSON 序列化,确保格式稳定性。
240///
241/// # 参数
242/// - `value`: 要计算哈希的 JSON 值
243///
244/// # 返回值
245/// - `Ok(String)`: 64 字符的十六进制哈希字符串
246/// - `Err(HashError)`: 序列化失败
247#[allow(dead_code)] // 供 evorule-governance re-export 使用
248pub fn content_hash(value: &JsonValue) -> Result<String, HashError> {
249    #[cfg(kani)]
250    {
251        // Kani 模式:blake3 的位操作循环会导致 CBMC memcmp 状态爆炸
252        // (4357+ 次循环展开未止)。用确定性简化哈希替代,保持幂等性。
253        // 注意:不用 format!(触发 core::unicode::skip_search 状态爆炸)。
254        Ok(String::from("c"))
255    }
256    #[cfg(not(kani))]
257    {
258        let serde_value = tcb_to_serde(value);
259        let serialized = serde_json::to_string(&serde_value)
260            .map_err(|e| HashError::new(format!("内容哈希序列化失败: {}", e)))?;
261        let hash = blake3::hash(serialized.as_bytes()).to_hex().to_string();
262
263        trace!(
264            序列化长度 = serialized.len(),
265            哈希值 = %hash,
266            "内容哈希计算完成"
267        );
268
269        Ok(hash)
270    }
271}
272
273/// 计算 Fact 的哈希(基于稳定的 JSON 序列化)
274///
275/// 使用显式 JSON 序列化格式而非 Debug trait,确保序列化格式的稳定性。
276///
277/// # 参数
278/// - `fact`: 要计算哈希的事实
279///
280/// # 返回值
281/// - `Ok(String)`: 64 字符的十六进制哈希字符串
282/// - `Err(HashError)`: 哈希计算失败
283///
284/// # 示例
285/// ```
286/// use evorule_reactor::{Fact, FactId, fact_hash};
287/// use evorule_tcb::JsonValue;
288///
289/// let fact = Fact::Command {
290///     id: FactId(1),
291///     instruction: JsonValue::empty_object(),
292/// };
293/// let hash = fact_hash(&fact).unwrap();
294/// assert_eq!(hash.len(), 64);
295/// ```
296pub fn fact_hash(fact: &Fact) -> Result<String, HashError> {
297    #[cfg(kani)]
298    {
299        // Kani 模式:blake3 的位操作循环会导致 CBMC memcmp 状态爆炸。
300        // 用确定性简化哈希替代:基于 id 的单字符映射(a-g),
301        // 保持幂等性(相同 id → 相同 hash)和区分性(不同 id → 不同 hash)。
302        // 不用 format!/to_string()/type_name(触发 Unicode 状态爆炸)。
303        let id = fact.id().0;
304        let h = match id % 7 {
305            0 => "a",
306            1 => "b",
307            2 => "c",
308            3 => "d",
309            4 => "e",
310            5 => "f",
311            _ => "g",
312        };
313        Ok(String::from(h))
314    }
315    #[cfg(not(kani))]
316    {
317        let fact_type = fact.type_name();
318        let fact_id = fact.id();
319
320        debug!(
321            事实ID = ?fact_id,
322            事实类型 = %fact_type,
323            "开始计算事实哈希"
324        );
325
326        let value = fact_to_stable_json(fact)?;
327        let serialized = serde_json::to_string(&value)
328            .map_err(|e| HashError::new(format!("序列化失败: {}", e)))?;
329
330        let hash = blake3::hash(serialized.as_bytes()).to_hex().to_string();
331
332        debug!(
333            事实ID = ?fact_id,
334            事实类型 = %fact_type,
335            哈希值 = %hash,
336            序列化长度 = serialized.len(),
337            "事实哈希计算完成"
338        );
339
340        Ok(hash)
341    }
342}
343
344/// 计算哈希链的最终链哈希
345///
346/// 这是审计链的核心计算函数。
347///
348/// # 算法
349/// - `prev_hash` 初始为 `"genesis"`
350/// - 对每个 Fact,计算 `content_hash = fact_hash(fact)`
351/// - 链哈希:`chain_hash = blake3(prev_hash + content_hash)`
352/// - 更新 `prev_hash = chain_hash`,继续处理下一个 Fact
353/// - 返回最终的 `prev_hash`(即整条链的链哈希)
354///
355/// # 参数
356/// - `facts`: 事实列表(按因果顺序)
357///
358/// # 返回值
359/// - `Ok(String)`: 最终链哈希(64 字符十六进制)
360/// - `Err(HashError)`: 任一 Fact 哈希计算失败
361///
362/// # 注意
363/// 本函数**只计算**链哈希,**不验证**完整性。
364/// 验证逻辑由调用方比对存储的哈希与重算的哈希。
365/// 空列表返回 `"genesis"`。
366pub fn compute_chain_hash(facts: &[Fact]) -> Result<String, HashError> {
367    let fact_count = facts.len();
368
369    debug!(事实数量 = fact_count, "开始计算链哈希");
370
371    if fact_count == 0 {
372        debug!("事实列表为空,返回 genesis 哈希");
373        return Ok(String::from("genesis"));
374    }
375
376    let mut prev_hash = String::from("genesis");
377
378    trace!(
379        初始前序哈希 = %prev_hash,
380        "初始化前序哈希为创世值"
381    );
382
383    for (index, fact) in facts.iter().enumerate() {
384        let fact_type = fact.type_name();
385        let fact_id = fact.id();
386
387        trace!(
388            索引 = index,
389            事实ID = ?fact_id,
390            事实类型 = %fact_type,
391            前序哈希 = %prev_hash,
392            "处理事实"
393        );
394
395        let fh = fact_hash(fact)?;
396
397        trace!(
398            索引 = index,
399            事实ID = ?fact_id,
400            事实哈希 = %fh,
401            "事实哈希计算完成"
402        );
403
404        let current = chain_step(&prev_hash, &fh);
405
406        trace!(
407            索引 = index,
408            事实ID = ?fact_id,
409            当前哈希 = %current,
410            "计算当前哈希(前序哈希+事实哈希)"
411        );
412
413        prev_hash = current;
414    }
415
416    debug!(
417        事实数量 = fact_count,
418        最终哈希 = %prev_hash,
419        "链哈希计算完成"
420    );
421
422    Ok(prev_hash)
423}
424
425/// 计算单步链哈希:chain_hash = blake3(prev_hash + content_hash)
426///
427/// Kani 模式下用确定性简化版本(字符串拼接)替代 blake3,
428/// 避免 CBMC 对 blake3 位操作循环的 memcmp 状态爆炸。
429/// 保持链步的结合性:`chain_step(chain_step(a, b), c)` 可归纳分解。
430///
431/// # 参数
432/// - `prev_hash`: 前序链哈希(初始为 `"genesis"`)
433/// - `content_hash`: 当前 Fact 的内容哈希
434///
435/// # 返回值
436/// 新的链哈希
437pub fn chain_step(prev_hash: &str, content_hash: &str) -> String {
438    #[cfg(kani)]
439    {
440        // Kani 模式:确定性简化,保持链步结构。
441        // 不用 format!(触发 core::unicode::skip_search 状态爆炸),
442        // 用 String::from + push_str(字节级操作,短字符串)。
443        let mut s = String::from(prev_hash);
444        s.push_str(content_hash);
445        s
446    }
447    #[cfg(not(kani))]
448    {
449        let combined = format!("{}{}", prev_hash, content_hash);
450        blake3::hash(combined.as_bytes()).to_hex().to_string()
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
457    use super::*;
458    use crate::fact::{Fact, FactId, IoType};
459    use evorule_tcb::JsonValue;
460
461    #[test]
462    fn test_fact_to_stable_json_format() {
463        let command = Fact::Command {
464            id: FactId(1),
465            instruction: JsonValue::object_from_pairs(&[
466                ("type", JsonValue::string("increment")),
467                (
468                    "params",
469                    JsonValue::object_from_pairs(&[
470                        ("attr", JsonValue::string("x")),
471                        ("delta", JsonValue::Integer(5)),
472                    ]),
473                ),
474            ]),
475        };
476
477        let json_value = fact_to_stable_json(&command).unwrap();
478
479        assert_eq!(json_value.get("type").unwrap().as_str().unwrap(), "Command");
480        assert_eq!(json_value.get("id").unwrap().as_u64().unwrap(), 1);
481        assert!(json_value.get("instruction").is_some());
482    }
483
484    #[test]
485    fn test_fact_hash_all_variants() {
486        let test_facts = vec![
487            Fact::Command {
488                id: FactId(1),
489                instruction: JsonValue::empty_object(),
490            },
491            Fact::PayloadUpdate {
492                id: FactId(2),
493                path: "test.path".into(),
494                value: JsonValue::string("test_value"),
495            },
496            Fact::StateTransition {
497                id: FactId(3),
498                cause: FactId(1),
499                new_payload: JsonValue::empty_object(),
500                new_queue: vec![],
501            },
502            Fact::IoRequest {
503                id: FactId(4),
504                cause: FactId(3),
505                io_type: IoType::http_get(),
506                params: JsonValue::empty_object(),
507            },
508            Fact::IoResponse {
509                id: FactId(5),
510                request_id: FactId(4),
511                result: JsonValue::string("response"),
512                error: None,
513            },
514            Fact::IoResponse {
515                id: FactId(6),
516                request_id: FactId(4),
517                result: JsonValue::Null,
518                error: Some("timeout".to_string()),
519            },
520            Fact::Stable {
521                id: FactId(7),
522                final_snapshot: JsonValue::empty_object(),
523            },
524            Fact::Error {
525                id: FactId(8),
526                message: "test error".into(),
527            },
528        ];
529
530        for fact in test_facts {
531            let hash = fact_hash(&fact).unwrap();
532            assert_eq!(hash.len(), 64);
533
534            let hash2 = fact_hash(&fact).unwrap();
535            assert_eq!(
536                hash,
537                hash2,
538                "fact_hash should be deterministic for {}",
539                fact.type_name()
540            );
541        }
542    }
543
544    #[test]
545    fn test_fact_hash_identity() {
546        let fact1 = Fact::Command {
547            id: FactId(1),
548            instruction: JsonValue::string("same"),
549        };
550        let fact2 = Fact::Command {
551            id: FactId(1),
552            instruction: JsonValue::string("same"),
553        };
554
555        assert_eq!(fact_hash(&fact1).unwrap(), fact_hash(&fact2).unwrap());
556    }
557
558    #[test]
559    fn test_fact_hash_different_ids() {
560        let fact1 = Fact::Command {
561            id: FactId(1),
562            instruction: JsonValue::string("same"),
563        };
564        let fact2 = Fact::Command {
565            id: FactId(2),
566            instruction: JsonValue::string("same"),
567        };
568
569        assert_ne!(fact_hash(&fact1).unwrap(), fact_hash(&fact2).unwrap());
570    }
571
572    #[test]
573    fn test_fact_hash_snapshot() {
574        let test_facts = [
575            Fact::Command {
576                id: FactId(1),
577                instruction: JsonValue::empty_object(),
578            },
579            Fact::PayloadUpdate {
580                id: FactId(2),
581                path: "test.path".into(),
582                value: JsonValue::string("test_value"),
583            },
584            Fact::StateTransition {
585                id: FactId(3),
586                cause: FactId(1),
587                new_payload: JsonValue::empty_object(),
588                new_queue: vec![],
589            },
590            Fact::IoRequest {
591                id: FactId(4),
592                cause: FactId(3),
593                io_type: IoType::http_get(),
594                params: JsonValue::empty_object(),
595            },
596            Fact::IoResponse {
597                id: FactId(5),
598                request_id: FactId(4),
599                result: JsonValue::string("response"),
600                error: None,
601            },
602            Fact::Stable {
603                id: FactId(6),
604                final_snapshot: JsonValue::empty_object(),
605            },
606            Fact::Error {
607                id: FactId(7),
608                message: "test error".into(),
609            },
610        ];
611
612        let current_hashes: Vec<String> =
613            test_facts.iter().map(|f| fact_hash(f).unwrap()).collect();
614        let snapshot_file = env!("CARGO_MANIFEST_DIR").to_string() + "/hash_snapshot.txt";
615
616        if std::path::Path::new(&snapshot_file).exists() {
617            let snapshot = std::fs::read_to_string(&snapshot_file).unwrap();
618            let expected_hashes: Vec<String> = snapshot.lines().map(|s| s.to_string()).collect();
619            assert_eq!(
620                current_hashes, expected_hashes,
621                "Hash snapshot mismatch! If this is an expected change (e.g., Fact struct modification), delete {} to regenerate.",
622                snapshot_file
623            );
624        } else {
625            let snapshot_content = current_hashes.join("\n");
626            std::fs::write(&snapshot_file, snapshot_content).unwrap();
627            println!("Created hash snapshot: {}", snapshot_file);
628        }
629    }
630
631    #[test]
632    fn test_compute_chain_hash_empty() {
633        let facts: Vec<Fact> = vec![];
634        let chain_hash = compute_chain_hash(&facts).unwrap();
635        assert_eq!(chain_hash, "genesis");
636    }
637
638    #[test]
639    fn test_compute_chain_hash_deterministic() {
640        let facts = vec![
641            Fact::Command {
642                id: FactId(1),
643                instruction: JsonValue::empty_object(),
644            },
645            Fact::StateTransition {
646                id: FactId(2),
647                cause: FactId(1),
648                new_payload: JsonValue::empty_object(),
649                new_queue: vec![],
650            },
651        ];
652
653        let result1 = compute_chain_hash(&facts).unwrap();
654        let result2 = compute_chain_hash(&facts).unwrap();
655        assert_eq!(result1, result2);
656    }
657
658    #[test]
659    fn test_compute_chain_hash_order_sensitive() {
660        let fact1 = Fact::Command {
661            id: FactId(1),
662            instruction: JsonValue::empty_object(),
663        };
664        let fact2 = Fact::StateTransition {
665            id: FactId(2),
666            cause: FactId(1),
667            new_payload: JsonValue::empty_object(),
668            new_queue: vec![],
669        };
670
671        let chain1 = compute_chain_hash(&[fact1.clone(), fact2.clone()]).unwrap();
672        let chain2 = compute_chain_hash(&[fact2, fact1]).unwrap();
673        assert_ne!(chain1, chain2, "链哈希应对 Fact 顺序敏感");
674    }
675
676    /// 交叉验证测试:与 evorule-governance/src/hash.rs 的快照比对
677    ///
678    /// 本测试确保 tier1 和 tier2 的哈希算法产生相同的哈希值。
679    /// 如果 tier2 hash.rs 仍维护独立实现,此测试会检测到不一致。
680    #[test]
681    fn test_cross_validate_with_tier2() {
682        // 与 evorule-governance/src/hash.rs 的 test_fact_hash_snapshot 使用相同的 Fact
683        let test_facts = [
684            Fact::Command {
685                id: FactId(1),
686                instruction: JsonValue::empty_object(),
687            },
688            Fact::PayloadUpdate {
689                id: FactId(2),
690                path: "test.path".into(),
691                value: JsonValue::string("test_value"),
692            },
693            Fact::StateTransition {
694                id: FactId(3),
695                cause: FactId(1),
696                new_payload: JsonValue::empty_object(),
697                new_queue: vec![],
698            },
699            Fact::IoRequest {
700                id: FactId(4),
701                cause: FactId(3),
702                io_type: IoType::http_get(),
703                params: JsonValue::empty_object(),
704            },
705            Fact::IoResponse {
706                id: FactId(5),
707                request_id: FactId(4),
708                result: JsonValue::string("response"),
709                error: None,
710            },
711            Fact::Stable {
712                id: FactId(6),
713                final_snapshot: JsonValue::empty_object(),
714            },
715            Fact::Error {
716                id: FactId(7),
717                message: "test error".into(),
718            },
719        ];
720
721        // 计算 tier1 的哈希
722        let tier1_hashes: Vec<String> = test_facts.iter().map(|f| fact_hash(f).unwrap()).collect();
723
724        // 读取 tier2 的快照文件(如果存在)
725        let tier2_snapshot_path = "../../evorule-governance/src/hash_snapshot.txt".to_string();
726        if std::path::Path::new(&tier2_snapshot_path).exists() {
727            let tier2_snapshot = std::fs::read_to_string(&tier2_snapshot_path).unwrap();
728            let tier2_hashes: Vec<String> = tier2_snapshot.lines().map(|s| s.to_string()).collect();
729
730            assert_eq!(
731                tier1_hashes, tier2_hashes,
732                "tier1 和 tier2 的 fact_hash 不一致!\
733                 如果这是预期变更(如 Fact 结构修改),\
734                 请删除 evorule-governance/src/hash_snapshot.txt 重新生成。"
735            );
736        }
737    }
738}