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-*用例如患者今日复诊属 U20000 区段就是为了钉死这一点即便在补充平面字符两侧标量坐标仍然正确。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 U0301 构成的é、连音符号序列、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:क्षि!的标量坐标为I0, D1, :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. SeeOFFSET_CONTRACT.mdbeside this module.3.1 核心吸附函数与先钳制后吸附对外入口snap_span_to_grapheme_boundaries(start, end, text)委托给私有实现_snap_span_to_grapheme_boundariesspans.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.pyOffsets0andlen(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_breakspans.py可以把它读成一份何时不产生边界的白名单CR后紧跟LF不产生边界CRLF 是一个整体韩文音节组合规则L后接L/V/LV/LVT、LV/V后接V/T、LVT/T后接T都不断开当前标量属于EXTEND组合附加符、ZWJ或SPACING_MARK时不断开——ée U0301因此是一个簇PREPEND类标量某些阿拉伯/天城文前置字符与后续字符不断开_continues_indic_conjunct处理印度语系 virama 连音_INDIC_LINKERS列出了 DevanagariU094D、BengaliU09CD等 9 种文字的 virama 码点spans.py这是क्षि被视为单簇的直接依据emoji ZWJ 序列当前是扩展图形符号、前一标量是ZWJ且 ZWJ 前也是图形符号时不断开‍⚕️ ZWJ ⚕ VS16一个簇区域指示符成对处理连续 RI 序列中前一个 RI 数量为偶数时才产生边界国旗 是一个簇此外还实现了表意文字描述序列IDSU2FF0–U2FFF 运算符的内部边界抑制见_ideographic_description_internal_boundariesspans.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 spansviterbi.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如独立的 U3000 全角空格原样返回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.countString.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 顶层字段字段值含义version1夹具版本三端测试均断言此值offset_unitunicode_scalar钉死坐标单位防止未来有人换成 byte/utf16cases42 条用例每条含id、category、script、text、input_start/input_end、expected_start/expected_end、replacement、expected_redacted每条用例是一个完整的解码 span → 吸附 → 打码端到端断言给定原文和模型输出的可能劈开裂的输入 span夹具规定吸附后的期望 span 以及最终脱敏文本。用例按四个类别组织cjk12 条zh-Hans/zh-Hant无空格姓名、全角标点、。、、CJK Extension B 补充平面字符–等indic27 条9 种印度语系文字 Deva、Beng、Guru、Gujr、Orya、Taml、Telu、Knda、Mlym 各 3 条覆盖 virama 连音与拉丁/数字混排如Latinक्षु42joiner6 条ZWJU200D、ZWNJU200C、emoji ZWJ 序列‍⚕️、‍‍‍、组合附加符José的é、区域指示符mixed6 条拉丁天城文、拉丁泰米尔文数字、孟加拉文拉丁、卡纳达文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、ZWJ11、⚕12、VS1613模型输入 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 种、且文本中确实出现U200D与U200C。这保证夹具本身不会悄悄退化逐用例吸附 打码断言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_spansViterbi 链路与coerce_token_classification_spanstoken 分类输出规整都收敛到同一期望 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.pySwift: 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),仅供参考