use serde::Serialize;
use crate::{OcrOutput, error::Result};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct OcrJsonItem {
#[serde(rename = "box", skip_serializing_if = "Option::is_none")]
pub box_: Option<[[f64; 2]; 4]>,
pub txt: String,
pub score: f64,
pub kind: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub latex: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eos_index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub truncated: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub table: Option<OcrTableItem>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct OcrTableItem {
pub rows: usize,
pub cols: usize,
pub confidence: &'static str,
pub degradations: Vec<crate::TableDegradation>,
pub holes: usize,
pub cells: usize,
pub table_html: String,
}
fn polygon_box(region: &crate::OcrRegion) -> Option<[[f64; 2]; 4]> {
region.polygon.map(|polygon| {
polygon
.points
.map(|point| [point[0] as f64, point[1] as f64])
})
}
pub fn to_output_items(output: &OcrOutput) -> Vec<OcrJsonItem> {
output
.regions
.iter()
.filter_map(|region| {
if let Some(table) = region.table.as_ref() {
return Some(OcrJsonItem {
box_: polygon_box(region),
txt: table.markdown.clone(),
score: region
.detection
.as_ref()
.map(|detection| detection.score as f64)
.unwrap_or(0.0),
kind: "table",
latex: None,
eos_index: None,
truncated: None,
table: Some(OcrTableItem {
rows: table.rows,
cols: table.cols,
confidence: table.confidence.as_str(),
degradations: table.degradations.clone(),
holes: table.holes,
cells: table.cells.len(),
table_html: table.table_html.clone(),
}),
});
}
if let Some(formula) = region.formula.as_ref() {
return Some(OcrJsonItem {
box_: polygon_box(region),
txt: formula.latex.clone(),
score: region
.detection
.as_ref()
.map(|detection| detection.score as f64)
.unwrap_or(0.0),
kind: "formula",
latex: Some(formula.latex.clone()),
eos_index: formula.eos_index,
truncated: Some(formula.truncated),
table: None,
});
}
let recognition = region.recognition.as_ref()?;
Some(OcrJsonItem {
box_: polygon_box(region),
txt: recognition.text.clone(),
score: recognition.score as f64,
kind: "text",
latex: None,
eos_index: None,
truncated: None,
table: None,
})
})
.collect()
}
pub fn to_output_json(output: &OcrOutput) -> Result<Value> {
let mut value = serde_json::to_value(output)
.map_err(|error| crate::error::RapidOcrError::InvalidInput(error.to_string()))?;
if let Value::Object(object) = &mut value {
object.insert(
"text".into(),
Value::String(output.plain_text(crate::TextOrder::Reading)),
);
object.insert(
"items".into(),
serde_json::to_value(to_output_items(output))
.map_err(|error| crate::error::RapidOcrError::InvalidInput(error.to_string()))?,
);
object.insert(
"formulas".into(),
serde_json::to_value(
output
.formula_latex(crate::TextOrder::Reading)
.into_iter()
.map(|(region_id, latex)| {
serde_json::json!({ "region": region_id, "latex": latex })
})
.collect::<Vec<_>>(),
)
.map_err(|error| crate::error::RapidOcrError::InvalidInput(error.to_string()))?,
);
object.insert(
"tables".into(),
serde_json::to_value(
output
.tables(crate::TextOrder::Reading)
.into_iter()
.map(|(region_id, table)| {
serde_json::json!({
"region": region_id,
"rows": table.rows,
"cols": table.cols,
"confidence": table.confidence.as_str(),
"degradations": table.degradations,
"holes": table.holes,
"cells": table.cells.len(),
"html": table.html,
"table_html": table.table_html,
"markdown": table.markdown,
"model_id": table.model_id,
})
})
.collect::<Vec<_>>(),
)
.map_err(|error| crate::error::RapidOcrError::InvalidInput(error.to_string()))?,
);
}
Ok(value)
}
#[cfg(test)]
mod tests {
use super::{to_output_items, to_output_json};
use crate::{
CoordinateSpace, EngineInfo, FormulaOutcome,
GenericProviderPreference as ProviderPreference, ImageInfo, InputTimings, OcrOutput,
OcrRegion, OcrTimings, Polygon, ProviderInfo, ProviderResolutionInfo, RecognitionOutcome,
RegionKind, RegionSource, ResolvedProvider, StageReport, StageReports, StageState,
TableOutcome, TableStructureConfidence,
};
#[test]
fn output_items_empty_for_a_page_with_no_regions() {
assert_eq!(to_output_items(&output_with(Vec::new())), Vec::new());
}
#[test]
fn formula_json_token_ids_are_serialized_only_when_present() {
let formula_region = |token_ids: Option<Vec<i64>>| OcrRegion {
source: RegionSource::Detected { detector_index: 0 },
kind: RegionKind::Formula,
polygon: Some(Polygon {
points: [[1.0, 6.0], [5.0, 6.0], [5.0, 9.0], [1.0, 9.0]],
}),
detection: Some(crate::DetectionOutcome { score: 0.71 }),
classification: None,
recognition: None,
formula: Some(FormulaOutcome {
latex: "x < y & z".into(),
eos_index: Some(2),
truncated: false,
model_id: "test-model".into(),
token_ids,
}),
table: None,
};
let document =
to_output_json(&output_with(vec![formula_region(None)])).expect("json document");
let formula = &document["regions"][0]["formula"];
assert_eq!(formula["latex"], "x < y & z");
assert_eq!(formula["eos_index"], 2);
assert_eq!(formula["truncated"], false);
assert_eq!(formula["model_id"], "test-model");
assert_eq!(formula["token_ids"], serde_json::Value::Null);
let document = to_output_json(&output_with(vec![formula_region(Some(vec![0, 82, 2]))]))
.expect("json document");
assert_eq!(
document["regions"][0]["formula"]["token_ids"],
serde_json::json!([0, 82, 2])
);
}
#[test]
fn formula_region_uses_latex_and_stays_out_of_plain_text() {
let output = output_with(vec![
OcrRegion::text(
RegionSource::Detected { detector_index: 0 },
Some(Polygon {
points: [[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]],
}),
None,
None,
Some(RecognitionOutcome {
text: "ok".into(),
score: 0.9,
words: None,
}),
),
OcrRegion {
source: RegionSource::Detected { detector_index: 0 },
kind: RegionKind::Formula,
polygon: Some(Polygon {
points: [[1.0, 6.0], [5.0, 6.0], [5.0, 9.0], [1.0, 9.0]],
}),
detection: Some(crate::DetectionOutcome { score: 0.71 }),
classification: None,
recognition: None,
formula: Some(FormulaOutcome {
latex: "\\frac{a}{b}".into(),
eos_index: Some(3),
truncated: false,
model_id: "pp_formulanet_plus_m".into(),
token_ids: None,
}),
table: None,
},
]);
let items = to_output_items(&output);
assert_eq!(items.len(), 2);
assert_eq!(items[0].kind, "text");
assert_eq!(items[0].latex, None);
assert_eq!(items[1].kind, "formula");
assert_eq!(items[1].txt, "\\frac{a}{b}");
assert_eq!(items[1].latex.as_deref(), Some("\\frac{a}{b}"));
assert_eq!(items[1].truncated, Some(false));
let document = to_output_json(&output).expect("json document");
assert_eq!(document["text"], "ok");
assert_eq!(document["formulas"][0]["latex"], "\\frac{a}{b}");
assert_eq!(document["regions"][1]["kind"], "formula");
assert_eq!(document["regions"][1]["formula"]["eos_index"], 3);
assert_eq!(output.len(), 2, "formula regions are recognition results");
assert_eq!(output.text_len(), 1);
assert!(!output.is_empty(), "a formula-only page is not empty");
}
#[test]
fn formula_only_output_is_not_empty() {
let output = output_with(vec![OcrRegion {
source: RegionSource::Input,
kind: RegionKind::Formula,
polygon: Some(Polygon {
points: [[1.0, 1.0], [9.0, 1.0], [9.0, 4.0], [1.0, 4.0]],
}),
detection: None,
classification: None,
recognition: None,
formula: Some(FormulaOutcome {
latex: "x".into(),
eos_index: Some(1),
truncated: false,
model_id: "m".into(),
token_ids: None,
}),
table: None,
}]);
output
.validate()
.expect("formula-only output must validate");
assert_eq!(output.len(), 1);
assert_eq!(output.text_len(), 0);
assert!(!output.is_empty());
assert!(output.plain_text(crate::TextOrder::Reading).is_empty());
}
#[test]
fn invalid_region_kinds_are_rejected_by_validate() {
let mut output = output_with(vec![OcrRegion {
source: RegionSource::Input,
kind: RegionKind::Formula,
polygon: None,
detection: None,
classification: None,
recognition: Some(RecognitionOutcome {
text: "leaked".into(),
score: 1.0,
words: None,
}),
formula: None,
table: None,
}]);
assert!(
output.validate().is_err(),
"formula region without formula outcome must be rejected"
);
output.regions[0].formula = Some(FormulaOutcome {
latex: "x".into(),
eos_index: None,
truncated: true,
model_id: "m".into(),
token_ids: None,
});
assert!(
output.validate().is_err(),
"formula region carrying CTC text must be rejected"
);
}
#[test]
fn the_table_region_arms_are_enforced_by_validate() {
let table = || TableOutcome {
html: "<html><body><table></table></body></html>".into(),
table_html: "<table></table>".into(),
markdown: String::new(),
rows: 0,
cols: 0,
confidence: TableStructureConfidence::High,
degradations: Vec::new(),
holes: 0,
cells: Vec::new(),
source_line_indices: Vec::new(),
model_id: "slanet-plus".into(),
};
let region = |kind, recognition, formula, table_outcome| OcrRegion {
source: RegionSource::Input,
kind,
polygon: None,
detection: None,
classification: None,
recognition,
formula,
table: table_outcome,
};
output_with(vec![region(RegionKind::Table, None, None, Some(table()))])
.validate()
.expect("a well-formed table region must validate");
let error = output_with(vec![region(RegionKind::Table, None, None, None)])
.validate()
.expect_err("a table region without an outcome must be rejected");
assert!(
error.to_string().contains("table"),
"the message must name the table outcome: {error}"
);
assert!(
output_with(vec![region(
RegionKind::Table,
Some(RecognitionOutcome {
text: "leaked".into(),
score: 1.0,
words: None,
}),
None,
Some(table()),
)])
.validate()
.is_err(),
"a table region carrying CTC text must be rejected"
);
assert!(
output_with(vec![region(
RegionKind::Table,
None,
Some(FormulaOutcome {
latex: "x".into(),
eos_index: None,
truncated: false,
model_id: "m".into(),
token_ids: None,
}),
Some(table()),
)])
.validate()
.is_err(),
"a table region carrying a formula outcome must be rejected"
);
assert!(
output_with(vec![region(
RegionKind::Text,
Some(RecognitionOutcome {
text: "ok".into(),
score: 1.0,
words: None,
}),
None,
Some(table()),
)])
.validate()
.is_err(),
"a text region carrying a table outcome must be rejected"
);
}
#[test]
fn a_table_region_reports_its_grid_without_faking_ctc_text() {
let table = table_outcome();
let output = output_with(vec![
OcrRegion::text(
RegionSource::Input,
None,
None,
None,
Some(RecognitionOutcome {
text: "ok".into(),
score: 0.9,
words: None,
}),
),
table_region(table.clone()),
]);
output.validate().expect("a table page must validate");
assert_eq!(output.table_count(), 1);
assert_eq!(output.text_len(), 1);
let items = to_output_items(&output);
assert_eq!(items.len(), 2);
assert_eq!(items[0].kind, "text");
assert_eq!(
items[0].table, None,
"a text item must not carry a table view"
);
assert_eq!(items[1].kind, "table");
assert_eq!(
items[1].txt, table.markdown,
"`txt` is the table's markdown"
);
assert_eq!(items[1].latex, None);
let view = items[1]
.table
.as_ref()
.expect("the compact table view must be present");
assert_eq!(view.rows, table.rows);
assert_eq!(view.cols, table.cols);
assert_eq!(view.cells, table.cells.len());
assert_eq!(view.confidence, "high");
assert_eq!(view.holes, 0);
assert!(view.degradations.is_empty());
assert_eq!(view.table_html, table.table_html);
let document = to_output_json(&output).expect("json document");
assert_eq!(document["regions"][1]["kind"], "table");
assert_eq!(
document["regions"][1]["table"]["markdown"], table.markdown,
"the full table outcome stays reachable through `regions`"
);
let tables = document["tables"]
.as_array()
.expect("`tables` must always be an array");
assert_eq!(tables.len(), 1);
assert_eq!(
tables[0]["region"], 1,
"the region id must be the item's id"
);
assert_eq!(tables[0]["rows"], table.rows);
assert_eq!(tables[0]["cols"], table.cols);
assert_eq!(tables[0]["confidence"], "high");
assert_eq!(tables[0]["cells"], table.cells.len());
assert_eq!(tables[0]["model_id"], "slanet-plus");
assert_eq!(tables[0]["html"], table.html);
assert_eq!(tables[0]["table_html"], table.table_html);
assert_eq!(tables[0]["markdown"], table.markdown);
assert_eq!(document["text"], "ok");
assert!(!document["text"].as_str().unwrap().contains('|'));
}
#[test]
fn a_page_without_tables_keeps_the_existing_item_shape() {
let output = output_with(vec![OcrRegion::text(
RegionSource::Detected { detector_index: 0 },
Some(Polygon {
points: [[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]],
}),
None,
None,
Some(RecognitionOutcome {
text: "ok".into(),
score: 0.9,
words: None,
}),
)]);
let items = serde_json::to_value(to_output_items(&output)).expect("items must serialize");
assert_eq!(
items,
serde_json::json!([{
"box": [[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]],
"txt": "ok",
"score": f64::from(0.9f32),
"kind": "text",
}]),
"a text item must serialize to exactly the pre-E15 shape"
);
let document = to_output_json(&output).expect("json document");
assert_eq!(
document["tables"].as_array().map(Vec::len),
Some(0),
"`tables` is always present, like `formulas`"
);
assert_eq!(
document["regions"][0].get("table"),
None,
"an absent table outcome must not be serialized"
);
}
fn text_region_with(id: usize, points: [[f32; 2]; 4], text: &str) -> OcrRegion {
OcrRegion::text(
RegionSource::Detected { detector_index: id },
Some(Polygon { points }),
None,
None,
Some(RecognitionOutcome {
text: text.to_string(),
score: 0.99,
words: None,
}),
)
}
fn count_occurrences(haystack: &str, needle: &str) -> usize {
haystack.matches(needle).count()
}
#[test]
fn the_three_channels_agree_on_the_table_grid_and_its_cells() {
let table = table_outcome();
let cells: Vec<String> = table.cells.iter().map(|cell| cell.text.clone()).collect();
let output = output_with(vec![
text_region_with(
0,
[[10.0, 1.0], [50.0, 1.0], [50.0, 3.0], [10.0, 3.0]],
"被消费的行",
),
table_region(table),
]);
let document = to_output_json(&output).expect("json should render");
let items = document["items"].as_array().expect("items is an array");
let table_item = items
.iter()
.find(|item| item["kind"] == "table")
.expect("a `kind: table` item must exist");
assert_eq!(table_item["table"]["rows"], 2);
assert_eq!(table_item["table"]["cols"], 2);
assert_eq!(table_item["table"]["cells"], 4);
assert_eq!(table_item["table"]["confidence"], "high");
assert_eq!(
table_item["txt"], document["tables"][0]["markdown"],
"the item text and the tables[] markdown must be the same string"
);
let json_text = table_item["txt"].as_str().expect("txt is a string");
for cell in &cells {
assert_eq!(
count_occurrences(json_text, cell),
1,
"json txt must carry `{cell}` exactly once: {json_text}"
);
}
let json_plain = document["text"].as_str().expect("text is a string");
assert_eq!(
count_occurrences(json_plain, "被消费的行"),
1,
"{json_plain}"
);
assert!(!json_plain.contains('|'), "{json_plain}");
let html = crate::output::html::render_report(
"三通道",
"source.png",
640,
480,
&to_output_items(&output),
"total 1.0 ms",
crate::output::html::ReportMode::Full,
)
.expect("report should render");
assert!(html.contains("2 × 2 · 4 个单元格 · 置信度 high"), "{html}");
for cell in &cells {
assert!(
html.contains(&format!("<td>{cell}</td>")),
"html must render `{cell}` as a real cell: {html}"
);
}
let markdown = crate::output::markdown::to_output_markdown(&output);
assert!(markdown.contains("| c00 | c01 |"), "{markdown}");
assert!(markdown.contains("| c10 | c11 |"), "{markdown}");
for cell in &cells {
assert_eq!(
count_occurrences(&markdown, cell),
1,
"markdown must carry `{cell}` exactly once: {markdown}"
);
}
assert_eq!(
count_occurrences(&markdown, "被消费的行"),
0,
"the consumed line is represented by the grid, not repeated: {markdown}"
);
assert_eq!(document["tables"][0]["cells"], cells.len());
assert!(
html.contains(&format!("{} 个单元格", cells.len())),
"{html}"
);
}
#[test]
fn the_three_output_channels_are_frozen_for_the_captured_real_page() {
use sha2::{Digest as _, Sha256};
fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/serve/result-real-42-regions.json");
let raw = std::fs::read(&path).unwrap_or_else(|error| {
panic!("captured body must exist at {}: {error}", path.display())
});
let output: OcrOutput = serde_json::from_slice(&raw)
.expect("the captured /result body round-trips back into OcrOutput");
let json = serde_json::to_string(&to_output_json(&output).expect("json channel"))
.expect("json channel serializes");
let html = crate::output::html::render_output_report(
"snapshot",
"source.png",
&output,
"total 0.0 ms",
crate::output::html::ReportMode::Full,
)
.expect("html channel renders");
let markdown = crate::output::markdown::to_output_markdown(&output);
let actual = format!(
"json={}\nhtml={}\nmarkdown={}",
sha256_hex(json.as_bytes()),
sha256_hex(html.as_bytes()),
sha256_hex(markdown.as_bytes()),
);
let expected = "json=63c1beb1a3e9140a911a69de29f981bdfd17ba546f10cf3dae1f56b9e53b6914\n\
html=d092c814a99e00dcc4f4890ce0bb2da1a0aaf258f3c5a8421b49c39e7eb64278\n\
markdown=3f99e617c337a90eb5cd27a41703ecede28352558f6922565ec9e4801183a0a2";
assert_eq!(actual, expected, "the three output channels changed bytes");
}
fn table_outcome() -> TableOutcome {
let cell = |row, col| crate::TableCellOutcome {
row,
col,
rowspan: 1,
colspan: 1,
text: format!("c{row}{col}"),
score: Some(0.9),
match_kind: crate::TableCellMatch::Single,
polygon: None,
};
TableOutcome {
html: "<html><body><table><tr><td>c00</td><td>c01</td></tr><tr><td>c10</td><td>c11</td></tr></table></body></html>"
.into(),
table_html:
"<table><tr><td>c00</td><td>c01</td></tr><tr><td>c10</td><td>c11</td></tr></table>"
.into(),
markdown: "| c00 | c01 |\n| --- | --- |\n| c10 | c11 |".into(),
rows: 2,
cols: 2,
confidence: TableStructureConfidence::High,
degradations: Vec::new(),
holes: 0,
cells: vec![cell(0, 0), cell(0, 1), cell(1, 0), cell(1, 1)],
source_line_indices: vec![0],
model_id: "slanet-plus".into(),
}
}
fn table_region(table: TableOutcome) -> OcrRegion {
OcrRegion {
source: RegionSource::Detected { detector_index: 1 },
kind: RegionKind::Table,
polygon: Some(Polygon {
points: [[1.0, 6.0], [5.0, 6.0], [5.0, 9.0], [1.0, 9.0]],
}),
detection: Some(crate::DetectionOutcome { score: 0.71 }),
classification: None,
recognition: None,
formula: None,
table: Some(table),
}
}
fn output_with(regions: Vec<OcrRegion>) -> OcrOutput {
let provider = ProviderResolutionInfo {
requested: ProviderPreference::Cpu,
selected_ep: ResolvedProvider::Cpu,
fallback_to_cpu: false,
};
OcrOutput {
schema_version: 1,
image: ImageInfo {
original_size: crate::ImageSize {
width: 10,
height: 10,
},
processed_size: crate::ImageSize {
width: 10,
height: 10,
},
coordinate_space: CoordinateSpace::Image,
},
stages: StageReports {
input: InputTimings::default(),
detector: StageReport {
state: StageState::Completed { items: 1 },
timing: None,
},
classifier: StageReport {
state: StageState::Disabled,
timing: None,
},
recognizer: StageReport {
state: StageState::Completed { items: 1 },
timing: None,
},
formula: StageReport::default(),
table: StageReport::default(),
},
regions,
timings: OcrTimings::default(),
engine: EngineInfo {
model_id: "test".into(),
provider: ProviderInfo {
detector: provider,
classifier: None,
recognizer: provider,
},
},
}
}
#[test]
fn p17_table_fixtures_still_load_and_carry_the_table_view() {
for (name, table_regions) in [
("p17-table-real.json", 1usize),
("p17-table-synthetic.json", 8usize),
] {
let raw = std::fs::read(crate::test_support::fixture_dir("json-output").join(name))
.expect("fixture");
let output: OcrOutput = serde_json::from_slice(&raw).expect("fixture must load");
assert_eq!(
output
.regions
.iter()
.filter(|region| region.table.is_some())
.count(),
table_regions,
"{name} must keep its table regions"
);
let doc = to_output_json(&output).expect("doc");
for key in ["items", "tables", "formulas", "text"] {
assert!(doc.get(key).is_some(), "{name} loses derived key `{key}`");
}
assert_eq!(
doc["tables"].as_array().expect("tables").len(),
table_regions
);
}
}
const P17_COST_RECORD: &str = "tests/fixtures/json-output/p17-cost-record.json";
const P17_VIEW_KEYS: [&str; 4] = ["text", "items", "formulas", "tables"];
fn p17_read_sample(fixture: &str) -> OcrOutput {
let path = crate::test_support::fixture_dir("json-output").join(fixture);
let raw = std::fs::read(&path)
.unwrap_or_else(|error| panic!("{fixture} must be committed: {error}"));
serde_json::from_slice(&raw).expect("fixture must load as OcrOutput")
}
fn p17_payload_facts(output: &OcrOutput) -> serde_json::Value {
let source = serde_json::to_string(&serde_json::to_value(output).expect("source value"))
.expect("compact source")
.len();
let doc = to_output_json(output).expect("doc");
let full = serde_json::to_string(&doc).expect("compact doc").len();
let mut views = serde_json::Map::new();
let mut views_total = 0usize;
for key in P17_VIEW_KEYS {
let bytes = serde_json::to_string(&doc[key])
.expect("view slice bytes")
.len();
views_total += bytes;
views.insert(key.to_string(), serde_json::json!(bytes));
}
let key_overhead: usize = P17_VIEW_KEYS.iter().map(|key| key.len() + 4).sum();
serde_json::json!({
"regions": output.regions.len(),
"text_regions": output
.regions
.iter()
.filter(|region| region.recognition.is_some())
.count(),
"formula_regions": output
.regions
.iter()
.filter(|region| region.formula.is_some())
.count(),
"table_regions": output
.regions
.iter()
.filter(|region| region.table.is_some())
.count(),
"payload_bytes": {
"source": source,
"full": full,
"views": views,
"views_total": views_total,
"key_overhead": key_overhead,
}
})
}
fn p17_time_min_ns<T>(runs: u32, mut f: impl FnMut() -> T) -> u64 {
let mut best = u64::MAX;
let mut last = None;
for _ in 0..runs {
let started = std::time::Instant::now();
let value = f();
best = best.min(started.elapsed().as_nanos() as u64);
last = Some(value);
}
std::hint::black_box(&last);
best
}
fn p17_percent(part: u64, whole: u64) -> f64 {
(part as f64 * 100.0 / whole as f64 * 100.0).round() / 100.0
}
fn p17_time_facts(output: &OcrOutput, runs: u32, rounds: u32) -> serde_json::Value {
let mut best = [u64::MAX; 6];
for round in 0..rounds {
let measured = [
p17_time_min_ns(runs, || serde_json::to_value(output).expect("to_value")),
p17_time_min_ns(runs, || to_output_json(output).expect("doc")),
p17_time_min_ns(runs, || to_output_items(output)),
p17_time_min_ns(runs, || output.plain_text(crate::TextOrder::Reading)),
p17_time_min_ns(runs, || {
output
.tables(crate::TextOrder::Reading)
.into_iter()
.map(|(region_id, _)| region_id)
.collect::<Vec<_>>()
}),
p17_time_min_ns(runs, || {
output
.formula_latex(crate::TextOrder::Reading)
.into_iter()
.map(|(region_id, _)| region_id)
.collect::<Vec<_>>()
}),
];
for (slot, value) in best.iter_mut().zip(measured) {
*slot = (*slot).min(value);
}
println!(
"P17 round={round} to_value_ns={} to_output_json_ns={}",
measured[0], measured[1]
);
}
let [
source_value_ns,
full_path_ns,
items_view_ns,
text_view_ns,
tables_view_ns,
formulas_view_ns,
] = best;
let views_body_ns = items_view_ns + text_view_ns + tables_view_ns + formulas_view_ns;
let increment_ns = full_path_ns - source_value_ns;
let payload_full = serde_json::to_string(&to_output_json(output).expect("doc"))
.expect("compact doc")
.len() as u64;
let payload_source =
serde_json::to_string(&serde_json::to_value(output).expect("source value"))
.expect("compact source")
.len() as u64;
assert!(
full_path_ns > source_value_ns,
"派生视图的构造必须真的花时间:to_output_json={full_path_ns} to_value={source_value_ns}"
);
assert!(
increment_ns >= views_body_ns,
"视图本体之和 ({views_body_ns}) 不能大于整体增量 ({increment_ns}):本轮测量噪声过大,请重跑"
);
serde_json::json!({
"time_ns": {
"to_value": source_value_ns,
"to_output_json": full_path_ns,
"items_view": items_view_ns,
"text_view": text_view_ns,
"tables_view": tables_view_ns,
"formulas_view": formulas_view_ns,
},
"derived": {
"increment_ns": increment_ns,
"views_body_ns": views_body_ns,
"eliminable_share_percent": p17_percent(views_body_ns, full_path_ns),
"redundancy_share_percent": p17_percent(payload_full - payload_source, payload_full),
}
})
}
#[test]
fn the_saved_p17_cost_record_is_recomputable_and_self_consistent() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let raw = std::fs::read_to_string(root.join(P17_COST_RECORD))
.expect("成本分项记录必须入库(由 --ignored 探针重生成)");
let record: serde_json::Value = serde_json::from_str(&raw).expect("记录是合法 JSON");
assert_eq!(record["schema_version"].as_u64(), Some(1));
assert!(
record["method"]["timings_are_machine_dependent"]
.as_bool()
.unwrap_or(false),
"记录必须自己声明“绝对耗时是机器相关的”,否则下面的占比会被当成全局阈值"
);
let judgement = record["judgement"]
.as_object()
.expect("judgement 必须是一个对象");
let mut judgement_keys = judgement.keys().map(String::as_str).collect::<Vec<_>>();
judgement_keys.sort_unstable();
assert_eq!(
judgement_keys,
[
"absolute_time_threshold_declared",
"byte_redundancy_reachable_without_breaking_the_output_protocol",
"decision",
"eliminable_time_denominator",
"question",
"rationale",
],
"判据块字段集变了:守卫与记述必须一起改"
);
let judgement_of = |key: &str| judgement.get(key).expect("已断言存活的字段");
let decision = judgement_of("decision").as_str().expect("decision");
assert!(
matches!(
decision,
"cancel-projection-refactor" | "proceed-projection-refactor"
),
"未知的结论写法: {decision:?}"
);
assert!(
!judgement_of("rationale").as_str().unwrap_or("").is_empty(),
"结论必须自述理由"
);
assert_eq!(
judgement_of("eliminable_time_denominator").as_str(),
Some("to_output_json"),
"占比必须写明分母,否则不同机器、不同口径的数字会被当成同一个判据(旧 §9 的 2.2–5.9% 就死在这上面)"
);
let byte_redundancy_reachable =
judgement_of("byte_redundancy_reachable_without_breaking_the_output_protocol")
.as_bool()
.expect("byte_redundancy_reachable_without_breaking_the_output_protocol");
let absolute_threshold_declared = judgement_of("absolute_time_threshold_declared")
.as_bool()
.expect("绝对阈值声明");
let samples = record["samples"].as_array().expect("samples array");
assert!(samples.len() >= 2, "至少一份真实 + 一份放大样本");
for sample in samples {
let id = sample["id"].as_str().expect("sample id");
let fixture = sample["fixture"].as_str().expect("sample fixture");
let output = p17_read_sample(fixture);
let facts = p17_payload_facts(&output);
for key in [
"regions",
"text_regions",
"formula_regions",
"table_regions",
] {
assert_eq!(
sample[key].as_u64().unwrap_or(u64::MAX),
facts[key].as_u64().unwrap_or(u64::MAX),
"样本 {id} 的 `{key}` 与当前夹具重算不一致(样本已不在、或记述陈旧)"
);
}
let recorded_bytes = &sample["payload_bytes"];
let live_bytes = &facts["payload_bytes"];
assert_eq!(
recorded_bytes["source"], live_bytes["source"],
"样本 {id} 的源体积变了(输出协议漂移)"
);
assert_eq!(
recorded_bytes["full"], live_bytes["full"],
"样本 {id} 的完整体积变了(输出协议漂移)"
);
for key in P17_VIEW_KEYS {
assert_eq!(
recorded_bytes["views"][key], live_bytes["views"][key],
"样本 {id} 的派生视图 `{key}` 体积变了"
);
}
let sum_of_parts = live_bytes["views_total"].as_u64().expect("views_total")
+ live_bytes["key_overhead"].as_u64().expect("key_overhead");
assert_eq!(
sum_of_parts,
live_bytes["full"].as_u64().expect("full")
- live_bytes["source"].as_u64().expect("source"),
"样本 {id} 的成本分项拼不回总体积"
);
assert_eq!(
recorded_bytes["views_total"], live_bytes["views_total"],
"样本 {id} 的派生视图总体积变了"
);
let time = &sample["time_ns"];
let got = |name: &str| {
time[name]
.as_u64()
.unwrap_or_else(|| panic!("样本 {id} 缺 time_ns.{name}"))
};
let derived = &sample["derived"];
let claims = |name: &str| {
derived[name]
.as_f64()
.unwrap_or_else(|| panic!("样本 {id} 缺 derived.{name}"))
};
let increment = derived["increment_ns"]
.as_u64()
.unwrap_or_else(|| panic!("样本 {id} 缺 derived.increment_ns"));
let views_body = derived["views_body_ns"]
.as_u64()
.unwrap_or_else(|| panic!("样本 {id} 缺 derived.views_body_ns"));
assert_eq!(
increment,
got("to_output_json") - got("to_value"),
"样本 {id} 的增量不是 `to_output_json − to_value`"
);
assert_eq!(
views_body,
got("items_view") + got("text_view") + got("tables_view") + got("formulas_view"),
"样本 {id} 的视图本体不是四个视图耗时之和"
);
assert!(
increment >= views_body,
"样本 {id} 里可消除部分 ({views_body}) 大于整体增量 ({increment}):记述不自洽"
);
let full_path = got("to_output_json");
let eliminable = claims("eliminable_share_percent");
assert!(
(eliminable - p17_percent(views_body, full_path)).abs() < 0.05,
"样本 {id} 的可消除占比不等于本体/全路径的重算值"
);
let redundancy = claims("redundancy_share_percent");
let live_full = live_bytes["full"].as_u64().expect("full");
let live_source = live_bytes["source"].as_u64().expect("source");
assert!(
(redundancy - p17_percent(live_full - live_source, live_full)).abs() < 0.05,
"样本 {id} 的冗余占比不等于当前字节体积的重算值"
);
assert!(
live_bytes["views_total"].as_u64().expect("views_total") > 0,
"样本 {id} 没任何派生负载,不能用来支撑“冗余只能靠破坏协议变更消除”这句前提"
);
}
if decision == "cancel-projection-refactor" {
assert!(
!byte_redundancy_reachable,
"取消结论拿“字节不变约束下消不掉冗余”当前提,但记述说冗余能在不破坏协议的前提下消掉"
);
assert!(
!absolute_threshold_declared,
"取消结论不得依赖一个机器无关的绝对耗时阈值(记述已声明耗时是机器相关的)"
);
}
assert!(
samples
.iter()
.any(|sample| sample["payload_bytes"]["full"].as_u64().unwrap_or(0) > 500_000),
"记述必须含一份大负载样本,不能默默退回小页面"
);
}
#[test]
#[ignore = "P1-7 量测探针(会跑上千次序列化),手动 --ignored 运行"]
fn p17_measure_json_table_views_payload_and_time() {
use serde_json::json;
const RUNS: u32 = 300;
const ROUNDS: u32 = 3;
let write_record = std::env::var("RAPID_STRUCTURE_P17_WRITE_RECORD")
.map(|value| value.trim() == "1")
.unwrap_or(false);
let samples = [
(
"real",
"p17-table-real.json",
"真实捕获页:多区域正文 + 1 张 10 格表格",
),
(
"synthetic",
"p17-table-synthetic.json",
"合成放大页:8 张 100 格表格,每张 HTML 与 table_html 均 ≈31 KB",
),
];
let mut records = Vec::new();
for (id, fixture, description) in samples {
let output = p17_read_sample(fixture);
println!("P17 sample={id} fixture={fixture} runs={RUNS} rounds={ROUNDS}");
let mut record = p17_payload_facts(&output);
record["id"] = json!(id);
record["fixture"] = json!(fixture);
record["description"] = json!(description);
let times = p17_time_facts(&output, RUNS, ROUNDS);
record["time_ns"] = times["time_ns"].clone();
record["derived"] = times["derived"].clone();
println!(
"P17 {id}: {}",
serde_json::to_string(&record).expect("record json")
);
records.push(record);
}
if !write_record {
println!("P17 打印模式:设 RAPID_STRUCTURE_P17_WRITE_RECORD=1 才会写入入库记述");
return;
}
let doc = json!({
"schema_version": 1,
"note": "P1-7(投影重构)的成本分项记述。守卫用例 the_saved_p17_cost_record_is_recomputable_and_self_consistent \
会重算样本存在性、字节分项与算术关系;绝对耗时是机器相关的,不当作阈值。",
"method": {
"profile": if cfg!(debug_assertions) { "debug" } else { "release" },
"statistic": "min-of-runs,取 rounds 轮的最小值",
"runs_per_round": RUNS,
"rounds": ROUNDS,
"timings_are_machine_dependent": true,
},
"judgement": {
"question": "把 items/text/tables/formulas 四个派生视图改成投影(不再重复走一遍源数据)能消掉多少成本?",
"eliminable_time_denominator": "to_output_json",
"byte_redundancy_reachable_without_breaking_the_output_protocol": false,
"absolute_time_threshold_declared": false,
"decision": "cancel-projection-refactor",
"rationale": "两条前提,一条口径:字节冗余恰好就是 `full − source = views_total + key_overhead` \
这一项,输出字节不变约束下只能靠破坏性协议变更消除(按裁决排除在本重构外);时间收益的上界就是 \
`views_body_ns`,它只作用于 JSON 构造路径(分母已在 `eliminable_time_denominator` 里写明)。\
占比的绝对值随机器与 CPU 负载浮动,所以本记述不声明任何机器无关的绝对阈值。",
},
"samples": records,
});
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(P17_COST_RECORD);
std::fs::write(
&path,
format!(
"{}\n",
serde_json::to_string_pretty(&doc).expect("pretty record json")
),
)
.expect("write the P1-7 cost record");
println!("wrote {} samples to {}", records.len(), path.display());
}
}