OpenMed 嵌套脱敏幂等性校验实战:用 `openmed.risk.idempotence` 做无值泄漏的双次对比审查
2026/9/19 21:23:49 网站建设 项目流程

OpenMed 嵌套脱敏幂等性校验实战:用openmed.risk.idempotence做无值泄漏的双次对比审查

【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed

脱敏流水线(de-identification pipeline)的重复执行是否稳定产出相同结果,是隐私审查中最基础也最容易出问题的一环。OpenMed 在openmed.risk.idempotence中提供了一个确定性、本地运行的嵌套结构化脱敏结果幂等性检查器check_idempotence,它把两次已产出的脱敏结果(FHIR 资源、OMOP 表格或任意嵌套 JSON)压缩成"无原始值"的证据快照并逐维度对比。读完本文,你将掌握它的输入契约、五维对比模型、输入边界与拒绝策略,以及如何在零网络调用、零敏感值回显的前提下把它接进自己的审查或门禁流程。

幂等性检查是什么(以及它不是什么)

openmed.risk.idempotence解决的是一个非常具体的问题:判断一份结构化的脱敏结果在被第二次处理时是否保持稳定。它比较的是"已经生产出来的两次结果",而不是重新对原文做一次脱敏——检查器本身不做脱敏、不推断临床语义、也不调用任何远程服务(模块 docstring 明确写着 "It does not redact input, infer clinical meaning, or contact a service")。

文档同时给出了严格的边界声明,这些定位决定了它适合被怎样使用:

  • 它是一个确定性、本地的审查辅助工具(a deterministic, local review aid);
  • 不是FHIR 或 OMOP 的一致性校验(conformance check);
  • 不是合规认证(compliance certification);
  • 不是临床决策保证(clinical decision guarantee)。

换句话说,幂等通过只说明"两次产出的证据一致",不说明"产出本身临床正确或合规"。同样的精神也体现在openmed/risk/redaction_diff.py中:该模块同样坚持 "value-free"(无值)原则,只对比聚合 action/category/count 数据并记录 policy 指纹,让审查者理解"什么变了"而不接触源值或替换值。

输入契约:三次检查接受的四种"一轮脱敏结果"

check_idempotence的两个参数类型都是IdempotenceInput(定义见 idempotence.py),即以下四种之一:

输入形式说明判定依据
嵌套 JSON 映射(Mapping最常见的 Pythondict,内含资源与报告_coerce_mapping
本地 JSON 文件(strPath传入文件路径,自动读取并解析_read_json_path
列表/元组(Sequence被当作纯资源树处理_coerce_pass
结果对象暴露resource/data/output等属性,及report/audit_report/metadata属性;或可调用to_dict()的对象StructuredRedactionResult协议

对于带报告的对象,报告元数据里可以携带三类信息(源码_snapshot逐一提取):

  • 聚合 counts:如redactedredacted_countspan_countremovedreplacedtotal等;来源可放在counts/count_summary/totals节,也可以作为顶层键直接出现(完整白名单见_COUNT_FIELDS);
  • 策略指纹policy_fingerprint/policy_hash字段,或policy/policy_name/policy_profile值(后者会被哈希化处理);
  • 脱敏事件redactions/redaction_events/events/actions/spans等容器中的事件条目,每条事件包含pathactionsurrogate(或surrogate_fingerprint/replacement等别名)。

一个值得注意的实现细节(_coerce_mapping与测试test_bare_fhir_resource_fields_are_not_wrapper_aliases):FHIR 资源自身的字段不会被误判为包装别名。比如{"resourceType": "Binary", "data": ...}中的data是资源字段,{"resourceType": "DiagnosticReport", "result": []}中的result也是资源字段——resourceType判别符优先于包装键别名。同时,如果同一层同时出现resourcedata两个包装键({"resource": {...}, "data": {...}}),会被判定为"歧义资源别名"并拒绝(见test_ambiguous_resource_and_metadata_aliases_are_rejected)。

快速上手:对比两个相同结果

文档给出的最小示例直接可用:

from openmed.risk import check_idempotence first = { "resource": { "resourceType": "Bundle", "entry": [{"resource": {"resourceType": "Patient", "id": "synthetic-a"}}], }, "report": { "policy_fingerprint": "sha256:" + "a" * 64, "counts": {"redacted": 1}, "redactions": [ { "path": "entry[0].resource.id", "action": "replace", "surrogate": "[SYNTHETIC-ID]", } ], }, } second = first # The second pass has the same synthetic evidence. result = check_idempotence(first, second) assert result.is_idempotent

两个参数都可以是嵌套映射、本地 JSON 文件路径,或带resource/data/output属性与报告的结果对象。报告里的countspolicy_fingerprint和脱敏事件(redactions)会被自动抽取为证据;即使两次传入的是裸资源(没有报告),检查器仍会执行确定性的形状(shape)对比。

结果对象的等价写法

测试文件 tests/unit/risk/test_idempotence.py 展示了带属性的 dataclass 输入方式——只要对象有resource/report属性,或者可to_dict(),就能直接传入:

from dataclasses import dataclass @dataclass(frozen=True) class _ResultObject: resource: object report: object report = check_idempotence( _ResultObject(resource, report_data), _ResultObject(resource, report_data), ) assert report.passed is True

注意report.passedis_idempotent的别名(源码 idempotence.py),专门为门禁风格(gate-style)的调用方设计。

五维对比模型:每次检查到底比什么

_compare_snapshots_compare_scalar_values共同实现了一个结构化的多维 diff,每一维对应ChangeDimension类型中的一个值(见 idempotence.py):

维度含义判定依据
shape嵌套输出形状每个路径节点的(kind, keys, length)签名(ShapeNode.signature
count聚合计数counts各键的值(_compare_count_maps
action动作及动作计数事件级actionaction_counts映射、事件增删
surrogate替代值指纹事件中surrogate_fingerprint的比对,以及"无事件元数据时的标量值对比"
policy_fingerprint策略指纹全局与事件级策略指纹

每个 diff 都会被归类为added/removed/changed之一,并按(维度顺序, 路径, 分类, before, after)稳定排序去重,保证输出可复现。

标量变化:没有事件元数据也能抓到

这是该实现最有价值的行为之一(源码_compare_scalar_values,测试test_scalar_change_without_event_metadata_is_not_idempotent):即使两次结果都不含任何脱敏事件报告,只要资源树中同一路径上的标量值(如patient_name)发生了改变,检查器就会在surrogate维度上报一条差异——而且不会把源值写进任何输出

report = check_idempotence( {"resource": {"patient_name": "synthetic-private-first-value"}}, {"resource": {"patient_name": "synthetic-private-second-value"}}, ) assert report.is_idempotent is False assert report.surrogates_match is False # 序列化产物中绝不出现源值本身 assert "synthetic-private-first-value" not in report.to_json() assert "patient_name" not in report.to_json()

读取报告结果

IdempotenceReport(idempotence.py)是一组 frozen dataclass,提供以下属性与方法:

result.is_idempotent # 所有维度均无差异 result.passed # is_idempotent 的别名 result.shape_match # 形状是否一致 result.counts_match # 聚合计数是否一致 result.actions_match # 动作与动作计数是否一致 result.surrogates_match # 替代值指纹是否一致 result.policy_fingerprint_match # 策略指纹是否一致 result.non_idempotent_paths # 发生非幂等变化的排序路径元组 result.summary # 聚合状态字典(含 changes_by_dimension、total_changes) result.differences # 结构化差异元组(IdempotenceDifference) result.to_dict() # 确定性 JSON 兼容字典 result.to_json(indent=None) # 稳定序列化(sort_keys=True, ensure_ascii=True) result.to_markdown() # 紧凑的 Markdown 审查摘要(含维度变化表与非幂等路径列表)

non_idempotent_paths返回排序后的路径集合,例如 OMOP 场景下会得到$.tables.person[0].person_id这样的 JSONPath 风格路径。测试test_omop_surrogate_change_is_classified_without_echoing_values验证了关键隐私属性:当两次替代值不同(synthetic-subject-surrogate-avs-b)时,报告判定surrogates_match is False,路径被准确报告,但两次的替代值字符串都不会出现在序列化结果中。

有界输入:检查器拒绝什么

check_idempotence对外层异常做了收口(源码 idempotence.py):任何非预期异常都会被转换为IdempotenceInputError,错误消息是封闭的(closed message),不包含输入值,也不透传自定义容器抛出的异常文本(测试test_cycles_depth_and_hostile_mappings_fail_with_sanitized_errors_ExplodingMapping验证了 "synthetic-sensitive-exception" 不会泄漏到错误消息里)。

被拒绝的输入类别包括:

  • 循环值(cyclic values):资源树中出现自引用;
  • 歧义的包装/元数据别名(ambiguous wrapper or metadata aliases):如同层同时存在多个资源包装键,或事件里同时出现actionoperation两个动作字段;
  • 重复的 JSON 对象键(duplicate keys):通过object_pairs_hook在解析时即拒绝(测试用{"id": "a", "id": "b"}验证);
  • 非有限数值(non-finite numbers):NaN/Infinity,包括文件中的NaN字面量(parse_constant=_reject_json_constant);
  • 不支持的标量类型:非 JSON 兼容的值(bytes、自定义对象等)。

固定的资源上限

检查器对输入施加硬性限制(常量定义见 idempotence.py),超过即抛IdempotenceInputError

限制项上限
文件大小(_MAX_FILE_BYTES16 MiB
总节点数(_MAX_TOTAL_NODES100,000
容器条目数(_MAX_CONTAINER_ITEMS20,000
嵌套深度(_MAX_DEPTH64 层
文本长度(_MAX_TEXT_CHARS1,000,000 字符
键长度(_MAX_KEY_CHARS512 字符
路径长度(_MAX_PATH_CHARS4,096 字符
脱敏事件数(_MAX_EVENTS4,096
元数据来源数(_MAX_METADATA_SOURCES256
计数值(_MAX_COUNT2^63 − 1
整数位宽(_MAX_INT_BITS4,096 bit

这些上限共同保证:即使面对深度嵌套、超大或恶意的输入,检查器也只会产生确定性的拒绝结果,而不会耗尽内存或无限递归(测试用 70 层嵌套验证了深度限制)。

隐私属性:值不出现在任何输出通道

这是该模块的设计核心,文档与实现完全一致。RedactionPassSummaryIdempotenceReport中只允许出现:

  • 模式路径(schema paths,如$.entry[0].resource.id);
  • 标量类型(kind:object / array / string / number / boolean / null);
  • 数组长度;
  • 计数(counts);
  • 安全动作名(safe action names);
  • SHA-256 指纹。

源值、替换值、未知的模式键、未知的动作名、未知的策略名绝不会被复制进 JSON、Markdown、repr或异常中(模块 docstring 与ShapeNode.to_dictRedactionEvent.to_dict的 docstring 反复强调 "without exposing its scalar value" / "raw-value-free")。

未知标识符的统一处理方式是指纹化,命名规则从源码可以清楚看到:

  • 未知 schema 键 →key:+ sha256 摘要(_safe_key,如$.key:3f7a...);
  • 未知动作 →action:+ sha256 摘要(_safe_action,白名单见_SAFE_ACTIONS);
  • 未知计数值 →count:+ sha256 摘要(_safe_count_key);
  • 无法解析的路径 →path:+ sha256 摘要(_render_path_value)。

动作白名单共有 9 个安全值(idempotence.py):dropformat_preservehashkeepmasknullredactremovereplace。其中keep/redact/replace/mask/hash/format_preserve与 OpenMed 的核心 span 动作常量ACTION_VALUES一致,额外的drop/null/remove则用于兼容更多脱敏产物的报告格式。指纹格式统一为sha256:hmac-sha256:前缀加 64 位十六进制(_DIGEST_RE)。

两点必须明确的语义边界:

  1. 替代值指纹只是"相等性证据"(equality evidence),不是加密匿名化(cryptographic anonymization)的声明——指纹相等说明两次产出相同,但不代表该替代值本身具备不可逆或匿名强度;
  2. 检查器只接受内存对象或本地 JSON 文件,且不做任何强制网络调用——check_idempotence的 docstring 明确 "performs no network access"。

测试test_unknown_action_and_policy_metadata_are_fingerprinted展示了端到端效果:报告里policysynthetic-private-policy-name、动作是synthetic-private-action时,is_idempotent仍为True(因为指纹一致),但序列化结果中不包含这三个私密字符串中的任何一个。

在 OpenMed 中的使用场景与边界

FHIR / OMOP 形状的脱敏产物对比

仓库的测试夹具(tests/unit/risk/test_idempotence.py)给出了两类典型输入的模板:

  • FHIR 形状resourceBundleentryPatientreportpolicy_fingerprintcountsactions
  • OMOP 形状data.tables下为person/visit_occurrence表,reportredactions事件列表。

这两类夹具必须保持合成且离线(synthetic and offline)——文档与实现都不建议把真实患者数据(哪怕是脱敏后的)直接灌入检查器做长期快照比对,因为检查器的设计前提就是"输入本身可能是敏感的,但输出必须无值"。

与 redaction-diff 的配合

如果你只需要对聚合摘要(action/category/count)做无值 diff,而不是对嵌套资源树逐路径比对,可以参考姊妹模块 redaction-diff 及其实现 openmed/risk/redaction_diff.py:它接受摘要而非文档/span,对比维度更聚焦,并同样把未知的 action/category/count 键指纹化为action:sha256:...形式。两个模块共享同一设计哲学:审查者只拿到形状、计数与指纹,拿不到任何敏感值

接入方式与限制

典型接入方式是把check_idempotence放进离线审查脚本或发布门禁:

from openmed.risk import check_idempotence, IdempotenceInputError try: result = check_idempotence("first_pass.json", "second_pass.json") except IdempotenceInputError as exc: # 封闭错误消息,不包含输入值 print("input rejected:", exc) else: if not result.is_idempotent: print(result.to_markdown()) print("changed paths:", result.non_idempotent_paths)

API 还提供了两个兼容别名,方便不同命名习惯的调用方:check_redaction_idempotencecompare_structured_redaction(均指向同一实现,见 openmed/risk/init.py 的导出与 idempotence.py)。

最后重申文档给出的两条硬边界:该检查器不验证临床语义(clinical semantics),也不能替代正式隐私审查(formal privacy review)。幂等性通过只说明流程稳定,合规性、匿名强度与临床正确性仍需要由对应的专业流程负责。

小结

openmed.risk.idempotence用一个约 1900 行的纯标准库实现,回答了脱敏审查中一个高频问题:"同一份结果再跑一遍,会不会变?" 它把这个问题拆解成 shape、count、action、surrogate、policy_fingerprint 五个可独立判定的维度,通过有界输入校验、封闭错误消息、未知值指纹化与全输出通道的无值原则,保证审查过程本身不引入新的数据暴露。无论是 FHIR Bundle 还是 OMOP 表格形状的产物,只要数据保持合成、检查保持本地,check_idempotence都可以作为确定性审查与发布门禁中一个可靠、可解释、可复现的环节。

【免费下载链接】openmedLocal-first healthcare AI: clinical NER & HIPAA PII de-identification that runs 100% on-device. 2,200+ medical models, 21 languages, Apple MLX + Python, no cloud, no patient data leaving your network. Apache-2.0项目地址: https://gitcode.com/GitHub_Trending/ope/openmed

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询