diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 54a497a6..fddad6c1 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -246,12 +246,14 @@ jobs: # 5.10.1 is the first installable release past CVE-2026-4372, # CVE-2026-5241 and CVE-2026-9856 (5.10.0, which carries the last of # them, was yanked); it requires huggingface_hub>=1.5 / - # tokenizers>=0.22, and ForgeLM itself needs torch>=2.4; trl 1.0.0 + # tokenizers>=0.22 and imports torch.float8_e8m0fnu, which first + # exists in torch 2.7.0 (this job failed on torch 2.4.0 with + # AttributeError at `import transformers`); trl 1.0.0 # pulls the accelerate>=1.4 and datasets>=4.7 floor. Keep 1:1 with the # pyproject [project].dependencies minimums (tests/test_packaging.py # compares them). pip install \ - "torch==2.4.0" \ + "torch==2.7.0" \ "transformers==5.10.1" \ "huggingface_hub==1.5.0" \ "peft==0.19.0" \ diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 10bb1375..7811fcf4 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -61,7 +61,7 @@ jobs: with: python-version: ${{ matrix.python }} - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: dist path: dist @@ -124,7 +124,7 @@ jobs: contents: read # download-artifact reads workflow run state id-token: write # OIDC trusted publishing to PyPI steps: - - uses: actions/download-artifact@v8 + - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: name: dist path: dist/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 69df425d..7680d5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,9 +176,26 @@ All notable changes to ForgeLM are documented here. which no longer works: the documented `{type: linear, factor: 4}` raised `KeyError: 'rope_type'` while the model was built, and `{rope_type: ..., factor: ...}` replaced the whole parameter dict so `rope_theta` was lost (`TypeError`). The requested keys are now merged over the model's own `rope_parameters` (the legacy key `type` is read as - `rope_type`), applied to the text config of a multimodal checkpoint, and the finished config is loaded. The - `sliding_window_attention` override travels in the same config object. Verified on transformers 5.13.0 for - `linear`, `dynamic` and `yarn` on a tiny local Llama; not exercised on the 5.3-5.9 range the floor still admits (#103). + `rope_type`), applied to the text config of a multimodal checkpoint, and the finished config is loaded. A model that + keeps one entry per layer type (Gemma 3: `full_attention` and `sliding_attention`) gets the scaling on its + `full_attention` entry, and one with per-layer-type entries but no `full_attention` is refused with exit 1 instead of + being scaled nowhere. Verified on transformers 5.13.0 for `linear`, `dynamic` and `yarn` on a tiny local Llama, and + on real Gemma 3 configs (#103). +- `training.sliding_window_attention` reaches the text config and takes effect. The override was passed as a + `from_pretrained(sliding_window=...)` keyword, which transformers 5 forwards to the model constructor once `config=` + is set, and a multimodal model (Qwen2-VL) keeps `sliding_window` on its text config, not the outer one. It now travels in + the same config object as `rope_scaling`. For the Qwen2 family the config also derives a per-layer `layer_types` + list from `use_sliding_window`, `sliding_window` and `max_window_layers`, so setting the number alone left every layer + on full attention; the text config is rebuilt with the flag on so the list is derived again (a pattern the checkpoint + supplied itself is kept), and a warning is logged when the result still has no sliding-attention layer. The unsloth backend builds no transformers config, so it + applied neither `rope_scaling` nor `sliding_window_attention` and trained with the model's own settings; it now + refuses a config that sets either when the config loads (exit 1, `--dry-run` included) instead of ignoring it (#103). +- The `torch` floor is `>=2.7.0`, not `>=2.4.0`. transformers 5.10.1 imports `torch.float8_e8m0fnu` when + `transformers.modeling_utils` loads, and that dtype first exists in torch 2.7.0; on 2.6.0 the import raises + `AttributeError`. transformers lists torch only as an extra, so pip never raised the old floor. The Nightly + Compatibility job's minimum-versions leg caught it (it installs every floor with `--no-deps`); its torch pin, the + installation pages (English and Turkish) and the `forgelm doctor` samples in the guides, references and user manuals + now say 2.7.0. The Intel Mac note stands: there is no `torch>=2.7` wheel for x86_64 macOS. - The `forgelm` CLI no longer sends Hugging Face's anonymous usage ping. TRL's trainers call `send_telemetry` when they are built, and `huggingface_hub` sends it unless `HF_HUB_DISABLE_TELEMETRY` is true or offline mode is on, which contradicted the privacy page ("does not send usage data anywhere"). `main()` now sets `HF_HUB_DISABLE_TELEMETRY=1` before anything imports diff --git a/docs/guides/air_gap_deployment-tr.md b/docs/guides/air_gap_deployment-tr.md index 18c5a29b..a583f3f8 100644 --- a/docs/guides/air_gap_deployment-tr.md +++ b/docs/guides/air_gap_deployment-tr.md @@ -106,8 +106,8 @@ forgelm doctor - environment check [+ pass] forgelm.install ForgeLM 0.9.0 running from /opt/airgap-venv/lib/python3.11/site-packages/forgelm (inside site-packages). [+ pass] python.version Python 3.11.4 (CPython). - [+ pass] torch.cuda torch 2.4.0 with CUDA 12.4. - [+ pass] numpy.torch_abi torch 2.4.0 + numpy 1.26.4 are ABI-compatible. + [+ pass] torch.cuda torch 2.7.0 with CUDA 12.6. + [+ pass] numpy.torch_abi torch 2.7.0 + numpy 1.26.4 are ABI-compatible. [+ pass] gpu.inventory 1 GPU(s) — GPU0: NVIDIA A100 (80.0 GiB). [+ pass] extras.qlora Discoverable, not import-tested (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [+ pass] extras.eval Discoverable, not import-tested (module lm_eval, purpose: lm-evaluation-harness benchmark scoring). diff --git a/docs/guides/air_gap_deployment.md b/docs/guides/air_gap_deployment.md index 30caa487..a25de50c 100644 --- a/docs/guides/air_gap_deployment.md +++ b/docs/guides/air_gap_deployment.md @@ -106,8 +106,8 @@ forgelm doctor - environment check [+ pass] forgelm.install ForgeLM 0.9.0 running from /opt/airgap-venv/lib/python3.11/site-packages/forgelm (inside site-packages). [+ pass] python.version Python 3.11.4 (CPython). - [+ pass] torch.cuda torch 2.4.0 with CUDA 12.4. - [+ pass] numpy.torch_abi torch 2.4.0 + numpy 1.26.4 are ABI-compatible. + [+ pass] torch.cuda torch 2.7.0 with CUDA 12.6. + [+ pass] numpy.torch_abi torch 2.7.0 + numpy 1.26.4 are ABI-compatible. [+ pass] gpu.inventory 1 GPU(s) — GPU0: NVIDIA A100 (80.0 GiB). [+ pass] extras.qlora Discoverable, not import-tested (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [+ pass] extras.eval Discoverable, not import-tested (module lm_eval, purpose: lm-evaluation-harness benchmark scoring). diff --git a/docs/guides/getting-started-tr.md b/docs/guides/getting-started-tr.md index 7d5add86..ac9be8e7 100644 --- a/docs/guides/getting-started-tr.md +++ b/docs/guides/getting-started-tr.md @@ -46,8 +46,8 @@ forgelm doctor - environment check [+ pass] forgelm.install ForgeLM 0.9.0 running from /home/me/.venv/lib/python3.11/site-packages/forgelm (inside site-packages). [+ pass] python.version Python 3.11.4 (CPython). - [+ pass] torch.cuda torch 2.4.0 with CUDA 12.4. - [+ pass] numpy.torch_abi torch 2.4.0 + numpy 1.26.4 are ABI-compatible. + [+ pass] torch.cuda torch 2.7.0 with CUDA 12.6. + [+ pass] numpy.torch_abi torch 2.7.0 + numpy 1.26.4 are ABI-compatible. [+ pass] gpu.inventory 1 GPU(s) — GPU0: NVIDIA RTX 4090 (24.0 GiB). [+ pass] extras.qlora Discoverable, not import-tested (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [! warn] extras.tracking Optional extra missing — install with: pip install 'forgelm[tracking]' (purpose: Weights & Biases experiment tracking). diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index e1f899a0..86313c27 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -46,8 +46,8 @@ forgelm doctor - environment check [+ pass] forgelm.install ForgeLM 0.9.0 running from /home/me/.venv/lib/python3.11/site-packages/forgelm (inside site-packages). [+ pass] python.version Python 3.11.4 (CPython). - [+ pass] torch.cuda torch 2.4.0 with CUDA 12.4. - [+ pass] numpy.torch_abi torch 2.4.0 + numpy 1.26.4 are ABI-compatible. + [+ pass] torch.cuda torch 2.7.0 with CUDA 12.6. + [+ pass] numpy.torch_abi torch 2.7.0 + numpy 1.26.4 are ABI-compatible. [+ pass] gpu.inventory 1 GPU(s) — GPU0: NVIDIA RTX 4090 (24.0 GiB). [+ pass] extras.qlora Discoverable, not import-tested (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [! warn] extras.tracking Optional extra missing — install with: pip install 'forgelm[tracking]' (purpose: Weights & Biases experiment tracking). diff --git a/docs/reference/configuration-tr.md b/docs/reference/configuration-tr.md index 0bf47d57..1bd2349b 100644 --- a/docs/reference/configuration-tr.md +++ b/docs/reference/configuration-tr.md @@ -138,9 +138,9 @@ training: | Alan | Tip | Varsayılan | Açıklama | |------|-----|-----------|----------| -| `rope_scaling` | `Optional[Dict[str, Any]]` | `null` | RoPE ölçekleme yöntemi sözlüğü (`{"type": "linear", "factor": 2.0}` vs.). Desteklenen tipler: `"linear"`, `"dynamic"`, `"yarn"`, `"longrope"`. | +| `rope_scaling` | `Optional[Dict[str, Any]]` | `null` | RoPE ölçekleme yöntemi sözlüğü (`{"type": "linear", "factor": 2.0}` vs.). Desteklenen tipler: `"linear"`, `"dynamic"`, `"yarn"`, `"longrope"`. `model.backend: unsloth` ile desteklenmez (reddedilir, exit 1). | | `neftune_noise_alpha` | float | `null` | NEFTune gürültü enjeksiyonu alpha değeri (ör. `5.0`) | -| `sliding_window_attention` | int | `null` | Kayan pencere dikkat boyutu (token) | +| `sliding_window_attention` | int | `null` | Kayan pencere dikkat boyutu (token). `model.backend: unsloth` ile desteklenmez (reddedilir, exit 1). | | `sample_packing` | bool | `false` | **Kullanımdan kaldırıldı** — `packing` için takma ad (TRL tek bir packing düğmesi sunar). `true` ayarlamak `DeprecationWarning` ile `packing: true`'ya yönlendirir; v1.0.0'da kaldırılacak. Bunun yerine `packing` kullanın. | > Kaldırma, daha erken değil v1.0.0'da gerçekleşecek; çünkü bir YAML alanını diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 87e8639a..1af45a54 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -140,9 +140,9 @@ across retries. Each retry attempt is logged to the audit trail. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `rope_scaling` | `Optional[Dict[str, Any]]` | `null` | RoPE scaling method dict (`{"type": "linear", "factor": 2.0}` etc.). Supported types: `"linear"`, `"dynamic"`, `"yarn"`, `"longrope"`. | +| `rope_scaling` | `Optional[Dict[str, Any]]` | `null` | RoPE scaling method dict (`{"type": "linear", "factor": 2.0}` etc.). Supported types: `"linear"`, `"dynamic"`, `"yarn"`, `"longrope"`. Not supported with `model.backend: unsloth` (rejected, exit 1). | | `neftune_noise_alpha` | float | `null` | NEFTune noise injection alpha (e.g., `5.0`) | -| `sliding_window_attention` | int | `null` | Sliding window attention size in tokens | +| `sliding_window_attention` | int | `null` | Sliding window attention size in tokens. Not supported with `model.backend: unsloth` (rejected, exit 1). | | `sample_packing` | bool | `false` | **Deprecated** alias for `packing` (TRL exposes a single packing knob). Setting `true` forwards to `packing: true` with a `DeprecationWarning`; will be removed in v1.0.0. Use `packing` instead. | > Removal will land in v1.0.0, not sooner, because removing a YAML field is a diff --git a/docs/reference/doctor_subcommand-tr.md b/docs/reference/doctor_subcommand-tr.md index e41f7d59..9940d21f 100644 --- a/docs/reference/doctor_subcommand-tr.md +++ b/docs/reference/doctor_subcommand-tr.md @@ -85,7 +85,7 @@ $ forgelm doctor forgelm doctor - environment check [+ pass] python.version Python 3.11.4 (CPython). - [+ pass] torch.cuda torch 2.4.0 with CUDA 12.4. + [+ pass] torch.cuda torch 2.7.0 with CUDA 12.6. [+ pass] gpu.inventory 1 GPU(s) - GPU0: NVIDIA RTX 4090 (24.0 GiB). [+ pass] extras.qlora Installed (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [! warn] extras.tracking Optional extra missing - install with: pip install 'forgelm[tracking]' (purpose: Weights & Biases experiment tracking). diff --git a/docs/reference/doctor_subcommand.md b/docs/reference/doctor_subcommand.md index ad7ef6e0..2db942bb 100644 --- a/docs/reference/doctor_subcommand.md +++ b/docs/reference/doctor_subcommand.md @@ -85,7 +85,7 @@ $ forgelm doctor forgelm doctor - environment check [+ pass] python.version Python 3.11.4 (CPython). - [+ pass] torch.cuda torch 2.4.0 with CUDA 12.4. + [+ pass] torch.cuda torch 2.7.0 with CUDA 12.6. [+ pass] gpu.inventory 1 GPU(s) - GPU0: NVIDIA RTX 4090 (24.0 GiB). [+ pass] extras.qlora Installed (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [! warn] extras.tracking Optional extra missing - install with: pip install 'forgelm[tracking]' (purpose: Weights & Biases experiment tracking). diff --git a/docs/usermanuals/en/compliance/gdpr.md b/docs/usermanuals/en/compliance/gdpr.md index fa2f8e6d..d0f37a87 100644 --- a/docs/usermanuals/en/compliance/gdpr.md +++ b/docs/usermanuals/en/compliance/gdpr.md @@ -76,10 +76,38 @@ The scan reports the lines where the identifier appears; zero matches is a valid "Delete my data." If a person's data was in your training set: -1. Find the rows with `forgelm reverse-pii`, then remove them with `forgelm purge --row-id --corpus `; every erasure is written to the audit log. +1. Find the rows with `forgelm reverse-pii`, then remove them with `forgelm purge --row-id --corpus `; every erasure is written to the audit log. `--row-id` is matched against the row's `id` or `row_id` field and never against a line number, and `forgelm ingest` does not write either field, so give every row a stable id **before you train** (see below). A corpus without ids cannot be purged row by row. 2. Source documents may still hold it — drop them from your raw store and re-ingest if necessary. 3. The model may have memorised some details — see "model-level erasure" below. +To make rows addressable, give each JSONL row a unique `id` once, when the corpus is created, and keep it when the file is later filtered or re-ordered. Rows with a missing, null or empty `id` get a new one; a row whose `id` (or `row_id`, which `purge` also matches) another row already uses stops the script, because `forgelm purge --row-id` would see both rows as one match (it refuses by default, and with `--row-matches all` it would erase every match) and you have to decide which row keeps the id. The output is written to a temporary file and moved into place only after every row passed, so a failed run leaves an existing output file untouched: + +```python +import json, os, uuid + +SRC, DST = "data/train.jsonl", "data/train.with-ids.jsonl" +tmp, seen = DST + ".tmp", set() +try: + with open(SRC, encoding="utf-8") as src, open(tmp, "w", encoding="utf-8") as dst: + for line_no, line in enumerate(src, start=1): + if not line.strip(): + continue + row = json.loads(line) + if row.get("id") in (None, ""): + row["id"] = str(uuid.uuid4()) + ids = {str(row[key]) for key in ("id", "row_id") if row.get(key) not in (None, "")} + if ids & seen: + raise SystemExit(f"line {line_no}: id {sorted(ids & seen)[0]!r} is already used by another row") + seen |= ids + dst.write(json.dumps(row, ensure_ascii=False) + "\n") + os.replace(tmp, DST) # reached only when every row passed +finally: + if os.path.exists(tmp): + os.remove(tmp) +``` + +Train from the file with ids. `forgelm reverse-pii` reports the file and line where the identifier occurs; read that row's `id` from the file and pass it to `forgelm purge --row-id`. Re-assigning ids after training breaks the link between the audit log and the corpus, so assign once. + ### Model-level erasure LLMs can memorise rare strings from training. Even with PII masking, removing all traces from a deployed model is hard. ForgeLM's defences: diff --git a/docs/usermanuals/en/getting-started/first-run.md b/docs/usermanuals/en/getting-started/first-run.md index 5b09a35c..d3f51f9e 100644 --- a/docs/usermanuals/en/getting-started/first-run.md +++ b/docs/usermanuals/en/getting-started/first-run.md @@ -33,8 +33,8 @@ $ forgelm doctor --offline forgelm doctor - environment check [+ pass] python.version Python 3.11.4 (CPython). - [! warn] torch.cuda torch 2.4.0 installed but CUDA is unavailable. CPU-only runs are supported but training will be very slow; expect to use this only for tiny experiments or smoke tests. - [+ pass] numpy.torch_abi torch 2.4.0 + numpy 2.1.0 are ABI-compatible. + [! warn] torch.cuda torch 2.7.0 installed but CUDA is unavailable. CPU-only runs are supported but training will be very slow; expect to use this only for tiny experiments or smoke tests. + [+ pass] numpy.torch_abi torch 2.7.0 + numpy 2.1.0 are ABI-compatible. [! warn] gpu.inventory No CUDA devices visible; CPU-only mode. [! warn] extras.distributed Optional extra missing — install with: pip install 'forgelm[distributed]' (purpose: DeepSpeed ZeRO + offload distributed training (Linux + macOS only)). [+ pass] disk.workspace Workspace /home/me/forgelm — 387.0 GiB free of 500.0 GiB. @@ -145,12 +145,14 @@ The output is a stream of timestamped `[INFO]` lines from the `forgelm.*` logger ## 6. Try the model ```shell -$ forgelm chat ./checkpoints/customer-support +$ forgelm chat Qwen/Qwen2.5-7B-Instruct --adapter ./checkpoints/customer-support/final_model forgelm> how do I cancel my subscription? You can cancel from Settings → Billing → Cancel subscription. Your access continues until the end of the current billing period… ``` +The positional argument is the base model from the `Model` line of the run summary; `--adapter` points at the LoRA weights in `final_model/` and merges them into it for the session. If your run saved a merged checkpoint instead (`final_model/` holds full weights and no `adapter_model.safetensors`), pass that directory as the only argument: `forgelm chat ./checkpoints/customer-support/final_model`. + ## What you got on disk ```text diff --git a/docs/usermanuals/en/getting-started/installation.md b/docs/usermanuals/en/getting-started/installation.md index d5aad6d2..9a1677b1 100644 --- a/docs/usermanuals/en/getting-started/installation.md +++ b/docs/usermanuals/en/getting-started/installation.md @@ -22,7 +22,7 @@ ForgeLM ships as a single PyPI package with optional dependency groups (called * ::: :::note -**Intel Macs (x86_64) are not supported.** ForgeLM requires `torch>=2.4.0` (the minimum that `transformers` 5 supports), and PyPI publishes no `torch>=2.4` wheel for x86_64 macOS. Use Apple Silicon, Linux, or Windows. +**Intel Macs (x86_64) are not supported.** ForgeLM requires `torch>=2.7.0` (the first release with a dtype `transformers` 5.10 imports), and PyPI publishes no `torch>=2.7` wheel for x86_64 macOS. Use Apple Silicon, Linux, or Windows. ::: ## Base install diff --git a/docs/usermanuals/en/operations/air-gap.md b/docs/usermanuals/en/operations/air-gap.md index c22cff50..aab61050 100644 --- a/docs/usermanuals/en/operations/air-gap.md +++ b/docs/usermanuals/en/operations/air-gap.md @@ -111,7 +111,7 @@ $ forgelm doctor --offline forgelm doctor — environment check [✓ pass] python.version Python 3.11.4 (CPython). - [✓ pass] torch.cuda torch 2.4.0 with CUDA 12.4. + [✓ pass] torch.cuda torch 2.7.0 with CUDA 12.6. [✓ pass] gpu.inventory 1 GPU(s) — GPU0: NVIDIA A100 (80.0 GiB). [✓ pass] extras.qlora Installed (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [✓ pass] extras.eval Installed (module lm_eval, purpose: lm-evaluation-harness benchmark scoring). diff --git a/docs/usermanuals/en/operations/supply-chain.md b/docs/usermanuals/en/operations/supply-chain.md index 47c50c00..7641fa43 100644 --- a/docs/usermanuals/en/operations/supply-chain.md +++ b/docs/usermanuals/en/operations/supply-chain.md @@ -18,18 +18,21 @@ ForgeLM emits a CycloneDX 1.5 SBOM per release tag (as a workflow-run artifact, ## Where to get the SBOM ```bash -# Find the successful publish run for a tag, then download its SBOMs -# (one directory per artifact). Run artifacts expire after 90 days. +# Find the successful publish run for a tag, then download one SBOM into the +# current directory. Run artifacts expire after 90 days. (--pattern 'sbom-*' +# would fetch every SBOM instead, one sub-directory per artifact.) gh run list --repo HodeTech/ForgeLM --workflow publish.yml --branch v --status success \ --json databaseId,createdAt -gh run download --repo HodeTech/ForgeLM --pattern 'sbom-*' +gh run download --repo HodeTech/ForgeLM --name sbom-ubuntu-latest-py3.11 -# Pretty-print one. +# Pretty-print it. jq . sbom-ubuntu-latest-py3.11.json | less -# Diff between two releases (lists added/removed dependencies). -diff <(jq -S '.components | sort_by(.purl)' sbom-prev.json) \ - <(jq -S '.components | sort_by(.purl)' sbom.json) +# Diff between two releases (lists added/removed dependencies): download the +# previous release's SBOM into its own directory, then compare. +gh run download --repo HodeTech/ForgeLM --name sbom-ubuntu-latest-py3.11 --dir previous +diff <(jq -S '.components | sort_by(.purl)' previous/sbom-ubuntu-latest-py3.11.json) \ + <(jq -S '.components | sort_by(.purl)' sbom-ubuntu-latest-py3.11.json) # Ingest into Dependency-Track for CVE correlation. curl -X POST -H "X-Api-Key: $DT_KEY" -H "Content-Type: application/octet-stream" \ diff --git a/docs/usermanuals/tr/compliance/gdpr.md b/docs/usermanuals/tr/compliance/gdpr.md index 21c8f61b..152a5c50 100644 --- a/docs/usermanuals/tr/compliance/gdpr.md +++ b/docs/usermanuals/tr/compliance/gdpr.md @@ -76,10 +76,38 @@ Tarama, tanımlayıcının geçtiği satırları raporlar; sıfır eşleşme ge "Verilerimi silin." Bir kişinin verisi eğitim setinizdeydi: -1. Satırları `forgelm reverse-pii` ile bulun, sonra `forgelm purge --row-id --corpus ` ile kaldırın; her silme audit log'a yazılır. +1. Satırları `forgelm reverse-pii` ile bulun, sonra `forgelm purge --row-id --corpus ` ile kaldırın; her silme audit log'a yazılır. `--row-id` satırın `id` veya `row_id` alanıyla eşleştirilir, satır numarasıyla asla; `forgelm ingest` bu alanlardan hiçbirini yazmaz. Bu yüzden **eğitimden önce** her satıra kalıcı bir id verin (aşağıya bakın). Id'si olmayan bir corpus satır satır silinemez. 2. Ham kaynak dokümanlar hâlâ içerebilir — ham deponuzdan düşürün ve gerekirse yeniden ingest edin. 3. Model bazı detayları ezberlemiş olabilir — aşağıdaki "model-seviyesi silme"ye bakın. +Satırları adreslenebilir kılmak için corpus oluşturulurken her JSONL satırına bir kez benzersiz bir `id` verin ve dosya sonradan filtrelenir ya da yeniden sıralanırsa bu id'yi koruyun. `id`'si eksik, null ya da boş olan satırlar yeni bir id alır; `id`'sini (ya da `purge`'ın da eşleştirdiği `row_id`'yi) başka bir satırın da kullandığı satırda betik durur, çünkü `forgelm purge --row-id` iki satırı tek eşleşme olarak görür (varsayılanda reddeder, `--row-matches all` ile her eşleşmeyi siler) ve id'yi hangi satırın koruyacağına sizin karar vermeniz gerekir. Çıktı önce geçici bir dosyaya yazılır ve yalnızca her satır doğrulandıktan sonra yerine taşınır; böylece başarısız bir çalıştırma mevcut çıktı dosyasına dokunmaz: + +```python +import json, os, uuid + +SRC, DST = "data/train.jsonl", "data/train.with-ids.jsonl" +tmp, seen = DST + ".tmp", set() +try: + with open(SRC, encoding="utf-8") as src, open(tmp, "w", encoding="utf-8") as dst: + for line_no, line in enumerate(src, start=1): + if not line.strip(): + continue + row = json.loads(line) + if row.get("id") in (None, ""): + row["id"] = str(uuid.uuid4()) + ids = {str(row[key]) for key in ("id", "row_id") if row.get(key) not in (None, "")} + if ids & seen: + raise SystemExit(f"line {line_no}: id {sorted(ids & seen)[0]!r} is already used by another row") + seen |= ids + dst.write(json.dumps(row, ensure_ascii=False) + "\n") + os.replace(tmp, DST) # reached only when every row passed +finally: + if os.path.exists(tmp): + os.remove(tmp) +``` + +Eğitimi id'li dosyadan yapın. `forgelm reverse-pii` tanımlayıcının geçtiği dosyayı ve satırı raporlar; o satırın `id` değerini dosyadan okuyup `forgelm purge --row-id`'ye verin. Eğitimden sonra id'leri yeniden atamak audit log ile corpus arasındaki bağı koparır; bu yüzden bir kez atayın. + ### Model-seviyesi silme LLM'ler eğitimden nadir string'leri ezberleyebilir. PII maskelemeyle bile deploy edilmiş bir modelden tüm izleri kaldırmak zordur. ForgeLM'in savunmaları: diff --git a/docs/usermanuals/tr/getting-started/first-run.md b/docs/usermanuals/tr/getting-started/first-run.md index 46b1cd20..7fc6d6b4 100644 --- a/docs/usermanuals/tr/getting-started/first-run.md +++ b/docs/usermanuals/tr/getting-started/first-run.md @@ -33,8 +33,8 @@ $ forgelm doctor --offline forgelm doctor - environment check [+ pass] python.version Python 3.11.4 (CPython). - [! warn] torch.cuda torch 2.4.0 installed but CUDA is unavailable. CPU-only runs are supported but training will be very slow; expect to use this only for tiny experiments or smoke tests. - [+ pass] numpy.torch_abi torch 2.4.0 + numpy 2.1.0 are ABI-compatible. + [! warn] torch.cuda torch 2.7.0 installed but CUDA is unavailable. CPU-only runs are supported but training will be very slow; expect to use this only for tiny experiments or smoke tests. + [+ pass] numpy.torch_abi torch 2.7.0 + numpy 2.1.0 are ABI-compatible. [! warn] gpu.inventory No CUDA devices visible; CPU-only mode. [! warn] extras.distributed Optional extra missing — install with: pip install 'forgelm[distributed]' (purpose: DeepSpeed ZeRO + offload distributed training (Linux + macOS only)). [+ pass] disk.workspace Workspace /home/me/forgelm — 387.0 GiB free of 500.0 GiB. @@ -145,12 +145,14 @@ $ forgelm --config configs/quickstart-customer-support.yaml ## 6. Modeli dene ```shell -$ forgelm chat ./checkpoints/customer-support +$ forgelm chat Qwen/Qwen2.5-7B-Instruct --adapter ./checkpoints/customer-support/final_model forgelm> aboneliği nasıl iptal ederim? Aboneliğinizi Ayarlar → Faturalandırma → Aboneliği İptal Et adımlarıyla iptal edebilirsiniz. Erişiminiz mevcut faturalama döneminin sonuna kadar devam eder… ``` +Konumsal argüman, çalıştırma özetindeki `Model` satırında yazan temel modeldir; `--adapter`, `final_model/` içindeki LoRA ağırlıklarını gösterir ve oturum için temel modele birleştirir. Çalıştırmanız bunun yerine birleştirilmiş bir checkpoint kaydettiyse (`final_model/` tam ağırlıkları içerir ve `adapter_model.safetensors` yoktur), o dizini tek argüman olarak verin: `forgelm chat ./checkpoints/customer-support/final_model`. + ## Diskte ne var ```text diff --git a/docs/usermanuals/tr/getting-started/installation.md b/docs/usermanuals/tr/getting-started/installation.md index 5d5ecd01..f5e4c7cd 100644 --- a/docs/usermanuals/tr/getting-started/installation.md +++ b/docs/usermanuals/tr/getting-started/installation.md @@ -22,7 +22,7 @@ ForgeLM, PyPI'da tek bir paket olarak gelir; ağır özellikler için opsiyonel ::: :::note -**Intel Mac'ler (x86_64) desteklenmiyor.** ForgeLM `torch>=2.4.0` gerektirir (`transformers` 5'in desteklediği asgari sürüm) ve PyPI'da x86_64 macOS için `torch>=2.4` wheel'i yok. Apple Silicon, Linux veya Windows kullanın. +**Intel Mac'ler (x86_64) desteklenmiyor.** ForgeLM `torch>=2.7.0` gerektirir (`transformers` 5.10'un import ettiği bir dtype'a sahip ilk sürüm) ve PyPI'da x86_64 macOS için `torch>=2.7` wheel'i yok. Apple Silicon, Linux veya Windows kullanın. ::: ## Temel kurulum diff --git a/docs/usermanuals/tr/operations/air-gap.md b/docs/usermanuals/tr/operations/air-gap.md index 39ede22f..54792a27 100644 --- a/docs/usermanuals/tr/operations/air-gap.md +++ b/docs/usermanuals/tr/operations/air-gap.md @@ -111,7 +111,7 @@ $ forgelm doctor --offline forgelm doctor — environment check [✓ pass] python.version Python 3.11.4 (CPython). - [✓ pass] torch.cuda torch 2.4.0 with CUDA 12.4. + [✓ pass] torch.cuda torch 2.7.0 with CUDA 12.6. [✓ pass] gpu.inventory 1 GPU(s) — GPU0: NVIDIA A100 (80.0 GiB). [✓ pass] extras.qlora Installed (module bitsandbytes, purpose: 4-bit / 8-bit QLoRA training). [✓ pass] extras.eval Installed (module lm_eval, purpose: lm-evaluation-harness benchmark scoring). diff --git a/docs/usermanuals/tr/operations/supply-chain.md b/docs/usermanuals/tr/operations/supply-chain.md index a2d13134..bf42375f 100644 --- a/docs/usermanuals/tr/operations/supply-chain.md +++ b/docs/usermanuals/tr/operations/supply-chain.md @@ -18,18 +18,21 @@ ForgeLM sürüm tag'i başına bir CycloneDX 1.5 SBOM üretir (workflow-run arti ## SBOM nereden alınır ```bash -# Bir tag için başarılı publish çalıştırmasını bulun, sonra SBOM'larını indirin -# (artifact başına bir dizin). Run artifact'ları 90 günde silinir. +# Bir tag için başarılı publish çalıştırmasını bulun, sonra bir SBOM'u mevcut +# dizine indirin. Run artifact'ları 90 günde silinir. (--pattern 'sbom-*' +# bunun yerine tüm SBOM'ları indirir, artifact başına bir alt dizinle.) gh run list --repo HodeTech/ForgeLM --workflow publish.yml --branch v --status success \ --json databaseId,createdAt -gh run download --repo HodeTech/ForgeLM --pattern 'sbom-*' +gh run download --repo HodeTech/ForgeLM --name sbom-ubuntu-latest-py3.11 -# Birini güzel-yazdır. +# Güzel-yazdır. jq . sbom-ubuntu-latest-py3.11.json | less -# İki sürüm arası diff (eklenen/kaldırılan bağımlılıkları listeler). -diff <(jq -S '.components | sort_by(.purl)' sbom-prev.json) \ - <(jq -S '.components | sort_by(.purl)' sbom.json) +# İki sürüm arası diff (eklenen/kaldırılan bağımlılıkları listeler): önceki +# sürümün SBOM'unu kendi dizinine indirip karşılaştırın. +gh run download --repo HodeTech/ForgeLM --name sbom-ubuntu-latest-py3.11 --dir previous +diff <(jq -S '.components | sort_by(.purl)' previous/sbom-ubuntu-latest-py3.11.json) \ + <(jq -S '.components | sort_by(.purl)' sbom-ubuntu-latest-py3.11.json) # CVE korelasyonu için Dependency-Track'a yükle. curl -X POST -H "X-Api-Key: $DT_KEY" -H "Content-Type: application/octet-stream" \ diff --git a/forgelm/cli/_config_load.py b/forgelm/cli/_config_load.py index 8357d535..04c846e7 100644 --- a/forgelm/cli/_config_load.py +++ b/forgelm/cli/_config_load.py @@ -30,6 +30,13 @@ def _warn_reserved_retention_enforce(config) -> None: ) +def _reject_unsloth_unsupported_overrides(config) -> None: + """Fail at config load (exit 1, also under ``--dry-run``) when the unsloth backend cannot apply an override.""" + from ..model import _reject_unsloth_unsupported_overrides as reject + + reject(config) + + def _load_config_or_exit(config_path: str, json_output: bool): """Load config and translate exceptions into the right exit code. @@ -43,6 +50,7 @@ def _load_config_or_exit(config_path: str, json_output: bool): logger.info("Loading configuration from %s...", config_path) config = load_config(config_path) _warn_reserved_retention_enforce(config) + _reject_unsloth_unsupported_overrides(config) return config except FileNotFoundError as e: msg = f"Config file not found: {e}" diff --git a/forgelm/model.py b/forgelm/model.py index 4d96ea14..660bd064 100644 --- a/forgelm/model.py +++ b/forgelm/model.py @@ -277,6 +277,31 @@ def _unsloth_accepts_revision(loader: Any) -> bool: return any(name == "revision" and p.kind is not inspect.Parameter.VAR_KEYWORD for name, p in parameters.items()) +def _reject_unsloth_unsupported_overrides(config: Any) -> None: + """Refuse ``training.rope_scaling`` / ``training.sliding_window_attention`` on the unsloth backend. + + Both are edits to the transformers config (see :func:`_build_model_kwargs`), which this backend never builds; + honouring neither would train with the model's own RoPE / attention window under a YAML that asked for more. + The CLI calls this when the config loads, so ``--dry-run`` fails too; :func:`_load_unsloth` calls it again for + library callers. (It lives here, not in the schema, to keep ``config.py`` within its deferred-split line budget.) + """ + if getattr(getattr(config, "model", None), "backend", None) != "unsloth": + return + ignored = [ + name + for name in ("rope_scaling", "sliding_window_attention") + if getattr(getattr(config, "training", None), name, None) + ] + if ignored: + from .config import ConfigError + + raise ConfigError( + f"training.{' and training.'.join(ignored)} {'is' if len(ignored) == 1 else 'are'} not supported by " + f"the unsloth backend. Set model.backend: transformers to apply {'it' if len(ignored) == 1 else 'them'}, " + "or remove the setting." + ) + + def _load_unsloth(config: Any) -> Tuple[Any, Any]: """Load the model + tokenizer + LoRA via the Unsloth backend. @@ -287,6 +312,8 @@ def _load_unsloth(config: Any) -> Tuple[Any, Any]: and got a default-branch checkout would carry a manifest asserting a reproducibility property the run does not have. """ + _reject_unsloth_unsupported_overrides(config) + try: from unsloth import FastLanguageModel except ImportError as e: @@ -420,6 +447,41 @@ def _device_map_for(config: Any, is_distributed: bool): return None # CPU-only +def _load_model_config(name_or_path: str, *, trust_remote_code: bool, revision: Optional[str] = None) -> Any: + """Load the checkpoint's config object (the one every override below edits before the weights are built).""" + from transformers import AutoConfig + + return AutoConfig.from_pretrained(name_or_path, trust_remote_code=trust_remote_code, revision=revision) + + +def _text_config(model_config: Any) -> Any: + """The sub-config that holds the decoder's settings: the config itself, or a multimodal model's text config.""" + return model_config.get_text_config() if hasattr(model_config, "get_text_config") else model_config + + +def _merge_rope_parameters(current: dict, requested: dict) -> dict: + """Merge the requested RoPE keys over the model's own ``rope_parameters``. + + Most models keep one flat dictionary. Some (Gemma 3) keep one entry per layer type, ``full_attention`` and + ``sliding_attention``, each with its own ``rope_type`` / ``rope_theta``; a flat key added next to those entries is + not read by either layer type, so the scaling would be silently ignored. Context-length scaling belongs to the + global-attention layers, so for such a model the requested keys go into the ``full_attention`` entry. A model + with per-layer-type entries and no ``full_attention`` one has nowhere to put the request, which is refused + rather than guessed. + """ + per_layer_type = {key: value for key, value in current.items() if isinstance(value, dict)} + if not per_layer_type: + return {**current, **requested} + if "full_attention" not in per_layer_type: + from .config import ConfigError + + raise ConfigError( + "training.rope_scaling cannot be applied: this model keeps its RoPE settings per layer type " + f"({', '.join(sorted(per_layer_type))}) and has no 'full_attention' entry to scale." + ) + return {**current, "full_attention": {**per_layer_type["full_attention"], **requested}} + + def _config_with_rope_scaling( name_or_path: str, rope_scaling: dict, *, trust_remote_code: bool, revision: Optional[str] = None ) -> Any: @@ -429,26 +491,67 @@ def _config_with_rope_scaling( expects the key ``rope_type``. Passing ``rope_scaling=`` to ``from_pretrained`` no longer works: the documented ``{type: linear, factor: 4}`` raises ``KeyError: 'rope_type'`` while the model is built, and ``{rope_type: ..., factor: ...}`` replaces the whole dict so ``rope_theta`` is lost (``TypeError`` in the - frequency computation). So the requested keys are merged *over* the model's own ``rope_parameters``, the legacy - key ``type`` is read as ``rope_type``, and the finished config object is what gets loaded. + frequency computation). So the requested keys are merged *over* the model's own ``rope_parameters`` (see + :func:`_merge_rope_parameters` for models with one entry per layer type), the legacy key ``type`` is read as + ``rope_type``, and the finished config object is what gets loaded. For a multimodal checkpoint the RoPE settings live in the text sub-config, which is where they are applied. A transformers without ``rope_parameters`` gets the legacy ``rope_scaling`` attribute instead. """ - from transformers import AutoConfig - - model_config = AutoConfig.from_pretrained(name_or_path, trust_remote_code=trust_remote_code, revision=revision) - target = model_config.get_text_config() if hasattr(model_config, "get_text_config") else model_config + model_config = _load_model_config(name_or_path, trust_remote_code=trust_remote_code, revision=revision) + target = _text_config(model_config) requested = dict(rope_scaling) if "type" in requested and "rope_type" not in requested: requested["rope_type"] = requested.pop("type") if hasattr(target, "rope_parameters"): - target.rope_parameters = {**(getattr(target, "rope_parameters", None) or {}), **requested} + target.rope_parameters = _merge_rope_parameters(dict(getattr(target, "rope_parameters", None) or {}), requested) else: target.rope_scaling = requested return model_config +def _with_sliding_window(model_config: Any, window: int) -> Any: + """Apply ``training.sliding_window_attention`` to the text config and return the config to load. + + The override has to reach the *text* config: a multimodal model (Qwen2-VL) keeps ``sliding_window`` there, not on + the outer config, so the former ``from_pretrained(sliding_window=...)`` keyword did not reach it. Models such as + Qwen2 also derive a per-layer ``layer_types`` list from ``use_sliding_window``, ``sliding_window`` and + ``max_window_layers`` when the config is built, so setting the attribute afterwards leaves every layer on full + attention. For those the text config is rebuilt from its own dictionary with the flag on and ``layer_types`` + cleared, which makes the config derive the list again; a ``layer_types`` that differs from what the config would + derive itself is a pattern the checkpoint supplied, and is kept. Models without ``use_sliding_window`` (Mistral, + Gemma 3) keep their layer pattern and only get the new window size. + """ + text = _text_config(model_config) + if not hasattr(text, "use_sliding_window"): + text.sliding_window = window + return model_config + + updates: Dict[str, Any] = {"sliding_window": window, "use_sliding_window": True} + original = text.to_dict() + if getattr(text, "layer_types", None) is not None: + derived = type(text).from_dict({**original, "layer_types": None}).layer_types + if list(text.layer_types) == list(derived): + updates["layer_types"] = None # the config computed this list itself: compute it again for the new window + rebuilt = type(text).from_dict({**original, **updates}) + if "sliding_attention" not in (getattr(rebuilt, "layer_types", None) or ["sliding_attention"]): + logger.warning( + "training.sliding_window_attention=%d has no effect on this model: no layer is a sliding-attention layer " + "(layer_types=%s). Models of this family switch layers to sliding attention from max_window_layers upwards.", + window, + list(rebuilt.layer_types), + ) + if text is model_config: + return rebuilt + for name in getattr(model_config, "sub_configs", {}): + if getattr(model_config, name, None) is text: + setattr(model_config, name, rebuilt) + return model_config + for key in updates: # a nested config the outer one does not declare: edit it in place, with the derived list + setattr(text, key, getattr(rebuilt, key)) + return model_config + + def _build_model_kwargs(config: Any, trust_remote_code: bool, revision: Optional[str] = None) -> dict: """Assemble from_pretrained kwargs (device_map, BnB, RoPE, sliding window, revision). @@ -491,20 +594,22 @@ def _build_model_kwargs(config: Any, trust_remote_code: bool, revision: Optional rope_scaling = config.training.rope_scaling sliding_window = config.training.sliding_window_attention - if rope_scaling: - logger.info("RoPE scaling enabled: %s", rope_scaling) - model_config = _config_with_rope_scaling( - config.model.name_or_path, rope_scaling, trust_remote_code=trust_remote_code, revision=revision - ) + if rope_scaling or sliding_window: + # Both overrides are edits to the model's config. Once ``config=`` is a finished object, ``from_pretrained`` + # hands unrecognised keyword arguments to the model constructor, so neither can travel as a keyword. + if rope_scaling: + logger.info("RoPE scaling enabled: %s", rope_scaling) + model_config = _config_with_rope_scaling( + config.model.name_or_path, rope_scaling, trust_remote_code=trust_remote_code, revision=revision + ) + else: + model_config = _load_model_config( + config.model.name_or_path, trust_remote_code=trust_remote_code, revision=revision + ) if sliding_window: - # Once ``config=`` is a finished object, from_pretrained hands unrecognised keyword arguments to the - # model constructor instead of the config, so the override is applied to the config itself. logger.info("Sliding window attention override: %d tokens", sliding_window) - model_config.sliding_window = sliding_window + model_config = _with_sliding_window(model_config, sliding_window) kwargs["config"] = model_config - elif sliding_window: - logger.info("Sliding window attention override: %d tokens", sliding_window) - kwargs["sliding_window"] = sliding_window return kwargs diff --git a/pyproject.toml b/pyproject.toml index 2bdd3ddf..6c754305 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,13 +43,19 @@ classifiers = [ ] dependencies = [ - "torch>=2.4.0,<3.0.0", - # transformers 5.x requires torch>=2.4. PyTorch Foundation never published - # >=2.3 wheels for x86_64 Darwin, so Intel Macs (x86_64) can no longer - # install ForgeLM's core dependency stack — that platform was dropped with - # the transformers-5 migration (v0.9.0). Apple Silicon, Linux and Windows - # are unaffected. The previous `numpy<2; darwin x86_64` ABI-guard marker is - # gone with the platform it protected (torch 2.4+ ships against NumPy 2.x). + "torch>=2.7.0,<3.0.0", + # transformers 5.10.1 (the security floor below) imports `torch.float8_e8m0fnu` + # when `transformers.modeling_utils` loads, and that dtype first exists in + # torch 2.7.0 (2.6.0 raises AttributeError, which breaks every model import). + # transformers' own metadata lists torch only as an extra, so pip would not + # upgrade an older torch for you; the floor has to be stated here. The + # nightly minimum-versions job installs exactly this version and caught the + # earlier `torch>=2.4.0`. PyTorch Foundation never published >=2.3 wheels + # for x86_64 Darwin, so Intel Macs (x86_64) can no longer install ForgeLM's + # core dependency stack — that platform was dropped with the transformers-5 + # migration (v0.9.0). Apple Silicon, Linux and Windows are unaffected. The + # previous `numpy<2; darwin x86_64` ABI-guard marker is gone with the + # platform it protected (torch 2.4+ ships against NumPy 2.x). # 5.10.1 is the first installable release past three advisories that reach ForgeLM's own load and save path from # a Hub repo: CVE-2026-4372 (fixed 5.3.0), CVE-2026-5241 (a LightGlue config.json overrides trust_remote_code; # fixed 5.5.0) and CVE-2026-9856 (chat_template names used unsanitised as file names by save_pretrained; fixed diff --git a/tests/test_assign_on_comment.py b/tests/test_assign_on_comment.py index 492d1454..c6931ab8 100644 --- a/tests/test_assign_on_comment.py +++ b/tests/test_assign_on_comment.py @@ -67,38 +67,62 @@ def __call__(self, method: str, endpoint: str, payload: Any = None) -> Any: for (m, prefix), reason in self.fail.items(): if m == method and endpoint.startswith(prefix): raise tool.GhError(f"{method} {endpoint} failed: {reason}") - if method == "GET" and endpoint == f"repos/{REPO}/issues/7": - self._reads += 1 - answer = self._issue() - if self._reads == 1 and self.after_first_read: - self.after_first_read() - return answer - if method == "GET" and "?assignee=" in endpoint: - login = endpoint.split("assignee=")[1].split("&")[0] - if self.assigned_pages is not None: - page = int(endpoint.rsplit("page=", 1)[1]) - return self.assigned_pages[page - 1] if page <= len(self.assigned_pages) else [] - return [{"number": 100 + i} for i in range(self.held.get(login, 0))] + [ - {"number": 999, "pull_request": {}} # a PR assigned to them must not count - ] - if method == "GET" and "/comments?" in endpoint: - return [{"user": {"type": "Bot"}, "body": b} for b in self.comments] + [ - {"user": {"type": "User"}, "body": b} for b in self.human_comments - ] - if method == "POST" and endpoint.endswith("/assignees"): - if not self.drop_assignment: - self.assignees += [a for a in payload["assignees"] if a not in self.assignees] - if self.after_assign: - self.after_assign() - return self._issue() - if method == "DELETE" and endpoint.endswith("/assignees"): - if not self.refuse_delete: - self.assignees = [a for a in self.assignees if a not in payload["assignees"]] - return self._issue() - if method == "POST" and endpoint.endswith("/comments"): - self.comments.append(payload["body"]) - return {} - raise AssertionError(f"unexpected call {method} {endpoint}") + handler = self._route(method, endpoint) + if handler is None: + raise AssertionError(f"unexpected call {method} {endpoint}") + return handler(endpoint, payload) + + def _route(self, method: str, endpoint: str): + """The handler that plays the GitHub endpoint being called, or None for one the tool should never hit.""" + if method == "GET": + if endpoint == f"repos/{REPO}/issues/7": + return self._get_issue + if "?assignee=" in endpoint: + return self._get_assigned_issues + if "/comments?" in endpoint: + return self._get_comments + elif endpoint.endswith("/assignees"): + return {"POST": self._add_assignees, "DELETE": self._remove_assignees}.get(method) + elif method == "POST" and endpoint.endswith("/comments"): + return self._post_comment + return None + + def _get_issue(self, endpoint: str, payload: Any) -> Dict[str, Any]: + self._reads += 1 + answer = self._issue() + if self._reads == 1 and self.after_first_read: + self.after_first_read() + return answer + + def _get_assigned_issues(self, endpoint: str, payload: Any) -> List[Dict[str, Any]]: + login = endpoint.split("assignee=")[1].split("&")[0] + if self.assigned_pages is not None: + page = int(endpoint.rsplit("page=", 1)[1]) + return self.assigned_pages[page - 1] if page <= len(self.assigned_pages) else [] + return [{"number": 100 + i} for i in range(self.held.get(login, 0))] + [ + {"number": 999, "pull_request": {}} # a PR assigned to them must not count + ] + + def _get_comments(self, endpoint: str, payload: Any) -> List[Dict[str, Any]]: + return [{"user": {"type": "Bot"}, "body": b} for b in self.comments] + [ + {"user": {"type": "User"}, "body": b} for b in self.human_comments + ] + + def _add_assignees(self, endpoint: str, payload: Any) -> Dict[str, Any]: + if not self.drop_assignment: + self.assignees += [a for a in payload["assignees"] if a not in self.assignees] + if self.after_assign: + self.after_assign() + return self._issue() + + def _remove_assignees(self, endpoint: str, payload: Any) -> Dict[str, Any]: + if not self.refuse_delete: + self.assignees = [a for a in self.assignees if a not in payload["assignees"]] + return self._issue() + + def _post_comment(self, endpoint: str, payload: Any) -> Dict[str, Any]: + self.comments.append(payload["body"]) + return {} def writes(self) -> List[tuple]: """Every call that changes an assignment (comments are not assignments).""" diff --git a/tests/test_cli.py b/tests/test_cli.py index eedc6395..b9fb69ac 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -489,3 +489,32 @@ def test_loading_a_config_file_triggers_it(self, tmp_path, caplog, minimal_confi with caplog.at_level(logging.WARNING): _load_config_or_exit(str(path), json_output=False) assert any("retention.enforce" in r.getMessage() for r in caplog.records) + + +class TestUnslothUnsupportedOverridesRejectedAtLoad: + """`rope_scaling` / `sliding_window_attention` need a transformers config the unsloth backend never builds (#103).""" + + @pytest.mark.parametrize( + "training", + [{"rope_scaling": {"type": "linear", "factor": 2.0}}, {"sliding_window_attention": 4096}], + ) + def test_config_load_exits_1_so_dry_run_fails_before_training(self, tmp_path, minimal_config, caplog, training): + import logging + + from forgelm.cli._config_load import _load_config_or_exit + + path = tmp_path / "c.yaml" + path.write_text( + yaml.dump(minimal_config(model={"name_or_path": "org/model", "backend": "unsloth"}, training=training)) + ) + with caplog.at_level(logging.ERROR), pytest.raises(SystemExit) as exc: + _load_config_or_exit(str(path), json_output=False) + assert exc.value.code == 1 + assert "not supported by the unsloth backend" in caplog.text + + def test_the_transformers_backend_still_loads(self, tmp_path, minimal_config): + from forgelm.cli._config_load import _load_config_or_exit + + path = tmp_path / "c.yaml" + path.write_text(yaml.dump(minimal_config(training={"rope_scaling": {"type": "linear", "factor": 2.0}}))) + assert _load_config_or_exit(str(path), json_output=False).training.rope_scaling is not None diff --git a/tests/test_model.py b/tests/test_model.py index cac537e0..92e627a2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -115,16 +115,25 @@ def test_quantization_config_added_with_cuda(self): assert "quantization_config" in kwargs fake_bnb.assert_called_once() - def test_sliding_window_alone_is_a_keyword_argument(self): + def test_sliding_window_alone_travels_in_a_config_object_not_a_keyword(self): + # With ``config=`` set, from_pretrained forwards unknown keywords to the model constructor, so the override + # is an edit to the (text) config, never ``sliding_window=...``. + model_config = SimpleNamespace(sliding_window=None) + fake_transformers = MagicMock() + fake_transformers.AutoConfig.from_pretrained.return_value = model_config cfg = _cfg( model={"name_or_path": "org/model", "load_in_4bit": False}, training={"sliding_window_attention": 4096}, ) - with patch("torch.cuda.is_available", return_value=False): + with ( + patch("torch.cuda.is_available", return_value=False), + patch.dict("sys.modules", {"transformers": fake_transformers}), + ): kwargs = model_mod._build_model_kwargs(cfg, trust_remote_code=False) - assert kwargs["sliding_window"] == 4096 and "config" not in kwargs + assert "sliding_window" not in kwargs and "rope_scaling" not in kwargs + assert kwargs["config"] is model_config and model_config.sliding_window == 4096 - def test_rope_scaling_travels_in_a_config_object_not_a_keyword(self): + def test_rope_scaling_and_sliding_window_travel_in_one_config_object(self): # transformers 5 no longer accepts `rope_scaling=` on from_pretrained (#103): the settings go into the # config, together with the sliding-window override, which would otherwise reach the model constructor. model_config = SimpleNamespace(rope_parameters={"rope_theta": 10000.0, "rope_type": "default"}) @@ -147,6 +156,12 @@ def test_rope_scaling_travels_in_a_config_object_not_a_keyword(self): "org/model", trust_remote_code=False, revision="abc123" ) + def test_no_overrides_leave_the_config_to_from_pretrained(self): + cfg = _cfg(model={"name_or_path": "org/model", "load_in_4bit": False}) + with patch("torch.cuda.is_available", return_value=False): + kwargs = model_mod._build_model_kwargs(cfg, trust_remote_code=False) + assert "config" not in kwargs and "sliding_window" not in kwargs + class TestConfigWithRopeScaling: """Against a real (tiny, local, CPU) Llama config: the merged settings must build and run a model (#103).""" @@ -215,6 +230,180 @@ def test_multimodal_settings_land_in_the_text_config(self): ) assert out is outer and text.rope_parameters["rope_type"] == "linear" + def test_nested_per_layer_type_parameters_scale_the_full_attention_entry(self): + # Gemma 3 keeps one rope_parameters entry per layer type; a flat key next to them is read by neither. + text = SimpleNamespace( + rope_parameters={ + "full_attention": {"rope_type": "default", "rope_theta": 1000000.0}, + "sliding_attention": {"rope_type": "default", "rope_theta": 10000.0}, + } + ) + outer = SimpleNamespace(get_text_config=lambda: text) + fake_transformers = MagicMock() + fake_transformers.AutoConfig.from_pretrained.return_value = outer + with patch.dict("sys.modules", {"transformers": fake_transformers}): + model_mod._config_with_rope_scaling("org/gemma", {"type": "linear", "factor": 8.0}, trust_remote_code=False) + assert text.rope_parameters["full_attention"] == {"rope_type": "linear", "rope_theta": 1000000.0, "factor": 8.0} + assert text.rope_parameters["sliding_attention"] == {"rope_type": "default", "rope_theta": 10000.0} + assert "factor" not in text.rope_parameters and "rope_type" not in text.rope_parameters + + def test_per_layer_type_parameters_without_full_attention_are_refused(self): + from forgelm.config import ConfigError + + text = SimpleNamespace(rope_parameters={"sliding_attention": {"rope_type": "default", "rope_theta": 10000.0}}) + fake_transformers = MagicMock() + fake_transformers.AutoConfig.from_pretrained.return_value = text + with patch.dict("sys.modules", {"transformers": fake_transformers}): + with pytest.raises(ConfigError, match="full_attention"): + model_mod._config_with_rope_scaling( + "org/odd", {"type": "linear", "factor": 2.0}, trust_remote_code=False + ) + + def test_a_real_gemma3_text_config_is_scaled_on_global_layers_only(self): + from transformers import Gemma3TextConfig + + config = Gemma3TextConfig(num_hidden_layers=6, hidden_size=32, intermediate_size=64, num_attention_heads=2) + sliding_before = dict(config.rope_parameters["sliding_attention"]) + fake_transformers = MagicMock() + fake_transformers.AutoConfig.from_pretrained.return_value = config + with patch.dict("sys.modules", {"transformers": fake_transformers}): + out = model_mod._config_with_rope_scaling( + "org/gemma3", {"type": "linear", "factor": 8.0}, trust_remote_code=False + ) + assert out.rope_parameters["full_attention"]["rope_type"] == "linear" + assert out.rope_parameters["full_attention"]["factor"] == 8.0 + assert out.rope_parameters["sliding_attention"] == sliding_before + + +class TestWithSlidingWindow: + """``_with_sliding_window`` edits the text config and keeps the derived per-layer pattern consistent.""" + + @staticmethod + def _qwen2(): + from transformers import Qwen2Config + + return Qwen2Config( + vocab_size=64, + hidden_size=32, + intermediate_size=64, + num_hidden_layers=4, + num_attention_heads=2, + num_key_value_heads=2, + max_window_layers=2, + ) + + def test_qwen2_layer_types_are_rederived_so_the_window_takes_effect(self): + config = self._qwen2() + assert set(config.layer_types) == {"full_attention"} + out = model_mod._with_sliding_window(config, 16) + assert out.sliding_window == 16 and out.use_sliding_window is True + assert out.layer_types == ["full_attention", "full_attention", "sliding_attention", "sliding_attention"] + + def test_qwen2_window_changes_what_the_model_computes(self): + # Same weights, same input: only attention over the last positions may differ, and only if the window is live. + import torch + from transformers import AutoModelForCausalLM + + torch.manual_seed(0) + full = AutoModelForCausalLM.from_config(self._qwen2()).eval() + windowed_config = model_mod._with_sliding_window(self._qwen2(), 4) + assert windowed_config.sliding_window == 4 + assert windowed_config.layer_types == [ + "full_attention", + "full_attention", + "sliding_attention", + "sliding_attention", + ] + windowed = AutoModelForCausalLM.from_config(windowed_config).eval() + windowed.load_state_dict(full.state_dict()) + input_ids = torch.arange(1, 13).unsqueeze(0) + with torch.no_grad(): + full_logits, windowed_logits = full(input_ids=input_ids).logits, windowed(input_ids=input_ids).logits + assert tuple(windowed_logits.shape) == (1, 12, 64) + assert torch.allclose(full_logits[:, :4], windowed_logits[:, :4], atol=1e-5) # inside the window: identical + assert not torch.allclose(full_logits[:, 11], windowed_logits[:, 11], atol=1e-5) # beyond it: attention differs + + def test_rebuilding_for_the_window_keeps_the_rope_scaling_applied_before_it(self): + config = self._qwen2() + config.rope_parameters = {"rope_theta": 10000.0, "rope_type": "linear", "factor": 4.0} + out = model_mod._with_sliding_window(config, 16) + assert out.rope_parameters == {"rope_theta": 10000.0, "rope_type": "linear", "factor": 4.0} + assert out.sliding_window == 16 and out.use_sliding_window is True + assert out.layer_types == ["full_attention", "full_attention", "sliding_attention", "sliding_attention"] + + def test_a_nested_text_config_the_outer_one_does_not_declare_is_edited_in_place_with_derived_layer_types(self): + text = self._qwen2() + outer = SimpleNamespace(get_text_config=lambda: text) # no ``sub_configs`` to swap the rebuilt config into + out = model_mod._with_sliding_window(outer, 16) + assert out is outer + assert text.sliding_window == 16 and text.use_sliding_window is True + assert text.layer_types == ["full_attention", "full_attention", "sliding_attention", "sliding_attention"] + + def test_a_layer_pattern_the_checkpoint_supplied_is_kept(self): + from transformers import Qwen2Config + + custom = ["full_attention", "sliding_attention", "full_attention", "sliding_attention"] + config = Qwen2Config( + vocab_size=64, + hidden_size=32, + intermediate_size=64, + num_hidden_layers=4, + num_attention_heads=2, + num_key_value_heads=2, + max_window_layers=2, + layer_types=custom, + ) + assert list(config.layer_types) == custom # differs from the derived ["full", "full", "sliding", "sliding"] + out = model_mod._with_sliding_window(config, 16) + assert out.sliding_window == 16 and out.use_sliding_window is True + assert list(out.layer_types) == custom + + def test_a_window_that_leaves_every_layer_on_full_attention_warns(self, caplog): + config = self._qwen2() + config.max_window_layers = 4 # sliding attention starts at layer 4 of 4: none + with caplog.at_level("WARNING", logger="forgelm.model"): + out = model_mod._with_sliding_window(config, 16) + assert "sliding_attention" not in out.layer_types + assert "has no effect" in caplog.text + + def test_multimodal_window_lands_in_the_text_config(self): + from transformers import Qwen2VLConfig + + outer = Qwen2VLConfig( + text_config={ + "vocab_size": 64, + "hidden_size": 32, + "intermediate_size": 64, + "num_hidden_layers": 4, + "num_attention_heads": 2, + "num_key_value_heads": 2, + "max_window_layers": 2, + } + ) + out = model_mod._with_sliding_window(outer, 16) + assert out is outer + text = out.get_text_config() + assert text.sliding_window == 16 and text.use_sliding_window is True + assert "sliding_attention" in text.layer_types + assert out.text_config is text + + def test_mistral_only_gets_the_window_size(self): + from transformers import MistralConfig + + config = MistralConfig( + vocab_size=64, hidden_size=32, intermediate_size=64, num_hidden_layers=2, num_attention_heads=2 + ) + out = model_mod._with_sliding_window(config, 16) + assert out is config and out.sliding_window == 16 + + def test_gemma3_keeps_its_explicit_layer_pattern(self): + from transformers import Gemma3TextConfig + + config = Gemma3TextConfig(num_hidden_layers=6, hidden_size=32, intermediate_size=64, num_attention_heads=2) + pattern = list(config.layer_types) + out = model_mod._with_sliding_window(config, 256) + assert out.sliding_window == 256 and list(out.layer_types) == pattern + class TestLoadTokenizer: """``_load_tokenizer`` ensures a pad_token and routes VLMs to AutoProcessor.""" @@ -563,6 +752,27 @@ class _Loader: assert model_mod._unsloth_accepts_revision(_Loader) is False + @pytest.mark.parametrize( + "training, named", + [ + ({"rope_scaling": {"type": "linear", "factor": 2.0}}, "training.rope_scaling is"), + ({"sliding_window_attention": 4096}, "training.sliding_window_attention is"), + ( + {"rope_scaling": {"type": "linear", "factor": 2.0}, "sliding_window_attention": 4096}, + "training.rope_scaling and training.sliding_window_attention are", + ), + ], + ) + def test_config_overrides_the_backend_cannot_apply_are_refused_before_any_load(self, training, named): + from forgelm.config import ConfigError + + cfg = _cfg(model={"name_or_path": "org/model", "backend": "unsloth"}, training=training) + loader = MagicMock() + with patch.dict("sys.modules", {"unsloth": MagicMock(FastLanguageModel=loader)}): + with pytest.raises(ConfigError, match=f"{named} not supported by the unsloth backend"): + model_mod._load_unsloth(cfg) + loader.from_pretrained.assert_not_called() + def test_pin_on_unsupported_unsloth_fails_closed(self, monkeypatch): _stub_resolver(monkeypatch, revision_resolved=SHA_A, resolution_source="pinned_resolved")