textmachine/backend/internal/pipeline/gender_wire_test.go

266 lines
12 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
import (
"context"
"os"
"strings"
"testing"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/store"
)
// gender_wire_test.go: backlog row 210 — the gender axis had a DELIVERY mechanism (D39.21, «род должен
// доезжать») and no producer at all. The terminologist's reply has three fields and none is gender; the
// miner reads the field out of a hand-written seed only. So on any book nobody seeded by hand the axis was
// dead by construction, and every gender test in the tree constructed the datum by hand — a green battery
// could not tell «the producer works» from «there is no producer».
//
// These tests are therefore END-TO-END on purpose: a real run, a real reply from the §2 classifier, and the
// question asked of the actual wire bytes rather than of a renderer called with a hand-built record.
// TestTheClassifierProducesGenderIntoTheBank is the producer half. The classifier answers the third column
// its prompt now asks for, and the datum has to survive every hop between the reply and the bank: the
// parser's closed vocabulary, attachClassifiedGender, the delta emission, and the seed schema the signature
// map and auto-bank are both written in.
func TestTheClassifierProducesGenderIntoTheBank(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isClassifierBody(body) {
// 方源 is a man; the mountain is a place and answers `none`, which must reach the bank as
// NOTHING rather than as the word.
return "方源\tname\tmale\n青茅山\tplace\tnone", "stop"
}
if isTerminologyBody(body) {
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
r := newVerifyRunner(t, bookPath)
defer r.Close()
_ = runToSignatureStop(t, r)
if got := r.lastTerminology.Gendered; got != 1 {
t.Fatalf("exactly one term carried a gender datum, got Gendered=%d", got)
}
yamlMap := readSignatureMap(t, r)
if man := termBlock(t, yamlMap, "方源"); !strings.Contains(man, "gender: male") {
t.Fatalf("the produced gender must reach the row the owner signs:\n%s", man)
}
// `none` is an answer, not a datum: the place's gender field stays EMPTY. Writing the word itself
// would reach membank's closed gender vocabulary and refuse the whole file on its next load — the
// row-84 class, where an unknown value silently injected no directive and now fails loudly instead.
if place := termBlock(t, yamlMap, "青茅山"); !strings.Contains(place, `gender: ""`) {
t.Fatalf("`none` must reach the bank as no gender at all:\n%s", place)
}
}
// TestASignedGenderBeatsTheProducedOne is the negative control the brief asks for by name. The owner's seed
// is the authority; the engine's proposal for a term the owner already signed is dropped by loadAutoBank
// (the unique key is held), so «the model decided my character is female» is not a state a signed term can
// reach. Asserted on the WIRE, because that is where being wrong would cost something.
func TestASignedGenderBeatsTheProducedOne(t *testing.T) {
rec := &reqRec{}
var editBody string
srv := newJSONProvider(rec, func(body string) (string, string) {
if isClassifierBody(body) {
return "方源\tname\tmale", "stop" // the engine says male…
}
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
editBody = body
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// …and the owner has signed FEMALE for the same term.
seed := "terms:\n - src: 方源\n dst: Фан Юань\n gender: female\n status: approved\n"
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true, glossarySeed: seed})
r := newRunner(t, bookPath) // AUTO mode: the run does not stop, so the edit wave actually renders
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
if editBody == "" {
t.Fatal("the edit wave must have run")
}
tx := lang.InjectionTextsFor("ru")
if !strings.Contains(editBody, "方源 → «Фан Юань»"+tx.GenderFemale) {
t.Fatalf("the SIGNED gender is what reaches the wire:\n%s", editBody)
}
if strings.Contains(editBody, tx.GenderMale) {
t.Fatalf("the engine's proposal overrode the owner's signature:\n%s", editBody)
}
// And the bank keeps one row for the term — the owner's — rather than two disagreeing ones.
rows, err := r.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
n := 0
for _, e := range rows {
if e.Src == "方源" {
n++
if e.Gender != "female" {
t.Errorf("the banked row must keep the signed gender, got %q", e.Gender)
}
}
}
if n != 1 {
t.Fatalf("a signed term must hold its key alone, got %d rows for 方源", n)
}
}
// TestWhereAProducedGenderActuallyReachesTheWire holds the delivery boundary of the whole axis, and it
// holds it in THREE separate statements because there are three separate doors and «it works» would hide
// which of them is open:
//
// 1. an unsigned row on a trusted key DOES carry its gender to the EDITOR — the door row 210 exists to
// open, and the one that makes the classifier's answer worth buying;
// 2. a collision-prone match carries it NOWHERE, whatever its status — the door that must stay shut,
// because a gender note on the wrong entity is wrong rather than merely unsigned;
// 3. a produced gender reaches the DRAFT wire never, because the draft wave runs over the BASE bank,
// which excludes Source:"mined" by construction (D39.42 п.3's money contract — «one re-payment» is
// honest at the WIRE level, not only in the version hash), and the auto-bank is written as `mined`.
// Opening THAT door is a decision about paying for two waves instead of one, and nobody has made it.
//
// ⚠ WHAT DOOR 3 USED TO PROMISE AND DID NOT KEEP. Its comment said «if a later pack admits mined rows to
// the base bank, the term appears and this reds». By execution it does not: planted exactly that way, four
// MONEY tests went red and this one stayed green. The reason is timing, not the bank — inside ONE run the
// draft wave finishes before the classifier has produced anything, so no rule about which bank the draft
// reads can put a gender on that wire in this run. The absence assertion below is therefore a statement
// about ORDER, and it is kept as one; the money contract it was claiming to hold is asserted where it
// actually lives, on the two materialized banks, at the end of this test.
func TestWhereAProducedGenderActuallyReachesTheWire(t *testing.T) {
rec := &reqRec{}
var draftBodies, editBodies []string
srv := newJSONProvider(rec, func(body string) (string, string) {
switch {
case isClassifierBody(body):
return "方源\tname\tmale", "stop"
case isTerminologyBody(body):
return "方源\tФан Юань", "stop"
case isEditBody(body):
editBodies = append(editBodies, body)
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
draftBodies = append(draftBodies, body)
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
tx := lang.InjectionTextsFor("ru")
// The datum IS in the bank the run finished with — the producer works.
rows, err := r.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
banked := ""
for _, e := range rows {
if e.Src == "方源" {
banked = e.Gender
}
}
if banked != "male" {
t.Fatalf("the produced gender must be in the bank the run ends with, got %q", banked)
}
// (1) THE EDITOR HAS IT — in the same run that produced it, off an UNSIGNED row.
gotEdit := false
for _, b := range editBodies {
if strings.Contains(b, tx.GenderMale) {
gotEdit = true
}
}
if !gotEdit {
t.Fatalf("the produced gender must reach the editor wire in the run that produced it:\n%s", strings.Join(editBodies, "\n---\n"))
}
// (3a) THE DRAFT WIRE HAS IT NEVER — in THIS run, as a matter of order: the draft wave is bought before
// the classifier answers. This assertion cannot see which bank the draft read, and no longer claims to.
for _, b := range draftBodies {
if strings.Contains(b, tx.GenderMale) {
t.Fatalf("a produced gender reached the DRAFT wire — that costs a second wave (D39.42 п.3); say which lock was opened and why:\n%s", b)
}
}
// (3b) AND THE MONEY CONTRACT ITSELF, asserted on the two banks the run ended with rather than on a
// wire that could not have carried the datum anyway. THIS is what reds when a later pack admits mined
// rows to the base bank — and what stayed green while door 3's comment said otherwise.
mined := false
for _, row := range r.bankRows {
if row.Src == "方源" && row.Source == "mined" {
mined = true
}
}
if !mined {
t.Fatalf("premise broken: the produced row must be stored as `mined`, or this pair of banks proves nothing about it: %+v", r.bankRows)
}
if r.baseMemory == r.memory {
t.Fatal("premise broken: with a mined row present the two banks must be separate objects — the base " +
"bank is what makes «one re-payment» honest at the wire level")
}
const namesTheTerm = "方源 пришёл к горе Цинмао."
enriched := r.memory.Select(namesTheTerm, 1, nil, r.Pipeline.Context.GlossaryTokenBudget)
base := r.baseMemory.Select(namesTheTerm, 1, nil, r.Pipeline.Context.GlossaryTokenBudget)
hasTerm := func(sel membank.Selection) bool {
for _, p := range sel.Injected {
if p.Src() == "方源" {
return true
}
}
return false
}
if !hasTerm(enriched) {
t.Errorf("the ENRICHED bank — the one the editor selects over — must carry the mined row, or the " +
"produced datum reaches nothing at all")
}
if hasTerm(base) {
t.Error("the BASE bank selected a mined row: the draft wave would then differ across a bank-mining " +
"enrichment, which is the second wave D39.42 п.3 says nobody has agreed to pay for")
}
// The auto-bank file, which the NEXT run reads, does carry it: the datum is durable, not lost.
raw, err := os.ReadFile(r.autoBankPath())
if err != nil {
t.Fatalf("the auto mode must have written its bank: %v", err)
}
if !strings.Contains(string(raw), "gender: male") {
t.Fatalf("the produced gender must be durable in the auto-bank:\n%s", raw)
}
}
// TestACollisionProneMatchCarriesNoGenderWhateverItsStatus is door 2, and it is a UNIT check on purpose:
// the branch cannot fire on zh at all (Han keys anchor at 2), so no end-to-end zh fixture can reach it,
// and a guard nothing exercises is a guard that quietly stops guarding. Both statuses are checked, because
// the whole point of the split is that this question is not the status question.
func TestACollisionProneMatchCarriesNoGenderWhateverItsStatus(t *testing.T) {
tx := lang.InjectionTextsFor("ru")
for _, status := range []string{"approved", "draft"} {
rows := []store.GlossaryEntry{{
BookID: "b", Src: "すずき", Dst: "Судзуки", Gender: "female", Status: status, Source: "seed",
}}
bank := membank.Materialize(rows, false)
sel := bank.Select("すずきが来た。", 1, nil, 0)
if len(sel.Injected) == 0 {
t.Fatalf("%s: the fixture must fire its short phonetic key", status)
}
for _, block := range []string{
membank.RenderGlossaryBlock(sel.Injected, tx),
membank.RenderEditorConstraintBlock(sel.Injected, tx),
} {
if strings.Contains(block, tx.GenderFemale) {
t.Fatalf("%s: a key that may have fired inside an unrelated word must carry no gender to any wire: %s", status, block)
}
}
}
}