Files
ragflow/internal/deepdoc/parser/pdf/layout/combined_column_test.go

284 lines
9.1 KiB
Go

package layout
import (
"encoding/json"
"math"
"os"
"path/filepath"
"sort"
"testing"
pdf "ragflow/internal/deepdoc/parser/pdf/type"
)
// ---- charspy JSON binding + line reconstruction (mirrors tool-py
// extract_column_divergence._reconstruct_lines so the Go detector is scored on
// the SAME line boxes the Python reference used) ----
type charBox struct {
Text string `json:"text"`
X0 float64 `json:"x0"`
X1 float64 `json:"x1"`
Top float64 `json:"top"`
Bottom float64 `json:"bottom"`
Size float64 `json:"size"`
}
type charPage struct {
Pages [][]charBox `json:"pages"`
}
func reconstructLines(chars []charBox) []pdf.TextBox {
if len(chars) == 0 {
return nil
}
sizes := make([]float64, len(chars))
for i, c := range chars {
sizes[i] = c.Size
}
sort.Float64s(sizes)
medSize := sizes[len(sizes)/2]
vTol := math.Max(medSize*0.8, 4.0)
hGap := math.Max(medSize*3.0, 15.0)
rows := map[int][]charBox{}
for _, c := range chars {
key := int(math.Round(c.Top / vTol))
rows[key] = append(rows[key], c)
}
keys := make([]int, 0, len(rows))
for k := range rows {
keys = append(keys, k)
}
sort.Ints(keys)
var lines []pdf.TextBox
for _, key := range keys {
row := rows[key]
sort.Slice(row, func(a, b int) bool { return row[a].X0 < row[b].X0 })
var cur *pdf.TextBox
for _, c := range row {
if cur == nil {
cur = &pdf.TextBox{X0: c.X0, X1: c.X1, Top: c.Top, Bottom: c.Bottom, Text: c.Text}
} else if c.X0-cur.X1 > hGap {
lines = append(lines, *cur)
cur = &pdf.TextBox{X0: c.X0, X1: c.X1, Top: c.Top, Bottom: c.Bottom, Text: c.Text}
} else if c.X1 > cur.X1 {
cur.X1 = c.X1
}
}
if cur != nil {
lines = append(lines, *cur)
}
}
return lines
}
// labeledPage mirrors the first 18 (locked) entries of
// tool-py/column_labeling_sheet.json — human-confirmed column truth.
type labeledPage struct {
pdf string
page int
truth int
}
// sheetEntry mirrors the JSON shape of tool-py/column_labeling_sheet.json.
type sheetEntry struct {
PDF string `json:"pdf"`
Page int `json:"page"`
TruthK *int `json:"truth_k"`
}
// loadLabeledPages reads every page with a non-null truth_k from the shared
// label sheet (single source of truth, also used by the Python reference).
// Pages still marked null (need the rendered PDF to decide) are skipped.
func loadLabeledPages(t *testing.T) []labeledPage {
sheetPath := "../tool-py/column_labeling_sheet.json"
raw, err := os.ReadFile(sheetPath)
if err != nil {
t.Skipf("label sheet not found at %s: %v", sheetPath, err)
}
var entries []sheetEntry
if err := json.Unmarshal(raw, &entries); err != nil {
t.Fatalf("unmarshal sheet: %v", err)
}
var out []labeledPage
for _, e := range entries {
if e.TruthK == nil {
continue
}
out = append(out, labeledPage{pdf: e.PDF, page: e.Page, truth: *e.TruthK})
}
if len(out) == 0 {
t.Skip("no labeled pages in sheet")
}
return out
}
// TestAssignColumnCombined_Labeled scores the gap+KMeans hybrid against the
// LIVE gap-only baseline on every labeled page in the shared sheet (the sheet
// is the single source of truth, also used by the Python reference). Hard
// pages were labeled from the page coverage profile + strip map in
// tool-py/analyze_hardcases.py, tagged H (high) / L (low) confidence in
// truth_note. The 18-page labeled sample that motivated this detector reported
// KMeans 11.1% / gap 61.1% accuracy, but that sample was hand-picked and must
// not be used as a target. The per-page / single / double / ACC lines below
// are the real signal, and the only asserted contract is "combined must not
// regress gap by >5pt".
func TestAssignColumnCombined_Labeled(t *testing.T) {
charspyDir := "../testdata/charspy"
if _, err := os.Stat(charspyDir); err != nil {
t.Skipf("charspy corpus not found at %s (run from package dir): %v", charspyDir, err)
}
cases := loadLabeledPages(t)
var correct, fs, miss, sCorrect, sTotal, dCorrect, dTotal int
var gCorrect, gFs, gMiss, gsCorrect, gsTotal, gdCorrect, gdTotal int
for _, c := range cases {
raw, err := os.ReadFile(filepath.Join(charspyDir, c.pdf))
if err != nil {
t.Fatalf("read %s: %v", c.pdf, err)
}
var cp charPage
if err := json.Unmarshal(raw, &cp); err != nil {
t.Fatalf("unmarshal %s: %v", c.pdf, err)
}
lines := reconstructLines(cp.Pages[c.page])
boxes := make([]pdf.TextBox, len(lines))
for i, l := range lines {
boxes[i] = l
boxes[i].PageNumber = 0
}
res := AssignColumn(boxes)
k := 1
for _, b := range res {
if b.ColID+1 > k {
k = b.ColID + 1
}
}
// Live gap-only baseline on the SAME lines (combined wraps gap, so
// this is the honest reference — not the old 18-page Python 61.1%).
g := gapColumnCount(lines, 0.04, 0.15, 2.0)
record := func(got, truth int, corr, fsC, missC, sCorr, sTot, dCorr, dTot *int) {
if got == truth {
*corr++
} else if got > truth {
*fsC++
} else {
*missC++
}
if truth == 1 {
*sTot++
if got == truth {
*sCorr++
}
} else {
*dTot++
if got == truth {
*dCorr++
}
}
}
record(k, c.truth, &correct, &fs, &miss, &sCorrect, &sTotal, &dCorrect, &dTotal)
record(g, c.truth, &gCorrect, &gFs, &gMiss, &gsCorrect, &gsTotal, &gdCorrect, &gdTotal)
tag := "OK"
if k != c.truth {
tag = "WRONG"
}
t.Logf("[%s] %s p%d truth=%d got=%d (gap=%d) comb single=%d/%d double=%d/%d gap single=%d/%d double=%d/%d",
tag, c.pdf, c.page, c.truth, k, g, sCorrect, sTotal, dCorrect, dTotal, gsCorrect, gsTotal, gdCorrect, gdTotal)
}
n := len(cases)
acc := 100.0 * float64(correct) / float64(n)
gapAcc := 100.0 * float64(gCorrect) / float64(n)
t.Logf("Go gap+KMeans combined: ACC=%.1f%% false-split=%d miss=%d single=%d/%d double=%d/%d (n=%d)",
acc, fs, miss, sCorrect, sTotal, dCorrect, dTotal, n)
t.Logf("Go gap-only baseline: ACC=%.1f%% false-split=%d miss=%d single=%d/%d double=%d/%d (n=%d)",
gapAcc, gFs, gMiss, gsCorrect, gsTotal, gdCorrect, gdTotal, n)
// The hybrid's contract: gap is the safe base, KMeans only ADDS recoveries
// (double columns gap misses). So combined must not regress gap by more
// than a small tolerance. The 75% / 88.9% figures were tuned targets on
// the comfortable 18 pages and must not be asserted (circular). Watch the
// two ACC lines above as harder pages are labeled and added.
if acc < gapAcc-5.0 {
t.Errorf("combined ACC=%.1f%% regresses gap-only baseline %.1f%% by >5pt (KMeans addition is harmful)", acc, gapAcc)
}
}
// TestKmeansK2PlusPlus_Smoke sanity-checks the density-aware k=2 gate on a
// clean bimodal vs single-mode input.
func TestKmeansK2PlusPlus_Smoke(t *testing.T) {
// Two well-separated modes, balanced -> two real clusters.
bimodal := []float64{10, 11, 12, 13, 90, 91, 92, 93}
labels, cents := kmeansK2PlusPlus(bimodal, 42)
if len(uniqueInts(labels)) != 2 {
t.Errorf("bimodal: expected 2 clusters, got %d", len(uniqueInts(labels)))
}
// kmeansK2PlusPlus does not guarantee centroid ordering, so assert the
// property it DOES guarantee: a bimodal split yields two well-separated
// centroids. (assignColIDs re-sorts them for ColID assignment.)
if math.Abs(cents[0]-cents[1]) < 50 {
t.Errorf("bimodal: centroids should be well separated, got %v", cents)
}
// Single tight mode -> still 2 labels after Lloyd, but minority tiny.
tight := []float64{10, 10, 10, 10, 11, 10, 10, 10}
labels2, _ := kmeansK2PlusPlus(tight, 42)
counts := map[int]int{}
for _, l := range labels2 {
counts[l]++
}
minC := math.MaxInt32
for _, c := range counts {
if c < minC {
minC = c
}
}
if float64(minC) >= 0.30*float64(len(tight)) {
t.Errorf("tight single-mode: minority %.0f%% should be <30%%", 100*float64(minC)/float64(len(tight)))
}
}
// TestAssignColumn_FullWidthBridgeKeepsBalancedTwoColumn is a regression test
// for the balance-gate / prune asymmetry: the balance gate clusters the BODY
// (full-width front matter excluded), but pruneColumns must count the SAME
// body — not all lines. A real but minority-right two-column block dominated
// by full-width title/abstract lines must stay two columns; otherwise the
// full-width lines inflate the left column and prune wrongly collapses the
// page to one.
func TestAssignColumn_FullWidthBridgeKeepsBalancedTwoColumn(t *testing.T) {
const (
minX0 = 100.0
maxX1 = 700.0
)
var boxes []pdf.TextBox
add := func(x0, x1 float64) {
top := float64(len(boxes)) * 12
boxes = append(boxes, pdf.TextBox{
PageNumber: 0, X0: x0, X1: x1, Top: top, Bottom: top + 10, Text: "b",
})
}
// Body: a genuine two-column block with a real gutter. The right column is
// the minority (6 of 20 body lines, i.e. 30% — exactly the gate floor).
for i := 0; i < 14; i++ {
add(minX0, 350) // left column
}
for i := 0; i < 6; i++ {
add(450, maxX1) // right column
}
// 60 full-width front-matter lines (title + abstract) bridge the gutter so
// gap reports a single column. They must not be re-counted by prune.
for i := 0; i < 60; i++ {
add(minX0, maxX1) // full width
}
res := AssignColumn(boxes)
k := 1
for _, b := range res {
if b.ColID+1 > k {
k = b.ColID + 1
}
}
if k != 2 {
t.Fatalf("balanced two-column page with full-width front matter collapsed to %d column(s); want 2", k)
}
}