rapid-structure-rs 1.0.0

Rust document-structure engine for PaddleOCR-family ONNX models: text detection/recognition (PP-OCRv6), table structure (SLANet-plus) and formula recognition (PP-FormulaNet) on Windows x64 via ONNX Runtime.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
use clap::Parser;
use rapid_structure_rs::evaluation::stats::{PERCENTILE_METHOD, percentile};
use rapid_structure_rs::{
    ClassifierPlan, ClassifierPolicy, DetectionPolicy, EngineConfig, ImageInput, OcrEngine,
    OcrRequest, OutputPolicy, PreprocessPolicy, RapidOcrEngine, RecognitionPolicy, StagePlan,
    TimingLedger, WordOutputMode,
};
use serde_json::json;
use std::{fs, path::PathBuf, sync::Arc, time::Instant};

#[derive(Debug, Parser)]
struct Cli {
    #[arg(long = "config")]
    config_path: Option<PathBuf>,
    #[arg(long, default_value = "datasets/text")]
    images_dir: PathBuf,
    #[arg(long, default_value_t = 1)]
    rounds: usize,
    #[arg(long, default_value_t = 0)]
    warmup_rounds: usize,
    #[arg(long)]
    output: Option<PathBuf>,
    #[arg(long)]
    max_side_len: Option<usize>,
    #[arg(long)]
    intra_threads: Option<usize>,
    /// SLANet-plus ONNX 路径。给出即启用页面级表格路由(门禁 G5 的对照开关)。
    ///
    /// 不给出时表格路由**显式关闭**,报告与启用表格功能之前逐位可比 —— 这正是
    /// G6(关闭时输出与基线一致)在性能侧的对照条件。
    #[arg(long)]
    table_model: Option<PathBuf>,
    /// 表格结构会话的 intra 线程数;缺省继承运行时推导值(E12 实测:改它没有依据)。
    #[arg(long)]
    table_intra_threads: Option<usize>,
}
fn main() {
    if let Err(e) = run() {
        eprintln!("error: {e}");
        std::process::exit(1);
    }
}
fn run() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();
    if cfg!(debug_assertions) {
        eprintln!(
            "warning: bench_warm_e2e is running a debug build; use `cargo run --release` for performance comparisons"
        );
    }
    let mut images = Vec::new();
    for e in fs::read_dir(&cli.images_dir)? {
        let p = e?.path();
        if p.is_file()
            && let Some(ext) = p.extension().and_then(|v| v.to_str())
            && matches!(
                ext.to_ascii_lowercase().as_str(),
                "jpg" | "jpeg" | "png" | "bmp" | "webp" | "tif" | "tiff"
            )
        {
            images.push(p);
        }
    }
    images.sort();
    if images.is_empty() {
        return Err(format!("no images found under {}", cli.images_dir.display()).into());
    }
    let mut cfg = match cli.config_path {
        Some(p) => EngineConfig::from_yaml_file(p)?,
        None => EngineConfig::default(),
    };
    if let Some(v) = cli.max_side_len {
        cfg.global.max_side_len = v;
    }
    // `--intra-threads` 仍然是矩阵脚本的入口,但它现在设置的是**唯一一份**运行时配置:
    // 三个阶段共享同一个 plan(见 `runtime::profile`)。
    if let Some(v) = cli.intra_threads {
        cfg.runtime.intra_threads = Some(v);
        cfg.runtime.inter_threads = Some(1);
        cfg.runtime.auto_tune_threads = false;
    }
    let effective_max_side = cfg.global.max_side_len as u32;
    // 配置里“请求”的运行时设置。实际生效的线程分配在 `meta.thread_plan`,
    // 由引擎解析后回报(例如 auto 的 Rayon 份额可能被已有全局池改写成实际值)。
    let runtime_config = serde_json::to_value(&cfg.runtime)?;
    let benchmark_meta = json!({
        "build_profile": if cfg!(debug_assertions) { "debug" } else { "release" },
        "max_side_len": effective_max_side,
        "runtime": runtime_config,
        "timing_scope": {
            "wall_ms": "file_read_plus_ocr",
            "ocr_total_ms": "ocr_pipeline_only",
        },
    });
    let engine_start = Instant::now();
    let mut engine = RapidOcrEngine::new(cfg)?;
    let init_ms = engine_start.elapsed().as_secs_f64() * 1000.0;
    // 引擎解析出的线程 plan:报告里的数字是**生效值**,不是 YAML 里写了什么。
    let thread_plan = serde_json::to_value(&engine.runtime_profile().threads)?;
    // 表格路由:不给 `--table-model` 就保持关闭,且**不校验模型路径**(关闭即无影响)。
    let table_policy = match &cli.table_model {
        Some(model_path) => rapid_structure_rs::TablePolicy {
            enabled: true,
            model_path: Some(model_path.clone()),
            intra_threads: cli.table_intra_threads,
            ..rapid_structure_rs::TablePolicy::default()
        },
        None => rapid_structure_rs::TablePolicy::default(),
    };
    let table_meta = json!({
        "enabled": table_policy.enabled,
        "model_path": cli.table_model.as_ref().map(|p| p.display().to_string()),
        "input_side_len": table_policy.input_side_len,
        "intra_threads": table_policy.intra_threads,
    });
    let request = |bytes: Vec<u8>| OcrRequest {
        input: ImageInput::Encoded(Arc::from(bytes)),
        roi: None,
        scale_hint: None,
        stages: StagePlan {
            detect: true,
            classify: ClassifierPlan {
                policy: ClassifierPolicy::IfAvailable,
                apply_rotation: true,
            },
            recognize: true,
        },
        preprocess: PreprocessPolicy {
            max_side: Some(effective_max_side),
            ..PreprocessPolicy::default()
        },
        detection: DetectionPolicy::default(),
        recognition: RecognitionPolicy {
            words: WordOutputMode::Off,
        },
        output: OutputPolicy::default(),
        formula: rapid_structure_rs::FormulaPolicy::default(),
        table: table_policy.clone(),
    };
    for _ in 0..cli.warmup_rounds {
        for p in &images {
            let _ = engine.recognize(request(fs::read(p)?))?;
        }
    }
    let mut wall = Vec::new();
    let mut total = Vec::new();
    let mut regions = Vec::new();
    // 分阶段耗时:每个样本的 `OcrOutput::timings` / `OcrOutput::stages` 都是**同一次
    // 请求内**的分阶段计时,因此这些样本可以独立统计,不需要把一次请求的内部阶段
    // 互相加减。检测器/分类器/识别器各自的 preprocess / infer / postprocess 分开收集,
    // 使报告能直接回答“时间花在 ORT 推理上还是 Rust 前后处理上”。
    let mut stages = StageSamples::default();
    // 时间账本:每个样本各自算一份(每一项只算一次),最后按字段求均值,
    // 再在均值账本上做守恒检查。`timing_ledger.conservation` 是守恒判据本身。
    let mut ledgers: Vec<TimingLedger> = Vec::new();
    // 记录实际解析到的 provider 与是否发生 CPU fallback:GPU 基准不得把
    // fallback 当加速成功(阶段 0/2/5 的证据要求)。
    let mut provider_resolution = None;
    for _ in 0..cli.rounds {
        for p in &images {
            let start = Instant::now();
            let out = engine.recognize(request(fs::read(p)?))?;
            wall.push(start.elapsed().as_secs_f64() * 1000.0);
            total.push(out.timings.total_ms as f64);
            regions.push(out.regions.len() as f64);
            stages.push(&out);
            ledgers.push(TimingLedger::from_timings(&out.timings));
            if provider_resolution.is_none() {
                let describe = |info: &rapid_structure_rs::ProviderResolutionInfo| {
                    serde_json::json!({
                        "requested": format!("{:?}", info.requested),
                        // `selected_ep` 只表示“交给 ORT 的 EP 链头部”,不是逐节点执行证据。
                        "selected_ep": format!("{:?}", info.selected_ep),
                        "fallback_to_cpu": info.fallback_to_cpu,
                    })
                };
                provider_resolution = Some(serde_json::json!({
                    "model_id": out.engine.model_id,
                    "detector": describe(&out.engine.provider.detector),
                    "classifier": out.engine.provider.classifier.as_ref().map(&describe),
                    "recognizer": describe(&out.engine.provider.recognizer),
                }));
            }
        }
    }
    let stage_report = stages.into_report(total.len());
    // 总耗时均值与分阶段均值来自同一批样本,因此可以安全相除得到时间占比。
    let total_avg = if total.is_empty() {
        0.0
    } else {
        total.iter().sum::<f64>() / total.len() as f64
    };
    let ledger_report = ledger_report(&ledgers);
    let timing_split = inference_share(&ledgers, total_avg);
    let report = json!({
        "meta": {
            "images_dir": cli.images_dir,
            "image_count": images.len(),
            "rounds": cli.rounds,
            "warmup_rounds": cli.warmup_rounds,
            // 启动时间:`RapidOcrEngine::new` 的墙钟耗时(含模型加载与会话创建)。
            "init_ms": init_ms,
            "benchmark": benchmark_meta,
            // 引擎解析出的线程分配(生效值;三个阶段共享同一个 plan)。
            // `null` 表示该值未被配置(交给 ORT / Rayon 自己的默认值)。
            "thread_plan": thread_plan,
            // 页面级表格路由的开关与参数;`enabled = false` 的报告与启用表格功能之前
            // 逐位可比(门禁 G6 的性能侧对照条件)。
            "table": table_meta,
            "provider_resolution": provider_resolution,
            // 实际使用的 ONNX Runtime 指纹:API 版本字符串 + 运行库文件(路径/体积/SHA-256)
            // + provider DLL。本 crate 用 `rustc-link-lib=static=onnxruntime` 链接,
            // 进程里没有 onnxruntime.dll 模块,因此指纹来自静态库或 exe(见
            // `runtime::ort_runtime`)。没有指纹的报告不可与其它报告严格比较。
            "ort_runtime": rapid_structure_rs::ort_runtime_fingerprint(),
            // 版本字符串仍然单独保留(旧报告里叫 `ort_runtime_version`),
            // 让“版本”这一项可以直接被 grep 到,不必先理解整个指纹结构。
            "ort_runtime_version": rapid_structure_rs::ort_runtime_version(),
        },
        "percentile_method": PERCENTILE_METHOD,
        "stats": {
            "wall_ms": stats(&wall),
            "ocr_total_ms": stats(&total),
            "regions": stats(&regions),
        },
        // 分阶段统计:每个指标都带 count / min / max / avg / p50 / p90,口径与
        // `stats.wall_ms` / `stats.ocr_total_ms` 完全一致(同一个 `stats()` helper)。
        "stages": stage_report,
        // 时间账本:每一项只算一次,余量显式列出,并给出守恒判定 + **解释**
        // (`conservation.interpretation`)。它是**诊断**仪器:`total_ms` 是“外层
        // `preprocess_ms` 窗口 + 内层 `inner.run()` 窗口”的总窗口,而命名分量只是这些窗口里的
        // 若干子窗口(两个窗口串行,不是重叠),因此占比只在 `conservation.excess_ms` 内成立,
        // 不能作为性能验收依据。
        "timing_ledger": ledger_report,
        // ORT 推理 vs Rust 前后处理的时间占比(阶段 6 门槛证据的来源之一);
        // 数值与 `timing_ledger` 一致,保留这个键是为了让旧的对比方式仍然可用。
        // 报告的恒等式是
        // `ort_inference_ms + rust_ms + input_overflow_ms + unattributed_ms == denominator_ms`
        // (`identity` / `identity_residual_ms` 两个键把它写进了报告本身)。
        // 与 `timing_ledger` 一样带着残差量级,读取时必须一起看。
        "timing_split": timing_split,
        // 峰值工作集口径与库内一致:`windows:GetProcessMemoryInfo.PeakWorkingSetSize`。
        "memory": {
            "peak_working_set_bytes": rapid_structure_rs::peak_working_set_bytes(),
            "source": rapid_structure_rs::peak_memory_source(),
        },
    });
    let text = serde_json::to_string_pretty(&report)?;
    if let Some(p) = cli.output {
        fs::write(&p, &text)?;
    }
    println!("{text}");
    Ok(())
}
fn stats(v: &[f64]) -> serde_json::Value {
    if v.is_empty() {
        return json!({"count":0});
    }
    let mut s = v.to_vec();
    s.sort_by(|a, b| a.total_cmp(b));
    let avg = s.iter().sum::<f64>() / s.len() as f64;
    // P0-2:p50/p90 统一走共享层 `stats::percentile`(线性插值),不再是旧的
    // “下中位 + 最近秩”。键名不变(`count/avg/p50/p90/min/max`),口径由报告顶层
    // 的 `percentile_method` 标记。
    json!({"count":s.len(),"avg":avg,"p50":percentile(&s,0.50),"p90":percentile(&s,0.90),"min":s[0],"max":s[s.len()-1]})
}

/// `None` 计时(某个阶段没有运行)不得被当成 0 ms 混进统计。
///
/// `OcrOutput` 用 `Option<f32>` 表达“该阶段没有执行”,例如关闭分类器后
/// `classifier_infer_ms` 为 `None`。把 `None` 折算成 `0.0` 会把“未运行”和
/// “运行了但耗时为零”混为一谈,从而系统性拉低均值。
#[derive(Default)]
struct StageSamples {
    input_decode_ms: Vec<f64>,
    input_resize_ms: Vec<f64>,
    input_crop_ms: Vec<f64>,
    detector_preprocess_ms: Vec<f64>,
    detector_infer_ms: Vec<f64>,
    detector_postprocess_ms: Vec<f64>,
    classifier_preprocess_ms: Vec<f64>,
    classifier_infer_ms: Vec<f64>,
    classifier_postprocess_ms: Vec<f64>,
    recognizer_preprocess_ms: Vec<f64>,
    recognizer_infer_ms: Vec<f64>,
    recognizer_postprocess_ms: Vec<f64>,
    preprocess_ms: Vec<f64>,
    postprocess_ms: Vec<f64>,
}

impl StageSamples {
    fn push(&mut self, out: &rapid_structure_rs::OcrOutput) {
        let push_opt = |dst: &mut Vec<f64>, value: Option<f32>| {
            if let Some(value) = value {
                dst.push(f64::from(value));
            }
        };
        push_opt(&mut self.input_decode_ms, Some(out.timings.decode_ms));
        push_opt(&mut self.input_resize_ms, Some(out.timings.resize_ms));
        push_opt(&mut self.input_crop_ms, Some(out.timings.crop_ms));

        push_opt(
            &mut self.detector_preprocess_ms,
            out.stages.detector.timing.map(|t| t.preprocess_ms),
        );
        push_opt(
            &mut self.detector_infer_ms,
            out.stages.detector.timing.map(|t| t.infer_ms),
        );
        push_opt(
            &mut self.detector_postprocess_ms,
            out.stages.detector.timing.map(|t| t.postprocess_ms),
        );

        push_opt(
            &mut self.classifier_preprocess_ms,
            out.stages.classifier.timing.map(|t| t.preprocess_ms),
        );
        push_opt(
            &mut self.classifier_infer_ms,
            out.stages.classifier.timing.map(|t| t.infer_ms),
        );
        push_opt(
            &mut self.classifier_postprocess_ms,
            out.stages.classifier.timing.map(|t| t.postprocess_ms),
        );

        push_opt(
            &mut self.recognizer_preprocess_ms,
            out.stages.recognizer.timing.map(|t| t.preprocess_ms),
        );
        push_opt(
            &mut self.recognizer_infer_ms,
            out.stages.recognizer.timing.map(|t| t.infer_ms),
        );
        push_opt(
            &mut self.recognizer_postprocess_ms,
            out.stages.recognizer.timing.map(|t| t.postprocess_ms),
        );

        // 顶层 preprocess_ms / postprocess_ms 已经跨阶段求和,是“Rust 侧”总账。
        self.preprocess_ms
            .push(f64::from(out.timings.preprocess_ms));
        self.postprocess_ms
            .push(f64::from(out.timings.postprocess_ms));
    }

    fn into_report(self, samples: usize) -> serde_json::Value {
        json!({
            "unit": "ms",
            "samples": samples,
            "input": {
                "decode_ms": stats(&self.input_decode_ms),
                "resize_ms": stats(&self.input_resize_ms),
                "crop_ms": stats(&self.input_crop_ms),
            },
            "detector": {
                "preprocess_ms": stats(&self.detector_preprocess_ms),
                "infer_ms": stats(&self.detector_infer_ms),
                "postprocess_ms": stats(&self.detector_postprocess_ms),
            },
            "classifier": {
                "preprocess_ms": stats(&self.classifier_preprocess_ms),
                "infer_ms": stats(&self.classifier_infer_ms),
                "postprocess_ms": stats(&self.classifier_postprocess_ms),
            },
            "recognizer": {
                "preprocess_ms": stats(&self.recognizer_preprocess_ms),
                "infer_ms": stats(&self.recognizer_infer_ms),
                "postprocess_ms": stats(&self.recognizer_postprocess_ms),
            },
            // 跨阶段总账:`preprocess_ms` 是三个阶段的 preprocess 之和,
            // `postprocess_ms` 同理。两者都不包含 ORT 推理时间。
            "page_total": {
                "preprocess_ms": stats(&self.preprocess_ms),
                "postprocess_ms": stats(&self.postprocess_ms),
            },
        })
    }
}

/// 守恒时间账本报告。
///
/// 每个样本各算一份账([`TimingLedger::from_timings`],每一项只算一次),再按字段求
/// 均值。守恒检查建立在均值账本上,而**均值账本与每个样本账本一样并不严格守恒**:
/// `total_ms` 是“外层 `preprocess_ms` 窗口(`rapid_structure.rs:619-719`)+ 内层 `inner.run()`
/// 窗口(`rapid_structure.rs:106-130`)”的总窗口,命名分量只是其中的若干子窗口,
/// 两者口径不同(**两个窗口串行,不是重叠**)。
///
/// 因此这里必须把“不守恒该怎么读”一起写进 JSON:
/// `conservation.interpretation` 明确写出“残差是 `total_ms` 与命名分量之和的口径差异、
/// 两个窗口串行、账本是诊断工具、占比只在残差量级内成立、`total_ms` 本身不受影响”,
/// `conservation.excess_ms` 给出该量级。
/// 报告的使用者**不得**把账本当作性能验收依据,也不得把 `conserved = false` 读成
/// “总量算错了”。
fn ledger_report(ledgers: &[TimingLedger]) -> serde_json::Value {
    if ledgers.is_empty() {
        return json!({
            "unit": "ms",
            "samples": 0,
            "error": "no timing samples were collected",
        });
    }
    let mean = TimingLedger::mean(ledgers);
    let conservation = mean.conservation();
    let shares = mean.shares();
    json!({
        "unit": "ms",
        "samples": ledgers.len(),
        "basis": "per-sample ledger, then per-field mean (each component counted exactly once)",
        "components": mean,
        "totals": {
            "input_ms": mean.input_ms(),
            "input_preprocess_ms": mean.input_preprocess_ms,
            "input_overflow_ms": mean.input_overflow_ms,
            "model_preprocess_ms": mean.model_preprocess_ms(),
            "inference_ms": mean.inference_ms(),
            "model_postprocess_ms": mean.model_postprocess_ms(),
            "page_postprocess_ms": mean.page_postprocess_ms,
            "formula_ms": mean.formula_ms,
            "table_detect_ms": mean.table_detect_ms,
            "table_structure_infer_ms": mean.table_structure_infer_ms,
            "table_backfill_ms": mean.table_backfill_ms,
            "rust_ms": mean.rust_ms(),
            "attributed_ms": mean.attributed_ms(),
            "unattributed_ms": mean.unattributed_ms,
        },
        "shares": shares,
        // 守恒判据 + **怎么读它**:`conservation.interpretation` 说明残差是“`total_ms` 的总窗口
        // 与命名分量之和”的口径差异(两个窗口串行,不是重叠),不是 `total_ms` 算错了;
        // `excess_ms` 是占比成立的上界。这个账本是诊断工具,不能作为性能验收依据。
        "conservation": conservation,
        // 每个样本各自的余量:用来看“均值守恒”是不是掩盖了单样本的大偏差。
        "per_sample_unattributed_ms": stats(
            &ledgers.iter().map(|ledger| ledger.unattributed_ms).collect::<Vec<_>>()
        ),
        "per_sample_residual_ms": stats(
            &ledgers
                .iter()
                .map(|ledger| ledger.conservation().residual_ms)
                .collect::<Vec<_>>()
        ),
    })
}

/// ONNX Runtime 推理 vs Rust 前后处理的时间占比。
///
/// 分子来自时间账本的均值,分母是 `stats.ocr_total_ms.avg`(同一个口径,来自
/// `OcrTimings::total_ms`)。
///
/// # 报告出来的恒等式
///
/// ```text
/// ort_inference_ms + rust_ms + input_overflow_ms + unattributed_ms == denominator_ms
/// ```
///
/// 各项都是时间账本(`timing_ledger.components` / `timing_ledger.totals`)里的同名量:
///
/// - `ort_inference_ms` = `TimingLedger::inference_ms()`(三个阶段 ORT 推理之和);
/// - `rust_ms` = `TimingLedger::rust_ms()`:输入侧用的是**被截断到外层 `preprocess_ms`
///   窗口内**的 `input_ms()`(= `input_named_within_ms + input_other_ms`)。选这个定义
///   是为了与账本按构造成立的不变量 `input_ms() <= input_preprocess_ms` 一致:账本不得
///   声称比它唯一能观测输入时间的那个窗口更多的输入时间,因此 `rust_ms` 只代表“外层窗口
///   内的 Rust 成本”;
/// - `input_overflow_ms` = `TimingLedger::input_overflow_ms`:`decode + resize + crop`
///   超出外层窗口、因此**不在** `rust_ms` 里的那部分输入时间;
/// - `unattributed_ms` = `total_ms - attributed_ms()`。
///
/// 账本的 `attributed_ms()` 计入**完整**的输入三项
/// (`input_named_within_ms + input_overflow_ms`),所以
/// `attributed_ms() - (rust_ms() + inference_ms()) == input_overflow_ms`:溢出项必须作为
/// 独立项出现在恒等式里。旧文档写的 `inference + rust + unattributed == total` 只在
/// `input_overflow_ms == 0` 时成立,溢出分支下它会少掉这个量。这一项**不**折进 `rust_ms`
/// (否则 `rust_ms` 就不再是“外层窗口内的 Rust 成本”,与 `input_ms() <= preprocess_ms`
/// 的定义矛盾),也**不**折进余量(否则已命名的分量会被藏进“未归属”)。
///
/// 按构造成立**不等于**这些占比是精确划分:账本的各个分量与 `total_ms` 的口径不同
/// (见 `timing_ledger.conservation`),所以每一项占比只在
/// `conservation.excess_ms` 的量级内成立(release 实测 ±0.69%)。这个量级足以支撑
/// “瓶颈在 ORT(比每个 Rust 分量大一个数量级)”,但不足以支撑更细的性能结论。
fn inference_share(ledgers: &[TimingLedger], total_avg: f64) -> serde_json::Value {
    if ledgers.is_empty() {
        return json!({"error": "no timing samples were collected"});
    }
    if total_avg <= 0.0 {
        return json!({"error": "ocr_total_ms.avg is zero; cannot compute shares"});
    }
    let mean = TimingLedger::mean(ledgers);
    let inference = mean.inference_ms();
    let rust = mean.rust_ms();
    let input_overflow = mean.input_overflow_ms;
    let unattributed = mean.unattributed_ms;
    let share = |value: f64| value / total_avg;
    // 恒等式的实测残差:按构造只应是浮点累加差(`total_avg` 与均值账本的 `total_ms`
    // 是同一批 `OcrTimings::total_ms` 的两种求和顺序)。把它一起报出来,读到这份 JSON 的
    // 人不必先去核对文档就能验证恒等式。
    let identity_sum = inference + rust + input_overflow + unattributed;
    json!({
        "basis": "per-sample ledger mean; the same samples back stats.ocr_total_ms",
        "identity": "ort_inference_ms + rust_ms + input_overflow_ms + unattributed_ms == denominator_ms",
        "identity_residual_ms": total_avg - identity_sum,
        "denominator_ms": total_avg,
        "ort_inference_ms": inference,
        "ort_inference_share": share(inference),
        "detector_infer_share": share(mean.detector_infer_ms),
        "classifier_infer_share": share(mean.classifier_infer_ms),
        "recognizer_infer_share": share(mean.recognizer_infer_ms),
        // 表格结构识别是推理,已计入 `ort_inference_ms`;单列出来是为了让
        // `ort_inference_ms` 的增长始终可解释(表格未启用时为 0)。
        "table_structure_infer_share": share(mean.table_structure_infer_ms),
        "rust_ms": rust,
        "rust_share": share(rust),
        // 兼容旧的字段名:以前把“页面级 preprocess + postprocess”称作 rust_preprocess /
        // rust_postprocess。这两个数字现在**不再是**那两项(那两项本身漏项且重复),
        // 而是时间账本里的输入侧与后处理侧,读旧字段名的人必须知道这一点。
        "rust_input_ms": mean.input_ms(),
        "rust_input_share": share(mean.input_ms()),
        "rust_model_preprocess_ms": mean.model_preprocess_ms(),
        "rust_model_preprocess_share": share(mean.model_preprocess_ms()),
        "rust_model_postprocess_ms": mean.model_postprocess_ms(),
        "rust_model_postprocess_share": share(mean.model_postprocess_ms()),
        "rust_page_postprocess_ms": mean.page_postprocess_ms,
        "rust_page_postprocess_share": share(mean.page_postprocess_ms),
        // 表格域的两个 Rust 侧分量也计入 `rust_ms`,同样单列(未启用时为 0)。
        "rust_table_detect_ms": mean.table_detect_ms,
        "rust_table_detect_share": share(mean.table_detect_ms),
        "rust_table_backfill_ms": mean.table_backfill_ms,
        "rust_table_backfill_share": share(mean.table_backfill_ms),
        // 输入侧溢出:`decode + resize + crop` 超出外层 `preprocess_ms` 窗口的量。
        // `rust_ms` 用的是截断后的 `input_ms()`,所以这一项是恒等式里不可省略的独立项。
        "input_overflow_ms": input_overflow,
        "input_overflow_share": share(input_overflow),
        // 被命名分量之和;`attributed_ms - (rust_ms + ort_inference_ms) == input_overflow_ms`。
        "attributed_ms": mean.attributed_ms(),
        "attributed_share": share(mean.attributed_ms()),
        "unattributed_ms": unattributed,
        "unattributed_share": share(unattributed),
    })
}

/// `timing_split` 的守恒回归:**`bench_warm_e2e` 级别**的恒等式测试。
///
/// `src/runtime/timing.rs` 的测试只覆盖账本自身(`TimingLedger`)。这里覆盖的是报告层:
/// `timing_split` 报出来的分解必须真的能加回 `denominator_ms`,包括
/// `input_overflow_ms > 0` 的分支——旧文档的 `inference + rust + unattributed == total`
/// 正是在那个分支上少掉溢出项。
#[cfg(test)]
mod tests {
    use super::*;

    /// 页面基准报告里 `stats.<metric>` 的键集合(P0-0 契约)。
    ///
    /// 三份报告(页面基准 / 表格报告 / 公式评测)的分位口径不同,批 3 才统一;
    /// 这一批先把键集合快照下来,保证“接到 `stats::percentile`”时只改数值算法、
    /// 不改键名。空分布只报 `count`(不能 panic)。
    #[test]
    fn the_page_benchmark_stats_report_a_frozen_key_set() {
        let full = stats(&[1.0, 2.0, 3.0, 4.0]);
        let mut keys: Vec<&str> = full
            .as_object()
            .expect("non-empty stats must be an object")
            .keys()
            .map(String::as_str)
            .collect();
        keys.sort_unstable();
        assert_eq!(
            keys,
            vec!["avg", "count", "max", "min", "p50", "p90"],
            "the page benchmark must keep exactly these stats keys"
        );

        let empty = stats(&[]);
        let empty_obj = empty.as_object().expect("empty stats must be an object");
        let empty_keys: Vec<&str> = empty_obj.keys().map(String::as_str).collect();
        assert_eq!(
            empty_keys,
            vec!["count"],
            "an empty distribution reports only `count`"
        );
        assert_eq!(empty["count"], 0);
    }

    /// P0-2 对拍用例:页面基准的 p50/p90 现在与共享层 `stats::percentile`(线性)一致。
    ///
    /// `[1,2,3,4]` 旧口径给出 `p50=2`(下中位)、`p90=4`(最近秩);统一后
    /// 应为 `p50=2.5`、`p90=3.7`。键名不变(上方契约用例已钉住)。
    #[test]
    fn the_page_benchmark_stats_use_the_shared_linear_percentile() {
        use rapid_structure_rs::evaluation::stats::percentile;
        let sorted = [1.0, 2.0, 3.0, 4.0];
        let full = stats(&[1.0, 2.0, 3.0, 4.0]);
        assert_eq!(full["p50"], percentile(&sorted, 0.50));
        assert_eq!(full["p50"], 2.5);
        assert!((full["p90"].as_f64().unwrap() - 3.7).abs() < 1e-12);
    }

    /// P0-2:页面基准报告顶层写的 `percentile_method` 必须等于单点常量 `"linear"`。
    #[test]
    fn the_page_benchmark_declares_the_linear_percentile_method() {
        assert_eq!(
            rapid_structure_rs::evaluation::stats::PERCENTILE_METHOD,
            "linear"
        );
    }

    /// 手工构造账本:只填恒等式用到的分量,其余为 0;
    /// `unattributed_ms` 与 `from_timings` 一样等于 `total_ms - attributed_ms()`。
    fn ledger(
        total_ms: f64,
        infer_ms: f64,
        input_named_within_ms: f64,
        input_overflow_ms: f64,
    ) -> TimingLedger {
        let mut ledger = TimingLedger {
            total_ms,
            input_preprocess_ms: input_named_within_ms,
            input_named_within_ms,
            input_overflow_ms,
            detector_infer_ms: infer_ms,
            ..TimingLedger::default()
        };
        ledger.unattributed_ms = ledger.total_ms - ledger.attributed_ms();
        ledger
    }

    fn number(split: &serde_json::Value, key: &str) -> f64 {
        split[key]
            .as_f64()
            .unwrap_or_else(|| panic!("`timing_split.{key}` must be a number: {split}"))
    }

    /// 四项之和必须精确回到分母;占比之和必须回到 1。
    fn assert_split_identity(split: &serde_json::Value) {
        let total = number(split, "denominator_ms");
        let sum = number(split, "ort_inference_ms")
            + number(split, "rust_ms")
            + number(split, "input_overflow_ms")
            + number(split, "unattributed_ms");
        assert!(
            (sum - total).abs() < 1e-9,
            "the reported identity must hold: {sum} != {total}: {split}"
        );
        assert!(
            number(split, "identity_residual_ms").abs() < 1e-9,
            "the report must carry a zero identity residual: {split}"
        );
        let share_sum = number(split, "ort_inference_share")
            + number(split, "rust_share")
            + number(split, "input_overflow_share")
            + number(split, "unattributed_share");
        assert!(
            (share_sum - 1.0).abs() < 1e-9,
            "the reported shares must sum to 1: {share_sum}: {split}"
        );
        assert!(
            split["identity"]
                .as_str()
                .is_some_and(|value| value.contains("input_overflow_ms")),
            "the report must name `input_overflow_ms` as part of the identity: {split}"
        );
    }

    /// `input_overflow_ms == 0`:恒等式成立(旧文档在这里是对的)。
    #[test]
    fn timing_split_identity_holds_without_input_overflow() {
        let sample = ledger(1000.0, 800.0, 100.0, 0.0);
        assert_eq!(sample.unattributed_ms, 100.0);
        let split = inference_share(&[sample], sample.total_ms);
        assert_eq!(number(&split, "denominator_ms"), 1000.0);
        assert_eq!(number(&split, "ort_inference_ms"), 800.0);
        assert_eq!(number(&split, "rust_ms"), 100.0);
        assert_eq!(number(&split, "input_overflow_ms"), 0.0);
        assert_eq!(number(&split, "unattributed_ms"), 100.0);
        assert_split_identity(&split);
    }

    /// `input_overflow_ms > 0`:`decode + resize + crop` 超出外层窗口时,旧恒等式
    /// (缺 `input_overflow_ms`)会少掉这一项,新恒等式必须仍然成立,并且溢出项
    /// 必须**显式**出现在报告里(既不折进 `rust_ms`,也不折进 `unattributed_ms`)。
    #[test]
    fn timing_split_identity_keeps_input_overflow_explicit() {
        // 与 `src/runtime/timing.rs::input_overflow_keeps_the_input_invariant_by_construction`
        // 同一口径:外层窗口 5 ms,命名三项之和 12 ms → 溢出 7 ms。
        let timings = rapid_structure_rs::OcrTimings {
            decode_ms: 2.0,
            resize_ms: 7.0,
            crop_ms: 3.0,
            preprocess_ms: 5.0,
            detector_preprocess_ms: 1.0,
            detector_infer_ms: 10.0,
            detector_postprocess_ms: 1.0,
            detect_ms: 12.0,
            classifier_preprocess_ms: 0.0,
            classifier_infer_ms: 0.0,
            classifier_postprocess_ms: 0.0,
            classify_ms: 0.0,
            recognizer_preprocess_ms: 1.0,
            recognizer_infer_ms: 20.0,
            recognizer_postprocess_ms: 2.0,
            recognize_ms: 23.0,
            formula_ms: 0.0,
            table_detect_ms: 0.0,
            table_structure_infer_ms: 0.0,
            table_backfill_ms: 0.0,
            postprocess_ms: 3.0,
            total_ms: 43.0,
        };
        let sample = TimingLedger::from_timings(&timings);
        assert_eq!(sample.input_overflow_ms, 7.0);
        assert_eq!(sample.total_ms, 43.0);

        let split = inference_share(&[sample], sample.total_ms);
        assert_eq!(number(&split, "ort_inference_ms"), 30.0);
        assert_eq!(number(&split, "rust_ms"), 13.0);
        assert_eq!(number(&split, "input_overflow_ms"), 7.0);
        assert_eq!(number(&split, "unattributed_ms"), -7.0);
        // 被命名分量之和比 `rust + inference` 多的部分恰好是溢出量。
        assert!(
            (number(&split, "attributed_ms")
                - (number(&split, "rust_ms") + number(&split, "ort_inference_ms"))
                - number(&split, "input_overflow_ms"))
            .abs()
                < 1e-9,
            "the overflow must be the only gap between rust+inference and attributed: {split}"
        );
        // 旧恒等式(缺 `input_overflow_ms`)在这个样本上不成立——这正是被修掉的缺陷。
        let stale = number(&split, "ort_inference_ms")
            + number(&split, "rust_ms")
            + number(&split, "unattributed_ms");
        assert!(
            (stale - number(&split, "denominator_ms")).abs() > 1.0,
            "this sample must really exercise the overflow branch: {split}"
        );
        assert_split_identity(&split);
    }
}