Skip to main content

vigil_sdk/
lib.rs

1//! # vigil-sdk
2//!
3//! Minimal stable SDK facade for embedding Vigil's local AI safety runtime into
4//! 3rd-party tools. Published on crates.io as `vigil-sdk` (Apache-2.0).
5//!
6//! ## Status
7//!
8//! **已发布 0.1.0**(crates.io)。SDK pub surface 经 Codex review 守门。
9//! 当前暴露的是**最小核心**:typed decisions/audit records + high-level
10//! firewall execution + high-level redaction scanning。
11//!
12//! 显式**不在** SDK 中:server runtime(Hub / oracle)/ 运行时 backend(NoopEngine /
13//! MockEngine / OrtEngine)/ ops 基建(bootstrap / model 分发)/ MCP 路由
14//! / 凭据 (lease) / policy 引擎 internals。
15//! 增加新项必经 codex review;**移除现项视为 breaking change,需 ADR 决策**。
16//!
17//! ## Quickstart
18//!
19//! ```rust
20//! use vigil_sdk::prelude::*;
21//!
22//! // 高层 redaction:默认路径(NoopEngine + Hard 规则)即可命中 secret 类。
23//! // soft 标签(email / phone / person 等)需 vigil-redaction 的 `ort` feature
24//! // + 模型环境;不在 SDK Phase 1 暴露,sdk 给 consumer 用 default safe path。
25//! let token = "ghp_0123456789abcdefghijklmnopqrstuvwxyz12";
26//! let result: RedactionResult = scan_text(token).unwrap();
27//! assert!(result.findings.iter().any(|f| f.kind == "github_token"));
28//! ```
29//!
30//! ## Invariant 约定(SDK consumer 必须遵守)
31//!
32//! 1. **Fail-closed**:任何 SDK 函数返 [`ScanError`] / [`FirewallError`] 时,
33//!    consumer **不可**降级为"放行"。所有错误路径默认走 deny。
34//! 2. **绝不存原文**:SDK 接收的 input 文本不会被 SDK 持久化;consumer 也不应
35//!    把原文写入 audit / log / 网络。审计走 [`DecisionRecord`] / [`AuditEvent`]
36//!    (已守 no-plaintext 不变量)。
37//! 3. **DecisionRecord 强制**:任何 effect 触发(tool invocation / approval / etc)
38//!    必须 first 产出 [`DecisionRecord`]。**不存在** SDK API 让 consumer 跳过这步。
39//! 4. **接口稳定**:SDK pub items 在 0.x 阶段允许小改进,但**移除**视为 breaking change;
40//!    v1.0 freeze 后**仅可加,不可删**(SDK SemVer 政策守门,见下)。
41//!
42//! ## SemVer 政策
43//!
44//! - 当前在 0.1.x:可能小改 SDK item 签名(必经 codex review + ADR)
45//! - v1.0 之后:freeze SDK pub items;新加项允许,删除/改签名禁止
46//! - **non-SDK** crate(vigil-policy 等)仍可独立演进,与 SDK 解耦
47//!
48//! ## 哪些 v0.7 sprint 会扩 SDK?
49//!
50//! - **Phase 2 Performance**:可能加 `PiiScanner::scan_perf` benchmark hooks(roadmap-only)
51//! - **Phase 3 Multi-Model**:加 ModelDescriptor + selection API
52//!
53//! ## 引用
54//!
55//! - 项目仓库:<https://github.com/duncatzat/vigils>
56
57#![deny(unsafe_code)]
58// v0.13.1 C5(2026-05-15):SDK 公开 surface 100% rustdoc coverage gate。
59// 任何新加 pub item 缺 doc comment 即编译失败,SDK SemVer 稳定性的硬门。
60#![deny(missing_docs)]
61
62// ─────────────── Stable SDK re-exports(Phase 1)───────────────
63
64// vigil-types: typed decisions / audit / approval / effect
65pub use vigil_types::{
66    ApprovalRequest, ApprovalResolution, ApprovalScope, ApprovalStatus, AuditEvent, DecisionKind,
67    DecisionRecord, EffectKind, EffectVector, ToolInvocation,
68};
69
70// vigil-firewall: high-level firewall execution
71pub use vigil_firewall::{
72    EngineStatusReport, // v0.8 Sprint 1 A2(commit 68683e1)— Sprint 4 R1 补 re-export(Codex 019deb53)
73    Firewall,
74    FirewallConfig,
75    FirewallError,
76    FirewallOutcome,
77    OAuthScopeContext,
78    PiiScanner,
79};
80
81// vigil-redaction: high-level redaction scanning
82pub use vigil_redaction::{
83    // v0.10 Sprint 6 — advisory lang detect(Heuristic;永不可信,仅 advisory)
84    detect_lang_heuristic,
85    scan_text,
86    scan_text_with_engine,
87    // v0.7-α2 Phase 2D(ADR 0016 Fail-Closed Bottom Line):budget-aware scan +
88    // 模型路径超时/错误退化 Hard-only。SDK consumer 用 budget API 对应 invariant #13
89    // Enhanced path 超 budget 的退化路径决策。
90    scan_text_with_engine_budgeted,
91    scan_text_with_engine_with_hint,
92    BudgetedScanOutcome,
93    EngineStatus,
94    Finding,
95    FindingSource,
96    // v0.10 Sprint 2 — typed LanguageHint(Decision A-prime;SDK 友好,fail-closed)
97    LangHintSource,
98    LanguageHint,
99    PrivacyLabel,
100    RedactionEngine,
101    RedactionResult,
102    RiskSignals,
103    ScanError,
104};
105
106// vigil-mcp: descriptor hash(用于 audit 关联,不暴露 router/upstream/server 内部)
107pub use vigil_mcp::descriptor_hash;
108
109// ─────────────── v0.16 — high-level firewall facade(SDK-owned builder)───────────────
110//
111// 补全 SDK "firewall execution" 卖点:消费者只用 vigil-sdk 即可起 firewall 跑决策,
112// 不暴露 Ledger / PolicyEngine / DescriptorOracle(内化,守 SDK 边界)。
113// 设计 + Codex review trajectory:docs/operations/sdk-firewall-builder/spike.md。
114pub mod firewall_builder;
115pub use firewall_builder::{FirewallBuildError, FirewallBuilder, SdkFirewall};
116
117// ─────────────── v0.8 Sprint 4 P3.1 — ensemble 浅级暴露(opt-in `ort` feature)───────────────
118//
119// **roadmap-v0.8 §2.4 ACCEPT**:暴露**配置级 API**(model_id 常量 + ensemble 工厂入口)
120// 而**非**完整 trait(ModelDescriptor / EnsembleEngine 等等 dual_confirm 算法稳定再暴露)。
121//
122// **当前 v0.8 暴露范围**(锁定 SemVer):
123// - 三 model_id 常量(stable string,SDK consumer 用作配置 / audit 跨表 join)
124// - `ort_ensemble_scanner_arc_from_env`(企业 release runner 工厂入口 — 三模型 union)
125//
126// **不**暴露(留 v0.9+ 视 dual_confirm 真稳后视情决定):
127// - `EnsembleEngine` 直接构造(避免 caller 自组 engines vec)
128// - `EngineAttribution`(P2.0 内部诊断,Sprint 3 P2.1 决议不引入 per_label_min_engines)
129// - `ModelDescriptor` trait(降模型变更频率风险)
130
131/// SDK consumer 配置 / 审计跨表 join 用的稳定 model_id 字符串常量(v0.8)。
132///
133/// 与 `vigil_redaction::model_descriptor::OpenAIPrivacyFilterDescriptor.model_id()`
134/// 等同源(若内部 ID 改,守门测试会捕捉)。
135pub const SDK_MODEL_ID_OPENAI_PRIVACY_FILTER_V1: &str = "openai-privacy-filter-v1";
136/// xlmr-pii-v1 stable model_id(35 BIO labels,multilang strong on address/account)。
137pub const SDK_MODEL_ID_XLMR_PII_V1: &str = "xlmr-pii-v1";
138/// yonigo-pii-v1 stable model_id(38 BIO labels,strong on email/phone)。
139pub const SDK_MODEL_ID_YONIGO_PII_V1: &str = "yonigo-pii-v1";
140
141/// **R1 MUST-FIX(Codex 019deb53)** — SDK 自拥有的 ensemble 工厂错误类型。
142///
143/// 直接 re-export `vigil-firewall` 工厂会让 vigil-firewall / vigil-redaction 的
144/// `EngineError` 签名变化级联破 SDK SemVer。**SDK-owned wrapper**:thin facade
145/// + 自有 `EngineFactoryError` enum,内部 wrap `vigil_redaction::engine::EngineError`,
146///   暴露 stable variant + Display(consumer 可 match 主路径,Other 兜底未来扩展)。
147///
148/// **SemVer 政策**:`#[non_exhaustive]` 强制 caller 写 `_` 通配,允许加 variant
149/// 不破 SemVer。Display 字符串可演进(MINOR);variant 名锁定(MAJOR 改)。
150#[cfg(feature = "ort")]
151#[derive(Debug)]
152#[non_exhaustive]
153pub enum EngineFactoryError {
154    /// 模型目录不存在(env 未设 / 路径错 / 文件未下载完整)
155    ModelNotFound {
156        /// 上下文(env var 名 + 路径,无 PII)
157        context: String,
158    },
159    /// ORT session / tokenizer 初始化失败(模型损坏 / ORT 版本不兼容)
160    SessionInit {
161        /// 失败原因(stable string,无 PII / 无原文)
162        reason: String,
163    },
164    /// 其他底层错误(留 SemVer 缓冲)
165    Other {
166        /// 失败原因(可能含 vigil-redaction EngineError 的 Display)
167        reason: String,
168    },
169}
170
171#[cfg(feature = "ort")]
172impl std::fmt::Display for EngineFactoryError {
173    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
174        match self {
175            Self::ModelNotFound { context } => write!(f, "model not found: {context}"),
176            Self::SessionInit { reason } => write!(f, "session init failed: {reason}"),
177            Self::Other { reason } => write!(f, "engine factory error: {reason}"),
178        }
179    }
180}
181
182#[cfg(feature = "ort")]
183impl std::error::Error for EngineFactoryError {}
184
185#[cfg(feature = "ort")]
186impl From<vigil_redaction::engine::EngineError> for EngineFactoryError {
187    fn from(e: vigil_redaction::engine::EngineError) -> Self {
188        use vigil_redaction::engine::EngineError;
189        match e {
190            EngineError::ModelNotFound { dir } => Self::ModelNotFound { context: dir },
191            EngineError::SessionInit(reason) | EngineError::TokenizerLoad(reason) => {
192                Self::SessionInit { reason }
193            }
194            // 其他 variant(InferRun / DecodeShape / Internal 等)兜底 Other。
195            // EngineError 是 non_exhaustive 的话此 _ 通配也 forward-compat。
196            other => Self::Other {
197                reason: format!("{other:?}"),
198            },
199        }
200    }
201}
202
203/// 企业 release runner 三引擎 ensemble 工厂入口(opt-in `ort` feature)。
204///
205/// **SDK-owned wrapper(R1 MUST-FIX,Codex 019deb53)**:thin facade 委托
206/// `vigil_firewall::ort_ensemble_scanner_arc_from_env`,但签名 pin 在 SDK 层
207/// (输入 `()` / 输出 `Result<Arc<dyn PiiScanner>, EngineFactoryError>`),
208/// vigil-firewall / vigil-redaction `EngineError` 签名变化不破 SDK SemVer。
209///
210/// 启动期 fail-fast:
211/// - `VIGIL_ENSEMBLE_OPENAI_DIR` / `VIGIL_ENSEMBLE_XLMR_DIR` / `VIGIL_ENSEMBLE_YONIGO_DIR`
212///   任一缺失 → [`EngineFactoryError::ModelNotFound`]
213/// - ORT init 失败 → [`EngineFactoryError::SessionInit`]
214/// - 三模型同时 init(eager;~17s cold,1.4-2.2GB RAM)
215///
216/// 适用场景:**企业 release runner**(EU recall 0.904 baseline 2026-05-03 P1.3 实测);
217/// **不**适用 default GUI / hub-cli — 推荐 `ort_scanner_arc_from_env` 单 OpenAI engine
218/// 路径(838MB RAM)。
219///
220/// # Errors
221/// 见 [`EngineFactoryError`] 三 variant + 未来 SemVer-friendly 扩展。
222#[cfg(feature = "ort")]
223pub fn ort_ensemble_scanner_arc_from_env(
224) -> Result<std::sync::Arc<dyn PiiScanner>, EngineFactoryError> {
225    vigil_firewall::ort_ensemble_scanner_arc_from_env().map_err(Into::into)
226}
227
228// ─────────────── v0.10 Sprint 1 F 续 — typed XlmrProfileMode SDK 暴露 ───────────────
229
230/// **v0.10 Sprint 1 F 续** — typed xlmr profile mode(替代裸 env)。
231///
232/// SDK consumer 用此 typed enum 替代 `VIGIL_XLMR_PROFILE` env 配置 xlmr 路径
233/// threshold profile;**reproducible / inspectable / 可进 DecisionRecord**。
234///
235/// 行为(详见 [`vigil_redaction::model_descriptor::XlmrProfileMode`] doc):
236/// - `Default`:v0.8 baseline(屏蔽 Email/Phone/Address;Person 不屏蔽,EU recall 0.904)
237/// - `FpStrict`:v0.9 P0 opt-in(Default + Person 1.1;EU recall 0.887,FP -22%,
238///   漏报 +45% — 仅企业 / 高 FP-strict 偏好场景)
239///
240/// **SemVer**:`#[non_exhaustive]` re-export from vigil-redaction;未来 vigil-redaction
241/// 加 variant(如 `RecallFirst`)透传到此(MINOR)。variant 名锁定(MAJOR 改)。
242pub use vigil_redaction::model_descriptor::XlmrProfileMode;
243
244/// **v0.10 Sprint 1 F 续** — typed xlmr profile mode 的 ensemble 工厂入口。
245///
246/// **与 [`ort_ensemble_scanner_arc_from_env`] 区别**:caller 显式传 typed
247/// [`XlmrProfileMode`],**忽略** `VIGIL_XLMR_PROFILE` env(SDK reproducible /
248/// inspectable;不依赖 env 漂移)。三 model dir env(`VIGIL_ENSEMBLE_OPENAI_DIR` /
249/// `_XLMR_DIR` / `_YONIGO_DIR`)仍读 — 这些是 ops 部署配置。
250///
251/// **SDK-owned wrapper**(对齐 v0.8 R1 教训):签名 pin SDK 层
252/// (`-> Result<Arc<dyn PiiScanner>, EngineFactoryError>`),底层 vigil-firewall /
253/// vigil-redaction `EngineError` 签名变化不破 SDK SemVer。
254///
255/// **典型用法**:
256/// ```ignore
257/// use vigil_sdk::{ort_ensemble_scanner_arc_with_xlmr_mode, XlmrProfileMode};
258/// // SDK consumer 不依赖 env,reproducible 选 default(等价 v0.8 baseline)
259/// let scanner = ort_ensemble_scanner_arc_with_xlmr_mode(XlmrProfileMode::Default)?;
260/// // 企业 / 高 FP-strict 模式
261/// let scanner = ort_ensemble_scanner_arc_with_xlmr_mode(XlmrProfileMode::FpStrict)?;
262/// ```
263///
264/// # Errors
265/// 见 [`EngineFactoryError`]:env unset(三 model dir 任一)/ 模型缺失 / ORT init 失败。
266#[cfg(feature = "ort")]
267pub fn ort_ensemble_scanner_arc_with_xlmr_mode(
268    mode: XlmrProfileMode,
269) -> Result<std::sync::Arc<dyn PiiScanner>, EngineFactoryError> {
270    vigil_firewall::ort_ensemble_scanner_arc_from_env_with_xlmr_mode(mode).map_err(Into::into)
271}
272
273// ─────────────── Prelude — default safe path ───────────────
274
275/// Prelude — 默认安全路径的常用导入。
276///
277/// `use vigil_sdk::prelude::*;` 即可获得 99% SDK consumer 需要的类型。
278///
279/// **不含**:`scan_text_with_engine`(高级 — 需自构 [`RedactionEngine`])、
280/// `RedactionEngine` trait(扩展点,大多 consumer 用 default `scan_text`)、
281/// `descriptor_hash`(audit 关联,advanced)。这些仍可显式从 `vigil_sdk::*`
282/// 直接 import。
283pub mod prelude {
284    pub use crate::{
285        scan_text, ApprovalRequest, ApprovalResolution, ApprovalScope, ApprovalStatus, AuditEvent,
286        DecisionKind, DecisionRecord, EffectKind, EffectVector, Finding, FindingSource, Firewall,
287        FirewallBuildError, FirewallBuilder, FirewallConfig, FirewallError, FirewallOutcome,
288        OAuthScopeContext, PiiScanner, PrivacyLabel, RedactionResult, RiskSignals, ScanError,
289        SdkFirewall, ToolInvocation,
290    };
291}
292
293// ─────────────── SDK contract doc-tests ───────────────
294
295/// 验证 SDK pub items 跨 crate 边界稳定可见(编译期 + doc-test 守门)。
296///
297/// ```
298/// // 1. 类型可 import
299/// use vigil_sdk::{Finding, FindingSource, PrivacyLabel};
300/// use vigil_sdk::prelude::*;
301///
302/// // 2. scan_text 高层 API:secret 类(github_token)走 Hard rule(默认路径)
303/// let r: RedactionResult = vigil_sdk::scan_text(
304///     "ghp_0123456789abcdefghijklmnopqrstuvwxyz12"
305/// ).unwrap();
306/// assert!(!r.findings.is_empty(), "github_token Hard rule 应命中");
307///
308/// // 3. PrivacyLabel enum 完整:8 类
309/// let _all = PrivacyLabel::ALL;
310///
311/// // 4. FindingSource 区分 Hard / Model 来源
312/// let _hard = FindingSource::Hard;
313/// ```
314#[doc(hidden)]
315pub fn __sdk_contract_visible() {}
316
317/// v0.8 Sprint 4 P3.1 — ensemble 浅级暴露 doc-test 守门。
318///
319/// ```
320/// // 1. model_id 常量可 import + 字符串语义稳定
321/// use vigil_sdk::{
322///     SDK_MODEL_ID_OPENAI_PRIVACY_FILTER_V1,
323///     SDK_MODEL_ID_XLMR_PII_V1,
324///     SDK_MODEL_ID_YONIGO_PII_V1,
325/// };
326///
327/// assert_eq!(SDK_MODEL_ID_OPENAI_PRIVACY_FILTER_V1, "openai-privacy-filter-v1");
328/// assert_eq!(SDK_MODEL_ID_XLMR_PII_V1, "xlmr-pii-v1");
329/// assert_eq!(SDK_MODEL_ID_YONIGO_PII_V1, "yonigo-pii-v1");
330///
331/// // 2. 三常量字符串 distinct(防漂移到同一字符串)
332/// let ids = [
333///     SDK_MODEL_ID_OPENAI_PRIVACY_FILTER_V1,
334///     SDK_MODEL_ID_XLMR_PII_V1,
335///     SDK_MODEL_ID_YONIGO_PII_V1,
336/// ];
337/// let unique: std::collections::HashSet<_> = ids.iter().collect();
338/// assert_eq!(unique.len(), 3, "三 model_id 必须 distinct");
339/// ```
340#[doc(hidden)]
341pub fn __sdk_ensemble_v0_8_visible() {}
342
343/// v0.7-α2 Phase 2D — budget-aware scan + EngineStatus 类型可见性守门
344/// (ADR 0016 Fail-Closed Bottom Line)。
345///
346/// ```
347/// use vigil_sdk::{BudgetedScanOutcome, EngineStatus};
348///
349/// // EngineStatus 三 variant 完整(Ok / DegradedTimeout / DegradedError)
350/// let _ok = EngineStatus::Ok;
351/// let _to = EngineStatus::DegradedTimeout;
352/// let _er = EngineStatus::DegradedError;
353///
354/// // 类型可 import,签名稳定 — 函数实际用法见 vigil-redaction crate doc
355/// let _budgeted_fn = vigil_sdk::scan_text_with_engine_budgeted;
356/// // BudgetedScanOutcome 结构可见(构造由 SDK 内部完成)
357/// fn _accept_outcome(_o: BudgetedScanOutcome) {}
358/// ```
359#[doc(hidden)]
360pub fn __sdk_budgeted_visible() {}
361
362#[cfg(test)]
363mod tests {
364    #![allow(clippy::unwrap_used, clippy::expect_used)]
365
366    use super::*;
367
368    /// SDK pub items 必须能跨 crate 边界 import 且在编译期可见。
369    /// 这个守门测试编译过即守住"SDK re-export 完整性"不变量。
370    #[test]
371    fn sdk_pub_items_compile_visible() {
372        // 类型可 import + 实例化(编译期 + 运行期 sanity)
373        let _: Option<DecisionKind> = None;
374        let _: Option<EffectKind> = None;
375        let _: Option<ApprovalStatus> = None;
376        let _: Option<FindingSource> = Some(FindingSource::Hard);
377        let _: Option<PrivacyLabel> = Some(PrivacyLabel::Email);
378    }
379
380    /// scan_text 默认路径:无 engine 注入 = NoopEngine(只跑 Hard 规则)。
381    /// Hard 规则当前主要覆盖 secret 类(github_token / openai_secret / etc),
382    /// soft 标签(email/phone/person/etc)依赖可选 ort engine model。
383    /// 这是 SDK consumer 的 default-safe path 守门。
384    #[test]
385    fn sdk_scan_text_default_path() {
386        // secret 类 Hard rule 命中 — github personal access token
387        let secret = "ghp_0123456789abcdefghijklmnopqrstuvwxyz12";
388        let r = scan_text(secret).expect("scan_text 成功");
389        assert!(
390            r.findings.iter().any(|f| f.kind == "github_token"),
391            "Hard rule 应命中 github_token,findings: {:?}",
392            r.findings
393        );
394    }
395
396    /// scan_text 对 clean 文本不应误报 — 默认路径不会因为没 model engine 而崩。
397    #[test]
398    fn sdk_scan_text_clean_text_no_secret_findings() {
399        let r = scan_text("This is a perfectly normal sentence.").expect("scan_text 成功");
400        // 默认路径 0 model 推理,clean 文本不应有 secret 类 finding(soft 依赖 ort)
401        assert!(
402            !r.findings.iter().any(|f| f.kind == "github_token"
403                || f.kind == "openai_secret_key"
404                || f.kind == "stripe_secret_key"),
405            "clean 文本不应触发任何 secret-类 hard rule"
406        );
407    }
408
409    /// PrivacyLabel ALL 完整(防止 SDK 暴露的 enum 添加新 variant 时遗漏)。
410    /// 当前 8 类:secret/account_number/email/phone/person/address/date/url。
411    #[test]
412    fn sdk_privacy_label_all_count() {
413        // 这个 assert 在 PrivacyLabel 添加新 variant 时会 fail,触发 SDK 边界
414        // review(SDK consumer 可能依赖 ALL 长度做 exhaustive 处理)
415        assert_eq!(
416            PrivacyLabel::ALL.len(),
417            8,
418            "PrivacyLabel ALL 长度变化是 SDK breaking change,需 ADR 决策"
419        );
420    }
421
422    /// v0.8 Sprint 4 P3.1 — model_id 常量与 vigil-redaction descriptor 同源守门。
423    ///
424    /// SDK 暴露三 model_id 字符串常量,**必须**等于
425    /// `OpenAIPrivacyFilterDescriptor / XlmrPiiDescriptor / YonigoPiiDescriptor`
426    /// 内部 `model_id()` 返值。任一漂移即 SDK 与 backend 解耦,SDK consumer 用
427    /// 常量 join audit ledger 会查不到。守门在此精确等值断言。
428    #[test]
429    fn sdk_model_id_constants_match_descriptor_source() {
430        use vigil_redaction::model_descriptor::{
431            ModelDescriptor, OpenAIPrivacyFilterDescriptor, XlmrPiiDescriptor, YonigoPiiDescriptor,
432        };
433        assert_eq!(
434            SDK_MODEL_ID_OPENAI_PRIVACY_FILTER_V1,
435            OpenAIPrivacyFilterDescriptor.model_id(),
436            "SDK_MODEL_ID_OPENAI_PRIVACY_FILTER_V1 必须与 OpenAIPrivacyFilterDescriptor.model_id() 同源"
437        );
438        assert_eq!(
439            SDK_MODEL_ID_XLMR_PII_V1,
440            XlmrPiiDescriptor::default().model_id(),
441            "SDK_MODEL_ID_XLMR_PII_V1 必须与 XlmrPiiDescriptor.model_id() 同源"
442        );
443        assert_eq!(
444            SDK_MODEL_ID_YONIGO_PII_V1,
445            YonigoPiiDescriptor.model_id(),
446            "SDK_MODEL_ID_YONIGO_PII_V1 必须与 YonigoPiiDescriptor.model_id() 同源"
447        );
448    }
449
450    /// v0.8 Sprint 4 P3.1 — `ort_ensemble_scanner_arc_from_env` 工厂入口可见性守门
451    /// (`ort` feature 启用时)。
452    ///
453    /// 不实际跑工厂(需要 VIGIL_ENSEMBLE_*_DIR + 真模型);只验函数签名 import
454    /// 跨 SDK 边界,等同 doc-test 编译期守门。
455    #[cfg(feature = "ort")]
456    #[test]
457    fn sdk_ort_ensemble_factory_visible_with_feature() {
458        let _factory: fn() -> Result<std::sync::Arc<dyn PiiScanner>, super::EngineFactoryError> =
459            super::ort_ensemble_scanner_arc_from_env;
460    }
461
462    /// **v0.10 Sprint 6** — advisory lang detect re-export 守门。
463    /// 关键不变量:detect 返 Heuristic source,lang_str 永返 None(D=C 锁定)。
464    #[test]
465    fn sdk_detect_lang_heuristic_advisory_only() {
466        // CJK 高 confidence 但仍不可信任(Heuristic source)
467        let h = super::detect_lang_heuristic("田中太郎さんが昨日来ました");
468        assert_eq!(h.lang, "ja");
469        assert_eq!(h.source, super::LangHintSource::Heuristic);
470        assert!(h.confidence >= 0.85);
471        assert_eq!(
472            h.lang_str(),
473            None,
474            "advisory detect 必须返 None(防 production 决策 — D=C / feedback_lang_review_authoritative)"
475        );
476
477        // 短文本无关键词 → en 低 confidence
478        let h_en = super::detect_lang_heuristic("John Smith works here.");
479        assert_eq!(h_en.lang, "en");
480        assert!(h_en.confidence < vigil_redaction::LANG_HINT_TRUSTED_CONFIDENCE);
481    }
482
483    /// **v0.10 Sprint 2** — LanguageHint typed wrapper re-export 守门
484    /// (Decision A-prime;SDK 友好 + fail-closed 决策)。
485    #[test]
486    fn sdk_language_hint_typed_wrapper_visible() {
487        // 1. typed wrapper 工厂方法
488        let h_caller = super::LanguageHint::caller_provided("de");
489        assert_eq!(h_caller.source, super::LangHintSource::CallerProvided);
490        assert_eq!(h_caller.lang_str(), Some("de"));
491
492        let h_fixture = super::LanguageHint::fixture("it");
493        assert_eq!(h_fixture.source, super::LangHintSource::FixtureExperimental);
494
495        // **关键不变量**:Heuristic source 即使 confidence=1.0 也返 None
496        // (D=C 锁定下的 SDK 边界 — heuristic 不可作 production 决策权威)
497        let h_heuristic = super::LanguageHint::heuristic("de", 1.0);
498        assert_eq!(
499            h_heuristic.lang_str(),
500            None,
501            "Heuristic source 必须返 None(feedback_lang_review_authoritative 约束)"
502        );
503
504        // 2. scan_text_with_engine_with_hint pub 可见
505        let _scan: fn(
506            &str,
507            &dyn super::RedactionEngine,
508            Option<&super::LanguageHint>,
509        ) -> Result<super::RedactionResult, super::ScanError> =
510            super::scan_text_with_engine_with_hint;
511
512        // 3. non_exhaustive enum SemVer 文档化
513        let _label = match super::LangHintSource::CallerProvided {
514            super::LangHintSource::CallerProvided => "caller",
515            super::LangHintSource::FixtureExperimental => "fixture",
516            super::LangHintSource::Heuristic => "heuristic",
517            _ => "unknown_future",
518        };
519    }
520
521    /// **v0.10 Sprint 1 F 续** — XlmrProfileMode re-export + typed 工厂入口可见性守门。
522    /// 编译期类型 + variant 完整性检查;运行期不跑(需 VIGIL_ENSEMBLE_*_DIR + 真模型)。
523    #[cfg(feature = "ort")]
524    #[test]
525    fn sdk_ort_ensemble_with_xlmr_mode_factory_visible() {
526        // 1. XlmrProfileMode re-export(2 variants 完整)
527        let _default = super::XlmrProfileMode::Default;
528        let _strict = super::XlmrProfileMode::FpStrict;
529
530        // 2. 工厂入口签名 pin SDK 层
531        let _factory: fn(
532            super::XlmrProfileMode,
533        )
534            -> Result<std::sync::Arc<dyn PiiScanner>, super::EngineFactoryError> =
535            super::ort_ensemble_scanner_arc_with_xlmr_mode;
536
537        // 3. non_exhaustive 强制 _ 通配(SemVer 文档化)
538        let _label = match super::XlmrProfileMode::Default {
539            super::XlmrProfileMode::Default => "default",
540            super::XlmrProfileMode::FpStrict => "fp_strict",
541            _ => "unknown_future",
542        };
543    }
544
545    /// v0.8 Sprint 4 R1(Codex 019deb53)— EngineFactoryError 类型守门:
546    /// SDK consumer 必须能 match 主 variant + Other 兜底(non_exhaustive)。
547    #[cfg(feature = "ort")]
548    #[test]
549    fn sdk_engine_factory_error_variants_matchable() {
550        let e = super::EngineFactoryError::ModelNotFound {
551            context: "test".into(),
552        };
553        let s = format!("{e}");
554        assert!(s.contains("model not found"));
555
556        // crate 内部穷举所有 variant(EngineFactoryError 在本 crate 同 module);
557        // 加新 variant 时 compiler force update 本 test。**外部 SDK consumer** 因
558        // #[non_exhaustive] 必须写 `_` 兜底 — 这条不变量由 sdk_engine_factory_error_*
559        // 类守门测试覆盖,本 test 验内部使用契约。
560        match e {
561            super::EngineFactoryError::ModelNotFound { .. } => {}
562            super::EngineFactoryError::SessionInit { .. } => {}
563            super::EngineFactoryError::Other { .. } => {}
564        }
565    }
566
567    /// v0.8 Sprint 4 R1 — EngineStatusReport re-export 守门(Codex MUST-FIX 3)。
568    /// guide §4.2 列 EngineStatusReport 是 v0.8 SDK pub item;此测试断言真可见。
569    #[test]
570    fn sdk_engine_status_report_pub_re_exported() {
571        let _ok = super::EngineStatusReport::Ok;
572        let _to = super::EngineStatusReport::DegradedTimeout;
573        let _err = super::EngineStatusReport::DegradedError;
574        let _un = super::EngineStatusReport::Unsupported;
575    }
576}