call me maybe

call me maybe — Savunma Çalışma Notları

Qwen3-0.6B ile doğal dildeki bir isteği tipli bir fonksiyon çağrısına çeviren projenin savunma rehberi. Notların tamamı, etkileşimli örneklerle.

Sağdaki çubuklar tek bir adımın logitleri: geçersiz tokenler -inf'e iner, kalan en yüksek geçerli token (mavi) seçilir.

Bu dosya repoda değil, bilerek dışarıda duruyor. Commit etme. Kodun 23 Ekim 2026 tarihli haline (son commit dfa70e3) göre yazıldı. Kodda bir şey değiştirirsen ilgili bölümü de güncelle.

İçindekiler

  1. Bu dosyayı nasıl çalışmalısın
  2. Proje ne yapıyor — büyük resim
  3. Bilmen gereken temel kavramlar
  4. Proje yapısı ve dosyaların görevleri
  5. Programın baştan sona akışı
  6. Fonksiyonlar — çalışma sırasıyla, tek tek
  7. Uçtan uca örnek: "What is the sum of 2 and 3?"

0. Bu dosyayı nasıl çalışmalısın

  1. Önce 1. ve 2. bölümü oku. Kavramları oturtmadan fonksiyonlara geçme; savunmada ilk sorulan şey "constrained decoding nedir?" olur.
  2. 4. bölümdeki akış şemasını kâğıda kendin çiz. Ezberleme, çiz.
  3. 5. bölümü editörde kod açıkken oku. Her fonksiyonda dosyaya bak, satırları takip et.
  4. 6. bölümdeki örneği kendi kendine sesli anlat. Değerlendiriciye anlatacağın şey tam olarak bu.
  5. 10. bölümdeki soruları cevaplara bakmadan cevaplamaya çalış, sonra kontrol et.
  6. 11. bölümdeki tariflerden en az 3 tanesini gerçekten uygula, çalıştır, sonra git checkout . ile geri al. Canlı değişiklik istenirse eller alışkın olsun.

1. Proje ne yapıyor — büyük resim

1.1 Tek cümlede

Doğal dilde yazılmış bir isteği ("What is the sum of 2 and 3?") alıp, onu çalıştırılabilir bir fonksiyon çağrısına çeviriyor:

JSON
{
  "prompt": "What is the sum of 2 and 3?",
  "name": "fn_add_numbers",
  "parameters": {"a": 2.0, "b": 3.0}
}

Program soruyu cevaplamıyor (5 demiyor). Soruyu cevaplamak için hangi fonksiyonun hangi argümanlarla çağrılması gerektiğini söylüyor.

İstekten çağrıya

prompt

What is the sum of 2 and 3?

{ "prompt": "What is the sum of 2 and 3?", "name": "fn_add_numbers", "parameters": {"a": 2.0, "b": 3.0}}
İskelet programdan gelir; model yalnızca vurgulu iki değeri üretir: fonksiyon adı ve argümanlar.

1.2 Buna neden "function calling" deniyor, neden önemli

LLM'ler (büyük dil modelleri) insan dilini iyi anlar ama çıktıları serbest metindir. Bir programın bu metni kullanabilmesi için çıktının yapılandırılmış (JSON gibi) ve şemaya uygun (doğru anahtarlar, doğru tipler) olması gerekir.

Function calling bu köprüyü kuruyor:

  • Asistanlar API çağırabiliyor, veritabanı sorgulayabiliyor ("yarın İstanbul'da hava?" → get_weather(city="Istanbul", day="tomorrow")).
  • Hesaplamalar modelin kafasından değil, gerçek koddan geliyor.
  • Uzun bir metinden yapılandırılmış veri çıkarılabiliyor.

1.3 Asıl problem: küçük model güvenilmez

Kullandığımız model Qwen3-0.6B, yani yaklaşık 0.6 milyar parametreli, çok küçük bir model. Subject'e göre böyle bir modele "bana JSON ver" diye sorunca belki %30 oranında geçerli JSON üretir. Kalan durumlarda:

  • parantez kapatmayı unutur,
  • sayı yerine "iki" yazar,
  • olmayan bir fonksiyon adı uydurur,
  • JSON'un etrafına "Sure! Here is your answer:" gibi metin ekler.

On deneme, iki yöntem

    3 / 10 geçerli JSON

    Temsili örnek. Serbest üretimdeki ~%30 oran subject'ten; hatalar 1.3'teki listeden. Kısıtlı çözümleme geçerliliği %100 yapar, doğruluğu yine model belirler.

    1.4 Çözüm: constrained decoding (kısıtlı çözümleme)

    Modele rica etmek yerine, modelin seçebileceği tokenleri kısıtlıyoruz. Model her adımda bütün kelime dağarcığı için bir puan üretir; biz geçersiz olan tokenlerin puanını eksi sonsuz yapıyoruz. Model istese bile geçersiz bir token seçemiyor.

    Sonuç:

    • Fonksiyon adı her zaman tanımlı fonksiyonlardan biri oluyor.
    • number tipindeki argüman her zaman float() ile parse edilebilen bir sayı oluyor.
    • string her zaman düzgün kapanmış bir metin oluyor.
    • boolean her zaman true veya false oluyor.

    Kritik ayrım (savunmada mutlaka söyle):

    Kavram Garanti mi? Neden
    Geçerlilik (JSON doğru, tipler doğru) %100 garanti Geçersiz token fiziksel olarak seçilemiyor
    Doğruluk (doğru fonksiyon, doğru değer) Garanti değil Geçerli seçenekler arasından seçimi hâlâ model yapıyor

    Constrained decoding modeli akıllandırmaz, sadece kurallara uymaya zorlar.

    1.5 Subject'in hedefleri

    • %90+ doğruluk: doğru fonksiyon ve doğru argümanlar.
    • %100 geçerli JSON.
    • 5 dakikanın altında bütün test promptlarını işlemek.
    • Hiç çökmemek: bozuk dosya, eksik dosya gibi durumlarda net hata mesajı vermek.

    1.6 Yasaklar (mutlaka bil)

    • dspy, outlines, transformers, pytorch, huggingface gibi paketleri kendi kodunda kullanmak yasak. (llm_sdk içeride kullanıyor, o serbest.)
    • llm_sdk paketinin private (alt çizgiyle başlayan, örneğin _model, _tokenizer) üyelerine erişmek yasak.
    • Fonksiyonu heuristic ile seçmek yasak ("promptta 'sum' geçiyorsa fn_add_numbers seç" gibi). Seçimi LLM yapmalı.
    • İzinli paketler: numpy, json, pydantic (sınıflar pydantic ile yazılmak zorunda).

    2. Bilmen gereken temel kavramlar

    2.1 Token ve tokenizer

    Model metni karakter karakter ya da kelime kelime görmez; token denen parçalara bölünmüş halini görür. Bir token bir harf, bir kelime parçası ya da tam bir kelime olabilir.

    Metin
    "What is the sum of 2 and 3?"
    → ["What", "Ġis", "Ġthe", "Ġsum", "Ġof", "Ġ", "2", "Ġand", "Ġ", "3", "?"]   (temsili)

    Bu bölme işini tokenizer yapar. Qwen, BPE (Byte Pair Encoding) kullanır: sık geçen karakter çiftlerini tekrar tekrar birleştirerek bir kelime dağarcığı oluşturur.

    Qwen'e özgü faydalı bilgi: Qwen tokenizer'ı rakamları tek tek böler. 265 sayısı tek bir token değil, "2", "6", "5" olarak üç token. Bu yüzden sayı üretimi rakam rakam ilerliyor.

    Kendin böl

    Temsili bölme: kelimeler başındaki boşlukla birlikte tek token, rakamlar Qwen'deki gibi tek tek. Türkçe harfler bayt düzeyinde iki karaktere dönüşür.

    2.2 Vocab (kelime dağarcığı) ve vocab.json

    Modelin bildiği bütün tokenlerin listesi. Her tokenin bir id'si (tam sayı) var. vocab.json bunu bir sözlük olarak tutuyor:

    JSON
    {"!": 0, "\"": 1, "#": 2, ..., "Ġthe": 279, ..., "fn": 8822, ...}

    (id'ler temsili.) Qwen'de yaklaşık 151 bin token var.

    Subject'in ipucu: "Think about how you can use the vocabulary JSON file to map between tokens and their string representations." Tam olarak bunu yapıyoruz: hangi tokenin geçerli olduğuna karar vermek için her tokenin metnini bilmemiz gerekiyor.

    2.3 Ġ ve Ċ nedir? (Byte-level BPE)

    BPE tokenizer'ı her şeyi bayt düzeyinde çalışır. Ama vocab dosyası okunabilir metin olmalı ve içinde boşluk, satır sonu gibi görünmez karakterler sorun çıkarır. Çözüm olarak her bayt, görünür bir Unicode karakterine eşlenir:

    Bayt Anlamı Vocab'ta görünüşü
    0x20 (32) boşluk Ġ (U+0120)
    0x0A (10) satır sonu \n Ċ (U+010A)
    0x41 (65) A A (kendisi)

    Yani:

    • "Ġhello" aslında " hello" demek (başında boşluk).
    • "Ċ" aslında "\n" demek.

    Projede Ċ tokenini sayının bittiğini bildirmek için kullanıyoruz (NUMBER_STOP_TOKEN). decode_bpe_string fonksiyonu da bu eşlemeyi tersine çevirip gerçek metni elde ediyor.

    Bayttan görünür karaktere

    Vocab'taki hali:

    Her karakterin UTF-8 baytları ve vocab'taki karşılıkları. Mavi olanlar 256'dan başlayan kod noktalarına taşınan yazdırılamaz baytlar.

    2.4 Token id ve input_ids

    Model metni değil, id listesini alır:

    Metin
    "What is the sum" → [3838, 374, 279, 2629]   (temsili)

    Bu listeye input_ids diyoruz. model.encode(text) metni bu listeye çeviriyor.

    2.5 Logit

    Model bir input_ids listesi alınca, bir sonraki token için vocab'taki her token için bir puan döndürür. Bu puanlara logit denir.

    Metin
    model.get_logits_from_input_ids([3838, 374, 279, 2629])
    → [-3.2, 0.1, 5.7, ..., 12.4, ...]   # ~151 bin sayı
    • Logit ne kadar yüksekse model o tokeni o kadar "istiyor".
    • Logitler olasılık değil, ham puan. Olasılığa çevirmek için softmax uygulanır: p_i = e^(logit_i) / Σ e^(logit_j).
    • Biz softmax'a gerek duymuyoruz, çünkü sadece en yüksek puanı arıyoruz (argmax). Softmax sıralamayı değiştirmez.

    2.6 Otoregresif üretim (token token)

    Model metni bir seferde üretmez:

    Metin
    1. input_ids → model → logitler → en iyi token seçilir
    2. seçilen token input_ids'e eklenir
    3. 1. adıma dön

    Her adımda bir forward pass (modelin bir kez çalıştırılması) yapılıyor. Programın hızını belirleyen şey bu forward pass sayısı.

    Döngü, adım adım

    input_ids
    model

    forward pass: 0
    Her adım bir forward pass: logitler gelir, en iyi token seçilir, input_ids'e eklenir ve döngü baştan başlar.

    2.7 Greedy seçim

    Her adımda en yüksek logitli tokeni seçmeye greedy decoding denir. Projede bunu kullanıyoruz (rastgele örnekleme yok). Avantajı: deterministik, yani aynı girdi her zaman aynı çıktıyı verir.

    2.8 Maskeleme ve -inf

    Constrained decoding'in kalbi:

    Metin
    logitler:   [ 5.1,  9.8,  2.3,  7.7 ]
    geçerli mi: [  ✗,    ✗,    ✓,    ✓  ]
    maskeli:    [-inf, -inf,  2.3,  7.7 ]   → argmax = 3. index

    Model 1. indexi (9.8) en çok istiyordu ama geçersizdi. Maskeden sonra en iyi geçerli token seçiliyor.

    Neden -inf, neden 0 değil? Logitler negatif olabilir. 0 yapsaydık, bütün geçerli tokenlerin logiti negatifken geçersiz token (0) kazanırdı. -inf her sayıdan küçük, ayrıca softmax'ta e^(-inf) = 0, yani olasılığı tam sıfır.

    Maskeyi sen uygula

    Geçerli/geçersiz işaretine dokunarak değiştirebilirsin. Logitler negatifken 0 ile maskelemek geçersiz tokene kazandırır.

    2.9 Gramer, "fragment" ve "prefix" düşüncesi

    Bir tokenin geçerli olup olmadığına karar verirken tek karakter değil, şu ana kadar üretilen metin + aday token birlikte kontrol ediliyor:

    Metin
    şu ana kadar: "12"
    aday token:   "."    → "12."  → hâlâ sayı olabilir mi? EVET → geçerli
    aday token:   "a"    → "12a"  → sayı olabilir mi? HAYIR → geçersiz

    İki ayrı soru var. Bunları karıştırma:

    Soru Fonksiyon örneği "12." için
    Devam edebilir mi? (validity) is_valid_number_fragment evet
    Burada bitebilir mi? (completeness) is_complete_number hayır

    Kural: gramerin kabul ettiği her parça, bir şekilde tamamlanabilir olmalı. Aksi halde döngü takılır. (Projede .5 hatası tam olarak buydu; bkz. 9. bölüm ve README "Challenges".)


    3. Proje yapısı ve dosyaların görevleri

    Metin
    callme/
    ├── Makefile                 # install, run, debug, clean, lint, lint-strict
    ├── pyproject.toml           # bağımlılıklar + mypy ayarları
    ├── uv.lock                  # bağımlılıkların kilitli sürümleri
    ├── .flake8                  # flake8: llm_sdk ve .venv hariç
    ├── .gitignore               # data/output/, .venv, cache'ler
    ├── README.md                # zorunlu dokümantasyon (İngilizce)
    ├── data/input/
    │   ├── functions_definition.json    # çağrılabilecek fonksiyonlar
    │   └── function_calling_tests.json  # işlenecek promptlar
    ├── llm_sdk/                 # HAZIR VERİLEN paket (dokunmuyoruz)
    │   └── llm_sdk/__init__.py  # Small_LLM_Model sınıfı
    └── src/
        ├── __init__.py          # boş; src'yi paket yapıyor
        ├── __main__.py          # giriş noktası, dosya okuma/yazma, akış
        ├── models.py            # pydantic modelleri
        ├── vocab.py             # vocab.json'u okuma
        ├── grammar.py           # SAF kurallar: hangi token geçerli?
        └── decoding.py          # modelle konuşan üretim döngüleri

    3.1 Katmanlar — neden böyle bölündü

    Metin
    __main__.py   → "Ne yapılacak?"     (dosyalar, akış, hatalar)
    decoding.py   → "Nasıl üretilecek?" (model + döngüler)
    grammar.py    → "Ne geçerli?"       (saf kurallar, model YOK)
    models.py     → "Veri neye benzer?" (pydantic şemaları)
    vocab.py      → "Tokenler neler?"   (vocab yükleme)

    En önemli tasarım kararı: grammar.py modeli hiç kullanmıyor. Sadece metin ve vocab alıp token id'leri döndürüyor. Bu sayede:

    • Kurallar tek başına okunabiliyor.
    • 0.6B'lik modeli yüklemeden test edilebiliyor (sahte modelli test böyle yapıldı).

    3.2 Yapılandırma dosyaları

    pyproject.toml

    • requires-python = ">=3.10" → subject 3.10+ istiyor.
    • dependencies: numpy, pydantic, llm-sdk.
    • [tool.uv.sources] llm-sdk = { path = "llm_sdk", editable = true } → llm_sdk PyPI'den değil, yerel klasörden kuruluyor. editable ise kopyalanmadan, klasördeki koddan doğrudan kullanılıyor demek.
    • [dependency-groups] dev: flake8, mypy → uv sync bunları da kurar.
    • [tool.mypy] ve overrides: .venv hariç, llm_sdk hataları yok sayılıyor (bizim kodumuz değil). src.vocab için warn_return_any = false, çünkü json.load tipi Any döndürüyor.

    uv.lock: Bütün bağımlılıkların (dolaylı olanlar dahil) tam sürümleri. Değerlendirici uv sync çalıştırınca birebir aynı sürümler kurulur.

    .flake8: extend-exclude ile .venv, llm_sdk, .mypy_cache hariç tutuluyor. exclude değil extend-exclude, çünkü exclude flake8'in kendi varsayılan listesini (.git, __pycache__) siler.

    Makefile

    Kural Komut Ne yapar
    install uv sync .venv oluşturur, bağımlılıkları uv.lock'a göre kurar
    run uv run python -m src Programı varsayılan yollarla çalıştırır
    debug uv run python -m pdb -m src pdb debugger altında çalıştırır
    clean find ... -exec rm -rf __pycache__, .mypy_cache, .pytest_cache, *.egg-info siler
    lint flake8 . + mypy (subject'teki flag'ler) Stil + tip kontrolü
    lint-strict flake8 . + mypy --strict Daha sıkı tip kontrolü (opsiyonel)

    .PHONY satırı: bu isimlerin dosya değil komut olduğunu söyler. Aynı isimde bir dosya olsa bile kural çalışır.

    python -m src neden çalışıyor? -m src, Python'a src paketini modül olarak çalıştırmasını söyler. Python da paket içindeki __main__.py dosyasını çalıştırır. Bu yüzden __main__.py içinde göreli importlar (from .vocab import ...) kullanabiliyoruz.

    3.3 llm_sdk — ne kullanıyoruz, ne kullanmıyoruz

    Small_LLM_Model sınıfının public metodları:

    Metod Kullanıyor muyuz? Nerede
    Small_LLM_Model() main — modeli bir kez yükler (varsayılan Qwen/Qwen3-0.6B)
    get_logits_from_input_ids(ids) _select_next_token — her adımda
    get_path_to_vocab_file() load_vocab
    encode(text) generate_function_call — promptu id'ye çevirmek için
    decode(ids) Kendi decode_bpe_string fonksiyonumuzu kullanıyoruz
    get_path_to_merges_file(), get_path_to_tokenizer_file() —

    Private üyelere (_model, _tokenizer, _device …) hiç dokunmuyoruz .


    4. Programın baştan sona akışı

    Metin
    uv run python -m src [--functions_definition F] [--input I] [--output O]
    │
    ▼
    main()
    ├─ parse_args()                          → F, I, O yolları
    ├─ load_functions(F)
    │    └─ load_json_file(F) → pydantic FunctionDefinition listesi
    ├─ load_prompts(I)
    │    └─ load_json_file(I) → pydantic PromptTest listesi
    ├─ Small_LLM_Model()                     → model BİR KEZ yüklenir
    ├─ load_vocab(model)                     → {"token": id}
    ├─ build_id_to_token(vocab)              → {id: "token"}
    ├─ process_prompts(...)                  → her prompt için:
    │    └─ generate_function_call(...)
    │         │
    │         ├─ AŞAMA 1: FONKSİYON ADI
    │         │   prompt = katalog + istek + "function name: "
    │         │   generate_constrained_string(adaylar = ['"fn_add_numbers"', ...])
    │         │     döngü:
    │         │       get_valid_tokens_for_candidates → geçerli id'ler
    │         │       _select_next_token              → maskele + argmax
    │         │       filter_candidates               → imkânsız adayları at
    │         │
    │         ├─ AŞAMA 2: HER PARAMETRE İÇİN (sırayla)
    │         │   prompt = fonksiyon + açıklama + istek + önceki değerler
    │         │            + "first argument (a) value: "
    │         │   tip == number  → generate_number              → float()
    │         │   tip == integer → generate_number(no decimal)  → int()
    │         │   tip == string  → generate_string              → str
    │         │   tip == boolean → generate_constrained_string(["true","false"])
    │         │   başka tip      → ValueError (sadece bu prompt başarısız)
    │         │
    │         └─ FunctionCallResult(prompt, name, parameters)
    │
    └─ write_results(O, results)             → JSON dosyası

    Hatalar: Başlangıçtaki her hata RuntimeError olur → main yakalar → Error: ... + exit 1. Tek bir prompttaki hata ise process_prompts tarafından yakalanır → o prompt atlanır, diğerleri devam eder.


    5. Fonksiyonlar — çalışma sırasıyla, tek tek

    Her fonksiyon için: Ne yapar → Neden var → Nasıl çalışır → Dikkat edilecekler.


    5.0 Pydantic modelleri — src/models.py

    Fonksiyonlardan önce veri şekillerini bilmek gerekiyor, çünkü her yerde bunlar dolaşıyor.

    Python
    class ParameterSpec(BaseModel):
        type: str
    
    class FunctionDefinition(BaseModel):
        name: str
        description: str
        parameters: dict[str, ParameterSpec]
        returns: ParameterSpec
    
    class PromptTest(BaseModel):
        prompt: str
    
    class FunctionCallResult(BaseModel):
        prompt: str
        name: str
        parameters: dict[str, Any]

    Ne yapar: Girdi dosyalarının ve çıktının şeklini tanımlıyor.

    Neden pydantic:

    • Subject zorunlu tutuyor ("All classes must use pydantic for validation").
    • FunctionDefinition.model_validate(item) çağrısı item sözlüğünü kontrol ediyor: name var mı, string mi? parameters bir sözlük mü, her değerinde type var mı? Bir şey eksik ya da yanlış tipteyse ValidationError fırlatıyor.
    • Doğrulama sınırda (dosya okunduğu anda) yapıldığı için programın geri kalanı güvenle fn.name, param_spec.type gibi alanlara erişebiliyor.

    ParameterSpec.type neden str, neden Literal["number", ...] değil: Eskiden Literal idi. Değerlendirici dosyaya "type": "integer" ya da "array" eklerse bütün dosya reddediliyor, hiçbir prompt işlenmiyordu. Şimdi her tip adı yüklemede kabul ediliyor; desteklenmeyen bir tip sadece o fonksiyonu seçen promptta hata veriyor. Pydantic doğrulaması hâlâ var: type alanı eksikse ya da string değilse yine hata veriyor.

    returns neden var ama kullanılmıyor: Subject'teki dosya formatında var, şemaya uygun olsun diye doğrulanıyor. Üretimde kullanılmıyor, çünkü biz fonksiyonu çağırmıyoruz, sadece çağrıyı tarif ediyoruz.

    FunctionCallResult.parameters neden dict[str, Any]: Değerler farklı tiplerde (float, int, str, bool) olabiliyor.

    Pydantic'in varsayılan davranışı: Girdi sözlüğünde fazladan anahtarlar varsa (örneğin fonksiyon tanımında "category": "math") sessizce yok sayılıyor (extra='ignore'). Hata vermiyor.

    Çıktı anahtarları: FunctionCallResult tam olarak prompt, name, parameters alanlarına sahip. model_dump() bunları bu sırayla sözlüğe çeviriyor. Subject'in "exactly the following keys" şartı buradan karşılanıyor.


    5.1 Sabitler — src/__main__.py başı

    Python
    DEFAULT_FUNCTIONS_PATH = "data/input/functions_definition.json"
    DEFAULT_INPUT_PATH = "data/input/function_calling_tests.json"
    DEFAULT_OUTPUT_PATH = "data/output/function_calls.json"

    Subject'teki varsayılan yollar. Çıktı dosyasının adı function_calls.json olmak zorunda (V.4). data/output/ klasörü .gitignore içinde, yani repoya girmiyor.


    5.2 main() — src/__main__.py

    Ne yapar: Programın tamamını yönetir; sırayla bütün adımları çağırır.

    Nasıl çalışır:

    Python
    try:
        args = parse_args()
        functions = load_functions(args.functions_definition)
        prompts = load_prompts(args.input)
        try:
            model = Small_LLM_Model()
        except Exception as e:
            raise RuntimeError(f"Failed to load the model: {e}") from e
        vocab = load_vocab(model)
        id_to_token = build_id_to_token(vocab)
        results = process_prompts(model, prompts, functions, vocab, id_to_token)
        write_results(args.output, results)
    except RuntimeError as e:
        print(f"Error: {e}", file=sys.stderr); sys.exit(1)
    except KeyboardInterrupt:
        print("\nInterrupted by user.", file=sys.stderr); sys.exit(130)
    except Exception as e:
        print(f"Unexpected error: {e}", file=sys.stderr); sys.exit(1)

    Neden bu sıra:

    1. Önce dosyalar, sonra model. Model yüklemek pahalı (saniyeler, ilk seferde indirme). Dosya bozuksa modeli hiç yüklemeden hızlıca hata vermek daha iyi.
    2. Model bir kez yükleniyor ve bütün promptlara aynı nesne geçiriliyor. Bellek prompt sayısıyla artmıyor.
    3. id_to_token bir kez hesaplanıyor.

    Hata stratejisi:

    • Her yardımcı fonksiyon beklediği hataları RuntimeError'a çevirip okunabilir bir mesaj ekliyor. main'in tek bir except RuntimeError ile hepsini yakalayabilmesi bu sayede.
    • Small_LLM_Model() ne fırlatacağı bilinmeyen bir şey (internet hatası, disk, torch …). Bu yüzden içte except Exception var ve hata RuntimeError'a çevriliyor.
    • except sırası önemli: Python yukarıdan aşağı dener. RuntimeError da bir Exception olduğu için Exception en alta konmalı. Yoksa düzgün mesajların yerine "Unexpected error" basılır.
    • KeyboardInterrupt (Ctrl+C), Exception'ın alt sınıfı değil, BaseException'dan geliyor. Bu yüzden ayrı yakalanıyor. 130, Unix'te Ctrl+C ile kesilen programların standart çıkış kodu (128 + sinyal numarası 2).
    • from e: Orijinal hatayı zincire ekliyor (__cause__). Debug ederken asıl sebebi görebilmek için.

    if __name__ == "__main__": main(): Dosya doğrudan çalıştırıldığında main() çağrılır, import edildiğinde çağrılmaz. python -m src ile çalıştırıldığında __name__ değeri "__main__" olur.


    5.3 parse_args() — src/__main__.py

    Ne yapar: Komut satırı argümanlarını okur: --functions_definition, --input, --output.

    Nasıl: argparse.ArgumentParser ve üç add_argument. Her birinin default değeri yukarıdaki sabitler; yani hepsi isteğe bağlı.

    Dikkat:

    • Argüman adı --functions_definition (alt çizgili). argparse bunu args.functions_definition olarak sunuyor.
    • Bilinmeyen bir argüman verilirse (--foo) argparse kendi hata mesajını basıp SystemExit(2) ile çıkıyor. Bu bir traceback değil, normal bir kullanım hatası.
    • description="..." ve help="..." şu an üç nokta. Değerlendirici --help çalıştırırsa anlamsız görünür. Bkz. 11. bölüm, Tarif 8.

    5.4 load_json_file(path) — src/__main__.py

    Ne yapar: Bir JSON dosyasını okuyup Python nesnesine çevirir.

    Python
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except FileNotFoundError as e:  → "File not found: ..."
    except json.JSONDecodeError as e:  → "Invalid JSON in file ...: ..."
    except UnicodeDecodeError as e:  → "File ... is not valid UTF-8: ..."
    except OSError as e:  → "Error reading file ...: ..."

    Neden:

    • with (context manager): Subject istiyor. Hata olsa bile dosya otomatik kapanır, kaynak sızıntısı olmaz.
    • encoding="utf-8": Açıkça belirtilmezse sistem varsayılanı kullanılır; makineden makineye değişebilir.

    Hata sınıfları ve sıraları:

    Hata Ne zaman Kimin alt sınıfı
    FileNotFoundError Dosya yok OSError
    json.JSONDecodeError JSON bozuk ValueError
    UnicodeDecodeError Dosya UTF-8 değil ValueError
    OSError İzin yok, dizin verildi, vs. —

    FileNotFoundError bir OSError olduğu için OSError'dan önce yazılmak zorunda. Yoksa özel mesaj ("File not found") hiç basılmaz.

    Dönüş tipi neden object: JSON herhangi bir şey olabilir (liste, sözlük, sayı …). Şekli çağıran fonksiyon kontrol ediyor.


    5.5 load_functions(path) — src/__main__.py

    Ne yapar: Fonksiyon tanımları dosyasını okuyup FunctionDefinition listesine çevirir.

    Kontroller, sırasıyla:

    1. load_json_file → dosya okunabilir ve geçerli JSON mı?
    2. isinstance(payload, list) → en dışta liste mi? (Örneğin bir sözlük verilirse: Expected a list of function definitions ..., got dict.)
    3. len(payload) == 0 → boş liste ise hata. Seçilecek hiçbir fonksiyon yoksa model bir şey üretemez.
    4. Her eleman için FunctionDefinition.model_validate(item) → pydantic şema kontrolü. Bir tanesi bile bozuksa bütün dosya reddediliyor (fonksiyon listesi tutarlı olmalı).

    5.6 load_prompts(path) — src/__main__.py

    load_functions ile aynı mantık, tek farkla: boş liste kabul ediliyor. İşlenecek prompt yoksa çıktı [] olur; bu bir hata değil.

    Her eleman PromptTest.model_validate(item) ile kontrol ediliyor: {"prompt": "..."} şeklinde olmalı.


    5.7 Small_LLM_Model() — llm_sdk (hazır)

    Bizim fonksiyonumuz değil ama bilmen gerekiyor:

    • Varsayılan model Qwen/Qwen3-0.6B.
    • Cihazı otomatik seçiyor: mps (Mac) > cuda (NVIDIA) > cpu.
    • İlk çalıştırmada modeli Hugging Face'ten indiriyor (1.5 GB), sonra önbellekten (`/.cache/huggingface`) kullanıyor.

    5.8 load_vocab(model) — src/vocab.py

    Ne yapar: Modelin vocab.json dosyasını okuyup {"token_metni": id} sözlüğü döndürür.

    Python
    try:
        path = model.get_path_to_vocab_file()
    except Exception as e:
        raise RuntimeError(f"Vocab file could not be found: {e}") from e
    
    try:
        with open(path, "r", encoding="utf-8") as f:
            return json.load(f)
    except (OSError, ValueError) as e:
        raise RuntimeError(f"Vocab file could not be read: {e}") from e

    Neden iki ayrı try:

    • Birincisi: get_path_to_vocab_file içeride hf_hub_download çağırıyor. Bu, huggingface ya da requests kütüphanelerinden önceden bilinmeyen hatalar fırlatabilir. Bu yüzden Exception.
    • İkincisi: Dosya okuma. OSError bütün dosya hatalarını, ValueError hem JSONDecodeError'ı hem UnicodeDecodeError'ı kapsıyor.

    Neden vocab'a ihtiyacımız var: Her adımda "bu token geçerli mi?" diye sormak için her tokenin metnini bilmemiz gerekiyor. Model bize sadece id'lere göre logit veriyor.


    5.9 build_id_to_token(token_to_id) — src/vocab.py

    Ne yapar: Sözlüğü ters çevirir: {"token": id} → {id: "token"}.

    Neden: Model en iyi tokeni bir index (id) olarak veriyor. O tokenin metnini öğrenmemiz gerekiyor (generated_so_far'a eklemek için). Ters sözlük olmasaydı her adımda 151 bin elemanlı vocab'ı tarayıp id'yi aramamız gerekirdi (O(n)). Ters sözlükle bu O(1).

    Neden bir kez, main içinde: Ters çevirme işi bir kez yapılıp bütün promptlara geçiriliyor; her promptta tekrar hesaplanmıyor.


    5.10 process_prompts(model, prompts, functions, vocab, id_to_token) — src/__main__.py

    Ne yapar: Her prompt için generate_function_call çağırır ve sonuçları toplar.

    Python
    for i, prompt_test in enumerate(prompts, start=1):
        print(f"[{i}/{len(prompts)}] Processing prompt: ...", file=sys.stderr)
        try:
            result = generate_function_call(...)
            results.append(result)
        except Exception as e:
            print(f"Failed to process prompt ...: {e}", file=sys.stderr)
    failed = len(prompts) - len(results)
    if failed:
        print(f"{failed}/{len(prompts)} prompt(s) could not be processed.", file=sys.stderr)
    return results

    Neden her prompt ayrı try içinde: Tek bir bozuk prompt (örneğin desteklenmeyen tipli bir fonksiyon seçen) bütün çalışmayı öldürmesin.

    Neden except Exception (geniş): Bu bir "batch" döngüsü; hangi hatayla karşılaşırsak karşılaşalım diğer promptlara devam etmek istiyoruz. KeyboardInterrupt bunun içinde değil (BaseException), yani Ctrl+C hâlâ programı durdurabiliyor.

    Neden stderr: İlerleme ve hata mesajları insan için, asıl çıktı (JSON dosyası) makine için. Bunları ayırmak iyi bir alışkanlık: stdout'u bir dosyaya yönlendiren biri log mesajlarıyla karışık bir çıktı almaz.

    Sondaki özet: Kaç promptun başarısız olduğunu net olarak gösteriyor. Subject "must always provide clear error messages" diyor.

    Dikkat: Başarısız prompt çıktı dosyasında yer almıyor. Bu bilinçli bir karar: sahte ya da uydurma bir sonuç yazmak, şemaya uygun ama yanlış veri üretmek demek.


    5.11 generate_function_call(model, prompt, functions, vocab, id_to_token) — src/decoding.py

    Projenin merkezi. Bir prompt için tam fonksiyon çağrısını iki aşamada üretiyor.

    Aşama 1 — Fonksiyon adı

    Python
    name_candidate = ['"' + fn.name + '"' for fn in functions]
    catalog = "Available functions:\n"
    for fn in functions:
        catalog += f"- {fn.name}: {fn.description}\n"
    name_prompt = catalog + "\n" + prompt + "\nfunction name: "
    input_ids = model.encode(name_prompt)[0].tolist()
    name_result = generate_constrained_string(model, input_ids, name_candidate, vocab, id_to_token)
    function_name = name_result.strip('"')

    Modele giden metin şöyle görünüyor:

    Metin
    Available functions:
    - fn_add_numbers: Add two numbers together and return their sum.
    - fn_greet: Generate a greeting message for a person by name.
    - fn_reverse_string: Reverse a string and return the reversed result.
    - fn_get_square_root: Calculate the square root of a number.
    - fn_substitute_string_with_regex: Replace all occurrences matching a regex pattern in a string.
    
    What is the sum of 2 and 3?
    function name: 

    Adaylar neden tırnak içinde ('"fn_add_numbers"'): Asıl sebep önek (prefix) problemi. Diyelim ki fn_add ve fn_add_numbers adında iki fonksiyon var. Tırnaksız olsaydı model fn_add yazdığı anda iki aday da hâlâ mümkün olurdu: fn_add tamamlanmış, fn_add_numbers devam ediyor. Ne zaman duracağımızı bilemezdik. Kapanış tırnağı adın bittiğini açıkça işaretliyor: "fn_add" ile "fn_add_numbers" artık birbirinin öneki değil.

    model.encode(...)[0].tolist(): encode 2 boyutlu bir tensör döndürüyor (şekli [1, n], batch boyutu 1). [0] ilk satırı alıyor, .tolist() da Python list[int]'e çeviriyor.

    .strip('"'): Baştaki ve sondaki tırnakları atıyor → fn_add_numbers.

    Seçimi kim yapıyor? Model. Kod sadece "tanımlı adlardan biri olmak zorunda" kısıtını koyuyor. Hangisinin seçileceği tamamen logitlere bağlı. Heuristic yok (anahtar kelime eşleştirme, if-else yok).

    Güvenlik kontrolü:

    Python
    for fn in functions:
        if fn.name == function_name:
            selected_functions = fn
            break
    if selected_functions is None:
        raise ValueError(...)

    Teoride buraya hiç düşülmez (aday listesi dışında bir şey üretilemez). Ama selected_functions'ın tipi FunctionDefinition | None olduğu için mypy'yi memnun etmek ve savunmacı programlama için var.

    Aşama 2 — Parametreler

    Python
    context = ""
    parameters = {}
    for index, (param_name, param_spec) in enumerate(selected_functions.parameters.items()):
        ordinal = ORDINAL_WORDS[index] if index < 6 else f"{index + 1}th"   # (gerçek kodda if/else)
        param_prompt = (
            f"Function: {selected_functions.name}\n"
            f"Description: {selected_functions.description}\n"
            f"{prompt}"
            f"{context}"
            f"\n{ordinal} argument ({param_name}) value: "
        )
        param_input_ids = model.encode(param_prompt)[0].tolist()
        ... tipe göre üret ...
        context += f"\n{param_name}: {parameters[param_name]}"

    İkinci parametre için modele giden metin:

    Metin
    Function: fn_add_numbers
    Description: Add two numbers together and return their sum.
    What is the sum of 2 and 3?
    a: 2.0
    second argument (b) value: 

    Neden her parametre için yeni bir prompt: Her parametre farklı bir soru: "a nedir?", "b nedir?". Prompt bu soruyu açıkça soruyor.

    context neden var: Önceki parametrelerin değerleri bir sonraki promptun içine ekleniyor (a: 2.0). Böylece model b için tekrar 2 yazmak yerine, a'nın zaten 2 olduğunu görüp sıradaki sayıya (3) geçiyor.

    Ordinal kelime (first, second …) neden var: Modele hangi sıradaki argümanı istediğimizi doğal dille söylüyor. 6'dan fazla parametre için 7th, 8th … kullanılıyor (eskiden IndexError veriyordu).

    dict sırası: Python 3.7+'ta sözlükler ekleme sırasını korur. Parametreler JSON dosyasındaki sırayla işleniyor.

    Tipe göre dallanma:

    param_spec.type Üretici Dönüşüm Çıktı tipi
    "number" generate_number float(raw) float (2.0)
    "integer" generate_number(..., allow_decimal=False) int(raw) int (2)
    "string" generate_string raw.strip('"') str
    "boolean" generate_constrained_string(["true","false"]) raw == "true" bool
    diğer — ValueError("Unsupported type ...") prompt başarısız

    float() ve int() neden hiç hata vermiyor: Gramer sadece tamamlanmış sayılarda durmaya izin veriyor. int() için ayrıca nokta tamamen yasak. Yani raw_value her zaman "2", "-0.5" gibi parse edilebilir bir metin.

    JSON'u model mi yazıyor? Hayır, ve bu önemli bir tasarım kararı. JSON'un iskeleti ({, "name":, "parameters": …) program tarafından sabit olarak kuruluyor (FunctionCallResult + json.dump). Model sadece serbest kısımları üretiyor: fonksiyon adını ve argüman değerlerini. Bu sayede:

    • İskelet hiçbir zaman bozulamıyor.
    • Model gereksiz tokenler ({, ", : gibi) için forward pass harcamıyor; daha hızlı.
    • Her değer, şemadaki tipine göre ayrı bir gramerle kısıtlanıyor.

    Değerlendirici "model JSON üretmiyor ki?" derse cevap: "Şema sabit; değişken olan yerler fonksiyon adı ve argüman değerleri. Bunları model üretiyor, constrained decoding ile her biri tipine göre kısıtlanıyor. Yapıyı modele bırakmak, geçerliliği riske atıp ekstra forward pass harcamak olurdu."


    5.12 generate_constrained_string(model, input_ids, candidates, vocab, id_to_token) — src/decoding.py

    Ne yapar: Model, sabit bir aday listesinden (fonksiyon adları ya da true/false) tam olarak birini üretsin diye token token ilerler.

    Python
    generated_so_far = ""
    input_ids = input_ids.copy()
    candidates = candidates.copy()
    
    for _ in range(MAX_ITERATIONS):
        candidates_list = get_valid_tokens_for_candidates(candidates, generated_so_far, vocab)
        chosen_id, chosen_token = _select_next_token(model, input_ids, candidates_list, id_to_token)
        generated_so_far += chosen_token
        input_ids.append(chosen_id)
        candidates = filter_candidates(candidates, generated_so_far)
        if len(candidates) == 1 and generated_so_far == candidates[0]:
            return candidates[0]
    
    raise RuntimeError("Maximum number of iterations reached; ...")

    Adım adım:

    1. Şu ana kadar üretilen metni ileriye taşıyabilecek tokenleri bul (en az bir adayın öneki kalmalı).
    2. Model bunlar arasından en iyisini seçsin.
    3. Seçilen tokeni hem metne hem input_ids'e ekle (model bir sonraki adımda kendi seçimini görsün).
    4. Artık mümkün olmayan adayları at.
    5. Tek aday kaldıysa ve tamamen üretildiyse bitir.

    Neden .copy(): input_ids ve candidates dışarıdan gelen listeler. Python'da listeler referansla geçer; append çağrısı çağıranın listesini de değiştirirdi. Kopyalayınca fonksiyonun yan etkisi olmuyor.

    Neden MAX_ITERATIONS: Güvenlik sınırı (100). Normalde aday sayısı sınırlı olduğu için döngü kendiliğinden bitiyor. Her token en az 1 karakter eklediği için en fazla "aday uzunluğu" kadar adım sürüyor.

    Neden burada sınıra gelince hata veriyor (string'deki gibi kapatmıyor): Yarım kalmış bir fonksiyon adı ("fn_add_num) ya da boolean (tru) anlamsız. Sessizce yanlış bir değer döndürmek yerine hata vermek daha doğru.


    5.13 get_valid_tokens_for_candidates(candidates, generated_so_far, vocab) — src/grammar.py

    Ne yapar: Her aday için geçerli tokenleri bulup birleştirir; tekrarları atar.

    Python
    ids_set = set()
    for candidate in candidates:
        ids_set.update(get_valid_tokens_for_target(candidate, generated_so_far, vocab))
    return list(ids_set)

    Neden set: Aynı token birden fazla adaya uyabilir. Örneğin başta " tokeni bütün adaylara uyar. set tekrarları otomatik atıyor.


    5.14 get_valid_tokens_for_target(target, generated_so_far, vocab) — src/grammar.py

    Ne yapar: Tek bir hedef metin için geçerli tokenleri bulur.

    Python
    for token_str, token_id in vocab.items():
        unified = generated_so_far + token_str
        if target.startswith(unified):
            id_list.append(token_id)

    Kural: şu ana kadarki metin + token hâlâ hedefin başlangıcı (öneki) ise token geçerli.

    Örnek: hedef "fn_greet", şu ana kadar "fn_

    Token Birleşim Hedefin öneki mi?
    gre "fn_gre
    greet "fn_greet
    greet" "fn_greet" (tamamı)
    add "fn_add
    Ġgreet "fn_ greet (boşluk)

    Neden tek karakter değil, bütün token: Tokenler çok karakterli. Model greet gibi uzun bir token seçebilir. Karakter karakter kontrol etmek, birden fazla karakter içeren tokenleri kaçırırdı.

    Maliyet: Her çağrıda bütün vocab (~151 bin) taranıyor. Aday sayısıyla çarpılıyor (5 fonksiyon → 5 tarama).


    5.15 _select_next_token(model, input_ids, valid_token_ids, id_to_token) — src/decoding.py

    Constrained decoding'in tam olarak gerçekleştiği yer.

    Python
    if not valid_token_ids:
        raise ValueError("No valid tokens left")
    
    valid_token_ids_set = set(valid_token_ids)
    raw_logits = model.get_logits_from_input_ids(input_ids)
    masked_raw_logits = raw_logits.copy()
    
    for token_id in range(len(masked_raw_logits)):
        if token_id not in valid_token_ids_set:
            masked_raw_logits[token_id] = float('-inf')
    
    max_id = max(masked_raw_logits)
    max_id_index = masked_raw_logits.index(max_id)
    token_str = id_to_token[max_id_index]
    return max_id_index, token_str

    Adım adım:

    1. Geçerli token yoksa hemen hata ver (yoksa hepsi -inf olur ve max anlamsızlaşır).
    2. Geçerli id'leri set'e çevir: in kontrolü listede O(n), set'te O(1). 151 bin kontrol için bu fark çok büyük.
    3. Modelden ham logitleri al (bir forward pass).
    4. Geçersiz her tokenin logitini -inf yap.
    5. En büyük logiti bul (greedy / argmax) ve indexini al.
    6. Index = token id. id_to_token ile metnini bul.

    Değişken adı uyarısı: max_id aslında bir id değil, en büyük logit değeri. max_id_index ise token id'si. Sorulursa bu karışıklığı bil.

    Eşitlik durumu: list.index() ilk eşleşmeyi döndürüyor; iki token aynı logite sahipse küçük id'li kazanıyor. Pratikte float logitlerde tam eşitlik çok nadir.

    Logit listesi vocab'tan uzun: Modelin çıktı katmanı, vocab.json'daki token sayısından biraz daha fazla eleman döndürüyor (özel tokenler ve dolgu). Bu fazladan id'ler hiçbir zaman geçerli listede olmadığı için her zaman -inf kalıyor ve asla seçilmiyor. Bu yüzden id_to_token[max_id_index] hiç KeyError vermiyor.

    Neden alt çizgiyle başlıyor (_select_...): Python'da "modül içi yardımcı" anlamına gelen bir konvansiyon. Subject'teki yasak llm_sdk'nın private üyeleri için; kendi modülümüzdeki private fonksiyon serbest.


    5.16 filter_candidates(candidates, generated_so_far) — src/grammar.py

    Ne yapar: Hâlâ üretilen metinle başlayan adayları tutar, diğerlerini atar.

    Python
    return [c for c in candidates if c.startswith(generated_so_far)]   # (gerçek kod döngüyle)

    Örnek: Adaylar ['"fn_add_numbers"', '"fn_greet"', '"fn_get_square_root"'], üretilen "fn_g: → ['"fn_greet"', '"fn_get_square_root"']

    Neden gerekli: Bir sonraki adımda get_valid_tokens_for_candidates sadece kalan adaylara bakar; hem daha hızlı (daha az tarama) hem bitiş koşulu (len(candidates) == 1) bunun sayesinde çalışıyor.


    5.17 generate_number(model, input_ids, vocab, id_to_token, allow_decimal=True) — src/decoding.py

    Ne yapar: Bir sayıyı token token üretir. Sayının nerede biteceğine model karar verir.

    Python
    generated_so_far = ""
    input_ids = input_ids.copy()
    for _ in range(MAX_ITERATIONS):
        valid_ids = get_valid_tokens_for_number(generated_so_far, vocab, allow_decimal)
        chosen_id, chosen_token = _select_next_token(model, input_ids, valid_ids, id_to_token)
        if chosen_token == NUMBER_STOP_TOKEN:
            return generated_so_far
        generated_so_far += chosen_token
        input_ids.append(chosen_id)
    raise RuntimeError(...)

    Bitiş mekanizması: Ċ (satır sonu) tokeni "durdur" anlamına geliyor. Bu token sadece sayı tamamlanmışsa geçerli listeye ekleniyor. Model "2" yazdıktan sonra iki seçeneğe sahip: bir rakam daha eklemek ("23") ya da Ċ seçip durmak. Model genelde durmayı seçiyor, çünkü promptta sayı 2.

    Neden Ċ (satır sonu): Doğal bir seçim: prompt ... value: ile bitiyor; model değeri yazdıktan sonra satır sonuna geçmeye meyilli. Ayrıca Ċ hiçbir sayının parçası olamaz, karışıklık çıkmaz.

    Stop tokeni neden generated_so_far'a eklenmiyor: O bir işaret, sayının parçası değil.

    allow_decimal: integer tipi için False geçiliyor; nokta hiç önerilmiyor. Varsayılan True, böylece number için olan eski çağrılar hiç değişmeden çalışıyor.

    Dönüş tipi str: Dönüşümü (float/int) çağıran yapıyor; bu fonksiyon iki tip için de kullanılıyor.


    5.18 get_valid_tokens_for_number(generated_so_far, vocab, allow_decimal=True) — src/grammar.py

    Python
    id_list = []
    if is_complete_number(generated_so_far):
        stop_id = vocab.get(NUMBER_STOP_TOKEN)
        if stop_id is not None:
            id_list.append(stop_id)
    for token_str, token_id in vocab.items():
        if is_valid_number_fragment(generated_so_far + token_str, allow_decimal):
            id_list.append(token_id)
    return id_list

    İki iş yapıyor:

    1. Sayı tamamlanmışsa stop tokenini ekliyor → model durabilir.
    2. Sayıyı geçerli tutan her tokeni ekliyor → model devam edebilir.

    vocab.get(...) neden [...] değil: Başka bir model kullanılırsa vocab'ta Ċ olmayabilir. get, KeyError yerine None döndürüyor.

    Örnek akış (Qwen'de rakamlar tek token):

    generated_so_far Tamam mı? Geçerli tokenler
    "" 0-9, -
    "2" Ċ, 0-9, .
    "2." 0-9
    "2.5" Ċ, 0-9
    "-" 0-9

    5.19 is_valid_number_fragment(fragment, allow_decimal=True) — src/grammar.py

    Soru: "Bu metin hâlâ bir sayıya dönüşebilir mi?"

    Python
    if fragment == "":
        return True
    allowed = "0123456789-"
    if allow_decimal:
        allowed += "."
    for index, part in enumerate(fragment):
        if part not in allowed:            return False   # izin verilmeyen karakter
        if part == "-" and index != 0:     return False   # eksi sadece başta
        if part == "." and (index == 0 or not fragment[index - 1].isdigit()):
                                           return False   # nokta sadece rakamdan sonra
    if fragment.count(".") > 1:            return False   # en fazla bir nokta
    return True

    Kurallar:

    • Sadece rakamlar, - ve (izin varsa) ..
    • - sadece ilk karakter olabilir.
    • . sadece bir rakamdan sonra gelebilir (.5, -. yasak).
    • En fazla bir nokta.

    index == 0 kontrolü neden şart: Python'da fragment[-1] son karakteri verir. index == 0 iken fragment[index - 1] hata vermez ama yanlış karakteri okur. or kısa devre yaptığı için index == 0 ise ikinci kısım hiç çalışmaz.

    .5 kuralının hikâyesi (savunmada anlat): İlk versiyonda nokta başta olabiliyordu. ".5" "geçerli" sayılıyordu ama is_complete_number(".5") hiçbir zaman True dönmüyordu (noktadan önce rakam yok). Sonuç: stop tokeni hiç sunulmuyordu, model 100 rakam boyunca devam ediyor ve hata alıyordu. Ders: gramerin kabul ettiği her parça tamamlanabilir olmalı.

    Neden bilimsel gösterim (1e5) yok: Promptlar normal sayılar içeriyor; e eklemek grameri karmaşıklaştırırdı. Ama sınırlama olarak bil.


    5.20 is_complete_number(fragment) — src/grammar.py

    Soru: "Bu metin burada biterse geçerli bir sayı olur mu?"

    Python
    if fragment.startswith("-"):
        fragment = fragment[1:]
    if "." not in fragment:
        return fragment.isdigit()
    if fragment.count(".") > 1:
        return False
    first, second = fragment.split(".")
    return first.isdigit() and second.isdigit()
    Girdi Sonuç Neden
    "345" rakamlardan oluşuyor
    "-5" eksi atılınca "5"
    "3.14" iki taraf da rakam
    "" "".isdigit() → False
    "-" eksi atılınca boş
    "12." second = ""
    ".5" first = ""

    Neden önemli: "" için False döndüğü için model en az bir rakam yazmadan duramıyor; boş sayı üretilemiyor.

    Sayı grameri oyun alanı

    is_valid_number_fragment
    is_complete_number

    Örnek bir vocab'tan get_valid_tokens_for_number sonucu

    Fonksiyonlar 5.19 ve 5.20'deki kodun birebir JavaScript karşılığı. Yeşil: geçerli token. Ċ yalnızca sayı tamamken çıkar.

    5.21 generate_string(model, input_ids, vocab, id_to_token) — src/decoding.py

    Ne yapar: Tırnak içinde bir metin üretir.

    Python
    input_ids = input_ids.copy()
    opening_id = vocab['"']
    opening_str = id_to_token[opening_id]
    generated_so_far = opening_str
    input_ids.append(opening_id)
    for _ in range(MAX_ITERATIONS):
        valid_ids = get_valid_tokens_for_string(generated_so_far[1:], vocab)
        chosen_id, chosen_token = _select_next_token(model, input_ids, valid_ids, id_to_token)
        generated_so_far += chosen_token
        input_ids.append(chosen_id)
        if is_complete_string(generated_so_far[1:]):
            return decode_bpe_string(generated_so_far, BYTE_DECODER)
    return decode_bpe_string(generated_so_far + '"', BYTE_DECODER)

    Açılış tırnağını neden biz koyuyoruz: " karakteri bir string'de iki zıt anlam taşıyor: başta "açılış", sonda "kapanış". Açılışı modele bırakırsak ilk "'nun hangisi olduğunu ayırt etmek zorlaşır. Çözüm: açılışı biz koyuyoruz (hem metne hem input_ids'e, böylece model tırnağın açıldığını "görüyor"). Ardından modelin ürettiği ilk " her zaman kapanış.

    generated_so_far[1:] neden: Kontrol fonksiyonlarına açılış tırnağı olmadan içerik gönderiliyor. Aksi halde açılış tırnağı "ortada bir tırnak" sayılır ve her şey reddedilirdi.

    100 tokende kapanmazsa: Hata vermek yerine string'i biz kapatıyoruz. Yarım bir string de geçerli bir string; çıktı şemaya uygun kalıyor. (Sayı ve aday için bunu yapmıyoruz, çünkü yarım bir sayı ya da fonksiyon adı anlamsız.)

    Sonuç: decode_bpe_string BPE karakterlerini gerçek metne çeviriyor (Ġ → boşluk). Dönen değer tırnaklı ("hello"). Tırnakları çağıran strip('"') ile atıyor.

    Sınırlama: String'in içinde " karakteri olamaz; ilk tırnak her zaman kapanış sayılıyor. (JSON'daki \" kaçış dizisi desteklenmiyor.)


    5.22 get_valid_tokens_for_string(generated_so_far, vocab) — src/grammar.py

    Bütün vocab'ı tarayıp is_valid_string_fragment(generated_so_far + token) olan tokenleri döndürüyor. Yapı get_valid_tokens_for_number ile aynı, sadece kural farklı.


    5.23 is_valid_string_fragment(fragment) — src/grammar.py

    Python
    for index, control in enumerate(fragment):
        if control == '"':
            return index == len(fragment) - 1
    return True

    Kural: Tırnak varsa sadece en sonda olabilir.

    Birleşim Sonuç Neden
    hello tırnak yok
    hello" tırnak sonda → kapanış
    hel"lo tırnak ortada
    hello", tırnaktan sonra virgül var

    Bu sayede ", gibi tırnak + başka karakter içeren tokenler otomatik reddediliyor; sadece temiz bir kapanış mümkün.


    5.24 is_complete_string(fragment) — src/grammar.py

    Python
    if len(fragment) == 0:
        return False
    return fragment[-1] == '"'

    Son karakter tırnaksa string bitmiş demek. Boş kontrolü, fragment[-1]'in IndexError vermemesi için.

    String grameri oyun alanı

    is_valid_string_fragment
    is_complete_string

    Örnek tokenler eklenince

    Tırnak yalnızca en sonda olabilir; ilk tırnak her zaman kapanış.

    5.25 build_byte_decoder() ve BYTE_DECODER — src/grammar.py

    Ne yapar: BPE'nin "görünür karakter → bayt" eşleme tablosunu kurar (GPT-2'nin bytes_to_unicode tablosunun tersi).

    Python
    byte_values = list(range(ord("!"), ord("~") + 1)) \
                + list(range(ord("¡"), ord("¬") + 1)) \
                + list(range(ord("®"), ord("ÿ") + 1))
    char_codes = byte_values[:]
    offset = 0
    for byte in range(256):
        if byte not in byte_values:
            byte_values.append(byte)
            char_codes.append(256 + offset)
            offset += 1
    return {chr(code): byte for code, byte in zip(char_codes, byte_values)}

    Mantık:

    • 256 baytın yazdırılabilir olanları (!–~, ¡–¬, ®–ÿ) kendi karakterleriyle eşleniyor: A → 65.

    • Geri kalan 68 bayt (boşluk, satır sonu, kontrol karakterleri …) sırayla 256, 257, 258 … kod noktalarına eşleniyor.

      • Bayt 10 (\n) → 256 + 10 = 266 → Ċ
      • Bayt 32 (boşluk) → 256 + 32 = 288 → Ġ

      (0–32 arasındaki baytların hepsi "yazdırılamaz" listesinde olduğu için offset bunlarda bayt değerine eşit.)

    BYTE_DECODER = build_byte_decoder(): Modül yüklenirken bir kez hesaplanıyor, her string'de tekrar hesaplanmıyor.

    256 bayt, tek tablo

    Bir hücreye dokun.

    Gri hücreler kendi karakterleriyle eşlenen 188 yazdırılabilir bayt; mavi hücreler 256 + offset'e taşınan 68 bayt.

    5.26 decode_bpe_string(raw, byte_decoder) — src/grammar.py

    Python
    buffer = bytearray()
    for char in raw:
        if char in byte_decoder:
            buffer.append(byte_decoder[char])
        else:
            buffer.extend(char.encode("utf-8"))
    return buffer.decode("utf-8", errors="replace")

    Ne yapar: Ham BPE metnini gerçek metne çevirir.

    • "Ġhello" → baytlar [32, 104, 101, 108, 108, 111] → " hello"
    • Türkçe ö gibi çok baytlı karakterler vocab'ta iki karakter olarak görünür (örneğin ö). Bunlar iki bayta çevrilip UTF-8 olarak birleşince ö oluyor.

    Neden önce bayta çevirip sonra decode: Çok baytlı bir karakter iki ayrı tokene bölünmüş olabilir. Karakter karakter çevirmek yerine bütün baytları biriktirip en sonda tek seferde decode etmek bu durumu doğru çözüyor.

    errors="replace": Bayt dizisi geçersiz UTF-8 ise (örneğin 100 token sınırında yarım kalmış bir karakter) çökmek yerine � koyuyor.

    else dalı: Tablo dışında bir karakter gelirse (normalde olmaz) kendi UTF-8 baytları kullanılıyor; savunmacı programlama.

    Bu bir "kendi decode'umuz": model.decode() kullanmıyoruz. Bonus maddelerindeki "tokenizer'ı yeniden yazma" yolunda kısmi bir adım; ama encode hâlâ kullanıldığı için bonus tam karşılanmıyor.


    5.27 write_results(path, results) — src/__main__.py

    Python
    data = [result.model_dump() for result in results]
    try:
        directory = os.path.dirname(path)
        if directory:
            os.makedirs(directory, exist_ok=True)
        with open(path, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2, ensure_ascii=False)
    except OSError as e:
        raise RuntimeError(f"Error writing results to {path}: {e}") from e
    • model_dump(): Pydantic nesnesini düz Python sözlüğüne çeviriyor.
    • os.path.dirname(path): "data/output/function_calls.json" → "data/output". "out.json" gibi bir yol için boş string döner; bu yüzden if directory: kontrolü var (os.makedirs("") hata verir).
    • exist_ok=True: Klasör zaten varsa hata vermiyor.
    • makedirs neden try içinde: İzin yoksa (PermissionError) ya da yolda dizin yerine dosya varsa (NotADirectoryError) hata yakalanıyor. Hepsi OSError'ın alt sınıfı.
    • indent=2: Okunabilir çıktı.
    • ensure_ascii=False: ö, ç gibi karakterler ö olarak değil, olduğu gibi yazılıyor. İki durumda da geçerli JSON.
    • JSON geçerliliği: json.dump Python nesnelerinden her zaman geçerli JSON üretiyor (sondaki virgül, yorum vs. olmaz). Değerler zaten doğru tipte olduğu için şema uyumu da garanti.

    6. Uçtan uca örnek: "What is the sum of 2 and 3?"

    Token bölmeleri temsili; gerçek id'ler ve bölmeler farklı olabilir. Mantık aynı.

    1. Dosyalar okunur: 5 fonksiyon, 11 prompt. Model yüklenir, vocab okunur, ters sözlük kurulur.

    2. Aşama 1 — fonksiyon adı

    Adaylar:

    Metin
    '"fn_add_numbers"'  '"fn_greet"'  '"fn_reverse_string"'
    '"fn_get_square_root"'  '"fn_substitute_string_with_regex"'
    Adım Şu ana kadar Geçerli tokenlerden bazıları Modelin seçimi Kalan adaylar
    1 `` ", "fn " 5
    2 " fn, f, fn_ fn 5
    3 "fn _, _add, _g … _add 1 ("fn_add_numbers")
    4 "fn_add _numbers, _, … _numbers 1
    5 "fn_add_numbers " " 1 → bitti

    Model 3. adımda _add tokenini seçti. Bunun sebebi promptta "sum" geçmesi ve fn_add_numbers açıklamasının "Add two numbers" olması. Bunu model kendi logitleriyle yaptı; kodda "sum" kelimesine bakan bir kural yok.

    Sonuç: function_name = "fn_add_numbers".

    3. Aşama 2 — parametre a (number)

    Prompt:

    Metin
    Function: fn_add_numbers
    Description: Add two numbers together and return their sum.
    What is the sum of 2 and 3?
    first argument (a) value: 
    Adım Şu ana kadar Tamam mı Geçerli Seçim
    1 `` 0-9, - 2
    2 2 Ċ, 0-9, . Ċ → dur

    float("2") → 2.0. context = "\na: 2.0".

    4. Aşama 2 — parametre b (number)

    Prompt:

    Metin
    Function: fn_add_numbers
    Description: Add two numbers together and return their sum.
    What is the sum of 2 and 3?
    a: 2.0
    second argument (b) value: 

    Model 3 seçiyor, sonra Ċ. → 3.0.

    5. Sonuç:

    Python
    FunctionCallResult(prompt="What is the sum of 2 and 3?",
                       name="fn_add_numbers",
                       parameters={"a": 2.0, "b": 3.0})

    Forward pass sayısı: Ad için 5 + a için 2 + b için 2 = 9 forward pass. Her forward pass öncesinde de vocab taramaları yapılıyor.

    Uçtan uca oynatıcı

    Modele giden metin

    Şu ana kadar
    Geçerli tokenlerden bazıları
    Adaylar
      
        
      forward pass: 0
      Adımlar bu bölümdeki tablolardan alındı; token bölmeleri temsili.

      Bir string örneği: "Greet shrek"

      Fonksiyon fn_greet seçildikten sonra prompt ... first argument (name) value: ile bitiyor. Kod açılış tırnağını ekliyor:

      Adım Şu ana kadar (ham) Seçim
      0 " (bizim koyduğumuz) —
      1 " sh
      2 "sh rek
      3 "shrek " → kapandı

      decode_bpe_string('"shrek"') → "shrek" → strip('"') → shrek.