From 62dc089d033bd5ca1d730f6e173ebafcd55db449 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 19 Oct 2026 14:01:44 +0300 Subject: [PATCH 1/4] fix: torch floor 2.7.0, Gemma 3 RoPE and text-config sliding window, doc corrections Nightly Compatibility (run 39223936514): the minimum-versions job installed torch 2.4.0 next to transformers 5.10.1, whose modeling_utils imports torch.float8_e8m0fnu; that dtype first exists in torch 2.7.0, so `import transformers` raised AttributeError. transformers lists torch only as an extra, so pip never raised the floor. pyproject, the nightly pin, the installation pages and the doctor samples now say torch>=2.7.0. Review findings on the development -> main promotion PR: - model.py: training.rope_scaling on a model with per-layer-type rope_parameters (Gemma 3) is applied to the full_attention entry; a model with such entries and no full_attention is refused (ConfigError, exit 1). - model.py: training.sliding_window_attention is applied to the text config (Qwen2-VL keeps it there), sets use_sliding_window and re-derives layer_types for the Qwen2 family, warns when no layer ends up sliding, and travels in the same config object as rope_scaling. - publish.yml: both download-artifact steps pinned to the v8.0.2 commit SHA. - CHANGELOG: drop the unverified "5.3-5.9" claim from the RoPE entry. - gdpr (EN+TR): explain assigning stable ids before training so that `forgelm purge --row-id` can address rows. - supply-chain (EN+TR): download and diff paths match `gh run download`. - first-run (EN+TR): `forgelm chat --adapter `. - tests: FakeGh in test_assign_on_comment split into per-endpoint handlers (CodeFactor "Complex Method"); real Gemma 3 / Qwen2 / Qwen2-VL / Mistral config tests for the two overrides. Refs #103 Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/nightly.yml | 6 +- .github/workflows/publish.yml | 4 +- CHANGELOG.md | 21 ++- docs/guides/air_gap_deployment-tr.md | 4 +- docs/guides/air_gap_deployment.md | 4 +- docs/guides/getting-started-tr.md | 4 +- docs/guides/getting-started.md | 4 +- docs/reference/doctor_subcommand-tr.md | 2 +- docs/reference/doctor_subcommand.md | 2 +- docs/usermanuals/en/compliance/gdpr.md | 16 +- .../en/getting-started/first-run.md | 8 +- .../en/getting-started/installation.md | 2 +- docs/usermanuals/en/operations/air-gap.md | 2 +- .../usermanuals/en/operations/supply-chain.md | 17 +- docs/usermanuals/tr/compliance/gdpr.md | 16 +- .../tr/getting-started/first-run.md | 8 +- .../tr/getting-started/installation.md | 2 +- docs/usermanuals/tr/operations/air-gap.md | 2 +- .../usermanuals/tr/operations/supply-chain.md | 17 +- forgelm/model.py | 110 ++++++++++--- pyproject.toml | 20 ++- tests/test_assign_on_comment.py | 88 ++++++---- tests/test_model.py | 152 +++++++++++++++++- 23 files changed, 407 insertions(+), 104 deletions(-) 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..12b7ac0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -176,9 +176,24 @@ 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, and a warning is logged + when the result still has no sliding-attention layer (#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/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..78803607 100644 --- a/docs/usermanuals/en/compliance/gdpr.md +++ b/docs/usermanuals/en/compliance/gdpr.md @@ -76,10 +76,24 @@ 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, add an `id` to each JSONL row once, when the corpus is created, and keep it when the file is later filtered or re-ordered: + +```python +import json, uuid + +with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-ids.jsonl", "w", encoding="utf-8") as dst: + for line in src: + row = json.loads(line) + row.setdefault("id", str(uuid.uuid4())) + dst.write(json.dumps(row, ensure_ascii=False) + "\n") +``` + +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..3bbd0449 100644 --- a/docs/usermanuals/tr/compliance/gdpr.md +++ b/docs/usermanuals/tr/compliance/gdpr.md @@ -76,10 +76,24 @@ 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 `id` ekleyin ve dosya sonradan filtrelenir ya da yeniden sıralanırsa bu id'yi koruyun: + +```python +import json, uuid + +with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-ids.jsonl", "w", encoding="utf-8") as dst: + for line in src: + row = json.loads(line) + row.setdefault("id", str(uuid.uuid4())) + dst.write(json.dumps(row, ensure_ascii=False) + "\n") +``` + +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/model.py b/forgelm/model.py index 4d96ea14..8ecda382 100644 --- a/forgelm/model.py +++ b/forgelm/model.py @@ -420,6 +420,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 +464,63 @@ 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. 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} + if hasattr(text, "layer_types"): + updates["layer_types"] = None # derived again from the three fields above + rebuilt = type(text).from_dict({**text.to_dict(), **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, value in updates.items(): # a nested config the outer one does not declare: edit it in place + setattr(text, key, value) + 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 +563,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_model.py b/tests/test_model.py index cac537e0..159d696c 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,135 @@ 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_rebuilt_config_builds_a_model_that_runs(self): + import torch + from transformers import AutoModelForCausalLM + + out = model_mod._with_sliding_window(self._qwen2(), 8) + logits = AutoModelForCausalLM.from_config(out)(input_ids=torch.arange(1, 13).unsqueeze(0)).logits + assert tuple(logits.shape) == (1, 12, 64) + + 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} + + 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.""" From e22e1ba310105b87f58ddbd9c47e99e44ceaee99 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 19 Oct 2026 14:17:42 +0300 Subject: [PATCH 2/4] fix(model): refuse rope/sliding-window overrides on unsloth, repair nested fallback, harden tests and GDPR example Review follow-up on #1019 (each comment verified against the code): - unsloth: _load_unsloth never builds a transformers config, so training.rope_scaling and training.sliding_window_attention were silently ignored; both are now refused with ConfigError (exit 1) before any load, and the configuration reference (EN+TR) says so. - _with_sliding_window: the in-place fallback for a text config the outer config does not declare in sub_configs set layer_types to None instead of the derived list; it now copies every updated field from the rebuilt config. - tests: the Qwen2 sliding-window tests assert the window and layer types, and a same-weights comparison shows the window changes what the model computes; new tests for the nested fallback and the unsloth refusal. Mutation-checked. - gdpr (EN+TR): the id-assignment example replaces null/empty ids and stops on an id another row already uses (purge --row-matches one refuses ambiguity, all erases every match). - Not changed: loading the config and the weights separately at the same `revision=pin`. The pin is the resolved commit SHA whenever one can be confirmed; otherwise the module deliberately falls back to the operator's literal and records that no SHA was verified, exactly as it does for the tokenizer and weights loads. Refs #103, #1018 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 +- docs/reference/configuration-tr.md | 4 +- docs/reference/configuration.md | 4 +- docs/usermanuals/en/compliance/gdpr.md | 13 ++++-- docs/usermanuals/tr/compliance/gdpr.md | 13 ++++-- forgelm/model.py | 19 ++++++++- tests/test_model.py | 55 ++++++++++++++++++++++++-- 7 files changed, 95 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b7ac0c..39444c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,7 +187,9 @@ All notable changes to ForgeLM are documented here. 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, and a warning is logged - when the result still has no sliding-attention layer (#103). + 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 (exit 1) 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 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/usermanuals/en/compliance/gdpr.md b/docs/usermanuals/en/compliance/gdpr.md index 78803607..992856c1 100644 --- a/docs/usermanuals/en/compliance/gdpr.md +++ b/docs/usermanuals/en/compliance/gdpr.md @@ -80,15 +80,22 @@ If a person's data was in your training set: 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, add an `id` to each JSONL row once, when the corpus is created, and keep it when the file is later filtered or re-ordered: +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` 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: ```python import json, uuid +seen = set() with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-ids.jsonl", "w", encoding="utf-8") as dst: - for line in src: + for line_no, line in enumerate(src, start=1): + if not line.strip(): + continue row = json.loads(line) - row.setdefault("id", str(uuid.uuid4())) + if row.get("id") in (None, ""): + row["id"] = str(uuid.uuid4()) + if str(row["id"]) in seen: + raise SystemExit(f"line {line_no}: id {row['id']!r} is already used by another row") + seen.add(str(row["id"])) dst.write(json.dumps(row, ensure_ascii=False) + "\n") ``` diff --git a/docs/usermanuals/tr/compliance/gdpr.md b/docs/usermanuals/tr/compliance/gdpr.md index 3bbd0449..e735a6af 100644 --- a/docs/usermanuals/tr/compliance/gdpr.md +++ b/docs/usermanuals/tr/compliance/gdpr.md @@ -80,15 +80,22 @@ Bir kişinin verisi eğitim setinizdeydi: 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 `id` ekleyin ve dosya sonradan filtrelenir ya da yeniden sıralanırsa bu id'yi koruyun: +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 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: ```python import json, uuid +seen = set() with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-ids.jsonl", "w", encoding="utf-8") as dst: - for line in src: + for line_no, line in enumerate(src, start=1): + if not line.strip(): + continue row = json.loads(line) - row.setdefault("id", str(uuid.uuid4())) + if row.get("id") in (None, ""): + row["id"] = str(uuid.uuid4()) + if str(row["id"]) in seen: + raise SystemExit(f"line {line_no}: id {row['id']!r} is already used by another row") + seen.add(str(row["id"])) dst.write(json.dumps(row, ensure_ascii=False) + "\n") ``` diff --git a/forgelm/model.py b/forgelm/model.py index 8ecda382..82becf3d 100644 --- a/forgelm/model.py +++ b/forgelm/model.py @@ -287,6 +287,21 @@ 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. """ + ignored = [ + name + for name in ("rope_scaling", "sliding_window_attention") + if getattr(getattr(config, "training", None), name, None) + ] + if ignored: + # Both are edits to the transformers config (see _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. + from .config import ConfigError + + raise ConfigError( + f"training.{' and training.'.join(ignored)} {'is' if len(ignored) == 1 else 'are'} not supported by " + "the unsloth backend. Set model.backend: transformers to apply them, or remove the setting." + ) + try: from unsloth import FastLanguageModel except ImportError as e: @@ -516,8 +531,8 @@ def _with_sliding_window(model_config: Any, window: int) -> Any: if getattr(model_config, name, None) is text: setattr(model_config, name, rebuilt) return model_config - for key, value in updates.items(): # a nested config the outer one does not declare: edit it in place - setattr(text, key, value) + 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 diff --git a/tests/test_model.py b/tests/test_model.py index 159d696c..be824566 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -299,19 +299,45 @@ def test_qwen2_layer_types_are_rederived_so_the_window_takes_effect(self): 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_rebuilt_config_builds_a_model_that_runs(self): + 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 - out = model_mod._with_sliding_window(self._qwen2(), 8) - logits = AutoModelForCausalLM.from_config(out)(input_ids=torch.arange(1, 13).unsqueeze(0)).logits - assert tuple(logits.shape) == (1, 12, 64) + 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_window_that_leaves_every_layer_on_full_attention_warns(self, caplog): config = self._qwen2() @@ -707,6 +733,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") From b9bd2f24a50a7a4a32e0865c02e2f06926c6d1dc Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 19 Oct 2026 14:38:11 +0300 Subject: [PATCH 3/4] fix(config-load): reject unsloth + rope/sliding-window at load time, check id and row_id collisions in the GDPR example Second review follow-up on #1019: - The unsloth refusal now also runs when the config loads (exit 1), so `--dry-run` fails instead of passing and failing only when training starts; the check lives in model.py and is shared with the _load_unsloth backstop (config.py has no line budget left, same reason as the other load-time checks in cli/_config_load.py). Tested through _load_config_or_exit and end to end with `python3 -m forgelm --dry-run` (exit 1). - gdpr (EN+TR): the id-assignment script rejects a collision across the two fields `forgelm purge --row-id` matches, `id` and `row_id`; run against sample data (null/empty ids, duplicate id, id vs row_id). Refs #103, #1018 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 +- docs/usermanuals/en/compliance/gdpr.md | 9 ++++--- docs/usermanuals/tr/compliance/gdpr.md | 9 ++++--- forgelm/cli/_config_load.py | 8 ++++++ forgelm/model.py | 34 +++++++++++++++++--------- tests/test_cli.py | 29 ++++++++++++++++++++++ 6 files changed, 71 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39444c6b..121fba5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -189,7 +189,7 @@ All notable changes to ForgeLM are documented here. on full attention; the text config is rebuilt with the flag on so the list is derived again, 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 (exit 1) instead of ignoring it (#103). + 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 diff --git a/docs/usermanuals/en/compliance/gdpr.md b/docs/usermanuals/en/compliance/gdpr.md index 992856c1..15c89e61 100644 --- a/docs/usermanuals/en/compliance/gdpr.md +++ b/docs/usermanuals/en/compliance/gdpr.md @@ -80,7 +80,7 @@ If a person's data was in your training set: 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` 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: +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: ```python import json, uuid @@ -93,9 +93,10 @@ with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-id row = json.loads(line) if row.get("id") in (None, ""): row["id"] = str(uuid.uuid4()) - if str(row["id"]) in seen: - raise SystemExit(f"line {line_no}: id {row['id']!r} is already used by another row") - seen.add(str(row["id"])) + 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") ``` diff --git a/docs/usermanuals/tr/compliance/gdpr.md b/docs/usermanuals/tr/compliance/gdpr.md index e735a6af..fee03cd0 100644 --- a/docs/usermanuals/tr/compliance/gdpr.md +++ b/docs/usermanuals/tr/compliance/gdpr.md @@ -80,7 +80,7 @@ Bir kişinin verisi eğitim setinizdeydi: 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 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: +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: ```python import json, uuid @@ -93,9 +93,10 @@ with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-id row = json.loads(line) if row.get("id") in (None, ""): row["id"] = str(uuid.uuid4()) - if str(row["id"]) in seen: - raise SystemExit(f"line {line_no}: id {row['id']!r} is already used by another row") - seen.add(str(row["id"])) + 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") ``` 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 82becf3d..5ca0f3ff 100644 --- a/forgelm/model.py +++ b/forgelm/model.py @@ -277,31 +277,43 @@ 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 _load_unsloth(config: Any) -> Tuple[Any, Any]: - """Load the model + tokenizer + LoRA via the Unsloth backend. +def _reject_unsloth_unsupported_overrides(config: Any) -> None: + """Refuse ``training.rope_scaling`` / ``training.sliding_window_attention`` on the unsloth backend. - Revision pinning on this backend is conditional on the installed unsloth - exposing a ``revision`` parameter (see :func:`_unsloth_accepts_revision`). - When it does not and the config asks for a pin, the load fails closed - rather than proceeding unpinned: an operator who wrote ``model.revision`` - and got a default-branch checkout would carry a manifest asserting a - reproducibility property the run does not have. + 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: - # Both are edits to the transformers config (see _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. from .config import ConfigError raise ConfigError( f"training.{' and training.'.join(ignored)} {'is' if len(ignored) == 1 else 'are'} not supported by " - "the unsloth backend. Set model.backend: transformers to apply them, or remove the setting." + 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. + + Revision pinning on this backend is conditional on the installed unsloth + exposing a ``revision`` parameter (see :func:`_unsloth_accepts_revision`). + When it does not and the config asks for a pin, the load fails closed + rather than proceeding unpinned: an operator who wrote ``model.revision`` + 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: 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 From c524db6d6309a1694052ed8faee4a5ec66d4f3a1 Mon Sep 17 00:00:00 2001 From: Cemil ILIK Date: Mon, 19 Oct 2026 14:51:57 +0300 Subject: [PATCH 4/4] fix(model): keep a checkpoint-supplied layer_types pattern; make the GDPR id script write atomically Third review follow-up on #1019: - _with_sliding_window cleared layer_types unconditionally, discarding a per-layer pattern the checkpoint supplied. It now clears it only when it equals what the config would derive from its own fields, and keeps it when it differs. Test added and mutation-checked. - gdpr (EN+TR): the id-assignment script opened the destination for writing before validating, so a failed run truncated an existing output. It now writes a temporary file, moves it into place only after every row passed, and removes it otherwise. Both mirrors run against sample data: failure leaves the previous output untouched and no temporary file behind. Refs #103, #1018 Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 +-- docs/usermanuals/en/compliance/gdpr.md | 38 +++++++++++++++----------- docs/usermanuals/tr/compliance/gdpr.md | 38 +++++++++++++++----------- forgelm/model.py | 14 ++++++---- tests/test_model.py | 19 +++++++++++++ 5 files changed, 74 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 121fba5e..7680d5c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -186,8 +186,8 @@ All notable changes to ForgeLM are documented here. 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, and a warning is logged - when the result still has no sliding-attention layer. The unsloth backend builds no transformers config, so it + 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 diff --git a/docs/usermanuals/en/compliance/gdpr.md b/docs/usermanuals/en/compliance/gdpr.md index 15c89e61..d0f37a87 100644 --- a/docs/usermanuals/en/compliance/gdpr.md +++ b/docs/usermanuals/en/compliance/gdpr.md @@ -80,24 +80,30 @@ If a person's data was in your training set: 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: +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, uuid - -seen = set() -with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-ids.jsonl", "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") +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. diff --git a/docs/usermanuals/tr/compliance/gdpr.md b/docs/usermanuals/tr/compliance/gdpr.md index fee03cd0..152a5c50 100644 --- a/docs/usermanuals/tr/compliance/gdpr.md +++ b/docs/usermanuals/tr/compliance/gdpr.md @@ -80,24 +80,30 @@ Bir kişinin verisi eğitim setinizdeydi: 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: +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, uuid - -seen = set() -with open("data/train.jsonl", encoding="utf-8") as src, open("data/train.with-ids.jsonl", "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") +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. diff --git a/forgelm/model.py b/forgelm/model.py index 5ca0f3ff..660bd064 100644 --- a/forgelm/model.py +++ b/forgelm/model.py @@ -518,8 +518,9 @@ def _with_sliding_window(model_config: Any, window: int) -> Any: 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. Models without ``use_sliding_window`` (Mistral, Gemma 3) - keep their layer pattern and only get the new window size. + 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"): @@ -527,9 +528,12 @@ def _with_sliding_window(model_config: Any, window: int) -> Any: return model_config updates: Dict[str, Any] = {"sliding_window": window, "use_sliding_window": True} - if hasattr(text, "layer_types"): - updates["layer_types"] = None # derived again from the three fields above - rebuilt = type(text).from_dict({**text.to_dict(), **updates}) + 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 " diff --git a/tests/test_model.py b/tests/test_model.py index be824566..92e627a2 100644 --- a/tests/test_model.py +++ b/tests/test_model.py @@ -339,6 +339,25 @@ def test_a_nested_text_config_the_outer_one_does_not_declare_is_edited_in_place_ 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