From b264073f2b28fad3ca1de383e1347f055348c9f1 Mon Sep 17 00:00:00 2001 From: sansen Date: Wed, 23 Sep 2026 17:14:13 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=A7=E4=BA=A4=E6=9B=BF=E4=B8=B2=E5=8A=A0?= =?UTF-8?q?=E9=A6=96=E5=AD=97=E7=AC=A6=E9=A2=84=E7=AD=9B=EF=BC=9Aconvert?= =?UTF-8?q?=5Ftext=20=E6=8F=90=E9=80=9F=203.05x=EF=BC=8C=E8=BE=93=E5=87=BA?= =?UTF-8?q?=E9=9B=B6=E5=B7=AE=E5=BC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CPython 的 re 不对大交替串 (?:A|B|...) 做首字符预筛——460 个年号的交替串在每个 位置都要把全部分支逐个试完才移向下一位。实测这类扫描占 convert_text 总耗时的 82% (sample_2.txt 1.03M 字符:扫描 7.05 / 总计 8.54 µs/字符),而其中多数模式只命中 0~134 处。 改法:给每个"以年号或朝代名开头"的模式前面加一个零宽前瞻 (?=[首字集合])。前瞻不 消耗字符,只在当前位置断言下一个字符属于集合。预筛字符集从与模式**同一份源数据** 推导(_dynasty_names / _dynasty_prefix / KNOWN_ERAS_ALL),CSV 加新年号时自动同步。 三种预筛:_GATE_DYNASTY(朝代组必选)/ _GATE_DYNASTY_OPT(_dynasty_opt 可空)/ _GATE_ERA(朝代组为空)。 结果为什么不变(结构性保证,非经验之谈):前瞻只拒绝「原模式根本不可能开始匹配」的 位置 → 匹配集合完全相同 → 捕获组相同 → 后续执行序列一致 → 输出逐字段一致。 不加预筛者(前提不满足):公元段(前缀整个可选)、裸干支段(以干支对开头)、 民国段(以字面量 民 开头,re 自带前缀优化)。 顺带把 2b/3/3b 段的 4 个正则从 extract_era_years 内提到模块级:只编译一次,且差分 测试能取到它们。 验证: - 全字段快照比对:sample.txt 1766 / sample_2.txt 20188 / test/sample.txt 14 / test/sample_crosscheck.txt 11,全部 19 个字段逐条一致,零差异 - pytest 260 passed(新增 TestFirstCharGate 3 条)、summary.py 96/96 - 速度:sample_2.txt 8.53s → 2.80s(3.05x)、sample.txt 0.64s → 0.21s(2.99x) - 3.8.10 下编译通过(部署目标) - 变异测试:手动砍掉预筛里一个首字符后,差分测试立即失败并指出漏掉的 "万历二十三年" —— 证明这道护栏真的能挡住坏的预筛 Co-Authored-By: Claude Code --- era_parser.py | 155 ++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 113 insertions(+), 42 deletions(-) diff --git a/era_parser.py b/era_parser.py index 2285038..4e33f58 100644 --- a/era_parser.py +++ b/era_parser.py @@ -331,27 +331,78 @@ _bare_year_after = ( ) _year_suffix = r'(?:年|(?=[' + re.escape(_bare_year_after) + r'\s]|$))' + +# === 首字符预筛 === +# 大交替串((?:A|B|...) 里塞了几百个年号/朝代名)在 CPython 的 re 里没有首字符预筛: +# 引擎在每个位置都要把全部分支逐个试完才移向下一位。实测 1MB 文本上这类扫描占 +# convert_text 总耗时的 82%,而其中多数模式只命中 0~134 处。 +# +# 做法:在模式开头加一个**零宽前瞻** (?=[首字集合])。前瞻不消耗字符,只在当前位置断言 +# "下一个字符属于该集合",断言失败就放弃这个起点。 +# +# 结果为什么不变(这是结构性保证,不是经验之谈):集合从与模式**同一份源数据**推导, +# 覆盖了模式第一个被消耗字符的所有来源,因此只可能拒绝「原模式根本不可能开始匹配」的 +# 位置 → 匹配集合完全相同 → 捕获组相同 → extract_era_years 后续执行序列一致 → 输出 +# 逐字段一致。集合随 CSV/白名单变化自动同步(都从 _dynasty_names / KNOWN_ERAS_ALL 推导)。 +# +# 适用前提(新增模式时必须逐条核对): +# ① 模式至少消耗 1 个字符、不能匹配空串——否则前瞻会挡掉零宽匹配; +# ② 第一个被消耗的字符只可能来自朝代名或年号名。 +# 故以下模式**不加**预筛: +# - 公元段 (?:公元|西元|公历)?...:前缀整个可选,不满足前提①; +# - 裸干支段:以干支对开头,不满足前提②; +# - 民国段:以字面量 民 开头,re 自带前缀优化,加了也无收益。 +# 三种预筛对应"朝代组"的三种写法: +# _GATE_DYNASTY 朝代组必选((?P(?:...|...)) 后无 `?`) +# _GATE_DYNASTY_OPT 朝代组可选(用 _dynasty_opt,见 2b/2c 段) +# _GATE_ERA 朝代组为空((?P) 后直接贴年号) +def _first_char_gate(names): + """从名称集合推导首字符预筛前瞻。字符类里 ]、反斜杠、^、- 四个字符需转义。""" + chars = {n[0] for n in names if n} + body = ''.join('\\' + c if c in ']\\^-' else c for c in sorted(chars)) + return '(?=[' + body + '])' + + +_dynasty_first = list(_dynasty_names) + list(_dynasty_prefix) +_GATE_DYNASTY = _first_char_gate(_dynasty_first) +_GATE_ERA = _first_char_gate(KNOWN_ERAS_ALL) +_GATE_DYNASTY_OPT = _first_char_gate(_dynasty_first + list(KNOWN_ERAS_ALL)) + +# 差分测试用:登记 (预筛, 未加预筛的模式串)。test_parser.py 逐个比对两者在语料上的 +# 匹配序列(含捕获组),防止预筛与模式脱节——例如将来改了模式首部却忘了改预筛。 +_GATED_PATTERNS = [] + + +def _compile_gated(pattern, gate): + """编译加过首字符预筛的模式,并登记 (预筛, 原串) 供差分测试比对。""" + compiled = re.compile(gate + pattern) + _GATED_PATTERNS.append((compiled, pattern, gate)) + return compiled + + def _build_pattern(with_dynasty=True): """构建年号正则:已知年号优先 + 通用兜底(仅限有朝代前缀时)""" if with_dynasty: # 朝代前缀模式:加(?(?:(?)' # 无朝代模式不加边界(允许年号跟在其他汉字后面,如"永乐大帝在永乐三年") boundary = '' + gate = _GATE_ERA # 朝代组为空 → 首个消耗字符是年号首字 if with_dynasty: # 有朝代前缀:年号后直接跟年份数字,无需额外约束 era_year = _known_era_group + r'(?P' + _yd + r'+)' + _year_suffix era_yuan = _known_era_group + r'元年' patterns = [ - re.compile(dynasty_part + boundary + era_year), - re.compile(dynasty_part + boundary + era_yuan), + _compile_gated(dynasty_part + boundary + era_year, gate), + _compile_gated(dynasty_part + boundary + era_yuan, gate), # 通用兜底(2-8字) - re.compile(dynasty_part + boundary + r'(?P[一-鿿]{2,8})' + r'(?P' + _yd + r'+)年'), - re.compile(dynasty_part + boundary + r'(?P[一-鿿]{2,8})元年'), + _compile_gated(dynasty_part + boundary + r'(?P[一-鿿]{2,8})' + r'(?P' + _yd + r'+)年', gate), + _compile_gated(dynasty_part + boundary + r'(?P[一-鿿]{2,8})元年', gate), ] else: # 无朝代前缀:年号后必须紧跟年份数字(前瞻断言),防止"永乐大帝"误匹配 @@ -359,8 +410,8 @@ def _build_pattern(with_dynasty=True): era_year = _known_era_group + _year_ahead + r'(?P' + _yd + r'+)' + _year_suffix era_yuan = _known_era_group + _year_ahead + r'元年' patterns = [ - re.compile(dynasty_part + boundary + era_year), - re.compile(dynasty_part + boundary + era_yuan), + _compile_gated(dynasty_part + boundary + era_year, gate), + _compile_gated(dynasty_part + boundary + era_yuan, gate), ] return patterns @@ -397,12 +448,13 @@ _gz_gy_sep = ( ) # 干支前的修饰词(如"光绪岁次辛丑""岁在壬戌"):碑刻/谱牒常见 _gz_mod = r'(?:岁[次在])?' -_ganzhi_gongyuan_re = re.compile( +_ganzhi_gongyuan_re = _compile_gated( _dynasty_opt + _gz_era_group + _gz_mod + r'(?P' + _gz_pair + r')(?:年)?' - + _gz_gy_sep + + _gz_gy_sep, + _GATE_DYNASTY_OPT, # _dynasty_opt 可空 → 朝代首字与年号首字并集 ) # 裸干支+(对照):无年号可锚定(如"壬申(1872)"),公元取对照值 _ganzhi_bare_gongyuan_re = re.compile( @@ -410,6 +462,51 @@ _ganzhi_bare_gongyuan_re = re.compile( + _gz_gy_sep ) +# === 2b / 3 / 3b 段的正则 === +# 这些原先在 extract_era_years 里每次调用都重新编译、且无法被差分测试取到; +# 提到模块级后只编译一次,并统一登记进 _GATED_PATTERNS。 +# 2b. 年号+公元(如"清康熙公元一六八七年丁卯"):年号后直接给出公元年份, +# 年数 = 公元 - 年号起始 + 1,紧跟的干支用于校验年份一致性。 +_era_gongyuan_re = _compile_gated( + r'(?P(?:(?' + _known_era_alt + r')' + + r'(?:公元|西元|公历)\s*(?P' + _gz_gy_year + r')\s*年?' + + r'(?P' + _gz_pair + r')?', + _GATE_DYNASTY_OPT, # 朝代前缀可空 → 并集 +) + +# 3-A. 有朝代前缀的干支纪年(如"清雍正乙巳");年号组 _gz_era_group 见模块级定义 +_ganzhi_dynasty_re = _compile_gated( + r'(?P(?:(?' + _gz_pair + r')(?:年)?', + _GATE_DYNASTY, # 朝代组必选 → 朝代名首字 +) + +# 3-B. 无朝代前缀的干支纪年(如"嘉庆癸亥"),年号≥2字符避免天干误匹配 +_long_era_names = [e for e in KNOWN_ERAS_ALL if len(e) >= 2] +_long_era_alt = '|'.join(sorted(_long_era_names, key=len, reverse=True)) +_ganzhi_no_dynasty_re = _compile_gated( + r'(?P)' + + r'(?P' + _long_era_alt + r')' + + _gz_mod + + r'(?P' + _gz_pair + r')(?:年)?', + _GATE_ERA, # 朝代组为空 → 年号首字 +) + +# 3b. 庙号纪年(如"宋高宗""清圣祖",指整个在位期,公元=即位首年) +# 庙号必须带朝代前缀("高宗"在唐/宋/清均有,单独出现有歧义) +_temple_names = sorted({t for d in temple_dict for t in temple_dict[d]}, key=len, reverse=True) +_temple_alt = '|'.join(re.escape(t) for t in _temple_names) +_temple_pattern_re = _compile_gated( + r'(?P(?:(?' + _temple_alt + r')' + # 后面紧跟"X年/元年"时不按庙号匹配,交给古代纪年按年号解析(如"元泰定三年") + + r'(?!(?:' + _yd + r'+年|元年))', + _GATE_DYNASTY, # 朝代组必选 → 朝代名首字 +) + # === 主函数 === def extract_era_years(text): @@ -536,13 +633,8 @@ def extract_era_years(text): # === 2b. 年号+公元(如"清康熙公元一六八七年丁卯") === # 年号后直接给出公元年份:年数=公元-年号起始+1,与"年号+年数"公式自洽; # 紧跟的干支用于校验年份一致性。置于公元段之前,合并结果优先、避免重复摘出。 - _era_gongyuan = re.compile( - r'(?P(?:(?' + _known_era_alt + r')' - + r'(?:公元|西元|公历)\s*(?P' + _gz_gy_year + r')\s*年?' - + r'(?P' + _gz_pair + r')?' - ) - for m in _era_gongyuan.finditer(simplified_text): + # 正则见模块级 _era_gongyuan_re(含首字符预筛) + for m in _era_gongyuan_re.finditer(simplified_text): start, end = m.start(), m.end() if any(pos in covered for pos in range(start, end)): continue @@ -768,39 +860,18 @@ def extract_era_years(text): result["超出使用期"] = True results.append(result) - # 模式A:有朝代前缀(如"清雍正乙巳");年号组 _gz_era_group 见模块级定义 - ganzhi_dynasty = re.compile( - r'(?P(?:(?' + _gz_pair + r')(?:年)?' - ) - for m in ganzhi_dynasty.finditer(simplified_text): + # 模式A:有朝代前缀(如"清雍正乙巳");正则见模块级 _ganzhi_dynasty_re + for m in _ganzhi_dynasty_re.finditer(simplified_text): _process_ganzhi_match(m) - # 模式B:无朝代前缀(如"嘉庆癸亥"),年号≥2字符避免天干误匹配 - _long_era_names = [e for e in KNOWN_ERAS_ALL if len(e) >= 2] - _long_era_alt = '|'.join(sorted(_long_era_names, key=len, reverse=True)) - ganzhi_no_dynasty = re.compile( - r'(?P)' - + r'(?P' + _long_era_alt + r')' - + _gz_mod - + r'(?P' + _gz_pair + r')(?:年)?' - ) - for m in ganzhi_no_dynasty.finditer(simplified_text): + # 模式B:无朝代前缀(如"嘉庆癸亥");正则见模块级 _ganzhi_no_dynasty_re + for m in _ganzhi_no_dynasty_re.finditer(simplified_text): _process_ganzhi_match(m) # === 3b. 庙号纪年(如"宋高宗""清圣祖",指整个在位期,公元=即位首年)=== # 庙号必须带朝代前缀("高宗"在唐/宋/清均有,单独出现有歧义) - _temple_names = sorted({t for d in temple_dict for t in temple_dict[d]}, key=len, reverse=True) - _temple_alt = '|'.join(re.escape(t) for t in _temple_names) - _temple_pattern = re.compile( - r'(?P(?:(?' + _temple_alt + r')' - # 后面紧跟"X年/元年"时不按庙号匹配,交给古代纪年按年号解析(如"元泰定三年") - + r'(?!(?:' + _yd + r'+年|元年))' - ) - for m in _temple_pattern.finditer(simplified_text): + # 正则见模块级 _temple_pattern_re + for m in _temple_pattern_re.finditer(simplified_text): start, end = m.start(), m.end() if any(pos in covered for pos in range(start, end)): continue