OpenMed 跨运行时实体 Span 偏移契约:Python 与 Swift 共享的 Unicode 标量坐标体系
【免费下载链接】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
本文基于 OpenMed 仓库中的 OFFSET_CONTRACT.md 展开,深入讲解该项目在 Python 与 Swift 两套运行时之间交换实体 span(实体片段)时遵循的"半开区间 Unicode 标量(code point)偏移"契约:为什么禁止使用 UTF-8 字节偏移与 UTF-16 码元偏移、非空 span 如何向外吸附到字素簇(grapheme cluster)边界、越界偏移如何先钳制再吸附,以及多 span 替换时必须"从最高起始偏移往最低"应用的顺序规则。读完本文,你将能够读懂 tests/fixtures/parity/offset_contract.json 共享夹具的每条测试用例,并对照 Python 实现、Swift 实现 和 pytest/XCTest 双侧一致性测试,理解该契约是如何被可执行地验证的。
1. 为什么需要一份跨运行时偏移契约
OpenMed 的定位是"本地优先"的医疗文本处理:临床 NER 与 HIPAA PII 脱敏完全在设备端运行,覆盖多语言(含中文、印度语系 9 种文字、阿拉伯文等)。同一批脱敏逻辑同时存在两种(甚至三种)实现:
- Python 侧:MLX 隐私过滤管线以及 PyTorch wrapper,解码工具集中在 openmed/core/decoding/ 目录;
- Swift 侧:iOS/macOS 上的 OpenMedKit 框架;
- Kotlin 侧:Android 的 openmedkit 同样消费同一份共享夹具(见 OffsetContractParityTest.kt)。
模型解码出的实体 span 需要跨进程、跨语言、跨线程传递,最终落到"对原文做切片、打码、替换"这一步。问题在于:同一段文字,在不同字符串模型下的"位置编号"完全不同。例如 Devanagari 连音क्षि是 1 个用户可见字符、4 个 Unicode 标量、15 个 UTF-8 字节,而在 SwiftString的 UTF-16 存储中又是另一个数字。如果 Python 用字节偏移、Swift 用码元偏移、Kotlin 用String.indices,三方对"第 4 个位置"的理解必然错位,导致脱敏边界劈进半个连音符号——轻则打码结果错误,重则残留部分 PHI(受保护健康信息)或破坏原始文本。
为此,OFFSET_CONTRACT.md 明确定义了一份所有运行时必须共同遵守的坐标契约,并用一份可执行夹具在三个平台做一致性测试。契约原文的表述是:
OpenMed exchanges entity spans between Python and Swift as half-open
[start, end)Unicode scalar (code point) offsets into the exact, unnormalized source string.
即:所有实体 span 都是针对"精确、未做归一化处理的原始源字符串"的半开区间[start, end)Unicode 标量(码点)偏移。
2. 契约的六条核心规则
契约文档用六条要点定义了全部约束,逐条展开如下。
2.1 半开区间[start, end),单位是 Unicode 标量
span 一律是半开区间:start含边界,end不含。坐标的单位是Unicode scalar(码点),直接索引"精确、未归一化"的源字符串。"不预先归一化"这一点很关键——契约不允许任何一方偷偷对原文做 NFC/NFD 变换后再用变换后的坐标去操作原文,否则会引入不可逆的坐标漂移(spans.py 的 CJK 偏移映射类CjkOffsetMap甚至在构造时显式要求文本必须已是 NFC,并抛异常拒绝未归一化输入,见 spans.py)。
2.2 各语言如何使用同一坐标
- Python 使用原生字符串索引。CPython 中
str[i]取的就是码点,因此 Python 的偏移可以直接等于契约中的 Unicode 标量偏移,无需任何换算。 - Swift 使用
String.UnicodeScalarView索引,并且必须经由PostProcessing的类型转换工具完成换算——不能直接拿String.Index或 UTF-16 位置当坐标用。这一点在 PostProcessing.swift 中有对应实现(见第 4 节)。 - 契约文档特别强调,Android/Kotlin 等其它运行时也必须映射回同一标量坐标,共享夹具即为共同判据。
2.3 UTF-8 字节偏移与 UTF-16 码元偏移永远不被接受
原文:
UTF-8 byte offsets and UTF-16 code-unit offsets are never accepted as entity coordinates.
这条禁令是整个契约的"负面清单"。字节偏移在遇到多字节字符时会系统性偏移(如𠀀占 4 字节、1 码点);UTF-16 码元偏移在补充平面字符(如 CJK Extension B 的生僻汉字)上每字符多占一个代理对。共享夹具里专门放置了cjk-extension-b-*用例(如"患者𠀀今日复诊",𠀀属 U+20000 区段),就是为了钉死这一点:即便在补充平面字符两侧,标量坐标仍然正确。
2.4 非空 span 向外扩展到字素簇边界
A non-empty span that starts or ends inside an extended grapheme cluster is expanded outward to the cluster boundaries.
若一个非空 span 的任一边界落在扩展字素簇(UAX #29 extended grapheme cluster)内部,则向外扩展到簇边界。这里的"向外"对start是向低偏移方向、对end是向高偏移方向。覆盖的场景包括:组合附加符(如e+ U+0301 构成的é)、连音符号序列、emoji ZWJ 序列(如 👩⚕️)、区域指示符对(如国旗 🇮🇳)以及其它所有扩展字素簇。
2.5 空 span 保持为空,并移动到前一个字素边界
An empty span remains empty and moves to the preceding grapheme boundary.
start == end的 span 不会被"扩展出内容",而是整体平移到所在位置的前一个簇边界。这保证空 span(例如某些解码器输出的"零宽实体")始终落在可预测的坐标上,且同样不劈开任何字素簇。
2.6 越界偏移先钳制、后吸附
Out-of-range decoder offsets are clamped to the source before snapping.
解码器输出完全可能越界(例如 token 级偏移映射后超出文本长度)。处理顺序是固定的两步:先把start、end钳制(clamp)到[0, len(text)]内,再执行字素边界吸附。这个顺序在两侧实现中完全一致,下文源码会逐一印证。
2.7 文档给出的标准示例:Devanagariक्षि
契约文档的核心示例:
the Devanagari cluster
क्षिcontains four Unicode scalars but one user-perceived character. A model span covering scalar offsets[1, 3)is therefore emitted as[0, 4).
क्षि由 4 个标量组成(क、्(virama)、ष、ि(vowel sign i)),但用户感知上是 1 个字符(一个完整的 aksara)。若模型给出覆盖中间标量的 span[1, 3),最终输出的必须是覆盖整个簇的[0, 4)。同一条规则适用于组合附加符、joiner 序列、emoji ZWJ 序列和所有其它扩展字素簇。共享夹具中对应的可执行用例是indic-deva-01:
{ "id": "indic-deva-01", "category": "indic", "script": "Deva", "text": "ID:क्षि!", "input_start": 4, "input_end": 6, "expected_start": 3, "expected_end": 7, "replacement": "[NAME]", "expected_redacted": "ID:[NAME]!" }文本"ID:क्षि!"的标量坐标为:I=0, D=1, :=2, क=3, ्=4, ष=5, ि=6, !=7。模型输入 span[4, 6)恰好劈开了簇中间的两个标量,按契约吸附为[3, 7)(整个क्षि),打码结果为"ID:[NAME]!"——而不是劈成ID:क्[NAME]ि!这类把半个连音留在明面上的错误结果。
3. Python 侧实现:钳制 + 吸附 + UAX #29 字素引擎
契约在 Python 侧的落点是 openmed/core/decoding/spans.py。该模块的 docstring 直接引用了契约文档:
Cross-runtime offsets are half-open Unicode scalar (code point) coordinates. They never use UTF-8 byte or UTF-16 code-unit positions, and every non-empty entity span is snapped outward so neither boundary bisects an extended grapheme cluster. See
OFFSET_CONTRACT.mdbeside this module.
3.1 核心吸附函数与"先钳制后吸附"
对外入口snap_span_to_grapheme_boundaries(start, end, text)委托给私有实现_snap_span_to_grapheme_boundaries(spans.py),其逻辑逐行对应契约的 2.4/2.5/2.6 三条规则:
def _snap_span_to_grapheme_boundaries( start: int, end: int, text: str, has_break: Callable[[int], bool], ) -> tuple[int, int]: """Snap a span with a caller-owned grapheme-boundary predicate.""" text_length = len(text) safe_start = max(0, min(int(start), text_length)) # 1. 钳制 start safe_end = max(safe_start, min(int(end), text_length)) # 2. 钳制 end(且不小于 start) snapped_start = safe_start while 0 < snapped_start < text_length and not has_break(snapped_start): snapped_start -= 1 # 3. start 向前退到簇边界 if safe_start == safe_end: return snapped_start, snapped_start # 4. 空 span:保持为空 snapped_end = safe_end while snapped_end < text_length and not has_break(snapped_end): snapped_end += 1 # 5. end 向后推到簇边界 return snapped_start, snapped_end从源码结构看,has_break谓词由调用方持有(通过grapheme_break_checker(text)一次性构建),这样同一文本的多个 span 可以复用同一份边界状态,避免逐 span 全量重扫。while循环的"逐步外移"正是"non-empty span 向外扩展"的机器可验证形式。
grapheme_break_checker还有一处与文档语义精确对应的细节(spans.py):
Offsets
0andlen(text)are cluster boundaries by definition and are reported as such.
即文本首尾恒为字素边界,空 span 钳制到边界后即停留在首尾,行为可预期。
3.2 纯标准库的 UAX #29 风格字素簇引擎
吸附能否正确,取决于簇边界判断是否正确。iter_grapheme_cluster_spans 用纯标准库(无第三方依赖,模块头注释强调 "These depend only on the standard library: no torch, no mlx")实现 UAX #29 风格的分簇,其 docstring 明确列出覆盖范围:
It covers combining and spacing marks, Hangul syllables, regional-indicator pairs, emoji modifiers and ZWJ sequences, and Indic virama conjuncts.
具体规则集中在_has_grapheme_break(spans.py),可以把它读成一份"何时不产生边界"的白名单:
CR后紧跟LF不产生边界(CRLF 是一个整体);- 韩文音节组合规则:
L后接L/V/LV/LVT、LV/V后接V/T、LVT/T后接T都不断开; - 当前标量属于
EXTEND(组合附加符)、ZWJ或SPACING_MARK时不断开——é(e + U+0301)因此是一个簇; PREPEND类标量(某些阿拉伯/天城文前置字符)与后续字符不断开;_continues_indic_conjunct处理印度语系 virama 连音(_INDIC_LINKERS列出了 DevanagariU+094D、BengaliU+09CD等 9 种文字的 virama 码点,spans.py),这是क्षि被视为单簇的直接依据;- emoji ZWJ 序列:当前是扩展图形符号、前一标量是
ZWJ且 ZWJ 前也是图形符号时不断开(👩⚕️ = 👩 + ZWJ + ⚕ + VS16,一个簇); - 区域指示符成对处理:连续 RI 序列中,前一个 RI 数量为偶数时才产生边界(国旗 🇮🇳 是一个簇);
- 此外还实现了表意文字描述序列(IDS,U+2FF0–U+2FFF 运算符)的内部边界抑制,见
_ideographic_description_internal_boundaries(spans.py)。
is_grapheme_boundary(index, text)(spans.py)则提供单点边界判定,供断言与测试使用;越界索引直接返回False。
3.3 解码调用链:Viterbi 输出如何落到契约坐标
契约不只是"存根规则",它内嵌在解码调用链里。viterbi.py 的token_spans_to_char_spans负责把 token 级 span 映射为字符级 span,其 docstring 直接声明输出为 "grapheme-safe Unicode scalar spans"(viterbi.py)。关键片段体现了契约第 2.6 条的"先钳制后吸附":
start = max(0, min(int(start_offset[0]), len(text))) end = max(start, min(int(end_offset[1]), len(text))) start, end = snap_span_to_graphemes(start, end, text) if cjk_enabled: if offset_map is not None: start, end = snap_char_span_to_word_boundaries(start, end, offset_map) assert_cjk_span_boundaries(start, end, text, offset_map)即:token 偏移 → 源字符偏移 →钳制→字素吸附→(中文场景下再吸附到分词词边界并用assert_cjk_span_boundaries断言)。契约还规定了中文等 CJK 文本的加严条件:span 不仅要落在字素边界,还要与分词器的词边界重合,且不允许输出"半个汉字词"——spans.py 的snap_char_span_to_word_boundaries会把与任何词相交的 span 向外扩展到完整词,而对不含任何词的纯空白 span(如独立的 U+3000 全角空格)原样返回,"whitespace is never promoted into a redactable word"。
4. Swift 侧实现:PostProcessing 中的标量坐标工具
Swift 侧的对应物是 PostProcessing.swift,契约文档明确点名 "Swift usesString.UnicodeScalarViewindices converted throughPostProcessing"。该文件提供了一组以 Unicode 标量偏移为唯一入参/出参的静态工具:
scalarSubstring(_ text:start:end:)(PostProcessing.swift):用半开标量偏移切取子串;越界范围返回空串。graphemeBoundaries(in:)(PostProcessing.swift):遍历String的字符序列,累计unicodeScalars.count得到每个簇边界的标量偏移(而非String.Index),再过滤掉印度语系连音内部位置(continuesIndicConjunct),与 Python 侧_continues_indic_conjunct语义对应。isGraphemeBoundary(_:in:)(PostProcessing.swift):越界返回false,与 Pythonis_grapheme_boundary的行为一致。snapScalarSpanToGraphemeBoundaries(start:end:in:)(PostProcessing.swift):契约吸附函数的 Swift 版,注释几乎逐句复述契约:
/// Clamp and snap a Unicode scalar span outward to grapheme boundaries. /// /// Empty spans remain empty and move to the preceding boundary. Non-empty /// spans expand to include every extended grapheme cluster they touch. public static func snapScalarSpanToGraphemeBoundaries( start: Int, end: Int, in text: String ) -> (start: Int, end: Int) { let textLength = unicodeScalarCount(in: text) let safeStart = max(0, min(start, textLength)) let safeEnd = max(safeStart, min(end, textLength)) let boundaries = graphemeBoundaries(in: text) let snappedStart = boundaries.last(where: { $0 <= safeStart }) ?? 0 guard safeStart != safeEnd else { return (snappedStart, snappedStart) } let snappedEnd = boundaries.first(where: { $0 >= safeEnd }) ?? textLength return (snappedStart, snappedEnd) }与 Python 的while逐步外移相比,Swift 版在边界数组上做last(where:)/first(where:)查找,算法等价:钳制后,start 取"不超过 safeStart 的最近边界",end 取"不低于 safeEnd 的最近边界",空 span 直接返回同一坐标。
replacingScalarSpan(in:start:end:with:)(PostProcessing.swift):将标量 span 转回String原生Range后执行replaceSubrange,注释特别强调 "without relying onString.count"(String.count按扩展字符计数,与标量偏移不同源,是典型踩坑点)。decodeEntities(tokens:text:)(PostProcessing.swift 起):token 解码入口,同样以标量偏移交换数据。
契约中"Swift 必须经过PostProcessing转换"的要求,从源码结构看正是为了防止各管线自行发明坐标换算:所有与标量坐标相关的换算被收敛到这一个命名空间。
5. 替换顺序规则:从最高起始偏移到最低
契约的最后一条操作性规则:
Replacement must convert the scalar offsets back to native string indices and apply multiple spans from highest start offset to lowest. This keeps earlier source offsets stable when replacement lengths differ.
含义是:对多个 span 执行打码/替换时,先把标量偏移转回该语言字符串模型的原生索引(Swift 即UnicodeScalarView的Range),然后按start从高到低逐个应用。原因很直接:一次替换若改变了文本长度(如张伟被替换为[NAME],3 个码点变 6 个字符),所有比它靠前的 span 的偏移不受影响,而靠后的 span 全部作废;从后往前替换,则已替换部分永远位于未处理 span 的右侧,早期源偏移保持稳定。这是契约中唯一一条关于"多 span 批量操作"的不变量,实现任何自定义脱敏层时都应遵守。
6. 共享可执行夹具:一份 JSON,三端消费
契约不是纸面约定——文档最后明确:"The shared executable examples live intests/fixtures/parity/offset_contract.jsonand are consumed by both pytest and XCTest." 实际仓库中该夹具还被 Android 的 Kotlin 测试一并消费。
6.1 夹具结构
offset_contract.json 顶层字段:
| 字段 | 值 | 含义 |
|---|---|---|
version | 1 | 夹具版本,三端测试均断言此值 |
offset_unit | "unicode_scalar" | 钉死坐标单位,防止未来有人换成 byte/utf16 |
cases | 42 条用例 | 每条含id、category、script、text、input_start/input_end、expected_start/expected_end、replacement、expected_redacted |
每条用例是一个完整的"解码 span → 吸附 → 打码"端到端断言:给定原文和模型输出的(可能劈开裂的)输入 span,夹具规定吸附后的期望 span 以及最终脱敏文本。用例按四个类别组织:
- cjk(12 条):
zh-Hans/zh-Hant无空格姓名、全角标点(:、。、;)、CJK Extension B 补充平面字符(𠀀–𠀅)等; - indic(27 条):9 种印度语系文字 Deva、Beng、Guru、Gujr、Orya、Taml、Telu、Knda、Mlym 各 3 条,覆盖 virama 连音与拉丁/数字混排(如
"Latinक्षु42"); - joiner(6 条):ZWJ(
U+200D)、ZWNJ(U+200C)、emoji ZWJ 序列(👩⚕️、👨👩👧👦)、组合附加符(José的é)、区域指示符(🇮🇳); - mixed(6 条):拉丁+天城文、拉丁+泰米尔文+数字、孟加拉文+拉丁、卡纳达文+ASCII 数字、汉字+拉丁+天城文混排、汉字+邮箱等跨脚本场景。
几个代表性用例(节选自夹具原文):
{"id":"cjk-zh-hans-no-space-01","script":"zh-Hans","text":"患者张伟今日复诊", "input_start":2,"input_end":4,"expected_start":2,"expected_end":4, "replacement":"[NAME]","expected_redacted":"患者[NAME]今日复诊"}, {"id":"indic-deva-01","script":"Deva","text":"ID:क्षि!", "input_start":4,"input_end":6,"expected_start":3,"expected_end":7, "replacement":"[NAME]","expected_redacted":"ID:[NAME]!"}, {"id":"joiner-emoji-health-01","script":"Emoji","text":"Clinician 👩⚕️ on call", "input_start":11,"input_end":13,"expected_start":10,"expected_end":14, "replacement":"[NAME]","expected_redacted":"Clinician [NAME] on call"}, {"id":"mixed-han-email-01","script":"Hani","text":"Email王芳@example.test", "input_start":5,"input_end":7,"expected_start":5,"expected_end":7, "replacement":"[NAME]","expected_redacted":"Email[NAME]@example.test"}其中 emoji 用例值得注意:👩⚕️占标量[10, 14)(👩=10、ZWJ=11、⚕=12、VS16=13),模型输入 span[11, 13)劈开了 ZWJ 序列内部,吸附后为[10, 14)整个簇——这正是"绝不劈开 joiner 序列"的可执行形态。
6.2 pytest 端
tests/unit/core/test_offset_contract_parity.py 做三层验证:
- 夹具自覆盖断言(
test_shared_offset_contract_fixture_has_required_coverage):断言version == 1、offset_unit == "unicode_scalar"、用例数 ≥ 40、类别必含cjk/indic/joiner/mixed、印度语系文字恰好是上述 9 种、且文本中确实出现U+200D与U+200C。这保证夹具本身不会悄悄退化; - 逐用例吸附 + 打码断言(
test_python_snapping_and_redaction_match_shared_fixture):调用snap_span_to_grapheme_boundaries得到 span,断言等于期望值、两端都是字素边界,然后手工执行text[:start] + replacement + text[end:],与expected_redacted逐字符比对,并额外比对 UTF-8 编码字节相等; - 解码器一致性断言(
test_python_decoders_enforce_shared_grapheme_boundaries):验证真实解码路径token_spans_to_char_spans(Viterbi 链路)与coerce_token_classification_spans(token 分类输出规整)都收敛到同一期望 span;还断言实体的byte_start/byte_end是由标量偏移现算的 UTF-8 字节位置——即字节偏移只作为派生展示字段存在,而不是交换坐标。
6.3 XCTest 端
OffsetContractParityTests.swift 读取同一个JSON 文件(repositoryRoot().appending(path: "tests/fixtures/parity/offset_contract.json"),用.convertFromSnakeCase解码input_start→inputStart等),对每条用例依次断言:
PostProcessing.snapScalarSpanToGraphemeBoundaries输出等于期望 span,且两端通过PostProcessing.isGraphemeBoundary校验;PostProcessing.decodeEntities(真实解码入口)输出的实体 span 等于期望值;EntityPrediction.snappedToGraphemeBoundaries(in:)归一化后的 span 与文本子串一致;PostProcessing.replacingScalarSpan的替换结果以及PlatformModel.redact(_:entities:method: .mask)最终脱敏文本,均与expected_redacted相等,并做Data(redacted.utf8)字节级比对。
6.4 Android 端
offset_contract 的消费方 还包括 Kotlin 侧的OffsetContractParityTest,即契约实际是"一份 JSON 夹具、三端(pytest / XCTest / JUnit)共同消费"的三方一致性护栏。
7. 实践要点:实现或扩展时如何遵守契约
综合契约文档与两侧源码,任何新增解码器、后处理或脱敏层都必须守住以下不变量:
- 交换坐标只用半开 Unicode 标量偏移,针对精确的未归一化源字符串;Python 直接用
str索引,Swift 一律经PostProcessing的scalarSubstring/replacingScalarSpan等工具换算,Kotlin 同理; - 拒绝字节/UTF-16 坐标入参:
byte_start/byte_end这类字节位置只能作为标量坐标的派生输出(如 test_offset_contract_parity.py 中那样现场计算),不得反向参与 span 运算; - 顺序固定:先钳制到
[0, len(text)],再吸附到簇边界(Python: spans.py;Swift: PostProcessing.swift),且空 span 只平移、不扩张; - 字素簇判定必须覆盖:组合附加符、韩文音节、ZWJ emoji、区域指示符对、印度语系 virama 连音、IDS 序列——这是夹具
joiner类别逐条盯住的; - 多 span 替换按 start 从高到低应用,替换前把标量偏移转回原生索引;
- 回归验证:修改任何一侧的吸附逻辑后,运行三端一致性测试即可判定是否违约——夹具中任何一条用例失败都意味着某个运行时把某个文字系统"劈开"了。
8. 小结
OFFSET_CONTRACT.md 用不到 30 行文字定义了一套约束极强、机器可验证的坐标协议:半开区间、Unicode 标量单位、禁用字节/UTF-16、越界先钳制、边界外扩到字素簇、空 span 前移、替换按最高起始偏移优先。它的价值不在单条规则,而在于"文档 + 共享 JSON 夹具 + 三端测试"的闭环——openmed/core/decoding/spans.py 与 PostProcessing.swift 各自独立实现了 UAX #29 风格的字素边界引擎,却必须对 tests/fixtures/parity/offset_contract.json 中 42 条多文字用例给出逐字节相同的答案。对多语言、本地化的医疗脱敏系统而言,这正是"坐标正确性"从口头约定升级为工程护栏的典型做法。
【免费下载链接】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),仅供参考