textmachine/backend/internal/pipeline/bookconsistency_test.go

295 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pipeline
// bookconsistency_test.go: the aggregation half of backlog row 406 — the part that finally compares one
// chapter with another. Each fixture spans at least two chapters, because a one-chapter fixture cannot tell
// this measure apart from the per-chunk post-check it was built to get past.
import (
"testing"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/store"
)
func consistencyBank(rows []store.GlossaryEntry) *membank.Bank {
return membank.MaterializeBank(membank.BankInput{
Rows: rows, TargetStemmer: lang.NewTargetStemmer(lang.TargetChecksFor("ru")),
}, false)
}
func row(src, dst, status string) store.GlossaryEntry {
return store.GlossaryEntry{BookID: "b", Src: src, Dst: dst, Status: status, Source: "seed"}
}
// TestATermRenderedTwoWaysAcrossChaptersIsNamedWithItsChapters is the measure's reason to exist. The term
// below reaches the reader as the bank wrote it in chapter 1 and as something else in chapter 3; no per-chunk
// check can see that, because neither chunk is wrong on its own — chapter 1 is clean and chapter 3 is a
// miss, and only holding both at once makes it an inconsistency. The chapters must be NAMED: "term X went out
// two ways" without them is not something an editor can act on.
func TestATermRenderedTwoWaysAcrossChaptersIsNamedWithItsChapters(t *testing.T) {
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "蛊师来了。", "Гу-мастер пришёл.")
c.add(3, "蛊师走了。", "Заклинатель ушёл.")
got := c.finish(rows)
if got.Split != 1 {
t.Fatalf("a term shipped the bank's way in ch1 and otherwise in ch3 must count as ONE split; got split=%d absent=%d covered=%d", got.Split, got.Absent, got.Covered)
}
if len(got.Terms) != 1 {
t.Fatalf("want exactly one named term, got %d: %+v", len(got.Terms), got.Terms)
}
term := got.Terms[0]
if term.Verdict != "split" || term.Src != "蛊师" {
t.Errorf("wrong term reported: %+v", term)
}
if len(term.WithForm) != 1 || term.WithForm[0] != 1 {
t.Errorf("the chapter that GOT the bank's rendering must be named: want [1], got %v", term.WithForm)
}
if len(term.WithoutForm) != 1 || term.WithoutForm[0] != 3 {
t.Errorf("the chapter that did NOT is the actionable half: want [3], got %v", term.WithoutForm)
}
}
// TestTheSameTermCleanInEveryChapterIsNotSplit is the negative twin. Without it the pin above would pass on
// an aggregation that called every multi-chapter term a split, which is the easiest way to get a
// consistency report that is loud and worthless.
func TestTheSameTermCleanInEveryChapterIsNotSplit(t *testing.T) {
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "蛊师来了。", "Гу-мастер пришёл.")
c.add(3, "蛊师走了。", "Гу-мастер ушёл.")
got := c.finish(rows)
if got.Split != 0 || got.Absent != 0 {
t.Errorf("a term shipped the bank's way in both chapters must be clean: split=%d absent=%d", got.Split, got.Absent)
}
if got.Covered != 1 {
t.Errorf("covered: got %d, want 1 — a clean term still has to appear in the denominator", got.Covered)
}
}
// TestEveryZeroCarriesThePopulationItCameFrom pins the denominators, and it is the one test here that is
// about the REPORT rather than the verdict. A consistency report whose only content is "0 problems" is
// indistinguishable from one that looked at nothing, and the run of 11.09 shipped exactly that shape three
// times from the polygon's own instrument. The fixture therefore holds one row of every kind that can make a
// count zero for a DIFFERENT reason, and asserts each lands in its own bucket.
func TestEveryZeroCarriesThePopulationItCameFrom(t *testing.T) {
rows := []store.GlossaryEntry{
row("蛊师", "гу-мастер", "approved"), // fires, shipped
row("仙人", "небожитель", "approved"), // never occurs in the source at all
row("妖怪", "", "approved"), // no rendering to look for
// A rendering that SURFACES without its key ever firing — a common-noun dst recurring away from its
// term, the recall cost dstFormPresent documents. It is not a judgement about the term either way,
// and counting it as a term shipped correctly is how a never-fired row quietly joins the denominator
// of terms that were actually judged.
row("宝剑", "меч", "approved"),
}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "蛊师来了。", "Гу-мастер пришёл. В углу стоял меч.")
got := c.finish(rows)
for _, k := range []struct {
name string
got int
want int
}{
{"bank rows", got.BankRows, 4},
{"judgeable (a dst to look for)", got.Judged, 3},
{"no dst", got.NoDst, 1},
{"key never fired in the source", got.NeverFired, 2},
{"covered", got.Covered, 1},
{"absent", got.Absent, 0},
{"split", got.Split, 0},
{"units judged", got.UnitsJudged, 1},
} {
if k.got != k.want {
t.Errorf("%s: got %d, want %d — the zeroes here must be separable, that is the whole point of the line", k.name, k.got, k.want)
}
}
// The partition must close. A row that falls out of every bucket is how a denominator quietly stops
// meaning what it says, and subtraction is what NeverFired is derived by.
if sum := got.Covered + got.Absent + got.Split + got.BlockedOnly + got.SuppressedSilent + got.NeverFired; sum != got.Judged {
t.Errorf("the judgeable rows do not add up: covered+absent+split+blocked+eaten+never-fired=%d, judgeable=%d", sum, got.Judged)
}
}
// TestSigningIsAFactAboutTheBookNotAboutATerm pins the axis correction. There is no such product unit as a
// signed TERM: a bank is signed as a whole, or the book travels with an unsigned one and the engine
// auto-continues (D39.144). The law on the wire is the same for every row either way (D39.104), so a term's
// own status must not cut the consistency counts — it would report on a unit the product does not have.
//
// The fixture holds one row of each engine status and asserts BOTH halves: the term counts see two absences
// and know nothing of status, while the book reports its signing state once, as a count of rows.
func TestSigningIsAFactAboutTheBookNotAboutATerm(t *testing.T) {
rows := []store.GlossaryEntry{
row("蛊师", "гу-мастер", "approved"),
row("仙人", "небожитель", "draft"),
}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "蛊师和仙人来了。", "Заклинатель и бессмертный пришли.")
got := c.finish(rows)
if got.Absent != 2 {
t.Errorf("both renderings are missing from this output and a term's status must not change that: got absent=%d, want 2", got.Absent)
}
if got.UnsignedRows != 1 {
t.Errorf("the BOOK's signing state is a row count, said once: got unsigned=%d of %d judgeable, want 1", got.UnsignedRows, got.Judged)
}
}
// TestABankWithoutARowLeavesTheMeasureAbsentRatherThanClean pins the nil path. A book with no glossary must
// report NOTHING here, not a clean sheet: "no bank to judge against" and "judged and found consistent" are
// the same output otherwise, and the reassuring one is the wrong one.
func TestABankWithoutARowLeavesTheMeasureAbsentRatherThanClean(t *testing.T) {
var c *consistencyScan // the shape QualityReport produces for a book with no bank rows
c.add(1, "蛊师来了。", "Гу-мастер пришёл.")
if got := c.finish(nil); got != nil {
t.Errorf("a book with no bank must produce no consistency section at all, got %+v", got)
}
}
// TestARenderingSurfacingWhereTheTermDidNotOccurIsNotCoverage pins the guard that keeps a chapter out of a
// term's picture when the term is not in that chapter's SOURCE. The dst here is a common noun, so it recurs
// in chapter 3 with nothing to do with the term — and if that counted as "chapter 3 got the bank's
// rendering", the term would read as split (one chapter with, one without) instead of what it is: a term
// that occurred once and was rendered otherwise.
//
// ⚠ A fixture where the stray surfacing lands in the SAME chapter as the firing cannot show this — the
// chapter is already in the picture — and a fixture where the term never fires at all cannot either, because
// such a row is discarded later anyway. It has to be a term that fires in one chapter and whose rendering
// turns up in another.
func TestARenderingSurfacingWhereTheTermDidNotOccurIsNotCoverage(t *testing.T) {
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "蛊师来了。", "Заклинатель пришёл.") // the term fires here and is rendered otherwise
c.add(3, "他走了。", "Мимо прошёл какой-то гу-мастер.") // the rendering surfaces; the term is not in this source
got := c.finish(rows)
if got.Absent != 1 {
t.Fatalf("the term fired once and its rendering never came out where it fired: want absent=1, got absent=%d split=%d covered=%d", got.Absent, got.Split, got.Covered)
}
if got.Split != 0 {
t.Errorf("chapter 3 must not count as a chapter that GOT the bank's rendering — the term is not in its source: split=%d", got.Split)
}
if len(got.Terms) == 1 && len(got.Terms[0].WithForm) != 0 {
t.Errorf("no chapter got the bank's rendering for this term, so the «with the bank's form» column must be empty: %v", got.Terms[0].WithForm)
}
}
// TestATermGoingOutTwoWaysINSIDEOneChapterIsSplit pins the within-chapter branch of the split rule, which
// every other split fixture here misses: they all span chapters, so the `splitIn` half of the verdict was
// never exercised and removing it left the battery green. A term rendered the bank's way for some of its
// occurrences and otherwise for the rest, inside ONE chapter, is a reader meeting two forms on one page —
// the worse case, not the milder one.
func TestATermGoingOutTwoWaysINSIDEOneChapterIsSplit(t *testing.T) {
rows := []store.GlossaryEntry{row("蛊师", "гу-мастер", "approved")}
c := newConsistencyScan(consistencyBank(rows))
// Three firings in one chapter; the bank's rendering comes out for one of them.
c.add(1, "蛊师来了。蛊师走了。蛊师站着。", "Гу-мастер вошёл. Заклинатель ушёл. Заклинатель встал.")
got := c.finish(rows)
if got.Split != 1 {
t.Fatalf("a term rendered two ways inside ONE chapter must read as split: split=%d covered=%d absent=%d", got.Split, got.Covered, got.Absent)
}
if len(got.Terms) != 1 {
t.Fatalf("the finding must be named: %+v", got.Terms)
}
if len(got.Terms[0].SplitIn) != 1 || got.Terms[0].SplitIn[0] != 1 {
t.Errorf("the chapter the split happened INSIDE must be named: want [1], got %v", got.Terms[0].SplitIn)
}
// And the cross-chapter columns must be empty here — otherwise this fixture is passing on the other
// branch and pins nothing new.
if len(got.Terms[0].WithoutForm) != 0 {
t.Errorf("no chapter is wholly without the rendering here; «without it» must be empty, got %v", got.Terms[0].WithoutForm)
}
}
// TestANestedRowWhoseCanonSURVIVEDIsNotCountedAsNeverFired pins the control line of the whole measure
// against the way it was quietly inflated. A row eaten by a longer key has NO firings of its own — its
// spans are removed before counting — so before this it fell out of every bucket and subtraction filed it
// under «key never fired in the source». That is the one line separating «the engine shipped the wrong
// thing» from «this term is not in this book», and every correctly-rendered nested row was padding it: on
// any book with aliases, which is any real book.
func TestANestedRowWhoseCanonSURVIVEDIsNotCountedAsNeverFired(t *testing.T) {
long := store.GlossaryEntry{BookID: "b", Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed",
Aliases: []store.GlossaryAlias{{Alias: "古月方源", AliasType: "name"}}}
nested := row("古月", "Гуюэ", "approved")
rows := []store.GlossaryEntry{long, nested}
c := newConsistencyScan(consistencyBank(rows))
// The longer key wins the match; the nested row's canon comes out anyway.
c.add(1, "古月方源来了。", "Гуюэ Фан Юань пришёл.")
got := c.finish(rows)
if got.NeverFired != 0 {
t.Errorf("古月 IS in this source — it was eaten by a longer key, which is the suppressor working, not the term being absent from the book: never-fired=%d", got.NeverFired)
}
if got.Covered != 2 {
t.Errorf("both rows reached the reader: covered=%d absent=%d split=%d", got.Covered, got.Absent, got.Split)
}
if got.SuppressedSilent != 0 {
t.Errorf("the nested canon «Гуюэ» IS in the output, so nothing was silently lost: eaten=%d", got.SuppressedSilent)
}
if sum := got.NeverFired + got.BlockedOnly + got.Covered + got.Absent + got.Split; sum != got.Judged {
t.Errorf("the partition does not close: %d vs judgeable %d", sum, got.Judged)
}
}
// TestOneCleanChapterDoesNotEraseAnEatenCaseInAnother pins the eaten class as PER-CHAPTER. It used to be
// gated on the term having no firings at all, so a term rendered perfectly in chapter 1 wiped out the fact
// that a longer key ate it — and lost its canon — in chapter 3. That is the surface this pack advertises as
// the second root of row 407, reporting zero on a book that has one.
func TestOneCleanChapterDoesNotEraseAnEatenCaseInAnother(t *testing.T) {
long := store.GlossaryEntry{BookID: "b", Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed",
Aliases: []store.GlossaryAlias{{Alias: "古月方源", AliasType: "name"}}}
nested := row("古月", "Гуюэ", "approved")
rows := []store.GlossaryEntry{long, nested}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "古月来了。", "Гуюэ пришёл.") // fires in its own right and ships correctly
c.add(3, "古月方源来了。", "Гу Юэ Фан Юань пришёл.") // eaten by the longer key, canon lost
got := c.finish(rows)
if got.SuppressedSilent != 1 {
t.Fatalf("chapter 3 lost 古月's canon behind a longer key; one clean chapter must not erase it: eaten=%d", got.SuppressedSilent)
}
var eaten *TermConsistency
for i := range got.Terms {
if got.Terms[i].Src == "古月" {
eaten = &got.Terms[i]
}
}
if eaten == nil {
t.Fatalf("the eaten case must be named, not only counted: %+v", got.Terms)
}
if len(eaten.EatenIn) != 1 || eaten.EatenIn[0] != 3 {
t.Errorf("the chapter where it was eaten must be named: want [3], got %v", eaten.EatenIn)
}
}
// TestTheVerdictRestsOnTheAnchoredColumn pins WHICH column the verdict is taken on, and it is the guard
// against this order being changed a third time by argument instead of by measurement. The fixture is the
// only kind that can pin it: a rendering the one-rune stem tolerance accepts and the safe rule does not
// («корифей пути демонов» against «корифея пути демонов»). On a fixture where the columns agree, ANY order
// passes — which is exactly how a planted swap survived here once already.
//
// ⛔ WHY THE ANCHORED ONE. The safe rule leans on a term's stored decl forms, and no machine-produced bank
// in this tree has any: zero decl rows in every mined export measured, against 3542 of ~50 in every
// hand-written seed. The labelled corpus that says «the tolerance buys nothing» is built from hand seeds —
// it cannot show the tolerance's value, because it does not have the illness the tolerance treats. On the
// product's real path, a book with no hand seed and a mined bank, the stemmer has nothing to stem and this
// column is the only one that reaches an inflected rendering at all.
func TestTheVerdictRestsOnTheAnchoredColumn(t *testing.T) {
rows := []store.GlossaryEntry{row("魔道巨擘", "корифей пути демонов", "approved")}
c := newConsistencyScan(consistencyBank(rows))
c.add(1, "魔道巨擘来了。", "С точки зрения корифея пути демонов, это пустяк.")
got := c.finish(rows)
// PREMISE, and the whole point of the fixture: the two columns DISAGREE here. Without it the assertion
// below passes whichever column is consulted.
if got.AbsentSafe != 1 {
t.Fatalf("premise broken: the safe rule must NOT find «корифея» for «корифей», or the columns do not disagree: absent-safe=%d covered-safe=%d", got.AbsentSafe, got.CoveredSafe)
}
if got.Covered != 1 || got.Absent != 0 {
t.Errorf("the verdict must come from the ANCHORED column: «корифея пути демонов» IS this rendering inflected, and on a mined bank — which carries no decl forms — nothing else reaches it. got covered=%d absent=%d split=%d", got.Covered, got.Absent, got.Split)
}
}