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) } } } }