"""Table structure similarity against ground-truth tables. Definitions (pinned): - A TableRecord is a dict with keys: rows (list[list[str]] of normalized cell strings), page_num (int|None), table_id (str|None), bbox (xyxy|None). - Predictions are matched to truths greedily, page-aware. The match score blends shape similarity (row/col count) with cell-content overlap, so a prediction with the same dimensions and overlapping headers wins over one with extra phantom rows. - For each matched pair we compute: - shape_similarity = 1 - mean(|rows_p - rows_t|/max_rows, |cols_p - cols_t|/max_cols) bounded to [0, 1]. - cell_content_f1 = F1 over the multiset of normalized cell strings. Multiset (not set) so duplicate "0.00" cells in financial tables count. - score = 0.5 * shape_similarity + 0.5 * cell_content_f1. - Document-level aggregates: - mean_table_score = mean(score) over matched pairs. - table_match_rate = matched_pairs / max(len(predictions), len(truths)). - table_count_delta = len(predictions) - len(truths). - Edge cases: empty/empty -> mean_table_score=1.0 (vacuous); one side empty -> 0.0 with all unmatched recorded as fp/fn. """ from __future__ import annotations import re from typing import Any, Iterable TableRecord = dict[str, Any] def compute_table_structure_score( predictions: Iterable[TableRecord], truths: Iterable[TableRecord], ) -> dict[str, Any]: pred_list = [item for item in (_normalize(record) for record in predictions) if item is not None] truth_list = [item for item in (_normalize(record) for record in truths) if item is not None] if not pred_list and not truth_list: return { "prediction_count": 0, "truth_count": 0, "matched_pair_count": 0, "table_match_rate": 1.0, "mean_table_score": 1.0, "mean_shape_similarity": 1.0, "mean_cell_content_f1": 1.0, "table_count_delta": 0, "matches": [], "unmatched_predictions": [], "unmatched_truths": [], } pairs: list[tuple[float, int, int, dict[str, float]]] = [] for pred_index, prediction in enumerate(pred_list): for truth_index, truth in enumerate(truth_list): if prediction["page_num"] is not None and truth["page_num"] is not None: if prediction["page_num"] != truth["page_num"]: continue scores = _score_pair(prediction, truth) if scores["match_score"] <= 0.0: continue pairs.append((scores["match_score"], pred_index, truth_index, scores)) pairs.sort(key=lambda item: item[0], reverse=True) pred_taken = [False] * len(pred_list) truth_taken = [False] * len(truth_list) matches: list[dict[str, Any]] = [] for _match_score, pred_index, truth_index, scores in pairs: if pred_taken[pred_index] or truth_taken[truth_index]: continue pred_taken[pred_index] = True truth_taken[truth_index] = True matches.append( { "prediction_index": pred_index, "truth_index": truth_index, "page_num": pred_list[pred_index]["page_num"] or truth_list[truth_index]["page_num"], "score": scores["score"], "shape_similarity": scores["shape_similarity"], "cell_content_f1": scores["cell_content_f1"], "predicted_shape": [len(pred_list[pred_index]["rows"]), _max_cols(pred_list[pred_index]["rows"])], "truth_shape": [len(truth_list[truth_index]["rows"]), _max_cols(truth_list[truth_index]["rows"])], } ) matched_pair_count = len(matches) denominator = max(len(pred_list), len(truth_list)) mean_table_score = (sum(match["score"] for match in matches) / denominator) if denominator else 1.0 mean_shape = (sum(match["shape_similarity"] for match in matches) / matched_pair_count) if matched_pair_count else 0.0 mean_cell = (sum(match["cell_content_f1"] for match in matches) / matched_pair_count) if matched_pair_count else 0.0 return { "prediction_count": len(pred_list), "truth_count": len(truth_list), "matched_pair_count": matched_pair_count, "table_match_rate": (matched_pair_count / denominator) if denominator else 1.0, "mean_table_score": mean_table_score, "mean_shape_similarity": mean_shape, "mean_cell_content_f1": mean_cell, "table_count_delta": len(pred_list) - len(truth_list), "matches": matches, "unmatched_predictions": [index for index, taken in enumerate(pred_taken) if not taken], "unmatched_truths": [index for index, taken in enumerate(truth_taken) if not taken], } def markdown_to_rows(markdown: str | None) -> list[list[str]]: if not markdown: return [] rows: list[list[str]] = [] for line in markdown.splitlines(): stripped = line.strip() if not (stripped.startswith("|") and stripped.endswith("|")): continue cells = [_normalize_cell(cell) for cell in stripped.strip("|").split("|")] if cells and all(re.fullmatch(r":?-{3,}:?", cell) for cell in cells if cell): continue rows.append(cells) return rows def html_to_rows(html: str | None) -> list[list[str]]: if not html: return [] rows: list[list[str]] = [] for tr_match in re.finditer(r"