summaryrefslogtreecommitdiff
path: root/internal/bible/validate.go
blob: 9c994361ef7d3399c0430a529a125ea95152590f (plain) (blame)
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
package bible

import (
	"fmt"
	"sort"
	"strings"
	"unicode/utf8"
)

// CorpusReport is the outcome of validating one corpus's sidecar + text (see
// CheckCorpus). Errors mean the corpus is unusable/malformed and fail
// --corpus-check (exit 1); Warnings flag coverage gaps against the reference
// Vulgate ("vul") and never fail the check -- a corpus may legitimately be
// incomplete (the built-in wuj is) and still be a valid drop-in.
type CorpusReport struct {
	Code     string
	Errors   []string
	Warnings []string
}

// OK reports whether the corpus is usable: no errors. Warnings never affect it.
func (r CorpusReport) OK() bool { return len(r.Errors) == 0 }

var validPsalmSystems = map[string]bool{"vulgate": true, "hebrew": true, "drb": true}

// CheckCorpus validates code's sidecar and text and reports coverage gaps vs
// "vul" as warnings.
//
// Sidecar: lang, name and psalm_system are required (psalm_system must be
// vulgate/hebrew/drb); sigla and autoselect are optional -- their absence, or
// autoselect=false, is never flagged (the built-in wuj sets autoselect=false
// and must pass clean).
//
// Text: every Book column value must be one of CanonicalBooks' 73 keys
// (error). A chapter missing entirely versus vul, a verse-number gap within a
// chapter, or a repeated verse number within a chapter, is a warning only --
// real source texts legitimately do this (e.g. the LXX's lettered doublet
// verses in 3 Kingdoms, or a scanned translation's occasional merged verse),
// and it must never turn a corpus that otherwise loads fine into a failure. A
// verse that IS present but whose text is a stub next to the aligned Vulgate
// verse (a likely truncation or lost line -- the one defect the count-based
// checks cannot see) is likewise a warning.
func CheckCorpus(code string) CorpusReport {
	r := CorpusReport{Code: code}

	m, ok := Meta(code)
	if !ok || m.Lang == "" || m.Name == "" || m.PsalmSystem == "" {
		r.Errors = append(r.Errors, "missing or incomplete sidecar (need lang, name, psalm_system)")
	}
	if m.PsalmSystem != "" && !validPsalmSystems[m.PsalmSystem] {
		r.Errors = append(r.Errors, fmt.Sprintf("invalid psalm_system %q (want vulgate|hebrew|drb)", m.PsalmSystem))
	}

	c := load(code)
	if len(c.books) == 0 {
		r.Errors = append(r.Errors, "no verses parsed (empty or malformed .tsv)")
		sort.Strings(r.Errors)
		return r
	}

	canon := CanonicalBooks()
	for book := range c.books {
		if !canon[book] {
			r.Errors = append(r.Errors, "unknown book name: "+book)
		}
	}

	ref := load("vul")
	for book, chaps := range c.books {
		refBook := ref.books[book] // nil if vul lacks the book (e.g. an EF-only name)
		for ch, verses := range chaps {
			seen := map[int]bool{}
			maxV := 0
			for _, v := range verses {
				if seen[v.Verse] {
					r.Warnings = append(r.Warnings, fmt.Sprintf("%s %d: duplicate verse %d", book, ch, v.Verse))
				}
				seen[v.Verse] = true
				if v.Verse > maxV {
					maxV = v.Verse
				}
			}
			if maxV > len(seen) {
				r.Warnings = append(r.Warnings, fmt.Sprintf("%s %d: verse gap (have %d verse(s), highest numbered %d)", book, ch, len(seen), maxV))
			}

			// Suspiciously-short verse: present, but a stub next to the SAME verse
			// in the reference Vulgate -- a likely truncation or lost line (an
			// opening dropped in a scrape, a merge artifact) that the
			// presence-only checks cannot see, since the verse IS there. The
			// comparison is cross-corpus, not within the chapter, precisely so a
			// genuinely terse verse ("Non occides", "Jesus wept") is NOT flagged:
			// the Vulgate's own copy is short too, so the guard below fails. It
			// fires only when the corpus verse is a stub AND the aligned Vulgate
			// verse is substantial. Skipped on the Psalms when the corpus
			// renumbers them (psalm_system != vulgate), where the per-verse
			// alignment with the Vulgate does not hold.
			if refBook != nil && !(book == "Psalms" && m.PsalmSystem != "vulgate") {
				if refCh, ok := refBook[ch]; ok {
					refLen := make(map[int]int, len(refCh))
					for _, rv := range refCh {
						refLen[rv.Verse] = utf8.RuneCountInString(strings.TrimSpace(rv.Text))
					}
					for _, v := range verses {
						n := utf8.RuneCountInString(strings.TrimSpace(v.Text))
						if rl := refLen[v.Verse]; rl >= 40 && n < 12 && n*4 < rl {
							r.Warnings = append(r.Warnings, fmt.Sprintf("%s %d:%d: text suspiciously short (%d chars vs vul %d)", book, ch, v.Verse, n, rl))
						}
					}
				}
			}
		}
		if refBook != nil {
			for ch := range refBook {
				if _, have := chaps[ch]; !have {
					r.Warnings = append(r.Warnings, fmt.Sprintf("%s: missing chapter %d (present in vul)", book, ch))
				}
			}
		}
	}

	sort.Strings(r.Errors)
	sort.Strings(r.Warnings)
	return r
}