Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/rust-pdf-stage21-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# PDF 原生内核阶段 21:表格脚本与全文几何边界批量化

## 实现边界

本阶段以 Stage 20 提交 `d504c5e` 为基线,不再继续 textpage 或 Path 迁移。私有协议从 27 升到 28;公开 `auto|python|rust` 计算选择、`auto|legacy|session` 渲染选择、公共 SDK 和输出 schema 均保持不变。

表格 HTML 物化复用页面级 `NativeScriptEvidence`:`_collect_document_sources()` 已生成的 owned 身份映射继续传入表格恢复链路。普通 0 度、无 fraction/inline 特殊证据且字符身份完整的 cell visual line 被合并为最多 8192 字的 Rust 批次;原生角色只要出现非 body,整 cell 回退既有 `_script_line_char_roles()` 参考路径,避免改变表格二维行合并后的精炼语义。诊断新增表格 owned 批次、命中行数和回退 cell 数。

表格候选的 marker 输入校验从每个 corridor 改为每个 `_RuleCandidateContext` 一次。同一方向候选组共享 marker-safe 结果和 source line 索引;重复 row、特殊字符、非有限坐标和 Python backend 仍走原参考路径。

新增页面级 `NativeGeometryEvidence`:Rust 一次从自有 text snapshot 准备 source/loose/tight/origin、rotation、字体 run key、字号、宽粒度文字类别和 anchor 标记。Python 主链路只传行成员索引和行元数据;`NativeGeometryRuns` 在全文内分配稳定 run ID,`NativeGeometryRisk` 连续累积布局/样式风险。解释器相关的字体族归一化、字号/字重取整和 Unicode 文字类别只在不同值上调用。身份缺失、特殊输入、旧扩展或规则替换时整本文档回退原逐行参考路径。

## 验证

- 32 PDF/299 页公开回放每份双跑;ModelJson、MiddleJson、素材哈希和诊断与 Stage 20 完整输出完全一致。
- MinerU 当前 `dev@f504cff`:31 份 eligible Flash 文本完整输出一致;32 份实际 medium shared 完整输出一致。MinerU 工作区仅保留原有未跟踪 `examples/`,未修改其源码。
- Rust/session 完整测试:5465 passed / 14 skipped。
- Python/legacy 完整测试:4756 passed / 723 skipped。
- Cargo workspace tests、Clippy `-D warnings`、rustfmt、Ruff check 和改动文件 format check 均通过。
- ABI3 wheel 为 `docvortex-0.5.7-cp310-abi3-macosx_11_0_arm64.whl`。CPython 3.14 独立环境中 DocVortex 核心路径和 MinerU Flash 文本路径的 `auto|rust` 输出一致,协议 28,geometry evidence API 可用。源码扩展与 wheel 扩展 SHA-256 均为 `6266f409337d742153dc5bc0727d3b28f32a8f4cb210d8a5ea6e06cc650d3deb`。

公开正确性诊断中,表格 owned 路径执行 368 批/27,602 行/932 个 cell 回退;geometry evidence 执行 598 页/34,990 行/0 回退。32 PDF 单遍完整 cProfile 中,geometry evidence 执行 299 页/17,495 行,表格 owned 执行 184 批/13,801 行。

## 性能与资源

正式计时均为每文档一次预热、五次热运行;公开 parse 按文档先基线后候选,MinerU 按文档交替方向,RSS 使用独立进程树采样。结果仅代表本机语料与当前 PDFium 环境。

| 链路 | Stage 20 基线 | Stage 21 候选 | 首轮总降幅 | 首轮最大耗时比 | 首轮最大 RSS 比 |
| --- | ---: | ---: | ---: | ---: | ---: |
| DocVortex 公开 parse,32 PDF | 15.943439 s | 15.513413 s | 2.70% | 1.02077 | 1.03600 |
| MinerU Flash 文本,31 PDF | 15.746155 s | 15.304521 s | 2.80% | 1.02846 | 1.04413 |
| MinerU medium shared,32 PDF | 16.010728 s | 17.845207 s | -11.46% | 1.58188 | 1.00490 |

shared 首轮有 4 个样本耗时超过 5%,均已按脚本自动反序复测:

- `small_ocr.pdf`: `1.09802 → 0.98395`
- `engineering_process_restrictions_table.pdf`: `1.51380 → 0.99771`
- `pollutant_discharge_tables.pdf`: `1.58188 → 0.88237`
- `quarterly_report_financial_tables.pdf`: `1.12287 → 0.90017`

四个样本均无持续退化。仅把这四个已触发样本替换为反序复测值作诊断汇总时,shared 总时间为 `16.174975 s → 15.777589 s`,约 2.46% 改善;该补充口径不替代首轮正式总表。所有 benchmark 完整输出均相等,公开 parse、Flash 和 shared 均无持续超过 5% 的耗时或 RSS 退化。

十样本表格重载焦点 profile 中:

- 墙钟:`11.889497 s → 10.338891 s`,约 13.03%。
- `_cell_script_roles`: `1.969974 s → 0.040009 s`
- `_prepare_table_core_rows`: `0.542125 s → 0.026794 s`
- `_document_requires_full_geometry`: `0.467145 s → 0.050283 s`
- 三项合计:`2.979244 s → 0.117086 s`,低于 `2.80 s` 门槛。

## 后续状态

原始 2 倍性能目标仍未完成。Stage 21 后的 32 PDF cProfile 主要剩余热点为 `_detect_table_candidates()` 约 `6.821 s`、`_materialize_table_blocks()` 约 `5.384 s`、`_infer_text_lanes()` 约 `3.252 s`、`build_document_geometry_plan()` 约 `3.626 s` 和 `_merge_owned_table_candidates()` 约 `2.224 s`。其中 geometry plan 的准入扫描已下降,剩余成本主要在确认布局风险后的 canonical 样本构造。Stage 22 应优先从表格候选合并/物化与 lane inference 重新 profile 后选择,不应继续扩大本轮 owned script/geometry 范围。

证据目录为 `output/pdf/native-kernel-20260929-stage21/`。本阶段不自动合并、发版或发布 PyPI。
1 change: 1 addition & 0 deletions rust/docvortex-core/src/geometry_risk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use crate::{
};

pub type Entry = (usize, Box4, Box4, Size, f64);
pub type RunKey = (String, u64, i32, i32, i32, String);

/// 保留线性插值分位数,不复用其他模块的取整采样规则。
pub(crate) fn quantile(mut values: Vec<f64>, fraction: f64) -> f64 {
Expand Down
2 changes: 1 addition & 1 deletion rust/docvortex-core/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! 不访问 Python 对象或 PDFium 的单线程批量计算内核。

pub const PROTOCOL_VERSION: u32 = 27;
pub const PROTOCOL_VERSION: u32 = 28;
pub mod columns;
pub mod note_index;
pub mod row_geometry;
Expand Down
45 changes: 44 additions & 1 deletion rust/docvortex-python/src/geometry_risk.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,36 @@
//! 文档风险累积器绑定:只在整行输入与最终结论边界接触 Python。
use docvortex_core::geometry_risk::{Entry, Risk};
use docvortex_core::geometry_risk::{Entry, Risk, RunKey};
use pyo3::prelude::*;
use std::collections::HashMap;

#[pyclass(module = "docvortex._native")]
pub(super) struct NativeGeometryRuns {
values: HashMap<RunKey, usize>,
}

#[pymethods]
impl NativeGeometryRuns {
/// 创建全文共享的 run 编号表,保证跨页同 key 继续累计。
#[new]
fn new() -> Self {
Self {
values: HashMap::new(),
}
}

/// 报告当前不同 run 数,仅用于测试和诊断。
fn __len__(&self) -> usize {
self.values.len()
}
}

impl NativeGeometryRuns {
/// 为完全相同的字体/角度/文字 key 分配稳定编号。
pub(super) fn intern(&mut self, key: RunKey) -> usize {
let next = self.values.len();
*self.values.entry(key).or_insert(next)
}
}

#[pyclass(module = "docvortex._native")]
pub(super) struct NativeGeometryRisk {
Expand Down Expand Up @@ -30,6 +60,19 @@ impl NativeGeometryRisk {
py.detach(|| self.state.add_line(page, source, height, skip_y, entries))
}

/// 供页面级自有 evidence 在 Rust 内完成 run 编号后连续累积风险。
pub(super) fn add_prepared(
&mut self,
py: Python<'_>,
page: usize,
source: i64,
height: f64,
skip_y: bool,
entries: Vec<Entry>,
) -> Option<bool> {
py.detach(|| self.state.add_line(page, source, height, skip_y, entries))
}

/// 返回布局与样式风险,不物化字符或字体字典。
fn finish(&self, py: Python<'_>) -> (bool, bool) {
py.detach(|| self.state.finish())
Expand Down
3 changes: 3 additions & 0 deletions rust/docvortex-python/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add_class::<table_merge::NativeTableGrid>()?;
module.add_class::<table_merge::NativeTableMerger>()?;
module.add_class::<geometry_risk::NativeGeometryRisk>()?;
module.add_class::<geometry_risk::NativeGeometryRuns>()?;
module.add_class::<geometry_document::NativeStyleDocument>()?;
module.add_function(wrap_pyfunction!(
geometry_runs::build_geometry_style,
Expand All @@ -44,6 +45,7 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
module
)?)?;
module.add_class::<snapshot::NativeTextSnapshot>()?;
module.add_class::<snapshot::NativeGeometryEvidence>()?;
module.add_class::<classification::NativeClassificationSnapshot>()?;
module.add_function(wrap_pyfunction!(
classification::read_pdfium_classification,
Expand All @@ -59,6 +61,7 @@ fn _native(module: &Bound<'_, PyModule>) -> PyResult<()> {
module
)?)?;
module.add_function(wrap_pyfunction!(snapshot::text_snapshot_stats, module)?)?;
module.add_function(wrap_pyfunction!(snapshot::geometry_evidence_stats, module)?)?;
module.add_function(wrap_pyfunction!(
snapshot::read_pdfium_text_snapshot,
module
Expand Down
174 changes: 173 additions & 1 deletion rust/docvortex-python/src/snapshot.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
//! 同库原始读取直接进入自有 canonical 快照,只在兼容边界构造 Python 字符。
use docvortex_core::geometry_risk::{Entry, RunKey};
use docvortex_core::{
extraction, text_assignment, text_content,
extraction,
geometry::{self, SourceRow},
text_assignment, text_content,
text_pipeline::{self, TextChar},
text_snapshot::{
self as snapshot, Angle, Character, Font, InputCharacter, Properties, SnapshotError,
Expand Down Expand Up @@ -157,6 +160,44 @@ pub struct NativeTextSnapshot {
raw_count: usize,
}

struct GeometryRecord {
source: SourceRow,
family: String,
font_size: f64,
flags: i32,
weight: i32,
script: String,
anchor: bool,
}

#[pyclass(frozen, module = "docvortex._native")]
pub struct NativeGeometryEvidence {
records: Vec<GeometryRecord>,
}

static GEOMETRY_EVIDENCE_PREPARES: AtomicU64 = AtomicU64::new(0);
static GEOMETRY_EVIDENCE_LINES: AtomicU64 = AtomicU64::new(0);
static GEOMETRY_EVIDENCE_FALLBACKS: AtomicU64 = AtomicU64::new(0);

/// Python float 字典语义把正负零视为同键;普通有限值按位型保持区分。
fn geometry_number_key(value: f64) -> u64 {
if value == 0.0 {
0
} else {
value.to_bits()
}
}

/// 报告页面级 geometry evidence 准备、风险行命中和明确回退次数。
#[pyfunction]
pub fn geometry_evidence_stats() -> (u64, u64, u64) {
(
GEOMETRY_EVIDENCE_PREPARES.load(Ordering::Relaxed),
GEOMETRY_EVIDENCE_LINES.load(Ordering::Relaxed),
GEOMETRY_EVIDENCE_FALLBACKS.load(Ordering::Relaxed),
)
}

/// 由 Rust 短暂持有并释放 textpage,字符规范化仍复用同一个自有快照构建入口。
#[pyfunction]
pub fn read_pdfium_page_text_snapshot(
Expand Down Expand Up @@ -418,6 +459,69 @@ pub fn read_pdfium_text_snapshot(
}

impl NativeTextSnapshot {
/// 一次准备页面级风险输入;解释器 Unicode/round 语义只在不同文本和字体上调用。
fn prepare_geometry_evidence_impl(
&self,
py: Python<'_>,
) -> PyResult<Option<NativeGeometryEvidence>> {
let char_geometry = py.import("docvortex.analyzers.native.pdf.char_geometry")?;
let script_group = char_geometry.getattr("_script_group")?;
let normalize = char_geometry.getattr("_normalized_font_family")?;
let round = py.import("builtins")?.getattr("round")?;
let mut groups: HashMap<String, (String, bool)> = HashMap::new();
let mut families: HashMap<String, String> = HashMap::new();
let mut font_metadata: HashMap<usize, (String, f64, i32, i32)> = HashMap::new();
let mut records = Vec::with_capacity(self.data.chars.len());
for ch in &self.data.chars {
let font = &self.data.fonts[ch.font];
if !font.size.is_finite() {
GEOMETRY_EVIDENCE_FALLBACKS.fetch_add(1, Ordering::Relaxed);
return Ok(None);
}
let metadata = if let Some(value) = font_metadata.get(&ch.font) {
value.clone()
} else {
let raw_name = if font.name.is_empty() {
"<unknown>".to_owned()
} else {
font.name.clone()
};
let family = if let Some(value) = families.get(&raw_name) {
value.clone()
} else {
let value: String = normalize.call1((&raw_name,))?.extract()?;
families.insert(raw_name.clone(), value.clone());
value
};
let rounded_size: f64 =
round.call1((font.size * 4.0,))?.extract::<i64>()? as f64 / 4.0;
let weight: i64 = round.call1((f64::from(font.weight) / 100.0,))?.extract()?;
let value = (family, rounded_size, font.flags, weight as i32);
Comment on lines +496 to +499

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve the unrounded font size for style-risk checks

For fonts whose size is not quarter-point aligned, this stores only rounded_size, and NativeGeometryEvidence.add_line() later passes that same value both as the run-key component and as the font-size sample. The Python reference intentionally rounds only the run key while returning the raw size from _font_run_key() for _style_line_is_inflated; near the 4-point eligibility or 1.5× inflation thresholds, documents with repeated lines across pages can therefore get a different style-risk decision and unnecessary or missing geometry repairs. Store the raw size separately and use the rounded value only when interning the run key.

Useful? React with 👍 / 👎.

font_metadata.insert(ch.font, value.clone());
value
};
let (script, anchor) = if let Some(value) = groups.get(&ch.text) {
value.clone()
} else {
let group: String = script_group.call1((&ch.text,))?.extract()?;
let value = (group.clone(), group != "other");
groups.insert(ch.text.clone(), value.clone());
value
};
records.push(GeometryRecord {
source: (Some(ch.bbox), ch.loose, ch.tight, ch.origin, ch.rotation),
family: metadata.0,
font_size: metadata.1,
flags: metadata.2,
weight: metadata.3,
script,
anchor,
});
}
GEOMETRY_EVIDENCE_PREPARES.fetch_add(1, Ordering::Relaxed);
Ok(Some(NativeGeometryEvidence { records }))
}

/// 一次物化中的字体字典共享,字符和 Bbox 独立;不会在 Rust 快照缓存 Python 可变对象。
fn geometry<'py>(&self, py: Python<'py>) -> PyResult<(Bound<'py, PyAny>, Bound<'py, PyList>)> {
let bbox_type = py
Expand Down Expand Up @@ -531,6 +635,14 @@ impl NativeTextSnapshot {

#[pymethods]
impl NativeTextSnapshot {
/// 一次准备全文风险使用的页面级 geometry evidence。
fn prepare_geometry_evidence(
&self,
py: Python<'_>,
) -> PyResult<Option<NativeGeometryEvidence>> {
self.prepare_geometry_evidence_impl(py)
}

/// 兼容属性每次创建独立输出,快照在页面关闭后仍能使用。
fn materialize_geometry<'py>(&self, py: Python<'py>) -> PyResult<Bound<'py, PyAny>> {
self.geometry(py).map(|(geometry, _)| geometry)
Expand Down Expand Up @@ -1025,6 +1137,66 @@ impl NativeTextSnapshot {
}
}

#[pymethods]
impl NativeGeometryEvidence {
/// 把同源行成员一次转换并累积到全文风险器;None 表示本行选择参考路径。
#[pyo3(signature = (risk, runs, page, source, height, skip_y, indices, size, angle))]
fn add_line(
&self,
py: Python<'_>,
mut risk: pyo3::PyRefMut<'_, super::geometry_risk::NativeGeometryRisk>,
mut runs: pyo3::PyRefMut<'_, super::geometry_risk::NativeGeometryRuns>,
page: usize,
source: i64,
height: f64,
skip_y: bool,
indices: Vec<usize>,
size: [f64; 2],
angle: i32,
) -> Option<bool> {
if indices.iter().any(|&index| index >= self.records.len())
|| !size.iter().all(|v| v.is_finite())
{
GEOMETRY_EVIDENCE_FALLBACKS.fetch_add(1, Ordering::Relaxed);
return None;
}
let rows: Vec<_> = indices
.iter()
.map(|&index| self.records[index].source)
.collect();
let prepared = py.detach(|| geometry::source_rows(rows, size, angle));
let mut entries: Vec<Entry> = Vec::with_capacity(indices.len());
for (record, prepared) in indices
.iter()
.map(|&index| &self.records[index])
.zip(prepared)
{
let Some(row) = prepared else {
continue;
};
if !record.anchor || !record.font_size.is_finite() {
continue;
}
let key: RunKey = (
record.family.clone(),
geometry_number_key(record.font_size),
record.flags,
record.weight,
angle,
record.script.clone(),
);
let run = runs.intern(key);
entries.push((run, row.3, row.4, row.5, record.font_size));
}
GEOMETRY_EVIDENCE_LINES.fetch_add(1, Ordering::Relaxed);
let result = risk.add_prepared(py, page, source, height, skip_y, entries);
if result.is_none() {
GEOMETRY_EVIDENCE_FALLBACKS.fetch_add(1, Ordering::Relaxed);
}
result
}
}

/// 累计剖析中的视觉证据阶段耗时,单位为纳秒。
fn record_visual_stage_stats(values: [std::time::Duration; 6]) {
let mut slot = VISUAL_STAGE_NS
Expand Down
8 changes: 7 additions & 1 deletion src/docvortex/_compute_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path
from types import ModuleType

_PROTOCOL_VERSION = 27
_PROTOCOL_VERSION = 28
_SELECTED_MODE = None
_LOAD_FAILURE = None

Expand Down Expand Up @@ -59,6 +59,12 @@ def backend_info() -> dict[str, str | int | None]:
"native_classification_snapshot_calls": native.classification_snapshot_stats() if native is not None else 0,
"native_script_snapshot_batches": native.script_snapshot_stats() if native is not None else 0,
"native_span_assignment_calls": span_calls,
"native_table_script_cell_batches": table_scripts.table_script_stats()
if (table_scripts := sys.modules.get("docvortex.analyzers.native.pdf.table_text_styles")) is not None
else (0, 0, 0),
"native_geometry_evidence_stats": native.geometry_evidence_stats()
if native is not None and hasattr(native, "geometry_evidence_stats")
else (0, 0, 0),
"native_span_assignment_unsupported": span_unsupported,
"backend": "rust" if native is not None else "python",
"protocol": getattr(native, "PROTOCOL_VERSION", None),
Expand Down
Loading
Loading