Mariusz Tober

Test, który nie przytakuje kodowi: niezależna wyrocznia i polska liczba mnoga

4 min czytania#testy#i18n#javascript#llm

Najtańszy sposób na zielony test to policzyć oczekiwaną wartość tym samym kodem, który się testuje. Uruchamiasz funkcję, kopiujesz wynik do asercji, test przechodzi. Od tej chwili test pilnuje tylko tego, żeby nic się nie zmieniło, w tym błędu, który właśnie skopiowałeś. Wyrocznia testu musi pochodzić z innego źródła niż testowany kod. Poniżej dwa miejsca z interaktywnej wizualizacji budowy modeli językowych, gdzie ta zasada zrobiła różnicę.

Sumy parametrów: wzór kontra nagłówki checkpointów

Strona liczy parametry modelu wzorem z config.json: embedding, uwaga, router, eksperci, ekspert współdzielony, normalizacje, wyjście. Wzór da się łatwo popsuć, na przykład zapominając o bramce eksperta współdzielonego (8192 parametry na warstwę) albo licząc embedding i wyjście raz, gdy wagi nie są związane.

Wyrocznią są sumy tensorów odczytane z nagłówków plików safetensors, bez pobierania wag. Skrypt, który je liczy, nie importuje niczego z kodu strony. Jego wynik trafia do testu jako stałe z datą odczytu:

// Sums of text-backbone tensors read from the checkpoint
// safetensors headers on 2026-11-05 (MTP head and vision tower excluded).
const CHECKPOINT_BACKBONE = {
  qwen359: 8_953_803_264,
  qwen3635b: 34_660_610_688,
  qwen35122b: 122_111_526_912,
  qwen35397b: 396_346_350_336,
  qwen3824t: 2_419_804_697_984,
};

test('formula totals equal the checkpoint backbone counts', () => {
  for (const [id, expected] of Object.entries(CHECKPOINT_BACKBONE)) {
    assert.equal(getPartitions(id).totalParams, expected, id);
  }
});

Pięć modeli, od 9 mld do 2,4 bln parametrów, zgodność co do jednego parametru. Obok stoi drugi test, który nie potrzebuje wyroczni z zewnątrz, bo sprawdza spójność: suma grup ma się równać całości, całość ma się równać liczbie warstw pomnożonej przez parametry jednej warstwy plus brzegi modelu, a liczba bajtów w bf16 ma wynosić dokładnie dwa razy liczba parametrów. Test spójności łapie błędy rachunkowe, test wyroczni łapie błędy w rozumieniu architektury. Żaden z nich nie zastępuje drugiego.

Trzeci test pilnuje rzeczy przyziemnej: model żyje w trzech miejscach (profil danych, słownik etykiet, lista wyboru w HTML), a test sprawdza, że te trzy listy są identyczne i że etykieta każdego modelu podaje tę samą liczbę warstw, co profil. Przy ręcznym dodawaniu modelu najłatwiej zapomnieć właśnie o jednym z tych trzech miejsc.

Polska liczba mnoga

Etykieta „Qwen3.8-2.4T-A95B · 92 warstwy” wygląda niewinnie, dopóki nie zobaczysz „92 warstw”. Po polsku rzeczownik po liczebniku ma trzy formy:

Implementacja zajmuje kilka linii:

function pluralIndex(count) {
  const n = Math.abs(Number(count));
  if (!Number.isInteger(n)) return 2;
  if (n === 1) return 0;
  if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) return 1;
  return 2;
}

W słowniku forma jest częścią szablonu: '{0} {0:warstwa|warstwy|warstw} × szerokość {1} × {2} punktacji ekspertów'. Token z dwukropkiem wybiera słowo według wartości argumentu i wypisuje tylko słowo, a zwykły {0} wstawia liczbę jak wcześniej. Tłumacz widzi wszystkie trzy formy w jednym miejscu i nie musi znać kodu.

Test odmiany ma wartości oczekiwane wypisane ręcznie, z gramatyki, nie z wyniku funkcji:

assert.equal(form(1), '1 warstwa');
assert.equal(form(2), '2 warstwy');
assert.equal(form(5), '5 warstw');
assert.equal(form(12), '12 warstw');
assert.equal(form(22), '22 warstwy');
assert.equal(form(92), '92 warstwy');

Przypadki 12 i 22 to sedno. Naiwna reguła „końcówka 2–4” daje „12 warstwy”, naiwna reguła „2–4 i nic więcej” daje „22 warstw”. Obie przeszłyby test, który sprawdza tylko 1, 2 i 5.

Znane ograniczenie: dla ułamków polszczyzna ma czwartą formę („1,5 warstwy”), a funkcja zwraca wtedy formę „many”. Warstwy są zawsze całkowite, więc to nie przeszkadza, ale warto o tym wiedzieć, zanim ktoś użyje tego samego tokenu dla gigabajtów. Intl.PluralRules('pl') zna wszystkie cztery kategorie i byłby naturalnym następcą, gdyby słownik zaczął odmieniać wielkości ułamkowe.

Brak klucza zatrzymuje stronę

Ostatnia decyzja w tym samym duchu: brak klucza w słowniku rzuca RangeError i strona nie startuje. Nie ma cichego spadku na angielski ani wypisania nazwy klucza. To brzmi surowo, ale cichy spadek to dokładnie ten rodzaj błędu, który przechodzi przez testy i przegląd, a potem wisi na produkcji miesiącami. Uzupełnia go test, który zbiera wszystkie atrybuty data-i18n z plików HTML i sprawdza, że każdy klucz istnieje, oraz test parzystości słowników PL i EN: te same klucze i te same symbole zastępcze {0}, {1}.

To samo przy pomiarach

Zasada działa też poza testami jednostkowymi. Porównując czas odpowiedzi dwóch tras do modeli w zewnętrznym API po jednym żądaniu na trasę, dostałem 49,1 s wobec 2,3 s, czyli „21 razy wolniej”. Po czterech żądaniach na trasę mediany wyniosły 4,6 s i 1,1 s. Kolejność się obroniła, liczba nie: pierwszy strzał mierzył zimny start, a nie model. Pierwszy pomiar odrzucam od tamtej pory z definicji, a do wniosku potrzebuję co najmniej czterech powtórzeń.

← Wszystkie wpisy