From acb35108c07627c33a2d399986ebd866a5570910 Mon Sep 17 00:00:00 2001 From: prosolis <5590409+prosolis@users.noreply.github.com> Date: Wed, 29 Jul 2026 00:28:32 -0700 Subject: [PATCH] Curated against English had quietly become curated against writing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seen live: "Esta manhã acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia poucas pessoas na rua." — unremarkable Portuguese, two marker hits, zero English hits, and a verdict of English. Corrected as English, read aloud in an American voice. The list was missing the ordinary machinery of the language: the contractions (ao, à, num), the tenses a diary is written in (estava, havia, fomos), the words that join two clauses (até, depois, então, onde). Each clears the bar the list already set — an English sentence has no reason to contain them — so their absence bought nothing. The floor stays at three. What changed is that three is now reachable by prose rather than only by a paragraph that argues its own case. fr and es get the same additions by analogy; neither has an account yet to catch it live, which is exactly how this one survived. "sin" and "tan" stay out of the es list: both are English words. Two regression tests, pointed in opposite directions — ordinary Portuguese must read as hers, and English about Portugal, English quoting Portuguese, and a plain English diary must all still read as English, held verdict included. Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7 --- internal/suggestions/doclang_test.go | 52 ++++++++++++++++++++++++++++ internal/suggestions/language.go | 35 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/internal/suggestions/doclang_test.go b/internal/suggestions/doclang_test.go index 0fdb567..3c3349c 100644 --- a/internal/suggestions/doclang_test.go +++ b/internal/suggestions/doclang_test.go @@ -446,3 +446,55 @@ func TestPassAnnouncesItsVerdict(t *testing.T) { t.Fatal("empty document answered with no verdict header at all") } } + +// TestOrdinaryProseIsEnoughEvidence is the regression for what the marker lists +// were caught doing on 2026-07-29, live, in a browser: unremarkable Portuguese +// read as English, because the list was curated against English so tightly that +// it had also been curated against ordinary writing. The document below scored +// two pair markers and zero English ones, and two is below the corroboration +// floor — so a paragraph with no evidence of English in it at all came back +// English, and was corrected and read aloud as English. +// +// Every sample here is prose a person might actually write, not prose chosen to +// contain markers. That is the whole point of the test: the failure was invisible +// to a suite whose fixtures all argued their own case. +func TestOrdinaryProseIsEnoughEvidence(t *testing.T) { + samples := []struct{ name, text string }{ + {"the one seen live", "Esta manhã acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia poucas pessoas na rua. Depois comprei um jornal e li-o sentado num banco ao sol."}, + {"an afternoon out", "Hoje o céu estava limpo e fomos até ao jardim junto ao rio. A minha mãe trouxe uma manta velha e sentámos-nos debaixo de uma árvore."}, + {"plans", "Amanhã vamos ao cinema depois do trabalho. Ontem estava demasiado cansada para sair de casa."}, + } + for _, s := range samples { + if got := documentLang(s.text, "pt-PT", ""); got != docLangPair { + t.Errorf("%s: documentLang = %q, want %q — ordinary Portuguese must not read as English\n%s", + s.name, got, docLangPair, s.text) + } + } +} + +// TestEnglishDidNotGetEasierToMistake is the other half, and the reason the +// additions were held to "a word an English sentence has no reason to contain". +// Widening a marker list is only safe if it widens in one direction: these are +// English documents, including ones about Portugal and ones quoting Portuguese, +// and every one of them must still come back English. +func TestEnglishDidNotGetEasierToMistake(t *testing.T) { + samples := []struct{ name, text string }{ + {"plain English", "This morning I woke up early and went for a run along the seafront. The air was fresh and there were few people about. Afterwards I bought a newspaper and read it on a bench."}, + {"English about Portugal", "We spent a week in Lisbon last summer. The trams were crowded but the food was wonderful, and we walked up to the castle every evening."}, + {"English quoting her", "My mother always says \"até amanhã\" when she leaves, never goodbye. I asked her why once and she said it sounded less final to her."}, + {"an English diary", "Today was long. I had two meetings before lunch and another one after, and by the time I got home I could not think straight. Tomorrow should be quieter."}, + } + for _, s := range samples { + if got := documentLang(s.text, "pt-PT", ""); got != docLangEnglish { + t.Errorf("%s: documentLang = %q, want %q — the widened list must not pull English across\n%s", + s.name, got, docLangEnglish, s.text) + } + } + // And the same document must not flip once it is already sitting in English: + // the hysteresis band is only a safety net if the low side holds too. + for _, s := range samples { + if got := documentLang(s.text, "pt-PT", docLangEnglish); got != docLangEnglish { + t.Errorf("%s: held verdict flipped to %q", s.name, got) + } + } +} diff --git a/internal/suggestions/language.go b/internal/suggestions/language.go index 0f3d8bd..99885c3 100644 --- a/internal/suggestions/language.go +++ b/internal/suggestions/language.go @@ -155,6 +155,19 @@ func distinctMarkers(s string, markers map[string]bool) int { // // A single marker is not enough (see readsAsPairLang), so these lists are read // as evidence to be corroborated rather than as a decision. +// +// **Curated against English is not the same as curated thinly**, and the first +// version of these lists confused the two. Seen live 2026-07-29: "Esta manhã +// acordei cedo e fui correr ao longo da marginal. O ar estava fresco e havia +// poucas pessoas na rua." — unremarkable Portuguese, two marker hits, *zero* +// English hits, and a verdict of English, because the document-level floor wants +// three. The list was missing the ordinary machinery of the language: the +// contractions (ao, à, num), the past tenses a diary is written in (estava, +// havia, fomos), and the words that join two clauses (até, depois, então, +// onde). Every one of them clears the bar above — an English sentence has no +// reason to contain them — so their absence bought nothing and cost the verdict. +// The floor stays at three; what changed is that three is now reachable by +// prose rather than only by a paragraph that happens to argue with itself. var latinMarkers = map[string]map[string]bool{ "fr": words( "je", "tu", "il", "elle", "ils", "elles", "nous", "vous", "est", "sont", @@ -164,6 +177,10 @@ var latinMarkers = map[string]map[string]bool{ "beaucoup", "toujours", "jamais", "quand", "bien", "chose", "temps", "moi", "toi", "lui", "peux", "veux", "sais", "faire", "dit", "aujourd", "hui", "quelque", "chez", "tout", "tous", "rien", "déjà", "encore", + // The same gap the pt-PT list was caught with, closed by analogy rather + // than by observation — no fr account exists yet to catch it live. + "aux", "après", "où", "avait", "étaient", "depuis", "jusqu", "chaque", + "autre", "même", "hier", "demain", "matin", "soir", "nôtre", "leurs", ), "pt-PT": words( "eu", "você", "ele", "ela", "eles", "elas", "nós", "são", "uma", "os", @@ -173,6 +190,18 @@ var latinMarkers = map[string]map[string]bool{ "nunca", "bem", "obrigado", "obrigada", "gosto", "tenho", "tem", "foi", "ser", "ter", "mais", "já", "ainda", "aqui", "ali", "nada", "tudo", "todos", "para", "pela", "pelo", "sobre", "assim", + // The contractions, which no English sentence has any use for. + "ao", "aos", "à", "às", "num", "numa", "dum", "duma", "pelos", "pelas", + "neste", "nesta", "disso", "deste", "desta", + // The tenses a journal is actually written in. + "estava", "estavam", "estão", "estamos", "havia", "houve", "era", "eram", + "fui", "fomos", "foram", "vai", "vamos", "tinha", "tinham", + // The joins between two clauses. + "até", "depois", "antes", "onde", "então", "enquanto", "embora", + "sem", "quem", "entre", + // And the everyday determiners and time words a diary can hardly avoid. + "nosso", "nossa", "outro", "outra", "mesmo", "mesma", "tão", + "muitos", "muitas", "poucos", "poucas", "hoje", "ontem", "amanhã", ), "es": words( "yo", "él", "ella", "ellos", "ellas", "nosotros", "una", "los", "las", @@ -181,6 +210,12 @@ var latinMarkers = map[string]map[string]bool{ "hacer", "siempre", "nunca", "bien", "gracias", "tengo", "tiene", "fue", "ser", "tener", "más", "aquí", "allí", "nada", "todos", "para", "sobre", "así", "hola", "señor", "usted", "muchas", + // Likewise by analogy: no es account exists yet either. "sin" and "tan" + // stay out — both are English words, which is the one disqualification. + "al", "después", "antes", "donde", "entonces", "mientras", "aunque", + "estaba", "estaban", "están", "había", "hubo", "fuimos", "fueron", + "nuestro", "nuestra", "otro", "otra", "mismo", "misma", "quién", "quien", + "muchos", "pocas", "pocos", "hoy", "ayer", "mañana", ), }