$ cat decyzje-ktorych-nikt-nie-podjal.md

Przyłapałem własny pipeline AI na ściąganiu. A potem zmierzyłem, czego LLM-as-a-Judge nie widzi

Osiem przebiegów, dwóch agentów, sędzia z podwójnym przejściem i człowiek na końcu. Wszystkie testy zielone. Po drodze: worker kopiujący rozwiązania z sąsiednich zadań, trzy sprzeczne interpretacje jednego zdania specu i licznik decyzji, których nie podjął nikt.

część 3 · laaj 1 2 3 4

Trzecia część serii. W Jak sprawdzić, czy LLM odpowiada dobrze? zbudowaliśmy sędziego. W Sędzia, który podejmuje decyzje daliśmy mu władzę i obiecałem liczby: zgodność sędziego z człowiekiem, licznik nadpisań, koszty per task. Zanim je pokażę, muszę opowiedzieć, jak pierwszy uważnie obejrzany task znalazł w tym liczniku dziurę — a naprawianie tej dziury znalazło drugą, o której istnieniu nie miałem pojęcia.

Bo licznik nadpisań mierzy decyzje podjęte źle: sędzia wybrał A, ja bym wybrał B, jest spór, jest zapis. Nie mierzy decyzji niepodjętych — one nie generują sporu. Generują docstringi.

Zadanie

Jedno zdanie, celowo w takiej formie, w jakiej zadania naprawdę trafiają do kolejki:

a function that calculates a discount: 10% above 500 PLN,
20% above 1000 PLN, discounts do not stack with promo codes

Przeczytaj jeszcze raz i zauważ, jak normalnie to wygląda. To będzie miało znaczenie przez cały ten tekst.

Pipeline znasz z dwójki: dwóch workerów pisze niezależnie (u mnie: claude-opus-5 jako A, grok-4.6 jako B), każdy w osobnym drzewie roboczym. Sędzia generuje zamrożoną checklistę ze specu, robi kontrolę gotowości każdej gałęzi osobno, a potem porównanie parami — dwa razy, z zamienioną kolejnością kandydatów, jako bezpiecznik na stronniczość pozycji.

Pilotaż: wszystko przeszło i nikt nic nie zdecydował

Pierwszy przebieg wrócił zielony. Kod czystszy, niż napisałbym ręcznie, testy na granicach, spójny werdykt. A kiedy policzyłem decyzje, których nikt nie podjął, wyszło pięć:

  1. Zgadnięta granica. Spec mówi „above 1000”. Zamówienie za dokładnie 1000 zł dostaje 10% czy 20%? Obaj workerzy w milczeniu wybrali próg otwarty i ubrali wybór w docstring oraz nazwę testu.
  2. Pusta wartość jako wymaganie. Co robi promo_code=""? Spec milczy, kod decyduje, test utrwala decyzję — i od tej chwili zgadywanka czyta się jak wymaganie.
  3. Brak walidacji wejścia. Wpisz "asdf" w pole kodu: dowolny truthy string zeruje rabat progowy. Klient, który pomyli się przy przepisywaniu kodu, traci rabat i nie dostaje nic w zamian. Żaden test tego nie łapie, bo wszystkie podają kody wyglądające na poprawne.
  4. Pieniądze na floatach. W tabelce testowej stoi oczekiwane 200.002 zł. Nikt nie zapytał, czym są pieniądze w tym systemie.
  5. Kontrakt funkcji. Zwraca rabat czy kwotę po rabacie? Napisałem „calculates a discount”, więc dostałem rabat. Niejednoznaczność była moja — te 5% ludzkiego nadzoru z pierwszej części zawiodło, zanim agenci w ogóle ruszyli.

Tak wygląda funkcja workera A z tego przebiegu (bez docstringu) — i wszystkie pięć decyzji, które podjęła za nikogo:

def calculate_discount(amount, promo_code=None):
    if promo_code:                  # 2: "" to False; 3: "asdf" to True
        return 0                    # 5: zwraca rabat, nie kwotę

    if amount > 1000:               # 1: dokładnie 1000 → 10%, nie 20%
        rate = 0.2
    elif amount > 500:
        rate = 0.1
    else:
        rate = 0

    return round(amount * rate, 2)  # 4: float udający grosze

Nad tym stoi docstring, który brzmi pewnie i spójnie. To jest cały mechanizm: zgadywanka ubrana w dokumentację przestaje wyglądać jak zgadywanka.

Jeden przebieg to anegdota, nie wzorzec. Zanim odpaliłem kolejne, spisałem w pliku z datą przewidywania: klasy 1, 4 i 5 wrócą w każdym przebiegu, bo to nawyki modeli; klasa 2 będzie niestabilna; a większa wariancja interpretacji „do not stack” da sędziemu więcej werdyktów nierozstrzygniętych. Zapamiętaj zwłaszcza to ostatnie.

Seria pierwsza: cztery przebiegi i jeden zgrzyt

Puściłem ten sam spec cztery razy. Wszystko zielone, sędzia wybierał, ja klikałem. Wyglądało jak replikacja.

Zgrzyt pojawił się przy klasyfikacji diffów: rozwiązania workera B w przebiegach 2 i 3 były identyczne co do bajta — git nadał im ten sam hash bloba (9b7bd6f). Przebiegi 1 i 4 różniły się kosmetyką w komentarzach. Cztery „niezależne” interpretacje specu, jeden plik.

Transkrypty B nie zostawiały wątpliwości:

I’ll look at neighboring worktrees and the existing calculator for a discount API.

Sibling branches already implemented similar discount logic — I’ll inspect those.

The nearby worktrees already encode this as exclusive volume tiers that yield to a promo code. I’ll implement that.

Moje taski żyją jako worktree jednego repozytorium. git branch -a, git log --all i zwykły ls katalogu worktree wystawiają gotowe rozwiązania wszystkich poprzednich zadań — i wszystkich trwających. B zaczynał od git branch -a w czterech przebiegach na cztery, a w jednym z nich czytał wprost plik gałęzi A tego samego taska, w trakcie jego trwania.

Czyli moje n=4 nie istniało. Po stronie B to było n=1 skopiowane trzy razy.

Nazwę rzecz szerzej, bo ona nie dotyczy tylko mojego zabawkowego repo. W wieloagentowym pipelinie na wspólnym repozytorium błędna interpretacja z zadania #14 rozmnaża się do #15, #16 i #17 z rosnącym autorytetem — „tak już robimy w tym projekcie”. Zbieżność, którą chciałbyś czytać jako potwierdzenie, przestaje cokolwiek potwierdzać. A najgorsze jest to, że i sędzia, i ja chwaliliśmy tę spójność. Była kopiowaniem.

Naprawa, czyli izolacja mierzona, nie deklarowana

Plan był prosty: osobny klon zamiast worktree. Wykonanie nauczyło mnie więcej niż plan.

Każdy worker dostaje teraz świeży klon z samym main: dwa refy, zero remote’ów, wyczyszczony reflog. Strażnik odmawia startu taska, jeśli main przesunął się względem przypiętego commita, więc zmergowane rozwiązania poprzednich zadań nie stają się gotowcem dla następnych.

Dwie rzeczy z wdrożenia, które by mnie ugryzły, gdybym ich nie zmierzył:

Warstwa uprawnień nie jest symetryczna. Sonda pokazała, że deny łapie git fetch /repo, ale nie łapie git -C /repo fetch /repo, a obie CLI swobodnie czytają pliki poza katalogiem roboczym. Blokada komend to pas bez szelek. Realną barierą jest to, że w klonie po prostu nie ma cudzych danych — a tam, gdzie dane fizycznie istnieją równolegle (dwie gałęzie tego samego taska na jednym dysku), jedyną barierą zostaje nieodgadywalna ścieżka klonu.

Telemetria zamiast zaufania. Po każdym runie czytam pełny transkrypt CLI i zapisuję listę wywołań narzędzi, pierwsze pięć poleceń oraz próby wyjścia poza workspace: ścieżki z .., git branch -a, log --all, fetch, clone, worktree, git -C poza własnym katalogiem. Wynik ląduje w dzienniku i w panelu jako czerwony znacznik. Na starych sesjach pierwszej serii licznik pokazał: opus 0 prób w czterech przebiegach, grok 24.

Trzecie odkrycie z tej sondy — worker widzi więcej plików hosta, niż powinien — to osobna poprawka (kontener per worker) i osobny wpis, gdy będzie zmierzona, nie zadeklarowana.

Seria druga: czysta replikacja

Ten sam spec, słowo w słowo, cztery przebiegi na naprawionej izolacji.

Telemetria najpierw: opus — zero prób ucieczki we wszystkich przebiegach. Grok — jedna (znowu git branch -a; znacznik się zapalił, klon był pusty). I rzecz, która retroaktywnie domyka diagnozę pierwszej serii: grok nadal w czterech przebiegach na cztery zaczyna pracę od git log -5 --oneline && git branch. Rekonesans po cudzych rozwiązaniach to jego odruch, nie reakcja na tamto środowisko. Tym razem po prostu nie było łupu — i cztery diffy B, które w pierwszej serii były kopiami jednego pliku, teraz są czterema różnymi rozwiązaniami.

Co wróciło, klasa po klasie:

Klasa run 1 run 2 run 3 run 4
1. Granica zgadnięta (próg otwarty) 2/2 2/2 2/2 2/2
2. Pusty promo utrwalony testem oba: "" zachowuje rabat nietestowane B: "" zachowuje rabat B: truthy kod kasuje rabat
3. Walidacja wejścia brak częściowa (B: is not None zamiast truthy, kod nadal niesprawdzany) brak A: rejestr kodów; B: ślepe zaufanie
4. Pieniądze na floatach 2/2 2/2 2/2 2/2
5. Zwraca rabat, nie kwotę 2/2 2/2 2/2 2/2

Klasy 1, 4 i 5 wróciły w ośmiu kandydaturach na osiem — zgodnie z przewidywaniem. To nie przypadek, to nawyki modeli. Najlepszy dowód dostałem przy okazji: opus w czwartym przebiegu ponownie wymyślił rejestr kodów promocyjnych z kodem "WELCOME5" — dokładnie tym samym, który wymyślił w skażonej serii, choć teraz nie miał go skąd podejrzeć. Zbieżność między przebiegami potrafi być nawykiem modelu udającym potwierdzenie. Warto o tym pamiętać za każdym razem, gdy „dwa niezależne modele doszły do tego samego”.

Klasa 2 rozjechała się na sprzeczne prawdy — mocniej, niż przewidywałem. W przebiegach 1 i 3 pusty string zachowuje rabat progowy i test to utrwala:

# przebieg 1, worker A
def test_empty_promo_code_keeps_threshold_discount():
    assert calculate_discount(800, promo_code="") == pytest.approx(80)

W przebiegu 4 worker B zdecydował odwrotnie: dowolny truthy kod — nawet "EMPTY" z zerową stawką — kasuje rabat całkowicie. Też z testem:

# przebieg 4, worker B
def test_promo_code_with_zero_percent_blocks_volume_discount():
    # Using a promo code opts out of volume discounts even if the promo is 0%.
    assert calculate_discount(1500, promo_code="EMPTY") == 0.0

Ta sama dziura w specu, dwie przeciwne „prawdy”, obie z zielonymi testami. Pojedynczy przebieg nie tylko nie dowodzi, że jest dobrze — nie dowodzi nawet, którą zgadywankę dostałeś.

„Do not stack” ma trzy znaczenia

W ośmiu kandydaturach to samo zdanie specu zamieniło się w trzy różne reguły:

if promo_code:                              # tłumi rabat progowy — 5 implementacji
    return 0.0

applied = max(volume_percent, promo_rate)   # bierze większą ze stawek — 1

rate = promo_percent / 100.0                # zastępuje własną stawką promo — 2

I tu jest mechanizm, dla którego piszę ten wpis: zamrożona checklista z dwójki — ta, która miała powstrzymać sędziego przed dopisywaniem wymagań — odhaczyła punkt „Discounts do not stack with promo codes” wszystkim ośmiu. Także kandydatowi, u którego zamówienie z kodem promocyjnym dostaje pełny rabat progowy, co pod pierwszą interpretacją jest wprost złamaniem wymagania. Checklista zadziałała dokładnie tak, jak ją zaprojektowałem: nie pozwoliła niczego dopisać. Przez co wiernie zamroziła niejednoznaczność. Odhaczanie chroni przed wymyślaniem — nie wstrzykuje brakujących decyzji.

Dla porządku: zrekonstruowałem wszystkie osiem kandydatur z diffów i uruchomiłem ich testy poza pipeline’em. Osiem zestawów, od 6 do 9 testów każdy, komplet zielony, zero czerwieni. Wszystko przeszło. To jest właśnie problem.

Dwa wyniki, których nie przewidziałem

Sędzia ustabilizował się na prawdziwej wariancji. W skażonej serii dwa przejścia sędziego (A/B i B/A) zgodziły się ze sobą w 2 przypadkach na 4. W czystej serii — w 4 na 4. Przewidywałem dokładnie odwrotnie: większa różnorodność kandydatów miała dać więcej werdyktów nierozstrzygniętych. Interpretacja, do której doszedłem po fakcie: przy niemal identycznych kandydatach sędzia rankował szum — stylistyczne drobiazgi, które w dwóch przejściach ważył różnie. Przy realnych różnicach miał na czym dyskryminować. Czyli kontaminacja nie tylko sfałszowała zbieżność workerów; zdestabilizowała też sędziego. Zostawiam tu swoją błędną predykcję na widoku, bo jest dowodem, że przewidywania spisałem przed przebiegami, a nie dopasowałem po nich.

Pytania padały. System je wyrzucał. Opus w ośmiu przebiegach na osiem — obu serii — kończył pracę wiadomością, w której jawnie nazywał swoje założenia:

The spec says discounts “do not stack” but doesn’t say which wins. […] If you want the customer-favourable rule instead, it’s a one-line change.

That’s a product decision I didn’t want to invent.

The return is the discount amount, not the final price — the task says “calculates a discount”.

Osiem razy dostałem z pipeline’u dokładnie te pytania, o których braku pisałem w pilotażu. I osiem razy umarły w polu result przebiegu, bo finalna wiadomość workera nie jest wejściem żadnego etapu: kontrola gotowości widzi diff i checklistę, porównanie parami widzi dwa diffy, ja w panelu widzę werdykt. Muszę więc skorygować własną tezę. To nie jest problem „nikt nie zapytał”. To problem „pytanie nie ma dokąd pójść”. Mój szablon zadań ma pole DECYZJE OTWARTE na wejściu — a pipeline nie ma go na wyjściu.

Ironia kosztowa gratis: droższy worker (opus, $0.40–0.52 za przebieg) produkował pytania, których nikt nie czytał. Tańszy (grok, $0.02–0.05 za przebieg) wygrywał większość werdyktów — w pierwszej serii częściowo dlatego, że ściągał.

Trzecia warstwa: pomiar zmierzył mnie

Dziennik z dwójki zapisuje też moje decyzje. Przeczytałem je jako dane i nie wypadłem w nich lepiej niż reszta systemu.

Karałem inwencję, którą widziałem. Przepuszczałem tę, której nie widać. Jedyne odrzucenie w obu seriach dałem tam, gdzie decyzje workerów zmaterializowały się w powierzchni API: wymyślony rejestr kodów u jednego, podwójny parametr promo_code/promo_percent u drugiego. Słusznie — ale niewidoczne zgadywanki, próg, floaty, kontrakt zwrotu, przeszły przez moje ręce osiem razy bez jednego komentarza. Człowiek w pętli odruchowo recenzuje to, co widać w diffie. Lista niepodjętych decyzji w diffie nie występuje.

Panel wymusił na mnie preferencję, której nie miałem. Przy remisie sędziego nie mam akcji „zaakceptuj remis” — któryś kod musi przejść dalej. Wybrałem więc gałąź A i w polu powodu napisałem, zgodnie z prawdą, „without a reason”. Problem w tym, że według mojej własnej architektury z dwójki każde nadpisanie to oznaczony przykład do kalibracji sędziego. Rzut monetą zapisany jako preferencja to szum wstrzyknięty prosto w zbiór kalibracyjny. Poprawka jest tania — akcja „zaakceptuj remis”, która przepuszcza gałąź mechanicznie, ale loguje preference: none — i wchodzi przed kolejną serią.

Decyzja to nie to samo, co zapis decyzji. To odrzucenie było dobrą decyzją — sędzia zresztą pierwszy nazwał problem, i to precyzyjniej niż ja:

candidate_2’s API has a correctness trap: promo_code carries no rate, so calling calculate_discount(1500, promo_code=’SAVE10’) without also passing promo_percent silently returns 0.0 discount (the code even tests this behavior as intended).

Ale mój wpis w dzienniku był skrótem myślowym pisanym w biegu. Do podjęcia decyzji intuicja wystarczy w zupełności — decyzje są od tego, żeby kierować ruchem, i ta pokierowała. Tyle że dziennik ma drugą funkcję: nadpisania i odrzucenia to materiał, z którego sędzia ma się uczyć. Przykład „both bad” bez precyzyjnego powodu niczego go nie nauczy. Reguła, którą sobie z tego wyprowadzam: uzasadniam wtedy, gdy idę wbrew sędziemu, bo tylko z takich przykładów kalibracja się uczy; zgody zostawiam puste.

I na koniec najsubtelniejsze znalezisko serii, ukryte w tym rejeccie. calculate_discount(1500, promo_code="SAVE10") == 0.0 — zachowanie, za które odrzuciłem przebieg czwarty — jest identyczne z zachowaniem, które zatwierdziłem w przebiegach 1 i 3. Tam kod promocyjny też zeruje wynik. Różnica siedzi wyłącznie w obietnicy:

# przebieg 1, worker A
def calculate_discount(amount, promo_code=None):
    """...when ``promo_code`` is given, the order is discounted through
    that code alone and this function grants nothing on top of it."""

# przebieg 4, worker B
def calculate_discount(amount, promo_code=None, promo_percent=0):
    """...If a promo code is used (a truthy ``promo_code`` or a non-zero
    ``promo_percent``), only the promo discount is applied."""

Pod pierwszą obietnicą zero jest poprawne: wycena promo dzieje się gdzie indziej, ta funkcja nie dolicza nic ponad kod. Pod drugą to samo zero staje się cichym okradaniem klienta — funkcja obiecuje rabat promocyjny, przyjmuje jego stawkę osobnym parametrem i domyślnie ustawia ją na zero. Ta sama linijka, ten sam wynik, przeciwny werdykt, a rozstrzyga kontrakt istniejący tylko w dokumentacji. Dopisuję to do taksonomii jako klasę szóstą — jawnie po fakcie, poza prerejestracją: rozjazd między zachowaniem a zadeklarowanym kontraktem. Zgadywanka, której nie widać nawet wtedy, gdy patrzysz na kod, bo mieszka w różnicy między kodem a zdaniem nad nim.

Zastrzeżenie: osiem przebiegów jednego celowo niedookreślonego specu, jedna para modeli, jeden dzień — dość, żeby pokazać wzorce, za mało, żeby je skwantyfikować. Surowe artefakty obu serii publikuję razem z wpisem; taksonomię i przewidywania spisałem przed drugą serią, a błędną predykcję zostawiłem powyżej na widoku.

Puenta

Osiem przebiegów, około 10 dolarów, komplet zielonych testów, sędzia spójny w drugiej serii w stu procentach. A liczba nierozstrzygniętych decyzji biznesowych nie spadła ani o jedną. One tylko zmieniały przebrania: docstring, nazwa testu, wartość oczekiwana — a po naprawie izolacji doszły dwa nowe: skopiowany precedens i kontrakt w dokumentacji.

Ta lista to jest część ludzka. Nie „przejrzyj kod” w ogólności — konkretnie: pytania, na które ani sędzia, ani checklista, ani zielony pytest nie mogą odpowiedzieć, bo odpowiedź nie istnieje w repo ani w diffie. Istnieje w głowie zamawiającego. Żadne okno kontekstowe tego nie naprawia, bo to nie jest problem kontekstu. To jest problem uprawnień.

Dlatego dalej nie sądzę, żeby udział człowieka malał wraz z lepszymi modelami. On się zagęszcza. W tych ośmiu taskach moja praca to były pojedyncze minuty — ale, jak pokazał dziennik, były to minuty patrzenia w złe miejsce: na werdykt, a nie na listę rozstrzygnięć, których nikt nie wziął na siebie.

Co dalej

Trzy poprawki, każda wynikająca wprost z danych, każda jako osobna, nazwana interwencja — bo jeśli zmienię wszystko naraz, nie będę wiedział, co zadziałało:

  1. Kanał open_decisions: obowiązkowa sekcja w kontrakcie obu workerów (nazwane decyzje podjęte w kodzie — nie liczba pytań, bo Goodhart nie śpi), agregowana przez sędziego i widoczna przy zatwierdzaniu. Werdykt przestanie brzmieć „winner: B”, a zacznie „winner: B, decyzje podjęte przez nikogo: 4”.
  2. „Zaakceptuj remis” w panelu, z logowaniem preference: none.
  3. Ten sam eksperyment z porządnym wejściem: spec napisany według mojego własnego szablonu zadań — z kryteriami akceptacji domykającymi wszystkie sześć klas i polem „zdecyduj sam” tam, gdzie decyzję świadomie deleguję. Sprawdzimy, które klasy domyka dobre wejście, a co i tak wymaga oka na wyjściu — i czy szablon pisany dla ludzi rzeczywiście jest, jak twierdziłem, tym samym szablonem dla maszyn.

A potem wracają liczby obiecane w dwójce: dwadzieścia prawdziwych tasków, zgodność sędziego z człowiekiem, koszty — tyle że licznik nadpisań dostanie drugą kolumnę, której brak wyszedł w tym wpisie: decyzje niepodjęte. Bo pierwszy task, któremu przyjrzałem się naprawdę uważnie, pokazał, że mogę zgadzać się z sędzią w każdym werdykcie i mimo to oddać na produkcję kod pełen decyzji, których nie podjął nikt.

Ten nawyk — pytanie, co wynik faktycznie dowodzi, a nie co pozornie pokazuje — to dalej ta sama umiejętność, o której jest cała ta seria. W tym wpisie po raz pierwszy wycelowana w każdą warstwę systemu po kolei.

$ prev
Sędzia, który podejmuje decyzje

Tam zbudowaliśmy sędziego. Tu dajemy mu władzę — i patrzymy, co może pójść nie tak.

← czytaj
$ next
Napisałem zadanie porządnie. Sędzia cztery razy ogłosił remis

Te same agenty, ten sam sędzia, zmienia się tylko opis zadania. Osiem rozwiązań, wszystkie poprawne, sędzia nie ma czego rozstrzygać. A potem znajduję zamówienie, na którym różnią się o sto złotych.

czytaj →
↑