The advice arrived in the language she was trying to read her way out of

Reported as "the Portuguese option isn't translating the advice in
English — it's just reprinting Portuguese." Nothing was wrong with
targetFor. It was reading a direction the account could not leave.

learnerPairs held only zh, so SetPair refused learning_pair for pt-PT
and every Portuguese account was learning_en by force. targetFor then
did exactly what it says: explanations follow the half of the pair she
is not learning, which for a forced learning_en account is Portuguese.
A Portuguese document, corrected in Portuguese, explained in
Portuguese, with no way to ask for English — correct behaviour derived
from a fact about the roster that was no longer true.

The note in learnerPairs was written one phase too early to see it. It
said turning a pair around needs a word list and a dictionary reading
into English, and that fr, es and pt-PT had neither. Portuguese has
both. Word boundaries are spaces — the megabyte jieba needs is a
property of a writing system that doesn't use them, not a debt every
pair owes. And the dictionary arrived with dict.db, which reads pt→en
as readily as en→pt; dreamProvider.reverse has been answering that
question since the pair shipped. What was actually blocking the pair a
native English speaker learning Portuguese needs was this list.

So pt-PT joins it, and the pt-PT pack gets the learner copy the control
renders from — each label in the language of whoever would pick it,
since someone on the wrong side of that switch cannot read the side
they are reaching for. fr and es clear the same two bars through the
same dict.db and stay out: their packs carry no learner block yet,
which is a translation question rather than a data one, and the server
should keep saying no until one is written.

Two things that assumed learning_pair meant Chinese, now that it
doesn't. The segmenter gate reads the pair as well as the direction, or
a Portuguese learner would load a megabyte of Chinese word list and
hover Portuguese words at /api/hanzi. And that endpoint's own comment
justified skipping providerFor with a guarantee it no longer has; the
real guarantee was always the caller's — it is only ever asked about
tokens the Chinese segmenter found — and a stray lookup was already
safe, answering a miss with an empty 200.

Tests in both packages. The auth test that pinned pt-PT's refusal now
pins its acceptance, with fr and es still refused; the suggestions test
pins the consequence where it actually lands, which is the language she
reads her advice in.

Claude-Session: https://claude.ai/code/session_01GJHNvirh7Hzhc9RL3HAvz7
This commit is contained in:
prosolis
2026-07-29 18:40:22 -07:00
parent 6026d98598
commit 3cc23b8ea4
6 changed files with 135 additions and 24 deletions
+39 -5
View File
@@ -180,14 +180,17 @@ func TestDirectionRoundTrip(t *testing.T) {
} }
} }
// The refusal this axis exists to make: a pair with no word list cannot be // The refusal this axis exists to make: a pair with no learner-side data cannot
// learned toward, however good its langpack is. fr, es and pt-PT all have copy, // be learned toward, however good its langpack is. fr and es have copy, voices
// voices and spelling dictionaries and nothing that could segment a sentence // and spelling dictionaries, and no `learner` block in their packs to offer the
// or read from that language into English, which is what a learner needs. // choice with — so the server keeps saying no until one is written.
//
// pt-PT is deliberately no longer in this list; see TestLearnerDirectionForPtPT
// below and the argument in `learnerPairs`.
func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) { func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
_, users, _ := newStores(t) _, users, _ := newStores(t)
for _, lang := range []string{"pt-PT", "fr", "es"} { for _, lang := range []string{"fr", "es"} {
if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil { if err := users.SetPair("bob", lang, DirectionLearningEn); err != nil {
t.Fatalf("set %s: %v", lang, err) t.Fatalf("set %s: %v", lang, err)
} }
@@ -201,6 +204,37 @@ func TestLearnerDirectionRefusedForPairsWithoutData(t *testing.T) {
} }
} }
// The other direction of that same rule, and the one a native English speaker
// writing Portuguese depends on.
//
// This is not only a settings toggle: `direction` is what decides which language
// Petal *explains* in (see suggestions.targetFor), so an account that cannot
// reach learning_pair gets its Portuguese annotated in Portuguese with no way to
// ask for English. Pinned in both directions — the move must take, and it must
// still be there when the account is read back.
func TestLearnerDirectionForPtPT(t *testing.T) {
_, users, _ := newStores(t)
if err := users.SetPair("bob", "pt-PT", DirectionLearningEn); err != nil {
t.Fatalf("set pt-PT: %v", err)
}
if w := patchMe(t, users, "bob", `{"direction":"learning_pair"}`); w.Code != http.StatusOK {
t.Fatalf("status = %d (%s), want 200", w.Code, w.Body.String())
}
u, _ := users.Get("bob")
if u.Direction != DirectionLearningPair || u.PairLang != "pt-PT" {
t.Fatalf("account = %+v, want pt-PT learning_pair", u)
}
// And it can be turned back, the same as zh.
if w := patchMe(t, users, "bob", `{"direction":"learning_en"}`); w.Code != http.StatusOK {
t.Fatalf("turn back: status = %d (%s)", w.Code, w.Body.String())
}
if u, _ := users.Get("bob"); u.Direction != DirectionLearningEn {
t.Fatalf("direction = %q after turning back", u.Direction)
}
}
// The two-field combination the handler validates as one decision. An account // The two-field combination the handler validates as one decision. An account
// already learning Chinese that asks only to change pair is asking for a state // already learning Chinese that asks only to change pair is asking for a state
// neither field names on its own — French with segmentation — and it must not // neither field names on its own — French with segmentation — and it must not
+27 -12
View File
@@ -104,19 +104,34 @@ const (
// The pairs whose *learner* direction Petal can actually serve, which is a // The pairs whose *learner* direction Petal can actually serve, which is a
// narrower thing than a shipped pair and narrower again than a langpack. // narrower thing than a shipped pair and narrower again than a langpack.
// //
// Turning a pair around needs data no langpack carries: a word list to segment // Turning a pair around needs data no langpack carries: a way to find word
// with, and a dictionary that reads from the pair language into English. Chinese // boundaries, and a dictionary that reads from the pair language into English. A
// has both as of Phase 26 (CC-CEDICT + jieba); French, Spanish and Portuguese // pair missing either would leave a writer looking at an editor that silently
// have neither yet, and — unlike a missing pack, which leaves a writer looking // does nothing when she hovers — worse than a missing pack, which at least reads
// at copy she cannot read — a missing word list would leave her looking at an // as a bug rather than as an absence. So the server refuses, for the same reason
// editor that silently does nothing when she hovers. Both are bad; only one is // and by the same mechanism as `shippedPairs`.
// legible as a bug. So the server refuses, for the same reason and by the same
// mechanism as `shippedPairs`.
// //
// This list is expected to grow one pair at a time and never to be inferred: // Chinese has both as of Phase 26 (CC-CEDICT + jieba). Portuguese turns out to
// segmentation is a property of a writing system, and there is no rule that // have both as well, and the original note here — "French, Spanish and
// derives "has a word list" from a language code. // Portuguese have neither" — was written one phase too early to see it:
var learnerPairs = []string{"zh"} //
// - Word boundaries are spaces. The megabyte word list jieba needs is a
// property of a writing system that doesn't use them, not a debt every pair
// owes; a Latin-script pair needs nothing loaded to be segmented.
// - The dictionary arrived with dict.db, which reads pt→en as readily as
// en→pt (see lexicon.dreamProvider.reverse). The reverse lookup the hover
// and the word card need is already there and already answering.
//
// So the pair a native English speaker learning Portuguese needs is real, and
// what was actually blocking it was this list. French and Spanish clear the same
// two bars through the same dict.db; they are held back only by their packs
// carrying no `learner` copy yet (see Pack.learner), which is a translation
// question rather than a data one.
//
// This list is still expected to grow one pair at a time and never to be
// inferred: segmentation is a property of a writing system, and there is no rule
// that derives "has a word list" from a language code.
var learnerPairs = []string{"zh", "pt-PT"}
// SupportsLearnerDirection reports whether a pair can be turned around. // SupportsLearnerDirection reports whether a pair can be turned around.
func SupportsLearnerDirection(lang string) bool { func SupportsLearnerDirection(lang string) bool {
+13 -4
View File
@@ -49,10 +49,19 @@ func (h *Handler) GlossRoutes() chi.Router {
// It does not go through [Handler.providerFor], and that is not an oversight. // It does not go through [Handler.providerFor], and that is not an oversight.
// providerFor picks a dictionary by the writer's *pair*, to answer "what does // providerFor picks a dictionary by the writer's *pair*, to answer "what does
// this English word mean in her language" — a question whose answer differs per // this English word mean in her language" — a question whose answer differs per
// pair. This endpoint asks the opposite question of exactly one language, and // pair. This endpoint asks the opposite question of exactly one language: it
// [auth.SupportsLearnerDirection] already guarantees that language is Chinese. // reads hanzi, and hanzi are Chinese whoever is looking them up. Routing it
// Routing it through the pair would add a database read per hover to choose // through the pair would add a database read per hover to choose between one
// between one option and itself. // option and itself.
//
// What no longer holds is the reason this used to give — that
// [auth.SupportsLearnerDirection] guarantees the caller is on the zh pair. Since
// Portuguese joined `learnerPairs` a learning_pair account may be Portuguese, so
// the guarantee now comes from the *caller*: the client only ever asks this
// route about a token its Chinese segmenter found, and that segmenter is loaded
// only for the zh pair (see useSegmenter in App.tsx). A stray lookup is still
// answered safely — a word the Chinese dictionary has never heard of is a 200
// with empty lists, exactly like any other miss.
func (h *Handler) HanziRoutes() chi.Router { func (h *Handler) HanziRoutes() chi.Router {
r := chi.NewRouter() r := chi.NewRouter()
r.Get("/{word}", h.hanzi) r.Get("/{word}", h.hanzi)
+30 -2
View File
@@ -119,8 +119,36 @@ func TestDocumentLangNeedsCorroboration(t *testing.T) {
} }
} }
// The two language decisions are genuinely independent, and only the zh pair can // The Portuguese half of the same rule, and the bug it was reported as: "the
// prove it today — it is the one pair that can be travelled in both directions. // Portuguese option isn't translating the advice in English — it's just
// reprinting Portuguese."
//
// Nothing was wrong with targetFor when that was reported. It was reading a
// direction the account could not leave: `learnerPairs` held only zh, so every
// pt-PT writer was learning_en by force and this function correctly explained a
// Portuguese document in Portuguese. Pinned here rather than only in the auth
// package because this is where the consequence actually lands — the language
// the writer reads her advice in.
func TestTargetExplainsPortugueseInEnglishForALearner(t *testing.T) {
learner := targetFor("pt-PT", auth.DirectionLearningPair, docLangPair)
if learner.Correct.Code != "pt-PT" {
t.Fatalf("corrected in %s, want the document's own Portuguese", learner.Correct.Code)
}
if learner.Explain.Code != "en" {
t.Fatalf("explained in %s, want English", learner.Explain.Code)
}
// And the native Portuguese speaker practising English is untouched: her
// Portuguese is still explained in Portuguese.
native := targetFor("pt-PT", auth.DirectionLearningEn, docLangPair)
if native.Correct.Code != "pt-PT" || native.Explain.Code != "pt-PT" {
t.Fatalf("learning_en on a Portuguese document: correct=%s explain=%s", native.Correct.Code, native.Explain.Code)
}
}
// The two language decisions are genuinely independent, and zh was the first
// pair that could prove it — the first that could be travelled in both
// directions.
// //
// A Mandarin native practising English who writes Chinese wants Chinese // A Mandarin native practising English who writes Chinese wants Chinese
// corrections explained in Chinese. An English native learning Chinese who writes // corrections explained in Chinese. An English native learning Chinese who writes
+7 -1
View File
@@ -118,7 +118,13 @@ export default function App() {
// Mandarin native drafting English quotes Chinese constantly, and none of that // Mandarin native drafting English quotes Chinese constantly, and none of that
// is what segmentation is for. Declared above the checkpoint because the // is what segmentation is for. Declared above the checkpoint because the
// offline 错别字 pass reads it. // offline 错别字 pass reads it.
const segmenter = useSegmenter(me?.direction === 'learning_pair') //
// Both halves of the gate matter now that Chinese is not the only pair with a
// learner direction. `learning_pair` alone used to imply zh; a writer learning
// Portuguese is also learning_pair and has no use for a megabyte of Chinese
// word list — nor for the hanzi hover it turns on, which would ask /api/hanzi
// about Portuguese words.
const segmenter = useSegmenter(me?.direction === 'learning_pair' && me?.pair_lang === 'zh')
const { const {
suggestions, suggestions,
+19
View File
@@ -42,6 +42,25 @@ export const ptPT: Pack = {
nativeName: 'Português', nativeName: 'Português',
locale: 'pt-PT', locale: 'pt-PT',
// pt-PT is the second pair Petal can be *learned* toward: spaces do the
// segmenting a Latin script needs, and dict.db already reads Portuguese into
// English (see auth.learnerPairs for both halves of that argument).
//
// Each label is written for whoever would pick it, which is why they are not
// in the same language as each other. A native Portuguese speaker practising
// English reads the first; an English speaker learning Portuguese reads the
// second, and would not be helped by being told "Português" in Portuguese.
//
// This is also the switch that decides which language Petal *explains* in, so
// it is the difference between a Portuguese document annotated in Portuguese
// and the same document annotated in English.
learner: {
label: 'Estou a aprender · I am learning',
toEn: 'inglês',
toPair: 'Portuguese',
failed: 'Não foi possível mudar · Couldnt switch — nothing changed',
},
app: { app: {
duplicateTitle: (title) => `${title} (cópia)`, duplicateTitle: (title) => `${title} (cópia)`,
garden: 'Jardim de palavras', garden: 'Jardim de palavras',