mirror of
https://github.com/axolotl-ai-cloud/axolotl.git
synced 2026-10-12 02:11:20 +00:00
feat: diffusion_decision plugin for typed-decision training on native diffusion LMs (#4117)
* feat(diffusion): add diffusion_decision plugin for typed-decision training * feat(diffusion): add public procedural decision mix, eval script and decision recipes * docs(diffusion): document typed-decision training * docs(examples): explain why the decision recipes pin varlen attention * test: decision trainer suppresses token perplexity; base diffusion trainer no longer reports it * test(diffusion_decision): share spec, record, canvas, tokenizer and harness factories across files * test(diffusion_decision): fold repeated setup into builders and parametrize near-identical cases * refactor(diffusion_decision): drop the evaluation harness, HF reader and data audit Training needs none of them; the prepared cache now validates on its identity and row hashes alone. * fix(diffusion_decision): address review on dev split, record ids and merge hook The automatic family dev split can no longer hold out every record; grouping synthesizes positional ids for rows without one and rejects duplicates; post_lora_merge skips runs without a decision config. Types the slot placement in the trainer test for mypy. * fix: use Ray native Pydantic support on Python 3.14 (cherry picked from commit 9bdcd4921d889d680f81e04ef4abd581e5de1db4) * feat(diffusion_decision): per-question label weights in the decision loss * fix(diffusion_decision): address review on collator fallback, pinned labels and the question cap The collator hook returns None without a decision block so defaults apply; canvas validation rejects pinned label positions, which the collator would supervise uncorrupted; max_questions_per_canvas is capped at the template's 64-question limit.
This commit is contained in:
@@ -5,6 +5,7 @@ include src/axolotl/core/trainers/prm/NOTICE
|
||||
include VERSION
|
||||
include src/axolotl/utils/chat_templates/templates/*.jinja
|
||||
include src/axolotl/integrations/kernels/libs/scattermoe_lora/metadata.json
|
||||
include src/axolotl/integrations/diffusion_decision/vendored/DJEV_LICENSE
|
||||
recursive-include src/axolotl *.yaml
|
||||
include AGENTS.md
|
||||
recursive-include docs/agents *.md
|
||||
|
||||
+170
-5
@@ -200,8 +200,8 @@ lora_target_modules: '^model\.(encoder\.language_model|decoder)\.layers\.\d+\.se
|
||||
|
||||
Vision, router and expert parameters stay frozen.
|
||||
|
||||
Nemotron supports opt-in Cut Cross Entropy for its aligned diffusion objective.
|
||||
With Axolotl's CCE fork installed, add:
|
||||
Nemotron supports opt-in Cut Cross Entropy for its aligned diffusion objective
|
||||
and for typed-decision training. With Axolotl's CCE fork installed, add:
|
||||
|
||||
```yaml
|
||||
plugins:
|
||||
@@ -211,9 +211,10 @@ cut_cross_entropy: true
|
||||
|
||||
Keep any other plugins the recipe requires in that list. The final training
|
||||
read computes full-vocabulary CE from hidden states without materializing
|
||||
vocabulary logits. Targets stay aligned, with no causal shift. This path
|
||||
requires a plain linear output head and does not support a LoRA output head or
|
||||
the `loo` objective.
|
||||
vocabulary logits; decision training still computes its restricted-label and
|
||||
Brier terms over the allowed answers. Targets stay aligned, with no causal
|
||||
shift. This path requires a plain linear output head and does not support a
|
||||
LoRA output head or the `loo` objective.
|
||||
|
||||
`axolotl merge-lora config.yaml` exports a merged checkpoint. When
|
||||
`merge_method` is omitted, DiffusionGemma selects `legacy` so its tied factors
|
||||
@@ -224,6 +225,170 @@ reply is a completion block appended to the conversation and denoised in one
|
||||
piece; see [Inference](inference.qmd#sec-chat-diffusion). Native models
|
||||
generate completions only.
|
||||
|
||||
## Typed-decision training
|
||||
|
||||
The `diffusion_decision` plugin trains a native diffusion model to answer
|
||||
typed multiple-choice, score and yes/no questions. Each record renders a fixed
|
||||
decision system prompt from its question schema, sends its `state` as the user
|
||||
message, and places one answer label per question in the output canvas using
|
||||
the canvas template of the public `mmastrac/djev` project.
|
||||
|
||||
`examples/nemotron-diffusion/decision-lora-8b.yaml` is the starting recipe for
|
||||
`nvidia/Nemotron-Labs-Diffusion-8B`. It uses BF16, `attn_implementation: varlen`,
|
||||
and rank-64/alpha-128 LoRA over all seven attention and MLP projections with
|
||||
`loraplus_lr_ratio: 8`. It trains one epoch with AdamW at `6.5e-6`, microbatch
|
||||
16 and eight accumulation steps (effective batch 128). The decision loss
|
||||
combines candidate-restricted CE, full-vocabulary CE and a `0.1` Brier term,
|
||||
with `unroll.k_max: 2`, no latent slots and non-reentrant gradient
|
||||
checkpointing. The YAML leaves the model revision unpinned; add
|
||||
`revision_of_model` to a local copy for an exact reproduction.
|
||||
|
||||
```bash
|
||||
axolotl preprocess examples/nemotron-diffusion/decision-lora-8b.yaml
|
||||
axolotl train examples/nemotron-diffusion/decision-lora-8b.yaml
|
||||
```
|
||||
|
||||
Set the `data_files` of the `datasets` and `test_datasets` entries to separate
|
||||
normalized train and development JSONL files. The recipe evaluates every 50
|
||||
steps and keeps the adapter with the best `eval_loss`. Keep held-out test data
|
||||
out of both entries.
|
||||
|
||||
### Dataset format
|
||||
|
||||
`type: diffusion_decision.jsonl` reads already-normalized JSONL, one record per
|
||||
line. `diffusion_decision.typed_decisions` and `diffusion_decision.procedural`
|
||||
normalize rows from the `typed_decisions` and procedural typed-decision source
|
||||
schemas into the same form.
|
||||
|
||||
```json
|
||||
{"source":"support_demo","family":"returns","id":"case-0042","group":"case-0042","instructions":"Decide from the supplied case only.","state":{"customer":"A customer says their unopened order arrived yesterday and asks for a refund.","policy":"Unopened items delivered within 30 days may be refunded."},"questions":{"refund":{"type":"choice","instructions":"Choose the next action.","options":[{"name":"approve","description":"Approve a refund under the stated policy."},{"name":"deny","description":"Deny the request because the policy does not apply."},{"name":"escalate","description":"Send the case to a human reviewer."}]},"urgency":{"type":"score","instructions":"Rate how urgently this needs review.","levels":["routine","soon","urgent"]},"eligible":{"type":"noul","instructions":"Is the customer eligible for a refund?","criteria":{"true":"The stated policy permits a refund.","false":"The stated policy does not permit a refund."}}},"labels":{"refund":{"kind":"dist","probs":[0.78,0.02,0.20]},"urgency":{"kind":"hard","gold_idx":0},"eligible":{"kind":"set","allowed_set":[0]}},"source_metadata":{"provenance":{"dataset":"my-org/support-decisions","revision":"immutable-revision","split":"train","source_id":"case-0042"}}}
|
||||
```
|
||||
|
||||
`source`, `id` and `group` identify the row; `family` is optional and is used
|
||||
for split isolation. Records with the same source, family, state and top-level
|
||||
`instructions` that share a `group` are rendered on one canvas, up to
|
||||
`max_questions_per_canvas` questions. There is no per-row `system` field: put
|
||||
shared context in `state`, global instructions in `instructions`, and
|
||||
per-question instructions on each question. Keep question IDs stable and
|
||||
nonempty.
|
||||
|
||||
| Type | Required schema field | Canonical alternative order |
|
||||
|---|---|---|
|
||||
| `choice` | `options` | List order. An option is a string or an object with `name` and `description`; descriptions are rendered. |
|
||||
| `score` | `levels` | List order, lowest to highest as defined by the source. Do not sort levels lexically. |
|
||||
| `noul` | none | `yes`, then `no`. Optional `criteria` documents the true/false conditions and is rendered. |
|
||||
|
||||
Label indexes always refer to this canonical order, never to option names or
|
||||
token IDs. Answer tokens come from `labels.codebook`: `vendored26` uses the
|
||||
djev letters `A`-`Z` (up to 26 alternatives); `expanded52` allows up to 52.
|
||||
|
||||
### Labels
|
||||
|
||||
Every question has exactly one label:
|
||||
|
||||
```json
|
||||
{"kind":"hard","gold_idx":1}
|
||||
{"kind":"dist","probs":[0.10,0.70,0.20]}
|
||||
{"kind":"set","allowed_set":[0,2]}
|
||||
```
|
||||
|
||||
`hard` names one zero-based alternative. `set` is a nonempty set of unique
|
||||
valid indexes. `dist` has one finite, nonnegative probability per alternative
|
||||
that sums to one within `1e-4`; zero entries are valid and must not be omitted.
|
||||
Partial distributions, ranking targets, per-row candidate token IDs and
|
||||
hard/soft blending are not accepted.
|
||||
|
||||
`labels.label_softmax` selects candidate-restricted CE (`restricted`),
|
||||
full-vocabulary CE over the allowed label tokens (`full`), or both (the
|
||||
default). The full-vocabulary term still penalizes probability outside the
|
||||
valid answers. `full_ce_weighting: dft` applies DFT weighting to hard-label
|
||||
full CE. `hard_label_smoothing` spreads that fraction of a one-hot CE target
|
||||
uniformly across the question's valid answer tokens; Brier targets are
|
||||
unchanged.
|
||||
|
||||
### Splits and provenance
|
||||
|
||||
Keep train, development and test data in separate files or dataset entries,
|
||||
and use `split: train` only for training entries. Preserve
|
||||
`source_metadata.provenance` with an immutable source location and revision,
|
||||
the split, and the source row ID. `typed_decisions` rows train only from a
|
||||
declared `train` split and reject a row `split` that disagrees with it. After
|
||||
normalization, the loader rejects states or grouped contexts shared between
|
||||
splits.
|
||||
|
||||
### Mixture and weighting
|
||||
|
||||
`mixture.weights` sets per-source sampling weights by `source`. Without
|
||||
weights, each source is sampled in proportion to its row count raised to
|
||||
`mixture.temperature` (default 0.5). `max_examples_per_source` caps rows per
|
||||
source. `loss_weight` multiplies each source's loss. Loss is averaged over the
|
||||
questions on a canvas and then over canvases, so a canvas with many questions
|
||||
does not outweigh a single-question canvas.
|
||||
|
||||
To train on a prebuilt draw sequence, set `mixture.premixed: true` and
|
||||
`per_batch_stratified: false`. Each training row then needs a unique draw ID
|
||||
and an origin matching its own identity; copies of one origin must be identical
|
||||
apart from this metadata:
|
||||
|
||||
```json
|
||||
"source_metadata": {
|
||||
"premix": {
|
||||
"draw_id": "run-000001",
|
||||
"origin": {"source": "support_demo", "id": "case-0042", "group": "case-0042"}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Batching
|
||||
|
||||
`batch_flattening: true` concatenates the logical microbatch and keeps
|
||||
decisions isolated through the varlen attention path. For variable-length
|
||||
decisions, set `batch_flattening: false`, `sample_packing: true` and
|
||||
`eval_sample_packing: false` instead. Packing uses `sequence_len` as the
|
||||
per-decision limit and `sequence_len * micro_batch_size` as the physical token
|
||||
budget. It requires `mixture.per_batch_stratified: false` and does not support
|
||||
sampled latent-slot counts. Reduce `micro_batch_size` and raise
|
||||
`gradient_accumulation_steps` if the effective batch does not fit.
|
||||
|
||||
### Outputs
|
||||
|
||||
Each saved adapter includes `diffusion_decision_manifest.json`, which records
|
||||
the base model and revision, diffusion spec, canvas width, label codebook,
|
||||
latent slots, read protocol and tokenizer special IDs that an external
|
||||
inference integration must reproduce. Axolotl does not ship a decision-serving
|
||||
endpoint. Use a separate config for a frozen test split, and do not use test
|
||||
data for checkpoint selection.
|
||||
|
||||
### Public procedural decision mix
|
||||
|
||||
`scripts/diffusion_lm/build_public_decision_mix.py` materializes a reproducible
|
||||
train/dev mix from
|
||||
[`tasksource/procedural-typed-decisions`](https://huggingface.co/datasets/tasksource/procedural-typed-decisions)
|
||||
at revision `916e6cce365a65c37d58db70c9e369795817651e`:
|
||||
|
||||
```bash
|
||||
python scripts/diffusion_lm/build_public_decision_mix.py ./data/public-procedural
|
||||
axolotl train examples/nemotron-diffusion/decision-lora-8b-public-procedural.yaml
|
||||
```
|
||||
|
||||
The output contains only rows from that dataset plus a `contract.json`
|
||||
recording the pinned revision and licenses. The dataset card declares
|
||||
Apache-2.0; the separate generator repository publishes its code under
|
||||
CC-BY-4.0. Keep `contract.json` with any materialized output you share. The
|
||||
model is covered by its own model-card license.
|
||||
|
||||
`--protected-normalized PATH` (repeatable) excludes any public row whose
|
||||
canonical state matches a row in your normalized evaluation JSONL; that file is
|
||||
never copied to the output. This is canonical-state decontamination only, not
|
||||
cross-source group or family disjointness. The train and dev outputs are
|
||||
checked against each other for state, family and group overlap.
|
||||
|
||||
`decision-lora-8b-public-procedural.yaml` pins the model revision and sets
|
||||
`mixture.temperature: 1.0`, which schedules each usable row once. At the pinned
|
||||
revisions, preprocessing keeps 30,080 train rows at the 2,048-token budget,
|
||||
which is 235 effective batches of 128, hence `max_steps: 235`. This mix is a
|
||||
reproducible starting point for the format, not a quality result.
|
||||
|
||||
## Converting a causal LM
|
||||
|
||||
`diffusion_lm.from_causal_lm: true` trains an ordinary causal checkpoint with
|
||||
|
||||
@@ -52,6 +52,7 @@ Most people start with two, each a single plugin to turn on: [Liger](#fused-trai
|
||||
| EAFT | `use_eaft: true` (+ `eaft_alpha`, `eaft_k`) | n/a | prompt to response | SFT loss weighted by top-k entropy |
|
||||
| DFT | `use_dft: true` | n/a | prompt to response | SFT loss weighted by detached token probability ([paper](https://arxiv.org/abs/2508.05629)) |
|
||||
| Native diffusion LM 🟡 | `diffusion_lm:` | n/a | prompt to response | masked/uniform-noise denoising objective from the model profile; LoRA only; see [diffusion_lm](diffusion_lm.qmd) |
|
||||
| Typed decisions 🟡 | plugin `diffusion_decision` + `diffusion_decision:` | n/a | typed decision JSONL | choice/score/yes-no labels on a diffusion canvas |
|
||||
|
||||
Most runs start with **SFT** (the default). Reach for the preference methods (DPO and its variants) once you have chosen/rejected pairs, and GRPO when you have a verifiable reward. The rest are specialized.
|
||||
|
||||
@@ -196,6 +197,7 @@ Generic HuggingFace support is universal. This table lists **added acceleration/
|
||||
| Stepwise supervised | `stepwise_supervised` | PRM data |
|
||||
| Preference | `dpo_datasets:` / `kto_datasets:` | chosen/rejected or completion+label |
|
||||
| Multimodal 🟡 | `chat_template` + image/audio | resizing, role boundaries |
|
||||
| Typed decisions 🟡 | `diffusion_decision.jsonl`, `.typed_decisions`, `.procedural` | `diffusion_decision` plugin only |
|
||||
|
||||
## Optimizers & schedulers
|
||||
|
||||
@@ -211,6 +213,7 @@ Generic HuggingFace support is universal. This table lists **added acceleration/
|
||||
|---|---|
|
||||
| `kd` | knowledge distillation (offline + online vLLM/SGLang teacher) |
|
||||
| `diffusion` 🔴 | legacy `diffusion:` block; translated to `diffusion_lm.from_causal_lm: true` |
|
||||
| `diffusion_decision` 🟡 | typed-decision training for native diffusion models |
|
||||
| `hatchery` | remote training (Tinker/Hatchery backends) |
|
||||
| `nemo_gym` | RL environments / verifiable rewards |
|
||||
| `llm_compressor` | sparse fine-tuning |
|
||||
@@ -289,7 +292,7 @@ Works, but flagged unstable:
|
||||
- Multimodal / VLM ("limited, no full feature parity")
|
||||
- N-D parallelism and Tensor Parallel
|
||||
- Expert Parallel (`deep_ep` or `torch` backend)
|
||||
- Native diffusion LMs (`diffusion_lm:`)
|
||||
- Native diffusion LMs (`diffusion_lm:`) and typed-decision training
|
||||
|
||||
### 🚧 WIP / feature-branch only
|
||||
Not in a tagged release yet:
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
Native diffusion recipes for Nemotron-Labs-Diffusion. See
|
||||
[`docs/diffusion_lm.qmd`](../../docs/diffusion_lm.qmd) for installation,
|
||||
attention, packing, objective options and adapter constraints.
|
||||
attention, packing, objective options, adapter constraints and the
|
||||
typed-decision dataset format.
|
||||
|
||||
| Config | Purpose |
|
||||
|---|---|
|
||||
| `lora-smoke.yaml` | Pinned 3B, two-step chat LoRA smoke with packing; not a quality recipe |
|
||||
| `decision-lora-8b.yaml` | 8B typed-decision LoRA starting recipe; set your own train/dev JSONL paths |
|
||||
| `decision-lora-8b-public-procedural.yaml` | 8B typed-decision LoRA on the public procedural mix from `scripts/diffusion_lm/build_public_decision_mix.py` |
|
||||
|
||||
```bash
|
||||
axolotl preprocess examples/nemotron-diffusion/lora-smoke.yaml
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Data: see "Public procedural decision mix" in docs/diffusion_lm.qmd.
|
||||
base_model: nvidia/Nemotron-Labs-Diffusion-8B
|
||||
revision_of_model: 16c67f0560b912e93e0cabb6e0c4f5c3086d95fc
|
||||
trust_remote_code: true
|
||||
plugins:
|
||||
- axolotl.integrations.diffusion_decision.DiffusionDecisionPlugin
|
||||
|
||||
adapter: lora
|
||||
lora_r: 64
|
||||
lora_alpha: 128
|
||||
lora_dropout: 0.0
|
||||
lora_target_modules: [q_proj, k_proj, v_proj, o_proj, gate_proj, up_proj, down_proj]
|
||||
loraplus_lr_ratio: 8
|
||||
# varlen is the only diffusion attention backend that supports batch_flattening: it runs
|
||||
# bidirectional attention over the concatenated microbatch from document offsets, with no
|
||||
# dense mask. flex_attention and sdpa isolate documents only via sample_packing's dense mask.
|
||||
attn_implementation: varlen
|
||||
bf16: true
|
||||
sequence_len: 2048
|
||||
batch_flattening: true
|
||||
sample_packing: false
|
||||
micro_batch_size: 16
|
||||
gradient_accumulation_steps: 8
|
||||
num_epochs: 1
|
||||
max_steps: 235
|
||||
learning_rate: 6.5e-6
|
||||
optimizer: adamw_torch
|
||||
lr_scheduler: constant
|
||||
warmup_steps: 0
|
||||
max_grad_norm: 1.0
|
||||
gradient_checkpointing: true
|
||||
gradient_checkpointing_kwargs:
|
||||
use_reentrant: false
|
||||
dataloader_num_workers: 0
|
||||
dataset_num_proc: 4
|
||||
remove_unused_columns: false
|
||||
val_set_size: 0
|
||||
chat_template: tokenizer_default
|
||||
|
||||
datasets:
|
||||
- path: json
|
||||
data_files: ./data/public-procedural/train.jsonl
|
||||
split: train
|
||||
type: diffusion_decision.jsonl
|
||||
test_datasets:
|
||||
- path: json
|
||||
data_files: ./data/public-procedural/dev.jsonl
|
||||
split: dev
|
||||
type: diffusion_decision.jsonl
|
||||
|
||||
dataset_prepared_path: ./last_run_prepared/public-procedural
|
||||
output_dir: ./outputs/public-procedural
|
||||
logging_steps: 1
|
||||
eval_strategy: steps
|
||||
eval_steps: 50
|
||||
save_strategy: steps
|
||||
save_steps: 50
|
||||
save_total_limit: 2
|
||||
eval_on_start: true
|
||||
load_best_model_at_end: true
|
||||
metric_for_best_model: eval_loss
|
||||
greater_is_better: false
|
||||
diffusion_lm:
|
||||
from_causal_lm: false
|
||||
generate_samples: false
|
||||
canvas_width: 128
|
||||
mask_token_id: 100
|
||||
eos_tail: none
|
||||
unroll:
|
||||
k_max: 2
|
||||
grad_through_steps: false
|
||||
diffusion_decision:
|
||||
mixture:
|
||||
temperature: 1.0
|
||||
read_fraction: 1.0
|
||||
latent:
|
||||
mode: none
|
||||
labels:
|
||||
label_softmax: both
|
||||
brier_weight: 0.1
|
||||
codebook: vendored26
|
||||
seed: 42
|
||||
@@ -0,0 +1,87 @@
|
||||
base_model: nvidia/Nemotron-Labs-Diffusion-8B
|
||||
trust_remote_code: true
|
||||
plugins:
|
||||
- axolotl.integrations.diffusion_decision.DiffusionDecisionPlugin
|
||||
|
||||
adapter: lora
|
||||
lora_r: 64
|
||||
lora_alpha: 128
|
||||
lora_dropout: 0.0
|
||||
lora_target_modules:
|
||||
- q_proj
|
||||
- k_proj
|
||||
- v_proj
|
||||
- o_proj
|
||||
- gate_proj
|
||||
- up_proj
|
||||
- down_proj
|
||||
loraplus_lr_ratio: 8
|
||||
|
||||
# varlen is the only diffusion attention backend that supports batch_flattening: it runs
|
||||
# bidirectional attention over the concatenated microbatch from document offsets, with no
|
||||
# dense mask. flex_attention and sdpa isolate documents only via sample_packing's dense mask.
|
||||
attn_implementation: varlen
|
||||
bf16: true
|
||||
sequence_len: 2048
|
||||
batch_flattening: true
|
||||
sample_packing: false
|
||||
eval_sample_packing: false
|
||||
micro_batch_size: 16
|
||||
gradient_accumulation_steps: 8
|
||||
num_epochs: 1
|
||||
learning_rate: 6.5e-6
|
||||
optimizer: adamw_torch
|
||||
lr_scheduler: constant
|
||||
warmup_steps: 0
|
||||
max_grad_norm: 1.0
|
||||
gradient_checkpointing: true
|
||||
gradient_checkpointing_kwargs:
|
||||
use_reentrant: false
|
||||
dataloader_num_workers: 0
|
||||
dataset_num_proc: 4
|
||||
val_set_size: 0
|
||||
remove_unused_columns: false
|
||||
|
||||
datasets:
|
||||
- path: json
|
||||
data_files: ./data/decision-train.jsonl
|
||||
split: train
|
||||
type: diffusion_decision.jsonl
|
||||
test_datasets:
|
||||
- path: json
|
||||
data_files: ./data/decision-dev.jsonl
|
||||
split: dev
|
||||
type: diffusion_decision.jsonl
|
||||
|
||||
dataset_prepared_path: ./last_run_prepared/nemotron-decision-8b
|
||||
output_dir: ./outputs/nemotron-decision-8b
|
||||
logging_steps: 1
|
||||
save_strategy: steps
|
||||
save_steps: 50
|
||||
eval_strategy: steps
|
||||
eval_steps: 50
|
||||
load_best_model_at_end: true
|
||||
metric_for_best_model: eval_loss
|
||||
greater_is_better: false
|
||||
|
||||
diffusion_lm:
|
||||
from_causal_lm: false
|
||||
generate_samples: false
|
||||
canvas_width: 128
|
||||
mask_token_id: 100
|
||||
eos_tail: none
|
||||
unroll:
|
||||
k_max: 2
|
||||
grad_through_steps: false
|
||||
diffusion_decision:
|
||||
read_fraction: 1.0
|
||||
mixture:
|
||||
premixed: false
|
||||
per_batch_stratified: false
|
||||
latent:
|
||||
mode: none
|
||||
labels:
|
||||
label_softmax: both
|
||||
brier_weight: 0.1
|
||||
codebook: vendored26
|
||||
seed: 42
|
||||
+3
-2
@@ -29,7 +29,7 @@ dependencies = [
|
||||
"hf_transfer",
|
||||
"sentencepiece",
|
||||
"gradio==6.10.0",
|
||||
"pydantic==2.12.5",
|
||||
"pydantic==2.13.5",
|
||||
"addict",
|
||||
"fire",
|
||||
"PyYAML==6.0.3",
|
||||
@@ -125,7 +125,7 @@ optimizers = [
|
||||
"q-galore-torch==1.0",
|
||||
]
|
||||
ray = [
|
||||
"ray[train]>=2.52.1",
|
||||
"ray[train]>=2.59.0",
|
||||
]
|
||||
vllm = [
|
||||
"vllm>=0.17.0",
|
||||
@@ -191,6 +191,7 @@ axolotl = [
|
||||
"core/trainers/prm/LICENSE",
|
||||
"core/trainers/prm/NOTICE",
|
||||
"integrations/kernels/libs/scattermoe_lora/metadata.json",
|
||||
"integrations/diffusion_decision/vendored/DJEV_LICENSE",
|
||||
]
|
||||
|
||||
"axolotl.integrations.baseten" = ["template/*.sh", "template/*.py"]
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Materialize a pinned, public-only typed-decision train/dev mix.
|
||||
|
||||
The generator never downloads or writes protected evaluation data. It can
|
||||
optionally use locally normalized evaluation JSONL rows to reject equal states
|
||||
before public output is written.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from datasets import load_dataset
|
||||
|
||||
from axolotl.integrations.diffusion_decision.adapters import normalize_record
|
||||
from axolotl.integrations.diffusion_decision.hygiene import (
|
||||
assert_split_isolation,
|
||||
decontaminate,
|
||||
)
|
||||
|
||||
DATASET = "tasksource/procedural-typed-decisions"
|
||||
REVISION = "916e6cce365a65c37d58db70c9e369795817651e"
|
||||
GENERATOR_REPOSITORY_LICENSE = "CC-BY-4.0"
|
||||
DATASET_CARD_LICENSE = "Apache-2.0"
|
||||
CONFIGS = (
|
||||
"arithmetic",
|
||||
"entity_belief_tracking",
|
||||
"event_state_reconstruction",
|
||||
"evidence_sufficiency",
|
||||
"multi_view_adjudication",
|
||||
"needle_retrieval",
|
||||
"partial_observation_calibration",
|
||||
"policy_applicability",
|
||||
"policy_under_uncertainty",
|
||||
"record_aggregation",
|
||||
"state_perturbation",
|
||||
"table_lookup",
|
||||
)
|
||||
TRAIN_PER_CONFIG = 2560
|
||||
DEV_PER_CONFIG = 256
|
||||
SEED = "public-procedural-typed-decisions-v1"
|
||||
CODEBOOK = "vendored26"
|
||||
|
||||
|
||||
def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _sha256_bytes(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def _read_jsonl(paths: Iterable[Path]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for path in paths:
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, start=1):
|
||||
if line.strip():
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(
|
||||
f"invalid JSON in {path}:{line_number}"
|
||||
) from error
|
||||
if not isinstance(row, dict):
|
||||
raise ValueError(
|
||||
f"protected row in {path}:{line_number} is not an object"
|
||||
)
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def _rank(record: Mapping[str, Any], config: str) -> str:
|
||||
record_id = record.get("id")
|
||||
if not isinstance(record_id, str) or not record_id:
|
||||
raise ValueError("public source record requires a nonempty id")
|
||||
return hashlib.sha256(f"{SEED}\0{config}\0{record_id}".encode()).hexdigest()
|
||||
|
||||
|
||||
def _normalize_row(
|
||||
row: Mapping[str, Any], *, config: str, split: str
|
||||
) -> dict[str, str]:
|
||||
record = normalize_record("procedural", dict(row), training=True, codebook=CODEBOOK)
|
||||
record["source"] = f"public_procedural.{config}"
|
||||
record["family"] = f"{config}:{split}"
|
||||
metadata = dict(record.get("source_metadata", {}))
|
||||
metadata["provenance"] = {
|
||||
"dataset": DATASET,
|
||||
"revision": REVISION,
|
||||
"license_evidence": {
|
||||
"dataset_card_declared": DATASET_CARD_LICENSE,
|
||||
"linked_generator_repository": GENERATOR_REPOSITORY_LICENSE,
|
||||
},
|
||||
"config": config,
|
||||
"split": split,
|
||||
"source_id": record["id"],
|
||||
}
|
||||
record["source_metadata"] = metadata
|
||||
return {"normalized": _canonical(record)}
|
||||
|
||||
|
||||
def _normalize(config: str, split: str) -> list[dict[str, Any]]:
|
||||
source = load_dataset(DATASET, name=config, split=split, revision=REVISION)
|
||||
normalized = source.map(
|
||||
_normalize_row,
|
||||
fn_kwargs={"config": config, "split": split},
|
||||
num_proc=4,
|
||||
remove_columns=source.column_names,
|
||||
keep_in_memory=True,
|
||||
desc=f"Normalizing {config} {split}",
|
||||
)
|
||||
return [json.loads(row["normalized"]) for row in normalized]
|
||||
|
||||
|
||||
def _select(
|
||||
records: Sequence[Mapping[str, Any]], config: str, limit: int
|
||||
) -> list[Mapping[str, Any]]:
|
||||
if len(records) < limit:
|
||||
raise ValueError(f"{config} has {len(records)} rows; need {limit}")
|
||||
return sorted(records, key=lambda record: _rank(record, config))[:limit]
|
||||
|
||||
|
||||
def _require_unique_ids(records: list[dict[str, Any]], config: str, split: str) -> None:
|
||||
ids = [record.get("id") for record in records]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ValueError(f"{config} {split} has duplicate public source ids")
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: Iterable[Mapping[str, Any]]) -> None:
|
||||
path.write_text("".join(_canonical(row) + "\n" for row in rows), encoding="utf-8")
|
||||
|
||||
|
||||
def _assert_source_group_isolation(
|
||||
splits: Mapping[str, Iterable[Mapping[str, Any]]],
|
||||
) -> None:
|
||||
origins: dict[tuple[str, str], str] = {}
|
||||
for split, records in splits.items():
|
||||
for record in records:
|
||||
source = record.get("source")
|
||||
group = record.get("group")
|
||||
if not isinstance(source, str) or not source:
|
||||
raise ValueError("decision record requires a source")
|
||||
if not isinstance(group, str) or not group:
|
||||
raise ValueError("decision record requires a group")
|
||||
key = source, group
|
||||
if key in origins and origins[key] != split:
|
||||
raise ValueError(
|
||||
f"source/group {key!r} overlaps splits {origins[key]!r} and {split!r}"
|
||||
)
|
||||
origins[key] = split
|
||||
|
||||
|
||||
def build(output_dir: Path, protected_paths: Iterable[Path] = ()) -> dict[str, Any]:
|
||||
protected_paths = list(protected_paths)
|
||||
protected = _read_jsonl(protected_paths)
|
||||
protected_sources = Counter(str(record.get("source", "")) for record in protected)
|
||||
|
||||
train, dev = [], []
|
||||
for config in CONFIGS:
|
||||
candidate_train = _normalize(config, "train")
|
||||
candidate_dev = _normalize(config, "validation")
|
||||
_require_unique_ids(candidate_train, config, "train")
|
||||
_require_unique_ids(candidate_dev, config, "validation")
|
||||
kept_train, _ = decontaminate(
|
||||
candidate_train, protected, exclude_families=False
|
||||
)
|
||||
kept_dev, _ = decontaminate(candidate_dev, protected, exclude_families=False)
|
||||
train.extend(_select(kept_train, config, TRAIN_PER_CONFIG))
|
||||
dev.extend(_select(kept_dev, config, DEV_PER_CONFIG))
|
||||
assert_split_isolation({"train": train, "dev": dev})
|
||||
_assert_source_group_isolation({"train": train, "dev": dev})
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
train_path, dev_path = output_dir / "train.jsonl", output_dir / "dev.jsonl"
|
||||
_write_jsonl(train_path, train)
|
||||
_write_jsonl(dev_path, dev)
|
||||
contract = {
|
||||
"schema_version": 1,
|
||||
"name": "public-procedural-typed-decisions-v1",
|
||||
"purpose": "Public, reproducible typed-decision train/dev materialization; not a quality claim.",
|
||||
"source": {
|
||||
"dataset": DATASET,
|
||||
"revision": REVISION,
|
||||
"card": f"https://huggingface.co/datasets/{DATASET}",
|
||||
"generator_repository": "https://github.com/sileod/tasksource",
|
||||
"license_evidence": {
|
||||
"dataset_card_declared": DATASET_CARD_LICENSE,
|
||||
"linked_generator_repository": GENERATOR_REPOSITORY_LICENSE,
|
||||
},
|
||||
"redistribution_status": "dataset card declares Apache-2.0; retain this provenance when sharing materialized output",
|
||||
"configs": list(CONFIGS),
|
||||
},
|
||||
"selection": {
|
||||
"algorithm": "sha256(seed\\0config\\0id), ascending",
|
||||
"seed": SEED,
|
||||
"codebook": CODEBOOK,
|
||||
"train_per_config": TRAIN_PER_CONFIG,
|
||||
"dev_per_config": DEV_PER_CONFIG,
|
||||
},
|
||||
"protected_decontamination": {
|
||||
"status": "performed" if protected else "not_requested",
|
||||
"checked_when_requested": ["canonical_state"],
|
||||
"source_family_group_check": "not comparable across sources: hygiene family keys and source/group keys include source; a cross-source semantic-group mapping is not available",
|
||||
"protected_rows_by_source": dict(sorted(protected_sources.items())),
|
||||
"protected_input_sha256": [_sha256_bytes(path) for path in protected_paths],
|
||||
},
|
||||
"files": {
|
||||
"train": {
|
||||
"path": "train.jsonl",
|
||||
"lines": len(train),
|
||||
"sha256": _sha256_bytes(train_path),
|
||||
},
|
||||
"dev": {
|
||||
"path": "dev.jsonl",
|
||||
"lines": len(dev),
|
||||
"sha256": _sha256_bytes(dev_path),
|
||||
},
|
||||
},
|
||||
}
|
||||
(output_dir / "contract.json").write_text(
|
||||
_canonical(contract) + "\n", encoding="utf-8"
|
||||
)
|
||||
return contract
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("output_dir", type=Path)
|
||||
parser.add_argument(
|
||||
"--protected-normalized",
|
||||
type=Path,
|
||||
action="append",
|
||||
help="Optional untracked normalized evaluation JSONL for canonical-state exclusion; never copied to output.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
build(args.output_dir, args.protected_normalized or ())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Typed decision training for native diffusion language models."""
|
||||
|
||||
__ci_config_keys__ = ("diffusion_decision",)
|
||||
|
||||
from .args import DiffusionDecisionArgs, DiffusionDecisionConfig
|
||||
from .plugin import DiffusionDecisionPlugin
|
||||
from .records import DecisionCanvas
|
||||
from .slots import SlotInit
|
||||
|
||||
__all__ = [
|
||||
"DecisionCanvas",
|
||||
"DiffusionDecisionArgs",
|
||||
"DiffusionDecisionConfig",
|
||||
"DiffusionDecisionPlugin",
|
||||
"SlotInit",
|
||||
]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Shared config, JSON, hashing, and file helpers for decision training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from axolotl.model_support import (
|
||||
DiffusionSpec,
|
||||
get_model_support_for_cfg,
|
||||
resolve_model_support,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
CONTROL_TOKEN_FIELDS = (
|
||||
"pad_token_id",
|
||||
"bos_token_id",
|
||||
"eos_token_id",
|
||||
"unk_token_id",
|
||||
"mask_token_id",
|
||||
)
|
||||
|
||||
|
||||
def _value(value: Any, name: str, default: Any = None) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
return value.get(name, default)
|
||||
return getattr(value, name, default)
|
||||
|
||||
|
||||
def model_config_overrides(cfg: Any) -> Any:
|
||||
"""``model_config`` overrides, which validated configs store under the field name."""
|
||||
overrides = _value(cfg, "overrides_of_model_config")
|
||||
return _value(cfg, "model_config") if overrides is None else overrides
|
||||
|
||||
|
||||
def require_diffusion_spec(cfg: Any) -> DiffusionSpec:
|
||||
if isinstance(cfg, Mapping) and not isinstance(cfg, DictDefault):
|
||||
cfg = DictDefault(cfg)
|
||||
profile = resolve_model_support(get_model_support_for_cfg(cfg))
|
||||
spec = None if profile is None else profile.diffusion
|
||||
if not isinstance(spec, DiffusionSpec):
|
||||
raise ValueError("diffusion_decision requires a resolved native DiffusionSpec")
|
||||
return spec
|
||||
|
||||
|
||||
def parse_token_ids(value: Any, name: str) -> tuple[int, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
values = (
|
||||
(value,) if isinstance(value, int) and not isinstance(value, bool) else value
|
||||
)
|
||||
if not isinstance(values, Sequence) or isinstance(values, (str, bytes)):
|
||||
raise ValueError(f"{name} must be an integer sequence")
|
||||
for token_id in values:
|
||||
if isinstance(token_id, bool) or not isinstance(token_id, int) or token_id < 0:
|
||||
raise ValueError(f"{name} must contain nonnegative integers")
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def optional_token_id(value: Any, name: str) -> int | None:
|
||||
token_ids = parse_token_ids(value, name)
|
||||
if len(token_ids) > 1:
|
||||
raise ValueError(f"{name} must resolve to at most one token ID")
|
||||
return token_ids[0] if token_ids else None
|
||||
|
||||
|
||||
def required_token_id(value: Any, name: str) -> int:
|
||||
token_id = optional_token_id(value, name)
|
||||
if token_id is None:
|
||||
raise ValueError(f"{name} must resolve to exactly one token ID")
|
||||
return token_id
|
||||
|
||||
|
||||
def resolve_mask_token_id(diffusion: Any, model_config: Any) -> int | None:
|
||||
value = _value(diffusion, "mask_token_id")
|
||||
if value is None:
|
||||
value = _value(model_config, "mask_token_id")
|
||||
return optional_token_id(value, "mask_token_id")
|
||||
|
||||
|
||||
def active_control_ids(*sources: Any) -> set[int]:
|
||||
return {
|
||||
token_id
|
||||
for source in sources
|
||||
for name in CONTROL_TOKEN_FIELDS
|
||||
for token_id in parse_token_ids(_value(source, name), name)
|
||||
}
|
||||
|
||||
|
||||
def canonical_json(value: Any, *, ensure_ascii: bool = False, **kwargs: Any) -> str:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=ensure_ascii,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def canonical_sha256(value: Any, **kwargs: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value, **kwargs).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def stable_seed(seed: int, key: str) -> int:
|
||||
return int.from_bytes(hashlib.sha256(f"{seed}:{key}".encode()).digest()[:8], "big")
|
||||
|
||||
|
||||
def sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1 << 20), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict[str, Any]]:
|
||||
records: list[dict[str, Any]] = []
|
||||
with path.open(encoding="utf-8") as handle:
|
||||
for line_number, line in enumerate(handle, start=1):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
record = json.loads(line)
|
||||
except json.JSONDecodeError as error:
|
||||
raise ValueError(
|
||||
f"invalid JSONL at {path}:{line_number}: {error.msg}"
|
||||
) from error
|
||||
if not isinstance(record, dict):
|
||||
raise ValueError(
|
||||
f"JSONL record at {path}:{line_number} must be an object"
|
||||
)
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
|
||||
def atomic_write_text(path: Path, text: str) -> None:
|
||||
descriptor, temporary = tempfile.mkstemp(
|
||||
prefix=f".{path.name}.", suffix=".tmp", dir=path.parent, text=True
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
|
||||
handle.write(text)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
except BaseException:
|
||||
Path(temporary).unlink(missing_ok=True)
|
||||
raise
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Source adapters for typed-decision datasets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from .jsonl import normalize_jsonl
|
||||
from .procedural import normalize as normalize_procedural
|
||||
from .typed_decisions import normalize as normalize_typed_decisions
|
||||
|
||||
_Adapter = Callable[[Mapping[str, Any], str | None], Mapping[str, Any]]
|
||||
|
||||
_ADAPTERS: dict[str, _Adapter] = {
|
||||
"jsonl": lambda row, _split: row,
|
||||
"procedural": lambda row, _split: normalize_procedural(row),
|
||||
"typed_decisions": lambda row, split: normalize_typed_decisions(
|
||||
row, source_split=split
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def normalize_record(
|
||||
adapter: str,
|
||||
row: Mapping[str, Any],
|
||||
*,
|
||||
training: bool,
|
||||
codebook: str = "vendored26",
|
||||
source_split: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
name = adapter.removeprefix("diffusion_decision.")
|
||||
if name not in _ADAPTERS:
|
||||
raise ValueError(f"unknown decision adapter: {adapter}")
|
||||
if training and name == "typed_decisions" and source_split != "train":
|
||||
raise ValueError("typed_decisions training requires the declared train split")
|
||||
result = normalize_jsonl(_ADAPTERS[name](row, source_split), codebook=codebook)
|
||||
if training and name == "jsonl" and result.get("source") == "typed_decisions":
|
||||
raise ValueError(
|
||||
"typed_decisions is reserved for evaluation when supplied through jsonl"
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"normalize_record",
|
||||
"normalize_procedural",
|
||||
"normalize_typed_decisions",
|
||||
"normalize_jsonl",
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Shared validation for normalized typed-decision source records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
LabelKind = Literal["hard", "dist", "set"]
|
||||
|
||||
|
||||
def decode_json(value: Any) -> Any:
|
||||
"""Decode structured source fields while retaining ordinary text states."""
|
||||
if not isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
|
||||
def probabilities(values: Sequence[float], count: int) -> list[float]:
|
||||
if len(values) != count or count < 2:
|
||||
raise ValueError("target must contain one probability per alternative")
|
||||
result = [float(value) for value in values]
|
||||
if any(not math.isfinite(value) or value < 0 for value in result):
|
||||
raise ValueError("target probabilities must be finite and nonnegative")
|
||||
total = sum(result)
|
||||
if not math.isclose(total, 1.0, rel_tol=0, abs_tol=1e-4):
|
||||
raise ValueError("target probabilities must sum to one")
|
||||
return [value / total for value in result]
|
||||
|
||||
|
||||
def label_target(
|
||||
values: Sequence[float],
|
||||
kind: LabelKind,
|
||||
*,
|
||||
candidate_ids: Sequence[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
support = [index for index, value in enumerate(values) if value > 0]
|
||||
if kind == "dist":
|
||||
if candidate_ids is None:
|
||||
return {"kind": "dist", "probs": list(values)}
|
||||
if len(candidate_ids) != len(values):
|
||||
raise ValueError("soft candidate IDs must align with probabilities")
|
||||
return {
|
||||
"kind": "dist",
|
||||
"candidate_indices": support,
|
||||
"candidate_ids": [str(candidate_ids[index]) for index in support],
|
||||
"probs": [values[index] for index in support],
|
||||
"other_probability": sum(
|
||||
value for index, value in enumerate(values) if index not in support
|
||||
),
|
||||
}
|
||||
if kind == "set":
|
||||
if not support:
|
||||
raise ValueError("set target must contain an allowed alternative")
|
||||
return {"kind": "set", "allowed_set": support}
|
||||
if kind != "hard":
|
||||
raise ValueError(f"unknown target kind: {kind}")
|
||||
if len(support) != 1:
|
||||
raise ValueError("hard target must be one-hot; refusing to discard soft mass")
|
||||
return {"kind": "hard", "gold_idx": support[0]}
|
||||
|
||||
|
||||
def finite_question(
|
||||
kind: str, instructions: str, options: Sequence[str], target: Sequence[float]
|
||||
) -> tuple[dict[str, Any], list[float]]:
|
||||
"""Normalize a question and remap its target to the serving option order."""
|
||||
names = [str(option) for option in options]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError("question alternatives must have distinct names")
|
||||
values = probabilities(target, len(names))
|
||||
question: dict[str, Any] = {"type": kind, "instructions": instructions}
|
||||
if kind in {"noul", "bool", "boolean"}:
|
||||
aliases = {"true": "yes", "false": "no", "yes": "yes", "no": "no"}
|
||||
canonical = [aliases.get(name.lower()) for name in names]
|
||||
if sorted(name for name in canonical if name is not None) != ["no", "yes"]:
|
||||
raise ValueError(
|
||||
"boolean alternatives must be exactly yes/no or true/false"
|
||||
)
|
||||
question["type"] = "noul"
|
||||
values = [values[canonical.index(name)] for name in ("yes", "no")]
|
||||
elif kind == "choice":
|
||||
question["options"] = names
|
||||
elif kind == "score":
|
||||
question["levels"] = names
|
||||
else:
|
||||
raise ValueError(f"unsupported finite question type: {kind}")
|
||||
return question, values
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Validate user-supplied records in the normalized decision format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from copy import deepcopy
|
||||
from typing import Any
|
||||
|
||||
from ..template import parse_decision_schema
|
||||
from .common import probabilities
|
||||
|
||||
|
||||
def normalize_jsonl(
|
||||
row: Mapping[str, Any], *, codebook: str = "vendored26"
|
||||
) -> dict[str, Any]:
|
||||
result = deepcopy(dict(row))
|
||||
for field in ("source", "group"):
|
||||
if not isinstance(result.get(field), str) or not result[field]:
|
||||
raise ValueError(f"normalized record requires {field}")
|
||||
if result.get("state") is None:
|
||||
raise ValueError("normalized record requires state")
|
||||
questions, labels = result.get("questions"), result.get("labels")
|
||||
if not isinstance(questions, dict) or not isinstance(labels, dict):
|
||||
raise ValueError("questions and labels must be objects")
|
||||
if set(questions) != set(labels):
|
||||
raise ValueError("each question must have exactly one target")
|
||||
parsed = parse_decision_schema(
|
||||
{"questions": [{**q, "id": key} for key, q in questions.items()]},
|
||||
codebook=codebook,
|
||||
)
|
||||
for question in parsed["questions"]:
|
||||
key = question["id"]
|
||||
if question["type"] not in {"noul", "choice", "score"}:
|
||||
raise ValueError("decision training requires finite label alternatives")
|
||||
count = len(question["labels"])
|
||||
target = labels[key]
|
||||
if not isinstance(target, dict):
|
||||
raise ValueError(f"question {key!r} target must be an object")
|
||||
kind = target.get("kind")
|
||||
if kind == "dist":
|
||||
indices = target.get("candidate_indices")
|
||||
if indices is None:
|
||||
target["probs"] = probabilities(target["probs"], count)
|
||||
else:
|
||||
if (
|
||||
not isinstance(indices, list)
|
||||
or not indices
|
||||
or len(indices) != len(target.get("probs", ()))
|
||||
or len(set(indices)) != len(indices)
|
||||
or any(
|
||||
isinstance(index, bool)
|
||||
or not isinstance(index, int)
|
||||
or not 0 <= index < count
|
||||
for index in indices
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"question {key!r} sparse candidates must identify alternatives"
|
||||
)
|
||||
values = [float(value) for value in target["probs"]]
|
||||
other = float(target.get("other_probability", 0.0))
|
||||
if (
|
||||
any(not math.isfinite(value) or value < 0 for value in values)
|
||||
or not math.isfinite(other)
|
||||
or other < 0
|
||||
or not math.isclose(sum(values) + other, 1.0, abs_tol=1e-4)
|
||||
):
|
||||
raise ValueError(
|
||||
f"question {key!r} sparse probabilities plus "
|
||||
"other_probability must sum to one"
|
||||
)
|
||||
target["probs"] = values
|
||||
target["other_probability"] = other
|
||||
candidate_ids = target.get("candidate_ids")
|
||||
if candidate_ids is not None and (
|
||||
not isinstance(candidate_ids, list)
|
||||
or len(candidate_ids) != len(indices)
|
||||
or any(
|
||||
not isinstance(value, str) or not value
|
||||
for value in candidate_ids
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"question {key!r} sparse candidate_ids must align "
|
||||
"with candidates"
|
||||
)
|
||||
elif kind in {"hard", "set"}:
|
||||
indices = (
|
||||
[target.get("gold_idx")]
|
||||
if kind == "hard"
|
||||
else target.get("allowed_set")
|
||||
)
|
||||
if (
|
||||
not isinstance(indices, list)
|
||||
or not indices
|
||||
or any(
|
||||
isinstance(index, bool)
|
||||
or not isinstance(index, int)
|
||||
or not 0 <= index < count
|
||||
for index in indices
|
||||
)
|
||||
):
|
||||
raise ValueError(
|
||||
f"question {key!r} target indices must identify alternatives"
|
||||
)
|
||||
if len(set(indices)) != len(indices):
|
||||
raise ValueError(f"question {key!r} allowed set must be unique")
|
||||
else:
|
||||
raise ValueError(f"unknown label kind {kind!r}")
|
||||
return result
|
||||
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .common import decode_json, finite_question, label_target
|
||||
|
||||
|
||||
def normalize(row):
|
||||
questions, answers = decode_json(row["questions"]), decode_json(row["answers"])
|
||||
out = {}
|
||||
labels = {}
|
||||
for key, q in questions.items():
|
||||
if key not in answers:
|
||||
raise ValueError(f"missing target for {key}")
|
||||
a = answers[key]
|
||||
kind = q["type"]
|
||||
criteria = q.get("criteria", {})
|
||||
if kind == "choice":
|
||||
options = list(criteria)
|
||||
descriptions = [criteria[x] for x in options]
|
||||
elif kind == "score":
|
||||
options = [str(x) for x in criteria]
|
||||
descriptions = options
|
||||
elif kind == "noul":
|
||||
options = ["yes", "no"]
|
||||
descriptions = (
|
||||
[criteria.get("true"), criteria.get("false")]
|
||||
if isinstance(criteria, dict)
|
||||
else [None, None]
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"unsupported procedural type {kind}")
|
||||
if kind == "noul":
|
||||
if "noul" not in a:
|
||||
raise ValueError(f"missing noul target for {key}")
|
||||
values = [float(a["noul"]), 1 - float(a["noul"])]
|
||||
else:
|
||||
probs = a.get("probabilities")
|
||||
if not isinstance(probs, dict):
|
||||
raise ValueError(f"incomplete probabilities for {key}")
|
||||
identities = options
|
||||
if kind == "score" and a.get("legend") is not None:
|
||||
legend = a["legend"]
|
||||
if not isinstance(legend, dict) or set(legend) != set(probs):
|
||||
raise ValueError(
|
||||
f"score legend must identify every probability for {key}"
|
||||
)
|
||||
inverse = {str(level): identity for identity, level in legend.items()}
|
||||
if len(inverse) != len(legend) or set(inverse) != set(options):
|
||||
raise ValueError(
|
||||
f"score legend must identify each level exactly once for {key}"
|
||||
)
|
||||
identities = [inverse[level] for level in options]
|
||||
elif kind == "score" and set(probs) != set(options):
|
||||
identities = [str(index) for index in range(len(options))]
|
||||
if set(probs) != set(identities):
|
||||
raise ValueError(f"incomplete probabilities for {key}")
|
||||
values = [float(probs[identity]) for identity in identities]
|
||||
question, values = finite_question(
|
||||
kind, q.get("instructions", ""), options, values
|
||||
)
|
||||
if kind == "choice":
|
||||
question["options"] = [
|
||||
{"name": name, "description": desc}
|
||||
for name, desc in zip(options, descriptions, strict=True)
|
||||
]
|
||||
elif kind == "noul":
|
||||
question["criteria"] = {"true": descriptions[0], "false": descriptions[1]}
|
||||
labels[key] = label_target(values, "hard" if max(values) == 1 else "dist")
|
||||
out[key] = question
|
||||
record_id = str(row["id"])
|
||||
task, separator, official_split = record_id.partition(":")
|
||||
metadata = {}
|
||||
if separator and official_split:
|
||||
metadata = {"task": task, "official_split": official_split.split(":", 1)[0]}
|
||||
return {
|
||||
"source": "procedural",
|
||||
"id": record_id,
|
||||
"group": record_id,
|
||||
"source_metadata": metadata,
|
||||
"state": decode_json(row["state"]),
|
||||
"questions": out,
|
||||
"labels": labels,
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Normalize the multi-question typed-decisions evaluation corpus."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from .common import decode_json, finite_question, label_target
|
||||
|
||||
|
||||
def normalize(
|
||||
row: Mapping[str, Any], *, source_split: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
row_split = row.get("split")
|
||||
if row_split is not None and (
|
||||
not isinstance(row_split, str) or row_split.lower() != source_split
|
||||
):
|
||||
raise ValueError("typed decisions row split disagrees with declared split")
|
||||
questions, gold = decode_json(row["questions"]), decode_json(row["gold"])
|
||||
if not isinstance(questions, dict) or not questions or not isinstance(gold, dict):
|
||||
raise ValueError("typed decisions require questions and gold objects")
|
||||
if set(questions) != set(gold):
|
||||
raise ValueError("each typed question must have exactly one target")
|
||||
normalized, labels = {}, {}
|
||||
for key, question in questions.items():
|
||||
answer = gold[key]
|
||||
kind, criteria = question["type"], question.get("criteria")
|
||||
if kind == "choice":
|
||||
if not isinstance(criteria, dict):
|
||||
raise ValueError("choice criteria must map names to descriptions")
|
||||
identities = list(criteria)
|
||||
names = identities
|
||||
elif kind == "score":
|
||||
if not isinstance(criteria, list):
|
||||
raise ValueError("score criteria must be an ordered list")
|
||||
identities = [str(index) for index in range(len(criteria))]
|
||||
names = [str(value) for value in criteria]
|
||||
elif kind == "noul":
|
||||
identities, names = ["false", "true"], ["no", "yes"]
|
||||
else:
|
||||
raise ValueError(f"unsupported typed question type: {kind}")
|
||||
probs = answer.get("probabilities")
|
||||
if not isinstance(probs, dict) or set(probs) != set(identities):
|
||||
raise ValueError(
|
||||
f"question {key!r} needs probabilities for each alternative"
|
||||
)
|
||||
result, values = finite_question(
|
||||
kind,
|
||||
str(question.get("instructions", "")),
|
||||
names,
|
||||
[probs[identity] for identity in identities],
|
||||
)
|
||||
if kind == "noul":
|
||||
identities = ["yes", "no"]
|
||||
if kind == "choice":
|
||||
assert isinstance(criteria, dict)
|
||||
result["options"] = [
|
||||
{"name": name, "description": criteria[name]} for name in names
|
||||
]
|
||||
elif kind == "noul" and criteria:
|
||||
result["criteria"] = criteria
|
||||
normalized[key] = result
|
||||
smoothing = answer.get("smoothing")
|
||||
if smoothing is None:
|
||||
labels[key] = label_target(values, "dist", candidate_ids=identities)
|
||||
else:
|
||||
if (
|
||||
isinstance(smoothing, bool)
|
||||
or not isinstance(smoothing, (float, int))
|
||||
or not math.isfinite(smoothing)
|
||||
or not 0.0 <= smoothing < 1.0
|
||||
):
|
||||
raise ValueError(
|
||||
f"question {key!r} hard smoothing must be finite and in [0, 1)"
|
||||
)
|
||||
target = label_target(values, "hard")
|
||||
target["smoothing"] = float(smoothing)
|
||||
labels[key] = target
|
||||
state = decode_json(row["state"])
|
||||
if state is None:
|
||||
raise ValueError("typed record requires a state")
|
||||
workflow = row["workflow"]
|
||||
if not isinstance(workflow, str) or not workflow:
|
||||
raise ValueError("typed record requires workflow metadata")
|
||||
return {
|
||||
"source": "typed_decisions",
|
||||
"family": workflow,
|
||||
"id": str(row["id"]),
|
||||
"group": str(row["id"]),
|
||||
"state": state,
|
||||
"questions": normalized,
|
||||
"labels": labels,
|
||||
"source_metadata": {
|
||||
"provenance": {
|
||||
"dataset": "LocalLLaMA/typed-decisions",
|
||||
"workflow": workflow,
|
||||
"split": source_split,
|
||||
"row_split": row_split,
|
||||
"source_id": str(row["id"]),
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
"""Configuration models for typed diffusion-decision training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from .vendored.djev_template import MAX_QUESTIONS
|
||||
|
||||
|
||||
class DecisionMixtureConfig(BaseModel):
|
||||
"""Deterministic source-mixture controls."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
weights: dict[str, float] | None = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Per-source sampling weights keyed by record source; exclusive with temperature."
|
||||
},
|
||||
)
|
||||
temperature: float | None = Field(
|
||||
default=None,
|
||||
ge=0.0,
|
||||
json_schema_extra={
|
||||
"description": "Sample each source in proportion to its row count raised to this power; unset uses 0.5."
|
||||
},
|
||||
)
|
||||
per_batch_stratified: bool = Field(
|
||||
default=True,
|
||||
json_schema_extra={
|
||||
"description": "Draw each microbatch with source-stratified sampling; must be false for packing or premixed data."
|
||||
},
|
||||
)
|
||||
max_examples_per_source: int | dict[str, int] | None = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Cap on training rows kept per source, as one integer or a per-source mapping."
|
||||
},
|
||||
)
|
||||
loss_weight: dict[str, float] = Field(
|
||||
default_factory=dict,
|
||||
json_schema_extra={"description": "Per-source multiplier on each canvas loss."},
|
||||
)
|
||||
premixed: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": "Treat training rows as a prebuilt draw sequence; each row needs source_metadata.premix."
|
||||
},
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_weights(self) -> "DecisionMixtureConfig":
|
||||
if self.weights is not None and self.temperature is not None:
|
||||
raise ValueError(
|
||||
"mixture specifies explicit weights or temperature, not both"
|
||||
)
|
||||
if self.temperature is not None and not math.isfinite(self.temperature):
|
||||
raise ValueError("mixture temperature must be finite")
|
||||
for field_name, values in (
|
||||
("weights", self.weights or {}),
|
||||
("loss_weight", self.loss_weight),
|
||||
):
|
||||
for source, value in values.items():
|
||||
if not source:
|
||||
raise ValueError(
|
||||
f"mixture {field_name} source names must be nonempty"
|
||||
)
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(
|
||||
f"mixture {field_name} values must be finite and positive"
|
||||
)
|
||||
if isinstance(self.max_examples_per_source, int):
|
||||
if self.max_examples_per_source < 1:
|
||||
raise ValueError("mixture max_examples_per_source must be positive")
|
||||
elif isinstance(self.max_examples_per_source, dict):
|
||||
for source, value in self.max_examples_per_source.items():
|
||||
if (
|
||||
not source
|
||||
or isinstance(value, bool)
|
||||
or not isinstance(value, int)
|
||||
or value < 1
|
||||
):
|
||||
raise ValueError(
|
||||
"mixture max_examples_per_source requires nonempty names and positive caps"
|
||||
)
|
||||
if self.premixed:
|
||||
incompatible = []
|
||||
if self.weights is not None:
|
||||
incompatible.append("weights")
|
||||
if self.temperature is not None:
|
||||
incompatible.append("temperature")
|
||||
if self.max_examples_per_source is not None:
|
||||
incompatible.append("max_examples_per_source")
|
||||
if self.per_batch_stratified:
|
||||
incompatible.append("per_batch_stratified")
|
||||
if incompatible:
|
||||
raise ValueError(
|
||||
"mixture premixed=true cannot be combined with "
|
||||
+ ", ".join(incompatible)
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class DecisionLabelsConfig(BaseModel):
|
||||
"""Typed-label objective controls."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
label_softmax: Literal["restricted", "full", "both"] = Field(
|
||||
default="both",
|
||||
json_schema_extra={
|
||||
"description": "Label CE over the question candidates, the full vocabulary, or both."
|
||||
},
|
||||
)
|
||||
full_ce_weighting: Literal["ce", "dft"] = Field(
|
||||
default="ce",
|
||||
json_schema_extra={
|
||||
"description": "Full-vocabulary CE for hard labels: plain CE or DFT probability weighting."
|
||||
},
|
||||
)
|
||||
brier_weight: float = Field(
|
||||
default=0.1,
|
||||
ge=0.0,
|
||||
json_schema_extra={
|
||||
"description": "Coefficient of the candidate-restricted Brier auxiliary loss."
|
||||
},
|
||||
)
|
||||
hard_label_smoothing: float = Field(
|
||||
default=0.0,
|
||||
ge=0.0,
|
||||
lt=1.0,
|
||||
json_schema_extra={
|
||||
"description": "Fraction of a one-hot CE target spread uniformly over the valid answer tokens."
|
||||
},
|
||||
)
|
||||
codebook: Literal["vendored26", "expanded52"] = Field(
|
||||
default="vendored26",
|
||||
json_schema_extra={
|
||||
"description": "Answer-label codebook: djev letters A-Z (26 options) or A-Z plus a-z (52)."
|
||||
},
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_brier_weight(self) -> "DecisionLabelsConfig":
|
||||
if not math.isfinite(self.brier_weight):
|
||||
raise ValueError("labels brier_weight must be finite")
|
||||
if self.hard_label_smoothing and self.full_ce_weighting == "dft":
|
||||
raise ValueError("hard_label_smoothing requires full_ce_weighting=ce")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionLatentConfig(BaseModel):
|
||||
"""Latent-slot initialization controls retained in the run manifest."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
mode: Literal["none", "pad", "pinned", "learned", "free", "mask", "prompt"] = Field(
|
||||
default="none",
|
||||
json_schema_extra={
|
||||
"description": "Latent-slot initialization; none adds no slots to the canvas."
|
||||
},
|
||||
)
|
||||
num_slots: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
json_schema_extra={"description": "Number of latent slots per canvas."},
|
||||
)
|
||||
sample_num_slots: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": "Sample a slot count up to num_slots per canvas instead of a fixed count."
|
||||
},
|
||||
)
|
||||
token_ids: list[int] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Slot token IDs for the pinned, learned, and prompt modes."
|
||||
},
|
||||
)
|
||||
free_update_policy: Literal["argmax"] | None = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Slot update rule between reads; required for mode free."
|
||||
},
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_slots(self) -> "DecisionLatentConfig":
|
||||
if any(token_id < 0 for token_id in self.token_ids):
|
||||
raise ValueError("latent token_ids must be nonnegative")
|
||||
if self.mode == "none" and (self.num_slots or self.token_ids):
|
||||
raise ValueError("latent mode=none requires num_slots=0 and no token_ids")
|
||||
if self.sample_num_slots and self.mode == "none":
|
||||
raise ValueError("sample_num_slots requires a non-none latent mode")
|
||||
if self.sample_num_slots and self.num_slots < 1:
|
||||
raise ValueError("sample_num_slots requires num_slots to be positive")
|
||||
if self.mode == "free" and self.free_update_policy != "argmax":
|
||||
raise ValueError("latent mode=free requires free_update_policy=argmax")
|
||||
if self.mode != "free" and self.free_update_policy is not None:
|
||||
raise ValueError("free_update_policy is valid only for latent mode=free")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionCarryConfig(BaseModel):
|
||||
"""Cross-read latent carry policy."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
enabled: bool = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": "Reserved; carrying latent state across reads is not supported in training."
|
||||
},
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_enabled(self) -> "DecisionCarryConfig":
|
||||
if self.enabled:
|
||||
raise ValueError("decision carry is not wired for training")
|
||||
return self
|
||||
|
||||
|
||||
class DiffusionDecisionConfig(BaseModel):
|
||||
"""Typed-decision data, latent, and readout settings."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
layout: Literal["thought_block", "prompt_slots"] = Field(
|
||||
default="thought_block",
|
||||
json_schema_extra={
|
||||
"description": "Decision layout recorded in the adapter manifest."
|
||||
},
|
||||
)
|
||||
max_questions_per_canvas: int = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
le=MAX_QUESTIONS,
|
||||
json_schema_extra={
|
||||
"description": "Maximum questions grouped into one canvas; capped by the template."
|
||||
},
|
||||
)
|
||||
read_fraction: float = Field(
|
||||
default=1.0,
|
||||
json_schema_extra={
|
||||
"description": "Fraction of canvases trained at the fully masked read time; the rest use sampled diffusion times."
|
||||
},
|
||||
)
|
||||
mixture: DecisionMixtureConfig = Field(
|
||||
default_factory=DecisionMixtureConfig,
|
||||
json_schema_extra={"description": "Source sampling and loss weighting."},
|
||||
)
|
||||
labels: DecisionLabelsConfig = Field(
|
||||
default_factory=DecisionLabelsConfig,
|
||||
json_schema_extra={"description": "Label objective and answer codebook."},
|
||||
)
|
||||
latent: DecisionLatentConfig = Field(
|
||||
default_factory=DecisionLatentConfig,
|
||||
json_schema_extra={
|
||||
"description": "Optional latent slots placed in the canvas."
|
||||
},
|
||||
)
|
||||
carry: DecisionCarryConfig = Field(
|
||||
default_factory=DecisionCarryConfig,
|
||||
json_schema_extra={
|
||||
"description": "Cross-read latent carry; must stay disabled."
|
||||
},
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_read_fraction(self) -> "DiffusionDecisionConfig":
|
||||
if not math.isfinite(self.read_fraction):
|
||||
raise ValueError("read_fraction must be finite")
|
||||
if not 0.0 <= self.read_fraction <= 1.0:
|
||||
raise ValueError("read_fraction must be in [0, 1]")
|
||||
return self
|
||||
|
||||
|
||||
class DiffusionDecisionArgs(BaseModel):
|
||||
"""Plugin entry that exposes the optional ``diffusion_decision`` block."""
|
||||
|
||||
diffusion_decision: DiffusionDecisionConfig | None = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Typed-decision training settings for native diffusion models."
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pre-mixture token-budget checks for fixed-width decision canvases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from axolotl.model_support import DiffusionLayout
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionBudget:
|
||||
logical_tokens: int
|
||||
physical_tokens: int
|
||||
|
||||
|
||||
def decision_budget(
|
||||
*, prompt_tokens: int, canvas_tokens: int, layout: DiffusionLayout
|
||||
) -> DecisionBudget:
|
||||
if prompt_tokens < 0 or canvas_tokens <= 0:
|
||||
raise ValueError("decision prompt and canvas lengths must be nonnegative")
|
||||
if layout not in {DiffusionLayout.FULL_SEQUENCE, DiffusionLayout.ENCODER_CANVAS}:
|
||||
raise ValueError(f"unsupported decision layout: {layout}")
|
||||
total = prompt_tokens + canvas_tokens
|
||||
return DecisionBudget(logical_tokens=total, physical_tokens=total)
|
||||
|
||||
|
||||
def exceeds_budget(
|
||||
budget: DecisionBudget,
|
||||
*,
|
||||
logical_limit: int | None,
|
||||
physical_limit: int | None,
|
||||
) -> str | None:
|
||||
if logical_limit is not None and budget.logical_tokens > logical_limit:
|
||||
return "logical"
|
||||
if physical_limit is not None and budget.physical_tokens > physical_limit:
|
||||
return "physical"
|
||||
return None
|
||||
@@ -0,0 +1,58 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import torch
|
||||
|
||||
from axolotl.core.trainers.diffusion_lm.batch import DiffusionBatch
|
||||
|
||||
from .records import DecisionCanvas
|
||||
|
||||
|
||||
class DecisionCanvasCollator:
|
||||
def __init__(self, pad_token_id: int = 0):
|
||||
self.pad_token_id = pad_token_id
|
||||
|
||||
def __call__(self, canvases: list[DecisionCanvas]) -> DiffusionBatch:
|
||||
if not canvases:
|
||||
raise ValueError("empty decision batch")
|
||||
pw = max(len(c.prompt_ids) for c in canvases)
|
||||
cw = len(canvases[0].canvas_ids)
|
||||
if any(len(c.canvas_ids) != cw or len(c.semantic_mask) != cw for c in canvases):
|
||||
raise ValueError("canvases must share width")
|
||||
b = len(canvases)
|
||||
prompt = torch.full((b, pw), self.pad_token_id, dtype=torch.long)
|
||||
canvas = torch.tensor([c.canvas_ids for c in canvases], dtype=torch.long)
|
||||
ev = torch.zeros((b, pw), dtype=torch.bool)
|
||||
ar = ev.clone()
|
||||
sem = torch.tensor([c.semantic_mask for c in canvases], dtype=torch.bool)
|
||||
loss = torch.zeros((b, cw), dtype=torch.bool)
|
||||
for i, c in enumerate(canvases):
|
||||
n = len(c.prompt_ids)
|
||||
prompt[i, :n] = torch.tensor(c.prompt_ids)
|
||||
ev[i, :n] = True
|
||||
ar[i, 1:n] = True
|
||||
if c.prompt_slot_mask:
|
||||
if len(c.prompt_slot_mask) != n:
|
||||
raise ValueError("prompt_slot_mask must align with prompt_ids")
|
||||
ar[i, :n] &= ~torch.tensor(c.prompt_slot_mask, dtype=torch.bool)
|
||||
loss[i, torch.tensor(c.label_positions, dtype=torch.long)] = True
|
||||
pinned = torch.tensor([c.pinned_mask for c in canvases], dtype=torch.bool)
|
||||
return DiffusionBatch(
|
||||
encoder_input_ids=prompt,
|
||||
encoder_validity=ev,
|
||||
encoder_ar_valid_mask=ar,
|
||||
encoder_document_ids=torch.arange(b)[:, None].expand(-1, pw),
|
||||
encoder_position_ids=torch.arange(pw)[None].expand(b, -1),
|
||||
canvas_clean_ids=canvas,
|
||||
canvas_semantic_validity=sem,
|
||||
canvas_loss_mask=loss,
|
||||
canvas_corruptible_mask=loss,
|
||||
canvas_input_pinned_mask=pinned,
|
||||
canvas_sc_eligible_mask=loss & ~pinned,
|
||||
canvas_read_only_mask=~loss,
|
||||
canvas_update_mask=loss & ~pinned,
|
||||
logical_ids=torch.arange(b),
|
||||
encoder_lengths=ev.sum(1),
|
||||
canvas_lengths=sem.sum(1),
|
||||
decoder_prefix_lengths=ev.sum(1),
|
||||
selected_block_ids=torch.zeros(b, dtype=torch.long),
|
||||
)
|
||||
@@ -0,0 +1,812 @@
|
||||
"""Dataset preparation for the model-agnostic diffusion-decision plugin."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from functools import partial
|
||||
from glob import glob
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from datasets import load_dataset
|
||||
from torch.utils.data import Dataset
|
||||
|
||||
from axolotl.common.datasets import TrainDatasetMeta
|
||||
from axolotl.core.trainers.diffusion_lm.sampling import resolve_native_packing_budget
|
||||
from axolotl.loaders import load_tokenizer
|
||||
from axolotl.model_support import (
|
||||
DiffusionLayout,
|
||||
DiffusionNoise,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
from ._util import (
|
||||
_value,
|
||||
active_control_ids,
|
||||
canonical_json,
|
||||
model_config_overrides,
|
||||
parse_token_ids,
|
||||
read_jsonl,
|
||||
require_diffusion_spec,
|
||||
required_token_id,
|
||||
resolve_mask_token_id,
|
||||
)
|
||||
from .adapters import normalize_record
|
||||
from .budgets import decision_budget, exceeds_budget
|
||||
from .grouping import group_records
|
||||
from .hygiene import assert_split_isolation, decontaminate, family_key
|
||||
from .loss import decision_example_from_canvas
|
||||
from .mixture import (
|
||||
DeterministicMixtureSampler,
|
||||
MixtureSample,
|
||||
prepare_source_pools,
|
||||
)
|
||||
from .permutation import permute_record
|
||||
from .prepared_cache import (
|
||||
identity as prepared_cache_identity,
|
||||
load as load_prepared_cache,
|
||||
store as store_prepared_cache,
|
||||
)
|
||||
from .preprocessing import build_decision_canvas
|
||||
from .slot_sampling import (
|
||||
DecisionDraw,
|
||||
project_max_canvas,
|
||||
project_slot_plan,
|
||||
sample_slot_count,
|
||||
)
|
||||
from .slots import SlotInit, SlotPlan
|
||||
from .template import SchemaError
|
||||
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _rows(entry: Any) -> list[dict[str, Any]]:
|
||||
path = _value(entry, "path")
|
||||
if not isinstance(path, str) or not path:
|
||||
raise ValueError("decision dataset requires a path")
|
||||
split = str(_value(entry, "split", "train"))
|
||||
kwargs = {
|
||||
key: _value(entry, key)
|
||||
for key in ("name", "revision")
|
||||
if _value(entry, key) is not None
|
||||
}
|
||||
data_files = _value(entry, "data_files")
|
||||
local_jsonl = _local_jsonl_paths(path, data_files, split)
|
||||
if local_jsonl is not None:
|
||||
return [record for path in local_jsonl for record in read_jsonl(path)]
|
||||
if (
|
||||
data_files is not None
|
||||
and path in {"json", "parquet", "csv", "text"}
|
||||
and not isinstance(data_files, Mapping)
|
||||
):
|
||||
kwargs["data_files"] = {split: data_files}
|
||||
elif data_files is not None:
|
||||
kwargs["data_files"] = data_files
|
||||
dataset = load_dataset(path, split=split, **kwargs)
|
||||
return [dict(row) for row in dataset]
|
||||
|
||||
|
||||
def _local_jsonl_paths(path: str, data_files: Any, split: str) -> list[Path] | None:
|
||||
if path != "json" or data_files is None:
|
||||
return None
|
||||
selected = data_files.get(split) if isinstance(data_files, Mapping) else data_files
|
||||
if isinstance(selected, str):
|
||||
names = [selected]
|
||||
elif isinstance(selected, Sequence) and not isinstance(selected, (str, bytes)):
|
||||
names = list(selected)
|
||||
else:
|
||||
return None
|
||||
if not names or not all(isinstance(name, str) for name in names):
|
||||
raise ValueError("local JSONL data_files must be a nonempty path or path list")
|
||||
expanded: list[Path] = []
|
||||
for name in names:
|
||||
if urlparse(name).scheme:
|
||||
return None
|
||||
matches = sorted(Path(match) for match in glob(name))
|
||||
if matches:
|
||||
if any(match.suffix != ".jsonl" for match in matches):
|
||||
return None
|
||||
expanded.extend(matches)
|
||||
elif Path(name).suffix == ".jsonl":
|
||||
raise ValueError(f"local JSONL path matched no files: {name}")
|
||||
else:
|
||||
return None
|
||||
if not expanded or not all(item.is_file() for item in expanded):
|
||||
raise ValueError("local JSONL data_files must resolve to regular files")
|
||||
return expanded
|
||||
|
||||
|
||||
def _prepared_cache_source_paths(entries: Sequence[Any]) -> list[Path] | None:
|
||||
paths: list[Path] = []
|
||||
for entry in entries:
|
||||
resolved = _local_jsonl_paths(
|
||||
_value(entry, "path"),
|
||||
_value(entry, "data_files"),
|
||||
str(_value(entry, "split", "train")),
|
||||
)
|
||||
if resolved is None:
|
||||
return None
|
||||
paths.extend(resolved)
|
||||
return paths
|
||||
|
||||
|
||||
def _prepared_cache_eligible(cfg: Any) -> bool:
|
||||
latent = _value(_value(cfg, "diffusion_decision"), "latent")
|
||||
return latent is None or _value(latent, "mode", "none") in {"none", "pad", "mask"}
|
||||
|
||||
|
||||
def _premix_identity(record: Mapping[str, Any]) -> tuple[str, str, str]:
|
||||
return (
|
||||
str(record.get("source", "")),
|
||||
str(record.get("id", "")),
|
||||
str(record.get("group", "")),
|
||||
)
|
||||
|
||||
|
||||
def _validate_premixed_records(records: Sequence[Mapping[str, Any]]) -> None:
|
||||
draw_origins: dict[str, str] = {}
|
||||
content_by_origin: dict[tuple[str, str, str], str] = {}
|
||||
for record in records:
|
||||
metadata = record.get("source_metadata")
|
||||
premix = metadata.get("premix") if isinstance(metadata, Mapping) else None
|
||||
if not isinstance(premix, Mapping):
|
||||
raise ValueError("premixed decision rows require source_metadata.premix")
|
||||
draw_id = premix.get("draw_id")
|
||||
origin = premix.get("origin")
|
||||
if not isinstance(draw_id, str) or not draw_id:
|
||||
raise ValueError("premixed decision rows require a nonempty premix draw_id")
|
||||
if not isinstance(origin, Mapping) or not origin:
|
||||
raise ValueError("premixed decision rows require a premix origin mapping")
|
||||
identity = _premix_identity(record)
|
||||
if (
|
||||
tuple(str(origin.get(field, "")) for field in ("source", "id", "group"))
|
||||
!= identity
|
||||
):
|
||||
raise ValueError(
|
||||
"premixed decision origin must match source, id, and group"
|
||||
)
|
||||
origin_value = canonical_json(origin, ensure_ascii=True)
|
||||
if draw_id in draw_origins:
|
||||
raise ValueError(f"premixed decision draw_id is duplicated: {draw_id}")
|
||||
draw_origins[draw_id] = origin_value
|
||||
content = dict(record)
|
||||
content_metadata = dict(metadata) if isinstance(metadata, Mapping) else {}
|
||||
content_metadata.pop("premix", None)
|
||||
content["source_metadata"] = content_metadata
|
||||
canonical_content = canonical_json(content, ensure_ascii=True, default=str)
|
||||
previous = content_by_origin.setdefault(identity, canonical_content)
|
||||
if previous != canonical_content:
|
||||
raise ValueError(
|
||||
"premixed copies of one decision record must have identical content"
|
||||
)
|
||||
|
||||
|
||||
def _is_eval(entry: Any) -> bool:
|
||||
return str(_value(entry, "split", "train")).lower() in {
|
||||
"eval",
|
||||
"test",
|
||||
"validation",
|
||||
"dev",
|
||||
"calibration",
|
||||
"ood",
|
||||
}
|
||||
|
||||
|
||||
def _family_dev_split(records: Sequence[dict[str, Any]], ratio: float = 0.2):
|
||||
groups: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list)
|
||||
for record in records:
|
||||
groups[family_key(record)].append(record)
|
||||
selected: set[tuple[str, str]] = set()
|
||||
target = round(len(records) * ratio)
|
||||
total = 0
|
||||
for key in sorted(
|
||||
groups,
|
||||
key=lambda item: hashlib.sha256(f"decision-dev-v1:{item}".encode()).hexdigest(),
|
||||
):
|
||||
if total >= target:
|
||||
break
|
||||
size = len(groups[key])
|
||||
if total + size >= len(records):
|
||||
continue
|
||||
selected.add(key)
|
||||
total += size
|
||||
if records:
|
||||
LOG.info(
|
||||
"Automatic decision dev split holds out %s of %s records",
|
||||
total,
|
||||
len(records),
|
||||
)
|
||||
return (
|
||||
[record for record in records if family_key(record) not in selected],
|
||||
[record for record in records if family_key(record) in selected],
|
||||
)
|
||||
|
||||
|
||||
def _thought_delimiters(model_config: Any) -> tuple[tuple[int, ...], tuple[int, ...]]:
|
||||
return (
|
||||
parse_token_ids(_value(model_config, "thought_open_ids"), "thought_open_ids"),
|
||||
parse_token_ids(_value(model_config, "thought_close_ids"), "thought_close_ids"),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_slot_plan(
|
||||
tokenizer: Any, cfg: Any, spec: Any, vocab_size: int
|
||||
) -> tuple[SlotPlan | None, tuple[int, ...], tuple[int, ...]]:
|
||||
decision = _value(cfg, "diffusion_decision")
|
||||
latent = _value(decision, "latent")
|
||||
if latent is None:
|
||||
return None, (), ()
|
||||
mode = _value(latent, "mode", "none")
|
||||
if mode == "none":
|
||||
return None, (), ()
|
||||
model_config = model_config_overrides(cfg)
|
||||
thought_open_ids, thought_close_ids = _thought_delimiters(model_config)
|
||||
token_ids = parse_token_ids(
|
||||
_value(latent, "token_ids", ()) or (), "latent.token_ids"
|
||||
)
|
||||
mask_token_id = resolve_mask_token_id(_value(cfg, "diffusion_lm"), model_config)
|
||||
if mode in {"pinned", "learned", "prompt"}:
|
||||
control_ids = active_control_ids(tokenizer, model_config)
|
||||
control_ids.update(thought_open_ids, thought_close_ids)
|
||||
if mask_token_id is not None:
|
||||
control_ids.add(mask_token_id)
|
||||
collisions = sorted(set(token_ids) & control_ids)
|
||||
if collisions:
|
||||
raise ValueError(
|
||||
"fixed decision slot token_ids cannot use active control IDs: "
|
||||
f"{collisions}"
|
||||
)
|
||||
plan = SlotInit(
|
||||
mode=mode,
|
||||
token_ids=token_ids,
|
||||
num_slots=_value(latent, "num_slots", 0),
|
||||
vocab_size=vocab_size,
|
||||
pad_id=required_token_id(
|
||||
_value(tokenizer, "pad_token_id"), "tokenizer.pad_token_id"
|
||||
),
|
||||
spec=spec,
|
||||
mask_token_id=mask_token_id,
|
||||
).build(seed=int(_value(cfg, "seed", 0)))
|
||||
if (
|
||||
plan.placement == "thought"
|
||||
and _value(spec, "layout") is DiffusionLayout.ENCODER_CANVAS
|
||||
and (not thought_open_ids or not thought_close_ids)
|
||||
):
|
||||
raise ValueError(
|
||||
"encoder-canvas thought slots require `model_config: {thought_open_ids: "
|
||||
"[...], thought_close_ids: [...]}` in the training config"
|
||||
)
|
||||
return plan, thought_open_ids, thought_close_ids
|
||||
|
||||
|
||||
def _canvas_row(
|
||||
tokenizer,
|
||||
record: dict[str, Any],
|
||||
cfg: Any,
|
||||
source_weight: float,
|
||||
*,
|
||||
spec,
|
||||
vocab_size: int | None = None,
|
||||
slot_plan: SlotPlan | None = None,
|
||||
thought_open_ids: Sequence[int] = (),
|
||||
thought_close_ids: Sequence[int] = (),
|
||||
):
|
||||
diffusion = _value(cfg, "diffusion_lm")
|
||||
decision = _value(cfg, "diffusion_decision")
|
||||
model_config = model_config_overrides(cfg)
|
||||
latent = _value(decision, "latent")
|
||||
configured_width = _value(diffusion, "canvas_width")
|
||||
width = 128 if configured_width is None else int(configured_width)
|
||||
if spec.max_canvas is not None:
|
||||
width = min(width, spec.max_canvas)
|
||||
mask_token_id = resolve_mask_token_id(diffusion, model_config)
|
||||
scaffold_ids = (
|
||||
tuple(int(token) for token in (_value(latent, "token_ids", ()) or ()))
|
||||
if slot_plan is None
|
||||
else ()
|
||||
)
|
||||
record = permute_record(
|
||||
record,
|
||||
seed=int(_value(cfg, "seed", 0)),
|
||||
codebook=_value(_value(decision, "labels"), "codebook", "vendored26"),
|
||||
)
|
||||
if vocab_size is None:
|
||||
vocab_size = _value(model_config, "vocab_size")
|
||||
if vocab_size is None:
|
||||
vocab_size = len(tokenizer)
|
||||
turn_close_id = required_token_id(
|
||||
_value(model_config, "turn_close_token_id", _value(tokenizer, "eos_token_id")),
|
||||
"turn_close_token_id",
|
||||
)
|
||||
canvas = build_decision_canvas(
|
||||
tokenizer,
|
||||
record,
|
||||
scaffold_ids=scaffold_ids,
|
||||
turn_close_id=turn_close_id,
|
||||
pad_id=required_token_id(
|
||||
_value(tokenizer, "pad_token_id"), "tokenizer.pad_token_id"
|
||||
),
|
||||
vocab_size=int(vocab_size),
|
||||
width=width,
|
||||
seed=int(_value(cfg, "seed", 0)),
|
||||
steps=int(_value(_value(diffusion, "unroll"), "k_max", 1)),
|
||||
noise_kind="absorbing" if spec.noise is DiffusionNoise.ABSORBING else "uniform",
|
||||
mask_token_id=mask_token_id,
|
||||
slot_plan=slot_plan,
|
||||
thought_open_ids=thought_open_ids,
|
||||
thought_close_ids=thought_close_ids,
|
||||
codebook=_value(_value(decision, "labels"), "codebook", "vendored26"),
|
||||
prevalidated_record=True,
|
||||
)
|
||||
row = {
|
||||
"canvas": canvas,
|
||||
"decision_example": decision_example_from_canvas(
|
||||
canvas, source_weight=source_weight
|
||||
),
|
||||
"source": record["source"],
|
||||
"length": len(canvas.prompt_ids) + len(canvas.canvas_ids),
|
||||
"record": record,
|
||||
"max_questions": int(_value(decision, "max_questions_per_canvas", 20)),
|
||||
}
|
||||
if slot_plan is not None:
|
||||
row["slot_plan"] = slot_plan
|
||||
if _value(latent, "sample_num_slots", False):
|
||||
row["slot_sampling"] = {
|
||||
"seed": int(_value(cfg, "seed", 0)),
|
||||
"max_slots": len(slot_plan.ids),
|
||||
"pad_token_id": required_token_id(
|
||||
_value(tokenizer, "pad_token_id"), "tokenizer.pad_token_id"
|
||||
),
|
||||
"padding_pinned": True,
|
||||
}
|
||||
return row
|
||||
|
||||
|
||||
def _build_canvas_worker(
|
||||
item: tuple[int, dict[str, Any], float],
|
||||
*,
|
||||
tokenizer: Any,
|
||||
cfg: Any,
|
||||
spec: Any,
|
||||
vocab_size: int,
|
||||
slot_plan: SlotPlan | None,
|
||||
thought_open_ids: tuple[int, ...],
|
||||
thought_close_ids: tuple[int, ...],
|
||||
) -> tuple[int, dict[str, Any] | None, bool]:
|
||||
index, record, source_weight = item
|
||||
try:
|
||||
row = _canvas_row(
|
||||
tokenizer,
|
||||
record,
|
||||
cfg,
|
||||
source_weight,
|
||||
spec=spec,
|
||||
vocab_size=vocab_size,
|
||||
slot_plan=slot_plan,
|
||||
thought_open_ids=thought_open_ids,
|
||||
thought_close_ids=thought_close_ids,
|
||||
)
|
||||
except SchemaError as error:
|
||||
if not _is_canvas_overflow(error):
|
||||
raise
|
||||
return index, None, True
|
||||
return index, row, False
|
||||
|
||||
|
||||
class DecisionDataset(Dataset):
|
||||
"""In-memory rows retaining typed canvases and decision-label examples."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
rows: Sequence[dict[str, Any]],
|
||||
manifest: Mapping[str, Any],
|
||||
*,
|
||||
expose_lengths: bool = True,
|
||||
):
|
||||
self._rows = tuple(rows)
|
||||
self.manifest = dict(manifest)
|
||||
self.column_names = ("length",) if expose_lengths else ()
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._rows)
|
||||
|
||||
def __getitem__(
|
||||
self, index: int | str | DecisionDraw | Sequence[int]
|
||||
) -> dict[str, Any] | list[int] | list[dict[str, Any]]:
|
||||
if isinstance(index, str):
|
||||
if index != "length":
|
||||
raise KeyError(index)
|
||||
return [int(row["length"]) for row in self._rows]
|
||||
if isinstance(index, int):
|
||||
return self._rows[index]
|
||||
if isinstance(index, Sequence):
|
||||
rows = []
|
||||
for item in index:
|
||||
if not isinstance(item, int):
|
||||
raise TypeError("packed decision indices must be integers")
|
||||
rows.append(self._rows[item])
|
||||
return rows
|
||||
maximum = self._rows[index.index]
|
||||
recipe = maximum.get("slot_sampling")
|
||||
plan = maximum.get("slot_plan")
|
||||
if not isinstance(recipe, Mapping) or not isinstance(plan, SlotPlan):
|
||||
raise ValueError("decision draw requires a sampled maximum slot row")
|
||||
count = sample_slot_count(
|
||||
seed=int(recipe["seed"]),
|
||||
epoch=index.epoch,
|
||||
global_draw_ordinal=index.global_draw_ordinal,
|
||||
max_slots=int(recipe["max_slots"]),
|
||||
)
|
||||
canvas = project_max_canvas(
|
||||
maximum["canvas"],
|
||||
plan,
|
||||
count,
|
||||
pad_token_id=int(recipe["pad_token_id"]),
|
||||
padding_pinned=bool(recipe["padding_pinned"]),
|
||||
)
|
||||
row = dict(maximum)
|
||||
row["canvas"] = canvas
|
||||
row["slot_plan"] = project_slot_plan(plan, count)
|
||||
row["decision_example"] = decision_example_from_canvas(
|
||||
canvas, source_weight=maximum["decision_example"].source_weight
|
||||
)
|
||||
row["decision_slot_count"] = count
|
||||
row["decision_draw"] = index
|
||||
return row
|
||||
|
||||
def remove_columns(self, columns: Sequence[str]) -> "DecisionDataset":
|
||||
if tuple(columns) != ("length",) or "length" not in self.column_names:
|
||||
raise ValueError("DecisionDataset only exposes the derived length column")
|
||||
return DecisionDataset(self._rows, self.manifest, expose_lengths=False)
|
||||
|
||||
|
||||
def _is_canvas_overflow(error: SchemaError) -> bool:
|
||||
message = str(error)
|
||||
return (
|
||||
message.startswith("answer template is ") and " canvas holds " in message
|
||||
) or (
|
||||
message.startswith("question ")
|
||||
and " alone needs " in message
|
||||
and message.endswith(" canvas rows")
|
||||
)
|
||||
|
||||
|
||||
def _canvas_rows(
|
||||
tokenizer: Any,
|
||||
records: Sequence[dict[str, Any]],
|
||||
cfg: Any,
|
||||
weights: Mapping[str, float],
|
||||
spec,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
canvas_too_long = 0
|
||||
vocab_size = _value(model_config_overrides(cfg), "vocab_size")
|
||||
if vocab_size is None:
|
||||
vocab_size = len(tokenizer)
|
||||
slot_plan, thought_open_ids, thought_close_ids = _resolve_slot_plan(
|
||||
tokenizer, cfg, spec, int(vocab_size)
|
||||
)
|
||||
workers = _value(cfg, "dataset_num_proc", 1) or 1
|
||||
workers = int(workers)
|
||||
if workers > 1:
|
||||
LOG.info(
|
||||
"Preparing %s decision canvases with %s worker threads",
|
||||
len(records),
|
||||
workers,
|
||||
)
|
||||
work = (
|
||||
(index, record, float(weights.get(record["source"], 1.0)))
|
||||
for index, record in enumerate(records)
|
||||
)
|
||||
worker = partial(
|
||||
_build_canvas_worker,
|
||||
tokenizer=tokenizer,
|
||||
cfg=cfg,
|
||||
spec=spec,
|
||||
vocab_size=int(vocab_size),
|
||||
slot_plan=slot_plan,
|
||||
thought_open_ids=tuple(thought_open_ids),
|
||||
thought_close_ids=tuple(thought_close_ids),
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=workers) as executor:
|
||||
prepared = list(executor.map(worker, work))
|
||||
prepared.sort(key=lambda item: item[0])
|
||||
rows = [row for _, row, _ in prepared if row is not None]
|
||||
return rows, sum(overflow for _, _, overflow in prepared)
|
||||
for index, record in enumerate(records):
|
||||
if index % 1000 == 0:
|
||||
LOG.info("Preparing decision canvases: %s/%s", index, len(records))
|
||||
try:
|
||||
rows.append(
|
||||
_canvas_row(
|
||||
tokenizer,
|
||||
record,
|
||||
cfg,
|
||||
float(weights.get(record["source"], 1.0)),
|
||||
spec=spec,
|
||||
vocab_size=int(vocab_size),
|
||||
slot_plan=slot_plan,
|
||||
thought_open_ids=thought_open_ids,
|
||||
thought_close_ids=thought_close_ids,
|
||||
)
|
||||
)
|
||||
except SchemaError as error:
|
||||
if not _is_canvas_overflow(error):
|
||||
raise
|
||||
canvas_too_long += 1
|
||||
return rows, canvas_too_long
|
||||
|
||||
|
||||
def _filter_budget_rows(
|
||||
rows: Sequence[dict[str, Any]], cfg: Any, spec, *, is_eval: bool = False
|
||||
) -> tuple[list[dict[str, Any]], dict[str, int]]:
|
||||
logical_limit = _value(cfg, "sequence_len")
|
||||
sample_packed = bool(_value(cfg, "sample_packing", False)) and (
|
||||
not is_eval or _value(cfg, "eval_sample_packing") is not False
|
||||
)
|
||||
packed = bool(_value(cfg, "batch_flattening", False)) or sample_packed
|
||||
resolved = (
|
||||
resolve_native_packing_budget(
|
||||
DictDefault(cfg) if isinstance(cfg, Mapping) else cfg,
|
||||
packed=packed,
|
||||
batch_size=(
|
||||
_value(cfg, "micro_batch_size")
|
||||
if sample_packed
|
||||
else _value(cfg, "eval_batch_size")
|
||||
if is_eval
|
||||
else _value(cfg, "micro_batch_size")
|
||||
),
|
||||
)
|
||||
if packed
|
||||
else None
|
||||
)
|
||||
physical_limit = None if resolved is None else resolved.payload_capacity
|
||||
kept: list[dict[str, Any]] = []
|
||||
drops = {"logical": 0, "physical": 0}
|
||||
for row in rows:
|
||||
canvas = row["canvas"]
|
||||
reason = exceeds_budget(
|
||||
decision_budget(
|
||||
prompt_tokens=len(canvas.prompt_ids),
|
||||
canvas_tokens=len(canvas.canvas_ids),
|
||||
layout=spec.layout,
|
||||
),
|
||||
logical_limit=None if logical_limit is None else int(logical_limit),
|
||||
physical_limit=None if physical_limit is None else int(physical_limit),
|
||||
)
|
||||
if reason is None:
|
||||
kept.append(row)
|
||||
else:
|
||||
drops[reason] += 1
|
||||
return kept, drops
|
||||
|
||||
|
||||
def load_decision_datasets(
|
||||
cfg: Any, preprocess: bool = False, *, tokenizer: Any = None
|
||||
) -> TrainDatasetMeta:
|
||||
"""Load, normalize, decontaminate, canvasize, and return plugin-owned splits."""
|
||||
del preprocess
|
||||
decision = _value(cfg, "diffusion_decision")
|
||||
spec = require_diffusion_spec(cfg)
|
||||
train_entries = [
|
||||
entry
|
||||
for entry in (_value(cfg, "datasets", []) or [])
|
||||
if str(_value(entry, "type", "")).startswith("diffusion_decision.")
|
||||
]
|
||||
test_entries = [
|
||||
entry
|
||||
for entry in (_value(cfg, "test_datasets", []) or [])
|
||||
if str(_value(entry, "type", "")).startswith("diffusion_decision.")
|
||||
]
|
||||
if not train_entries:
|
||||
raise ValueError("diffusion_decision requires at least one decision dataset")
|
||||
if tokenizer is None:
|
||||
tokenizer = load_tokenizer(cfg)
|
||||
prepared_path = _value(cfg, "dataset_prepared_path")
|
||||
cache_identity = None
|
||||
if (
|
||||
isinstance(prepared_path, (str, Path))
|
||||
and str(prepared_path)
|
||||
and _prepared_cache_eligible(cfg)
|
||||
):
|
||||
source_paths = _prepared_cache_source_paths([*train_entries, *test_entries])
|
||||
if source_paths is not None:
|
||||
cache_identity = prepared_cache_identity(cfg, tokenizer, spec, source_paths)
|
||||
else:
|
||||
LOG.info("Decision prepared-row cache bypassed: nonlocal or opaque source")
|
||||
elif isinstance(prepared_path, (str, Path)) and str(prepared_path):
|
||||
LOG.info("Decision prepared-row cache bypassed: unsupported latent mode")
|
||||
if cache_identity is not None:
|
||||
cache_key, cache_payload = cache_identity
|
||||
cached = load_prepared_cache(Path(prepared_path), cache_key, cache_payload)
|
||||
if cached is not None:
|
||||
cached_train, cached_eval, cached_manifest = cached
|
||||
LOG.info("Decision prepared-row cache hit")
|
||||
return TrainDatasetMeta(
|
||||
train_dataset=DecisionDataset(cached_train, cached_manifest),
|
||||
eval_dataset=DecisionDataset(cached_eval, cached_manifest)
|
||||
if cached_eval
|
||||
else None,
|
||||
total_num_steps=None,
|
||||
)
|
||||
LOG.info("Decision prepared-row cache miss")
|
||||
train: list[dict[str, Any]] = []
|
||||
explicit_eval: list[dict[str, Any]] = []
|
||||
protected_eval: list[dict[str, Any]] = []
|
||||
for entry, force_dev in [
|
||||
*((entry, False) for entry in train_entries),
|
||||
*((entry, True) for entry in test_entries),
|
||||
]:
|
||||
adapter = str(_value(entry, "type"))
|
||||
LOG.info(
|
||||
"Loading decision source %s (%s)",
|
||||
_value(entry, "path"),
|
||||
_value(entry, "split", "train"),
|
||||
)
|
||||
normalized = [
|
||||
normalize_record(
|
||||
adapter,
|
||||
row,
|
||||
training=not (_is_eval(entry) or force_dev),
|
||||
source_split=str(_value(entry, "split", "train")).lower(),
|
||||
codebook=_value(_value(decision, "labels"), "codebook", "vendored26"),
|
||||
)
|
||||
for row in _rows(entry)
|
||||
]
|
||||
split = str(_value(entry, "split", "train")).lower()
|
||||
if split in {"test", "calibration", "ood"}:
|
||||
protected_eval.extend(normalized)
|
||||
elif force_dev or split in {"dev", "validation", "eval"}:
|
||||
explicit_eval.extend(normalized)
|
||||
elif _is_eval(entry):
|
||||
protected_eval.extend(normalized)
|
||||
else:
|
||||
train.extend(normalized)
|
||||
if explicit_eval:
|
||||
dev = explicit_eval
|
||||
else:
|
||||
train, dev = _family_dev_split(train)
|
||||
LOG.info(
|
||||
"Decontaminating %s training records against %s development and %s protected records",
|
||||
len(train),
|
||||
len(dev),
|
||||
len(protected_eval),
|
||||
)
|
||||
mixture = _value(decision, "mixture")
|
||||
premixed = bool(_value(mixture, "premixed", False))
|
||||
retained, drops = decontaminate(train, [*dev, *protected_eval])
|
||||
if premixed and len(retained) != len(train):
|
||||
raise ValueError("premixed decision rows overlap an evaluation split")
|
||||
train = [dict(record) for record in retained]
|
||||
max_questions = int(_value(decision, "max_questions_per_canvas", 20))
|
||||
if premixed:
|
||||
_validate_premixed_records(train)
|
||||
else:
|
||||
LOG.info("Grouping compatible decision records")
|
||||
train = group_records(train, max_questions=max_questions)
|
||||
dev = group_records(dev, max_questions=max_questions)
|
||||
assert_split_isolation({"train": train, "eval": dev})
|
||||
weights = _value(_value(decision, "mixture"), "loss_weight", {}) or {}
|
||||
train_canvas_too_long = 0
|
||||
train_budget_drops = {"logical": 0, "physical": 0}
|
||||
pools = None
|
||||
sampled_batches: tuple[tuple[MixtureSample[dict[str, Any]], ...], ...] = ()
|
||||
sampler = None
|
||||
if premixed:
|
||||
train_rows, train_canvas_too_long = _canvas_rows(
|
||||
tokenizer, [dict(record) for record in train], cfg, weights, spec
|
||||
)
|
||||
train_rows, train_budget_drops = _filter_budget_rows(train_rows, cfg, spec)
|
||||
if len(train_rows) != len(train):
|
||||
raise ValueError(
|
||||
"premixed decision rows must all pass canvas and budget validation"
|
||||
)
|
||||
else:
|
||||
pools = prepare_source_pools(
|
||||
train,
|
||||
(),
|
||||
max_examples_per_source=_value(mixture, "max_examples_per_source"),
|
||||
seed=int(_value(cfg, "seed", 0)),
|
||||
)
|
||||
usable_pools: dict[str, tuple[dict[str, Any], ...]] = {}
|
||||
for source, records in pools.records.items():
|
||||
rows, dropped = _canvas_rows(
|
||||
tokenizer, [dict(record) for record in records], cfg, weights, spec
|
||||
)
|
||||
train_canvas_too_long += dropped
|
||||
rows, budget_drops = _filter_budget_rows(rows, cfg, spec)
|
||||
for reason, count in budget_drops.items():
|
||||
train_budget_drops[reason] += count
|
||||
if rows:
|
||||
usable_pools[source] = tuple(rows)
|
||||
configured_weights = _value(mixture, "weights")
|
||||
if isinstance(configured_weights, Mapping):
|
||||
configured_weights = {
|
||||
source: configured_weights[source]
|
||||
for source in usable_pools
|
||||
if source in configured_weights
|
||||
}
|
||||
sampler = DeterministicMixtureSampler(
|
||||
usable_pools,
|
||||
weights=configured_weights,
|
||||
temperature=_value(mixture, "temperature"),
|
||||
seed=int(_value(cfg, "seed", 0)),
|
||||
)
|
||||
batch_size = int(_value(cfg, "micro_batch_size", 1))
|
||||
sampled_batches = tuple(
|
||||
sampler.epoch_batches(
|
||||
batch_size,
|
||||
stratified=bool(_value(mixture, "per_batch_stratified", True)),
|
||||
)
|
||||
)
|
||||
train_rows = [
|
||||
dict(sample.record) for batch in sampled_batches for sample in batch
|
||||
]
|
||||
eval_rows, eval_canvas_too_long = _canvas_rows(tokenizer, dev, cfg, weights, spec)
|
||||
eval_rows, eval_budget_drops = _filter_budget_rows(
|
||||
eval_rows, cfg, spec, is_eval=True
|
||||
)
|
||||
drop_counts = dict(drops)
|
||||
if pools is not None:
|
||||
drop_counts.update(
|
||||
{
|
||||
key: value
|
||||
for key, value in pools.counts.items()
|
||||
if key not in drop_counts
|
||||
}
|
||||
)
|
||||
manifest = {
|
||||
"train_rows": len(train_rows),
|
||||
"eval_rows": len(eval_rows),
|
||||
"protected_eval_rows": len(protected_eval),
|
||||
"train_sources": sorted({row["source"] for row in train}),
|
||||
"eval_sources": sorted({row["source"] for row in dev}),
|
||||
"dropped": drop_counts,
|
||||
"canvas_too_long": {
|
||||
"train": train_canvas_too_long,
|
||||
"eval": eval_canvas_too_long,
|
||||
},
|
||||
"budget_drops": {"train": train_budget_drops, "eval": eval_budget_drops},
|
||||
"mixture_probabilities": dict(sampler.probabilities) if sampler else {},
|
||||
"premixed": premixed,
|
||||
"per_batch_stratified": bool(_value(mixture, "per_batch_stratified", True)),
|
||||
"stratified_micro_batch_size": int(_value(cfg, "micro_batch_size", 1)),
|
||||
"mixture_seed": int(_value(cfg, "seed", 0)),
|
||||
"stratified_epoch_batches": tuple(
|
||||
tuple(sample.source for sample in batch) for batch in sampled_batches
|
||||
),
|
||||
}
|
||||
if any(
|
||||
_value(record.get("source_metadata", {}), "official_split") is not None
|
||||
for record in [*train, *dev, *protected_eval]
|
||||
if record["source"] == "procedural"
|
||||
):
|
||||
manifest["procedural_grouping_limit"] = (
|
||||
"official partitions and state decontamination only; no parent-family key"
|
||||
)
|
||||
if cache_identity is not None:
|
||||
store_prepared_cache(
|
||||
Path(prepared_path),
|
||||
cache_key,
|
||||
cache_payload,
|
||||
cfg,
|
||||
train_rows,
|
||||
eval_rows,
|
||||
manifest,
|
||||
)
|
||||
LOG.info(
|
||||
"Prepared %s training draws and %s evaluation rows; budget drops: %s",
|
||||
len(train_rows),
|
||||
len(eval_rows),
|
||||
manifest["budget_drops"],
|
||||
)
|
||||
return TrainDatasetMeta(
|
||||
train_dataset=DecisionDataset(train_rows, manifest),
|
||||
eval_dataset=DecisionDataset(eval_rows, manifest) if eval_rows else None,
|
||||
total_num_steps=None,
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Group compatible normalized decision records without changing their labels."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
|
||||
def group_records(
|
||||
records: Sequence[Mapping[str, Any]], *, max_questions: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Combine only records with identical serving context and split without loss."""
|
||||
if max_questions <= 0:
|
||||
raise ValueError("max_questions must be positive")
|
||||
groups: OrderedDict[tuple[str, str, str, bytes, bytes], list[Mapping[str, Any]]] = (
|
||||
OrderedDict()
|
||||
)
|
||||
for record in records:
|
||||
groups.setdefault(_key(record), []).append(record)
|
||||
result: list[dict[str, Any]] = []
|
||||
for group in groups.values():
|
||||
result.extend(_split_group(group, max_questions))
|
||||
return result
|
||||
|
||||
|
||||
def _key(record: Mapping[str, Any]) -> tuple[str, str, str, bytes, bytes]:
|
||||
source = record.get("source")
|
||||
group = record.get("group")
|
||||
if not isinstance(source, str) or not source:
|
||||
raise ValueError("decision record requires a nonempty source")
|
||||
if not isinstance(group, str) or not group:
|
||||
raise ValueError("decision record requires a nonempty source group")
|
||||
family = record.get("family", group)
|
||||
if not isinstance(family, str) or not family:
|
||||
raise ValueError("decision record requires a nonempty family when present")
|
||||
return (
|
||||
source,
|
||||
group,
|
||||
family,
|
||||
_bytes(record.get("state")),
|
||||
_bytes(record.get("instructions", "")),
|
||||
)
|
||||
|
||||
|
||||
def _bytes(value: Any) -> bytes:
|
||||
if isinstance(value, str):
|
||||
return value.encode("utf-8")
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
sort_keys=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _split_group(
|
||||
records: Sequence[Mapping[str, Any]], max_questions: int
|
||||
) -> list[dict[str, Any]]:
|
||||
if len(records) == 1:
|
||||
record = records[0]
|
||||
questions = record.get("questions")
|
||||
labels = record.get("labels")
|
||||
if not isinstance(questions, Mapping) or not isinstance(labels, Mapping):
|
||||
raise ValueError("decision record requires questions and labels mappings")
|
||||
if not questions:
|
||||
raise ValueError("decision record requires at least one question")
|
||||
if set(questions) != set(labels):
|
||||
raise ValueError("decision record question and label ids differ")
|
||||
if len(questions) <= max_questions:
|
||||
return [copy.deepcopy(dict(record))]
|
||||
chunks: list[dict[str, Any]] = []
|
||||
current: dict[str, Any] | None = None
|
||||
used: set[str] = set()
|
||||
seen_ids: set[str] = set()
|
||||
for position, record in enumerate(records):
|
||||
questions = record.get("questions")
|
||||
labels = record.get("labels")
|
||||
record_id = _record_id(record, position)
|
||||
if not isinstance(questions, Mapping) or not isinstance(labels, Mapping):
|
||||
raise ValueError("decision record requires questions and labels mappings")
|
||||
if not questions:
|
||||
raise ValueError("decision record requires at least one question")
|
||||
if record_id in seen_ids:
|
||||
raise ValueError(f"decision record id is duplicated: {record_id}")
|
||||
seen_ids.add(record_id)
|
||||
if set(questions) != set(labels):
|
||||
raise ValueError("decision record question and label ids differ")
|
||||
for question_id, question in questions.items():
|
||||
if not isinstance(question_id, str) or not question_id:
|
||||
raise ValueError("decision question ids must be nonempty strings")
|
||||
if current is None or len(current["questions"]) == max_questions:
|
||||
current = _new_chunk(records[0], len(chunks))
|
||||
chunks.append(current)
|
||||
used = set()
|
||||
output_id = _unique_id(question_id, used)
|
||||
used.add(output_id)
|
||||
current["questions"][output_id] = copy.deepcopy(question)
|
||||
current["labels"][output_id] = copy.deepcopy(labels[question_id])
|
||||
current["grouped_record_ids"].append(record_id)
|
||||
current["grouped_source_metadata"][record_id] = copy.deepcopy(
|
||||
record.get("source_metadata", {})
|
||||
)
|
||||
current["grouped_question_metadata"][output_id] = {
|
||||
"record_id": record_id,
|
||||
"original_question_id": question_id,
|
||||
"source_metadata": copy.deepcopy(record.get("source_metadata", {})),
|
||||
}
|
||||
for chunk in chunks:
|
||||
chunk["grouped_record_ids"] = tuple(dict.fromkeys(chunk["grouped_record_ids"]))
|
||||
return chunks
|
||||
|
||||
|
||||
def _record_id(record: Mapping[str, Any], position: int) -> str:
|
||||
record_id = record.get("id")
|
||||
if isinstance(record_id, str) and record_id:
|
||||
return record_id
|
||||
if record_id is not None:
|
||||
raise ValueError("decision record id must be a nonempty string")
|
||||
return f"{record.get('group')}#record{position}"
|
||||
|
||||
|
||||
def _new_chunk(record: Mapping[str, Any], index: int) -> dict[str, Any]:
|
||||
result = {
|
||||
key: copy.deepcopy(value)
|
||||
for key, value in record.items()
|
||||
if key not in {"questions", "labels", "id", "source_metadata"}
|
||||
}
|
||||
result["id"] = f"{_record_id(record, 0)}#group{index}"
|
||||
result["source_metadata"] = copy.deepcopy(record.get("source_metadata", {}))
|
||||
result["questions"] = OrderedDict()
|
||||
result["labels"] = OrderedDict()
|
||||
result["grouped_record_ids"] = []
|
||||
result["grouped_question_metadata"] = {}
|
||||
result["grouped_source_metadata"] = {}
|
||||
return result
|
||||
|
||||
|
||||
def _unique_id(question_id: str, used: set[str]) -> str:
|
||||
if question_id not in used:
|
||||
return question_id
|
||||
candidate = f"{question_id}__2"
|
||||
suffix = 2
|
||||
while candidate in used:
|
||||
suffix += 1
|
||||
candidate = f"{question_id}__{suffix}"
|
||||
return candidate
|
||||
@@ -0,0 +1,86 @@
|
||||
"""State decontamination and family boundaries for decision datasets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from ._util import canonical_json
|
||||
|
||||
|
||||
def state_fingerprint(state: Any) -> str:
|
||||
"""Hash canonical state content independently of questions and targets."""
|
||||
if state is None:
|
||||
raise ValueError("decision record requires a state")
|
||||
if isinstance(state, str):
|
||||
try:
|
||||
state = json.loads(state)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
if isinstance(state, str):
|
||||
text = " ".join(unicodedata.normalize("NFC", state).split())
|
||||
else:
|
||||
text = canonical_json(state, allow_nan=False)
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def family_key(record: Mapping[str, Any]) -> tuple[str, str]:
|
||||
source = record.get("source")
|
||||
family = record.get("family") or record.get("group")
|
||||
if not isinstance(source, str) or not source:
|
||||
raise ValueError("decision record requires a source")
|
||||
if not isinstance(family, str) or not family:
|
||||
raise ValueError("decision record requires a family or group")
|
||||
return source, family
|
||||
|
||||
|
||||
def decontaminate(
|
||||
training: Iterable[Mapping[str, Any]],
|
||||
evaluation: Iterable[Mapping[str, Any]],
|
||||
*,
|
||||
exclude_families: bool = True,
|
||||
) -> tuple[list[Mapping[str, Any]], dict[str, int]]:
|
||||
"""Remove evaluation states across sources and optionally held-out families."""
|
||||
eval_states: set[str] = set()
|
||||
eval_families: set[tuple[str, str]] = set()
|
||||
for record in evaluation:
|
||||
eval_states.add(state_fingerprint(record["state"]))
|
||||
eval_families.add(family_key(record))
|
||||
kept = []
|
||||
counts = {"input": 0, "state_overlap": 0, "family_overlap": 0, "kept": 0}
|
||||
for record in training:
|
||||
counts["input"] += 1
|
||||
key = family_key(record)
|
||||
if state_fingerprint(record["state"]) in eval_states:
|
||||
counts["state_overlap"] += 1
|
||||
elif exclude_families and key in eval_families:
|
||||
counts["family_overlap"] += 1
|
||||
else:
|
||||
kept.append(record)
|
||||
counts["kept"] += 1
|
||||
return kept, counts
|
||||
|
||||
|
||||
def assert_split_isolation(
|
||||
splits: Mapping[str, Iterable[Mapping[str, Any]]],
|
||||
) -> None:
|
||||
"""Reject state or source-family leakage between named dataset splits."""
|
||||
states: dict[str, str] = {}
|
||||
families: dict[tuple[str, str], str] = {}
|
||||
for split, records in splits.items():
|
||||
for record in records:
|
||||
fingerprint = state_fingerprint(record["state"])
|
||||
key = family_key(record)
|
||||
if fingerprint in states and states[fingerprint] != split:
|
||||
raise ValueError(
|
||||
f"state overlaps splits {states[fingerprint]!r} and {split!r}"
|
||||
)
|
||||
if key in families and families[key] != split:
|
||||
raise ValueError(
|
||||
f"family {key!r} overlaps splits {families[key]!r} and {split!r}"
|
||||
)
|
||||
states[fingerprint] = split
|
||||
families[key] = split
|
||||
@@ -0,0 +1,926 @@
|
||||
"""Typed label objectives for decision canvases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Mapping, Sequence, TypeAlias
|
||||
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from .records import DecisionCanvas
|
||||
|
||||
LabelSoftmax = Literal["restricted", "full", "both"]
|
||||
FullCEWeighting = Literal["ce", "dft"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HardLabel:
|
||||
"""A single correct index among a question's allowed token IDs."""
|
||||
|
||||
gold_index: int
|
||||
smoothing: float | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DistributionLabel:
|
||||
"""A dense or sparse probability target for a question's allowed token IDs.
|
||||
|
||||
``candidate_indices`` makes ``probabilities`` sparse. ``other_probability``
|
||||
is the target mass for every vocabulary token outside those candidates and is
|
||||
only meaningful with the full-vocabulary objective.
|
||||
"""
|
||||
|
||||
probabilities: tuple[float, ...]
|
||||
candidate_indices: tuple[int, ...] | None = None
|
||||
other_probability: float = 0.0
|
||||
candidate_ids: tuple[str, ...] | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SetLabel:
|
||||
"""Indices of every correct alternative among the allowed token IDs."""
|
||||
|
||||
allowed_indices: tuple[int, ...]
|
||||
|
||||
|
||||
DecisionLabelTarget: TypeAlias = HardLabel | DistributionLabel | SetLabel
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionLabelQuestion:
|
||||
"""One supervised canvas position and its typed label target."""
|
||||
|
||||
position: int
|
||||
allowed_token_ids: tuple[int, ...]
|
||||
target: DecisionLabelTarget
|
||||
weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionLabelExample:
|
||||
"""Questions from one canvas, weighted as one training example."""
|
||||
|
||||
questions: tuple[DecisionLabelQuestion, ...]
|
||||
source_weight: float = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionLossResult:
|
||||
"""Source-weighted question/example averages for logging and optimization."""
|
||||
|
||||
loss: torch.Tensor
|
||||
restricted_loss: torch.Tensor
|
||||
full_vocab_loss: torch.Tensor
|
||||
brier_loss: torch.Tensor
|
||||
effective_full_vocab_loss: torch.Tensor | None = None
|
||||
full_vocab_dft_hard_weight_sum: torch.Tensor | None = None
|
||||
full_vocab_dft_hard_count: torch.Tensor | None = None
|
||||
per_example_loss: tuple[torch.Tensor, ...] | None = None
|
||||
per_example_restricted_loss: tuple[torch.Tensor, ...] | None = None
|
||||
per_example_full_vocab_loss: tuple[torch.Tensor, ...] | None = None
|
||||
per_example_effective_full_vocab_loss: tuple[torch.Tensor, ...] | None = None
|
||||
per_example_full_vocab_dft_hard_weight_sum: tuple[torch.Tensor, ...] | None = None
|
||||
per_example_full_vocab_dft_hard_count: tuple[torch.Tensor, ...] | None = None
|
||||
per_example_brier_loss: tuple[torch.Tensor, ...] | None = None
|
||||
|
||||
|
||||
def _optional_smoothing(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, (float, int))
|
||||
or not math.isfinite(value)
|
||||
or not 0.0 <= value < 1.0
|
||||
):
|
||||
raise ValueError("hard target smoothing must be finite and in [0, 1)")
|
||||
return float(value)
|
||||
|
||||
|
||||
def label_target_from_mapping(target: Mapping[str, object]) -> DecisionLabelTarget:
|
||||
"""Convert a normalized-record target into the typed loss contract."""
|
||||
kind = target.get("kind")
|
||||
if kind == "hard":
|
||||
return HardLabel(
|
||||
_index(target.get("gold_idx"), "gold_idx"),
|
||||
_optional_smoothing(target.get("smoothing")),
|
||||
)
|
||||
if kind == "dist":
|
||||
values = target.get("probs")
|
||||
if not isinstance(values, (list, tuple)) or not values:
|
||||
raise ValueError("dist target requires a nonempty probs sequence")
|
||||
probabilities = tuple(_probability(value) for value in values)
|
||||
indices = target.get("candidate_indices")
|
||||
if indices is None:
|
||||
candidate_indices = None
|
||||
else:
|
||||
if not isinstance(indices, (list, tuple)) or len(indices) != len(values):
|
||||
raise ValueError("dist candidate_indices must align with probs")
|
||||
candidate_indices = tuple(
|
||||
_index(value, "candidate_indices") for value in indices
|
||||
)
|
||||
other_probability = _probability(target.get("other_probability", 0.0))
|
||||
candidate_ids_value = target.get("candidate_ids")
|
||||
if candidate_ids_value is None:
|
||||
candidate_ids = None
|
||||
elif (
|
||||
not isinstance(candidate_ids_value, (list, tuple))
|
||||
or len(candidate_ids_value) != len(probabilities)
|
||||
or any(
|
||||
not isinstance(value, str) or not value for value in candidate_ids_value
|
||||
)
|
||||
):
|
||||
raise ValueError("dist candidate_ids must align with probs")
|
||||
else:
|
||||
candidate_ids = tuple(candidate_ids_value)
|
||||
_validate_distribution_values(
|
||||
probabilities, candidate_indices, other_probability
|
||||
)
|
||||
return DistributionLabel(
|
||||
probabilities, candidate_indices, other_probability, candidate_ids
|
||||
)
|
||||
if kind == "set":
|
||||
values = target.get("allowed_set")
|
||||
if not isinstance(values, (list, tuple)) or not values:
|
||||
raise ValueError("set target requires a nonempty allowed_set sequence")
|
||||
indices = tuple(_index(value, "allowed_set") for value in values)
|
||||
if len(set(indices)) != len(indices):
|
||||
raise ValueError("set target indices must be unique")
|
||||
return SetLabel(indices)
|
||||
raise ValueError(f"unknown decision label kind {kind!r}")
|
||||
|
||||
|
||||
def decision_example_from_canvas(
|
||||
canvas: DecisionCanvas, *, source_weight: float = 1.0
|
||||
) -> DecisionLabelExample:
|
||||
"""Adapt a validated decision canvas for the label-loss API."""
|
||||
questions = tuple(
|
||||
DecisionLabelQuestion(
|
||||
position=int(position),
|
||||
allowed_token_ids=tuple(int(token) for token in allowed_ids),
|
||||
target=(
|
||||
target
|
||||
if isinstance(target, (HardLabel, DistributionLabel, SetLabel))
|
||||
else label_target_from_mapping(target)
|
||||
),
|
||||
weight=_question_weight(target),
|
||||
)
|
||||
for position, allowed_ids, target in zip(
|
||||
canvas.label_positions, canvas.allowed_ids, canvas.targets, strict=True
|
||||
)
|
||||
)
|
||||
return DecisionLabelExample(questions=questions, source_weight=source_weight)
|
||||
|
||||
|
||||
def decision_label_loss(
|
||||
logits: torch.Tensor,
|
||||
examples: Sequence[DecisionLabelExample],
|
||||
supervision_mask: torch.Tensor,
|
||||
*,
|
||||
label_softmax: LabelSoftmax = "both",
|
||||
full_ce_weighting: FullCEWeighting = "ce",
|
||||
brier_weight: float = 0.1,
|
||||
hard_label_smoothing: float = 0.0,
|
||||
) -> DecisionLossResult:
|
||||
"""Compute typed label losses using only supervised label positions.
|
||||
|
||||
``both`` applies restricted and full-vocabulary CE to every label kind.
|
||||
Set labels use a detached highest-scoring member of the correct set for
|
||||
full-vocabulary CE while retaining their set-mass objective.
|
||||
"""
|
||||
_validate_inputs(
|
||||
logits,
|
||||
examples,
|
||||
supervision_mask,
|
||||
label_softmax,
|
||||
full_ce_weighting,
|
||||
brier_weight,
|
||||
hard_label_smoothing,
|
||||
)
|
||||
restricted_examples = []
|
||||
full_vocab_examples = []
|
||||
effective_full_vocab_examples = []
|
||||
full_vocab_dft_hard_weight_sum_examples = []
|
||||
full_vocab_dft_hard_count_examples = []
|
||||
brier_examples = []
|
||||
for batch_index, example in enumerate(examples):
|
||||
_validate_source_weight(example.source_weight)
|
||||
restricted_questions = []
|
||||
full_vocab_questions = []
|
||||
effective_full_vocab_questions = []
|
||||
full_vocab_dft_hard_weight_sums = []
|
||||
full_vocab_dft_hard_counts = []
|
||||
brier_questions = []
|
||||
for question in example.questions:
|
||||
query_logits = logits[batch_index, question.position].float()
|
||||
allowed_ids = _allowed_ids(
|
||||
question.allowed_token_ids, query_logits.shape[0]
|
||||
)
|
||||
allowed_ids = allowed_ids.to(query_logits.device)
|
||||
restricted_logprobs = F.log_softmax(
|
||||
query_logits.index_select(0, allowed_ids), dim=0
|
||||
)
|
||||
restricted_probs = restricted_logprobs.exp()
|
||||
if (
|
||||
isinstance(question.target, DistributionLabel)
|
||||
and question.target.other_probability > 0
|
||||
and label_softmax == "full"
|
||||
):
|
||||
restricted = query_logits.new_zeros(())
|
||||
brier = query_logits.new_zeros(())
|
||||
full_target = allowed_ids[0]
|
||||
full_distribution = None
|
||||
else:
|
||||
restricted, brier, full_target, full_distribution = (
|
||||
_restricted_question_loss(
|
||||
restricted_logprobs,
|
||||
restricted_probs,
|
||||
allowed_ids,
|
||||
question.target,
|
||||
hard_label_smoothing=hard_label_smoothing,
|
||||
)
|
||||
)
|
||||
zero = query_logits.new_zeros(())
|
||||
if label_softmax == "restricted":
|
||||
full_vocab = zero
|
||||
effective_full_vocab = zero
|
||||
dft_hard_weight_sum = zero
|
||||
dft_hard_count = zero
|
||||
else:
|
||||
full_logprobs = F.log_softmax(query_logits, dim=0)
|
||||
full_vocab = _full_vocab_question_loss(
|
||||
full_logprobs,
|
||||
allowed_ids,
|
||||
question.target,
|
||||
full_target,
|
||||
full_distribution,
|
||||
)
|
||||
dft_hard = full_ce_weighting == "dft" and isinstance(
|
||||
question.target, HardLabel
|
||||
)
|
||||
dft_weight = (-full_vocab.detach()).exp() if dft_hard else zero
|
||||
effective_full_vocab = (
|
||||
full_vocab * dft_weight if dft_hard else full_vocab
|
||||
)
|
||||
dft_hard_weight_sum = dft_weight
|
||||
dft_hard_count = zero.new_ones(()) if dft_hard else zero
|
||||
if label_softmax == "full":
|
||||
restricted = zero
|
||||
restricted_questions.append(restricted)
|
||||
full_vocab_questions.append(full_vocab)
|
||||
effective_full_vocab_questions.append(effective_full_vocab)
|
||||
full_vocab_dft_hard_weight_sums.append(dft_hard_weight_sum)
|
||||
full_vocab_dft_hard_counts.append(dft_hard_count)
|
||||
brier_questions.append(brier)
|
||||
weight = example.source_weight
|
||||
qw = [question.weight for question in example.questions]
|
||||
restricted_examples.append(_weighted_mean(restricted_questions, qw) * weight)
|
||||
full_vocab_examples.append(_weighted_mean(full_vocab_questions, qw) * weight)
|
||||
effective_full_vocab_examples.append(
|
||||
_weighted_mean(effective_full_vocab_questions, qw) * weight
|
||||
)
|
||||
full_vocab_dft_hard_weight_sum_examples.append(
|
||||
torch.stack(full_vocab_dft_hard_weight_sums).sum()
|
||||
)
|
||||
full_vocab_dft_hard_count_examples.append(
|
||||
torch.stack(full_vocab_dft_hard_counts).sum()
|
||||
)
|
||||
brier_examples.append(_weighted_mean(brier_questions, qw) * weight)
|
||||
restricted_loss = torch.stack(restricted_examples).mean()
|
||||
full_vocab_loss = torch.stack(full_vocab_examples).mean()
|
||||
effective_full_vocab_loss = torch.stack(effective_full_vocab_examples).mean()
|
||||
full_vocab_dft_hard_weight_sum = torch.stack(
|
||||
full_vocab_dft_hard_weight_sum_examples
|
||||
).sum()
|
||||
full_vocab_dft_hard_count = torch.stack(full_vocab_dft_hard_count_examples).sum()
|
||||
brier_loss = torch.stack(brier_examples).mean()
|
||||
metric_restricted = (
|
||||
restricted_examples
|
||||
if label_softmax != "full"
|
||||
else [restricted_loss.new_zeros(()) for _ in examples]
|
||||
)
|
||||
return DecisionLossResult(
|
||||
loss=restricted_loss + effective_full_vocab_loss + brier_weight * brier_loss,
|
||||
restricted_loss=restricted_loss,
|
||||
full_vocab_loss=full_vocab_loss,
|
||||
brier_loss=brier_loss,
|
||||
effective_full_vocab_loss=effective_full_vocab_loss,
|
||||
full_vocab_dft_hard_weight_sum=full_vocab_dft_hard_weight_sum,
|
||||
full_vocab_dft_hard_count=full_vocab_dft_hard_count,
|
||||
per_example_loss=tuple(
|
||||
restricted.detach()
|
||||
+ effective_full.detach()
|
||||
+ brier_weight * brier.detach()
|
||||
for restricted, effective_full, brier in zip(
|
||||
metric_restricted,
|
||||
effective_full_vocab_examples,
|
||||
brier_examples,
|
||||
strict=True,
|
||||
)
|
||||
),
|
||||
per_example_restricted_loss=(
|
||||
tuple(value.detach() for value in metric_restricted)
|
||||
),
|
||||
per_example_full_vocab_loss=tuple(
|
||||
value.detach() for value in full_vocab_examples
|
||||
),
|
||||
per_example_effective_full_vocab_loss=tuple(
|
||||
value.detach() for value in effective_full_vocab_examples
|
||||
),
|
||||
per_example_full_vocab_dft_hard_weight_sum=tuple(
|
||||
value.detach() for value in full_vocab_dft_hard_weight_sum_examples
|
||||
),
|
||||
per_example_full_vocab_dft_hard_count=tuple(
|
||||
value.detach() for value in full_vocab_dft_hard_count_examples
|
||||
),
|
||||
per_example_brier_loss=tuple(value.detach() for value in brier_examples),
|
||||
)
|
||||
|
||||
|
||||
def decision_label_loss_from_hidden(
|
||||
hidden: torch.Tensor,
|
||||
head: torch.nn.Linear,
|
||||
examples: Sequence[DecisionLabelExample],
|
||||
supervision_mask: torch.Tensor,
|
||||
*,
|
||||
linear_token_loss: Callable[
|
||||
[torch.Tensor, torch.nn.Linear, torch.Tensor], torch.Tensor
|
||||
],
|
||||
label_softmax: LabelSoftmax = "both",
|
||||
full_ce_weighting: FullCEWeighting = "ce",
|
||||
brier_weight: float = 0.1,
|
||||
hard_label_smoothing: float = 0.0,
|
||||
) -> DecisionLossResult:
|
||||
"""Compute typed loss without materializing full-vocabulary logits."""
|
||||
_validate_hidden_inputs(
|
||||
hidden,
|
||||
head,
|
||||
examples,
|
||||
supervision_mask,
|
||||
label_softmax,
|
||||
full_ce_weighting,
|
||||
brier_weight,
|
||||
hard_label_smoothing,
|
||||
)
|
||||
restricted_examples: list[torch.Tensor] = []
|
||||
brier_examples: list[torch.Tensor] = []
|
||||
full_targets: list[torch.Tensor] = []
|
||||
full_soft_corrections: list[torch.Tensor | None] = []
|
||||
selected_hidden: list[torch.Tensor] = []
|
||||
full_example_indices: list[int] = []
|
||||
full_question_weights: list[float] = []
|
||||
full_dft_hard: list[bool] = []
|
||||
residual_full_losses: list[tuple[int, torch.Tensor, float]] = []
|
||||
for batch_index, example in enumerate(examples):
|
||||
_validate_source_weight(example.source_weight)
|
||||
restricted_questions: list[torch.Tensor] = []
|
||||
brier_questions: list[torch.Tensor] = []
|
||||
for question in example.questions:
|
||||
query_hidden = hidden[batch_index, question.position]
|
||||
projection_hidden = query_hidden.to(dtype=head.weight.dtype)
|
||||
allowed_ids = _allowed_ids(question.allowed_token_ids, head.weight.shape[0])
|
||||
allowed_ids = allowed_ids.to(query_hidden.device)
|
||||
selected_bias = (
|
||||
head.bias.index_select(0, allowed_ids)
|
||||
if head.bias is not None
|
||||
else None
|
||||
)
|
||||
restricted_logits = F.linear(
|
||||
projection_hidden,
|
||||
head.weight.index_select(0, allowed_ids),
|
||||
selected_bias,
|
||||
).float()
|
||||
restricted_logprobs = F.log_softmax(restricted_logits, dim=0)
|
||||
if (
|
||||
isinstance(question.target, DistributionLabel)
|
||||
and question.target.other_probability > 0
|
||||
and label_softmax == "full"
|
||||
):
|
||||
restricted = query_hidden.new_zeros(())
|
||||
brier = query_hidden.new_zeros(())
|
||||
full_target = allowed_ids[0]
|
||||
full_distribution = None
|
||||
else:
|
||||
restricted, brier, full_target, full_distribution = (
|
||||
_restricted_question_loss(
|
||||
restricted_logprobs,
|
||||
restricted_logprobs.exp(),
|
||||
allowed_ids,
|
||||
question.target,
|
||||
hard_label_smoothing=hard_label_smoothing,
|
||||
)
|
||||
)
|
||||
restricted_questions.append(restricted)
|
||||
brier_questions.append(brier)
|
||||
if label_softmax != "restricted":
|
||||
if (
|
||||
isinstance(question.target, DistributionLabel)
|
||||
and question.target.other_probability > 0
|
||||
):
|
||||
full_logprobs = F.log_softmax(
|
||||
F.linear(projection_hidden, head.weight, head.bias).float(),
|
||||
dim=0,
|
||||
)
|
||||
residual_full_losses.append(
|
||||
(
|
||||
batch_index,
|
||||
_full_vocab_question_loss(
|
||||
full_logprobs,
|
||||
allowed_ids,
|
||||
question.target,
|
||||
full_target,
|
||||
full_distribution,
|
||||
),
|
||||
question.weight,
|
||||
)
|
||||
)
|
||||
continue
|
||||
selected_hidden.append(projection_hidden)
|
||||
full_targets.append(full_target)
|
||||
full_question_weights.append(question.weight)
|
||||
full_soft_corrections.append(
|
||||
None
|
||||
if full_distribution is None
|
||||
else restricted_logits[full_distribution.argmax()]
|
||||
- (full_distribution * restricted_logits).sum()
|
||||
)
|
||||
full_example_indices.append(batch_index)
|
||||
full_dft_hard.append(
|
||||
full_ce_weighting == "dft"
|
||||
and isinstance(question.target, HardLabel)
|
||||
)
|
||||
weight = example.source_weight
|
||||
qw = [question.weight for question in example.questions]
|
||||
restricted_examples.append(_weighted_mean(restricted_questions, qw) * weight)
|
||||
brier_examples.append(_weighted_mean(brier_questions, qw) * weight)
|
||||
restricted_loss = torch.stack(restricted_examples).mean()
|
||||
brier_loss = torch.stack(brier_examples).mean()
|
||||
full_vocab_loss = hidden.new_zeros((), dtype=torch.float32)
|
||||
effective_full_vocab_loss = hidden.new_zeros((), dtype=torch.float32)
|
||||
full_vocab_dft_hard_weight_sum = hidden.new_zeros((), dtype=torch.float32)
|
||||
full_vocab_dft_hard_count = hidden.new_zeros((), dtype=torch.float32)
|
||||
dft_hard_weight_sum_per_example = [
|
||||
hidden.new_zeros((), dtype=torch.float32) for _ in examples
|
||||
]
|
||||
dft_hard_count_per_example = [
|
||||
hidden.new_zeros((), dtype=torch.float32) for _ in examples
|
||||
]
|
||||
if label_softmax != "restricted":
|
||||
if selected_hidden:
|
||||
token_losses = linear_token_loss(
|
||||
torch.stack(selected_hidden), head, torch.stack(full_targets)
|
||||
)
|
||||
if token_losses.ndim != 1 or token_losses.shape[0] != len(selected_hidden):
|
||||
raise ValueError(
|
||||
"linear_token_loss must return one loss per selected query"
|
||||
)
|
||||
corrections = torch.stack(
|
||||
[
|
||||
token_losses[index].new_zeros(())
|
||||
if value is None
|
||||
else value.to(dtype=token_losses.dtype)
|
||||
for index, value in enumerate(full_soft_corrections)
|
||||
]
|
||||
)
|
||||
token_losses = token_losses + corrections
|
||||
else:
|
||||
token_losses = hidden.new_empty((0,), dtype=torch.float32)
|
||||
per_example: list[list[torch.Tensor]] = [[] for _ in examples]
|
||||
per_example_weights: list[list[float]] = [[] for _ in examples]
|
||||
for loss, batch_index, question_weight in zip(
|
||||
token_losses, full_example_indices, full_question_weights, strict=True
|
||||
):
|
||||
per_example[batch_index].append(loss)
|
||||
per_example_weights[batch_index].append(question_weight)
|
||||
for batch_index, loss, question_weight in residual_full_losses:
|
||||
per_example[batch_index].append(loss)
|
||||
per_example_weights[batch_index].append(question_weight)
|
||||
weighted = [
|
||||
_weighted_mean(losses, per_example_weights[index])
|
||||
* examples[index].source_weight
|
||||
for index, losses in enumerate(per_example)
|
||||
]
|
||||
full_vocab_loss = torch.stack(weighted).mean()
|
||||
if full_ce_weighting == "dft":
|
||||
dft_weights = (-token_losses.detach()).exp()
|
||||
dft_hard_mask = torch.tensor(
|
||||
full_dft_hard, device=token_losses.device, dtype=torch.bool
|
||||
)
|
||||
effective_token_losses = torch.where(
|
||||
dft_hard_mask, token_losses * dft_weights, token_losses
|
||||
)
|
||||
else:
|
||||
dft_weights = torch.zeros_like(token_losses)
|
||||
effective_token_losses = token_losses
|
||||
effective_per_example: list[list[torch.Tensor]] = [[] for _ in examples]
|
||||
for loss, dft_weight, dft_hard, batch_index in zip(
|
||||
effective_token_losses,
|
||||
dft_weights,
|
||||
full_dft_hard,
|
||||
full_example_indices,
|
||||
strict=True,
|
||||
):
|
||||
effective_per_example[batch_index].append(loss)
|
||||
if dft_hard:
|
||||
dft_hard_weight_sum_per_example[batch_index] = (
|
||||
dft_hard_weight_sum_per_example[batch_index] + dft_weight
|
||||
)
|
||||
dft_hard_count_per_example[batch_index] = (
|
||||
dft_hard_count_per_example[batch_index] + 1
|
||||
)
|
||||
for batch_index, loss, _question_weight_unused in residual_full_losses:
|
||||
effective_per_example[batch_index].append(loss)
|
||||
effective_weighted = [
|
||||
_weighted_mean(losses, per_example_weights[index])
|
||||
* examples[index].source_weight
|
||||
for index, losses in enumerate(effective_per_example)
|
||||
]
|
||||
effective_full_vocab_loss = torch.stack(effective_weighted).mean()
|
||||
full_vocab_dft_hard_weight_sum = torch.stack(
|
||||
dft_hard_weight_sum_per_example
|
||||
).sum()
|
||||
full_vocab_dft_hard_count = torch.stack(dft_hard_count_per_example).sum()
|
||||
if label_softmax == "full":
|
||||
restricted_loss = restricted_loss.new_zeros(())
|
||||
metric_restricted = (
|
||||
restricted_examples
|
||||
if label_softmax != "full"
|
||||
else [restricted_loss.new_zeros(()) for _ in examples]
|
||||
)
|
||||
metric_full = (
|
||||
weighted
|
||||
if label_softmax != "restricted"
|
||||
else [full_vocab_loss.new_zeros(()) for _ in examples]
|
||||
)
|
||||
return DecisionLossResult(
|
||||
loss=restricted_loss + effective_full_vocab_loss + brier_weight * brier_loss,
|
||||
restricted_loss=restricted_loss,
|
||||
full_vocab_loss=full_vocab_loss,
|
||||
brier_loss=brier_loss,
|
||||
effective_full_vocab_loss=effective_full_vocab_loss,
|
||||
full_vocab_dft_hard_weight_sum=full_vocab_dft_hard_weight_sum,
|
||||
full_vocab_dft_hard_count=full_vocab_dft_hard_count,
|
||||
per_example_loss=tuple(
|
||||
restricted.detach()
|
||||
+ effective_full.detach()
|
||||
+ brier_weight * brier.detach()
|
||||
for restricted, effective_full, brier in zip(
|
||||
metric_restricted,
|
||||
effective_weighted if label_softmax != "restricted" else metric_full,
|
||||
brier_examples,
|
||||
strict=True,
|
||||
)
|
||||
),
|
||||
per_example_restricted_loss=tuple(
|
||||
value.detach() for value in metric_restricted
|
||||
),
|
||||
per_example_full_vocab_loss=tuple(value.detach() for value in metric_full),
|
||||
per_example_effective_full_vocab_loss=tuple(
|
||||
value.detach()
|
||||
for value in (
|
||||
effective_weighted if label_softmax != "restricted" else metric_full
|
||||
)
|
||||
),
|
||||
per_example_full_vocab_dft_hard_weight_sum=tuple(
|
||||
value.detach() for value in dft_hard_weight_sum_per_example
|
||||
)
|
||||
if label_softmax != "restricted"
|
||||
else tuple(hidden.new_zeros(()) for _ in examples),
|
||||
per_example_full_vocab_dft_hard_count=tuple(
|
||||
value.detach() for value in dft_hard_count_per_example
|
||||
),
|
||||
per_example_brier_loss=tuple(value.detach() for value in brier_examples),
|
||||
)
|
||||
|
||||
|
||||
def _restricted_question_loss(
|
||||
restricted_logprobs: torch.Tensor,
|
||||
restricted_probs: torch.Tensor,
|
||||
allowed_ids: torch.Tensor,
|
||||
target: DecisionLabelTarget,
|
||||
*,
|
||||
hard_label_smoothing: float = 0.0,
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor, torch.Tensor | None]:
|
||||
if isinstance(target, HardLabel):
|
||||
_validate_index(target.gold_index, allowed_ids.shape[0], "gold_index")
|
||||
smoothing = (
|
||||
hard_label_smoothing
|
||||
if target.smoothing is None
|
||||
else _optional_smoothing(target.smoothing)
|
||||
)
|
||||
hard_desired = F.one_hot(
|
||||
torch.tensor(target.gold_index, device=restricted_logprobs.device),
|
||||
num_classes=allowed_ids.shape[0],
|
||||
).to(dtype=restricted_logprobs.dtype)
|
||||
if smoothing:
|
||||
desired = (
|
||||
hard_desired * (1.0 - smoothing) + smoothing / allowed_ids.shape[0]
|
||||
)
|
||||
primary = -(desired * restricted_logprobs).sum()
|
||||
else:
|
||||
desired = hard_desired
|
||||
primary = -restricted_logprobs[target.gold_index]
|
||||
full_target = allowed_ids[target.gold_index]
|
||||
full_distribution = desired if smoothing else None
|
||||
elif isinstance(target, DistributionLabel):
|
||||
indices, probabilities = _distribution_candidates(target, allowed_ids.shape[0])
|
||||
if target.other_probability > 0:
|
||||
raise ValueError(
|
||||
"a sparse dist target with other_probability requires "
|
||||
"label_softmax=full"
|
||||
)
|
||||
desired = torch.tensor(
|
||||
probabilities,
|
||||
dtype=restricted_logprobs.dtype,
|
||||
device=restricted_logprobs.device,
|
||||
)
|
||||
if len(indices) != allowed_ids.shape[0]:
|
||||
dense_desired = torch.zeros_like(restricted_logprobs)
|
||||
dense_desired[torch.tensor(indices, device=allowed_ids.device)] = desired
|
||||
desired = dense_desired
|
||||
ce_desired = desired
|
||||
if hard_label_smoothing and _is_one_hot_distribution(
|
||||
target, allowed_ids.shape[0]
|
||||
):
|
||||
ce_desired = (
|
||||
desired * (1.0 - hard_label_smoothing)
|
||||
+ hard_label_smoothing / allowed_ids.shape[0]
|
||||
)
|
||||
positive = ce_desired > 0
|
||||
primary = torch.where(
|
||||
positive,
|
||||
ce_desired * (ce_desired.log() - restricted_logprobs),
|
||||
torch.zeros_like(ce_desired),
|
||||
).sum()
|
||||
full_target = allowed_ids[desired.argmax()]
|
||||
full_distribution = ce_desired
|
||||
elif isinstance(target, SetLabel):
|
||||
_validate_set(target.allowed_indices, allowed_ids.shape[0])
|
||||
indices = torch.tensor(
|
||||
target.allowed_indices, dtype=torch.long, device=restricted_logprobs.device
|
||||
)
|
||||
set_logprobs = restricted_logprobs.index_select(0, indices)
|
||||
primary = -torch.logsumexp(set_logprobs, dim=0)
|
||||
set_mass = set_logprobs.exp().sum()
|
||||
full_target = allowed_ids[indices[set_logprobs.detach().argmax()]]
|
||||
brier = (1.0 - set_mass).square()
|
||||
full_distribution = None
|
||||
else:
|
||||
raise TypeError("unsupported decision label target")
|
||||
|
||||
if not isinstance(target, SetLabel):
|
||||
brier_target = hard_desired if isinstance(target, HardLabel) else desired
|
||||
brier = torch.sum((restricted_probs - brier_target).square())
|
||||
return primary, brier, full_target, full_distribution
|
||||
|
||||
|
||||
def _full_vocab_question_loss(
|
||||
full_logprobs: torch.Tensor,
|
||||
allowed_ids: torch.Tensor,
|
||||
target: DecisionLabelTarget,
|
||||
full_target: torch.Tensor,
|
||||
full_distribution: torch.Tensor | None,
|
||||
) -> torch.Tensor:
|
||||
if isinstance(target, DistributionLabel) and target.other_probability > 0:
|
||||
indices, probabilities = _distribution_candidates(target, allowed_ids.shape[0])
|
||||
candidate_ids = allowed_ids[torch.tensor(indices, device=allowed_ids.device)]
|
||||
desired = torch.tensor(
|
||||
probabilities, dtype=full_logprobs.dtype, device=full_logprobs.device
|
||||
)
|
||||
loss = -(desired * full_logprobs.index_select(0, candidate_ids)).sum()
|
||||
other_logprob = full_logprobs.index_fill(
|
||||
0, candidate_ids, -torch.inf
|
||||
).logsumexp(0)
|
||||
return loss - target.other_probability * other_logprob
|
||||
if full_distribution is None:
|
||||
if not isinstance(target, DistributionLabel):
|
||||
return -full_logprobs[full_target]
|
||||
indices, probabilities = _distribution_candidates(target, allowed_ids.shape[0])
|
||||
candidate_ids = allowed_ids[torch.tensor(indices, device=allowed_ids.device)]
|
||||
desired = torch.tensor(
|
||||
probabilities, dtype=full_logprobs.dtype, device=full_logprobs.device
|
||||
)
|
||||
else:
|
||||
candidate_ids = allowed_ids
|
||||
desired = full_distribution
|
||||
return -(desired * full_logprobs.index_select(0, candidate_ids)).sum()
|
||||
|
||||
|
||||
def _validate_inputs(
|
||||
logits: torch.Tensor,
|
||||
examples: Sequence[DecisionLabelExample],
|
||||
supervision_mask: torch.Tensor,
|
||||
label_softmax: LabelSoftmax,
|
||||
full_ce_weighting: FullCEWeighting,
|
||||
brier_weight: float,
|
||||
hard_label_smoothing: float,
|
||||
) -> None:
|
||||
if logits.ndim != 3:
|
||||
raise ValueError("logits must be [batch, sequence, vocabulary]")
|
||||
if supervision_mask.shape != logits.shape[:2]:
|
||||
raise ValueError("supervision_mask must be [batch, sequence]")
|
||||
if supervision_mask.dtype is not torch.bool:
|
||||
raise TypeError("supervision_mask must be bool")
|
||||
if supervision_mask.device != logits.device:
|
||||
raise ValueError("supervision_mask and logits must share a device")
|
||||
if len(examples) != logits.shape[0] or not examples:
|
||||
raise ValueError("examples must contain one nonempty entry per batch item")
|
||||
if label_softmax not in {"restricted", "full", "both"}:
|
||||
raise ValueError("label_softmax must be restricted, full, or both")
|
||||
if full_ce_weighting not in {"ce", "dft"}:
|
||||
raise ValueError("full_ce_weighting must be ce or dft")
|
||||
if (
|
||||
isinstance(brier_weight, bool)
|
||||
or not isinstance(brier_weight, (float, int))
|
||||
or not math.isfinite(brier_weight)
|
||||
):
|
||||
raise ValueError("brier_weight must be finite")
|
||||
if brier_weight < 0:
|
||||
raise ValueError("brier_weight must be nonnegative")
|
||||
if (
|
||||
isinstance(hard_label_smoothing, bool)
|
||||
or not isinstance(hard_label_smoothing, (float, int))
|
||||
or not math.isfinite(hard_label_smoothing)
|
||||
or not 0.0 <= hard_label_smoothing < 1.0
|
||||
):
|
||||
raise ValueError("hard_label_smoothing must be finite and in [0, 1)")
|
||||
for batch_index, example in enumerate(examples):
|
||||
if not example.questions:
|
||||
raise ValueError("each example must have at least one question")
|
||||
positions: set[int] = set()
|
||||
for question in example.questions:
|
||||
if (
|
||||
isinstance(question.position, bool)
|
||||
or not isinstance(question.position, int)
|
||||
or not 0 <= question.position < logits.shape[1]
|
||||
):
|
||||
raise ValueError("question position is outside the canvas logits")
|
||||
if question.position in positions:
|
||||
raise ValueError("each example requires distinct question positions")
|
||||
positions.add(question.position)
|
||||
torch._assert_async(
|
||||
supervision_mask[batch_index, question.position],
|
||||
"decision loss requires supervised label positions",
|
||||
)
|
||||
|
||||
|
||||
def _validate_hidden_inputs(
|
||||
hidden: torch.Tensor,
|
||||
head: torch.nn.Linear,
|
||||
examples: Sequence[DecisionLabelExample],
|
||||
supervision_mask: torch.Tensor,
|
||||
label_softmax: LabelSoftmax,
|
||||
full_ce_weighting: FullCEWeighting,
|
||||
brier_weight: float,
|
||||
hard_label_smoothing: float,
|
||||
) -> None:
|
||||
_validate_inputs(
|
||||
hidden,
|
||||
examples,
|
||||
supervision_mask,
|
||||
label_softmax,
|
||||
full_ce_weighting,
|
||||
brier_weight,
|
||||
hard_label_smoothing,
|
||||
)
|
||||
if not isinstance(head, torch.nn.Linear) or head.weight.ndim != 2:
|
||||
raise TypeError("head must be a linear vocabulary projection")
|
||||
if head.weight.shape[1] != hidden.shape[2]:
|
||||
raise ValueError("head input features must match hidden states")
|
||||
if head.bias is not None and head.bias.shape != (head.weight.shape[0],):
|
||||
raise ValueError("head bias must align with vocabulary rows")
|
||||
|
||||
|
||||
def _allowed_ids(token_ids: tuple[int, ...], vocab_size: int) -> torch.Tensor:
|
||||
if not token_ids:
|
||||
raise ValueError("a question requires at least one allowed token ID")
|
||||
if len(set(token_ids)) != len(token_ids):
|
||||
raise ValueError("allowed token IDs must be unique")
|
||||
if any(
|
||||
isinstance(token, bool)
|
||||
or not isinstance(token, int)
|
||||
or not 0 <= token < vocab_size
|
||||
for token in token_ids
|
||||
):
|
||||
raise ValueError("allowed token ID is outside the vocabulary")
|
||||
return torch.tensor(token_ids, dtype=torch.long)
|
||||
|
||||
|
||||
def _question_weight(target: object) -> float:
|
||||
"""Optional per-question ``weight`` on a normalized label mapping (default 1)."""
|
||||
if not isinstance(target, Mapping):
|
||||
return 1.0
|
||||
weight = target.get("weight", 1.0)
|
||||
if (
|
||||
isinstance(weight, bool)
|
||||
or not isinstance(weight, (int, float))
|
||||
or not math.isfinite(weight)
|
||||
or weight <= 0
|
||||
):
|
||||
raise ValueError("label weight must be a positive finite number")
|
||||
return float(weight)
|
||||
|
||||
|
||||
def _weighted_mean(
|
||||
losses: Sequence[torch.Tensor], weights: Sequence[float]
|
||||
) -> torch.Tensor:
|
||||
"""Question average with per-question weights; weight 1 everywhere is the plain mean."""
|
||||
stacked = torch.stack(list(losses))
|
||||
scale = torch.tensor(list(weights), device=stacked.device, dtype=stacked.dtype)
|
||||
return (stacked * scale).sum() / stacked.shape[0]
|
||||
|
||||
|
||||
def _validate_source_weight(weight: float) -> None:
|
||||
if (
|
||||
isinstance(weight, bool)
|
||||
or not isinstance(weight, (float, int))
|
||||
or not math.isfinite(weight)
|
||||
or weight <= 0
|
||||
):
|
||||
raise ValueError("source_weight must be a positive finite number")
|
||||
|
||||
|
||||
def _validate_index(index: int, count: int, name: str) -> None:
|
||||
if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index < count:
|
||||
raise ValueError(f"{name} must identify an allowed label")
|
||||
|
||||
|
||||
def _distribution_candidates(
|
||||
target: DistributionLabel, count: int
|
||||
) -> tuple[tuple[int, ...], tuple[float, ...]]:
|
||||
indices = (
|
||||
tuple(range(count))
|
||||
if target.candidate_indices is None
|
||||
else target.candidate_indices
|
||||
)
|
||||
_validate_distribution_values(
|
||||
target.probabilities, indices, target.other_probability, count
|
||||
)
|
||||
return indices, target.probabilities
|
||||
|
||||
|
||||
def _validate_distribution_values(
|
||||
probabilities: tuple[float, ...],
|
||||
candidate_indices: tuple[int, ...] | None,
|
||||
other_probability: float,
|
||||
count: int | None = None,
|
||||
) -> None:
|
||||
if not probabilities:
|
||||
raise ValueError("dist target requires at least one candidate")
|
||||
if candidate_indices is None:
|
||||
if count is not None and len(probabilities) != count:
|
||||
raise ValueError("dist target probabilities must align with allowed labels")
|
||||
elif len(candidate_indices) != len(probabilities):
|
||||
raise ValueError("dist candidate_indices must align with probs")
|
||||
elif len(set(candidate_indices)) != len(candidate_indices):
|
||||
raise ValueError("dist candidate_indices must be unique")
|
||||
elif count is not None:
|
||||
for index in candidate_indices:
|
||||
_validate_index(index, count, "dist candidate index")
|
||||
if any(
|
||||
isinstance(value, bool) or not math.isfinite(value) or value < 0
|
||||
for value in probabilities
|
||||
):
|
||||
raise ValueError("dist target probabilities must be finite and nonnegative")
|
||||
if not math.isfinite(other_probability) or other_probability < 0:
|
||||
raise ValueError("dist other_probability must be finite and nonnegative")
|
||||
if not math.isclose(
|
||||
sum(probabilities) + other_probability, 1.0, rel_tol=0.0, abs_tol=1e-6
|
||||
):
|
||||
raise ValueError(
|
||||
"dist target probabilities plus other_probability must sum to one"
|
||||
)
|
||||
|
||||
|
||||
def _validate_set(indices: tuple[int, ...], count: int) -> None:
|
||||
if not indices or len(set(indices)) != len(indices):
|
||||
raise ValueError("set target indices must be nonempty and unique")
|
||||
for index in indices:
|
||||
_validate_index(index, count, "set target index")
|
||||
|
||||
|
||||
def _is_one_hot_distribution(target: DistributionLabel, count: int) -> bool:
|
||||
return (
|
||||
target.other_probability == 0.0
|
||||
and (target.candidate_indices is not None or len(target.probabilities) == count)
|
||||
and sum(value == 1.0 for value in target.probabilities) == 1
|
||||
and all(value in {0.0, 1.0} for value in target.probabilities)
|
||||
)
|
||||
|
||||
|
||||
def _index(value: object, name: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a nonnegative integer")
|
||||
return value
|
||||
|
||||
|
||||
def _probability(value: object) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (float, int)):
|
||||
raise ValueError("dist target probabilities must be numeric")
|
||||
result = float(value)
|
||||
if not math.isfinite(result) or result < 0:
|
||||
raise ValueError("dist target probabilities must be finite and nonnegative")
|
||||
return result
|
||||
@@ -0,0 +1,341 @@
|
||||
"""Serializable decision-training provenance saved with adapters and checkpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__ci_config_keys__ = ("diffusion_decision",)
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, get_type_hints
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
StrictInt,
|
||||
create_model,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from transformers import TrainerCallback
|
||||
|
||||
from axolotl.model_support import DiffusionSpec
|
||||
|
||||
from ._util import (
|
||||
CONTROL_TOKEN_FIELDS,
|
||||
_value,
|
||||
optional_token_id,
|
||||
parse_token_ids,
|
||||
require_diffusion_spec,
|
||||
resolve_mask_token_id,
|
||||
)
|
||||
from .args import DiffusionDecisionConfig
|
||||
|
||||
MANIFEST_FILENAME = "diffusion_decision_manifest.json"
|
||||
MANIFEST_SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class ModelIdentity(BaseModel):
|
||||
"""Resolved base-model identity retained by a decision adapter."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
base_model: str = Field(min_length=1)
|
||||
requested_revision: str | None = None
|
||||
resolved_revision: str | None = None
|
||||
model_config_type: str | None = None
|
||||
mask_token_id: StrictInt | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class TokenizerSpecialIds(BaseModel):
|
||||
"""Tokenizer IDs required to reconstruct a decision canvas."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
bos_token_id: StrictInt | None = Field(default=None, ge=0)
|
||||
eos_token_id: StrictInt | None = Field(default=None, ge=0)
|
||||
pad_token_id: StrictInt | None = Field(default=None, ge=0)
|
||||
unk_token_id: StrictInt | None = Field(default=None, ge=0)
|
||||
mask_token_id: StrictInt | None = Field(default=None, ge=0)
|
||||
all_special_ids: list[StrictInt] = Field(default_factory=list)
|
||||
|
||||
@field_validator("all_special_ids")
|
||||
@classmethod
|
||||
def validate_special_ids(cls, value: list[int]) -> list[int]:
|
||||
if any(token < 0 for token in value):
|
||||
raise ValueError("tokenizer special IDs must be nonnegative")
|
||||
return list(dict.fromkeys(value))
|
||||
|
||||
|
||||
_SPEC_FIELD_OVERRIDES: dict[str, Any] = {
|
||||
"max_canvas": (StrictInt | None, Field(ge=1, default=None)),
|
||||
"max_context": (StrictInt | None, Field(ge=1, default=None)),
|
||||
"time_floor": (float, Field(ge=0.0, le=1.0)),
|
||||
}
|
||||
|
||||
|
||||
class _DiffusionSpecManifestBase(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_spec_contract(self) -> "_DiffusionSpecManifestBase":
|
||||
DiffusionSpec(**self.model_dump())
|
||||
return self
|
||||
|
||||
|
||||
DiffusionSpecManifest = create_model(
|
||||
"DiffusionSpecManifest",
|
||||
__base__=_DiffusionSpecManifestBase,
|
||||
__doc__="Validated primitive representation of the resolved model contract.",
|
||||
**{
|
||||
name: _SPEC_FIELD_OVERRIDES.get(name, (hint, ...))
|
||||
for name, hint in get_type_hints(DiffusionSpec).items()
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _SlotConfiguration(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
mode: Literal["none", "pad", "pinned", "learned", "free", "mask", "prompt"]
|
||||
num_slots: StrictInt = Field(ge=0)
|
||||
token_ids: list[StrictInt] = Field(default_factory=list)
|
||||
free_update_policy: Literal["argmax"] | None = None
|
||||
free_slot_init_policy: Literal["prepared_canvas_v0", "fresh_runtime_v1"] = (
|
||||
"prepared_canvas_v0"
|
||||
)
|
||||
|
||||
@field_validator("token_ids")
|
||||
@classmethod
|
||||
def validate_token_ids(cls, value: list[int]) -> list[int]:
|
||||
if any(token < 0 for token in value):
|
||||
raise ValueError("configured slot token IDs must be nonnegative")
|
||||
return value
|
||||
|
||||
|
||||
class ConfiguredSlots(_SlotConfiguration):
|
||||
"""Static slot configuration without runtime count sampling."""
|
||||
|
||||
sample_num_slots: Literal[False] = False
|
||||
|
||||
|
||||
class SampledTrainingSlots(BaseModel):
|
||||
"""Versioned per-logical-draw slot-count policy for a sampled-slot run."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
policy: Literal["uniform_inclusive_v1"] = "uniform_inclusive_v1"
|
||||
min_slots: StrictInt = Field(default=0, ge=0)
|
||||
max_slots: StrictInt = Field(ge=1)
|
||||
draw_unit: Literal["logical_draw"] = "logical_draw"
|
||||
seed_derivation: Literal["seed_epoch_global_draw_ordinal_v1"] = (
|
||||
"seed_epoch_global_draw_ordinal_v1"
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_bounds(self) -> "SampledTrainingSlots":
|
||||
if self.min_slots > self.max_slots:
|
||||
raise ValueError("sampled slot minimum cannot exceed its maximum")
|
||||
return self
|
||||
|
||||
|
||||
class FixedMaxEvaluationSlots(BaseModel):
|
||||
"""Fixed evaluation materialization used for a sampled-slot training run."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
policy: Literal["fixed_configured_max_v1"] = "fixed_configured_max_v1"
|
||||
count: StrictInt = Field(ge=1)
|
||||
|
||||
|
||||
class SampledConfiguredSlots(_SlotConfiguration):
|
||||
"""Configured slots with distinct training-sampling and evaluation policies."""
|
||||
|
||||
sample_num_slots: Literal[True] = True
|
||||
sampled_training: SampledTrainingSlots
|
||||
fixed_max_evaluation: FixedMaxEvaluationSlots
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_sampled_contract(self) -> "SampledConfiguredSlots":
|
||||
if self.mode == "none" or self.num_slots < 1:
|
||||
raise ValueError("sampled slots require a non-none mode and positive count")
|
||||
if self.sampled_training.max_slots != self.num_slots:
|
||||
raise ValueError("sampled slot maximum must equal configured num_slots")
|
||||
if self.fixed_max_evaluation.count != self.num_slots:
|
||||
raise ValueError(
|
||||
"fixed evaluation slot count must equal configured num_slots"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
_RETIRED_READ_FIELDS = frozenset({"eval_steps", "noise_draws", "reader"})
|
||||
|
||||
|
||||
class NoiseReadProtocol(BaseModel):
|
||||
"""Configured decision read and diffusion controls."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
read_fraction: float
|
||||
carry_enabled: bool
|
||||
diffusion_steps: StrictInt = Field(ge=1)
|
||||
t_eps: float | None = Field(default=None, ge=0.0, le=1.0)
|
||||
time_weighting: str | None = None
|
||||
objective_reduction: str | None = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def drop_retired_fields(cls, data: Any) -> Any:
|
||||
if isinstance(data, dict):
|
||||
data = {k: v for k, v in data.items() if k not in _RETIRED_READ_FIELDS}
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_protocol(self) -> "NoiseReadProtocol":
|
||||
if (
|
||||
not math.isfinite(self.read_fraction)
|
||||
or not 0.0 <= self.read_fraction <= 1.0
|
||||
):
|
||||
raise ValueError("read_fraction must be finite and in [0, 1]")
|
||||
if self.t_eps is not None and not math.isfinite(self.t_eps):
|
||||
raise ValueError("t_eps must be finite")
|
||||
return self
|
||||
|
||||
|
||||
class DecisionManifest(BaseModel):
|
||||
"""Validated, portable decision-adapter provenance."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
schema_version: Literal[1] = 1
|
||||
model: ModelIdentity
|
||||
diffusion_spec: DiffusionSpecManifest
|
||||
decision_layout: Literal["thought_block", "prompt_slots"]
|
||||
canvas_width: StrictInt = Field(ge=1)
|
||||
label_codebook: Literal["vendored26", "expanded52"] = "vendored26"
|
||||
configured_slots: ConfiguredSlots | SampledConfiguredSlots
|
||||
noise_read_protocol: NoiseReadProtocol
|
||||
tokenizer_special_ids: TokenizerSpecialIds
|
||||
initial_common_adapter_sha256: str | None = None
|
||||
initial_common_adapter_fingerprint_reason: str | None = None
|
||||
initial_common_adapter_devices: list[str] = Field(default_factory=list)
|
||||
initial_common_adapter_dtypes: list[str] = Field(default_factory=list)
|
||||
initial_common_adapter_tensor_count: StrictInt = Field(default=0, ge=0)
|
||||
|
||||
@classmethod
|
||||
def from_path(cls, path: str | Path) -> "DecisionManifest":
|
||||
return cls.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
||||
|
||||
def write_to(self, directory: str | Path) -> Path:
|
||||
destination = Path(directory) / MANIFEST_FILENAME
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_text(
|
||||
json.dumps(self.model_dump(mode="json"), indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return destination
|
||||
|
||||
|
||||
def _decision_config(cfg: Any) -> DiffusionDecisionConfig:
|
||||
value = _value(cfg, "diffusion_decision")
|
||||
if isinstance(value, DiffusionDecisionConfig):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return DiffusionDecisionConfig.model_validate(value)
|
||||
raise ValueError("diffusion_decision manifest requires diffusion_decision settings")
|
||||
|
||||
|
||||
def build_decision_manifest(
|
||||
cfg: Any,
|
||||
*,
|
||||
tokenizer: Any = None,
|
||||
model: Any = None,
|
||||
) -> DecisionManifest:
|
||||
"""Build a validated manifest without changing model or tokenizer state."""
|
||||
decision = _decision_config(cfg)
|
||||
diffusion = _value(cfg, "diffusion_lm")
|
||||
if diffusion is None:
|
||||
raise ValueError("diffusion_decision manifest requires diffusion_lm settings")
|
||||
spec = require_diffusion_spec(cfg)
|
||||
model_config = _value(model, "config")
|
||||
mask_token_id = resolve_mask_token_id(diffusion, model_config)
|
||||
canvas_width = _value(diffusion, "canvas_width")
|
||||
if canvas_width is None:
|
||||
canvas_width = 128
|
||||
return DecisionManifest(
|
||||
model=ModelIdentity(
|
||||
base_model=str(_value(cfg, "base_model")),
|
||||
requested_revision=_value(cfg, "revision_of_model"),
|
||||
resolved_revision=_value(model_config, "_commit_hash"),
|
||||
model_config_type=_value(cfg, "model_config_type"),
|
||||
mask_token_id=mask_token_id,
|
||||
),
|
||||
diffusion_spec=DiffusionSpecManifest.model_validate(spec.to_dict()),
|
||||
decision_layout=decision.layout,
|
||||
canvas_width=canvas_width,
|
||||
label_codebook=decision.labels.codebook,
|
||||
configured_slots=(
|
||||
SampledConfiguredSlots(
|
||||
mode=decision.latent.mode,
|
||||
num_slots=decision.latent.num_slots,
|
||||
token_ids=list(decision.latent.token_ids),
|
||||
free_update_policy=decision.latent.free_update_policy,
|
||||
free_slot_init_policy=(
|
||||
"fresh_runtime_v1"
|
||||
if decision.latent.mode == "free"
|
||||
else "prepared_canvas_v0"
|
||||
),
|
||||
sampled_training=SampledTrainingSlots(
|
||||
max_slots=decision.latent.num_slots
|
||||
),
|
||||
fixed_max_evaluation=FixedMaxEvaluationSlots(
|
||||
count=decision.latent.num_slots
|
||||
),
|
||||
)
|
||||
if decision.latent.sample_num_slots
|
||||
else ConfiguredSlots(
|
||||
mode=decision.latent.mode,
|
||||
num_slots=decision.latent.num_slots,
|
||||
token_ids=list(decision.latent.token_ids),
|
||||
sample_num_slots=False,
|
||||
free_update_policy=decision.latent.free_update_policy,
|
||||
free_slot_init_policy=(
|
||||
"fresh_runtime_v1"
|
||||
if decision.latent.mode == "free"
|
||||
else "prepared_canvas_v0"
|
||||
),
|
||||
)
|
||||
),
|
||||
noise_read_protocol=NoiseReadProtocol(
|
||||
read_fraction=decision.read_fraction,
|
||||
carry_enabled=decision.carry.enabled,
|
||||
diffusion_steps=_value(diffusion, "num_diffusion_steps", 1),
|
||||
t_eps=_value(diffusion, "t_eps"),
|
||||
time_weighting=_value(diffusion, "time_weighting"),
|
||||
objective_reduction=_value(diffusion, "objective_reduction"),
|
||||
),
|
||||
tokenizer_special_ids=TokenizerSpecialIds(
|
||||
**{
|
||||
name: optional_token_id(_value(tokenizer, name), name)
|
||||
for name in CONTROL_TOKEN_FIELDS
|
||||
},
|
||||
all_special_ids=list(
|
||||
parse_token_ids(_value(tokenizer, "all_special_ids"), "all_special_ids")
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DecisionManifestCheckpointCallback(TrainerCallback):
|
||||
"""Write validated decision provenance with Trainer checkpoint directories."""
|
||||
|
||||
def __init__(self, manifest: DecisionManifest) -> None:
|
||||
self.manifest = manifest
|
||||
|
||||
def on_save(self, args, state, control, **_kwargs):
|
||||
if state.is_world_process_zero:
|
||||
self.manifest.write_to(
|
||||
Path(args.output_dir) / f"checkpoint-{state.global_step}"
|
||||
)
|
||||
return control
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Deterministic, decontaminated source mixing for decision training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
from collections import Counter, defaultdict
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, TypeVar
|
||||
|
||||
from ._util import stable_seed
|
||||
from .hygiene import decontaminate
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedSourcePools:
|
||||
"""Decontaminated and capped normalized records partitioned by source."""
|
||||
|
||||
records: Mapping[str, tuple[Mapping[str, Any], ...]]
|
||||
counts: Mapping[str, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MixtureSample(Generic[T]):
|
||||
"""A training example with its source retained for metrics and loss weighting."""
|
||||
|
||||
source: str
|
||||
record: T
|
||||
|
||||
|
||||
class _SourceCursor(Generic[T]):
|
||||
def __init__(self, records: Sequence[T], seed: int) -> None:
|
||||
self._records = tuple(records)
|
||||
self._order = list(range(len(records)))
|
||||
self._random = random.Random(seed) # nosec B311 - Deterministic training order.
|
||||
self._random.shuffle(self._order)
|
||||
self._offset = 0
|
||||
|
||||
def next(self) -> T:
|
||||
if self._offset == len(self._order):
|
||||
self._random.shuffle(self._order)
|
||||
self._offset = 0
|
||||
record = self._records[self._order[self._offset]]
|
||||
self._offset += 1
|
||||
return record
|
||||
|
||||
|
||||
class DeterministicMixtureSampler(Generic[T]):
|
||||
"""Draw source-tagged examples and source-stratified microbatches."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pools: Mapping[str, Sequence[T]],
|
||||
*,
|
||||
weights: Mapping[str, float] | None = None,
|
||||
temperature: float | None = None,
|
||||
seed: int = 0,
|
||||
) -> None:
|
||||
self._pools = _validate_pools(pools)
|
||||
self.sources = tuple(sorted(self._pools))
|
||||
self.probabilities = _probabilities(
|
||||
self._pools, self.sources, weights, temperature
|
||||
)
|
||||
self._probability_values = tuple(
|
||||
self.probabilities[source] for source in self.sources
|
||||
)
|
||||
self._seed = seed
|
||||
self._source_random = random.Random(seed) # nosec B311 - Deterministic draws.
|
||||
self._cursors = {
|
||||
source: _SourceCursor(records, stable_seed(seed, source))
|
||||
for source, records in self._pools.items()
|
||||
}
|
||||
self._fractional_credit = {source: 0.0 for source in self.sources}
|
||||
|
||||
def draw(self, count: int) -> tuple[MixtureSample[T], ...]:
|
||||
"""Draw examples by source probability, cycling deterministically within sources."""
|
||||
_validate_count(count, "count")
|
||||
choices = self._source_random.choices(
|
||||
self.sources, weights=self._probability_values, k=count
|
||||
)
|
||||
return tuple(
|
||||
MixtureSample(source, self._cursors[source].next()) for source in choices
|
||||
)
|
||||
|
||||
def batches(
|
||||
self, batch_size: int, count: int, *, stratified: bool = True
|
||||
) -> Iterator[tuple[MixtureSample[T], ...]]:
|
||||
"""Yield fixed-size deterministic microbatches without flattening examples."""
|
||||
_validate_count(batch_size, "batch_size", positive=True)
|
||||
_validate_count(count, "count")
|
||||
for _ in range(count):
|
||||
if stratified:
|
||||
sources = self._stratified_sources(batch_size)
|
||||
self._source_random.shuffle(sources)
|
||||
yield tuple(
|
||||
MixtureSample(source, self._cursors[source].next())
|
||||
for source in sources
|
||||
)
|
||||
else:
|
||||
yield self.draw(batch_size)
|
||||
|
||||
def epoch_batches(
|
||||
self, batch_size: int, *, stratified: bool = True
|
||||
) -> Iterator[tuple[MixtureSample[T], ...]]:
|
||||
"""Yield batches until every capped source record is seen once."""
|
||||
_validate_count(batch_size, "batch_size", positive=True)
|
||||
epoch_seed = self._source_random.randrange(1 << 63) # nosec B311 - Reproducible seed.
|
||||
cursors = {
|
||||
source: _SourceCursor(records, stable_seed(epoch_seed, source))
|
||||
for source, records in self._pools.items()
|
||||
}
|
||||
credit = {source: 0.0 for source in self.sources}
|
||||
seen = {source: 0 for source in self.sources}
|
||||
while not all(
|
||||
seen[source] >= len(self._pools[source]) for source in self.sources
|
||||
):
|
||||
if stratified:
|
||||
sources = self._stratified_sources(batch_size, credit)
|
||||
random.Random(epoch_seed).shuffle(sources) # nosec B311 - Reproducible order.
|
||||
else:
|
||||
sources = self._source_random.choices(
|
||||
self.sources, weights=self._probability_values, k=batch_size
|
||||
)
|
||||
epoch_seed += 1
|
||||
batch = tuple(
|
||||
MixtureSample(source, cursors[source].next()) for source in sources
|
||||
)
|
||||
for source in sources:
|
||||
seen[source] += 1
|
||||
yield batch
|
||||
|
||||
def _stratified_sources(
|
||||
self, batch_size: int, fractional_credit: dict[str, float] | None = None
|
||||
) -> list[str]:
|
||||
credit = (
|
||||
self._fractional_credit if fractional_credit is None else fractional_credit
|
||||
)
|
||||
expected = {
|
||||
source: self.probabilities[source] * batch_size for source in self.sources
|
||||
}
|
||||
fixed = {source: math.floor(value) for source, value in expected.items()}
|
||||
remaining = batch_size - sum(fixed.values())
|
||||
selected = [source for source in self.sources for _ in range(fixed[source])]
|
||||
for source in self.sources:
|
||||
credit[source] += expected[source] - fixed[source]
|
||||
for _ in range(remaining):
|
||||
source = max(
|
||||
enumerate(self.sources),
|
||||
key=lambda item: (credit[item[1]], -item[0]),
|
||||
)[1]
|
||||
selected.append(source)
|
||||
credit[source] -= 1.0
|
||||
return selected
|
||||
|
||||
|
||||
def prepare_source_pools(
|
||||
training: Sequence[Mapping[str, Any]],
|
||||
evaluation: Sequence[Mapping[str, Any]],
|
||||
*,
|
||||
max_examples_per_source: int | Mapping[str, int] | None = None,
|
||||
exclude_families: bool = True,
|
||||
seed: int = 0,
|
||||
) -> PreparedSourcePools:
|
||||
"""Decontaminate records, then deterministically cap each nonempty source pool."""
|
||||
retained, counts = decontaminate(
|
||||
training, evaluation, exclude_families=exclude_families
|
||||
)
|
||||
grouped: dict[str, list[Mapping[str, Any]]] = defaultdict(list)
|
||||
for record in retained:
|
||||
grouped[record["source"]].append(record)
|
||||
_validate_caps(max_examples_per_source, grouped)
|
||||
pools = {
|
||||
source: _cap_records(
|
||||
records, _cap_for_source(max_examples_per_source, source), seed
|
||||
)
|
||||
for source, records in grouped.items()
|
||||
}
|
||||
records = {source: pool for source, pool in pools.items() if pool}
|
||||
if not records:
|
||||
raise ValueError("no nonempty source pools remain after decontamination")
|
||||
capped = sum(len(grouped[source]) - len(pool) for source, pool in records.items())
|
||||
result_counts = dict(counts)
|
||||
result_counts["capped"] = capped
|
||||
result_counts["sources"] = len(records)
|
||||
return PreparedSourcePools(records=records, counts=result_counts)
|
||||
|
||||
|
||||
def source_counts(samples: Sequence[MixtureSample[object]]) -> Mapping[str, int]:
|
||||
"""Count source-tagged samples without inspecting or altering their records."""
|
||||
return dict(Counter(sample.source for sample in samples))
|
||||
|
||||
|
||||
def _validate_pools(pools: Mapping[str, Sequence[T]]) -> dict[str, tuple[T, ...]]:
|
||||
if not pools:
|
||||
raise ValueError("mixture requires at least one source pool")
|
||||
result = {}
|
||||
for source, records in pools.items():
|
||||
if not isinstance(source, str) or not source:
|
||||
raise ValueError("mixture source names must be nonempty strings")
|
||||
if not records:
|
||||
raise ValueError(f"mixture source {source!r} is empty")
|
||||
result[source] = tuple(records)
|
||||
return result
|
||||
|
||||
|
||||
def _probabilities(
|
||||
pools: Mapping[str, Sequence[object]],
|
||||
sources: tuple[str, ...],
|
||||
weights: Mapping[str, float] | None,
|
||||
temperature: float | None,
|
||||
) -> Mapping[str, float]:
|
||||
if weights is not None:
|
||||
if temperature is not None:
|
||||
raise ValueError("specify explicit weights or temperature, not both")
|
||||
if set(weights) != set(sources):
|
||||
raise ValueError("explicit mixture weights must name every nonempty source")
|
||||
values = {
|
||||
source: _positive_finite(weights[source], "mixture weight")
|
||||
for source in sources
|
||||
}
|
||||
return _normalize_probabilities(values)
|
||||
else:
|
||||
resolved_temperature = 0.5 if temperature is None else temperature
|
||||
exponent = _nonnegative_finite(resolved_temperature, "mixture temperature")
|
||||
log_values = {
|
||||
source: exponent * math.log(len(pools[source])) for source in sources
|
||||
}
|
||||
if any(not math.isfinite(value) for value in log_values.values()):
|
||||
raise ValueError("mixture temperature produces nonfinite source weights")
|
||||
maximum = max(log_values.values())
|
||||
values = {
|
||||
source: math.exp(value - maximum) for source, value in log_values.items()
|
||||
}
|
||||
return _normalize_probabilities(values)
|
||||
|
||||
|
||||
def _normalize_probabilities(values: Mapping[str, float]) -> Mapping[str, float]:
|
||||
maximum = max(values.values())
|
||||
scaled = {source: value / maximum for source, value in values.items()}
|
||||
total = sum(scaled.values())
|
||||
probabilities = {source: value / total for source, value in scaled.items()}
|
||||
if any(not math.isfinite(value) or value <= 0 for value in probabilities.values()):
|
||||
raise ValueError("mixture source probability underflowed to zero")
|
||||
return probabilities
|
||||
|
||||
|
||||
def _validate_caps(
|
||||
cap: int | Mapping[str, int] | None, pools: Mapping[str, Sequence[object]]
|
||||
) -> None:
|
||||
if isinstance(cap, Mapping):
|
||||
unknown = set(cap) - set(pools)
|
||||
if unknown:
|
||||
raise ValueError("per-source cap names an unavailable source")
|
||||
for value in cap.values():
|
||||
_positive_integer(value, "max_examples_per_source")
|
||||
elif cap is not None:
|
||||
_positive_integer(cap, "max_examples_per_source")
|
||||
|
||||
|
||||
def _cap_for_source(cap: int | Mapping[str, int] | None, source: str) -> int | None:
|
||||
if isinstance(cap, Mapping):
|
||||
return cap.get(source)
|
||||
return cap
|
||||
|
||||
|
||||
def _cap_records(
|
||||
records: Sequence[Mapping[str, Any]], cap: int | None, seed: int
|
||||
) -> tuple[Mapping[str, Any], ...]:
|
||||
if cap is None or len(records) <= cap:
|
||||
return tuple(records)
|
||||
order = list(range(len(records)))
|
||||
random.Random(stable_seed(seed, records[0]["source"])).shuffle( # nosec B311
|
||||
order
|
||||
)
|
||||
return tuple(records[index] for index in order[:cap])
|
||||
|
||||
|
||||
def _positive_finite(value: float, name: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{name} must be positive and finite")
|
||||
result = float(value)
|
||||
if not math.isfinite(result) or result <= 0:
|
||||
raise ValueError(f"{name} must be positive and finite")
|
||||
return result
|
||||
|
||||
|
||||
def _nonnegative_finite(value: float, name: str) -> float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise ValueError(f"{name} must be nonnegative and finite")
|
||||
result = float(value)
|
||||
if not math.isfinite(result) or result < 0:
|
||||
raise ValueError(f"{name} must be nonnegative and finite")
|
||||
return result
|
||||
|
||||
|
||||
def _positive_integer(value: int, name: str) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError(f"{name} must be a positive integer")
|
||||
|
||||
|
||||
def _validate_count(value: int, name: str, *, positive: bool = False) -> None:
|
||||
if (
|
||||
isinstance(value, bool)
|
||||
or not isinstance(value, int)
|
||||
or (value < 0 or (positive and value == 0))
|
||||
):
|
||||
qualifier = "positive" if positive else "nonnegative"
|
||||
raise ValueError(f"{name} must be a {qualifier} integer")
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Deterministic question and choice ordering with target preservation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import random
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from ._util import canonical_json
|
||||
from .adapters.jsonl import normalize_jsonl
|
||||
|
||||
|
||||
def permute_record(
|
||||
record: Mapping[str, Any], *, seed: int, codebook: str = "vendored26"
|
||||
) -> dict[str, Any]:
|
||||
result = normalize_jsonl(record, codebook=codebook)
|
||||
identity = _permutation_identity(result)
|
||||
keys = sorted(result["questions"])
|
||||
_generator(seed, identity, "questions").shuffle(keys)
|
||||
result["questions"] = {key: result["questions"][key] for key in keys}
|
||||
result["labels"] = {key: result["labels"][key] for key in keys}
|
||||
for key, question in result["questions"].items():
|
||||
if question["type"] != "choice":
|
||||
continue
|
||||
options = question["options"]
|
||||
order = list(range(len(options)))
|
||||
_generator(seed, identity, f"choices:{key}").shuffle(order)
|
||||
question["options"] = [options[index] for index in order]
|
||||
target = result["labels"][key]
|
||||
inverse = {old: new for new, old in enumerate(order)}
|
||||
if target["kind"] == "hard":
|
||||
target["gold_idx"] = inverse[target["gold_idx"]]
|
||||
elif target["kind"] == "dist":
|
||||
if "candidate_indices" in target:
|
||||
candidates = sorted(
|
||||
zip(
|
||||
(inverse[index] for index in target["candidate_indices"]),
|
||||
target["probs"],
|
||||
target.get("candidate_ids") or [None] * len(target["probs"]),
|
||||
strict=True,
|
||||
)
|
||||
)
|
||||
target["candidate_indices"] = [index for index, _, _ in candidates]
|
||||
target["probs"] = [probability for _, probability, _ in candidates]
|
||||
if target.get("candidate_ids") is not None:
|
||||
target["candidate_ids"] = [
|
||||
identity for _, _, identity in candidates
|
||||
]
|
||||
else:
|
||||
target["probs"] = [target["probs"][index] for index in order]
|
||||
else:
|
||||
target["allowed_set"] = sorted(
|
||||
inverse[index] for index in target["allowed_set"]
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _generator(seed: int, identity: str, stream: str) -> random.Random:
|
||||
digest = hashlib.sha256(f"{seed}:{stream}:{identity}".encode()).digest()
|
||||
return random.Random(int.from_bytes(digest, "big")) # nosec B311 - Dataset ordering.
|
||||
|
||||
|
||||
def _permutation_identity(record: Mapping[str, Any]) -> str:
|
||||
value = {
|
||||
key: record.get(key) for key in ("source", "group", "id", "state", "questions")
|
||||
}
|
||||
return canonical_json(value, allow_nan=False)
|
||||
@@ -0,0 +1,193 @@
|
||||
"""Axolotl registration for typed diffusion-decision training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
__ci_config_keys__ = ("diffusion_decision",)
|
||||
|
||||
import shutil
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from axolotl.integrations.base import BasePlugin
|
||||
|
||||
from ._util import _value
|
||||
from .args import DiffusionDecisionConfig
|
||||
from .manifest import (
|
||||
MANIFEST_FILENAME,
|
||||
DecisionManifest,
|
||||
DecisionManifestCheckpointCallback,
|
||||
build_decision_manifest,
|
||||
)
|
||||
|
||||
|
||||
def _output_dir(cfg: Any) -> str | Path:
|
||||
output_dir: str | Path | None = (
|
||||
cfg.get("output_dir") if isinstance(cfg, Mapping) else cfg.output_dir
|
||||
)
|
||||
if not isinstance(output_dir, (str, Path)):
|
||||
raise ValueError("diffusion_decision manifest requires output_dir")
|
||||
return output_dir
|
||||
|
||||
|
||||
class DiffusionDecisionPlugin(BasePlugin):
|
||||
"""Register decision settings and defer layout handling to ``DiffusionSpec``."""
|
||||
|
||||
def get_input_args(self) -> str:
|
||||
return "axolotl.integrations.diffusion_decision.args.DiffusionDecisionArgs"
|
||||
|
||||
@staticmethod
|
||||
def _decision_config(cfg: Any) -> DiffusionDecisionConfig | None:
|
||||
value = _value(cfg, "diffusion_decision")
|
||||
if value is None or isinstance(value, DiffusionDecisionConfig):
|
||||
return value
|
||||
if isinstance(value, Mapping):
|
||||
return DiffusionDecisionConfig.model_validate(value)
|
||||
raise TypeError("diffusion_decision must be a mapping")
|
||||
|
||||
def register(self, cfg: dict) -> None:
|
||||
if self._decision_config(cfg) is None:
|
||||
return
|
||||
diffusion = cfg.get("diffusion_lm")
|
||||
if diffusion is None:
|
||||
raise ValueError("diffusion_decision requires native diffusion_lm settings")
|
||||
if _value(diffusion, "from_causal_lm", False):
|
||||
raise ValueError(
|
||||
"diffusion_decision does not support diffusion_lm.from_causal_lm"
|
||||
)
|
||||
self._validate_fixed_logical_batch(cfg)
|
||||
|
||||
def _require_runtime(self, cfg: Any) -> DiffusionDecisionConfig | None:
|
||||
decision = self._decision_config(cfg)
|
||||
if decision is None:
|
||||
return None
|
||||
self._validate_fixed_logical_batch(cfg)
|
||||
return decision
|
||||
|
||||
@staticmethod
|
||||
def _validate_fixed_logical_batch(cfg: Any) -> None:
|
||||
batch_flattening = (
|
||||
cfg.get("batch_flattening")
|
||||
if isinstance(cfg, Mapping)
|
||||
else getattr(cfg, "batch_flattening", False)
|
||||
)
|
||||
micro_batch_size = _value(cfg, "micro_batch_size")
|
||||
sample_packing = bool(_value(cfg, "sample_packing", False))
|
||||
logical_length = _value(cfg, "sequence_len")
|
||||
if sample_packing:
|
||||
if not all(
|
||||
isinstance(value, int) and not isinstance(value, bool) and value > 0
|
||||
for value in (logical_length, micro_batch_size)
|
||||
):
|
||||
raise ValueError(
|
||||
"diffusion_decision sample_packing requires positive sequence_len and micro_batch_size"
|
||||
)
|
||||
return
|
||||
if (
|
||||
batch_flattening is False
|
||||
and isinstance(micro_batch_size, int)
|
||||
and (micro_batch_size > 1)
|
||||
):
|
||||
raise ValueError(
|
||||
"diffusion_decision fixed logical batches require batch_flattening: true when micro_batch_size is greater than one"
|
||||
)
|
||||
if batch_flattening is not True:
|
||||
return
|
||||
physical_budget = (
|
||||
logical_length * micro_batch_size
|
||||
if isinstance(logical_length, int) and isinstance(micro_batch_size, int)
|
||||
else None
|
||||
)
|
||||
if not all(
|
||||
isinstance(value, int) and not isinstance(value, bool) and value > 0
|
||||
for value in (logical_length, physical_budget, micro_batch_size)
|
||||
):
|
||||
raise ValueError(
|
||||
"diffusion_decision fixed logical batches require positive logical length, physical budget, and micro batch size"
|
||||
)
|
||||
|
||||
def load_datasets(self, cfg, preprocess: bool = False):
|
||||
if self._require_runtime(cfg) is None:
|
||||
return None
|
||||
try:
|
||||
from .datasets import load_decision_datasets
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"diffusion_decision dataset integration is not installed"
|
||||
) from exc
|
||||
return load_decision_datasets(cfg, preprocess=preprocess)
|
||||
|
||||
def pre_lora_load(self, cfg, model) -> None:
|
||||
decision = self._require_runtime(cfg)
|
||||
if decision is None:
|
||||
return
|
||||
from .slot_runtime import (
|
||||
merge_trainable_slot_indices,
|
||||
resolve_trainable_slot_runtime,
|
||||
)
|
||||
|
||||
runtime = resolve_trainable_slot_runtime(cfg, model, decision)
|
||||
if runtime is not None:
|
||||
merge_trainable_slot_indices(cfg, runtime)
|
||||
|
||||
def get_trainer_cls(self, cfg):
|
||||
if self._require_runtime(cfg) is None:
|
||||
return None
|
||||
try:
|
||||
from .trainer import DiffusionDecisionTrainer
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"diffusion_decision trainer integration is not installed"
|
||||
) from exc
|
||||
return DiffusionDecisionTrainer
|
||||
|
||||
def post_trainer_create(self, cfg, trainer) -> None:
|
||||
if self._require_runtime(cfg) is None:
|
||||
return
|
||||
tokenizer = getattr(trainer, "processing_class", None)
|
||||
if tokenizer is None:
|
||||
tokenizer = getattr(trainer, "tokenizer", None)
|
||||
self._decision_manifest = build_decision_manifest(
|
||||
cfg,
|
||||
tokenizer=tokenizer,
|
||||
model=getattr(trainer, "model", None),
|
||||
)
|
||||
from axolotl.utils.distributed import is_main_process
|
||||
|
||||
if is_main_process():
|
||||
self._decision_manifest.write_to(_output_dir(cfg))
|
||||
trainer.add_callback(
|
||||
DecisionManifestCheckpointCallback(self._decision_manifest)
|
||||
)
|
||||
|
||||
def post_train(self, cfg, model) -> None:
|
||||
if self._require_runtime(cfg) is None:
|
||||
return
|
||||
from axolotl.utils.distributed import is_main_process
|
||||
|
||||
if not is_main_process():
|
||||
return
|
||||
manifest = getattr(self, "_decision_manifest", None)
|
||||
if not isinstance(manifest, DecisionManifest):
|
||||
manifest = build_decision_manifest(cfg, model=model)
|
||||
manifest.write_to(_output_dir(cfg))
|
||||
|
||||
def post_lora_merge(self, cfg, adapter_path: str, output_path: str) -> None:
|
||||
if self._decision_config(cfg) is None:
|
||||
return
|
||||
source = Path(adapter_path) / MANIFEST_FILENAME
|
||||
if not source.exists():
|
||||
return
|
||||
DecisionManifest.from_path(source)
|
||||
shutil.copy2(source, Path(output_path) / source.name)
|
||||
|
||||
def get_collator_cls_and_kwargs(self, cfg, is_eval: bool = False):
|
||||
if self._require_runtime(cfg) is None:
|
||||
return None
|
||||
try:
|
||||
from .training_collator import decision_collator_for_config
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"diffusion_decision collator integration is not installed"
|
||||
) from exc
|
||||
return decision_collator_for_config(cfg, is_eval=is_eval)
|
||||
@@ -0,0 +1,402 @@
|
||||
"""Versioned local prepared-row cache for decision training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
import tokenizers
|
||||
import transformers
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
from ._util import (
|
||||
_value,
|
||||
atomic_write_text,
|
||||
canonical_sha256,
|
||||
model_config_overrides,
|
||||
sha256_file,
|
||||
)
|
||||
from .loss import decision_example_from_canvas
|
||||
from .records import DecisionCanvas, OrdinalMetadata
|
||||
from .slots import SlotPlan
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
PRODUCER = "decision-prepared-rows-v2"
|
||||
LOG = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _json_default(item: Any) -> Any:
|
||||
if isinstance(item, Enum):
|
||||
return item.value
|
||||
if is_dataclass(item) and not isinstance(item, type):
|
||||
return asdict(item)
|
||||
if isinstance(item, Path):
|
||||
return str(item)
|
||||
if hasattr(item, "to_dict"):
|
||||
return item.to_dict()
|
||||
return str(item)
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
return canonical_sha256(value, default=_json_default)
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, Path):
|
||||
return str(value)
|
||||
if isinstance(value, Enum):
|
||||
return _json_value(value.value)
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
|
||||
return [_json_value(item) for item in value]
|
||||
if hasattr(value, "model_dump"):
|
||||
return _json_value(value.model_dump(mode="json"))
|
||||
if hasattr(value, "to_dict"):
|
||||
return _json_value(value.to_dict())
|
||||
raise TypeError(f"cannot serialize decision preparation value {type(value)!r}")
|
||||
|
||||
|
||||
def _semantic_config(cfg: Any) -> dict[str, Any]:
|
||||
fields = (
|
||||
"base_model",
|
||||
"revision_of_model",
|
||||
"model_config_type",
|
||||
"seed",
|
||||
"sequence_len",
|
||||
"micro_batch_size",
|
||||
"eval_batch_size",
|
||||
"gradient_accumulation_steps",
|
||||
"attn_implementation",
|
||||
"sample_packing",
|
||||
"eval_sample_packing",
|
||||
"batch_flattening",
|
||||
)
|
||||
result = {
|
||||
name: _json_value(value)
|
||||
for name in fields
|
||||
if (value := _value(cfg, name)) is not None
|
||||
}
|
||||
for name in ("diffusion_lm", "diffusion_decision"):
|
||||
value = _value(cfg, name)
|
||||
if value is not None:
|
||||
result[name] = _json_value(value)
|
||||
return result
|
||||
|
||||
|
||||
def _entry_identity(
|
||||
cfg: Any, selected: Sequence[tuple[str, int, Any]] | None = None
|
||||
) -> list[dict[str, Any]]:
|
||||
entries = []
|
||||
candidates = (
|
||||
selected
|
||||
if selected is not None
|
||||
else (
|
||||
(collection, index, entry)
|
||||
for collection in ("datasets", "test_datasets")
|
||||
for index, entry in enumerate(_value(cfg, collection, ()) or ())
|
||||
)
|
||||
)
|
||||
for collection, index, entry in candidates:
|
||||
adapter = _value(entry, "type")
|
||||
if isinstance(adapter, str) and adapter.startswith("diffusion_decision."):
|
||||
entries.append(
|
||||
{
|
||||
"collection": collection,
|
||||
"index": index,
|
||||
"type": adapter,
|
||||
"path": _value(entry, "path"),
|
||||
"split": _value(entry, "split", "train"),
|
||||
"name": _value(entry, "name"),
|
||||
"revision": _value(entry, "revision"),
|
||||
"data_files": _value(entry, "data_files"),
|
||||
}
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def _resolved_tokenizer_revision(cfg: Any, tokenizer: Any) -> str | None:
|
||||
init_kwargs = getattr(tokenizer, "init_kwargs", None)
|
||||
model_config = model_config_overrides(cfg)
|
||||
for value in (
|
||||
getattr(tokenizer, "_commit_hash", None),
|
||||
_value(init_kwargs, "_commit_hash"),
|
||||
_value(model_config, "_commit_hash"),
|
||||
):
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
requested_revision = _value(cfg, "revision_of_model")
|
||||
if isinstance(requested_revision, str) and re.fullmatch(
|
||||
r"[0-9a-fA-F]{40}", requested_revision
|
||||
):
|
||||
return requested_revision.lower()
|
||||
return None
|
||||
|
||||
|
||||
def _tokenizer_root(
|
||||
cfg: Any, tokenizer: Any
|
||||
) -> tuple[Path, dict[str, str] | None] | None:
|
||||
name_or_path = getattr(tokenizer, "name_or_path", None)
|
||||
if not isinstance(name_or_path, str):
|
||||
return None
|
||||
local_root = Path(name_or_path)
|
||||
if local_root.is_dir():
|
||||
return local_root, None
|
||||
revision = _resolved_tokenizer_revision(cfg, tokenizer)
|
||||
if revision is None:
|
||||
return None
|
||||
for filename in (
|
||||
"tokenizer_config.json",
|
||||
"tokenizer.json",
|
||||
"special_tokens_map.json",
|
||||
"added_tokens.json",
|
||||
"chat_template.jinja",
|
||||
):
|
||||
try:
|
||||
tokenizer_file = Path(
|
||||
hf_hub_download(
|
||||
name_or_path,
|
||||
filename=filename,
|
||||
repo_type="model",
|
||||
revision=revision,
|
||||
local_files_only=True,
|
||||
)
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if tokenizer_file.is_file():
|
||||
return tokenizer_file.parent, {
|
||||
"repo_id": name_or_path,
|
||||
"resolved_revision": revision,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def identity(
|
||||
cfg: Any,
|
||||
tokenizer: Any,
|
||||
spec: Any,
|
||||
source_paths: Sequence[Path],
|
||||
*,
|
||||
selected_entries: Sequence[tuple[str, int, Any]] | None = None,
|
||||
scope: Mapping[str, Any] | None = None,
|
||||
) -> tuple[str, dict[str, Any]] | None:
|
||||
backend = getattr(getattr(tokenizer, "backend_tokenizer", None), "to_str", None)
|
||||
vocabulary = getattr(tokenizer, "get_vocab", None)
|
||||
resolved_root = _tokenizer_root(cfg, tokenizer)
|
||||
if resolved_root is None or not (callable(backend) and callable(vocabulary)):
|
||||
return None
|
||||
token_root, hub_identity = resolved_root
|
||||
tokenizer_files = sorted(
|
||||
path
|
||||
for path in token_root.iterdir()
|
||||
if path.is_file()
|
||||
and path.name
|
||||
in {
|
||||
"tokenizer.json",
|
||||
"tokenizer_config.json",
|
||||
"special_tokens_map.json",
|
||||
"added_tokens.json",
|
||||
"chat_template.jinja",
|
||||
}
|
||||
)
|
||||
if not tokenizer_files or not source_paths:
|
||||
return None
|
||||
try:
|
||||
spec_value = asdict(spec) if is_dataclass(spec) else vars(spec) # type: ignore[arg-type]
|
||||
semantic_config = _semantic_config(cfg)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
payload: dict[str, Any] = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"producer": PRODUCER,
|
||||
"producer_sha256": sha256_file(Path(__file__)),
|
||||
"producer_dependencies": [
|
||||
(str(path.relative_to(Path(__file__).parent)), sha256_file(path))
|
||||
for path in sorted(Path(__file__).parent.rglob("*.py"))
|
||||
if "__pycache__" not in path.parts
|
||||
],
|
||||
"config": semantic_config,
|
||||
"entries": _entry_identity(cfg, selected_entries),
|
||||
"scope": dict(scope or {}),
|
||||
"preparation": {
|
||||
"model_config": model_config_overrides(cfg),
|
||||
"sample_packing": _value(cfg, "sample_packing"),
|
||||
"eval_sample_packing": _value(cfg, "eval_sample_packing"),
|
||||
"batch_flattening": _value(cfg, "batch_flattening"),
|
||||
"eval_batch_size": _value(cfg, "eval_batch_size"),
|
||||
},
|
||||
"spec": spec_value,
|
||||
"tokenizer": {
|
||||
"files": [
|
||||
(str(path.relative_to(token_root)), sha256_file(path))
|
||||
for path in tokenizer_files
|
||||
],
|
||||
"special_ids": {
|
||||
name: getattr(tokenizer, name, None)
|
||||
for name in (
|
||||
"pad_token_id",
|
||||
"bos_token_id",
|
||||
"eos_token_id",
|
||||
"unk_token_id",
|
||||
"mask_token_id",
|
||||
)
|
||||
},
|
||||
"vocab_size": len(tokenizer),
|
||||
"chat_template": getattr(tokenizer, "chat_template", None),
|
||||
"vocab": sorted(vocabulary().items()),
|
||||
"added_vocab": sorted(
|
||||
getattr(tokenizer, "get_added_vocab", lambda: {})().items()
|
||||
),
|
||||
"backend": backend(),
|
||||
"class": f"{type(tokenizer).__module__}.{type(tokenizer).__qualname__}",
|
||||
"tokenizers_version": tokenizers.__version__,
|
||||
"transformers_version": transformers.__version__,
|
||||
},
|
||||
"sources": [(str(path.resolve()), sha256_file(path)) for path in source_paths],
|
||||
}
|
||||
if hub_identity is not None:
|
||||
payload["tokenizer"]["hub"] = hub_identity
|
||||
return _sha(payload), payload
|
||||
|
||||
|
||||
def _canvas_to_json(canvas: DecisionCanvas) -> dict[str, Any]:
|
||||
data = asdict(canvas)
|
||||
return data
|
||||
|
||||
|
||||
def _canvas_from_json(data: Mapping[str, Any]) -> DecisionCanvas:
|
||||
ordinal = tuple(
|
||||
None
|
||||
if item is None
|
||||
else OrdinalMetadata(
|
||||
levels=tuple(item["levels"]),
|
||||
source_ids=tuple(item["source_ids"]),
|
||||
candidate_ranks=tuple(item["candidate_ranks"]),
|
||||
)
|
||||
for item in data.get("ordinal_metadata", ())
|
||||
)
|
||||
return DecisionCanvas(
|
||||
prompt_ids=tuple(data["prompt_ids"]),
|
||||
canvas_ids=tuple(data["canvas_ids"]),
|
||||
label_positions=tuple(data["label_positions"]),
|
||||
allowed_ids=tuple(tuple(value) for value in data["allowed_ids"]),
|
||||
question_ids=tuple(data["question_ids"]),
|
||||
targets=tuple(data["targets"]),
|
||||
pinned_mask=tuple(data["pinned_mask"]),
|
||||
semantic_mask=tuple(data["semantic_mask"]),
|
||||
slot_mask=tuple(data["slot_mask"]),
|
||||
template_length=int(data["template_length"]),
|
||||
prompt_slot_mask=tuple(data.get("prompt_slot_mask", ())),
|
||||
ordinal_metadata=ordinal,
|
||||
)
|
||||
|
||||
|
||||
def _row_to_json(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
value = {
|
||||
key: item
|
||||
for key, item in row.items()
|
||||
if key not in {"canvas", "decision_example", "slot_plan"}
|
||||
}
|
||||
value["canvas"] = _canvas_to_json(row["canvas"])
|
||||
example = row.get("decision_example")
|
||||
if example is not None:
|
||||
value["_decision_example_source_weight"] = example.source_weight
|
||||
plan = row.get("slot_plan")
|
||||
if plan is not None:
|
||||
value["slot_plan"] = asdict(plan)
|
||||
return value
|
||||
|
||||
|
||||
def _row_from_json(data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
row = dict(data)
|
||||
if isinstance(row.get("record"), dict) and "grouped_record_ids" in row["record"]:
|
||||
row["record"] = dict(row["record"])
|
||||
row["record"]["grouped_record_ids"] = tuple(row["record"]["grouped_record_ids"])
|
||||
row["canvas"] = _canvas_from_json(row["canvas"])
|
||||
if "slot_plan" in row:
|
||||
plan = row["slot_plan"]
|
||||
row["slot_plan"] = SlotPlan(
|
||||
ids=tuple(plan["ids"]),
|
||||
placement=plan["placement"],
|
||||
pinned_mask=tuple(plan["pinned_mask"]),
|
||||
update_mask=tuple(plan["update_mask"]),
|
||||
loss_mask=tuple(plan["loss_mask"]),
|
||||
trainable_token_ids=tuple(plan["trainable_token_ids"]),
|
||||
)
|
||||
source_weight = float(
|
||||
row.pop("_decision_example_source_weight", row.get("source_weight", 1.0))
|
||||
)
|
||||
row["decision_example"] = decision_example_from_canvas(
|
||||
row["canvas"], source_weight=source_weight
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
def load(
|
||||
root: Path,
|
||||
key: str,
|
||||
identity_payload: Mapping[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]] | None:
|
||||
path = root / "diffusion_decision_cache" / f"{key}.json"
|
||||
try:
|
||||
payload = json.loads(path.read_text())
|
||||
if _sha(payload["content"]) != payload["sha256"]:
|
||||
return None
|
||||
metadata = payload["content"]
|
||||
if _sha(metadata["identity"]) != _sha(identity_payload):
|
||||
LOG.info("Decision prepared-row cache identity mismatch")
|
||||
return None
|
||||
rows = metadata["rows"]
|
||||
train = [_row_from_json(row) for row in rows["train"]]
|
||||
eval_rows = [_row_from_json(row) for row in rows["eval"]]
|
||||
manifest = dict(metadata["manifest"])
|
||||
if "stratified_epoch_batches" in manifest:
|
||||
manifest["stratified_epoch_batches"] = tuple(
|
||||
tuple(batch) for batch in manifest["stratified_epoch_batches"]
|
||||
)
|
||||
if _sha(rows) != metadata["rows_sha256"]:
|
||||
LOG.info("Decision prepared-row cache payload validation mismatch")
|
||||
return None
|
||||
return train, eval_rows, manifest
|
||||
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def store(
|
||||
root: Path,
|
||||
key: str,
|
||||
identity_payload: Mapping[str, Any],
|
||||
cfg: Any,
|
||||
train: Sequence[Mapping[str, Any]],
|
||||
eval_rows: Sequence[Mapping[str, Any]],
|
||||
manifest: Mapping[str, Any],
|
||||
) -> None:
|
||||
rows = {
|
||||
"train": [_row_to_json(row) for row in train],
|
||||
"eval": [_row_to_json(row) for row in eval_rows],
|
||||
}
|
||||
content = {
|
||||
"identity": identity_payload,
|
||||
"config": _semantic_config(cfg),
|
||||
"manifest": manifest,
|
||||
"rows_sha256": _sha(rows),
|
||||
"rows": rows,
|
||||
}
|
||||
parent = root / "diffusion_decision_cache"
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_text(
|
||||
parent / f"{key}.json",
|
||||
json.dumps(
|
||||
{"sha256": _sha(content), "content": content},
|
||||
sort_keys=True,
|
||||
default=_json_default,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Build model-tokenized decision canvases from validated source records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from .adapters.jsonl import normalize_jsonl
|
||||
from .prompts import decision_prompt_ids
|
||||
from .readers.base import validate_canvas
|
||||
from .records import DecisionCanvas, OrdinalMetadata
|
||||
from .slots import SlotPlan
|
||||
from .template import (
|
||||
SchemaError,
|
||||
parse_decision_schema,
|
||||
resolve_template,
|
||||
system_text,
|
||||
)
|
||||
|
||||
|
||||
def build_decision_canvas(
|
||||
tokenizer,
|
||||
record: Mapping[str, Any],
|
||||
prompt_ids: Sequence[int] | None = None,
|
||||
*,
|
||||
scaffold_ids: Sequence[int],
|
||||
turn_close_id: int,
|
||||
pad_id: int,
|
||||
vocab_size: int,
|
||||
width: int = 128,
|
||||
seed: int = 0,
|
||||
steps: int = 1,
|
||||
noise_kind: str = "uniform",
|
||||
mask_token_id: int | None = None,
|
||||
slot_plan: SlotPlan | None = None,
|
||||
thought_open_ids: Sequence[int] = (),
|
||||
thought_close_ids: Sequence[int] = (),
|
||||
include_ordinal_metadata: bool = False,
|
||||
codebook: str = "vendored26",
|
||||
prevalidated_record: bool = False,
|
||||
) -> DecisionCanvas:
|
||||
if steps < 1 or width < 2 or vocab_size < 2:
|
||||
raise ValueError("steps, canvas width, and vocabulary must be positive")
|
||||
if noise_kind not in {"uniform", "absorbing"}:
|
||||
raise ValueError("unknown noise kind")
|
||||
if noise_kind == "absorbing" and (
|
||||
mask_token_id is None or not 0 <= mask_token_id < vocab_size
|
||||
):
|
||||
raise ValueError("absorbing noise requires a valid mask_token_id")
|
||||
normalized = (
|
||||
dict(record)
|
||||
if prevalidated_record
|
||||
else normalize_jsonl(record, codebook=codebook)
|
||||
)
|
||||
schema = parse_decision_schema(
|
||||
{
|
||||
"instructions": normalized.get("instructions"),
|
||||
"questions": [
|
||||
{**question, "id": key}
|
||||
for key, question in normalized["questions"].items()
|
||||
],
|
||||
},
|
||||
codebook=codebook,
|
||||
)
|
||||
if prompt_ids is None:
|
||||
prompt_ids = decision_prompt_ids(
|
||||
tokenizer, system_text(schema), normalized["state"]
|
||||
)
|
||||
prompt_ids = tuple(prompt_ids)
|
||||
plan = _slot_plan(slot_plan)
|
||||
prompt_slot_mask: tuple[bool, ...] = ()
|
||||
if plan is not None and plan.placement == "prompt":
|
||||
prompt_ids = (*plan.ids, *prompt_ids)
|
||||
prompt_slot_mask = (True,) * len(plan.ids) + (False,) * (
|
||||
len(prompt_ids) - len(plan.ids)
|
||||
)
|
||||
head = tuple(scaffold_ids)
|
||||
if plan is not None and plan.placement == "thought":
|
||||
head = (*head, *thought_open_ids, *plan.ids, *thought_close_ids)
|
||||
pad_slots = () if plan is None or plan.placement != "after_turn" else plan.ids
|
||||
base, slots = resolve_template(
|
||||
tokenizer,
|
||||
schema["questions"],
|
||||
head=head,
|
||||
fmt=schema["format"],
|
||||
width=width - len(pad_slots),
|
||||
)
|
||||
ids = list(base) + [turn_close_id]
|
||||
slot_positions: tuple[int, ...] = ()
|
||||
if plan is not None:
|
||||
if plan.placement == "thought":
|
||||
start = len(scaffold_ids) + len(thought_open_ids)
|
||||
slot_positions = tuple(range(start, start + len(plan.ids)))
|
||||
elif plan.placement == "after_turn":
|
||||
start = len(ids)
|
||||
slot_positions = tuple(range(start, start + len(plan.ids)))
|
||||
ids.extend(plan.ids)
|
||||
if len(ids) > width:
|
||||
raise SchemaError("decision canvas exceeds width")
|
||||
ids.extend([pad_id] * (width - len(ids)))
|
||||
positions = tuple(slot["pos"] for slot in slots)
|
||||
generator = random.Random(seed) # nosec B311 - Reproduce pinned serving noise.
|
||||
for position in positions:
|
||||
if noise_kind == "uniform":
|
||||
ids[position] = generator.randrange(vocab_size)
|
||||
else:
|
||||
assert mask_token_id is not None
|
||||
ids[position] = mask_token_id
|
||||
result = DecisionCanvas(
|
||||
prompt_ids=tuple(prompt_ids),
|
||||
canvas_ids=tuple(ids),
|
||||
label_positions=positions,
|
||||
allowed_ids=tuple(tuple(slot["label_ids"]) for slot in slots),
|
||||
question_ids=tuple(question["id"] for question in schema["questions"]),
|
||||
targets=tuple(
|
||||
normalized["labels"][question["id"]] for question in schema["questions"]
|
||||
),
|
||||
pinned_mask=tuple(
|
||||
(
|
||||
steps > 1
|
||||
and index not in positions
|
||||
and not (
|
||||
plan is not None
|
||||
and index in slot_positions
|
||||
and plan.update_mask[slot_positions.index(index)]
|
||||
)
|
||||
)
|
||||
or (
|
||||
plan is not None
|
||||
and index in slot_positions
|
||||
and plan.pinned_mask[slot_positions.index(index)]
|
||||
)
|
||||
for index in range(width)
|
||||
),
|
||||
semantic_mask=(True,) * width,
|
||||
slot_mask=tuple(index in slot_positions for index in range(width)),
|
||||
template_length=len(base),
|
||||
prompt_slot_mask=prompt_slot_mask,
|
||||
ordinal_metadata=(
|
||||
_ordinal_metadata(
|
||||
normalized, tuple(tuple(slot["label_ids"]) for slot in slots)
|
||||
)
|
||||
if include_ordinal_metadata
|
||||
else ()
|
||||
),
|
||||
)
|
||||
validate_canvas(result)
|
||||
if any(
|
||||
token >= vocab_size
|
||||
for tokens in (result.prompt_ids, result.canvas_ids, *result.allowed_ids)
|
||||
for token in tokens
|
||||
):
|
||||
raise ValueError("decision token ID exceeds the model vocabulary")
|
||||
return result
|
||||
|
||||
|
||||
def _slot_plan(slot_plan: SlotPlan | None) -> SlotPlan | None:
|
||||
if slot_plan is None:
|
||||
return None
|
||||
widths = {
|
||||
len(slot_plan.ids),
|
||||
len(slot_plan.pinned_mask),
|
||||
len(slot_plan.update_mask),
|
||||
len(slot_plan.loss_mask),
|
||||
}
|
||||
if len(widths) != 1:
|
||||
raise ValueError("slot plan masks must match the number of slot IDs")
|
||||
if any(slot_plan.loss_mask):
|
||||
raise ValueError("decision slots cannot carry label loss")
|
||||
if any(
|
||||
update and pinned
|
||||
for update, pinned in zip(
|
||||
slot_plan.update_mask, slot_plan.pinned_mask, strict=True
|
||||
)
|
||||
):
|
||||
raise ValueError("pinned decision slots cannot be updateable")
|
||||
if slot_plan.placement == "none":
|
||||
if slot_plan.ids:
|
||||
raise ValueError("a none slot plan cannot contain slots")
|
||||
return None
|
||||
if slot_plan.placement not in {"thought", "after_turn", "prompt"}:
|
||||
raise ValueError(f"unknown slot placement: {slot_plan.placement!r}")
|
||||
return slot_plan
|
||||
|
||||
|
||||
def _ordinal_metadata(
|
||||
normalized: Mapping[str, Any], allowed_ids: tuple[tuple[int, ...], ...]
|
||||
) -> tuple[OrdinalMetadata | None, ...]:
|
||||
questions = normalized["questions"]
|
||||
result: list[OrdinalMetadata | None] = []
|
||||
for (question_id, question), candidates in zip(
|
||||
questions.items(), allowed_ids, strict=True
|
||||
):
|
||||
del question_id
|
||||
if question.get("type") != "score":
|
||||
result.append(None)
|
||||
continue
|
||||
levels = question.get("levels")
|
||||
if not isinstance(levels, Sequence) or isinstance(levels, (str, bytes)):
|
||||
raise ValueError("score ordinal metadata requires source-ordered levels")
|
||||
if len(levels) != len(candidates):
|
||||
raise ValueError("score levels must align with template candidates")
|
||||
rendered_levels = tuple(str(level) for level in levels)
|
||||
if any(not level for level in rendered_levels):
|
||||
raise ValueError("score levels must be nonempty")
|
||||
result.append(
|
||||
OrdinalMetadata(
|
||||
levels=rendered_levels,
|
||||
source_ids=tuple(str(index) for index in range(len(candidates))),
|
||||
candidate_ranks=tuple(range(len(candidates))),
|
||||
)
|
||||
)
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def ordinal_metadata_for_canvas(
|
||||
record: Mapping[str, Any], canvas: DecisionCanvas, *, codebook: str = "vendored26"
|
||||
) -> tuple[OrdinalMetadata | None, ...]:
|
||||
"""Derive score ranks from the normalized source criteria, never token IDs."""
|
||||
normalized = normalize_jsonl(record, codebook=codebook)
|
||||
result = _ordinal_metadata(
|
||||
normalized, tuple(tuple(ids) for ids in canvas.allowed_ids)
|
||||
)
|
||||
if tuple(normalized["questions"]) != tuple(canvas.question_ids):
|
||||
raise ValueError(
|
||||
"normalized score questions must align with the prepared canvas"
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tokenize decision prompts with the loaded tokenizer's chat template."""
|
||||
|
||||
import json
|
||||
from collections.abc import Mapping, Sequence
|
||||
from numbers import Integral
|
||||
from typing import Any
|
||||
|
||||
|
||||
def serialize_state(state: Any) -> str:
|
||||
"""Match djev's text-state serialization."""
|
||||
if state is None:
|
||||
raise ValueError("state is required")
|
||||
return state if isinstance(state, str) else json.dumps(state, ensure_ascii=False)
|
||||
|
||||
|
||||
def _content(text: str, structured_content: bool) -> str | list[dict[str, str]]:
|
||||
if structured_content:
|
||||
return [{"type": "text", "text": text}]
|
||||
return text
|
||||
|
||||
|
||||
def _input_ids(output: Any) -> tuple[int, ...]:
|
||||
value = output["input_ids"] if isinstance(output, Mapping) else output
|
||||
if hasattr(value, "tolist"):
|
||||
value = value.tolist()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
raise TypeError("apply_chat_template must return token ids")
|
||||
if (
|
||||
value
|
||||
and isinstance(value[0], Sequence)
|
||||
and not isinstance(value[0], (str, bytes))
|
||||
):
|
||||
if len(value) != 1:
|
||||
raise ValueError("apply_chat_template returned more than one prompt")
|
||||
value = value[0]
|
||||
if any(
|
||||
isinstance(token, bool) or not isinstance(token, Integral) for token in value
|
||||
):
|
||||
raise TypeError("apply_chat_template must return integer token ids")
|
||||
return tuple(int(token) for token in value)
|
||||
|
||||
|
||||
def decision_prompt_ids(
|
||||
tokenizer: Any,
|
||||
system_text: str,
|
||||
state: Any,
|
||||
*,
|
||||
thinking: bool = False,
|
||||
structured_content: bool = False,
|
||||
) -> tuple[int, ...]:
|
||||
"""Return the source-compatible chat prompt ending at the model turn.
|
||||
|
||||
Plain text is the pinned serving contract. Callers with a tokenizer template
|
||||
that requires OpenAI-style text parts must explicitly set
|
||||
``structured_content=True``.
|
||||
"""
|
||||
state_text = serialize_state(state)
|
||||
messages = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": _content(system_text, structured_content),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": _content(state_text, structured_content),
|
||||
},
|
||||
]
|
||||
output = tokenizer.apply_chat_template(
|
||||
messages,
|
||||
tokenize=True,
|
||||
add_generation_prompt=True,
|
||||
enable_thinking=thinking,
|
||||
)
|
||||
return _input_ids(output)
|
||||
@@ -0,0 +1,10 @@
|
||||
"""In-process readers for structured diffusion decisions."""
|
||||
|
||||
__ci_config_keys__ = ("diffusion_decision",)
|
||||
|
||||
from .base import DecisionRead, ReadDiagnostics
|
||||
|
||||
__all__ = (
|
||||
"DecisionRead",
|
||||
"ReadDiagnostics",
|
||||
)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Shared typed results and validation for decision readers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from numbers import Integral
|
||||
from typing import Sequence
|
||||
|
||||
import torch
|
||||
|
||||
from ..records import DecisionCanvas, OrdinalMetadata
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReadDiagnostics:
|
||||
"""Inputs and recurrence facts retained for an optional parity fixture."""
|
||||
|
||||
noise_seed: int | None
|
||||
noise_kind: str
|
||||
update_policy: str
|
||||
steps: int
|
||||
forward_count: int
|
||||
initial_canvas_ids: torch.Tensor
|
||||
final_canvas_ids: torch.Tensor
|
||||
slot_init_policy: str = "prepared_canvas_v0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionRead:
|
||||
"""Full-vocabulary and candidate-restricted scores for one decision canvas."""
|
||||
|
||||
question_ids: tuple[str, ...]
|
||||
label_positions: torch.Tensor
|
||||
allowed_ids: torch.Tensor
|
||||
candidate_mask: torch.Tensor
|
||||
full_vocab_logprobs: torch.Tensor
|
||||
restricted_probs: torch.Tensor
|
||||
diagnostics: ReadDiagnostics | None = None
|
||||
|
||||
|
||||
def validate_canvas(canvas: DecisionCanvas) -> int:
|
||||
"""Validate the model-agnostic canvas invariants before any model execution."""
|
||||
|
||||
width = len(canvas.canvas_ids)
|
||||
if not canvas.prompt_ids or width < 2:
|
||||
raise ValueError("a decision canvas requires a prompt and token space")
|
||||
if (
|
||||
isinstance(canvas.template_length, bool)
|
||||
or not isinstance(canvas.template_length, Integral)
|
||||
or not 0 < canvas.template_length < width
|
||||
):
|
||||
raise ValueError("template_length must end before the turn-close token")
|
||||
for tokens in (canvas.prompt_ids, canvas.canvas_ids, *canvas.allowed_ids):
|
||||
if any(
|
||||
isinstance(token, bool) or not isinstance(token, Integral) or token < 0
|
||||
for token in tokens
|
||||
):
|
||||
raise ValueError("decision token IDs must be nonnegative integers")
|
||||
if len(set(canvas.question_ids)) != len(canvas.question_ids) or any(
|
||||
not isinstance(key, str) or not key for key in canvas.question_ids
|
||||
):
|
||||
raise ValueError("question IDs must be distinct nonempty strings")
|
||||
fields: tuple[tuple[str, Sequence[object]], ...] = (
|
||||
("pinned_mask", canvas.pinned_mask),
|
||||
("semantic_mask", canvas.semantic_mask),
|
||||
("slot_mask", canvas.slot_mask),
|
||||
)
|
||||
for name, values in fields:
|
||||
if len(values) != width:
|
||||
raise ValueError(f"{name} must have one value for each canvas token")
|
||||
if canvas.prompt_slot_mask:
|
||||
if len(canvas.prompt_slot_mask) != len(canvas.prompt_ids):
|
||||
raise ValueError(
|
||||
"prompt_slot_mask must have one value for each prompt token"
|
||||
)
|
||||
if any(not isinstance(value, bool) for value in canvas.prompt_slot_mask):
|
||||
raise TypeError("prompt_slot_mask values must be bool")
|
||||
question_count = len(canvas.label_positions)
|
||||
if question_count == 0:
|
||||
raise ValueError("a decision canvas must contain at least one label position")
|
||||
if not (
|
||||
len(canvas.allowed_ids) == len(canvas.question_ids) == question_count
|
||||
and len(canvas.targets) in (0, question_count)
|
||||
):
|
||||
raise ValueError("question metadata must align with label_positions")
|
||||
if canvas.ordinal_metadata and len(canvas.ordinal_metadata) != question_count:
|
||||
raise ValueError("ordinal metadata must align with canvas questions")
|
||||
seen_positions: set[int] = set()
|
||||
for index, (position, candidates) in enumerate(
|
||||
zip(canvas.label_positions, canvas.allowed_ids, strict=True)
|
||||
):
|
||||
if (
|
||||
isinstance(position, bool)
|
||||
or not isinstance(position, Integral)
|
||||
or position < 0
|
||||
or position >= canvas.template_length
|
||||
):
|
||||
raise ValueError(
|
||||
f"label position {position} is outside the decision canvas"
|
||||
)
|
||||
if position in seen_positions:
|
||||
raise ValueError("each question must have a distinct label position")
|
||||
if canvas.slot_mask[position]:
|
||||
raise ValueError("latent slots cannot be label positions")
|
||||
if canvas.pinned_mask[position]:
|
||||
raise ValueError("pinned tokens cannot be label positions")
|
||||
if not canvas.semantic_mask[position]:
|
||||
raise ValueError("label positions must be semantically valid")
|
||||
if not candidates:
|
||||
raise ValueError(f"question {index} has no allowed label token")
|
||||
if len(set(candidates)) != len(candidates):
|
||||
raise ValueError(f"question {index} repeats an allowed label token")
|
||||
metadata = canvas.ordinal_metadata[index] if canvas.ordinal_metadata else None
|
||||
if metadata is not None:
|
||||
if not isinstance(metadata, OrdinalMetadata):
|
||||
raise TypeError("ordinal metadata must be OrdinalMetadata or None")
|
||||
metadata.check_candidates(len(candidates))
|
||||
seen_positions.add(position)
|
||||
return width
|
||||
|
||||
|
||||
def restricted_probabilities(
|
||||
full_vocab_logprobs: torch.Tensor, allowed_ids: Sequence[Sequence[int]]
|
||||
) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
||||
"""Apply a per-question candidate softmax without losing full-vocabulary scores."""
|
||||
|
||||
if full_vocab_logprobs.ndim != 2:
|
||||
raise ValueError("full_vocab_logprobs must be [questions, vocabulary]")
|
||||
question_count, vocab_size = full_vocab_logprobs.shape
|
||||
if question_count != len(allowed_ids):
|
||||
raise ValueError("allowed_ids must contain one candidate sequence per question")
|
||||
maximum = max(len(ids) for ids in allowed_ids)
|
||||
ids = torch.zeros(
|
||||
(question_count, maximum), dtype=torch.long, device=full_vocab_logprobs.device
|
||||
)
|
||||
valid = torch.zeros_like(ids, dtype=torch.bool)
|
||||
for row, candidates in enumerate(allowed_ids):
|
||||
if any(token < 0 or token >= vocab_size for token in candidates):
|
||||
raise ValueError("allowed label token ID is outside the model vocabulary")
|
||||
candidate_ids = torch.as_tensor(
|
||||
candidates, dtype=torch.long, device=full_vocab_logprobs.device
|
||||
)
|
||||
ids[row, : candidate_ids.numel()] = candidate_ids
|
||||
valid[row, : candidate_ids.numel()] = True
|
||||
selected = full_vocab_logprobs.gather(1, ids)
|
||||
selected = selected.masked_fill(~valid, -torch.inf)
|
||||
probabilities = torch.softmax(selected, dim=-1).masked_fill(~valid, 0.0)
|
||||
return ids, valid, probabilities
|
||||
@@ -0,0 +1,54 @@
|
||||
from dataclasses import dataclass
|
||||
from numbers import Integral
|
||||
from typing import Any, Sequence
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrdinalMetadata:
|
||||
"""Source-defined score ordering for one candidate-restricted question."""
|
||||
|
||||
levels: Sequence[str]
|
||||
source_ids: Sequence[str]
|
||||
candidate_ranks: Sequence[int]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
count = len(self.levels)
|
||||
if len(self.source_ids) != count or len(self.candidate_ranks) != count:
|
||||
raise ValueError("ordinal metadata must align with allowed candidates")
|
||||
if (
|
||||
any(not isinstance(item, str) or not item for item in self.levels)
|
||||
or any(not isinstance(item, str) or not item for item in self.source_ids)
|
||||
or len(set(self.source_ids)) != count
|
||||
):
|
||||
raise ValueError(
|
||||
"ordinal levels and source IDs must be nonempty and source IDs unique"
|
||||
)
|
||||
if any(
|
||||
isinstance(rank, bool) or not isinstance(rank, Integral)
|
||||
for rank in self.candidate_ranks
|
||||
):
|
||||
raise ValueError("ordinal candidate ranks must be integers")
|
||||
if set(self.candidate_ranks) != set(range(count)):
|
||||
raise ValueError("ordinal candidate ranks must be a zero-based bijection")
|
||||
|
||||
def check_candidates(self, count: int) -> None:
|
||||
if count < 2:
|
||||
raise ValueError("ordinal metadata requires at least two candidates")
|
||||
if len(self.levels) != count:
|
||||
raise ValueError("ordinal metadata must align with allowed candidates")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionCanvas:
|
||||
prompt_ids: Sequence[int]
|
||||
canvas_ids: Sequence[int]
|
||||
label_positions: Sequence[int]
|
||||
allowed_ids: Sequence[Sequence[int]]
|
||||
question_ids: Sequence[str]
|
||||
targets: Sequence[Any]
|
||||
pinned_mask: Sequence[bool]
|
||||
semantic_mask: Sequence[bool]
|
||||
slot_mask: Sequence[bool]
|
||||
template_length: int
|
||||
prompt_slot_mask: Sequence[bool] = ()
|
||||
ordinal_metadata: Sequence[OrdinalMetadata | None] = ()
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Runtime resolution for learned decision-slot embedding rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, MutableMapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from axolotl.model_support import DiffusionSpec
|
||||
|
||||
from ._util import (
|
||||
_value,
|
||||
active_control_ids,
|
||||
optional_token_id,
|
||||
parse_token_ids,
|
||||
require_diffusion_spec,
|
||||
)
|
||||
from .slots import SlotInit, SlotPlan
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrainableSlotRuntime:
|
||||
"""Validated learned/prompt slot rows and their actual input-embedding path."""
|
||||
|
||||
plan: SlotPlan
|
||||
embedding_module_path: str
|
||||
|
||||
|
||||
def _set_value(value: Any, name: str, item: Any) -> None:
|
||||
if isinstance(value, MutableMapping):
|
||||
value[name] = item
|
||||
elif isinstance(value, Mapping):
|
||||
raise TypeError("decision slot configuration mapping must be mutable")
|
||||
else:
|
||||
setattr(value, name, item)
|
||||
|
||||
|
||||
def _embedding_path(model: Any, embedding: Any) -> str:
|
||||
if not hasattr(model, "named_modules"):
|
||||
raise TypeError("model must expose named_modules for trainable slot rows")
|
||||
matches = [name for name, module in model.named_modules() if module is embedding]
|
||||
if len(matches) != 1 or not matches[0]:
|
||||
raise ValueError(
|
||||
"could not determine one non-root input embedding module path for "
|
||||
"peft_trainable_token_indices"
|
||||
)
|
||||
return matches[0]
|
||||
|
||||
|
||||
def resolve_trainable_slot_runtime(
|
||||
cfg: Any,
|
||||
model: Any,
|
||||
decision: Any,
|
||||
*,
|
||||
spec: DiffusionSpec | None = None,
|
||||
) -> TrainableSlotRuntime | None:
|
||||
"""Resolve learned/prompt rows from config and the model's actual embedding."""
|
||||
latent = _value(decision, "latent")
|
||||
if latent is None:
|
||||
raise ValueError("diffusion_decision requires latent settings")
|
||||
mode = _value(latent, "mode", "none")
|
||||
if mode not in {"learned", "prompt"}:
|
||||
return None
|
||||
if not hasattr(model, "get_input_embeddings"):
|
||||
raise TypeError(
|
||||
"model must expose get_input_embeddings for trainable slot rows"
|
||||
)
|
||||
embedding = model.get_input_embeddings()
|
||||
vocab_size = _value(embedding, "num_embeddings")
|
||||
if (
|
||||
isinstance(vocab_size, bool)
|
||||
or not isinstance(vocab_size, int)
|
||||
or vocab_size < 1
|
||||
):
|
||||
raise ValueError("model input embedding must expose a positive num_embeddings")
|
||||
config = _value(model, "config")
|
||||
if config is None:
|
||||
raise TypeError("model must expose config for trainable slot rows")
|
||||
mask_token_id = optional_token_id(_value(config, "mask_token_id"), "mask_token_id")
|
||||
pad_token_id = optional_token_id(_value(config, "pad_token_id"), "pad_token_id")
|
||||
control_ids = active_control_ids(config)
|
||||
invalid_control_ids = sorted(
|
||||
token_id for token_id in control_ids if token_id >= vocab_size
|
||||
)
|
||||
if invalid_control_ids:
|
||||
raise ValueError(
|
||||
"model config control IDs must be within the input embedding vocabulary: "
|
||||
f"{invalid_control_ids}"
|
||||
)
|
||||
token_ids = parse_token_ids(
|
||||
_value(latent, "token_ids", ()) or (), "latent.token_ids"
|
||||
)
|
||||
if set(token_ids) & control_ids:
|
||||
raise ValueError(
|
||||
"learned/prompt slot token_ids cannot use active pad, bos, eos, or mask IDs"
|
||||
)
|
||||
plan = SlotInit(
|
||||
mode=mode,
|
||||
token_ids=token_ids,
|
||||
num_slots=_value(latent, "num_slots", 0),
|
||||
vocab_size=vocab_size,
|
||||
pad_id=pad_token_id,
|
||||
spec=require_diffusion_spec(cfg) if spec is None else spec,
|
||||
mask_token_id=mask_token_id,
|
||||
).build()
|
||||
return TrainableSlotRuntime(plan, _embedding_path(model, embedding))
|
||||
|
||||
|
||||
def merge_trainable_slot_indices(cfg: Any, runtime: TrainableSlotRuntime) -> None:
|
||||
"""Merge learned/prompt rows into PEFT's embedding-row configuration."""
|
||||
configured = _value(cfg, "peft_trainable_token_indices")
|
||||
additions = list(runtime.plan.trainable_token_ids)
|
||||
if not additions:
|
||||
return
|
||||
if configured is None:
|
||||
_set_value(cfg, "peft_trainable_token_indices", additions)
|
||||
return
|
||||
if isinstance(configured, Sequence) and not isinstance(configured, (str, bytes)):
|
||||
_set_value(
|
||||
cfg,
|
||||
"peft_trainable_token_indices",
|
||||
_merged_indices(configured, additions),
|
||||
)
|
||||
return
|
||||
if isinstance(configured, Mapping):
|
||||
existing = configured.get(runtime.embedding_module_path)
|
||||
if existing is None:
|
||||
raise ValueError(
|
||||
"peft_trainable_token_indices mapping must include the actual input "
|
||||
f"embedding path {runtime.embedding_module_path!r} before learned/prompt "
|
||||
"decision slots can be merged"
|
||||
)
|
||||
if not isinstance(existing, Sequence) or isinstance(existing, (str, bytes)):
|
||||
raise ValueError(
|
||||
"peft_trainable_token_indices mapping values must be integer sequences"
|
||||
)
|
||||
updated = dict(configured)
|
||||
updated[runtime.embedding_module_path] = _merged_indices(existing, additions)
|
||||
_set_value(cfg, "peft_trainable_token_indices", updated)
|
||||
return
|
||||
raise ValueError(
|
||||
"peft_trainable_token_indices must be an integer sequence or an embedding-path mapping"
|
||||
)
|
||||
|
||||
|
||||
def _merged_indices(existing: Sequence[Any], additions: Sequence[int]) -> list[int]:
|
||||
result: list[int] = []
|
||||
for value in (*existing, *additions):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(
|
||||
"peft_trainable_token_indices must contain nonnegative integers"
|
||||
)
|
||||
if value not in result:
|
||||
result.append(value)
|
||||
return result
|
||||
@@ -0,0 +1,199 @@
|
||||
"""Pure count sampling and maximum-canvas slot materialization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Final
|
||||
|
||||
from .records import DecisionCanvas
|
||||
from .slots import SlotPlan
|
||||
|
||||
UNIFORM_INCLUSIVE_V1: Final = "uniform_inclusive_v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionDraw:
|
||||
"""One pre-sharding logical decision draw with a stable sampling identity."""
|
||||
|
||||
index: int
|
||||
epoch: int
|
||||
global_draw_ordinal: int
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_nonnegative_int(self.index, "index")
|
||||
_validate_nonnegative_int(self.epoch, "epoch")
|
||||
_validate_nonnegative_int(self.global_draw_ordinal, "global_draw_ordinal")
|
||||
|
||||
|
||||
def sample_slot_count(
|
||||
*,
|
||||
seed: int,
|
||||
epoch: int,
|
||||
global_draw_ordinal: int,
|
||||
max_slots: int,
|
||||
min_slots: int = 0,
|
||||
policy: str = UNIFORM_INCLUSIVE_V1,
|
||||
) -> int:
|
||||
"""Return a stateless count for one logical training draw."""
|
||||
_validate_nonnegative_int(seed, "seed")
|
||||
_validate_nonnegative_int(epoch, "epoch")
|
||||
_validate_nonnegative_int(global_draw_ordinal, "global_draw_ordinal")
|
||||
_validate_nonnegative_int(min_slots, "min_slots")
|
||||
_validate_nonnegative_int(max_slots, "max_slots")
|
||||
if min_slots > max_slots:
|
||||
raise ValueError("min_slots must not exceed max_slots")
|
||||
if policy != UNIFORM_INCLUSIVE_V1:
|
||||
raise ValueError(f"unsupported slot-count policy: {policy!r}")
|
||||
|
||||
population = max_slots - min_slots + 1
|
||||
if population > 1 << 256:
|
||||
raise ValueError("slot-count population exceeds the sampler domain")
|
||||
limit = (1 << 256) - ((1 << 256) % population)
|
||||
counter = 0
|
||||
while True:
|
||||
payload = f"{policy}:{seed}:{epoch}:{global_draw_ordinal}:{counter}".encode()
|
||||
value = int.from_bytes(hashlib.sha256(payload).digest(), "big")
|
||||
if value < limit:
|
||||
return min_slots + (value % population)
|
||||
counter += 1
|
||||
|
||||
|
||||
def project_slot_plan(maximum: SlotPlan, count: int) -> SlotPlan:
|
||||
"""Keep the stable prefix of a validated maximum-count plan."""
|
||||
_validate_nonnegative_int(count, "count")
|
||||
maximum_count = _validate_slot_plan(maximum)
|
||||
if count > maximum_count:
|
||||
raise ValueError("slot count exceeds the maximum slot plan")
|
||||
if count == maximum_count:
|
||||
return maximum
|
||||
return SlotPlan(
|
||||
ids=maximum.ids[:count],
|
||||
placement=maximum.placement,
|
||||
pinned_mask=maximum.pinned_mask[:count],
|
||||
update_mask=maximum.update_mask[:count],
|
||||
loss_mask=maximum.loss_mask[:count],
|
||||
trainable_token_ids=maximum.trainable_token_ids[:count],
|
||||
)
|
||||
|
||||
|
||||
def project_max_canvas(
|
||||
maximum_canvas: DecisionCanvas,
|
||||
maximum_plan: SlotPlan,
|
||||
count: int,
|
||||
*,
|
||||
pad_token_id: int,
|
||||
padding_pinned: bool,
|
||||
) -> DecisionCanvas:
|
||||
"""Materialize a smaller concrete slot count from a maximum-count canvas."""
|
||||
_validate_nonnegative_int(count, "count")
|
||||
_validate_nonnegative_int(pad_token_id, "pad_token_id")
|
||||
if not isinstance(padding_pinned, bool):
|
||||
raise ValueError("padding_pinned must be a bool")
|
||||
maximum_count = _validate_slot_plan(maximum_plan)
|
||||
if count > maximum_count:
|
||||
raise ValueError("slot count exceeds the maximum slot plan")
|
||||
if count == maximum_count:
|
||||
return maximum_canvas
|
||||
|
||||
if maximum_plan.placement == "none":
|
||||
raise ValueError("a none slot plan has no count to project")
|
||||
if maximum_plan.placement == "prompt":
|
||||
return _project_prompt(maximum_canvas, maximum_plan, count)
|
||||
|
||||
positions = _canvas_slot_positions(maximum_canvas, maximum_plan)
|
||||
remove = positions[count:]
|
||||
if maximum_plan.placement == "thought":
|
||||
if any(
|
||||
position <= positions[-1] for position in maximum_canvas.label_positions
|
||||
):
|
||||
raise ValueError("thought slots must precede every label position")
|
||||
label_positions = tuple(
|
||||
position - len(remove) for position in maximum_canvas.label_positions
|
||||
)
|
||||
template_length = maximum_canvas.template_length - len(remove)
|
||||
elif maximum_plan.placement == "after_turn":
|
||||
label_positions = tuple(maximum_canvas.label_positions)
|
||||
template_length = maximum_canvas.template_length
|
||||
else:
|
||||
raise ValueError(f"unsupported slot placement: {maximum_plan.placement!r}")
|
||||
|
||||
return replace(
|
||||
maximum_canvas,
|
||||
canvas_ids=_delete_and_extend(maximum_canvas.canvas_ids, remove, pad_token_id),
|
||||
label_positions=label_positions,
|
||||
pinned_mask=_delete_and_extend(
|
||||
maximum_canvas.pinned_mask, remove, padding_pinned
|
||||
),
|
||||
semantic_mask=_delete_and_extend(maximum_canvas.semantic_mask, remove, True),
|
||||
slot_mask=_delete_and_extend(maximum_canvas.slot_mask, remove, False),
|
||||
template_length=template_length,
|
||||
)
|
||||
|
||||
|
||||
def _project_prompt(
|
||||
canvas: DecisionCanvas, maximum: SlotPlan, count: int
|
||||
) -> DecisionCanvas:
|
||||
maximum_count = len(maximum.ids)
|
||||
if len(canvas.prompt_slot_mask) != len(canvas.prompt_ids):
|
||||
raise ValueError("prompt slots require a complete prompt_slot_mask")
|
||||
if tuple(canvas.prompt_ids[:maximum_count]) != maximum.ids:
|
||||
raise ValueError("prompt slot IDs must be the leading prompt IDs")
|
||||
if any(not value for value in canvas.prompt_slot_mask[:maximum_count]) or any(
|
||||
canvas.prompt_slot_mask[maximum_count:]
|
||||
):
|
||||
raise ValueError("prompt slot mask must mark only the leading slot IDs")
|
||||
if any(canvas.slot_mask):
|
||||
raise ValueError("prompt slots must not occupy canvas positions")
|
||||
return replace(
|
||||
canvas,
|
||||
prompt_ids=(
|
||||
*maximum.ids[:count],
|
||||
*canvas.prompt_ids[maximum_count:],
|
||||
),
|
||||
prompt_slot_mask=(True,) * count
|
||||
+ (False,) * (len(canvas.prompt_ids) - maximum_count),
|
||||
)
|
||||
|
||||
|
||||
def _canvas_slot_positions(
|
||||
canvas: DecisionCanvas, maximum: SlotPlan
|
||||
) -> tuple[int, ...]:
|
||||
positions = tuple(index for index, value in enumerate(canvas.slot_mask) if value)
|
||||
if len(positions) != len(maximum.ids):
|
||||
raise ValueError("canvas slot mask does not match the maximum slot plan")
|
||||
if positions != tuple(range(positions[0], positions[0] + len(positions))):
|
||||
raise ValueError("canvas slot positions must be contiguous")
|
||||
if tuple(canvas.canvas_ids[index] for index in positions) != maximum.ids:
|
||||
raise ValueError("canvas slot IDs do not match the maximum slot plan")
|
||||
return positions
|
||||
|
||||
|
||||
def _delete_and_extend(values, remove: tuple[int, ...], padding_value):
|
||||
removed = set(remove)
|
||||
kept = tuple(value for index, value in enumerate(values) if index not in removed)
|
||||
return (*kept, *(padding_value for _ in remove))
|
||||
|
||||
|
||||
def _validate_slot_plan(plan: SlotPlan) -> int:
|
||||
count = len(plan.ids)
|
||||
if plan.placement not in {"thought", "after_turn", "prompt"}:
|
||||
raise ValueError("slot sampling requires a materialized slot placement")
|
||||
masks = (plan.pinned_mask, plan.update_mask, plan.loss_mask)
|
||||
if any(len(mask) != count for mask in masks):
|
||||
raise ValueError("slot plan masks must match slot IDs")
|
||||
if any(plan.loss_mask):
|
||||
raise ValueError("sampled decision slots cannot carry loss")
|
||||
if any(
|
||||
pinned and update
|
||||
for pinned, update in zip(plan.pinned_mask, plan.update_mask, strict=True)
|
||||
):
|
||||
raise ValueError("pinned sampled slots cannot be updateable")
|
||||
if plan.trainable_token_ids != plan.ids[: len(plan.trainable_token_ids)]:
|
||||
raise ValueError("trainable slot IDs must be a stable slot prefix")
|
||||
return count
|
||||
|
||||
|
||||
def _validate_nonnegative_int(value: int, name: str) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
||||
raise ValueError(f"{name} must be a nonnegative integer")
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Pure latent-slot initialization contracts for decision canvases."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal, Sequence
|
||||
|
||||
from axolotl.model_support import DiffusionNoise, DiffusionSpec
|
||||
|
||||
SlotMode = Literal["none", "pad", "pinned", "learned", "free", "mask", "prompt"]
|
||||
SlotPlacement = Literal["none", "thought", "after_turn", "prompt"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SlotPlan:
|
||||
"""Initial token values and immutable mask facts for one latent-slot arm."""
|
||||
|
||||
ids: tuple[int, ...]
|
||||
placement: SlotPlacement
|
||||
pinned_mask: tuple[bool, ...]
|
||||
update_mask: tuple[bool, ...]
|
||||
loss_mask: tuple[bool, ...]
|
||||
trainable_token_ids: tuple[int, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, init=False)
|
||||
class SlotInit:
|
||||
"""Resolve a configured slot mode without changing a model or a canvas."""
|
||||
|
||||
mode: SlotMode
|
||||
num_slots: int
|
||||
token_ids: tuple[int, ...]
|
||||
vocab_size: int | None
|
||||
pad_id: int | None
|
||||
spec: DiffusionSpec | None
|
||||
mask_token_id: int | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mode: SlotMode = "none",
|
||||
ids: Sequence[int] = (),
|
||||
*,
|
||||
token_ids: Sequence[int] | None = None,
|
||||
num_slots: int = 0,
|
||||
vocab_size: int | None = None,
|
||||
pad_id: int | None = None,
|
||||
spec: DiffusionSpec | None = None,
|
||||
mask_token_id: int | None = None,
|
||||
) -> None:
|
||||
if token_ids is not None and ids:
|
||||
raise ValueError("provide either ids or token_ids, not both")
|
||||
object.__setattr__(self, "mode", mode)
|
||||
object.__setattr__(self, "num_slots", num_slots)
|
||||
object.__setattr__(
|
||||
self, "token_ids", tuple(ids if token_ids is None else token_ids)
|
||||
)
|
||||
object.__setattr__(self, "vocab_size", vocab_size)
|
||||
object.__setattr__(self, "pad_id", pad_id)
|
||||
object.__setattr__(self, "spec", spec)
|
||||
object.__setattr__(self, "mask_token_id", mask_token_id)
|
||||
|
||||
@property
|
||||
def ids(self) -> tuple[int, ...]:
|
||||
"""Compatibility alias for the configured token IDs."""
|
||||
return self.token_ids
|
||||
|
||||
def build(
|
||||
self,
|
||||
*,
|
||||
seed: int | None = None,
|
||||
generator: random.Random | None = None,
|
||||
) -> SlotPlan:
|
||||
"""Validate the mode and resolve its deterministic initial slot values."""
|
||||
self._validate_common()
|
||||
if self.mode == "none":
|
||||
return SlotPlan((), "none", (), (), (), ())
|
||||
assert self.vocab_size is not None
|
||||
if self.mode == "pad":
|
||||
assert self.pad_id is not None
|
||||
self._require_no_token_ids()
|
||||
return self._fixed_plan(
|
||||
(self.pad_id,) * self.num_slots, placement="after_turn"
|
||||
)
|
||||
if self.mode == "pinned":
|
||||
values = self._pinned_values()
|
||||
return self._fixed_plan(values, placement="thought")
|
||||
if self.mode in {"learned", "prompt"}:
|
||||
self._require_exact_token_count(self.num_slots)
|
||||
if len(set(self.token_ids)) != self.num_slots:
|
||||
raise ValueError(f"{self.mode} slots require distinct token_ids")
|
||||
return self._fixed_plan(
|
||||
self.token_ids,
|
||||
placement="prompt" if self.mode == "prompt" else "thought",
|
||||
trainable=True,
|
||||
)
|
||||
if self.mode == "mask":
|
||||
self._require_no_token_ids()
|
||||
mask_token_id = self._absorbing_mask_token_id()
|
||||
return self._fixed_plan(
|
||||
(mask_token_id,) * self.num_slots, placement="thought"
|
||||
)
|
||||
if self.mode == "free":
|
||||
self._require_no_token_ids()
|
||||
assert self.spec is not None
|
||||
if self.spec.noise is DiffusionNoise.ABSORBING:
|
||||
values = (self._absorbing_mask_token_id(),) * self.num_slots
|
||||
else:
|
||||
values = self._uniform_values(seed=seed, generator=generator)
|
||||
return SlotPlan(
|
||||
values,
|
||||
"thought",
|
||||
(False,) * self.num_slots,
|
||||
(True,) * self.num_slots,
|
||||
(False,) * self.num_slots,
|
||||
(),
|
||||
)
|
||||
raise ValueError(f"unknown slot mode: {self.mode!r}")
|
||||
|
||||
def _validate_common(self) -> None:
|
||||
if self.mode not in {
|
||||
"none",
|
||||
"pad",
|
||||
"pinned",
|
||||
"learned",
|
||||
"free",
|
||||
"mask",
|
||||
"prompt",
|
||||
}:
|
||||
raise ValueError(f"unknown slot mode: {self.mode!r}")
|
||||
if isinstance(self.num_slots, bool) or not isinstance(self.num_slots, int):
|
||||
raise ValueError("num_slots must be an integer")
|
||||
if self.num_slots < 0:
|
||||
raise ValueError("num_slots must be nonnegative")
|
||||
if self.mode == "none":
|
||||
if self.num_slots or self.token_ids:
|
||||
raise ValueError("mode=none requires num_slots=0 and no token_ids")
|
||||
return
|
||||
if self.num_slots < 1:
|
||||
raise ValueError(f"mode={self.mode} requires num_slots to be positive")
|
||||
if isinstance(self.vocab_size, bool) or not isinstance(self.vocab_size, int):
|
||||
raise ValueError("slot modes require an integer vocab_size")
|
||||
if self.vocab_size < 1:
|
||||
raise ValueError("vocab_size must be positive")
|
||||
if self.mode == "pad":
|
||||
self._validate_token_id(self.pad_id, "pad_id")
|
||||
if not isinstance(self.spec, DiffusionSpec):
|
||||
raise TypeError("slot modes require a DiffusionSpec")
|
||||
for token_id in self.token_ids:
|
||||
self._validate_token_id(token_id, "token_ids")
|
||||
|
||||
def _validate_token_id(self, token_id: int | None, name: str) -> None:
|
||||
if isinstance(token_id, bool) or not isinstance(token_id, int):
|
||||
raise ValueError(f"{name} must be an integer")
|
||||
assert self.vocab_size is not None
|
||||
if not 0 <= token_id < self.vocab_size:
|
||||
raise ValueError(f"{name} must be within the model vocabulary")
|
||||
|
||||
def _require_no_token_ids(self) -> None:
|
||||
if self.token_ids:
|
||||
raise ValueError(f"mode={self.mode} does not accept token_ids")
|
||||
|
||||
def _require_exact_token_count(self, count: int) -> None:
|
||||
if len(self.token_ids) != count:
|
||||
raise ValueError(f"mode={self.mode} requires exactly {count} token_ids")
|
||||
|
||||
def _pinned_values(self) -> tuple[int, ...]:
|
||||
if len(self.token_ids) == 1:
|
||||
return self.token_ids * self.num_slots
|
||||
if len(self.token_ids) != self.num_slots:
|
||||
raise ValueError(
|
||||
"mode=pinned requires exactly 1 token_id or exactly num_slots distinct token_ids"
|
||||
)
|
||||
if len(set(self.token_ids)) != self.num_slots:
|
||||
raise ValueError("mode=pinned distinct token_ids must not repeat")
|
||||
return self.token_ids
|
||||
|
||||
def _absorbing_mask_token_id(self) -> int:
|
||||
assert self.spec is not None
|
||||
if self.spec.noise is not DiffusionNoise.ABSORBING:
|
||||
raise ValueError(f"mode={self.mode} requires absorbing diffusion noise")
|
||||
self._validate_token_id(self.mask_token_id, "mask_token_id")
|
||||
assert self.mask_token_id is not None
|
||||
return self.mask_token_id
|
||||
|
||||
def _uniform_values(
|
||||
self, *, seed: int | None, generator: random.Random | None
|
||||
) -> tuple[int, ...]:
|
||||
if seed is not None and generator is not None:
|
||||
raise ValueError("free uniform slots accept either seed or generator")
|
||||
if generator is None:
|
||||
if isinstance(seed, bool) or not isinstance(seed, int):
|
||||
raise ValueError("free uniform slots require an explicit integer seed")
|
||||
generator = random.Random(seed) # nosec B311 - caller supplied deterministic seed.
|
||||
assert self.vocab_size is not None
|
||||
return tuple(
|
||||
generator.randrange(self.vocab_size) for _ in range(self.num_slots)
|
||||
)
|
||||
|
||||
def _fixed_plan(
|
||||
self,
|
||||
values: tuple[int, ...],
|
||||
*,
|
||||
placement: SlotPlacement,
|
||||
trainable: bool = False,
|
||||
) -> SlotPlan:
|
||||
return SlotPlan(
|
||||
values,
|
||||
placement,
|
||||
(True,) * self.num_slots,
|
||||
(False,) * self.num_slots,
|
||||
(False,) * self.num_slots,
|
||||
values if trainable else (),
|
||||
)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Tokenizer-local access to the pinned djev schema and template functions."""
|
||||
|
||||
from .vendored.djev_template import (
|
||||
FORMATS,
|
||||
SchemaError,
|
||||
answer_text,
|
||||
parse_schema,
|
||||
resolve_template as _resolve_template,
|
||||
schedule,
|
||||
system_text,
|
||||
)
|
||||
|
||||
EXPANDED52 = tuple("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
|
||||
|
||||
|
||||
def codebook_alphabet(codebook):
|
||||
if codebook == "vendored26":
|
||||
return None
|
||||
if codebook == "expanded52":
|
||||
return EXPANDED52
|
||||
raise ValueError(f"unknown decision label codebook {codebook!r}")
|
||||
|
||||
|
||||
def parse_decision_schema(value, *, codebook="vendored26"):
|
||||
return parse_schema(value, alphabet=codebook_alphabet(codebook))
|
||||
|
||||
|
||||
def resolve_template(tokenizer, qs, head=(), lead="", fmt="lines", width=128):
|
||||
return _resolve_template(
|
||||
qs,
|
||||
list(head),
|
||||
lead,
|
||||
fmt,
|
||||
enc=lambda text: tokenizer.encode(text, add_special_tokens=False),
|
||||
canvas_length=width,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FORMATS",
|
||||
"SchemaError",
|
||||
"answer_text",
|
||||
"parse_schema",
|
||||
"resolve_template",
|
||||
"schedule",
|
||||
"system_text",
|
||||
"codebook_alphabet",
|
||||
"parse_decision_schema",
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,311 @@
|
||||
"""Model-agnostic batches for typed decision diffusion training."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from axolotl.integrations.diffusion_decision._util import (
|
||||
_value,
|
||||
require_diffusion_spec,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.collator import DecisionCanvasCollator
|
||||
from axolotl.integrations.diffusion_decision.loss import (
|
||||
DecisionLabelExample,
|
||||
decision_example_from_canvas,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.records import DecisionCanvas
|
||||
from axolotl.model_support import (
|
||||
DiffusionLayout,
|
||||
DiffusionSpec,
|
||||
)
|
||||
|
||||
|
||||
def _decision_example(
|
||||
canvas: DecisionCanvas, row: Mapping[str, Any]
|
||||
) -> DecisionLabelExample:
|
||||
source = row.get("decision_example")
|
||||
if source is None:
|
||||
source = decision_example_from_canvas(
|
||||
canvas, source_weight=float(row.get("source_weight", 1.0))
|
||||
)
|
||||
if not isinstance(source, DecisionLabelExample):
|
||||
raise TypeError("decision_example must be a DecisionLabelExample")
|
||||
return replace(
|
||||
source,
|
||||
questions=tuple(
|
||||
replace(question, position=index)
|
||||
for index, question in enumerate(source.questions)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DecisionTrainingCollator:
|
||||
"""Emit layout-specific token tensors and a shared decision-loss contract."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
tokenizer: Any | DiffusionSpec | None = None,
|
||||
*,
|
||||
spec: DiffusionSpec | None = None,
|
||||
pad_token_id: int | None = None,
|
||||
padding: bool | str = True,
|
||||
max_length: int | None = None,
|
||||
pad_to_multiple_of: int | None = None,
|
||||
label_pad_token_id: int = -100,
|
||||
return_tensors: str = "pt",
|
||||
physical_payload_capacity: int | None = None,
|
||||
) -> None:
|
||||
if isinstance(tokenizer, DiffusionSpec):
|
||||
if spec is not None:
|
||||
raise TypeError("spec was provided twice")
|
||||
spec = tokenizer
|
||||
tokenizer = None
|
||||
if not isinstance(spec, DiffusionSpec):
|
||||
raise TypeError("DecisionTrainingCollator requires a DiffusionSpec")
|
||||
if padding not in {True, "longest"}:
|
||||
raise ValueError("DecisionTrainingCollator requires longest padding")
|
||||
if label_pad_token_id != -100:
|
||||
raise ValueError("DecisionTrainingCollator uses native decision masks")
|
||||
if return_tensors != "pt":
|
||||
raise ValueError("DecisionTrainingCollator requires return_tensors='pt'")
|
||||
self.spec = spec
|
||||
self.pad_token_id = int(
|
||||
pad_token_id
|
||||
if pad_token_id is not None
|
||||
else _value(tokenizer, "pad_token_id", 0) or 0
|
||||
)
|
||||
self.max_length = max_length
|
||||
self.pad_to_multiple_of = pad_to_multiple_of
|
||||
self.physical_payload_capacity = physical_payload_capacity
|
||||
self._encoder_canvas = DecisionCanvasCollator(pad_token_id=self.pad_token_id)
|
||||
|
||||
def __call__(self, rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
rows = self._flatten_packed_rows(rows)
|
||||
if not rows:
|
||||
raise ValueError("empty decision batch")
|
||||
canvases = tuple(self._canvas(row) for row in rows)
|
||||
examples = tuple(
|
||||
_decision_example(canvas, row)
|
||||
for canvas, row in zip(canvases, rows, strict=True)
|
||||
)
|
||||
metadata = _decision_metadata(canvases, examples)
|
||||
metadata["decision_sources"] = tuple(self._source(row) for row in rows)
|
||||
metadata["decision_slot_counts"] = torch.tensor(
|
||||
[
|
||||
int(
|
||||
row.get(
|
||||
"decision_slot_count",
|
||||
sum(canvas.prompt_slot_mask)
|
||||
if canvas.prompt_slot_mask
|
||||
else sum(canvas.slot_mask),
|
||||
)
|
||||
)
|
||||
for row, canvas in zip(rows, canvases, strict=True)
|
||||
],
|
||||
dtype=torch.long,
|
||||
)
|
||||
metadata["decision_draws"] = tuple(row.get("decision_draw") for row in rows)
|
||||
if self.spec.layout is DiffusionLayout.FULL_SEQUENCE:
|
||||
return self._full_sequence(canvases, examples, metadata)
|
||||
if self.spec.layout is DiffusionLayout.ENCODER_CANVAS:
|
||||
result = dict(metadata)
|
||||
batch = self._encoder_canvas(list(canvases))
|
||||
result["diffusion_batch"] = batch
|
||||
result["decision_prompt_input_pinned_mask"] = batch.encoder_validity.clone()
|
||||
result["decision_logical_rows"] = metadata["decision_logical_rows"]
|
||||
result["decision_label_rows"] = torch.full_like(
|
||||
metadata["decision_label_rows"], -1
|
||||
)
|
||||
return result
|
||||
raise ValueError(f"unsupported decision layout: {self.spec.layout}")
|
||||
|
||||
@staticmethod
|
||||
def _flatten_packed_rows(
|
||||
rows: Sequence[Mapping[str, Any] | Sequence[Mapping[str, Any]]],
|
||||
) -> list[Mapping[str, Any]]:
|
||||
"""Accept the nested logical examples emitted by MultipackBatchSampler."""
|
||||
|
||||
flattened: list[Mapping[str, Any]] = []
|
||||
for row in rows:
|
||||
if isinstance(row, Mapping):
|
||||
flattened.append(row)
|
||||
elif isinstance(row, Sequence) and not isinstance(row, (str, bytes)):
|
||||
if not all(isinstance(item, Mapping) for item in row):
|
||||
raise TypeError("packed decision batches must contain row mappings")
|
||||
flattened.extend(row)
|
||||
else:
|
||||
raise TypeError("decision batches must contain row mappings")
|
||||
return flattened
|
||||
|
||||
@staticmethod
|
||||
def _canvas(row: Mapping[str, Any]) -> DecisionCanvas:
|
||||
canvas = row.get("canvas")
|
||||
if not isinstance(canvas, DecisionCanvas):
|
||||
raise TypeError(
|
||||
"decision dataset rows require a DecisionCanvas under 'canvas'"
|
||||
)
|
||||
return canvas
|
||||
|
||||
@staticmethod
|
||||
def _source(row: Mapping[str, Any]) -> str:
|
||||
source = row.get("source")
|
||||
if not isinstance(source, str) or not source:
|
||||
raise ValueError("decision dataset rows require a nonempty source")
|
||||
return source
|
||||
|
||||
def _full_sequence(
|
||||
self,
|
||||
canvases: Sequence[DecisionCanvas],
|
||||
examples: tuple[DecisionLabelExample, ...],
|
||||
metadata: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
ids: list[int] = []
|
||||
documents: list[int] = []
|
||||
positions: list[int] = []
|
||||
loss: list[bool] = []
|
||||
corruptible: list[bool] = []
|
||||
pinned: list[bool] = []
|
||||
slots: list[bool] = []
|
||||
prompt_slots: list[bool] = []
|
||||
label_positions: list[list[int]] = []
|
||||
offset = 0
|
||||
for document, canvas in enumerate(canvases):
|
||||
prompt = tuple(int(token) for token in canvas.prompt_ids)
|
||||
output = tuple(int(token) for token in canvas.canvas_ids)
|
||||
ids.extend((*prompt, *output))
|
||||
documents.extend([document] * (len(prompt) + len(output)))
|
||||
positions.extend(range(len(prompt) + len(output)))
|
||||
label_set = set(int(position) for position in canvas.label_positions)
|
||||
output_loss = [index in label_set for index in range(len(output))]
|
||||
loss.extend([False] * len(prompt) + output_loss)
|
||||
corruptible.extend([False] * len(prompt) + output_loss)
|
||||
pinned.extend([True] * len(prompt) + list(canvas.pinned_mask))
|
||||
slots.extend([False] * len(prompt) + list(canvas.slot_mask))
|
||||
prompt_slots.extend(canvas.prompt_slot_mask or (False,) * len(prompt))
|
||||
prompt_slots.extend([False] * len(output))
|
||||
label_positions.append(
|
||||
[offset + len(prompt) + position for position in canvas.label_positions]
|
||||
)
|
||||
offset += len(prompt) + len(output)
|
||||
if (
|
||||
self.physical_payload_capacity is not None
|
||||
and len(ids) > self.physical_payload_capacity
|
||||
):
|
||||
raise ValueError(
|
||||
"decision full-sequence microbatch exceeds native physical payload capacity"
|
||||
)
|
||||
input_ids = torch.tensor(ids, dtype=torch.long)[None]
|
||||
metadata.update(
|
||||
{
|
||||
"input_ids": input_ids,
|
||||
"document_ids": torch.tensor(documents, dtype=torch.long)[None],
|
||||
"semantic_validity": torch.ones_like(input_ids, dtype=torch.bool),
|
||||
"position_ids": torch.tensor(positions, dtype=torch.long)[None],
|
||||
"canvas_loss_mask": torch.tensor(loss, dtype=torch.bool)[None],
|
||||
"canvas_corruptible_mask": torch.tensor(corruptible, dtype=torch.bool)[
|
||||
None
|
||||
],
|
||||
"canvas_input_pinned_mask": torch.tensor(pinned, dtype=torch.bool)[
|
||||
None
|
||||
],
|
||||
"canvas_update_mask": torch.tensor(corruptible, dtype=torch.bool)[None]
|
||||
& ~torch.tensor(pinned, dtype=torch.bool)[None],
|
||||
"decision_slot_mask": torch.tensor(slots, dtype=torch.bool)[None],
|
||||
"decision_prompt_slot_mask": torch.tensor(
|
||||
prompt_slots, dtype=torch.bool
|
||||
)[None],
|
||||
"decision_prompt_input_pinned_mask": torch.tensor(
|
||||
[True] * len(ids), dtype=torch.bool
|
||||
)[None],
|
||||
"decision_label_rows": _label_rows(label_positions),
|
||||
"decision_label_positions": _label_positions(label_positions),
|
||||
}
|
||||
)
|
||||
metadata["decision_examples"] = examples
|
||||
return metadata
|
||||
|
||||
|
||||
def _decision_metadata(
|
||||
canvases: Sequence[DecisionCanvas], examples: tuple[DecisionLabelExample, ...]
|
||||
) -> dict[str, Any]:
|
||||
width = max(len(canvas.question_ids) for canvas in canvases)
|
||||
batch = len(canvases)
|
||||
question_mask = torch.zeros((batch, width), dtype=torch.bool)
|
||||
logical_rows = torch.full((batch, width), -1, dtype=torch.long)
|
||||
positions = torch.full((batch, width), -1, dtype=torch.long)
|
||||
slot_mask = torch.zeros(
|
||||
(batch, max(len(canvas.canvas_ids) for canvas in canvases)), dtype=torch.bool
|
||||
)
|
||||
prompt_slot_mask = torch.zeros(
|
||||
(batch, max(len(canvas.prompt_ids) for canvas in canvases)), dtype=torch.bool
|
||||
)
|
||||
for row, canvas in enumerate(canvases):
|
||||
count = len(canvas.question_ids)
|
||||
question_mask[row, :count] = True
|
||||
logical_rows[row, :count] = row
|
||||
positions[row, :count] = torch.tensor(canvas.label_positions, dtype=torch.long)
|
||||
slot_mask[row, : len(canvas.slot_mask)] = torch.tensor(
|
||||
canvas.slot_mask, dtype=torch.bool
|
||||
)
|
||||
if canvas.prompt_slot_mask:
|
||||
if len(canvas.prompt_slot_mask) != len(canvas.prompt_ids):
|
||||
raise ValueError("prompt_slot_mask must align with prompt_ids")
|
||||
prompt_slot_mask[row, : len(canvas.prompt_slot_mask)] = torch.tensor(
|
||||
canvas.prompt_slot_mask, dtype=torch.bool
|
||||
)
|
||||
return {
|
||||
"decision_examples": examples,
|
||||
"decision_question_mask": question_mask,
|
||||
"decision_supervision_mask": question_mask.clone(),
|
||||
"decision_logical_rows": logical_rows,
|
||||
"decision_label_rows": logical_rows.clone(),
|
||||
"decision_label_positions": positions,
|
||||
"decision_slot_mask": slot_mask,
|
||||
"decision_prompt_slot_mask": prompt_slot_mask,
|
||||
}
|
||||
|
||||
|
||||
def _label_rows(label_positions: Sequence[Sequence[int]]) -> torch.Tensor:
|
||||
result = torch.full(
|
||||
(len(label_positions), max(map(len, label_positions))), -1, dtype=torch.long
|
||||
)
|
||||
for row, values in enumerate(label_positions):
|
||||
result[row, : len(values)] = 0
|
||||
return result
|
||||
|
||||
|
||||
def _label_positions(label_positions: Sequence[Sequence[int]]) -> torch.Tensor:
|
||||
result = torch.full(
|
||||
(len(label_positions), max(map(len, label_positions))), -1, dtype=torch.long
|
||||
)
|
||||
for row, values in enumerate(label_positions):
|
||||
result[row, : len(values)] = torch.tensor(values, dtype=torch.long)
|
||||
return result
|
||||
|
||||
|
||||
def decision_collator_for_config(cfg: Any, is_eval: bool = False):
|
||||
spec = require_diffusion_spec(cfg)
|
||||
tokenizer = _value(cfg, "tokenizer")
|
||||
pad_token_id = int(_value(tokenizer, "pad_token_id", 0) or 0)
|
||||
from axolotl.core.trainers.diffusion_lm.sampling import (
|
||||
resolve_native_packing_budget,
|
||||
)
|
||||
|
||||
packed = bool(_value(cfg, "sample_packing", False)) and (
|
||||
not is_eval or _value(cfg, "eval_sample_packing", False) is not False
|
||||
)
|
||||
fixed_logical_batch = bool(_value(cfg, "batch_flattening", False))
|
||||
budget = resolve_native_packing_budget(
|
||||
cfg, packed=packed or fixed_logical_batch, is_eval=is_eval
|
||||
)
|
||||
return DecisionTrainingCollator, {
|
||||
"spec": spec,
|
||||
"pad_token_id": pad_token_id,
|
||||
"physical_payload_capacity": None
|
||||
if budget is None
|
||||
else budget.payload_capacity,
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Template helpers from mmastrac/djev, Apache-2.0 (see DJEV_LICENSE).
|
||||
|
||||
Revision e5841cf41e9211608e698492658685c36e24e77a; source SHA-256
|
||||
9348332d5c33391df50c321bc1f7721b1c57b587e333f7e80058aa6499378a24.
|
||||
The resolver receives its tokenizer and width explicitly instead of globals.
|
||||
"""
|
||||
|
||||
MAX_QUESTIONS = 64
|
||||
|
||||
|
||||
MAX_SAMPLES = 32
|
||||
|
||||
|
||||
SPAN_REGION = 24
|
||||
|
||||
|
||||
SPAN_LIST_REGION = 64
|
||||
|
||||
|
||||
class SchemaError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def parse_schema(value, *, alphabet=None):
|
||||
"""Parse a DJeV schema, with an optional local label alphabet adaptation."""
|
||||
if alphabet is not None and (
|
||||
not isinstance(alphabet, (list, tuple))
|
||||
or len(alphabet) < 2
|
||||
or any(
|
||||
not isinstance(label, str)
|
||||
or not label
|
||||
or label != label.strip()
|
||||
or "\n" in label
|
||||
or "\r" in label
|
||||
or len(label) != 1
|
||||
for label in alphabet
|
||||
)
|
||||
or len(set(alphabet)) != len(alphabet)
|
||||
):
|
||||
raise SchemaError(
|
||||
"schema: label alphabet must contain distinct one-character labels"
|
||||
)
|
||||
if (
|
||||
not isinstance(value, dict)
|
||||
or not isinstance(value.get("questions"), list)
|
||||
or not value["questions"]
|
||||
):
|
||||
raise SchemaError("schema: needs a non-empty questions array")
|
||||
if len(value["questions"]) > MAX_QUESTIONS:
|
||||
raise SchemaError(f"schema: at most {MAX_QUESTIONS} questions")
|
||||
qs = []
|
||||
seen = set()
|
||||
for q in value["questions"]:
|
||||
qid = str(q.get("id", "")).strip()
|
||||
if not qid or ":" in qid or "\n" in qid:
|
||||
raise SchemaError(
|
||||
f"question id {qid!r} must be non-empty, no ':' or newline"
|
||||
)
|
||||
if qid in seen:
|
||||
raise SchemaError(f"duplicate question id {qid!r}")
|
||||
seen.add(qid)
|
||||
kind = q.get("type")
|
||||
if kind in ("noul", "bool", "boolean"):
|
||||
kind = "noul"
|
||||
crit = q.get("criteria") or {}
|
||||
choices = [("yes", crit.get("true")), ("no", crit.get("false"))]
|
||||
labels = ["yes", "no"]
|
||||
elif kind == "choice":
|
||||
opts = q.get("options") or []
|
||||
choices = [
|
||||
(o["name"], o.get("description"))
|
||||
if isinstance(o, dict)
|
||||
else (str(o), None)
|
||||
for o in opts
|
||||
]
|
||||
labels = (
|
||||
list(alphabet[: len(choices)])
|
||||
if alphabet is not None
|
||||
else [chr(ord("A") + i) for i in range(len(choices))]
|
||||
)
|
||||
elif kind == "score":
|
||||
choices = [(str(level), None) for level in (q.get("levels") or [])]
|
||||
labels = (
|
||||
[str(i + 1) for i in range(len(choices))]
|
||||
if len(choices) <= 9
|
||||
else (
|
||||
list(alphabet[: len(choices)])
|
||||
if alphabet is not None
|
||||
else [chr(ord("A") + i) for i in range(len(choices))]
|
||||
)
|
||||
)
|
||||
elif kind in ("span", "spans"):
|
||||
default = SPAN_REGION if kind == "span" else SPAN_LIST_REGION
|
||||
span_cfg = {
|
||||
"max_tokens": q.get("max_tokens", default),
|
||||
"max_items": q.get("max_items", 16),
|
||||
}
|
||||
for key, hi in (("max_tokens", 96), ("max_items", 64)):
|
||||
v = span_cfg[key]
|
||||
if isinstance(v, bool) or not isinstance(v, int) or not 1 <= v <= hi:
|
||||
raise SchemaError(f"question {qid!r}: {key} must be 1 to {hi}")
|
||||
choices, labels = [], []
|
||||
else:
|
||||
raise SchemaError(f"question {qid!r}: unknown type {kind!r}")
|
||||
if kind not in ("span", "spans") and len(choices) < 2:
|
||||
raise SchemaError(f"question {qid!r}: needs at least two alternatives")
|
||||
maximum = len(alphabet) if alphabet is not None else 26
|
||||
if len(choices) > maximum:
|
||||
raise SchemaError(f"question {qid!r}: at most {maximum} alternatives")
|
||||
deps = q.get("depends_on") or []
|
||||
ask_if = q.get("ask_if") or {}
|
||||
if not isinstance(deps, list) or not all(isinstance(d, str) for d in deps):
|
||||
raise SchemaError(
|
||||
f"question {qid!r}: depends_on must be a list of question ids"
|
||||
)
|
||||
if not isinstance(ask_if, dict) or not all(
|
||||
isinstance(v, list) and v for v in ask_if.values()
|
||||
):
|
||||
raise SchemaError(
|
||||
f"question {qid!r}: ask_if must map a question id to a "
|
||||
"non-empty list of its answers"
|
||||
)
|
||||
if kind in ("span", "spans") and (deps or ask_if):
|
||||
raise SchemaError(
|
||||
f"question {qid!r}: a span question cannot depend on another question"
|
||||
)
|
||||
qs.append(
|
||||
{
|
||||
"id": qid,
|
||||
"type": kind,
|
||||
"instructions": str(q.get("instructions", "")),
|
||||
"choices": choices,
|
||||
"labels": labels,
|
||||
"depends_on": list(dict.fromkeys(list(deps) + list(ask_if))),
|
||||
"ask_if": ask_if,
|
||||
"alone": bool(q.get("alone", False)),
|
||||
"span": span_cfg if kind in ("span", "spans") else None,
|
||||
}
|
||||
)
|
||||
by_id = {q["id"]: q for q in qs}
|
||||
for q in qs:
|
||||
for dep in q["depends_on"]:
|
||||
if dep not in by_id or dep == q["id"]:
|
||||
raise SchemaError(
|
||||
f"question {q['id']!r}: depends on unknown question {dep!r}"
|
||||
)
|
||||
if by_id[dep]["span"]:
|
||||
raise SchemaError(
|
||||
f"question {q['id']!r}: cannot depend on the span question {dep!r}"
|
||||
)
|
||||
for dep, vals in q["ask_if"].items():
|
||||
names = [c[0] for c in by_id[dep]["choices"]]
|
||||
if any(v not in names for v in vals):
|
||||
raise SchemaError(
|
||||
f"question {q['id']!r}: ask_if values for {dep!r} "
|
||||
f"must be among {names}"
|
||||
)
|
||||
schedule(qs) # refuses a cycle
|
||||
samples = value.get("samples", "auto")
|
||||
if samples == "auto":
|
||||
policy = {
|
||||
"mode": "auto",
|
||||
"max": max(1, min(int(value.get("auto_max", 4)), MAX_SAMPLES)),
|
||||
"threshold": float(value.get("auto_threshold", 0.1)),
|
||||
}
|
||||
elif isinstance(samples, int) and samples >= 1:
|
||||
policy = {"mode": "fixed", "n": min(samples, MAX_SAMPLES)}
|
||||
else:
|
||||
raise SchemaError('schema: samples must be a positive count or "auto"')
|
||||
ask = value.get("ask")
|
||||
if ask is not None:
|
||||
if not isinstance(ask, list) or not ask or any(a not in seen for a in ask):
|
||||
raise SchemaError("schema: ask must list question ids from this schema")
|
||||
for q in qs:
|
||||
if q["id"] in ask and any(d not in ask for d in q["depends_on"]):
|
||||
raise SchemaError(
|
||||
f"schema: ask names {q['id']!r} but not everything it depends on"
|
||||
)
|
||||
chunk_rows = value.get("chunk_rows")
|
||||
if chunk_rows is not None and (not isinstance(chunk_rows, int) or chunk_rows < 8):
|
||||
raise SchemaError("schema: chunk_rows must be an integer of at least 8")
|
||||
chunk_prompt = value.get("chunk_prompt", "own")
|
||||
if chunk_prompt not in ("shared", "own"):
|
||||
raise SchemaError('schema: chunk_prompt must be "shared" or "own"')
|
||||
sequential = bool(value.get("sequential", False))
|
||||
think = value.get("think", 0)
|
||||
if isinstance(think, bool) or not isinstance(think, int) or not 0 <= think <= 4096:
|
||||
raise SchemaError("schema: think must be a thought budget in tokens, 0 to 4096")
|
||||
return {
|
||||
"questions": qs,
|
||||
"instructions": value.get("instructions"),
|
||||
"policy": policy,
|
||||
"steps": max(1, min(int(value.get("steps", 1)), 8)),
|
||||
"think": think,
|
||||
"ask": ask,
|
||||
"chunk_rows": chunk_rows,
|
||||
"chunk_prompt": chunk_prompt,
|
||||
"sequential": sequential,
|
||||
"format": "lines" if len([q for q in qs if not q["span"]]) <= 10 else "indexed",
|
||||
}
|
||||
|
||||
|
||||
FORMATS = {
|
||||
"lines": (
|
||||
"\n",
|
||||
"{id}: ",
|
||||
'Reply with one line per question, in this order, formatted as "id: label".',
|
||||
),
|
||||
"indexed": (
|
||||
" ",
|
||||
"{id}",
|
||||
"Reply on one line with each question's id immediately followed by its "
|
||||
"label, separated by single spaces.",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def system_text(schema, chunked=False):
|
||||
s = (
|
||||
"Answer a fixed set of questions about the state the user provides. "
|
||||
"Each question lists its allowed answers; reply with exactly one label "
|
||||
"per question.\n"
|
||||
)
|
||||
if schema.get("instructions"):
|
||||
s += "\n" + str(schema["instructions"]).strip() + "\n"
|
||||
for q in schema["questions"]:
|
||||
s += f"\nQuestion {q['id']}: {q['instructions'].strip()}\n"
|
||||
for (name, desc), label in zip(q["choices"], q["labels"], strict=False):
|
||||
if q["type"] == "noul":
|
||||
s += f" {label}: {str(desc).strip()}\n" if desc else f" {label}\n"
|
||||
elif desc:
|
||||
s += f" {label}: {name} ({str(desc).strip()})\n"
|
||||
else:
|
||||
s += f" {label}: {name}\n"
|
||||
s += "\n" + FORMATS[schema.get("format", "lines")][2]
|
||||
if chunked:
|
||||
s += (
|
||||
" A reply may cover only some of the questions; answer every line "
|
||||
"that is present."
|
||||
)
|
||||
return s
|
||||
|
||||
|
||||
def answer_text(qs, labels, fmt="lines"):
|
||||
join, lead, _ = FORMATS[fmt]
|
||||
return join.join(
|
||||
lead.format(id=q["id"]) + q["labels"][i]
|
||||
for q, i in zip(qs, labels, strict=False)
|
||||
)
|
||||
|
||||
|
||||
def resolve_template(qs, head, lead, fmt, *, enc, canvas_length):
|
||||
"""Tokenize the answer template and find each question's slot. Every label
|
||||
must change exactly one token, at the same position for all of a question's
|
||||
labels, or this raises SchemaError. ``head`` is the token run the canvas
|
||||
starts with: the empty thought block for a plain read, and empty when the
|
||||
prompt already ends the thought channel. ``lead`` is the text before the
|
||||
first answer: the join when earlier answers are in the prompt, so the
|
||||
tokens match one joint template."""
|
||||
base_labels = [0] * len(qs)
|
||||
base = head + enc(lead + answer_text(qs, base_labels, fmt))
|
||||
if len(base) + 1 > canvas_length:
|
||||
raise SchemaError(
|
||||
f"answer template is {len(base)} tokens; the canvas holds {canvas_length - 1}"
|
||||
)
|
||||
if len(qs) == 1 and len(base) + 1 > canvas_length:
|
||||
raise SchemaError(
|
||||
f"question {qs[0]['id']!r} alone needs {len(base) + 1} canvas rows"
|
||||
)
|
||||
slots = []
|
||||
for qi, q in enumerate(qs):
|
||||
pos = None
|
||||
ids = [0] * len(q["labels"])
|
||||
for li in range(1, len(q["labels"])):
|
||||
labels = list(base_labels)
|
||||
labels[qi] = li
|
||||
e = head + enc(lead + answer_text(qs, labels, fmt))
|
||||
if len(e) != len(base):
|
||||
raise SchemaError(
|
||||
f"question {q['id']!r}: label {q['labels'][li]!r} is not a "
|
||||
"single token"
|
||||
)
|
||||
diffs = [i for i in range(len(e)) if e[i] != base[i]]
|
||||
if len(diffs) != 1 or (pos is not None and diffs[0] != pos):
|
||||
raise SchemaError(
|
||||
f"question {q['id']!r}: labels do not share one template slot"
|
||||
)
|
||||
pos = diffs[0]
|
||||
ids[li] = e[pos]
|
||||
ids[0] = base[pos]
|
||||
if len(set(ids)) != len(ids):
|
||||
raise SchemaError(
|
||||
f"question {q['id']!r}: two labels tokenize to the same id"
|
||||
)
|
||||
slots.append({"pos": pos, "label_ids": ids})
|
||||
return base, slots
|
||||
|
||||
|
||||
def schedule(qs):
|
||||
"""Questions in stages: a question's stage comes after the stages of
|
||||
everything it depends on. Declaration order is kept within a stage."""
|
||||
ids = {q["id"] for q in qs}
|
||||
pending = list(qs)
|
||||
done: set = set()
|
||||
levels = []
|
||||
while pending:
|
||||
level = [
|
||||
q
|
||||
for q in pending
|
||||
if all(d in done or d not in ids for d in q["depends_on"])
|
||||
]
|
||||
if not level:
|
||||
raise SchemaError(
|
||||
"schema: dependency cycle among " + ", ".join(q["id"] for q in pending)
|
||||
)
|
||||
levels.append(level)
|
||||
done |= {q["id"] for q in level}
|
||||
pending = [q for q in pending if q["id"] not in done]
|
||||
return levels
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,446 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
from torch import nn
|
||||
from transformers import DiffusionGemmaConfig
|
||||
|
||||
from axolotl.core.trainers.diffusion_lm.backends.full_sequence import (
|
||||
FullSequenceBackend,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.loss import decision_example_from_canvas
|
||||
from axolotl.integrations.diffusion_decision.preprocessing import build_decision_canvas
|
||||
from axolotl.integrations.diffusion_decision.records import DecisionCanvas
|
||||
from axolotl.integrations.diffusion_decision.slots import SlotInit, SlotPlan
|
||||
from axolotl.integrations.diffusion_decision.trainer import DiffusionDecisionTrainer
|
||||
from axolotl.model_support import (
|
||||
DiffusionLayout,
|
||||
DiffusionNoise,
|
||||
DiffusionSpec,
|
||||
EosHandling,
|
||||
FirstPositionAlignment,
|
||||
GenerationAdapter,
|
||||
LogitAlignment,
|
||||
MaskTokenPolicy,
|
||||
ObjectiveReduction,
|
||||
ReductionScope,
|
||||
TimeWeighting,
|
||||
)
|
||||
|
||||
DJEV_FIXTURE = json.loads(
|
||||
(Path(__file__).parent / "fixtures" / "djev_template_e5841cf.json").read_text()
|
||||
)
|
||||
|
||||
|
||||
class CharacterTokenizer:
|
||||
def encode(self, text, add_special_tokens=False):
|
||||
assert not add_special_tokens
|
||||
return [ord(character) for character in text]
|
||||
|
||||
|
||||
class ChatCharacterTokenizer(CharacterTokenizer):
|
||||
pad_token_id = 0
|
||||
bos_token_id = 1
|
||||
eos_token_id = 11
|
||||
unk_token_id = 2
|
||||
|
||||
def apply_chat_template(
|
||||
self,
|
||||
messages,
|
||||
*,
|
||||
tokenize: bool,
|
||||
add_generation_prompt: bool,
|
||||
enable_thinking: bool,
|
||||
) -> list[int]:
|
||||
assert tokenize and add_generation_prompt and not enable_thinking
|
||||
assert len(messages) == 2
|
||||
return [99]
|
||||
|
||||
|
||||
class RecordedTokenizer:
|
||||
def __init__(self, encodings):
|
||||
self.encodings = encodings
|
||||
|
||||
def encode(self, text, add_special_tokens=False):
|
||||
assert not add_special_tokens
|
||||
return self.encodings[text]
|
||||
|
||||
|
||||
def make_spec(
|
||||
*,
|
||||
noise: DiffusionNoise = DiffusionNoise.UNIFORM,
|
||||
layout: DiffusionLayout = DiffusionLayout.FULL_SEQUENCE,
|
||||
self_conditioning: bool | None = None,
|
||||
mask_token_policy: MaskTokenPolicy | None = None,
|
||||
**fields: Any,
|
||||
) -> DiffusionSpec:
|
||||
values: dict[str, Any] = {
|
||||
"noise": noise,
|
||||
"layout": layout,
|
||||
"logit_alignment": LogitAlignment.ALIGNED,
|
||||
"first_position_alignment": FirstPositionAlignment.DUPLICATE_FIRST,
|
||||
"self_conditioning": (
|
||||
layout is DiffusionLayout.ENCODER_CANVAS
|
||||
if self_conditioning is None
|
||||
else self_conditioning
|
||||
),
|
||||
"max_canvas": 128,
|
||||
"max_context": 1024,
|
||||
"eos_handling": EosHandling.INDEPENDENT,
|
||||
"mask_token_policy": (
|
||||
(
|
||||
MaskTokenPolicy.MODEL
|
||||
if noise is DiffusionNoise.ABSORBING
|
||||
else MaskTokenPolicy.NONE
|
||||
)
|
||||
if mask_token_policy is None
|
||||
else mask_token_policy
|
||||
),
|
||||
"default_time_weighting": TimeWeighting.NONE,
|
||||
"objective_reduction": ObjectiveReduction.MASKED_TOKEN_MEAN,
|
||||
"generation_adapter": GenerationAdapter.FULL_SEQUENCE,
|
||||
"reduction_scope": ReductionScope.MICROBATCH,
|
||||
}
|
||||
values.update(fields)
|
||||
return DiffusionSpec(**values)
|
||||
|
||||
|
||||
def trainer_spec(
|
||||
layout: DiffusionLayout, alignment: LogitAlignment = LogitAlignment.ALIGNED
|
||||
) -> DiffusionSpec:
|
||||
return make_spec(
|
||||
noise=DiffusionNoise.ABSORBING,
|
||||
layout=layout,
|
||||
logit_alignment=alignment,
|
||||
max_canvas=16,
|
||||
max_context=32,
|
||||
objective_reduction=ObjectiveReduction.EXAMPLE_MEAN,
|
||||
reduction_scope=ReductionScope.GLOBAL_WINDOW,
|
||||
)
|
||||
|
||||
|
||||
def make_record(
|
||||
identifier: str | None = "record",
|
||||
*,
|
||||
source: str = "test",
|
||||
group: str = "test",
|
||||
state: Any = "state",
|
||||
question: str = "q",
|
||||
question_type: str = "choice",
|
||||
questions: dict[str, Any] | None = None,
|
||||
labels: dict[str, Any] | None = None,
|
||||
**fields: Any,
|
||||
) -> dict[str, Any]:
|
||||
schema: dict[str, Any] = {"type": question_type}
|
||||
if question_type == "choice":
|
||||
schema.update(instructions="Pick.", options=["one", "two"])
|
||||
record: dict[str, Any] = {
|
||||
"id": identifier,
|
||||
"source": source,
|
||||
"group": group,
|
||||
"state": state,
|
||||
"questions": {question: schema} if questions is None else questions,
|
||||
"labels": (
|
||||
{question: {"kind": "hard", "gold_idx": 0}} if labels is None else labels
|
||||
),
|
||||
}
|
||||
record.update(fields)
|
||||
return {key: value for key, value in record.items() if value is not None}
|
||||
|
||||
|
||||
def make_canvas(
|
||||
prompt_ids=(1, 2),
|
||||
canvas_ids=(3, 4, 5, 6),
|
||||
label_positions=(1,),
|
||||
name: str = "q",
|
||||
*,
|
||||
allowed=(1, 2, 3),
|
||||
allowed_ids=None,
|
||||
question_ids=None,
|
||||
targets=None,
|
||||
**fields: Any,
|
||||
) -> DecisionCanvas:
|
||||
width = len(canvas_ids)
|
||||
values: dict[str, Any] = {
|
||||
"prompt_ids": tuple(prompt_ids),
|
||||
"canvas_ids": tuple(canvas_ids),
|
||||
"label_positions": tuple(label_positions),
|
||||
"allowed_ids": (
|
||||
tuple(tuple(allowed) for _ in label_positions)
|
||||
if allowed_ids is None
|
||||
else allowed_ids
|
||||
),
|
||||
"question_ids": (
|
||||
tuple(f"{name}-{index}" for index in range(len(label_positions)))
|
||||
if question_ids is None
|
||||
else question_ids
|
||||
),
|
||||
"targets": (
|
||||
tuple({"kind": "hard", "gold_idx": 0} for _ in label_positions)
|
||||
if targets is None
|
||||
else targets
|
||||
),
|
||||
"pinned_mask": (False,) * width,
|
||||
"semantic_mask": (True,) * width,
|
||||
"slot_mask": (False,) * width,
|
||||
"template_length": width,
|
||||
}
|
||||
values.update(fields)
|
||||
return DecisionCanvas(**values)
|
||||
|
||||
|
||||
def make_rows(canvases, *, weights=None) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"canvas": canvas,
|
||||
"source": f"source-{index}",
|
||||
"decision_example": decision_example_from_canvas(
|
||||
canvas, **({} if weights is None else {"source_weight": weights[index]})
|
||||
),
|
||||
}
|
||||
for index, canvas in enumerate(canvases)
|
||||
]
|
||||
|
||||
|
||||
def slot_token_ids(mode: str, count: int = 2) -> tuple[int, ...]:
|
||||
if mode == "pinned":
|
||||
return (7,)
|
||||
if mode in {"learned", "prompt"}:
|
||||
return (7, 8, 10)[:count]
|
||||
return ()
|
||||
|
||||
|
||||
def free_plan_seed(mode: str, noise: DiffusionNoise) -> int | None:
|
||||
return 17 if mode == "free" and noise is DiffusionNoise.UNIFORM else None
|
||||
|
||||
|
||||
def make_slot_init(
|
||||
mode,
|
||||
*,
|
||||
count: int = 2,
|
||||
ids: tuple[int, ...] = (),
|
||||
noise: DiffusionNoise = DiffusionNoise.UNIFORM,
|
||||
spec: DiffusionSpec | None = None,
|
||||
vocab_size: int = 256,
|
||||
**fields: Any,
|
||||
) -> SlotInit:
|
||||
return SlotInit(
|
||||
mode,
|
||||
token_ids=ids,
|
||||
num_slots=count,
|
||||
vocab_size=vocab_size,
|
||||
pad_id=0,
|
||||
spec=make_spec(noise=noise) if spec is None else spec,
|
||||
mask_token_id=9,
|
||||
**fields,
|
||||
)
|
||||
|
||||
|
||||
def make_slot_plan(
|
||||
mode,
|
||||
*,
|
||||
count: int = 2,
|
||||
ids: tuple[int, ...] = (),
|
||||
noise: DiffusionNoise = DiffusionNoise.UNIFORM,
|
||||
spec: DiffusionSpec | None = None,
|
||||
seed: int | None = None,
|
||||
) -> SlotPlan:
|
||||
return make_slot_init(mode, count=count, ids=ids, noise=noise, spec=spec).build(
|
||||
seed=seed
|
||||
)
|
||||
|
||||
|
||||
def build_canvas(
|
||||
slot_plan: SlotPlan | None = None,
|
||||
*,
|
||||
record: dict[str, Any] | None = None,
|
||||
tokenizer=None,
|
||||
prompt_ids=(99,),
|
||||
noise: DiffusionNoise = DiffusionNoise.UNIFORM,
|
||||
noise_kind: str | None = None,
|
||||
steps: int = 1,
|
||||
width: int = 128,
|
||||
seed: int = 23,
|
||||
thought_open_ids=(70, 71),
|
||||
thought_close_ids=(72,),
|
||||
**fields: Any,
|
||||
) -> DecisionCanvas:
|
||||
kind = noise.value if noise_kind is None else noise_kind
|
||||
arguments: dict[str, Any] = {
|
||||
"prompt_ids": prompt_ids,
|
||||
"scaffold_ids": (),
|
||||
"turn_close_id": 106,
|
||||
"pad_id": 0,
|
||||
"vocab_size": 256,
|
||||
"width": width,
|
||||
"seed": seed,
|
||||
"steps": steps,
|
||||
"noise_kind": kind,
|
||||
"mask_token_id": 9 if kind == "absorbing" else None,
|
||||
"slot_plan": slot_plan,
|
||||
"thought_open_ids": thought_open_ids,
|
||||
"thought_close_ids": thought_close_ids,
|
||||
}
|
||||
arguments.update(fields)
|
||||
return build_decision_canvas(
|
||||
CharacterTokenizer() if tokenizer is None else tokenizer,
|
||||
make_record() if record is None else record,
|
||||
**arguments,
|
||||
)
|
||||
|
||||
|
||||
def make_cfg(**overrides: Any) -> dict[str, Any]:
|
||||
cfg: dict[str, Any] = {
|
||||
"seed": 23,
|
||||
"model_config": {"vocab_size": 256, "mask_token_id": 9},
|
||||
"diffusion_lm": {"canvas_width": 128, "mask_token_id": 9},
|
||||
"diffusion_decision": {"max_questions_per_canvas": 20},
|
||||
}
|
||||
for key, value in overrides.items():
|
||||
if isinstance(value, dict) and isinstance(cfg.get(key), dict):
|
||||
cfg[key].update(value)
|
||||
else:
|
||||
cfg[key] = value
|
||||
return cfg
|
||||
|
||||
|
||||
def tiny_gemma_config() -> DiffusionGemmaConfig:
|
||||
return DiffusionGemmaConfig(
|
||||
text_config={
|
||||
"vocab_size": 32,
|
||||
"hidden_size": 16,
|
||||
"intermediate_size": 32,
|
||||
"num_hidden_layers": 1,
|
||||
"num_attention_heads": 2,
|
||||
"num_key_value_heads": 1,
|
||||
"head_dim": 8,
|
||||
"max_position_embeddings": 32,
|
||||
"layer_types": ["full_attention"],
|
||||
"per_layer_config": {"0": {"head_dim": 8}},
|
||||
"sliding_window": 8,
|
||||
"use_bidirectional_attention": "vision",
|
||||
"num_experts": 2,
|
||||
"top_k_experts": 1,
|
||||
"moe_intermediate_size": 16,
|
||||
"pad_token_id": 0,
|
||||
"eos_token_id": 1,
|
||||
"bos_token_id": 2,
|
||||
},
|
||||
vision_config={
|
||||
"model_type": "gemma4_vision",
|
||||
"hidden_size": 16,
|
||||
"intermediate_size": 32,
|
||||
"num_hidden_layers": 1,
|
||||
"num_attention_heads": 2,
|
||||
"num_key_value_heads": 2,
|
||||
"head_dim": 8,
|
||||
"max_position_embeddings": 32,
|
||||
"patch_size": 16,
|
||||
"position_embedding_size": 16,
|
||||
},
|
||||
canvas_length=8,
|
||||
boi_token_id=31,
|
||||
eoi_token_id=30,
|
||||
image_token_id=29,
|
||||
)
|
||||
|
||||
|
||||
class EchoModel(nn.Module):
|
||||
def __init__(
|
||||
self, vocab_size: int = 256, mask_token_id: int = 9, scale: float = 1.0
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.anchor = nn.Parameter(torch.zeros(()))
|
||||
self.config = SimpleNamespace(
|
||||
vocab_size=vocab_size, mask_token_id=mask_token_id
|
||||
)
|
||||
self.scale = scale
|
||||
self.calls = 0
|
||||
self.seen_input_ids: torch.Tensor | None = None
|
||||
|
||||
def forward(self, input_ids, attention_mask, position_ids, use_cache, **kwargs):
|
||||
del attention_mask, position_ids, use_cache, kwargs
|
||||
self.calls += 1
|
||||
self.seen_input_ids = input_ids.detach().clone()
|
||||
logits = torch.nn.functional.one_hot(
|
||||
input_ids, num_classes=self.config.vocab_size
|
||||
).float()
|
||||
return SimpleNamespace(logits=logits * self.scale + self.anchor)
|
||||
|
||||
|
||||
class TrainerHarness(DiffusionDecisionTrainer):
|
||||
def __init__(
|
||||
self,
|
||||
spec: DiffusionSpec,
|
||||
decision: dict[str, object] | None = None,
|
||||
*,
|
||||
world_size: int = 1,
|
||||
native_values: dict[str, object] | None = None,
|
||||
mask_token_id: int = 10,
|
||||
native_time: float = 0.5,
|
||||
) -> None:
|
||||
self._spec = spec
|
||||
self.axolotl_cfg = {"diffusion_decision": decision or {}}
|
||||
self.args = SimpleNamespace(world_size=world_size)
|
||||
self._special_token_ids: set[int] = set()
|
||||
self._native_values = native_values or {}
|
||||
self._mask_token_id = mask_token_id
|
||||
self._native_time = native_time
|
||||
|
||||
@property
|
||||
def _native_spec(self) -> DiffusionSpec:
|
||||
return self._spec
|
||||
|
||||
def _full_sequence_backend(self) -> FullSequenceBackend:
|
||||
return FullSequenceBackend(
|
||||
mask_token_id=self._mask_token_id, attention_backend="dense"
|
||||
)
|
||||
|
||||
def _native_value(self, name: str, default=None):
|
||||
values: dict[str, object] = {"t_eps": 0.5, "self_conditioning": None}
|
||||
values.update(self._native_values)
|
||||
return values.get(name, default)
|
||||
|
||||
def _native_unroll_settings(self) -> tuple[int, bool]:
|
||||
return 1, False
|
||||
|
||||
@staticmethod
|
||||
def _sample_native_unroll_steps(k_max: int, device: torch.device) -> int:
|
||||
del k_max, device
|
||||
return 1
|
||||
|
||||
def _sample_native_times(
|
||||
self, count: int, device: torch.device, default_eps: float
|
||||
) -> torch.Tensor:
|
||||
del default_eps
|
||||
return torch.full((count,), self._native_time, device=device)
|
||||
|
||||
|
||||
class MultistepTrainerHarness(TrainerHarness):
|
||||
_sampled_steps_value = 1
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
spec: DiffusionSpec,
|
||||
*,
|
||||
k_max: int,
|
||||
sampled_steps: int,
|
||||
grad_through_steps: bool = False,
|
||||
decision: dict[str, object] | None = None,
|
||||
) -> None:
|
||||
super().__init__(spec, decision)
|
||||
self._k_max = k_max
|
||||
MultistepTrainerHarness._sampled_steps_value = sampled_steps
|
||||
self._grad_through_steps = grad_through_steps
|
||||
|
||||
def _native_unroll_settings(self) -> tuple[int, bool]:
|
||||
return self._k_max, self._grad_through_steps
|
||||
|
||||
@staticmethod
|
||||
def _sample_native_unroll_steps(k_max: int, device: torch.device) -> int:
|
||||
del k_max, device
|
||||
return MultistepTrainerHarness._sampled_steps_value
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Public source dispatch enforces shared validation and evaluation-only data."""
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.adapters import normalize_record
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
make_record,
|
||||
)
|
||||
|
||||
|
||||
def local_record(source="local"):
|
||||
return make_record(
|
||||
None, source=source, group="a", state="observed", question_type="noul"
|
||||
)
|
||||
|
||||
|
||||
def test_prefixed_and_short_names_are_equivalent():
|
||||
row = local_record()
|
||||
assert normalize_record(
|
||||
"diffusion_decision.jsonl", row, training=True
|
||||
) == normalize_record("jsonl", row, training=True)
|
||||
|
||||
|
||||
def test_eval_corpus_cannot_enter_training_through_jsonl():
|
||||
with pytest.raises(ValueError, match="reserved for evaluation"):
|
||||
normalize_record("jsonl", local_record("typed_decisions"), training=True)
|
||||
assert (
|
||||
normalize_record("jsonl", local_record("typed_decisions"), training=False)[
|
||||
"source"
|
||||
]
|
||||
== "typed_decisions"
|
||||
)
|
||||
|
||||
|
||||
def test_eval_corpus_adapter_rejected_before_reading_row():
|
||||
with pytest.raises(ValueError, match="declared train split"):
|
||||
normalize_record("typed_decisions", {}, training=True)
|
||||
|
||||
|
||||
def test_explicit_typed_train_adapter_is_allowed():
|
||||
row = {
|
||||
"id": "a",
|
||||
"workflow": "workflow",
|
||||
"state": "{}",
|
||||
"questions": '{"q":{"type":"noul"}}',
|
||||
"gold": '{"q":{"probabilities":{"false":0.0,"true":1.0}}}',
|
||||
}
|
||||
assert (
|
||||
normalize_record("typed_decisions", row, training=True, source_split="train")[
|
||||
"source"
|
||||
]
|
||||
== "typed_decisions"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_adapter_rejected():
|
||||
with pytest.raises(ValueError, match="unknown decision adapter"):
|
||||
normalize_record("typo", {}, training=False)
|
||||
@@ -0,0 +1,18 @@
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.budgets import (
|
||||
decision_budget,
|
||||
exceeds_budget,
|
||||
)
|
||||
from axolotl.model_support import DiffusionLayout
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout", [DiffusionLayout.FULL_SEQUENCE, DiffusionLayout.ENCODER_CANVAS]
|
||||
)
|
||||
def test_fixed_canvas_cost_charges_prompt_and_full_canvas_for_both_layouts(layout):
|
||||
budget = decision_budget(prompt_tokens=19, canvas_tokens=128, layout=layout)
|
||||
assert budget.logical_tokens == 147
|
||||
assert budget.physical_tokens == 147
|
||||
assert exceeds_budget(budget, logical_limit=146, physical_limit=None) == "logical"
|
||||
assert exceeds_budget(budget, logical_limit=147, physical_limit=146) == "physical"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Decision canvas visibility and atomic budget contracts."""
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.collator import DecisionCanvasCollator
|
||||
from axolotl.integrations.diffusion_decision.records import DecisionCanvas
|
||||
from axolotl.integrations.diffusion_decision.template import resolve_template
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
CharacterTokenizer,
|
||||
)
|
||||
|
||||
|
||||
def test_template_indexed_and_overflow():
|
||||
questions = [{"id": str(index), "labels": ["a", "b"]} for index in range(64)]
|
||||
_, slots = resolve_template(
|
||||
CharacterTokenizer(), questions, fmt="indexed", width=256
|
||||
)
|
||||
assert len(slots) == 64
|
||||
assert all(len(slot["label_ids"]) == 2 for slot in slots)
|
||||
with pytest.raises(ValueError):
|
||||
resolve_template(CharacterTokenizer(), questions, fmt="indexed", width=8)
|
||||
|
||||
|
||||
def test_collator_preserves_prompt_and_canvas_metadata():
|
||||
canvas = DecisionCanvas(
|
||||
prompt_ids=[1, 2],
|
||||
canvas_ids=[3, 4, 0, 0],
|
||||
label_positions=[1],
|
||||
allowed_ids=[[4, 5]],
|
||||
question_ids=["q"],
|
||||
targets=[{"kind": "hard", "gold_idx": 0}],
|
||||
pinned_mask=[True, False, True, True],
|
||||
semantic_mask=[True, True, True, True],
|
||||
slot_mask=[False, False, False, False],
|
||||
template_length=2,
|
||||
)
|
||||
batch = DecisionCanvasCollator()([canvas])
|
||||
assert batch.canvas_loss_mask.tolist() == [[False, True, False, False]]
|
||||
assert batch.canvas_input_pinned_mask.tolist() == [[True, False, True, True]]
|
||||
assert batch.decoder_prefix_lengths.tolist() == [2]
|
||||
assert batch.encoder_lengths.tolist() == [2]
|
||||
assert batch.canvas_semantic_validity.tolist() == [[True] * 4]
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Fixed decision slots are resolved once by dataset preparation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision import datasets
|
||||
from axolotl.integrations.diffusion_decision.datasets import DecisionDataset
|
||||
from axolotl.integrations.diffusion_decision.preprocessing import build_decision_canvas
|
||||
from axolotl.integrations.diffusion_decision.slot_sampling import DecisionDraw
|
||||
from axolotl.model_support import (
|
||||
DiffusionLayout,
|
||||
DiffusionNoise,
|
||||
DiffusionSpec,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
ChatCharacterTokenizer,
|
||||
make_cfg,
|
||||
make_record,
|
||||
make_spec,
|
||||
)
|
||||
|
||||
|
||||
def _spec(noise: DiffusionNoise = DiffusionNoise.UNIFORM, *, encoder: bool = False):
|
||||
return make_spec(
|
||||
noise=noise,
|
||||
layout=(
|
||||
DiffusionLayout.ENCODER_CANVAS if encoder else DiffusionLayout.FULL_SEQUENCE
|
||||
),
|
||||
self_conditioning=False,
|
||||
)
|
||||
|
||||
|
||||
def _cfg(mode: str = "none", *, count: int = 0, ids: tuple[int, ...] = ()) -> dict:
|
||||
return make_cfg(
|
||||
model_config={"thought_open_ids": [70], "thought_close_ids": [71]},
|
||||
diffusion_decision={
|
||||
"latent": {"mode": mode, "num_slots": count, "token_ids": ids}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _prepared(cfg: dict, spec: DiffusionSpec):
|
||||
rows, dropped = datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, spec
|
||||
)
|
||||
assert dropped == 0
|
||||
assert len(rows) == 1
|
||||
return rows[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "count", "ids", "noise", "placement", "expected"),
|
||||
[
|
||||
("pad", 2, (), DiffusionNoise.UNIFORM, "after_turn", (0, 0)),
|
||||
("pinned", 2, (7,), DiffusionNoise.UNIFORM, "thought", (7, 7)),
|
||||
("learned", 2, (7, 8), DiffusionNoise.UNIFORM, "thought", (7, 8)),
|
||||
("prompt", 2, (7, 8), DiffusionNoise.UNIFORM, "prompt", (7, 8)),
|
||||
("mask", 2, (), DiffusionNoise.ABSORBING, "thought", (9, 9)),
|
||||
],
|
||||
)
|
||||
def test_dataset_rows_apply_fixed_slot_plan(
|
||||
mode: str,
|
||||
count: int,
|
||||
ids: tuple[int, ...],
|
||||
noise: DiffusionNoise,
|
||||
placement: str,
|
||||
expected: tuple[int, ...],
|
||||
):
|
||||
row = _prepared(_cfg(mode, count=count, ids=ids), _spec(noise))
|
||||
canvas = row["canvas"]
|
||||
plan = row["slot_plan"]
|
||||
|
||||
assert plan.placement == placement
|
||||
assert plan.ids == expected
|
||||
assert all(not canvas.slot_mask[position] for position in canvas.label_positions)
|
||||
if placement == "prompt":
|
||||
assert canvas.prompt_ids[:count] == expected
|
||||
assert not any(canvas.slot_mask)
|
||||
else:
|
||||
positions = tuple(
|
||||
index for index, selected in enumerate(canvas.slot_mask) if selected
|
||||
)
|
||||
assert len(positions) == count
|
||||
assert tuple(canvas.canvas_ids[index] for index in positions) == expected
|
||||
assert all(canvas.pinned_mask[index] for index in positions)
|
||||
assert plan.trainable_token_ids == ((7, 8) if mode in {"learned", "prompt"} else ())
|
||||
|
||||
|
||||
def test_none_mode_uses_the_legacy_canvas_call_and_row_shape():
|
||||
cfg = _cfg()
|
||||
record = make_record()
|
||||
row = _prepared(cfg, _spec())
|
||||
expected = build_decision_canvas(
|
||||
ChatCharacterTokenizer(),
|
||||
datasets.permute_record(deepcopy(record), seed=23),
|
||||
scaffold_ids=(),
|
||||
turn_close_id=11,
|
||||
pad_id=0,
|
||||
vocab_size=256,
|
||||
width=128,
|
||||
seed=23,
|
||||
steps=1,
|
||||
noise_kind="uniform",
|
||||
mask_token_id=9,
|
||||
)
|
||||
|
||||
assert row["canvas"] == expected
|
||||
assert set(row) == {
|
||||
"canvas",
|
||||
"decision_example",
|
||||
"source",
|
||||
"length",
|
||||
"record",
|
||||
"max_questions",
|
||||
}
|
||||
|
||||
|
||||
def test_slot_width_overflow_is_counted_without_truncation():
|
||||
cfg = _cfg("learned", count=2, ids=(7, 8))
|
||||
cfg["diffusion_lm"]["canvas_width"] = 8
|
||||
|
||||
rows, dropped = datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, _spec()
|
||||
)
|
||||
|
||||
assert rows == []
|
||||
assert dropped == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mode,ids", [("pinned", (7,)), ("learned", (7,)), ("prompt", (7,)), ("pad", ())]
|
||||
)
|
||||
def test_dynamic_slots_prepare_maximum_canvas_with_sampling_recipe(
|
||||
mode: str, ids: tuple[int, ...]
|
||||
):
|
||||
cfg = _cfg()
|
||||
cfg["diffusion_decision"]["latent"] = {
|
||||
"mode": mode,
|
||||
"num_slots": 1,
|
||||
"token_ids": ids,
|
||||
"sample_num_slots": True,
|
||||
}
|
||||
rows, dropped = datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, _spec()
|
||||
)
|
||||
assert dropped == 0
|
||||
assert rows[0]["slot_sampling"]["max_slots"] == 1
|
||||
assert len(rows[0]["slot_plan"].ids) == 1
|
||||
|
||||
|
||||
def test_sampled_prompt_draw_projects_leading_prompt_slots_and_preserves_weight():
|
||||
cfg = _cfg("prompt", count=3, ids=(7, 8, 10))
|
||||
cfg["diffusion_decision"]["latent"]["sample_num_slots"] = True
|
||||
rows, dropped = datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, _spec()
|
||||
)
|
||||
assert dropped == 0
|
||||
maximum = rows[0]
|
||||
maximum["decision_example"] = maximum["decision_example"].__class__(
|
||||
questions=maximum["decision_example"].questions, source_weight=1.5
|
||||
)
|
||||
dataset = DecisionDataset(rows, {})
|
||||
|
||||
projected = [dataset[DecisionDraw(0, 0, ordinal)] for ordinal in range(64)]
|
||||
by_count = {row["decision_slot_count"]: row for row in projected}
|
||||
assert set(by_count) == {0, 1, 2, 3}
|
||||
for count, row in by_count.items():
|
||||
canvas = row["canvas"]
|
||||
assert canvas.prompt_ids[:count] == (7, 8, 10)[:count]
|
||||
assert canvas.prompt_slot_mask == (True,) * count + (False,) * (
|
||||
len(canvas.prompt_ids) - count
|
||||
)
|
||||
assert not any(canvas.slot_mask)
|
||||
assert row["decision_example"].source_weight == 1.5
|
||||
|
||||
|
||||
def test_encoder_thought_slots_require_explicit_model_contract_delimiters():
|
||||
cfg = _cfg("pinned", count=1, ids=(7,))
|
||||
del cfg["model_config"]["thought_open_ids"]
|
||||
del cfg["model_config"]["thought_close_ids"]
|
||||
|
||||
with pytest.raises(ValueError, match=r"model_config: \{thought_open_ids"):
|
||||
datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, _spec(encoder=True)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ("pinned", "learned", "prompt"))
|
||||
@pytest.mark.parametrize("token_id", (0, 1, 2, 11, 9, 70, 71))
|
||||
def test_fixed_slot_ids_reject_active_tokenizer_model_and_delimiter_controls(
|
||||
mode: str, token_id: int
|
||||
):
|
||||
ids = (token_id,) if mode == "pinned" else (token_id, 8)
|
||||
cfg = _cfg(mode, count=2 if mode != "pinned" else 2, ids=ids)
|
||||
|
||||
with pytest.raises(ValueError, match="active control IDs"):
|
||||
datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, _spec()
|
||||
)
|
||||
|
||||
|
||||
def test_fixed_slots_reject_tokenizer_eos_without_model_config_metadata():
|
||||
cfg = _cfg("pinned", count=1, ids=(11,))
|
||||
cfg["model_config"] = {"vocab_size": 256}
|
||||
|
||||
with pytest.raises(ValueError, match="active control IDs"):
|
||||
datasets._canvas_rows(
|
||||
ChatCharacterTokenizer(), [make_record()], cfg, {}, _spec()
|
||||
)
|
||||
|
||||
|
||||
def test_validated_cfg_model_config_overrides_reach_slot_plan(
|
||||
min_base_cfg, monkeypatch
|
||||
):
|
||||
from collections import OrderedDict
|
||||
|
||||
from axolotl.integrations.base import PluginManager
|
||||
from axolotl.integrations.diffusion_decision._util import model_config_overrides
|
||||
from axolotl.integrations.diffusion_decision.plugin import (
|
||||
DiffusionDecisionPlugin,
|
||||
)
|
||||
from axolotl.utils.config import validate_config
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
plugin_path = "axolotl.integrations.diffusion_decision.DiffusionDecisionPlugin"
|
||||
monkeypatch.setattr(
|
||||
PluginManager.get_instance(),
|
||||
"plugins",
|
||||
OrderedDict({plugin_path: DiffusionDecisionPlugin()}),
|
||||
)
|
||||
raw = _cfg("pinned", count=1, ids=(7,))
|
||||
cfg = validate_config(
|
||||
DictDefault(
|
||||
min_base_cfg
|
||||
| {
|
||||
"plugins": [plugin_path],
|
||||
"seed": raw["seed"],
|
||||
"model_config": raw["model_config"],
|
||||
"diffusion_lm": {"from_causal_lm": False, **raw["diffusion_lm"]},
|
||||
"diffusion_decision": raw["diffusion_decision"],
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
assert cfg.model_config is None
|
||||
assert model_config_overrides(cfg)["thought_open_ids"] == [70]
|
||||
plan, thought_open_ids, thought_close_ids = datasets._resolve_slot_plan(
|
||||
ChatCharacterTokenizer(), cfg, _spec(encoder=True), 256
|
||||
)
|
||||
assert plan is not None and plan.placement == "thought"
|
||||
assert (thought_open_ids, thought_close_ids) == ((70,), (71,))
|
||||
cfg.diffusion_decision["latent"]["token_ids"] = [70]
|
||||
with pytest.raises(ValueError, match="active control IDs"):
|
||||
datasets._resolve_slot_plan(
|
||||
ChatCharacterTokenizer(), cfg, _spec(encoder=True), 256
|
||||
)
|
||||
@@ -0,0 +1,799 @@
|
||||
import json
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from transformers import PretrainedConfig
|
||||
|
||||
from axolotl.integrations.diffusion_decision import datasets, prepared_cache
|
||||
from axolotl.integrations.diffusion_decision.args import DecisionMixtureConfig
|
||||
from axolotl.integrations.diffusion_decision.loss import decision_example_from_canvas
|
||||
from axolotl.integrations.diffusion_decision.records import (
|
||||
DecisionCanvas,
|
||||
OrdinalMetadata,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.slot_sampling import (
|
||||
DecisionDraw,
|
||||
sample_slot_count,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.slots import SlotPlan
|
||||
from axolotl.model_support import DiffusionLayout
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import make_canvas, make_record
|
||||
|
||||
PREMIXED = {"premixed": True, "per_batch_stratified": False}
|
||||
DEFAULT_MIXTURE = {
|
||||
"weights": {"alpha": 0.8, "beta": 0.2},
|
||||
"max_examples_per_source": {"alpha": 2, "beta": 2},
|
||||
"per_batch_stratified": True,
|
||||
}
|
||||
SPEC = SimpleNamespace(
|
||||
max_canvas=128,
|
||||
noise=datasets.DiffusionNoise.UNIFORM,
|
||||
layout=DiffusionLayout.FULL_SEQUENCE,
|
||||
)
|
||||
IDENTITY = {"identity": 1}
|
||||
|
||||
|
||||
def _record(source: str, group: str, state: str, identifier: str) -> dict[str, Any]:
|
||||
return make_record(
|
||||
identifier, source=source, group=group, state=state, question_type="noul"
|
||||
)
|
||||
|
||||
|
||||
def _premixed(draw_id: str, state: str = "state", **fields: Any) -> dict[str, Any]:
|
||||
record = _record("alpha", "same", state, "origin")
|
||||
record["source_metadata"] = {
|
||||
"premix": {
|
||||
"draw_id": draw_id,
|
||||
"origin": {"source": "alpha", "id": "origin", "group": "same"},
|
||||
}
|
||||
}
|
||||
record.update(fields)
|
||||
return record
|
||||
|
||||
|
||||
def _entry(path: str, split: str | None = None, **fields: Any) -> dict[str, Any]:
|
||||
entry = {"path": path, "type": "diffusion_decision.jsonl", **fields}
|
||||
if split is not None:
|
||||
entry["split"] = split
|
||||
return entry
|
||||
|
||||
|
||||
DEV_ENTRY = _entry("dev", "validation")
|
||||
|
||||
|
||||
def _cfg(
|
||||
*entries: dict[str, Any],
|
||||
test_datasets: tuple[dict[str, Any], ...] = (),
|
||||
mixture: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"seed": 13,
|
||||
"micro_batch_size": 2,
|
||||
"datasets": list(entries),
|
||||
"test_datasets": list(test_datasets),
|
||||
"diffusion_decision": {
|
||||
"labels": {},
|
||||
"mixture": DEFAULT_MIXTURE if mixture is None else mixture,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _patch_loader(monkeypatch, rows_by_path: dict[str, list[dict[str, Any]]]) -> None:
|
||||
monkeypatch.setattr(datasets, "_rows", lambda entry: rows_by_path[entry["path"]])
|
||||
monkeypatch.setattr(
|
||||
datasets, "normalize_record", lambda _adapter, row, **_kwargs: dict(row)
|
||||
)
|
||||
monkeypatch.setattr(datasets, "load_tokenizer", lambda _cfg: range(131072))
|
||||
monkeypatch.setattr(datasets, "require_diffusion_spec", lambda _cfg: SPEC)
|
||||
monkeypatch.setattr(
|
||||
datasets,
|
||||
"_canvas_row",
|
||||
lambda _tokenizer, row, _cfg, source_weight, **_kwargs: {
|
||||
"canvas": SimpleNamespace(prompt_ids=(1,), canvas_ids=(2,) * 128),
|
||||
"record": row,
|
||||
"source": row["source"],
|
||||
"source_weight": source_weight,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _unit_canvas(**fields: Any) -> DecisionCanvas:
|
||||
values: dict[str, Any] = {
|
||||
"prompt_ids": (1,),
|
||||
"canvas_ids": (2,),
|
||||
"label_positions": (0,),
|
||||
"allowed": (3,),
|
||||
"question_ids": ("q",),
|
||||
}
|
||||
return make_canvas(**{**values, **fields})
|
||||
|
||||
|
||||
def _cache_row(identifier: str = "x") -> dict[str, Any]:
|
||||
return {"canvas": _unit_canvas(), "record": {"id": identifier}, "source": "x"}
|
||||
|
||||
|
||||
class FakeTokenizer(SimpleNamespace):
|
||||
backend_tokenizer = SimpleNamespace(to_str=lambda: "backend")
|
||||
pad_token_id = bos_token_id = eos_token_id = unk_token_id = mask_token_id = 0
|
||||
vocab: dict[str, int] = {"x": 0}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return getattr(self, "vocab_size", len(self.vocab))
|
||||
|
||||
def get_vocab(self) -> dict[str, int]:
|
||||
return self.vocab
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tokenizer_root(tmp_path):
|
||||
root = tmp_path / "tokenizer"
|
||||
root.mkdir()
|
||||
(root / "tokenizer.json").write_text("tokenizer")
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def empty_source(tmp_path):
|
||||
source = tmp_path / "source.jsonl"
|
||||
source.write_text("{}\n")
|
||||
return source
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_source_cfg(monkeypatch):
|
||||
_patch_loader(
|
||||
monkeypatch,
|
||||
{
|
||||
"train": [_record("alpha", "a", "a", "a"), _record("beta", "b", "b", "b")],
|
||||
"dev": [_record("gamma", "g", "g", "g")],
|
||||
},
|
||||
)
|
||||
return _cfg(_entry("train"), test_datasets=(_entry("dev", "dev"),))
|
||||
|
||||
|
||||
def test_loader_protects_official_test_calibration_and_ood(monkeypatch):
|
||||
_patch_loader(
|
||||
monkeypatch,
|
||||
{
|
||||
"train": [
|
||||
_record("alpha", "keep", "train-keep", "a0"),
|
||||
_record("alpha", "state-overlap", "shared", "a1"),
|
||||
_record("beta", "family-overlap", "train-family", "b0"),
|
||||
_record("beta", "keep-b", "train-b", "b1"),
|
||||
],
|
||||
"cal": [_record("alpha", "cal", "shared", "c0")],
|
||||
"ood": [_record("beta", "family-overlap", "ood-state", "o0")],
|
||||
"heldout": [_record("alpha", "heldout", "heldout-state", "h0")],
|
||||
"dev": [_record("gamma", "dev", "dev-state", "d0")],
|
||||
},
|
||||
)
|
||||
cfg = _cfg(
|
||||
_entry("train", "train"),
|
||||
_entry("cal", "calibration"),
|
||||
_entry("ood", "ood"),
|
||||
test_datasets=(DEV_ENTRY, _entry("heldout", "test")),
|
||||
)
|
||||
|
||||
result = datasets.load_decision_datasets(cfg)
|
||||
retained = [row["record"] for row in result.train_dataset]
|
||||
eval_rows = [row["record"] for row in result.eval_dataset]
|
||||
|
||||
assert {row["id"] for row in retained} == {"a0", "b1"}
|
||||
assert {row["id"] for row in eval_rows} == {"d0"}
|
||||
assert result.train_dataset.manifest["protected_eval_rows"] == 3
|
||||
assert result.train_dataset.manifest["dropped"]["state_overlap"] == 1
|
||||
assert result.train_dataset.manifest["dropped"]["family_overlap"] == 1
|
||||
|
||||
|
||||
def test_loader_realizes_each_capped_record_once_in_stratified_batches(monkeypatch):
|
||||
records = [
|
||||
_record(source, f"{tag}{index}", f"s{tag}{index}", f"{tag}{index}")
|
||||
for source, tag in (("alpha", "a"), ("beta", "b"))
|
||||
for index in range(3)
|
||||
]
|
||||
_patch_loader(
|
||||
monkeypatch, {"train": records, "dev": [_record("gamma", "g", "sg", "g")]}
|
||||
)
|
||||
cfg = _cfg(_entry("train", "train"), test_datasets=(DEV_ENTRY,))
|
||||
|
||||
result = datasets.load_decision_datasets(cfg)
|
||||
retained = [row["record"] for row in result.train_dataset]
|
||||
manifest = result.train_dataset.manifest
|
||||
|
||||
assert len(retained) >= 4
|
||||
assert len({row["id"] for row in retained}) == 4
|
||||
assert {row["source"] for row in retained} == {"alpha", "beta"}
|
||||
assert manifest["dropped"]["capped"] == 2
|
||||
assert manifest["mixture_probabilities"] == {"alpha": 0.8, "beta": 0.2}
|
||||
assert manifest["stratified_epoch_batches"]
|
||||
assert all(
|
||||
len(batch) <= manifest["stratified_micro_batch_size"]
|
||||
for batch in manifest["stratified_epoch_batches"]
|
||||
)
|
||||
|
||||
|
||||
def test_loader_preserves_premixed_draw_multiplicity_without_mixture_expansion(
|
||||
monkeypatch,
|
||||
):
|
||||
_patch_loader(
|
||||
monkeypatch,
|
||||
{
|
||||
"train": [_premixed("draw-1"), _premixed("draw-2")],
|
||||
"dev": [_record("gamma", "dev", "dev-state", "dev")],
|
||||
},
|
||||
)
|
||||
cfg = _cfg(_entry("train", "train"), test_datasets=(DEV_ENTRY,), mixture=PREMIXED)
|
||||
|
||||
result = datasets.load_decision_datasets(cfg)
|
||||
|
||||
rows = list(result.train_dataset)
|
||||
assert len(rows) == 2
|
||||
assert [row["record"]["source_metadata"]["premix"]["draw_id"] for row in rows] == [
|
||||
"draw-1",
|
||||
"draw-2",
|
||||
]
|
||||
assert result.train_dataset.manifest["premixed"] is True
|
||||
assert result.train_dataset.manifest["mixture_probabilities"] == {}
|
||||
assert result.train_dataset.manifest["stratified_epoch_batches"] == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"weights": {"alpha": 1.0}},
|
||||
{"temperature": 1.0},
|
||||
{"max_examples_per_source": 1},
|
||||
{"per_batch_stratified": True},
|
||||
],
|
||||
)
|
||||
def test_premixed_mixture_rejects_ignored_mixture_controls(kwargs):
|
||||
with pytest.raises(ValueError, match="premixed=true"):
|
||||
DecisionMixtureConfig(
|
||||
premixed=True, **{"per_batch_stratified": False, **kwargs}
|
||||
)
|
||||
|
||||
|
||||
def test_premixed_mixture_accepts_unweighted_unstratified_rows():
|
||||
assert DecisionMixtureConfig(premixed=True, per_batch_stratified=False).premixed
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("train", "dev", "match"),
|
||||
[
|
||||
pytest.param(
|
||||
[
|
||||
_premixed("draw-1"),
|
||||
_premixed("draw-2", labels={"q": {"kind": "hard", "gold_idx": 1}}),
|
||||
],
|
||||
None,
|
||||
"identical content",
|
||||
id="changed-content-for-one-origin",
|
||||
),
|
||||
pytest.param(
|
||||
[_premixed("draw-1"), _premixed("draw-1")],
|
||||
None,
|
||||
"draw_id is duplicated",
|
||||
id="duplicate-draw-id",
|
||||
),
|
||||
pytest.param(
|
||||
[_premixed("draw-1", state="shared")],
|
||||
[_record("gamma", "dev", "shared", "dev")],
|
||||
"overlap an evaluation split",
|
||||
id="decontamination-drop",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_premixed_rejects_invalid_draws(monkeypatch, train, dev, match):
|
||||
_patch_loader(monkeypatch, {"train": train, "dev": dev or []})
|
||||
cfg = _cfg(
|
||||
_entry("train", "train"),
|
||||
test_datasets=(DEV_ENTRY,) if dev else (),
|
||||
mixture=PREMIXED,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match=match):
|
||||
datasets.load_decision_datasets(cfg)
|
||||
|
||||
|
||||
def test_premixed_toggle_changes_prepared_cache_config():
|
||||
regular = _cfg(_entry("train"))
|
||||
premixed = _cfg(_entry("train"), mixture=PREMIXED)
|
||||
|
||||
assert prepared_cache._semantic_config(regular) != prepared_cache._semantic_config(
|
||||
premixed
|
||||
)
|
||||
|
||||
|
||||
def test_family_split_and_protected_split_names():
|
||||
rows = [
|
||||
_record("a", "x", "one", "a"),
|
||||
_record("a", "y", "two", "b"),
|
||||
_record("b", "g", "three", "c"),
|
||||
]
|
||||
train, dev = datasets._family_dev_split(rows, ratio=0.5)
|
||||
assert train and dev
|
||||
assert {datasets.family_key(row) for row in train}.isdisjoint(
|
||||
{datasets.family_key(row) for row in dev}
|
||||
)
|
||||
assert all(
|
||||
datasets._is_eval({"split": split}) for split in ("test", "calibration", "ood")
|
||||
)
|
||||
assert not datasets._is_eval({"split": "train"})
|
||||
|
||||
|
||||
def test_family_split_keeps_training_data_when_one_family_would_take_it_all():
|
||||
rows = [_record("a", "x", f"r{i}", "a") for i in range(4)]
|
||||
train, dev = datasets._family_dev_split(rows, ratio=0.5)
|
||||
assert len(train) == 4 and dev == []
|
||||
|
||||
rows.append(_record("a", "y", "lone", "b"))
|
||||
train, dev = datasets._family_dev_split(rows, ratio=0.2)
|
||||
assert len(train) + len(dev) == 5 and 0 < len(dev) < 5
|
||||
|
||||
|
||||
def test_local_jsonl_preserves_heterogeneous_nested_records_and_glob_order(tmp_path):
|
||||
(tmp_path / "01.jsonl").write_text(
|
||||
'{"state":{"scenario":"one","items":[1]},"options":["a"]}\n'
|
||||
)
|
||||
(tmp_path / "02.jsonl").write_text(
|
||||
'{"state":{"scenario":"two","items":[{"x":2}]},"options":{"yes":1}}\n'
|
||||
)
|
||||
|
||||
rows = datasets._rows(
|
||||
{"path": "json", "split": "train", "data_files": str(tmp_path / "*.jsonl")}
|
||||
)
|
||||
|
||||
assert [row["state"]["scenario"] for row in rows] == ["one", "two"]
|
||||
assert isinstance(rows[0]["options"], list)
|
||||
assert isinstance(rows[1]["options"], dict)
|
||||
|
||||
|
||||
def test_local_jsonl_reports_path_and_line_for_invalid_json(tmp_path):
|
||||
path = tmp_path / "bad.jsonl"
|
||||
path.write_text('{"ok":true}\n{"broken":\n')
|
||||
|
||||
with pytest.raises(ValueError, match=r"invalid JSONL at .*bad\.jsonl:2:"):
|
||||
datasets._rows({"path": "json", "split": "train", "data_files": str(path)})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["https://example.org/data.jsonl", "local.json"])
|
||||
def test_nonlocal_or_non_jsonl_inputs_use_hf_loader(monkeypatch, tmp_path, name):
|
||||
if name == "local.json":
|
||||
path = tmp_path / name
|
||||
path.write_text('[{"value":1}]')
|
||||
name = str(path)
|
||||
calls = []
|
||||
|
||||
def load(path, **kwargs):
|
||||
calls.append((path, kwargs))
|
||||
return [{"value": 1}]
|
||||
|
||||
monkeypatch.setattr(datasets, "load_dataset", load)
|
||||
assert datasets._rows({"path": "json", "data_files": name, "split": "dev"}) == [
|
||||
{"value": 1}
|
||||
]
|
||||
assert calls == [("json", {"split": "dev", "data_files": {"dev": name}})]
|
||||
|
||||
|
||||
def test_canvas_named_invalid_question_is_not_silently_dropped(monkeypatch):
|
||||
def fail(*args, **kwargs):
|
||||
raise datasets.SchemaError(
|
||||
"question 'canvas_choice': labels do not share one template slot"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(datasets, "_canvas_row", fail)
|
||||
with pytest.raises(datasets.SchemaError, match="canvas_choice"):
|
||||
datasets._canvas_rows(range(131072), [{"source": "alpha"}], {}, {}, None)
|
||||
assert datasets._is_canvas_overflow(
|
||||
datasets.SchemaError("answer template is 140 tokens; the canvas holds 127")
|
||||
)
|
||||
|
||||
|
||||
def test_parallel_canvas_rows_preserves_source_order_and_overflow_accounting(
|
||||
monkeypatch,
|
||||
):
|
||||
class InlineExecutor:
|
||||
def __init__(self, **_kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_args):
|
||||
return False
|
||||
|
||||
def map(self, fn, values, **_kwargs):
|
||||
return [fn(value) for value in reversed(list(values))]
|
||||
|
||||
def canvas_row(_tokenizer, row, _cfg, source_weight, **_kwargs):
|
||||
if row["id"] == "overflow":
|
||||
raise datasets.SchemaError(
|
||||
"answer template is 140 tokens; the canvas holds 127"
|
||||
)
|
||||
return {"id": row["id"], "source_weight": source_weight}
|
||||
|
||||
records = [
|
||||
{"id": "first", "source": "alpha"},
|
||||
{"id": "overflow", "source": "alpha"},
|
||||
{"id": "last", "source": "beta"},
|
||||
]
|
||||
monkeypatch.setattr(datasets, "_canvas_row", canvas_row)
|
||||
monkeypatch.setattr(datasets, "ThreadPoolExecutor", InlineExecutor)
|
||||
monkeypatch.setattr(datasets, "_resolve_slot_plan", lambda *_args: (None, (), ()))
|
||||
spec = SimpleNamespace(max_canvas=None)
|
||||
serial, serial_drops = datasets._canvas_rows(
|
||||
range(100), records, {"dataset_num_proc": 1}, {"alpha": 2.0}, spec
|
||||
)
|
||||
parallel, parallel_drops = datasets._canvas_rows(
|
||||
range(100), records, {"dataset_num_proc": 2}, {"alpha": 2.0}, spec
|
||||
)
|
||||
|
||||
assert parallel == serial
|
||||
assert parallel_drops == serial_drops == 1
|
||||
|
||||
|
||||
def test_budget_filter_precedes_source_probabilities_and_sampling(
|
||||
monkeypatch, two_source_cfg
|
||||
):
|
||||
def canvas_row(_tokenizer, row, _cfg, source_weight, **kwargs):
|
||||
return {
|
||||
"canvas": SimpleNamespace(
|
||||
prompt_ids=(1,) * (50 if row["source"] == "alpha" else 1),
|
||||
canvas_ids=(2,) * 128,
|
||||
),
|
||||
"record": row,
|
||||
"source": row["source"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(datasets, "_canvas_row", canvas_row)
|
||||
two_source_cfg["sequence_len"] = 150
|
||||
result = datasets.load_decision_datasets(two_source_cfg)
|
||||
manifest = result.train_dataset.manifest
|
||||
assert manifest["budget_drops"]["train"] == {"logical": 1, "physical": 0}
|
||||
assert manifest["mixture_probabilities"] == {"beta": 1.0}
|
||||
assert all(row["source"] == "beta" for row in result.train_dataset)
|
||||
assert sum(map(len, manifest["stratified_epoch_batches"])) == len(
|
||||
result.train_dataset
|
||||
)
|
||||
|
||||
|
||||
def test_budget_filter_uses_resolved_payload_capacity(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def resolve(cfg, *, packed, batch_size=None):
|
||||
calls.append(packed)
|
||||
return SimpleNamespace(payload_capacity=128)
|
||||
|
||||
monkeypatch.setattr(datasets, "resolve_native_packing_budget", resolve)
|
||||
row = {"canvas": SimpleNamespace(prompt_ids=(1,), canvas_ids=(2,) * 128)}
|
||||
cfg = {
|
||||
"sample_packing": False,
|
||||
"batch_flattening": True,
|
||||
"sequence_len": 2048,
|
||||
"micro_batch_size": 1,
|
||||
"diffusion_lm": {},
|
||||
}
|
||||
rows, drops = datasets._filter_budget_rows([row], cfg, SPEC)
|
||||
assert not rows
|
||||
assert drops == {"logical": 0, "physical": 1}
|
||||
assert calls == [True]
|
||||
|
||||
|
||||
def test_loader_reuses_supplied_tokenizer(monkeypatch, two_source_cfg):
|
||||
def unexpected(_cfg):
|
||||
raise AssertionError("must reuse the supplied tokenizer")
|
||||
|
||||
monkeypatch.setattr(datasets, "load_tokenizer", unexpected)
|
||||
result = datasets.load_decision_datasets(two_source_cfg, tokenizer=range(131072))
|
||||
assert len(result.train_dataset) > 0
|
||||
|
||||
|
||||
def test_local_prepared_cache_reuses_typed_rows_in_exact_order(
|
||||
tmp_path, tokenizer_root, monkeypatch
|
||||
):
|
||||
source = tmp_path / "source.jsonl"
|
||||
|
||||
def write_source(state: str) -> None:
|
||||
source.write_text(json.dumps(_record("alpha", "a", state, "id")) + "\n")
|
||||
|
||||
write_source("state")
|
||||
tokenizer = FakeTokenizer(
|
||||
name_or_path=str(tokenizer_root), chat_template="base", vocab_size=256
|
||||
)
|
||||
cfg = _cfg(
|
||||
_entry("json", data_files=str(source)),
|
||||
mixture={
|
||||
"weights": {"alpha": 1.0},
|
||||
"max_examples_per_source": {"alpha": 1},
|
||||
"per_batch_stratified": True,
|
||||
},
|
||||
)
|
||||
cfg.update(
|
||||
{
|
||||
"dataset_prepared_path": str(tmp_path / "prepared"),
|
||||
"model_config": {"vocab_size": 256, "turn_close_token_id": 2},
|
||||
"diffusion_lm": {"canvas_width": 128},
|
||||
}
|
||||
)
|
||||
calls = 0
|
||||
original_rows = datasets._rows
|
||||
|
||||
def rows(entry):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return original_rows(entry)
|
||||
|
||||
monkeypatch.setattr(datasets, "_rows", rows)
|
||||
monkeypatch.setattr(datasets, "load_tokenizer", lambda _cfg: tokenizer)
|
||||
monkeypatch.setattr(datasets, "require_diffusion_spec", lambda _cfg: SPEC)
|
||||
|
||||
def canvas_row(_tokenizer, row, _cfg, source_weight, **_kwargs):
|
||||
canvas = _unit_canvas(
|
||||
canvas_ids=(2,) * 128, label_positions=(3,), allowed=(4,), template_length=1
|
||||
)
|
||||
return {
|
||||
"canvas": canvas,
|
||||
"decision_example": decision_example_from_canvas(
|
||||
canvas, source_weight=source_weight
|
||||
),
|
||||
"record": row,
|
||||
"source": row["source"],
|
||||
"source_weight": source_weight,
|
||||
"length": 129,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(datasets, "_canvas_row", canvas_row)
|
||||
|
||||
first = datasets.load_decision_datasets(cfg)
|
||||
first_ids = [row["record"]["id"] for row in first.train_dataset]
|
||||
first_rows = [dict(row) for row in first.train_dataset]
|
||||
first_schedule = first.train_dataset.manifest["stratified_epoch_batches"]
|
||||
first_calls = calls
|
||||
cfg["seed"] = 14
|
||||
datasets.load_decision_datasets(cfg)
|
||||
assert calls > first_calls
|
||||
second_cold_calls = calls
|
||||
cfg["seed"] = 13
|
||||
second = datasets.load_decision_datasets(cfg)
|
||||
assert calls == second_cold_calls
|
||||
assert [row["record"]["id"] for row in second.train_dataset] == first_ids
|
||||
assert [dict(row) for row in second.train_dataset] == first_rows
|
||||
assert second.train_dataset.manifest["stratified_epoch_batches"] == first_schedule
|
||||
assert isinstance(second.train_dataset[0]["canvas"], DecisionCanvas)
|
||||
|
||||
def requires_rebuild(mutate, restore):
|
||||
nonlocal calls
|
||||
before = calls
|
||||
mutate()
|
||||
datasets.load_decision_datasets(cfg)
|
||||
assert calls > before
|
||||
restore()
|
||||
before = calls
|
||||
datasets.load_decision_datasets(cfg)
|
||||
assert calls == before
|
||||
|
||||
requires_rebuild(lambda: write_source("changed"), lambda: write_source("state"))
|
||||
requires_rebuild(
|
||||
lambda: setattr(tokenizer, "chat_template", "changed"),
|
||||
lambda: setattr(tokenizer, "chat_template", "base"),
|
||||
)
|
||||
requires_rebuild(
|
||||
lambda: cfg["diffusion_lm"].update(canvas_width=129),
|
||||
lambda: cfg["diffusion_lm"].update(canvas_width=128),
|
||||
)
|
||||
requires_rebuild(
|
||||
lambda: cfg.update(micro_batch_size=3), lambda: cfg.update(micro_batch_size=2)
|
||||
)
|
||||
|
||||
|
||||
def test_prepared_cache_preserves_nested_typed_values_and_live_vocab_identity(
|
||||
tokenizer_root, empty_source
|
||||
):
|
||||
first, second = (
|
||||
prepared_cache.identity(
|
||||
{},
|
||||
FakeTokenizer(name_or_path=str(tokenizer_root), vocab=vocab),
|
||||
SPEC,
|
||||
[empty_source],
|
||||
)
|
||||
for vocab in ({"a": 1}, {"b": 1})
|
||||
)
|
||||
assert first is not None and second is not None
|
||||
assert first[0] != second[0]
|
||||
canvas = _unit_canvas(
|
||||
slot_mask=(True,),
|
||||
ordinal_metadata=(OrdinalMetadata(("low",), ("0",), (0,)),),
|
||||
)
|
||||
row = {
|
||||
"canvas": canvas,
|
||||
"decision_example": decision_example_from_canvas(canvas, source_weight=0.25),
|
||||
"record": {"id": "x", "grouped_record_ids": ("x", "y")},
|
||||
"source": "x",
|
||||
"slot_plan": SlotPlan((9,), "thought", (True,), (False,), (False,), (9,)),
|
||||
}
|
||||
restored = prepared_cache._row_from_json(
|
||||
json.loads(json.dumps(prepared_cache._row_to_json(row)))
|
||||
)
|
||||
assert restored["canvas"] == canvas
|
||||
assert restored["slot_plan"] == row["slot_plan"]
|
||||
assert restored["decision_example"] == row["decision_example"]
|
||||
assert restored["record"] == row["record"]
|
||||
|
||||
|
||||
def test_prepared_cache_uses_exact_cached_hub_tokenizer_snapshot(
|
||||
tmp_path, empty_source, monkeypatch
|
||||
):
|
||||
import huggingface_hub
|
||||
|
||||
cache = tmp_path / "hub"
|
||||
repo_id = "nvidia/Nemotron-Labs-Diffusion-8B"
|
||||
revisions = ("a" * 40, "b" * 40)
|
||||
for revision in revisions:
|
||||
snapshot = (
|
||||
cache
|
||||
/ "models--nvidia--Nemotron-Labs-Diffusion-8B"
|
||||
/ "snapshots"
|
||||
/ revision
|
||||
)
|
||||
snapshot.mkdir(parents=True)
|
||||
(snapshot / "tokenizer_config.json").write_text("tokenizer")
|
||||
monkeypatch.setattr(huggingface_hub.constants, "HF_HUB_CACHE", str(cache))
|
||||
calls = []
|
||||
hf_hub_download = prepared_cache.hf_hub_download
|
||||
|
||||
def local_tokenizer_file(*args, **kwargs):
|
||||
calls.append((args, kwargs))
|
||||
return hf_hub_download(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(prepared_cache, "hf_hub_download", local_tokenizer_file)
|
||||
|
||||
def identity(cfg):
|
||||
return prepared_cache.identity(
|
||||
cfg, FakeTokenizer(name_or_path=repo_id), SPEC, [empty_source]
|
||||
)
|
||||
|
||||
first = identity({"model_config": {"_commit_hash": revisions[0]}})
|
||||
second = identity({"model_config": {"_commit_hash": revisions[1]}})
|
||||
from_immutable_request = identity({"revision_of_model": revisions[0]})
|
||||
from_moving_request = identity({"revision_of_model": "main"})
|
||||
|
||||
assert (
|
||||
first is not None and second is not None and from_immutable_request is not None
|
||||
)
|
||||
assert first[0] != second[0]
|
||||
assert from_moving_request is None
|
||||
assert first[1]["tokenizer"]["hub"] == {
|
||||
"repo_id": repo_id,
|
||||
"resolved_revision": revisions[0],
|
||||
}
|
||||
assert [kwargs["local_files_only"] for _, kwargs in calls] == [True, True, True]
|
||||
assert [kwargs["revision"] for _, kwargs in calls] == [*revisions, revisions[0]]
|
||||
assert [kwargs["filename"] for _, kwargs in calls] == ["tokenizer_config.json"] * 3
|
||||
validated_shape = identity(
|
||||
{
|
||||
"model_config": None,
|
||||
"overrides_of_model_config": {"_commit_hash": revisions[0]},
|
||||
}
|
||||
)
|
||||
assert validated_shape is not None
|
||||
assert validated_shape[1]["tokenizer"]["hub"]["resolved_revision"] == revisions[0]
|
||||
|
||||
|
||||
def test_prepared_cache_identity_binds_entry_roles_and_model_controls(
|
||||
tokenizer_root, empty_source
|
||||
):
|
||||
base = {
|
||||
"datasets": [_entry("json", "train", data_files=str(empty_source))],
|
||||
"test_datasets": [],
|
||||
"model_config": {"vocab_size": 8, "turn_close_token_id": 2},
|
||||
}
|
||||
role = {
|
||||
**base,
|
||||
"datasets": [],
|
||||
"test_datasets": [{**base["datasets"][0], "split": "dev"}],
|
||||
}
|
||||
controls = {**base, "model_config": {"vocab_size": 8, "turn_close_token_id": 3}}
|
||||
keys = [
|
||||
prepared_cache.identity(
|
||||
cfg, FakeTokenizer(name_or_path=str(tokenizer_root)), SPEC, [empty_source]
|
||||
)[0]
|
||||
for cfg in (base, role, controls)
|
||||
]
|
||||
assert len(set(keys)) == 3
|
||||
|
||||
|
||||
def test_prepared_cache_repairs_corrupt_payload_and_warms(tmp_path):
|
||||
row = _cache_row()
|
||||
prepared_cache.store(tmp_path, "key", IDENTITY, {}, [row], [], {})
|
||||
path = tmp_path / "diffusion_decision_cache" / "key.json"
|
||||
payload = json.loads(path.read_text())
|
||||
payload["content"]["manifest"] = {"bad": True}
|
||||
path.write_text(json.dumps(payload))
|
||||
assert prepared_cache.load(tmp_path, "key", IDENTITY) is None
|
||||
prepared_cache.store(tmp_path, "key", IDENTITY, {}, [row], [], {})
|
||||
first = prepared_cache.load(tmp_path, "key", IDENTITY)
|
||||
second = prepared_cache.load(tmp_path, "key", IDENTITY)
|
||||
assert first is not None and second is not None
|
||||
assert first[0][0]["canvas"] == row["canvas"]
|
||||
|
||||
|
||||
def test_prepared_cache_serializes_pretrained_model_config(tmp_path):
|
||||
cfg = {"model_config": PretrainedConfig(vocab_size=8, pad_token_id=0)}
|
||||
identity = {"model": cfg["model_config"]}
|
||||
prepared_cache.store(tmp_path, "config", identity, cfg, [_cache_row()], [], {})
|
||||
assert prepared_cache.load(tmp_path, "config", identity) is not None
|
||||
|
||||
|
||||
def test_prepared_cache_atomic_same_key_writers_and_readers(tmp_path):
|
||||
prepared_cache.store(tmp_path, "shared", IDENTITY, {}, [_cache_row("old")], [], {})
|
||||
barrier = threading.Barrier(3)
|
||||
|
||||
def writer(identifier):
|
||||
barrier.wait()
|
||||
prepared_cache.store(
|
||||
tmp_path, "shared", IDENTITY, {}, [_cache_row(identifier)], [], {}
|
||||
)
|
||||
|
||||
def reader():
|
||||
barrier.wait()
|
||||
seen = []
|
||||
for _ in range(30):
|
||||
loaded = prepared_cache.load(tmp_path, "shared", IDENTITY)
|
||||
assert loaded is not None
|
||||
seen.append(loaded[0][0]["record"]["id"])
|
||||
return seen
|
||||
|
||||
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||
futures = [
|
||||
pool.submit(writer, "a"),
|
||||
pool.submit(writer, "b"),
|
||||
pool.submit(reader),
|
||||
]
|
||||
seen = futures[2].result()
|
||||
futures[0].result()
|
||||
futures[1].result()
|
||||
assert set(seen) <= {"old", "a", "b"}
|
||||
assert prepared_cache.load(tmp_path, "shared", IDENTITY) is not None
|
||||
|
||||
|
||||
def test_decision_draw_projects_maximum_row_and_preserves_weight():
|
||||
canvas = make_canvas(
|
||||
prompt_ids=(1,),
|
||||
canvas_ids=(7, 8, 9, 4, 0, 0),
|
||||
label_positions=(3,),
|
||||
allowed=(4,),
|
||||
question_ids=("q",),
|
||||
pinned_mask=(True, True, True, False, True, True),
|
||||
slot_mask=(True, True, True, False, False, False),
|
||||
template_length=4,
|
||||
)
|
||||
plan = SlotPlan((7, 8, 9), "thought", (True,) * 3, (False,) * 3, (False,) * 3, ())
|
||||
row = {
|
||||
"canvas": canvas,
|
||||
"slot_plan": plan,
|
||||
"slot_sampling": {
|
||||
"seed": 1,
|
||||
"max_slots": 3,
|
||||
"pad_token_id": 0,
|
||||
"padding_pinned": True,
|
||||
},
|
||||
"decision_example": decision_example_from_canvas(canvas, source_weight=0.25),
|
||||
"source": "x",
|
||||
}
|
||||
dataset = datasets.DecisionDataset([row], {})
|
||||
seen = {}
|
||||
for ordinal in range(100):
|
||||
count = sample_slot_count(
|
||||
seed=1, epoch=0, global_draw_ordinal=ordinal, max_slots=3
|
||||
)
|
||||
seen.setdefault(count, dataset[DecisionDraw(0, 0, ordinal)])
|
||||
assert set(seen) == {0, 1, 2, 3}
|
||||
for count, projected in seen.items():
|
||||
assert projected["decision_slot_count"] == count
|
||||
assert len(projected["slot_plan"].ids) == count
|
||||
assert projected["decision_example"].source_weight == 0.25
|
||||
assert dataset[0] is row
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Native encoder-canvas graph-carry coverage for decision unrolls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from peft import LoraConfig, get_peft_model
|
||||
|
||||
import axolotl.model_support.diffusion_gemma.modeling as gemma_modeling
|
||||
from axolotl.integrations.diffusion_decision.records import DecisionCanvas
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
DecisionTrainingCollator,
|
||||
)
|
||||
from axolotl.model_support import DiffusionLayout, LogitAlignment
|
||||
from axolotl.model_support.diffusion_gemma.modeling import (
|
||||
AxolotlDiffusionGemmaForBlockDiffusion,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
MultistepTrainerHarness,
|
||||
tiny_gemma_config,
|
||||
trainer_spec,
|
||||
)
|
||||
|
||||
|
||||
def _inputs():
|
||||
canvas = DecisionCanvas(
|
||||
prompt_ids=(2, 3),
|
||||
canvas_ids=(25, 6, 7, 8, 0, 0, 0, 0),
|
||||
label_positions=(1, 3),
|
||||
allowed_ids=((1, 2, 3), (4, 5, 6)),
|
||||
question_ids=("q0", "q1"),
|
||||
targets=({"kind": "hard", "gold_idx": 0},) * 2,
|
||||
pinned_mask=(True, False, False, False, True, True, True, True),
|
||||
semantic_mask=(True,) * 8,
|
||||
slot_mask=(True, False, False, False, False, False, False, False),
|
||||
template_length=4,
|
||||
)
|
||||
return DecisionTrainingCollator(
|
||||
trainer_spec(DiffusionLayout.ENCODER_CANVAS, LogitAlignment.ALIGNED)
|
||||
)([{"canvas": canvas, "source": "native"}])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [2, 3])
|
||||
@pytest.mark.parametrize("grad_through_steps", [False, True])
|
||||
@pytest.mark.parametrize("evaluate", [False, True])
|
||||
def test_native_gemma_peft_grad_through_steps_carries_intermediate_graph(
|
||||
monkeypatch, steps, grad_through_steps, evaluate
|
||||
):
|
||||
torch.manual_seed(17)
|
||||
base = AxolotlDiffusionGemmaForBlockDiffusion(tiny_gemma_config())
|
||||
model = get_peft_model(
|
||||
base,
|
||||
LoraConfig(
|
||||
r=2,
|
||||
lora_alpha=2,
|
||||
target_modules=r"^model\.decoder\.layers\.0\.self_attn\.q_proj$",
|
||||
task_type=None,
|
||||
),
|
||||
).train()
|
||||
seen = []
|
||||
states = []
|
||||
original = gemma_modeling.decode_packed_canvas
|
||||
|
||||
def traced(*args, **kwargs):
|
||||
states.append(args[1].detach().clone())
|
||||
conditioning = args[5] if len(args) > 5 else None
|
||||
if conditioning is not None and conditioning.requires_grad:
|
||||
conditioning.retain_grad()
|
||||
seen.append(conditioning)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(gemma_modeling, "decode_packed_canvas", traced)
|
||||
inputs = _inputs()
|
||||
trainer = MultistepTrainerHarness(
|
||||
trainer_spec(DiffusionLayout.ENCODER_CANVAS, LogitAlignment.ALIGNED),
|
||||
k_max=steps,
|
||||
sampled_steps=steps,
|
||||
grad_through_steps=grad_through_steps,
|
||||
decision={"latent": {"mode": "learned", "num_slots": 1}},
|
||||
)
|
||||
model.train(not evaluate)
|
||||
with torch.set_grad_enabled(not evaluate):
|
||||
loss = trainer.compute_loss(model, inputs)
|
||||
carried = [value for value in seen if value is not None]
|
||||
assert len(carried) == steps - 1
|
||||
assert len(states) == steps
|
||||
assert all(torch.equal(state, states[0]) for state in states[1:])
|
||||
assert not inputs["diffusion_batch"].canvas_loss_mask[0, 0]
|
||||
assert all(
|
||||
value.requires_grad is (grad_through_steps and not evaluate)
|
||||
for value in carried
|
||||
)
|
||||
if evaluate:
|
||||
assert not loss.requires_grad
|
||||
assert all(parameter.grad is None for parameter in model.parameters())
|
||||
return
|
||||
loss.backward()
|
||||
assert (
|
||||
any(
|
||||
value.grad is not None and torch.count_nonzero(value.grad)
|
||||
for value in carried
|
||||
)
|
||||
is grad_through_steps
|
||||
)
|
||||
assert any(
|
||||
p.grad is not None and torch.count_nonzero(p.grad)
|
||||
for n, p in model.named_parameters()
|
||||
if ".lora_" in n
|
||||
)
|
||||
|
||||
|
||||
def test_grad_through_steps_rejects_non_self_conditioning_layout():
|
||||
trainer = MultistepTrainerHarness(
|
||||
trainer_spec(DiffusionLayout.FULL_SEQUENCE, LogitAlignment.ALIGNED),
|
||||
k_max=2,
|
||||
sampled_steps=2,
|
||||
grad_through_steps=True,
|
||||
)
|
||||
with pytest.raises(NotImplementedError, match="encoder-canvas self-conditioning"):
|
||||
trainer.compute_loss(torch.nn.Linear(1, 1), _inputs())
|
||||
|
||||
|
||||
def test_grad_through_steps_rejects_encoder_without_self_conditioning():
|
||||
spec = replace(
|
||||
trainer_spec(DiffusionLayout.ENCODER_CANVAS, LogitAlignment.ALIGNED),
|
||||
self_conditioning=False,
|
||||
)
|
||||
trainer = MultistepTrainerHarness(
|
||||
spec, k_max=2, sampled_steps=2, grad_through_steps=True
|
||||
)
|
||||
with pytest.raises(NotImplementedError, match="encoder-canvas self-conditioning"):
|
||||
trainer.compute_loss(torch.nn.Linear(1, 1), _inputs())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""CPU contract for the non-self-conditioning decision recurrence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from axolotl.core.trainers.diffusion_lm.unroll import run_unroll
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [1, 2, 3])
|
||||
def test_decision_recurrence_updates_only_free_slots_between_reads(steps):
|
||||
state = torch.tensor([[9, 4, 7]])
|
||||
update_mask = torch.tensor([[True, False, False]])
|
||||
seen = []
|
||||
|
||||
def forward(current, *_args):
|
||||
seen.append(current.clone())
|
||||
logits = torch.nn.functional.one_hot((current + 1) % 16, 16).float()
|
||||
return type("Output", (), {"logits": logits})()
|
||||
|
||||
_, _, final = run_unroll(
|
||||
state=state,
|
||||
update_mask=update_mask,
|
||||
steps=steps,
|
||||
grad_through_steps=False,
|
||||
supports_self_conditioning=False,
|
||||
k1_conditioning_mask=None,
|
||||
recurrent_conditioning_mask=None,
|
||||
forward_step=forward,
|
||||
logits_from_outputs=lambda output: output.logits,
|
||||
update_state=lambda current, logits, mask: torch.where(
|
||||
mask, logits.argmax(-1), current
|
||||
),
|
||||
pilot_for_single_step=False,
|
||||
)
|
||||
assert len(seen) == steps
|
||||
assert torch.equal(final[0, 1:], state[0, 1:])
|
||||
assert final.tolist() == [[9 + steps - 1, 4, 7]]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Exact upstream fixtures generated with pinned Gemma and Dream tokenizers."""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision import template
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
DJEV_FIXTURE,
|
||||
RecordedTokenizer,
|
||||
)
|
||||
|
||||
|
||||
def json_value(value):
|
||||
return json.loads(json.dumps(value))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["gemma", "dream"])
|
||||
@pytest.mark.parametrize("index", range(22))
|
||||
def test_schema_system_answer_and_resolver_match_pinned_djev(family, index):
|
||||
reference = DJEV_FIXTURE["tokenizers"][family]
|
||||
case = reference["cases"][index]
|
||||
schema = template.parse_schema(case["schema"])
|
||||
assert json_value(schema) == case["parsed"]
|
||||
assert template.system_text(schema, chunked=case["chunked"]) == case["system_text"]
|
||||
assert (
|
||||
template.answer_text(
|
||||
schema["questions"], case["answer_indices"], schema["format"]
|
||||
)
|
||||
== case["answer_text"]
|
||||
)
|
||||
args = {
|
||||
"tokenizer": RecordedTokenizer(reference["encodings"]),
|
||||
"qs": schema["questions"],
|
||||
"head": case["head"],
|
||||
"lead": case["lead"],
|
||||
"fmt": schema["format"],
|
||||
"width": case["width"],
|
||||
}
|
||||
if "template_error" in case:
|
||||
with pytest.raises(
|
||||
template.SchemaError, match="^" + re.escape(case["template_error"]) + "$"
|
||||
):
|
||||
template.resolve_template(**args)
|
||||
else:
|
||||
assert json_value(template.resolve_template(**args)) == case["template"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", DJEV_FIXTURE["invalid_schemas"])
|
||||
def test_schema_rejection_matches_pinned_djev(case):
|
||||
with pytest.raises(
|
||||
template.SchemaError, match="^" + re.escape(case["error"]) + "$"
|
||||
):
|
||||
template.parse_schema(case["schema"])
|
||||
|
||||
|
||||
def test_fixture_includes_successful_native_tokenization_and_boundary_cases():
|
||||
assert DJEV_FIXTURE["source_revision"] == "e5841cf41e9211608e698492658685c36e24e77a"
|
||||
for reference in DJEV_FIXTURE["tokenizers"].values():
|
||||
assert sum("template" in case for case in reference["cases"]) >= 20
|
||||
assert {case["parsed"]["format"] for case in reference["cases"]} == {
|
||||
"lines",
|
||||
"indexed",
|
||||
}
|
||||
assert any(
|
||||
len(case["parsed"]["questions"]) == 64 for case in reference["cases"]
|
||||
)
|
||||
assert reference["tokenizer_files_sha256"]
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Fixed decision slots retain their prepared masks through training and reads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from axolotl.integrations.diffusion_decision.loss import decision_label_loss
|
||||
from axolotl.integrations.diffusion_decision.trainer import DiffusionDecisionTrainer
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
DecisionTrainingCollator,
|
||||
)
|
||||
from axolotl.model_support import (
|
||||
DiffusionNoise,
|
||||
DiffusionSpec,
|
||||
LogitAlignment,
|
||||
ObjectiveReduction,
|
||||
ReductionScope,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
EchoModel,
|
||||
TrainerHarness,
|
||||
build_canvas,
|
||||
make_record,
|
||||
make_slot_plan,
|
||||
make_spec,
|
||||
slot_token_ids,
|
||||
)
|
||||
|
||||
|
||||
def _spec(noise: DiffusionNoise) -> DiffusionSpec:
|
||||
return make_spec(
|
||||
noise=noise,
|
||||
objective_reduction=ObjectiveReduction.EXAMPLE_MEAN,
|
||||
reduction_scope=ReductionScope.GLOBAL_WINDOW,
|
||||
)
|
||||
|
||||
|
||||
def _prepared_canvas(mode: str, noise: DiffusionNoise):
|
||||
spec = _spec(noise)
|
||||
plan = make_slot_plan(mode, ids=slot_token_ids(mode), spec=spec)
|
||||
canvas = build_canvas(
|
||||
plan,
|
||||
record=make_record("fixed-slot", question="choice"),
|
||||
prompt_ids=(90,),
|
||||
noise=noise,
|
||||
thought_open_ids=(70,),
|
||||
thought_close_ids=(71,),
|
||||
)
|
||||
return spec, plan, canvas
|
||||
|
||||
|
||||
def _slot_config(mode: str) -> dict[str, object]:
|
||||
return {
|
||||
"mode": mode,
|
||||
"num_slots": 2,
|
||||
"token_ids": list(slot_token_ids(mode)),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "noise"),
|
||||
[
|
||||
("pad", DiffusionNoise.UNIFORM),
|
||||
("pinned", DiffusionNoise.UNIFORM),
|
||||
("learned", DiffusionNoise.UNIFORM),
|
||||
("prompt", DiffusionNoise.UNIFORM),
|
||||
("mask", DiffusionNoise.ABSORBING),
|
||||
],
|
||||
)
|
||||
def test_prepared_fixed_slots_stay_pinned_through_trainer(mode, noise):
|
||||
spec, plan, canvas = _prepared_canvas(mode, noise)
|
||||
fixed_canvas = torch.tensor(canvas.slot_mask, dtype=torch.bool)
|
||||
label_mask = torch.zeros(len(canvas.canvas_ids), dtype=torch.bool)
|
||||
label_mask[torch.tensor(canvas.label_positions)] = True
|
||||
assert not torch.any(fixed_canvas & label_mask)
|
||||
|
||||
batch = DecisionTrainingCollator(spec)([{"canvas": canvas, "source": "test"}])
|
||||
assert batch["decision_label_positions"][0, 0].item() == (
|
||||
len(canvas.prompt_ids) + canvas.label_positions[0]
|
||||
)
|
||||
fixed_positions = (
|
||||
torch.arange(len(plan.ids))
|
||||
if mode == "prompt"
|
||||
else torch.where(fixed_canvas)[0] + len(canvas.prompt_ids)
|
||||
)
|
||||
batch["canvas_corruptible_mask"][0, fixed_positions] = True
|
||||
batch["canvas_update_mask"][0, fixed_positions] = True
|
||||
trainer = TrainerHarness(
|
||||
spec, {"latent": _slot_config(mode)}, mask_token_id=9, native_time=1.0
|
||||
)
|
||||
trainer._validate_decision_config(trainer._decision_config(), k_max=1, spec=spec)
|
||||
train_model = EchoModel()
|
||||
trainer._full_sequence_logits(train_model, batch, spec, trainer._decision_config())
|
||||
assert train_model.seen_input_ids is not None
|
||||
expected = torch.tensor(
|
||||
(
|
||||
plan.ids
|
||||
if mode == "prompt"
|
||||
else torch.tensor(canvas.canvas_ids)[fixed_canvas].tolist()
|
||||
),
|
||||
dtype=torch.long,
|
||||
)
|
||||
torch.testing.assert_close(train_model.seen_input_ids[0, fixed_positions], expected)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [1, 2, 3])
|
||||
def test_tiny_native_dream_mask_slots_are_pinned_and_train_through_lora(
|
||||
monkeypatch: pytest.MonkeyPatch, steps: int
|
||||
):
|
||||
from peft import LoraConfig, get_peft_model
|
||||
from transformers import AutoConfig
|
||||
|
||||
from axolotl.model_support.dream import _model_class
|
||||
|
||||
from tests.native_source_fixtures import native_source_fixture_path
|
||||
|
||||
source = native_source_fixture_path("dream")
|
||||
if source is None:
|
||||
pytest.skip("native Dream source fixture unavailable")
|
||||
config = AutoConfig.from_pretrained(
|
||||
source, trust_remote_code=True, local_files_only=True
|
||||
)
|
||||
for name, value in {
|
||||
"vocab_size": 256,
|
||||
"hidden_size": 64,
|
||||
"intermediate_size": 128,
|
||||
"num_hidden_layers": 1,
|
||||
"num_attention_heads": 4,
|
||||
"num_key_value_heads": 2,
|
||||
"head_dim": 16,
|
||||
"max_position_embeddings": 128,
|
||||
"max_window_layers": 1,
|
||||
"bos_token_id": 1,
|
||||
"eos_token_id": 1,
|
||||
"pad_token_id": 0,
|
||||
"mask_token_id": 9,
|
||||
"use_cache": False,
|
||||
}.items():
|
||||
setattr(config, name, value)
|
||||
config._name_or_path = str(source)
|
||||
model = (
|
||||
cast(Any, _model_class()).from_config(config, trust_remote_code=True).train()
|
||||
)
|
||||
model = get_peft_model(
|
||||
model, LoraConfig(r=2, lora_alpha=4, target_modules=["q_proj"])
|
||||
)
|
||||
spec, plan, canvas = _prepared_canvas("mask", DiffusionNoise.ABSORBING)
|
||||
spec = replace(spec, logit_alignment=LogitAlignment.SHIFTED)
|
||||
batch = DecisionTrainingCollator(spec)([{"canvas": canvas, "source": "test"}])
|
||||
slots = torch.where(torch.tensor(canvas.slot_mask))[0] + len(canvas.prompt_ids)
|
||||
assert batch["canvas_input_pinned_mask"][0, slots].all()
|
||||
assert not batch["canvas_loss_mask"][0, slots].any()
|
||||
assert not batch["canvas_corruptible_mask"][0, slots].any()
|
||||
assert not batch["canvas_update_mask"][0, slots].any()
|
||||
seen: list[torch.Tensor] = []
|
||||
raw_logits: list[torch.Tensor] = []
|
||||
selected_inputs: list[torch.Tensor] = []
|
||||
selected_outputs: list[torch.Tensor] = []
|
||||
|
||||
def capture_input(_module, args, kwargs):
|
||||
input_ids = kwargs["input_ids"] if "input_ids" in kwargs else args[0]
|
||||
seen.append(input_ids.detach().clone())
|
||||
|
||||
def capture_output(_module, _args, _kwargs, output):
|
||||
raw_logits.append(output.logits.detach().clone())
|
||||
|
||||
original_select = DiffusionDecisionTrainer._select_question_logits
|
||||
|
||||
def capture_selected(logits, inputs, *, coordinates=None):
|
||||
selected_inputs.append(logits.detach().clone())
|
||||
selected, supervision = original_select(logits, inputs, coordinates=coordinates)
|
||||
selected_outputs.append(selected.detach().clone())
|
||||
return selected, supervision
|
||||
|
||||
monkeypatch.setattr(
|
||||
DiffusionDecisionTrainer,
|
||||
"_select_question_logits",
|
||||
staticmethod(capture_selected),
|
||||
)
|
||||
base_model = model.get_base_model()
|
||||
input_hook = base_model.register_forward_pre_hook(capture_input, with_kwargs=True)
|
||||
output_hook = base_model.register_forward_hook(capture_output, with_kwargs=True)
|
||||
|
||||
class KStepHarness(TrainerHarness):
|
||||
@staticmethod
|
||||
def _native_unroll_settings():
|
||||
return steps, False
|
||||
|
||||
@staticmethod
|
||||
def _sample_native_unroll_steps(k_max, device):
|
||||
del k_max, device
|
||||
return steps
|
||||
|
||||
trainer = KStepHarness(
|
||||
spec, {"latent": _slot_config("mask")}, mask_token_id=9, native_time=1.0
|
||||
)
|
||||
try:
|
||||
loss = cast(torch.Tensor, trainer.compute_loss(model, batch))
|
||||
finally:
|
||||
input_hook.remove()
|
||||
output_hook.remove()
|
||||
assert torch.isfinite(loss)
|
||||
assert len(seen) == len(raw_logits) == steps
|
||||
assert selected_inputs and selected_outputs
|
||||
assert all(
|
||||
torch.equal(value[0, slots], torch.full_like(slots, 9)) for value in seen
|
||||
)
|
||||
labels = torch.tensor(canvas.label_positions) + len(canvas.prompt_ids)
|
||||
assert all(
|
||||
torch.equal(value[0, labels], torch.full_like(labels, 9)) for value in seen
|
||||
)
|
||||
pinned = torch.tensor(canvas.pinned_mask, dtype=torch.bool)
|
||||
scaffold = torch.where(pinned)[0] + len(canvas.prompt_ids)
|
||||
expected_scaffold = torch.tensor(canvas.canvas_ids)[pinned]
|
||||
assert all(torch.equal(value[0, scaffold], expected_scaffold) for value in seen)
|
||||
torch.testing.assert_close(seen[-1][0, slots], torch.full_like(slots, 9))
|
||||
expected_aligned = torch.cat([raw_logits[-1][:, :1], raw_logits[-1][:, :-1]], dim=1)
|
||||
torch.testing.assert_close(selected_inputs[-1], expected_aligned)
|
||||
rows = batch["decision_label_rows"].long()
|
||||
positions = batch["decision_label_positions"].long()
|
||||
expected_selected = expected_aligned[rows.clamp_min(0), positions.clamp_min(0)]
|
||||
torch.testing.assert_close(selected_outputs[-1], expected_selected)
|
||||
decision = trainer._decision_config()
|
||||
expected_loss = decision_label_loss(
|
||||
selected_outputs[-1],
|
||||
batch["decision_examples"],
|
||||
batch["decision_supervision_mask"],
|
||||
label_softmax=decision.labels.label_softmax,
|
||||
brier_weight=decision.labels.brier_weight,
|
||||
).loss
|
||||
torch.testing.assert_close(loss.detach(), expected_loss.detach())
|
||||
loss.backward()
|
||||
lora_grads = [
|
||||
parameter.grad
|
||||
for name, parameter in model.named_parameters()
|
||||
if "lora_" in name
|
||||
]
|
||||
assert lora_grads and all(
|
||||
gradient is not None and torch.isfinite(gradient).all()
|
||||
for gradient in lora_grads
|
||||
)
|
||||
assert any(gradient.abs().sum() > 0 for gradient in lora_grads)
|
||||
@@ -0,0 +1,112 @@
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.grouping import group_records
|
||||
|
||||
|
||||
def _record(
|
||||
identifier, *, source="source", group="group", state='{"a": 1}', instructions=""
|
||||
):
|
||||
return {
|
||||
"id": identifier,
|
||||
"source": source,
|
||||
"group": group,
|
||||
"state": state,
|
||||
"instructions": instructions,
|
||||
"questions": {"q": {"type": "choice", "options": ["a", "b"]}},
|
||||
"labels": {"q": {"kind": "hard", "gold_idx": 0 if identifier == "one" else 1}},
|
||||
"source_metadata": {"origin": identifier},
|
||||
}
|
||||
|
||||
|
||||
def test_groups_identical_context_and_stably_renames_question_collisions():
|
||||
grouped = group_records([_record("one"), _record("two")], max_questions=4)
|
||||
|
||||
assert len(grouped) == 1
|
||||
record = grouped[0]
|
||||
assert list(record["questions"]) == ["q", "q__2"]
|
||||
assert record["labels"]["q"] == {"kind": "hard", "gold_idx": 0}
|
||||
assert record["labels"]["q__2"] == {"kind": "hard", "gold_idx": 1}
|
||||
assert record["grouped_record_ids"] == ("one", "two")
|
||||
assert record["grouped_question_metadata"]["q__2"] == {
|
||||
"record_id": "two",
|
||||
"original_question_id": "q",
|
||||
"source_metadata": {"origin": "two"},
|
||||
}
|
||||
|
||||
|
||||
def test_keeps_mismatched_state_source_group_or_global_instructions_separate():
|
||||
records = [
|
||||
_record("one"),
|
||||
_record("two", state='{"a":1}'),
|
||||
_record("three", source="other"),
|
||||
_record("four", group="other"),
|
||||
_record("five", instructions="global"),
|
||||
{**_record("six"), "family": "other"},
|
||||
]
|
||||
|
||||
grouped = group_records(records, max_questions=4)
|
||||
|
||||
assert len(grouped) == len(records)
|
||||
assert [record["id"] for record in grouped] == [record["id"] for record in records]
|
||||
assert [record["source_metadata"] for record in grouped] == [
|
||||
record["source_metadata"] for record in records
|
||||
]
|
||||
|
||||
|
||||
def test_rejects_empty_question_mappings():
|
||||
record = _record("one")
|
||||
record["questions"] = {}
|
||||
record["labels"] = {}
|
||||
|
||||
try:
|
||||
group_records([record], max_questions=2)
|
||||
except ValueError as error:
|
||||
assert "at least one question" in str(error)
|
||||
else:
|
||||
raise AssertionError("empty questions were accepted")
|
||||
|
||||
|
||||
def test_splits_at_question_bound_without_truncating_targets_or_metadata():
|
||||
first = _record("one")
|
||||
first["questions"] = {f"q{index}": {"type": "noul"} for index in range(3)}
|
||||
first["labels"] = {
|
||||
f"q{index}": {"kind": "dist", "probs": [index, 3 - index]} for index in range(3)
|
||||
}
|
||||
second = _record("two")
|
||||
second["questions"] = {"x": {"type": "score"}, "y": {"type": "choice"}}
|
||||
second["labels"] = {
|
||||
"x": {"kind": "set", "allowed_set": [0, 1]},
|
||||
"y": {"kind": "hard", "gold_idx": 1},
|
||||
}
|
||||
|
||||
grouped = group_records([first, second], max_questions=2)
|
||||
|
||||
assert [len(record["questions"]) for record in grouped] == [2, 2, 1]
|
||||
targets = [target for record in grouped for target in record["labels"].values()]
|
||||
assert targets == [
|
||||
{"kind": "dist", "probs": [0, 3]},
|
||||
{"kind": "dist", "probs": [1, 2]},
|
||||
{"kind": "dist", "probs": [2, 1]},
|
||||
{"kind": "set", "allowed_set": [0, 1]},
|
||||
{"kind": "hard", "gold_idx": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_rejects_duplicate_record_ids_within_a_group():
|
||||
records = [_record("one"), _record("one"), _record("two")]
|
||||
with pytest.raises(ValueError, match="duplicated: one"):
|
||||
group_records(records, max_questions=1)
|
||||
|
||||
|
||||
def test_records_without_ids_get_positional_ids_and_keep_their_metadata():
|
||||
records = [_record("one"), _record("two")]
|
||||
for record in records:
|
||||
del record["id"]
|
||||
grouped = group_records(records, max_questions=1)
|
||||
|
||||
assert [record["id"] for record in grouped] == [
|
||||
"group#record0#group0",
|
||||
"group#record0#group1",
|
||||
]
|
||||
assert grouped[1]["grouped_record_ids"] == ("group#record1",)
|
||||
assert grouped[1]["grouped_source_metadata"] == {"group#record1": {"origin": "two"}}
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Leakage checks use state identity, not labels or source-specific row IDs."""
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.hygiene import (
|
||||
assert_split_isolation,
|
||||
decontaminate,
|
||||
state_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
def row(state, family="family-a", source="source-a"):
|
||||
return {"state": state, "family": family, "source": source, "labels": {}}
|
||||
|
||||
|
||||
def test_canonical_state_identity():
|
||||
assert state_fingerprint('{ "b": 2, "a": 1 }') == state_fingerprint(
|
||||
{"a": 1, "b": 2}
|
||||
)
|
||||
assert state_fingerprint("cafe\u0301 \n value") == state_fingerprint("café value")
|
||||
assert state_fingerprint({"value": [1, 2]}) != state_fingerprint({"value": [2, 1]})
|
||||
|
||||
|
||||
def test_cross_source_decontamination_and_family_exclusion():
|
||||
evaluation = [row({"x": 1})]
|
||||
candidates = [
|
||||
row('{"x":1}', "other", "source-b"),
|
||||
row({"x": 2}),
|
||||
row({"x": 3}, "family-b"),
|
||||
]
|
||||
kept, counts = decontaminate(iter(candidates), iter(evaluation))
|
||||
assert kept == [candidates[2]]
|
||||
assert counts == {"input": 3, "state_overlap": 1, "family_overlap": 1, "kept": 1}
|
||||
assert candidates[0]["labels"] == {}
|
||||
|
||||
|
||||
def test_official_split_state_filter_can_preserve_shared_families():
|
||||
candidates = [row("different state")]
|
||||
kept, counts = decontaminate(
|
||||
candidates, [row("evaluation")], exclude_families=False
|
||||
)
|
||||
assert kept == candidates
|
||||
assert counts["family_overlap"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("second", [row("same", "other", "source-b"), row("different")])
|
||||
def test_split_guard_rejects_state_or_family_overlap(second):
|
||||
with pytest.raises(ValueError, match="overlaps splits"):
|
||||
assert_split_isolation({"train": [row("same")], "test": [second]})
|
||||
|
||||
|
||||
def test_family_names_are_source_scoped_and_repetitions_within_split_are_valid():
|
||||
assert_split_isolation(
|
||||
{"train": [row("a"), row("b")], "test": [row("c", source="source-b")]}
|
||||
)
|
||||
|
||||
|
||||
def test_missing_state_or_family_is_not_silently_accepted():
|
||||
with pytest.raises(ValueError, match="state"):
|
||||
state_fingerprint(None)
|
||||
with pytest.raises(ValueError, match="family"):
|
||||
decontaminate([{"source": "x", "state": "y"}], [])
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Normalized records reject targets incompatible with their question schema."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.adapters.jsonl import normalize_jsonl
|
||||
|
||||
|
||||
def record(target):
|
||||
return {
|
||||
"source": "local",
|
||||
"group": "family-1",
|
||||
"state": {"observation": 1},
|
||||
"questions": {"q": {"type": "choice", "options": ["act", "wait"]}},
|
||||
"labels": {"q": target},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
{"kind": "hard", "gold_idx": 1},
|
||||
{"kind": "dist", "probs": [0.2, 0.8]},
|
||||
{"kind": "set", "allowed_set": [0, 1]},
|
||||
],
|
||||
)
|
||||
def test_all_label_kinds_roundtrip_without_mutating_input(target):
|
||||
source = record(target)
|
||||
original = deepcopy(source)
|
||||
result = normalize_jsonl(source)
|
||||
assert result == original
|
||||
result["questions"]["q"]["options"].append("other")
|
||||
assert source == original
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
{"kind": "hard", "gold_idx": -1},
|
||||
{"kind": "hard", "gold_idx": True},
|
||||
{"kind": "set", "allowed_set": []},
|
||||
{"kind": "set", "allowed_set": [0, 0]},
|
||||
{"kind": "dist", "probs": [0.8, 0.8]},
|
||||
],
|
||||
)
|
||||
def test_invalid_targets_are_rejected(target):
|
||||
with pytest.raises(ValueError):
|
||||
normalize_jsonl(record(target))
|
||||
|
||||
|
||||
def test_question_target_keys_must_match():
|
||||
source = record({"kind": "hard", "gold_idx": 0})
|
||||
source["labels"] = {}
|
||||
with pytest.raises(ValueError, match="exactly one"):
|
||||
normalize_jsonl(source)
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision import adapters
|
||||
from axolotl.integrations.diffusion_decision.adapters import (
|
||||
jsonl as jsonl_adapter,
|
||||
normalize_record,
|
||||
)
|
||||
|
||||
|
||||
def _row():
|
||||
return {
|
||||
"id": "row",
|
||||
"source": "local",
|
||||
"group": "group",
|
||||
"state": {"value": 1},
|
||||
"questions": {
|
||||
"q": {"type": "choice", "instructions": "pick", "options": ["a", "b"]}
|
||||
},
|
||||
"labels": {"q": {"kind": "hard", "gold_idx": 1}},
|
||||
}
|
||||
|
||||
|
||||
def test_jsonl_dispatch_validates_once_and_does_not_mutate(monkeypatch):
|
||||
row = _row()
|
||||
original = deepcopy(row)
|
||||
calls = 0
|
||||
real = jsonl_adapter.normalize_jsonl
|
||||
|
||||
def counted(*args, **kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
return real(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(adapters, "normalize_jsonl", counted)
|
||||
assert normalize_record("jsonl", row, training=False) == original
|
||||
assert row == original
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_jsonl_dispatch_preserves_malformed_error():
|
||||
row = _row()
|
||||
row["labels"] = {}
|
||||
with pytest.raises(ValueError, match="exactly one target"):
|
||||
normalize_record("jsonl", row, training=False)
|
||||
@@ -0,0 +1,742 @@
|
||||
import math
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
|
||||
from axolotl.integrations.diffusion_decision.loss import (
|
||||
DecisionLabelExample,
|
||||
DecisionLabelQuestion,
|
||||
DistributionLabel,
|
||||
HardLabel,
|
||||
SetLabel,
|
||||
decision_label_loss,
|
||||
decision_label_loss_from_hidden,
|
||||
label_target_from_mapping,
|
||||
)
|
||||
|
||||
ONE = torch.tensor([[True]])
|
||||
MIXED_MASK = torch.tensor([[True, False, False], [False, True, True]])
|
||||
FULL = {"label_softmax": "full"}
|
||||
RESULT_FIELDS = (
|
||||
"loss",
|
||||
"restricted_loss",
|
||||
"full_vocab_loss",
|
||||
"effective_full_vocab_loss",
|
||||
"full_vocab_dft_hard_weight_sum",
|
||||
"full_vocab_dft_hard_count",
|
||||
"brier_loss",
|
||||
)
|
||||
|
||||
|
||||
def _dense_linear_token_loss(hidden, head, targets):
|
||||
return F.cross_entropy(
|
||||
F.linear(hidden, head.weight, head.bias).float(), targets, reduction="none"
|
||||
)
|
||||
|
||||
|
||||
def _example(*questions, weight=1.0):
|
||||
return DecisionLabelExample(questions=tuple(questions), source_weight=weight)
|
||||
|
||||
|
||||
def _question(position, ids, target):
|
||||
return DecisionLabelQuestion(position, tuple(ids), target)
|
||||
|
||||
|
||||
def _single(position, ids, target, weight=1.0):
|
||||
return _example(_question(position, ids, target), weight=weight)
|
||||
|
||||
|
||||
def _dist(probs, candidates=None, other=None):
|
||||
fields = {} if candidates is None else {"candidate_indices": candidates}
|
||||
if other is not None:
|
||||
fields["other_probability"] = other
|
||||
return DistributionLabel(tuple(probs), **fields)
|
||||
|
||||
|
||||
def _row(*values, grad=False, dtype=None):
|
||||
return torch.tensor([[list(values)]], dtype=dtype, requires_grad=grad)
|
||||
|
||||
|
||||
MIXED_EXAMPLES = [
|
||||
_single(0, (0, 3), SetLabel((0, 1)), weight=1.75),
|
||||
_example(
|
||||
_question(1, (1, 2, 4), _dist((0.0, 0.4, 0.6))),
|
||||
_question(2, (2, 5), HardLabel(0)),
|
||||
weight=0.25,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _loss(logits, examples, mask=ONE, **kwargs):
|
||||
if not isinstance(examples, list):
|
||||
examples = [examples]
|
||||
return decision_label_loss(logits, examples, mask, **kwargs)
|
||||
|
||||
|
||||
def _hidden_loss(hidden, head, examples, mask=ONE, **kwargs):
|
||||
if not isinstance(examples, list):
|
||||
examples = [examples]
|
||||
kwargs.setdefault("linear_token_loss", _dense_linear_token_loss)
|
||||
return decision_label_loss_from_hidden(hidden, head, examples, mask, **kwargs)
|
||||
|
||||
|
||||
def _assert_fields_close(actual, expected, names=RESULT_FIELDS, **tolerance):
|
||||
for name in names:
|
||||
torch.testing.assert_close(
|
||||
getattr(actual, name), getattr(expected, name), **tolerance
|
||||
)
|
||||
|
||||
|
||||
def _assert_finite_backward(result, *leaves):
|
||||
assert torch.isfinite(result.loss)
|
||||
result.loss.backward()
|
||||
for leaf in leaves:
|
||||
assert leaf.grad is not None and torch.isfinite(leaf.grad).all()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["both", "full", "restricted"])
|
||||
def test_per_example_components_average_to_aggregates_for_mixed_examples(mode):
|
||||
torch.manual_seed(41)
|
||||
hidden = torch.randn(2, 3, 4, dtype=torch.float64)
|
||||
head = torch.nn.Linear(4, 7, dtype=torch.float64)
|
||||
result = _hidden_loss(hidden, head, MIXED_EXAMPLES, MIXED_MASK, label_softmax=mode)
|
||||
for aggregate, values in [
|
||||
(result.loss, result.per_example_loss),
|
||||
(result.restricted_loss, result.per_example_restricted_loss),
|
||||
(result.full_vocab_loss, result.per_example_full_vocab_loss),
|
||||
(result.brier_loss, result.per_example_brier_loss),
|
||||
]:
|
||||
assert values is not None
|
||||
torch.testing.assert_close(torch.stack(values).mean(), aggregate)
|
||||
assert all(not value.requires_grad for value in values)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["both", "full", "restricted"])
|
||||
def test_hidden_loss_accepts_accelerate_fp32_outputs_with_bf16_head(mode):
|
||||
from accelerate.utils import convert_outputs_to_fp32
|
||||
|
||||
torch.manual_seed(19)
|
||||
encoder = torch.nn.Linear(4, 4)
|
||||
head = torch.nn.Linear(4, 3, dtype=torch.bfloat16)
|
||||
|
||||
@convert_outputs_to_fp32
|
||||
def forward(values):
|
||||
with torch.autocast("cpu", dtype=torch.bfloat16):
|
||||
return {"last_hidden_state": encoder(values)}
|
||||
|
||||
hidden = forward(torch.ones(1, 1, 4))["last_hidden_state"]
|
||||
hidden.retain_grad()
|
||||
assert hidden.dtype == torch.float32
|
||||
example = _single(0, (0, 1, 2), HardLabel(1))
|
||||
seen = []
|
||||
|
||||
def callback(query_hidden, callback_head, targets):
|
||||
seen.append(query_hidden.dtype)
|
||||
return _dense_linear_token_loss(query_hidden, callback_head, targets)
|
||||
|
||||
result = _hidden_loss(
|
||||
hidden, head, example, linear_token_loss=callback, label_softmax=mode
|
||||
)
|
||||
reference = _loss(
|
||||
F.linear(hidden.to(head.weight.dtype), head.weight, head.bias).float(),
|
||||
example,
|
||||
label_softmax=mode,
|
||||
)
|
||||
torch.testing.assert_close(result.loss, reference.loss)
|
||||
result.loss.backward()
|
||||
assert seen == ([] if mode == "restricted" else [torch.bfloat16])
|
||||
assert hidden.grad.dtype == torch.float32
|
||||
assert torch.isfinite(hidden.grad).all() and torch.count_nonzero(hidden.grad)
|
||||
assert encoder.weight.grad is not None
|
||||
assert torch.isfinite(encoder.weight.grad).all()
|
||||
assert head.weight.dtype == torch.bfloat16
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "mode"),
|
||||
[
|
||||
(HardLabel(1), "both"),
|
||||
(_dist((0.0, 0.25, 0.75)), "both"),
|
||||
(SetLabel((0, 2)), "both"),
|
||||
(HardLabel(1), "full"),
|
||||
(SetLabel((0, 2)), "restricted"),
|
||||
],
|
||||
)
|
||||
def test_hidden_cce_loss_matches_dense_callback_and_backpropagates(target, mode):
|
||||
torch.manual_seed(7)
|
||||
hidden = torch.randn(2, 3, 4, dtype=torch.float64, requires_grad=True)
|
||||
head = torch.nn.Linear(4, 7, bias=True, dtype=torch.float64)
|
||||
examples = [
|
||||
_single(0, (1, 3, 5), target, weight=2.0),
|
||||
_example(
|
||||
_question(1, (0, 2), HardLabel(0)),
|
||||
_question(2, (1, 4, 6), _dist((0.0, 0.5, 0.5))),
|
||||
weight=0.5,
|
||||
),
|
||||
]
|
||||
calls = []
|
||||
|
||||
def callback(query_hidden, callback_head, targets):
|
||||
calls.append((query_hidden.shape, targets.shape))
|
||||
return _dense_linear_token_loss(query_hidden, callback_head, targets)
|
||||
|
||||
result = _hidden_loss(
|
||||
hidden,
|
||||
head,
|
||||
examples,
|
||||
MIXED_MASK,
|
||||
linear_token_loss=callback,
|
||||
label_softmax=mode,
|
||||
brier_weight=0.3,
|
||||
)
|
||||
_assert_finite_backward(result, hidden, head.weight, head.bias)
|
||||
expected_calls = (
|
||||
[] if mode == "restricted" else [(torch.Size([3, 4]), torch.Size([3]))]
|
||||
)
|
||||
assert calls == expected_calls
|
||||
if mode == "full":
|
||||
torch.testing.assert_close(
|
||||
result.restricted_loss, torch.zeros_like(result.restricted_loss)
|
||||
)
|
||||
|
||||
|
||||
def test_hidden_cce_helper_rejects_reduced_callback_output():
|
||||
head = torch.nn.Linear(2, 3, bias=False, dtype=torch.float64)
|
||||
with pytest.raises(ValueError, match="one loss per selected"):
|
||||
_hidden_loss(
|
||||
torch.zeros(1, 1, 2, dtype=torch.float64),
|
||||
head,
|
||||
_single(0, (0, 1), HardLabel(0)),
|
||||
linear_token_loss=lambda values, linear, targets: _dense_linear_token_loss(
|
||||
values, linear, targets
|
||||
).mean(),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target", [HardLabel(150), _dist((0.0,) * 150 + (1.0,))], ids=["hard", "dist"]
|
||||
)
|
||||
def test_151_candidate_final_index_has_normalized_loss_and_gradients(target):
|
||||
hidden = torch.randn(1, 1, 4, dtype=torch.float64, requires_grad=True)
|
||||
head = torch.nn.Linear(4, 151, bias=True, dtype=torch.float64)
|
||||
logits = F.linear(hidden, head.weight, head.bias)
|
||||
result = _loss(logits, _single(0, tuple(range(151)), target))
|
||||
_assert_finite_backward(result, hidden, head.weight)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("examples", "mask", "kwargs", "fields", "dtype"),
|
||||
[
|
||||
(MIXED_EXAMPLES, MIXED_MASK, {}, RESULT_FIELDS, torch.float64),
|
||||
(
|
||||
MIXED_EXAMPLES,
|
||||
MIXED_MASK,
|
||||
{"full_ce_weighting": "dft"},
|
||||
RESULT_FIELDS,
|
||||
torch.float64,
|
||||
),
|
||||
(
|
||||
_single(0, (0, 3), _dist((0.0, 1.0))),
|
||||
ONE,
|
||||
{**FULL, "hard_label_smoothing": 0.1},
|
||||
("loss", "full_vocab_loss", "brier_loss"),
|
||||
torch.float64,
|
||||
),
|
||||
(
|
||||
_single(0, (0, 1), _dist((0.5,), (1,), 0.5)),
|
||||
ONE,
|
||||
{**FULL, "brier_weight": 0.0},
|
||||
("loss", "full_vocab_loss"),
|
||||
torch.float32,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"ce-typed-unequal-questions",
|
||||
"dft-typed-unequal-questions",
|
||||
"one-hot-distribution-smoothing",
|
||||
"sparse-distribution-residual",
|
||||
],
|
||||
)
|
||||
def test_hidden_path_matches_dense_loss_and_gradients(
|
||||
examples, mask, kwargs, fields, dtype
|
||||
):
|
||||
torch.manual_seed(11)
|
||||
hidden = torch.randn(*mask.shape, 4, dtype=dtype, requires_grad=True)
|
||||
head = torch.nn.Linear(4, 7, dtype=dtype)
|
||||
dense_hidden = hidden.detach().clone().requires_grad_()
|
||||
dense_head = torch.nn.Linear(4, 7, dtype=dtype)
|
||||
dense_head.load_state_dict(head.state_dict())
|
||||
cce = _hidden_loss(hidden, head, examples, mask, **kwargs)
|
||||
logits = F.linear(dense_hidden, dense_head.weight, dense_head.bias)
|
||||
dense = _loss(logits, examples, mask, **kwargs)
|
||||
_assert_fields_close(cce, dense, fields)
|
||||
cce.loss.backward()
|
||||
dense.loss.backward()
|
||||
torch.testing.assert_close(hidden.grad, dense_hidden.grad)
|
||||
torch.testing.assert_close(head.weight.grad, dense_head.weight.grad)
|
||||
torch.testing.assert_close(head.bias.grad, dense_head.bias.grad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("examples", "mask", "variant"),
|
||||
[
|
||||
(
|
||||
[_single(0, (0, 2, 3), HardLabel(1)), _single(1, (1, 3), SetLabel((0, 1)))],
|
||||
torch.tensor([[True, False], [False, True]]),
|
||||
"ce",
|
||||
),
|
||||
(
|
||||
[
|
||||
_example(
|
||||
_question(0, (0, 1, 2), _dist((0.2, 0.3, 0.5))),
|
||||
_question(1, (0, 2, 3), SetLabel((0, 2))),
|
||||
),
|
||||
_single(0, (1, 2, 3), _dist((0.5, 0.5, 0.0))),
|
||||
],
|
||||
torch.tensor([[True, True], [True, False]]),
|
||||
"dft",
|
||||
),
|
||||
],
|
||||
ids=["explicit-ce-is-default", "dft-leaves-distribution-and-set-unchanged"],
|
||||
)
|
||||
def test_full_ce_weighting_variants_preserve_loss_and_gradients(
|
||||
examples, mask, variant
|
||||
):
|
||||
logits = torch.tensor(
|
||||
[
|
||||
[[0.4, -0.2, 1.3, 0.1], [0.8, -0.1, 0.2, 1.1]],
|
||||
[[0.3, 0.9, -0.6, 0.2], [-0.4, 0.5, 1.0, 0.7]],
|
||||
],
|
||||
dtype=torch.float64,
|
||||
)
|
||||
baseline_logits = logits.clone().requires_grad_()
|
||||
variant_logits = logits.clone().requires_grad_()
|
||||
baseline = _loss(baseline_logits, examples, mask)
|
||||
result = _loss(variant_logits, examples, mask, full_ce_weighting=variant)
|
||||
baseline.loss.backward()
|
||||
result.loss.backward()
|
||||
|
||||
torch.testing.assert_close(result.loss, baseline.loss, rtol=0, atol=0)
|
||||
torch.testing.assert_close(
|
||||
variant_logits.grad, baseline_logits.grad, rtol=0, atol=0
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
result.effective_full_vocab_loss, result.full_vocab_loss, rtol=0, atol=0
|
||||
)
|
||||
zero = torch.zeros_like(result.loss)
|
||||
torch.testing.assert_close(result.full_vocab_dft_hard_weight_sum, zero)
|
||||
torch.testing.assert_close(result.full_vocab_dft_hard_count, zero)
|
||||
|
||||
|
||||
def test_dft_full_ce_uses_detached_target_probability_and_preserves_raw_metric():
|
||||
logits = _row(0.3, -0.5, 1.1, grad=True, dtype=torch.float64)
|
||||
expected_logits = logits.detach().clone().requires_grad_()
|
||||
result = _loss(
|
||||
logits,
|
||||
_single(0, (0, 1, 2), HardLabel(2)),
|
||||
full_ce_weighting="dft",
|
||||
brier_weight=0,
|
||||
**FULL,
|
||||
)
|
||||
full_ce = -F.log_softmax(logits[0, 0].float(), dim=0)[2]
|
||||
probability = full_ce.detach().neg().exp()
|
||||
torch.testing.assert_close(result.full_vocab_loss, full_ce)
|
||||
torch.testing.assert_close(result.effective_full_vocab_loss, probability * full_ce)
|
||||
torch.testing.assert_close(result.full_vocab_dft_hard_weight_sum, probability)
|
||||
torch.testing.assert_close(
|
||||
result.full_vocab_dft_hard_count, torch.ones_like(probability)
|
||||
)
|
||||
result.loss.backward()
|
||||
(-F.softmax(expected_logits[0, 0].float(), dim=0)[2]).backward()
|
||||
torch.testing.assert_close(logits.grad, expected_logits.grad)
|
||||
|
||||
|
||||
def _nll(row, index, ids=None):
|
||||
return -F.log_softmax(row if ids is None else row[list(ids)], dim=0)[index]
|
||||
|
||||
|
||||
def _hard_components(row):
|
||||
restricted, full = _nll(row, 1, (0, 3)), _nll(row, 3)
|
||||
brier = (F.softmax(row[[0, 3]], dim=0) - torch.tensor([0.0, 1.0])).square().sum()
|
||||
return {
|
||||
"restricted_loss": restricted,
|
||||
"full_vocab_loss": full,
|
||||
"brier_loss": brier,
|
||||
"loss": restricted + full + 0.5 * brier,
|
||||
}
|
||||
|
||||
|
||||
def _distribution_components(row):
|
||||
target = torch.tensor([0.25, 0.75])
|
||||
kl = torch.sum(target * (target.log() - F.log_softmax(row[[1, 2]], dim=0)))
|
||||
full = -(target * F.log_softmax(row, dim=0)[[1, 2]]).sum()
|
||||
return {"restricted_loss": kl, "full_vocab_loss": full, "loss": kl + full}
|
||||
|
||||
|
||||
def _sparse_residual_components(row):
|
||||
logprobs = F.log_softmax(row, dim=0)
|
||||
full = -(torch.tensor([0.25, 0.5]) * logprobs[[1, 2]]).sum()
|
||||
full = full - 0.25 * torch.log1p(-logprobs[[1, 2]].exp().sum())
|
||||
return {"full_vocab_loss": full, "loss": full}
|
||||
|
||||
|
||||
def _set_components(row):
|
||||
mass = F.softmax(row[[0, 1, 2]], dim=0)[[0, 2]].sum()
|
||||
full = _nll(row, 0)
|
||||
return {
|
||||
"restricted_loss": -mass.log(),
|
||||
"full_vocab_loss": full,
|
||||
"brier_loss": (1.0 - mass).square(),
|
||||
"loss": -mass.log() + full + 0.25 * (1.0 - mass).square(),
|
||||
}
|
||||
|
||||
|
||||
ZERO = torch.zeros(())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("logits", "question", "kwargs", "expected"),
|
||||
[
|
||||
(
|
||||
torch.tensor([[[30.0, -30.0, 20.0, -20.0], [0.2, 1.5, -0.4, 0.7]]]),
|
||||
_question(1, (0, 3), HardLabel(1)),
|
||||
{"brier_weight": 0.5},
|
||||
_hard_components,
|
||||
),
|
||||
(
|
||||
_row(0.3, -0.2, 1.1, 0.0),
|
||||
_question(0, (1, 2), _dist((0.25, 0.75))),
|
||||
{"brier_weight": 0.0},
|
||||
_distribution_components,
|
||||
),
|
||||
(
|
||||
_row(0.3, -0.2, 1.1, 0.0),
|
||||
_question(0, (0, 1, 2), _dist((0.25, 0.5), (1, 2), 0.25)),
|
||||
{**FULL, "brier_weight": 0.0},
|
||||
_sparse_residual_components,
|
||||
),
|
||||
(
|
||||
_row(0.1, 0.2, 1.5),
|
||||
_question(0, (0, 1, 2), _dist((0.0, 0.0, 1.0))),
|
||||
{"brier_weight": 0},
|
||||
lambda row: {"restricted_loss": _nll(row, 2)},
|
||||
),
|
||||
(
|
||||
_row(1.0, -1.0, 0.4, 2.0),
|
||||
_question(0, (0, 1, 2), SetLabel((0, 2))),
|
||||
{"brier_weight": 0.25},
|
||||
_set_components,
|
||||
),
|
||||
(
|
||||
_row(0.3, 1.2, -0.1),
|
||||
_question(0, (0, 1, 2), SetLabel((0, 1, 2))),
|
||||
{"label_softmax": "restricted", "brier_weight": 0},
|
||||
lambda row: {"restricted_loss": ZERO, "brier_loss": ZERO},
|
||||
),
|
||||
(
|
||||
_row(0.1, 1.2, -0.2),
|
||||
_question(0, (0, 2), HardLabel(0)),
|
||||
{**FULL, "brier_weight": 0},
|
||||
lambda row: {"restricted_loss": ZERO, "full_vocab_loss": _nll(row, 0)},
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"hard-combines-restricted-full-and-brier-at-supervised-position",
|
||||
"distribution-restricted-kl-and-full-soft-ce",
|
||||
"sparse-distribution-full-ce-keeps-residual-other-mass",
|
||||
"distribution-kl-accepts-zero-probability-alternatives",
|
||||
"set-allowed-mass-and-detached-best-member-full-ce",
|
||||
"set-mass-zero-when-every-allowed-label-correct",
|
||||
"full-mode-excludes-restricted-primary-for-hard",
|
||||
],
|
||||
)
|
||||
def test_single_question_loss_components(logits, question, kwargs, expected):
|
||||
mask = torch.zeros(logits.shape[:2], dtype=torch.bool)
|
||||
mask[0, question.position] = True
|
||||
result = _loss(logits, _example(question), mask, **kwargs)
|
||||
assert torch.isfinite(result.loss)
|
||||
for name, value in expected(logits[0, question.position]).items():
|
||||
torch.testing.assert_close(getattr(result, name), value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("allowed_ids", "target", "expected_target", "gold", "hard"),
|
||||
[
|
||||
((0, 3), _dist((0.0, 1.0)), (0.05, 0.95), 3, (0.0, 1.0)),
|
||||
(
|
||||
(0, 1, 2, 3),
|
||||
_dist((0.0, 0.0, 1.0, 0.0)),
|
||||
(0.025, 0.025, 0.925, 0.025),
|
||||
2,
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
),
|
||||
((0, 1, 2, 3), _dist((1.0,), (3,)), (0.025, 0.025, 0.025, 0.925), 3, None),
|
||||
],
|
||||
ids=["two-way", "four-way", "sparse-one-hot"],
|
||||
)
|
||||
def test_one_hot_distribution_smoothing_uses_allowed_support_for_ce_and_keeps_hard_brier(
|
||||
allowed_ids, target, expected_target, gold, hard
|
||||
):
|
||||
logits = _row(0.2, 1.5, -0.4, 0.7, 1.1, grad=True)
|
||||
example = _single(0, allowed_ids, target)
|
||||
result = _loss(logits, example, brier_weight=0.5, hard_label_smoothing=0.1, **FULL)
|
||||
unsmoothed = _loss(logits, example, brier_weight=0.5, **FULL)
|
||||
result.loss.backward()
|
||||
|
||||
full_logprobs = F.log_softmax(logits.detach()[0, 0], dim=0)
|
||||
torch.testing.assert_close(
|
||||
result.full_vocab_loss,
|
||||
-(torch.tensor(expected_target) * full_logprobs[list(allowed_ids)]).sum(),
|
||||
)
|
||||
if hard is not None:
|
||||
restricted_probs = F.softmax(logits.detach()[0, 0, list(allowed_ids)], dim=0)
|
||||
torch.testing.assert_close(
|
||||
result.brier_loss, (restricted_probs - torch.tensor(hard)).square().sum()
|
||||
)
|
||||
assert not torch.allclose(result.loss, unsmoothed.loss)
|
||||
assert logits.grad is not None
|
||||
assert logits.grad[0, 0, gold] < 0
|
||||
for token_id in set(range(logits.shape[-1])) - set(allowed_ids):
|
||||
assert logits.grad[0, 0, token_id] > 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("logits", "examples", "kwargs"),
|
||||
[
|
||||
(
|
||||
_row(0.0, 100.0, 0.0, 0.0, 0.0, grad=True),
|
||||
_single(0, (0, 1), _dist((0.5,), (1,), 0.5)),
|
||||
{**FULL, "brier_weight": 0.0},
|
||||
),
|
||||
(
|
||||
_row(1000.0, -1000.0, 999.0, grad=True),
|
||||
_single(0, (0, 1, 2), SetLabel((1,))),
|
||||
{"label_softmax": "restricted", "brier_weight": 0},
|
||||
),
|
||||
(
|
||||
torch.tensor(
|
||||
[[[0.1, 0.4, -0.3]], [[0.1, 0.4, -0.3]]],
|
||||
dtype=torch.float64,
|
||||
requires_grad=True,
|
||||
),
|
||||
[
|
||||
_single(0, (0, 1, 2), HardLabel(1)),
|
||||
_single(0, (0, 1, 2), HardLabel(1, smoothing=0.1)),
|
||||
],
|
||||
{**FULL, "hard_label_smoothing": 0.0},
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"sparse-residual-when-candidate-mass-rounds-to-one",
|
||||
"set-mass-logsumexp-for-extremely-unlikely-correct-label",
|
||||
"mixed-per-target-smoothing-with-global-smoothing-disabled",
|
||||
],
|
||||
)
|
||||
def test_loss_and_gradients_stay_finite_at_numerical_edges(logits, examples, kwargs):
|
||||
mask = torch.ones(logits.shape[:2], dtype=torch.bool)
|
||||
_assert_finite_backward(_loss(logits, examples, mask, **kwargs), logits)
|
||||
|
||||
|
||||
def test_distribution_full_ce_has_soft_target_gradient_and_ignores_zero_mass():
|
||||
logits = _row(0.7, -0.3, 1.2, 0.1, grad=True)
|
||||
example = _single(0, (0, 2, 3), _dist((0.25, 0.75, 0.0)))
|
||||
result = _loss(logits, example, brier_weight=0.0, **FULL)
|
||||
result.loss.backward()
|
||||
|
||||
target = torch.tensor([0.25, 0.75, 0.0])
|
||||
expected = -(target * F.log_softmax(logits.detach()[0, 0], 0)[[0, 2, 3]]).sum()
|
||||
torch.testing.assert_close(result.full_vocab_loss, expected)
|
||||
assert logits.grad is not None
|
||||
assert logits.grad[0, 0, 0] != 0
|
||||
assert logits.grad[0, 0, 2] != 0
|
||||
|
||||
|
||||
def test_question_then_example_average_applies_source_weights():
|
||||
examples = [
|
||||
_example(
|
||||
_question(0, (0, 1), HardLabel(0)),
|
||||
_question(1, (2, 3), HardLabel(1)),
|
||||
weight=2.0,
|
||||
),
|
||||
_single(0, (0, 1, 2, 3), HardLabel(2), weight=0.5),
|
||||
]
|
||||
result = _loss(
|
||||
torch.zeros(2, 2, 4),
|
||||
examples,
|
||||
torch.tensor([[True, True], [True, False]]),
|
||||
label_softmax="restricted",
|
||||
brier_weight=0,
|
||||
)
|
||||
expected = (2.0 * math.log(2) + 0.5 * math.log(4)) / 2
|
||||
torch.testing.assert_close(result.loss, torch.tensor(expected))
|
||||
|
||||
|
||||
def test_question_weights_scale_each_question_within_the_canvas_average():
|
||||
# Two questions on one canvas: weight 3 on the first, 1 on the second.
|
||||
examples = [
|
||||
_example(
|
||||
DecisionLabelQuestion(0, (0, 1), HardLabel(0), weight=3.0),
|
||||
DecisionLabelQuestion(1, (0, 1, 2, 3), HardLabel(1)),
|
||||
)
|
||||
]
|
||||
result = _loss(
|
||||
torch.zeros(1, 2, 4),
|
||||
examples,
|
||||
torch.tensor([[True, True]]),
|
||||
label_softmax="restricted",
|
||||
brier_weight=0,
|
||||
)
|
||||
expected = (3.0 * math.log(2) + 1.0 * math.log(4)) / 2
|
||||
torch.testing.assert_close(result.loss, torch.tensor(expected))
|
||||
# Weight 1 everywhere is the plain question mean.
|
||||
plain = _loss(
|
||||
torch.zeros(1, 2, 4),
|
||||
[
|
||||
_example(
|
||||
_question(0, (0, 1), HardLabel(0)),
|
||||
_question(1, (0, 1, 2, 3), HardLabel(1)),
|
||||
)
|
||||
],
|
||||
torch.tensor([[True, True]]),
|
||||
label_softmax="restricted",
|
||||
brier_weight=0,
|
||||
)
|
||||
torch.testing.assert_close(
|
||||
plain.loss, torch.tensor((math.log(2) + math.log(4)) / 2)
|
||||
)
|
||||
|
||||
|
||||
def test_mapping_label_weight_is_read_and_validated():
|
||||
from axolotl.integrations.diffusion_decision.loss import _question_weight
|
||||
|
||||
assert _question_weight({"kind": "hard", "gold_idx": 0}) == 1.0
|
||||
assert _question_weight({"kind": "dist", "probs": [1.0], "weight": 0.25}) == 0.25
|
||||
with pytest.raises(ValueError, match="label weight"):
|
||||
_question_weight({"kind": "hard", "gold_idx": 0, "weight": 0})
|
||||
with pytest.raises(ValueError, match="label weight"):
|
||||
_question_weight({"kind": "hard", "gold_idx": 0, "weight": float("nan")})
|
||||
|
||||
|
||||
def test_loss_only_backpropagates_through_supervised_label_positions():
|
||||
logits = torch.randn(1, 3, 5, requires_grad=True)
|
||||
mask = torch.tensor([[False, True, False]])
|
||||
result = _loss(logits, _single(1, (1, 3), HardLabel(0)), mask, brier_weight=0)
|
||||
result.loss.backward()
|
||||
|
||||
assert logits.grad is not None
|
||||
assert not torch.count_nonzero(logits.grad[0, 0])
|
||||
assert torch.count_nonzero(logits.grad[0, 1])
|
||||
assert not torch.count_nonzero(logits.grad[0, 2])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("target", "error"),
|
||||
[
|
||||
({"kind": "hard", "gold_idx": -1}, "gold_idx"),
|
||||
({"kind": "dist", "probs": [0.2, 0.2]}, "sum to one"),
|
||||
({"kind": "set", "allowed_set": [0, 0]}, "unique"),
|
||||
({"kind": "other"}, "unknown"),
|
||||
],
|
||||
)
|
||||
def test_mapping_target_contracts(target, error):
|
||||
with pytest.raises(ValueError, match=error):
|
||||
label_target_from_mapping(target)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("example", "kwargs", "error", "match"),
|
||||
[
|
||||
(_single(1, (0, 1), _dist((0.5, 0.5))), {}, RuntimeError, "supervised"),
|
||||
(_single(0, (0, 1), _dist((1.0,))), {}, ValueError, "align"),
|
||||
(
|
||||
_example(
|
||||
_question(0, (0, 1), HardLabel(0)), _question(0, (1, 2), HardLabel(1))
|
||||
),
|
||||
{},
|
||||
ValueError,
|
||||
"distinct",
|
||||
),
|
||||
(
|
||||
_single(0, (0, 1), _dist((0.75,), (1,), 0.25)),
|
||||
{"label_softmax": "both"},
|
||||
ValueError,
|
||||
"requires label_softmax=full",
|
||||
),
|
||||
(_single(True, (0, 1), HardLabel(0)), {}, ValueError, None),
|
||||
(_single(0, (True, 1), HardLabel(0)), {}, ValueError, None),
|
||||
(_single(0, (0, 1), HardLabel(True)), {}, ValueError, None),
|
||||
(_single(0, (0, 1), SetLabel((True,))), {}, ValueError, None),
|
||||
],
|
||||
ids=[
|
||||
"unsupervised-label-position",
|
||||
"misaligned-distribution-target",
|
||||
"duplicate-question-positions",
|
||||
"sparse-residual-needs-full-softmax",
|
||||
"boolean-position",
|
||||
"boolean-allowed-id",
|
||||
"boolean-gold-index",
|
||||
"boolean-set-member",
|
||||
],
|
||||
)
|
||||
def test_loss_rejects_invalid_examples(example, kwargs, error, match):
|
||||
with pytest.raises(error, match=match):
|
||||
_loss(torch.zeros(1, 2, 4), example, torch.tensor([[True, False]]), **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("left", "right", "fields", "exact"),
|
||||
[
|
||||
(
|
||||
(_single(0, (0, 3), _dist((0.25, 0.75))), {**FULL, "brier_weight": 0.1}),
|
||||
(
|
||||
_single(0, (0, 3), _dist((0.25, 0.75))),
|
||||
{**FULL, "brier_weight": 0.1, "hard_label_smoothing": 0.1},
|
||||
),
|
||||
("loss",),
|
||||
False,
|
||||
),
|
||||
(
|
||||
(_single(0, (0, 3), _dist((0.0, 1.0))), FULL),
|
||||
(
|
||||
_single(0, (0, 3), _dist((0.0, 1.0))),
|
||||
{**FULL, "hard_label_smoothing": 0.0},
|
||||
),
|
||||
("loss",),
|
||||
False,
|
||||
),
|
||||
(
|
||||
(
|
||||
_single(0, (0, 2, 4), HardLabel(1)),
|
||||
{**FULL, "brier_weight": 0.1, "hard_label_smoothing": 0.1},
|
||||
),
|
||||
(
|
||||
_single(0, (0, 2, 4), HardLabel(1, smoothing=0.1)),
|
||||
{**FULL, "brier_weight": 0.1, "hard_label_smoothing": 0.0},
|
||||
),
|
||||
("loss", "full_vocab_loss", "brier_loss"),
|
||||
True,
|
||||
),
|
||||
(
|
||||
(_single(0, (1, 2), _dist((0.0, 1.0))), {"brier_weight": 0.0}),
|
||||
(_single(0, (1, 2), HardLabel(1)), {"brier_weight": 0.0}),
|
||||
("loss", "full_vocab_loss"),
|
||||
False,
|
||||
),
|
||||
],
|
||||
ids=[
|
||||
"hard-smoothing-leaves-soft-distribution-unchanged",
|
||||
"zero-hard-smoothing-preserves-one-hot-distribution",
|
||||
"per-target-smoothing-matches-global-and-keeps-one-hot-brier",
|
||||
"one-hot-distribution-full-ce-matches-hard-label",
|
||||
],
|
||||
)
|
||||
def test_equivalent_objectives_produce_the_same_loss(left, right, fields, exact):
|
||||
logits = _row(0.2, -0.7, 1.3, 0.4, 0.9, dtype=torch.float64)
|
||||
tolerance = {"rtol": 0, "atol": 0} if exact else {}
|
||||
_assert_fields_close(
|
||||
_loss(logits, left[0], **left[1]),
|
||||
_loss(logits, right[0], **right[1]),
|
||||
fields,
|
||||
**tolerance,
|
||||
)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Decision manifest compatibility and runtime-cost coverage."""
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.diffusion_decision.manifest import (
|
||||
DecisionManifest,
|
||||
build_decision_manifest,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
|
||||
class _ModelWithoutParameterTraversal:
|
||||
config = SimpleNamespace(mask_token_id=100, _commit_hash="resolved-revision")
|
||||
|
||||
def named_parameters(self):
|
||||
raise AssertionError("manifest generation must not traverse model parameters")
|
||||
|
||||
|
||||
def _manifest_config():
|
||||
return DictDefault(
|
||||
{
|
||||
"base_model": "nvidia/Nemotron-Labs-Diffusion-3B",
|
||||
"revision_of_model": "requested-revision",
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"diffusion_lm": {"canvas_width": 128, "mask_token_id": 100},
|
||||
"diffusion_decision": {
|
||||
"layout": "prompt_slots",
|
||||
"latent": {"mode": "none", "num_slots": 0},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_manifest_build_does_not_traverse_model_parameters():
|
||||
manifest = build_decision_manifest(
|
||||
_manifest_config(), model=_ModelWithoutParameterTraversal()
|
||||
)
|
||||
|
||||
assert manifest.initial_common_adapter_sha256 is None
|
||||
assert manifest.initial_common_adapter_fingerprint_reason is None
|
||||
assert manifest.initial_common_adapter_devices == []
|
||||
assert manifest.initial_common_adapter_dtypes == []
|
||||
assert manifest.initial_common_adapter_tensor_count == 0
|
||||
|
||||
|
||||
def test_manifest_parses_legacy_initial_adapter_fingerprint_fields():
|
||||
manifest = build_decision_manifest(_manifest_config())
|
||||
legacy = manifest.model_dump(mode="json") | {
|
||||
"initial_common_adapter_sha256": "a" * 64,
|
||||
"initial_common_adapter_fingerprint_reason": None,
|
||||
"initial_common_adapter_devices": ["cuda:0"],
|
||||
"initial_common_adapter_dtypes": ["torch.bfloat16"],
|
||||
"initial_common_adapter_tensor_count": 2,
|
||||
}
|
||||
|
||||
restored = DecisionManifest.model_validate_json(json.dumps(legacy))
|
||||
|
||||
assert restored.initial_common_adapter_sha256 == "a" * 64
|
||||
assert restored.initial_common_adapter_devices == ["cuda:0"]
|
||||
assert restored.initial_common_adapter_dtypes == ["torch.bfloat16"]
|
||||
assert restored.initial_common_adapter_tensor_count == 2
|
||||
|
||||
|
||||
def test_manifest_drops_retired_read_protocol_fields():
|
||||
manifest = build_decision_manifest(_manifest_config())
|
||||
legacy = manifest.model_dump(mode="json")
|
||||
legacy["noise_read_protocol"] |= {"reader": "hf", "noise_draws": 2}
|
||||
|
||||
restored = DecisionManifest.model_validate_json(json.dumps(legacy))
|
||||
|
||||
assert restored == manifest
|
||||
@@ -0,0 +1,251 @@
|
||||
from collections import Counter
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.mixture import (
|
||||
DeterministicMixtureSampler,
|
||||
prepare_source_pools,
|
||||
)
|
||||
|
||||
|
||||
def _record(source, group, state, questions=1):
|
||||
return {
|
||||
"source": source,
|
||||
"group": group,
|
||||
"state": state,
|
||||
"questions": {str(index): {} for index in range(questions)},
|
||||
}
|
||||
|
||||
|
||||
def test_prepare_source_pools_decontaminates_and_caps_each_source_deterministically():
|
||||
training = [
|
||||
_record("alpha", "alpha-0", "keep-a"),
|
||||
_record("alpha", "alpha-1", "keep-b"),
|
||||
_record("alpha", "alpha-2", "overlap"),
|
||||
_record("beta", "beta-0", "keep-c"),
|
||||
]
|
||||
evaluation = [_record("eval", "eval-0", "overlap")]
|
||||
|
||||
first = prepare_source_pools(
|
||||
training, evaluation, max_examples_per_source={"alpha": 1}, seed=7
|
||||
)
|
||||
second = prepare_source_pools(
|
||||
training, evaluation, max_examples_per_source={"alpha": 1}, seed=7
|
||||
)
|
||||
|
||||
assert first.records == second.records
|
||||
assert set(first.records) == {"alpha", "beta"}
|
||||
assert len(first.records["alpha"]) == 1
|
||||
assert first.counts == {
|
||||
"input": 4,
|
||||
"state_overlap": 1,
|
||||
"family_overlap": 0,
|
||||
"kept": 3,
|
||||
"capped": 1,
|
||||
"sources": 2,
|
||||
}
|
||||
assert all(
|
||||
record["state"] != "overlap"
|
||||
for pool in first.records.values()
|
||||
for record in pool
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_source_pools_removes_training_families_held_out_for_evaluation():
|
||||
training = [
|
||||
_record("alpha", "shared-family", "train-only"),
|
||||
_record("beta", "beta-family", "keep"),
|
||||
]
|
||||
evaluation = [_record("alpha", "shared-family", "eval-only")]
|
||||
|
||||
prepared = prepare_source_pools(training, evaluation)
|
||||
|
||||
assert set(prepared.records) == {"beta"}
|
||||
assert prepared.counts["family_overlap"] == 1
|
||||
|
||||
|
||||
def test_explicit_weight_draw_shares_match_within_two_percent_over_ten_thousand():
|
||||
sampler = DeterministicMixtureSampler(
|
||||
{"a": tuple(range(3)), "b": tuple(range(5)), "c": tuple(range(7))},
|
||||
weights={"a": 0.5, "b": 0.3, "c": 0.2},
|
||||
seed=19,
|
||||
)
|
||||
|
||||
counts = Counter(sample.source for sample in sampler.draw(10_000))
|
||||
|
||||
for source, expected in sampler.probabilities.items():
|
||||
assert abs(counts[source] / 10_000 - expected) <= 0.02
|
||||
|
||||
|
||||
def test_temperature_uses_capped_source_sizes():
|
||||
sampler = DeterministicMixtureSampler(
|
||||
{"small": tuple(range(4)), "large": tuple(range(16))}, temperature=0.5
|
||||
)
|
||||
|
||||
assert sampler.probabilities == {
|
||||
"large": pytest.approx(2 / 3),
|
||||
"small": pytest.approx(1 / 3),
|
||||
}
|
||||
|
||||
|
||||
def test_probability_normalization_handles_finite_extreme_explicit_weights():
|
||||
sampler = DeterministicMixtureSampler(
|
||||
{"a": (1,), "b": (2,)}, weights={"a": 1e308, "b": 1e308}
|
||||
)
|
||||
|
||||
assert sampler.probabilities == {"a": 0.5, "b": 0.5}
|
||||
|
||||
|
||||
def test_temperature_zero_is_uniform_and_large_finite_temperature_is_stable():
|
||||
uniform = DeterministicMixtureSampler(
|
||||
{"small": (1,), "large": tuple(range(16))}, temperature=0
|
||||
)
|
||||
stable = DeterministicMixtureSampler(
|
||||
{"small": (1,), "large": (2,)}, temperature=1000
|
||||
)
|
||||
|
||||
assert uniform.probabilities == {"large": 0.5, "small": 0.5}
|
||||
assert all(0 < probability < 1 for probability in stable.probabilities.values())
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pools", "weights", "temperature"),
|
||||
[
|
||||
(
|
||||
{"small": (1,), "large": tuple(range(2))},
|
||||
{"small": 1e-308, "large": 1e308},
|
||||
None,
|
||||
),
|
||||
({"small": (1,), "large": tuple(range(2))}, None, 2000),
|
||||
({"small": (1,), "large": tuple(range(16))}, None, 1e308),
|
||||
],
|
||||
)
|
||||
def test_probability_construction_rejects_source_underflow_or_overflow(
|
||||
pools, weights, temperature
|
||||
):
|
||||
with pytest.raises(ValueError, match="underflowed|nonfinite"):
|
||||
DeterministicMixtureSampler(pools, weights=weights, temperature=temperature)
|
||||
|
||||
|
||||
def test_stratified_batches_include_sources_with_at_least_one_expected_slot():
|
||||
sampler = DeterministicMixtureSampler(
|
||||
{source: tuple(range(3)) for source in ("a", "b", "c", "d")},
|
||||
weights={"a": 0.5, "b": 0.25, "c": 0.125, "d": 0.125},
|
||||
seed=3,
|
||||
)
|
||||
|
||||
batches = list(sampler.batches(8, 30, stratified=True))
|
||||
|
||||
assert all(len(batch) == 8 for batch in batches)
|
||||
assert all(
|
||||
{sample.source for sample in batch} == {"a", "b", "c", "d"} for batch in batches
|
||||
)
|
||||
assert all(
|
||||
Counter(sample.source for sample in batch) == {"a": 4, "b": 2, "c": 1, "d": 1}
|
||||
for batch in batches
|
||||
)
|
||||
|
||||
|
||||
def test_fractional_stratification_converges_to_source_probabilities():
|
||||
sampler = DeterministicMixtureSampler(
|
||||
{source: tuple(range(3)) for source in ("a", "b", "c")},
|
||||
weights={"a": 0.6, "b": 0.25, "c": 0.15},
|
||||
seed=3,
|
||||
)
|
||||
|
||||
counts = Counter(
|
||||
sample.source
|
||||
for batch in sampler.batches(4, 2_500, stratified=True)
|
||||
for sample in batch
|
||||
)
|
||||
|
||||
for source, expected in sampler.probabilities.items():
|
||||
assert abs(counts[source] / 10_000 - expected) <= 0.02
|
||||
|
||||
|
||||
def test_seed_reproduces_draws_and_stratified_batches():
|
||||
pools = {"a": tuple(range(4)), "b": tuple(range(6))}
|
||||
first = DeterministicMixtureSampler(pools, weights={"a": 0.4, "b": 0.6}, seed=11)
|
||||
second = DeterministicMixtureSampler(pools, weights={"a": 0.4, "b": 0.6}, seed=11)
|
||||
|
||||
assert first.draw(40) == second.draw(40)
|
||||
assert list(first.batches(5, 20)) == list(second.batches(5, 20))
|
||||
|
||||
|
||||
def test_epoch_batches_cover_every_capped_source_record_before_stopping():
|
||||
sampler = DeterministicMixtureSampler(
|
||||
{"small": tuple(range(2)), "large": tuple(range(7))},
|
||||
weights={"small": 0.5, "large": 0.5},
|
||||
seed=11,
|
||||
)
|
||||
|
||||
batches = list(sampler.epoch_batches(4))
|
||||
records = {
|
||||
source: {
|
||||
sample.record
|
||||
for batch in batches
|
||||
for sample in batch
|
||||
if sample.source == source
|
||||
}
|
||||
for source in ("small", "large")
|
||||
}
|
||||
|
||||
assert all(len(batch) == 4 for batch in batches)
|
||||
assert records == {"small": {0, 1}, "large": set(range(7))}
|
||||
|
||||
|
||||
def test_non_stratified_epoch_is_seeded_and_covers_every_source_record():
|
||||
pools = {"a": tuple(range(2)), "b": tuple(range(5))}
|
||||
first = DeterministicMixtureSampler(pools, weights={"a": 0.2, "b": 0.8}, seed=7)
|
||||
second = DeterministicMixtureSampler(pools, weights={"a": 0.2, "b": 0.8}, seed=7)
|
||||
|
||||
batches = list(first.epoch_batches(3, stratified=False))
|
||||
assert batches == list(second.epoch_batches(3, stratified=False))
|
||||
assert all(len(batch) == 3 for batch in batches)
|
||||
assert {
|
||||
(sample.source, sample.record) for batch in batches for sample in batch
|
||||
} >= {("a", 0), ("a", 1), *{("b", value) for value in range(5)}}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pools", "weights", "temperature", "error"),
|
||||
[
|
||||
({}, None, None, "at least one"),
|
||||
({"a": ()}, None, None, "empty"),
|
||||
({"a": (1,)}, {"missing": 1.0}, None, "every nonempty"),
|
||||
({"a": (1,)}, {"a": 1.0}, 0.5, "or temperature"),
|
||||
({"a": (1,)}, {"a": 0.0}, None, "positive"),
|
||||
],
|
||||
)
|
||||
def test_sampler_rejects_empty_or_invalid_source_definitions(
|
||||
pools, weights, temperature, error
|
||||
):
|
||||
with pytest.raises(ValueError, match=error):
|
||||
DeterministicMixtureSampler(pools, weights=weights, temperature=temperature)
|
||||
|
||||
|
||||
def test_examples_remain_whole_records_when_sampled():
|
||||
record = _record("alpha", "family", "state", questions=3)
|
||||
sampler = DeterministicMixtureSampler({"alpha": (record,)}, seed=2)
|
||||
|
||||
sample = sampler.draw(1)[0]
|
||||
|
||||
assert sample.source == "alpha"
|
||||
assert sample.record is record
|
||||
assert len(sample.record["questions"]) == 3
|
||||
|
||||
|
||||
def test_prepare_rejects_cap_for_source_removed_by_decontamination():
|
||||
training = [_record("alpha", "alpha", "overlap")]
|
||||
evaluation = [_record("eval", "eval", "overlap")]
|
||||
|
||||
with pytest.raises(ValueError, match="unavailable"):
|
||||
prepare_source_pools(training, evaluation, max_examples_per_source={"alpha": 1})
|
||||
|
||||
|
||||
def test_prepare_rejects_when_decontamination_removes_every_source():
|
||||
training = [_record("alpha", "alpha", "overlap")]
|
||||
evaluation = [_record("eval", "eval", "overlap")]
|
||||
|
||||
with pytest.raises(ValueError, match="no nonempty"):
|
||||
prepare_source_pools(training, evaluation)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""CUDA smoke coverage for native Nemotron free-slot decision recurrence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from peft import LoraConfig, TaskType, get_peft_model
|
||||
from transformers.dynamic_module_utils import get_class_from_dynamic_module
|
||||
|
||||
from axolotl.core.trainers.diffusion_lm.backends.full_sequence import (
|
||||
FullSequenceBackend,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.loss import (
|
||||
DecisionLabelExample,
|
||||
DecisionLabelQuestion,
|
||||
HardLabel,
|
||||
)
|
||||
from axolotl.model_support import DiffusionLayout, LogitAlignment
|
||||
from axolotl.model_support.nemotron_diffusion.compat import (
|
||||
resolve_nemotron_model_class,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
MultistepTrainerHarness,
|
||||
trainer_spec,
|
||||
)
|
||||
from tests.native_source_fixtures import native_source_fixture_path
|
||||
|
||||
|
||||
def _model(device: torch.device):
|
||||
source = native_source_fixture_path("nemotron")
|
||||
if source is None:
|
||||
pytest.skip("native Nemotron source fixture unavailable")
|
||||
config_class = get_class_from_dynamic_module(
|
||||
"configuration_nemotron_labs_diffusion.NemotronLabsDiffusionConfig",
|
||||
str(source),
|
||||
local_files_only=True,
|
||||
)
|
||||
config = config_class(
|
||||
vocab_size=128,
|
||||
hidden_size=256,
|
||||
intermediate_size=512,
|
||||
num_hidden_layers=1,
|
||||
num_attention_heads=4,
|
||||
num_key_value_heads=2,
|
||||
head_dim=64,
|
||||
max_position_embeddings=64,
|
||||
mask_token_id=100,
|
||||
dlm_paradigm="bidirectional",
|
||||
rope_parameters={
|
||||
"llama_4_scaling_beta": 1.0,
|
||||
"original_max_position_embeddings": 1,
|
||||
},
|
||||
use_cache=False,
|
||||
)
|
||||
config._attn_implementation = "eager"
|
||||
base = resolve_nemotron_model_class(str(source))(config).to(
|
||||
device=device, dtype=torch.bfloat16
|
||||
)
|
||||
traces: list[tuple[torch.Tensor, torch.Tensor]] = []
|
||||
original_forward = base.forward
|
||||
|
||||
def traced_forward(*args, **kwargs):
|
||||
result = original_forward(*args, **kwargs)
|
||||
traces.append(
|
||||
(kwargs["input_ids"].detach().clone(), result.logits.detach().clone())
|
||||
)
|
||||
return result
|
||||
|
||||
base.forward = traced_forward
|
||||
model = get_peft_model(
|
||||
base,
|
||||
LoraConfig(
|
||||
task_type=TaskType.CAUSAL_LM,
|
||||
target_modules=["q_proj"],
|
||||
r=2,
|
||||
lora_alpha=2,
|
||||
),
|
||||
).train()
|
||||
return model, traces, source
|
||||
|
||||
|
||||
def _inputs(device: torch.device) -> dict[str, object]:
|
||||
return {
|
||||
"input_ids": torch.tensor([[1, 7, 11, 8, 2, 3, 9, 12, 10, 4]], device=device),
|
||||
"document_ids": torch.tensor([[0, 0, 0, 0, 0, 1, 1, 1, 1, 1]], device=device),
|
||||
"semantic_validity": torch.ones((1, 10), dtype=torch.bool, device=device),
|
||||
"position_ids": torch.tensor([[0, 1, 2, 3, 4, 0, 1, 2, 3, 4]], device=device),
|
||||
"canvas_corruptible_mask": torch.tensor(
|
||||
[[False, False, True, False, False, False, False, True, False, False]],
|
||||
device=device,
|
||||
),
|
||||
"canvas_input_pinned_mask": torch.tensor(
|
||||
[[False, False, False, True, True, True, False, False, True, True]],
|
||||
device=device,
|
||||
),
|
||||
"canvas_update_mask": torch.zeros((1, 10), dtype=torch.bool, device=device),
|
||||
"decision_slot_mask": torch.tensor(
|
||||
[[False, True, False, True, False, False, True, False, False, False]],
|
||||
device=device,
|
||||
),
|
||||
"canvas_loss_mask": torch.tensor(
|
||||
[[False, False, True, False, False, False, False, True, False, False]],
|
||||
device=device,
|
||||
),
|
||||
"decision_examples": (
|
||||
DecisionLabelExample(
|
||||
questions=(DecisionLabelQuestion(0, (1, 2, 3), HardLabel(0)),)
|
||||
),
|
||||
DecisionLabelExample(
|
||||
questions=(DecisionLabelQuestion(0, (4, 5, 6), HardLabel(1)),)
|
||||
),
|
||||
),
|
||||
"decision_question_mask": torch.tensor([[True], [True]], device=device),
|
||||
"decision_supervision_mask": torch.tensor([[True], [True]], device=device),
|
||||
"decision_label_rows": torch.tensor([[0], [0]], device=device),
|
||||
"decision_label_positions": torch.tensor([[2], [7]], device=device),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [2, 3])
|
||||
@pytest.mark.skipif(not torch.cuda.is_available(), reason="requires CUDA")
|
||||
def test_native_nemotron_varlen_free_slots_evolve_without_slot_loss(monkeypatch, steps):
|
||||
from axolotl.core.trainers.diffusion_lm import varlen
|
||||
|
||||
device = torch.device("cuda")
|
||||
torch.manual_seed(314159)
|
||||
torch.cuda.reset_peak_memory_stats(device)
|
||||
kernel_calls = []
|
||||
original_kernel = varlen.varlen_attn
|
||||
|
||||
def traced_kernel(*args, **kwargs):
|
||||
kernel_calls.append(args[3].detach().clone())
|
||||
return original_kernel(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(varlen, "varlen_attn", traced_kernel)
|
||||
model, traces, source = _model(device)
|
||||
trainer = MultistepTrainerHarness(
|
||||
trainer_spec(DiffusionLayout.FULL_SEQUENCE, LogitAlignment.ALIGNED),
|
||||
k_max=steps,
|
||||
sampled_steps=steps,
|
||||
decision={
|
||||
"latent": {
|
||||
"mode": "free",
|
||||
"num_slots": 1,
|
||||
"free_update_policy": "argmax",
|
||||
}
|
||||
},
|
||||
)
|
||||
trainer._full_sequence_backend = lambda: FullSequenceBackend(
|
||||
mask_token_id=100, attention_backend="varlen"
|
||||
)
|
||||
inputs = _inputs(device)
|
||||
loss = trainer.compute_loss(model, inputs)
|
||||
loss.backward()
|
||||
torch.cuda.synchronize(device)
|
||||
|
||||
slots = torch.tensor([1, 6], device=device)
|
||||
pinned_slots = torch.tensor([3], device=device)
|
||||
held = torch.tensor([0, 2, 3, 4, 5, 7, 8, 9], device=device)
|
||||
metadata = FullSequenceBackend(mask_token_id=100, attention_backend="varlen").pack(
|
||||
inputs["input_ids"], inputs["document_ids"], inputs["semantic_validity"]
|
||||
)["diffusion_varlen"]
|
||||
assert metadata is not None
|
||||
assert metadata.cu_seqlens.tolist() == [0, 5, 10]
|
||||
assert len(kernel_calls) == steps
|
||||
assert all(cu_seqlens.tolist() == [0, 5, 10] for cu_seqlens in kernel_calls)
|
||||
assert len(traces) == steps
|
||||
assert all(
|
||||
torch.equal(state[:, held], traces[0][0][:, held]) for state, _ in traces
|
||||
)
|
||||
assert not torch.any(inputs["decision_slot_mask"] & inputs["canvas_loss_mask"])
|
||||
assert all(
|
||||
torch.equal(state[:, pinned_slots], traces[0][0][:, pinned_slots])
|
||||
for state, _ in traces
|
||||
)
|
||||
for (_state, logits), (next_state, _) in zip(traces[:-1], traces[1:], strict=True):
|
||||
torch.testing.assert_close(next_state[:, slots], logits.argmax(-1)[:, slots])
|
||||
assert torch.any(traces[1][0][:, slots] != traces[0][0][:, slots])
|
||||
lora_grads = [
|
||||
parameter.grad
|
||||
for name, parameter in model.named_parameters()
|
||||
if ".lora_" in name and parameter.requires_grad
|
||||
]
|
||||
assert torch.isfinite(loss)
|
||||
assert lora_grads
|
||||
assert all(
|
||||
gradient is not None and torch.isfinite(gradient).all()
|
||||
for gradient in lora_grads
|
||||
)
|
||||
assert any(torch.count_nonzero(gradient) for gradient in lora_grads)
|
||||
print(
|
||||
"native_nemotron_free_varlen"
|
||||
f" steps={steps} fixture={source} torch={torch.__version__}"
|
||||
f" peak_bytes={torch.cuda.max_memory_allocated(device)}"
|
||||
)
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Option permutations preserve the meanings of all typed targets."""
|
||||
|
||||
from copy import deepcopy
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.permutation import permute_record
|
||||
|
||||
|
||||
def record(target):
|
||||
return {
|
||||
"source": "fixture",
|
||||
"group": "group",
|
||||
"id": "row",
|
||||
"state": {"seen": "objects"},
|
||||
"questions": {
|
||||
"choice": {
|
||||
"type": "choice",
|
||||
"options": [
|
||||
{"name": name, "description": f"description of {name}"}
|
||||
for name in ("apple", "pear", "plum", "orange")
|
||||
],
|
||||
},
|
||||
"binary": {"type": "noul"},
|
||||
},
|
||||
"labels": {"choice": target, "binary": {"kind": "hard", "gold_idx": 1}},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"target",
|
||||
[
|
||||
{"kind": "hard", "gold_idx": 2},
|
||||
{"kind": "dist", "probs": [0.1, 0.2, 0.3, 0.4]},
|
||||
{
|
||||
"kind": "dist",
|
||||
"candidate_indices": [0, 2],
|
||||
"candidate_ids": ["apple", "plum"],
|
||||
"probs": [0.1, 0.6],
|
||||
"other_probability": 0.3,
|
||||
},
|
||||
{"kind": "set", "allowed_set": [0, 2]},
|
||||
],
|
||||
)
|
||||
def test_semantic_targets_preserved_for_every_seed(target):
|
||||
original = record(target)
|
||||
untouched = deepcopy(original)
|
||||
orders = set()
|
||||
question_orders = set()
|
||||
for seed in range(20):
|
||||
result = permute_record(original, seed=seed)
|
||||
assert result == permute_record(original, seed=seed)
|
||||
options = result["questions"]["choice"]["options"]
|
||||
names = [option["name"] for option in options]
|
||||
assert all(
|
||||
option["description"] == f"description of {option['name']}"
|
||||
for option in options
|
||||
)
|
||||
remapped = result["labels"]["choice"]
|
||||
if target["kind"] == "hard":
|
||||
assert names[remapped["gold_idx"]] == "plum"
|
||||
elif target["kind"] == "dist":
|
||||
if "candidate_indices" in target:
|
||||
assert {
|
||||
names[index]: probability
|
||||
for index, probability in zip(
|
||||
remapped["candidate_indices"], remapped["probs"], strict=True
|
||||
)
|
||||
} == {"apple": 0.1, "plum": 0.6}
|
||||
assert remapped["other_probability"] == 0.3
|
||||
assert {
|
||||
names[index]: identity
|
||||
for index, identity in zip(
|
||||
remapped["candidate_indices"],
|
||||
remapped["candidate_ids"],
|
||||
strict=True,
|
||||
)
|
||||
} == {"apple": "apple", "plum": "plum"}
|
||||
else:
|
||||
assert dict(zip(names, remapped["probs"], strict=True)) == {
|
||||
"apple": 0.1,
|
||||
"pear": 0.2,
|
||||
"plum": 0.3,
|
||||
"orange": 0.4,
|
||||
}
|
||||
else:
|
||||
assert {names[index] for index in remapped["allowed_set"]} == {
|
||||
"apple",
|
||||
"plum",
|
||||
}
|
||||
assert result["labels"]["binary"] == untouched["labels"]["binary"]
|
||||
assert result["state"] == untouched["state"]
|
||||
orders.add(tuple(names))
|
||||
question_orders.add(tuple(result["questions"]))
|
||||
assert original == untouched
|
||||
assert len(orders) > 1
|
||||
assert len(question_orders) == 2
|
||||
|
||||
|
||||
def test_question_input_order_does_not_change_seeded_result():
|
||||
first = record({"kind": "hard", "gold_idx": 0})
|
||||
second = deepcopy(first)
|
||||
second["questions"] = dict(reversed(list(second["questions"].items())))
|
||||
assert permute_record(first, seed=5) == permute_record(second, seed=5)
|
||||
|
||||
|
||||
def test_expanded_codebook_is_preserved_during_permutation():
|
||||
names = list("ABCDEFGHIJKLMNOPQRSTUVWXYZab")
|
||||
original = {
|
||||
"source": "fixture",
|
||||
"group": "group",
|
||||
"id": "expanded-choice",
|
||||
"state": {"seen": "objects"},
|
||||
"questions": {
|
||||
"choice": {
|
||||
"type": "choice",
|
||||
"options": [
|
||||
{"name": name, "description": f"description of {name}"}
|
||||
for name in names
|
||||
],
|
||||
}
|
||||
},
|
||||
"labels": {"choice": {"kind": "dist", "probs": [1 / 28] * 28}},
|
||||
}
|
||||
|
||||
result = permute_record(original, seed=7, codebook="expanded52")
|
||||
|
||||
assert len(result["questions"]["choice"]["options"]) == 28
|
||||
assert result["labels"]["choice"]["probs"] == [1 / 28] * 28
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Decision plugin configuration and registration tests."""
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from axolotl.integrations.diffusion_decision.args import (
|
||||
DiffusionDecisionArgs,
|
||||
DiffusionDecisionConfig,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.plugin import DiffusionDecisionPlugin
|
||||
|
||||
PLUGIN_PATH = "axolotl.integrations.diffusion_decision.DiffusionDecisionPlugin"
|
||||
|
||||
|
||||
def test_plugin_exposes_optional_nested_config():
|
||||
assert (
|
||||
DiffusionDecisionPlugin().get_input_args()
|
||||
== "axolotl.integrations.diffusion_decision.args.DiffusionDecisionArgs"
|
||||
)
|
||||
assert DiffusionDecisionArgs().diffusion_decision is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", ("thought_block", "prompt_slots"))
|
||||
def test_config_preserves_both_decision_layouts(layout):
|
||||
config = DiffusionDecisionConfig.model_validate(
|
||||
{
|
||||
"layout": layout,
|
||||
"read_fraction": 0.4,
|
||||
"mixture": {"weights": {"one": 1.0}},
|
||||
"labels": {"label_softmax": "both", "brier_weight": 0.1},
|
||||
"latent": {"mode": "learned", "num_slots": 8},
|
||||
}
|
||||
)
|
||||
assert config.layout == layout
|
||||
assert config.latent.mode == "learned"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"block,message",
|
||||
[
|
||||
({"latent": {"mode": "none", "num_slots": 1}}, "mode=none"),
|
||||
({"latent": {"token_ids": [-1]}}, "nonnegative"),
|
||||
({"carry": {"enabled": True}}, "carry is not wired"),
|
||||
({"mixture": {"weights": {"source": 0.0}}}, "finite and positive"),
|
||||
(
|
||||
{"mixture": {"weights": {"source": 1.0}, "temperature": 0.5}},
|
||||
"explicit weights or temperature",
|
||||
),
|
||||
({"mixture": {"max_examples_per_source": {"source": 0}}}, "positive caps"),
|
||||
({"labels": {"brier_weight": float("inf")}}, "finite"),
|
||||
({"read_fraction": float("inf")}, "finite"),
|
||||
],
|
||||
)
|
||||
def test_config_rejects_invalid_semantic_controls(block, message):
|
||||
with pytest.raises(ValidationError, match=message):
|
||||
DiffusionDecisionConfig.model_validate(block)
|
||||
|
||||
|
||||
def test_config_accepts_per_source_caps_without_numeric_union_comparison():
|
||||
config = DiffusionDecisionConfig.model_validate(
|
||||
{"mixture": {"max_examples_per_source": {"source": 2}}}
|
||||
)
|
||||
assert config.mixture.max_examples_per_source == {"source": 2}
|
||||
|
||||
|
||||
def test_labels_default_to_ce_weighting_and_accept_dft_only():
|
||||
assert DiffusionDecisionConfig().labels.full_ce_weighting == "ce"
|
||||
assert (
|
||||
DiffusionDecisionConfig.model_validate(
|
||||
{"labels": {"full_ce_weighting": "dft"}}
|
||||
).labels.full_ce_weighting
|
||||
== "dft"
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
DiffusionDecisionConfig.model_validate(
|
||||
{"labels": {"full_ce_weighting": "other"}}
|
||||
)
|
||||
|
||||
|
||||
def test_labels_accept_hard_label_smoothing_only_inside_unit_interval():
|
||||
assert (
|
||||
DiffusionDecisionConfig.model_validate(
|
||||
{"labels": {"hard_label_smoothing": 0.1}}
|
||||
).labels.hard_label_smoothing
|
||||
== 0.1
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
DiffusionDecisionConfig.model_validate(
|
||||
{"labels": {"hard_label_smoothing": 1.0}}
|
||||
)
|
||||
with pytest.raises(ValidationError, match="requires full_ce_weighting=ce"):
|
||||
DiffusionDecisionConfig.model_validate(
|
||||
{"labels": {"hard_label_smoothing": 0.1, "full_ce_weighting": "dft"}}
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_input_args_merge_into_config_schema(min_base_cfg, monkeypatch):
|
||||
from axolotl.integrations.base import PluginManager
|
||||
from axolotl.integrations.config import merge_input_args
|
||||
|
||||
manager = PluginManager.get_instance()
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"plugins",
|
||||
OrderedDict({PLUGIN_PATH: DiffusionDecisionPlugin()}),
|
||||
)
|
||||
_capabilities, config_cls = merge_input_args()
|
||||
config = config_cls(
|
||||
**(
|
||||
min_base_cfg
|
||||
| {
|
||||
"plugins": [PLUGIN_PATH],
|
||||
"diffusion_decision": {
|
||||
"layout": "prompt_slots",
|
||||
"latent": {"mode": "prompt", "num_slots": 4},
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert config.diffusion_decision.layout == "prompt_slots"
|
||||
assert config.diffusion_decision.latent.num_slots == 4
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout", ("thought_block", "prompt_slots"))
|
||||
def test_native_registration_is_layout_agnostic(layout):
|
||||
plugin = DiffusionDecisionPlugin()
|
||||
plugin.register(
|
||||
{
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
"diffusion_decision": {"layout": layout},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_registration_rejects_legacy_diffusion_compatibility():
|
||||
with pytest.raises(ValueError, match="native diffusion_lm"):
|
||||
DiffusionDecisionPlugin().register({"diffusion_decision": {}})
|
||||
with pytest.raises(ValueError, match="from_causal_lm"):
|
||||
DiffusionDecisionPlugin().register(
|
||||
{
|
||||
"diffusion_lm": {"from_causal_lm": True},
|
||||
"diffusion_decision": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_registration_allows_sample_packing_with_top_level_budget():
|
||||
DiffusionDecisionPlugin().register(
|
||||
{
|
||||
"sample_packing": True,
|
||||
"micro_batch_size": 2,
|
||||
"sequence_len": 2048,
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
"diffusion_decision": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_sample_packing_passes_sequence_len_times_micro_batch_capacity():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
decision_collator_for_config,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
_collator, kwargs = decision_collator_for_config(
|
||||
DictDefault(
|
||||
{
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"tokenizer": SimpleNamespace(pad_token_id=0),
|
||||
"sample_packing": True,
|
||||
"micro_batch_size": 3,
|
||||
"sequence_len": 128,
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert kwargs["physical_payload_capacity"] == 384
|
||||
|
||||
|
||||
def test_eval_packing_uses_the_training_physical_budget():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
decision_collator_for_config,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
_collator, kwargs = decision_collator_for_config(
|
||||
DictDefault(
|
||||
{
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"tokenizer": SimpleNamespace(pad_token_id=0),
|
||||
"sample_packing": True,
|
||||
"eval_sample_packing": True,
|
||||
"micro_batch_size": 16,
|
||||
"eval_batch_size": 8,
|
||||
"sequence_len": 128,
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
}
|
||||
),
|
||||
is_eval=True,
|
||||
)
|
||||
assert kwargs["physical_payload_capacity"] == 2048
|
||||
|
||||
|
||||
def test_fixed_logical_eval_uses_the_eval_physical_budget():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
decision_collator_for_config,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
_collator, kwargs = decision_collator_for_config(
|
||||
DictDefault(
|
||||
{
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"tokenizer": SimpleNamespace(pad_token_id=0),
|
||||
"sample_packing": False,
|
||||
"batch_flattening": True,
|
||||
"micro_batch_size": 16,
|
||||
"eval_batch_size": 8,
|
||||
"sequence_len": 128,
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
}
|
||||
),
|
||||
is_eval=True,
|
||||
)
|
||||
assert kwargs["physical_payload_capacity"] == 1024
|
||||
|
||||
|
||||
def test_registration_rejects_explicit_unflattened_microbatches():
|
||||
config = {
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
"diffusion_decision": {},
|
||||
"sample_packing": False,
|
||||
"eval_sample_packing": True,
|
||||
"micro_batch_size": 8,
|
||||
}
|
||||
config["batch_flattening"] = False
|
||||
with pytest.raises(ValueError, match="require batch_flattening: true"):
|
||||
DiffusionDecisionPlugin().register(config)
|
||||
|
||||
|
||||
def test_fixed_logical_batch_requires_capacity():
|
||||
config = {
|
||||
"diffusion_lm": {
|
||||
"from_causal_lm": False,
|
||||
},
|
||||
"diffusion_decision": {},
|
||||
"batch_flattening": True,
|
||||
"sample_packing": False,
|
||||
"micro_batch_size": 8,
|
||||
"sequence_len": 2048,
|
||||
}
|
||||
DiffusionDecisionPlugin().register(config)
|
||||
|
||||
|
||||
def test_fixed_logical_batch_passes_physical_capacity_to_decision_collator():
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
decision_collator_for_config,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
_collator, kwargs = decision_collator_for_config(
|
||||
DictDefault(
|
||||
{
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"tokenizer": SimpleNamespace(pad_token_id=0),
|
||||
"sample_packing": False,
|
||||
"batch_flattening": True,
|
||||
"micro_batch_size": 8,
|
||||
"sequence_len": 2048,
|
||||
"diffusion_lm": {
|
||||
"from_causal_lm": False,
|
||||
"canvas_width": 128,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
assert kwargs["physical_payload_capacity"] == 16384
|
||||
|
||||
|
||||
def test_native_nemotron_plugin_routes_through_causal_builder(
|
||||
min_base_cfg, monkeypatch
|
||||
):
|
||||
"""Exercise PluginManager and the causal builder with the resolved native profile."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.core.builders.causal import HFCausalTrainerBuilder
|
||||
from axolotl.integrations.base import PluginManager
|
||||
from axolotl.integrations.diffusion_decision.trainer import (
|
||||
DiffusionDecisionTrainer,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
DecisionTrainingCollator,
|
||||
)
|
||||
from axolotl.model_support import (
|
||||
DiffusionLayout,
|
||||
MaskTokenPolicy,
|
||||
get_model_support_for_cfg,
|
||||
resolve_model_support,
|
||||
)
|
||||
from axolotl.utils.config import validate_config
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
manager = PluginManager.get_instance()
|
||||
monkeypatch.setattr(
|
||||
manager,
|
||||
"plugins",
|
||||
OrderedDict({PLUGIN_PATH: DiffusionDecisionPlugin()}),
|
||||
)
|
||||
cfg = validate_config(
|
||||
DictDefault(
|
||||
min_base_cfg
|
||||
| {
|
||||
"plugins": [PLUGIN_PATH],
|
||||
"attn_implementation": "varlen",
|
||||
"diffusion_lm": {
|
||||
"from_causal_lm": False,
|
||||
"canvas_width": 128,
|
||||
"mask_token_id": 100,
|
||||
},
|
||||
"diffusion_decision": {"layout": "thought_block"},
|
||||
}
|
||||
)
|
||||
)
|
||||
cfg.model_config_type = "nemotron_labs_diffusion"
|
||||
cfg.tokenizer = SimpleNamespace(pad_token_id=0)
|
||||
|
||||
profile = resolve_model_support(get_model_support_for_cfg(cfg))
|
||||
assert profile is not None
|
||||
assert profile.diffusion is not None
|
||||
assert profile.diffusion.layout is DiffusionLayout.FULL_SEQUENCE
|
||||
assert profile.diffusion.mask_token_policy is MaskTokenPolicy.MODEL
|
||||
assert cfg.diffusion_lm.mask_token_id == 100
|
||||
|
||||
builder = HFCausalTrainerBuilder.__new__(HFCausalTrainerBuilder)
|
||||
builder.cfg = cfg
|
||||
builder.tokenizer = cfg.tokenizer
|
||||
assert builder._get_trainer_cls() is DiffusionDecisionTrainer
|
||||
|
||||
training_args = SimpleNamespace(
|
||||
pretraining=False,
|
||||
sample_packing=True,
|
||||
eval_sample_packing=False,
|
||||
)
|
||||
collator = builder.build_collator(
|
||||
training_args,
|
||||
padding=True,
|
||||
pad_to_multiple_of=8192,
|
||||
)
|
||||
eval_collator = builder.build_collator(
|
||||
training_args,
|
||||
is_eval=True,
|
||||
padding=True,
|
||||
pad_to_multiple_of=8192,
|
||||
)
|
||||
for candidate in (collator, eval_collator):
|
||||
assert isinstance(candidate, DecisionTrainingCollator)
|
||||
assert candidate.spec is profile.diffusion
|
||||
assert candidate.pad_token_id == 0
|
||||
|
||||
|
||||
def test_config_accepts_versioned_sampled_slot_policy():
|
||||
config = DiffusionDecisionConfig.model_validate(
|
||||
{"latent": {"mode": "pinned", "num_slots": 8, "sample_num_slots": True}}
|
||||
)
|
||||
|
||||
assert config.latent.sample_num_slots is True
|
||||
assert config.latent.num_slots == 8
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"latent,message",
|
||||
[
|
||||
(
|
||||
{"mode": "none", "sample_num_slots": True},
|
||||
"sample_num_slots requires a non-none",
|
||||
),
|
||||
(
|
||||
{"mode": "pinned", "sample_num_slots": True},
|
||||
"sample_num_slots requires num_slots",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_config_rejects_invalid_sampled_slot_bounds(latent, message):
|
||||
with pytest.raises(ValidationError, match=message):
|
||||
DiffusionDecisionConfig.model_validate({"latent": latent})
|
||||
|
||||
|
||||
def test_post_lora_merge_ignores_manifests_for_non_decision_runs(tmp_path):
|
||||
from axolotl.integrations.diffusion_decision.manifest import MANIFEST_FILENAME
|
||||
from axolotl.integrations.diffusion_decision.plugin import DiffusionDecisionPlugin
|
||||
|
||||
adapter, merged = tmp_path / "adapter", tmp_path / "merged"
|
||||
adapter.mkdir()
|
||||
merged.mkdir()
|
||||
(adapter / MANIFEST_FILENAME).write_text("{not a manifest")
|
||||
|
||||
DiffusionDecisionPlugin().post_lora_merge({}, str(adapter), str(merged))
|
||||
|
||||
assert not (merged / MANIFEST_FILENAME).exists()
|
||||
|
||||
|
||||
def test_config_caps_questions_per_canvas_at_the_template_limit():
|
||||
from axolotl.integrations.diffusion_decision.args import DiffusionDecisionConfig
|
||||
from axolotl.integrations.diffusion_decision.vendored.djev_template import (
|
||||
MAX_QUESTIONS,
|
||||
)
|
||||
|
||||
DiffusionDecisionConfig.model_validate({"max_questions_per_canvas": MAX_QUESTIONS})
|
||||
with pytest.raises(ValueError, match="max_questions_per_canvas"):
|
||||
DiffusionDecisionConfig.model_validate(
|
||||
{"max_questions_per_canvas": MAX_QUESTIONS + 1}
|
||||
)
|
||||
|
||||
|
||||
def test_collator_hook_defers_to_defaults_without_a_decision_block():
|
||||
from axolotl.integrations.diffusion_decision.plugin import DiffusionDecisionPlugin
|
||||
|
||||
assert DiffusionDecisionPlugin().get_collator_cls_and_kwargs({}) is None
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Preprocessing uses independent upstream fixtures with actual tokenizer IDs."""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.adapters import normalize_record
|
||||
from axolotl.integrations.diffusion_decision.adapters.jsonl import normalize_jsonl
|
||||
from axolotl.integrations.diffusion_decision.preprocessing import build_decision_canvas
|
||||
from axolotl.integrations.diffusion_decision.records import OrdinalMetadata
|
||||
from axolotl.integrations.diffusion_decision.slots import SlotInit
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
DJEV_FIXTURE,
|
||||
RecordedTokenizer,
|
||||
make_spec,
|
||||
)
|
||||
|
||||
|
||||
def inputs(family, index):
|
||||
reference = DJEV_FIXTURE["tokenizers"][family]
|
||||
case = reference["cases"][index]
|
||||
labels = {}
|
||||
for i, q in enumerate(case["parsed"]["questions"]):
|
||||
count = len(q["labels"])
|
||||
labels[q["id"]] = (
|
||||
{"kind": "hard", "gold_idx": 0}
|
||||
if i % 3 == 0
|
||||
else {"kind": "dist", "probs": [1 / count] * count}
|
||||
if i % 3 == 1
|
||||
else {"kind": "set", "allowed_set": [0, count - 1]}
|
||||
)
|
||||
record = {
|
||||
"source": "fixture",
|
||||
"group": "synthetic",
|
||||
"state": "observed",
|
||||
"questions": {
|
||||
q["id"]: {k: v for k, v in q.items() if k != "id"}
|
||||
for q in case["schema"]["questions"]
|
||||
},
|
||||
"labels": labels,
|
||||
}
|
||||
return reference, case, record
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["gemma", "dream"])
|
||||
@pytest.mark.parametrize("index", [0, 9, 10, 19])
|
||||
def test_exact_template_positions_noise_and_target_preservation(family, index):
|
||||
reference, case, record = inputs(family, index)
|
||||
kwargs = dict(
|
||||
scaffold_ids=case["head"],
|
||||
turn_close_id=106 if family == "gemma" else 151643,
|
||||
pad_id=0 if family == "gemma" else 151643,
|
||||
vocab_size=262144 if family == "gemma" else 152064,
|
||||
seed=23,
|
||||
)
|
||||
first = build_decision_canvas(
|
||||
RecordedTokenizer(reference["encodings"]), record, [1, 2], **kwargs
|
||||
)
|
||||
second = build_decision_canvas(
|
||||
RecordedTokenizer(reference["encodings"]), record, [1, 2], steps=2, **kwargs
|
||||
)
|
||||
base, slots = case["template"]
|
||||
expected = (
|
||||
base + [kwargs["turn_close_id"]] + [kwargs["pad_id"]] * (128 - len(base) - 1)
|
||||
)
|
||||
rng = random.Random(23)
|
||||
for slot in slots:
|
||||
expected[slot["pos"]] = rng.randrange(kwargs["vocab_size"])
|
||||
assert list(first.canvas_ids) == expected
|
||||
assert first.canvas_ids == second.canvas_ids
|
||||
assert first.template_length == len(base)
|
||||
assert first.targets == tuple(record["labels"].values())
|
||||
assert not any(first.pinned_mask)
|
||||
assert all(
|
||||
second.pinned_mask[i] == (i not in first.label_positions) for i in range(128)
|
||||
)
|
||||
assert all(first.semantic_mask) and not any(first.slot_mask)
|
||||
|
||||
|
||||
def test_prevalidated_record_matches_validated_canvas():
|
||||
reference, case, record = inputs("gemma", 0)
|
||||
kwargs = dict(
|
||||
scaffold_ids=case["head"],
|
||||
turn_close_id=106,
|
||||
pad_id=0,
|
||||
vocab_size=262144,
|
||||
seed=23,
|
||||
)
|
||||
tokenizer = RecordedTokenizer(reference["encodings"])
|
||||
expected = build_decision_canvas(tokenizer, record, [1, 2], **kwargs)
|
||||
actual = build_decision_canvas(
|
||||
tokenizer,
|
||||
normalize_jsonl(record),
|
||||
[1, 2],
|
||||
prevalidated_record=True,
|
||||
**kwargs,
|
||||
)
|
||||
assert actual == expected
|
||||
|
||||
|
||||
def test_absorbing_noise_and_invalid_soft_target():
|
||||
reference, case, record = inputs("dream", 2)
|
||||
kwargs = dict(
|
||||
scaffold_ids=case["head"],
|
||||
turn_close_id=151643,
|
||||
pad_id=151643,
|
||||
vocab_size=152064,
|
||||
noise_kind="absorbing",
|
||||
mask_token_id=151666,
|
||||
)
|
||||
result = build_decision_canvas(
|
||||
RecordedTokenizer(reference["encodings"]), record, [1], **kwargs
|
||||
)
|
||||
assert all(result.canvas_ids[pos] == 151666 for pos in result.label_positions)
|
||||
record["labels"]["1"]["probs"] = [0.1]
|
||||
with pytest.raises(ValueError, match="one probability"):
|
||||
build_decision_canvas(
|
||||
RecordedTokenizer(reference["encodings"]), record, [1], **kwargs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("steps", [2, 3])
|
||||
def test_generated_free_slots_remain_updateable_while_kstep_scaffold_is_pinned(steps):
|
||||
reference, case, record = inputs("gemma", 0)
|
||||
plan = SlotInit(
|
||||
"free",
|
||||
num_slots=2,
|
||||
vocab_size=262144,
|
||||
spec=make_spec(max_canvas=None, max_context=None),
|
||||
).build(seed=7)
|
||||
canvas = build_decision_canvas(
|
||||
RecordedTokenizer(reference["encodings"]),
|
||||
record,
|
||||
[1, 2],
|
||||
scaffold_ids=case["head"],
|
||||
turn_close_id=106,
|
||||
pad_id=0,
|
||||
vocab_size=262144,
|
||||
steps=steps,
|
||||
slot_plan=plan,
|
||||
)
|
||||
slots = [index for index, value in enumerate(canvas.slot_mask) if value]
|
||||
labels = set(canvas.label_positions)
|
||||
assert len(slots) == 2
|
||||
assert all(not canvas.pinned_mask[index] for index in slots)
|
||||
assert all(not canvas.pinned_mask[index] for index in labels)
|
||||
assert all(
|
||||
canvas.pinned_mask[index]
|
||||
for index in range(len(canvas.canvas_ids))
|
||||
if index not in labels and index not in slots
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("family", ["gemma", "dream"])
|
||||
def test_canvas_builds_source_prompt_from_validated_record(family):
|
||||
reference, case, record = inputs(family, 0)
|
||||
record["state"] = {"observed": "20°"}
|
||||
record["instructions"] = case["schema"]["instructions"]
|
||||
|
||||
class ChatTokenizer(RecordedTokenizer):
|
||||
def apply_chat_template(self, messages, **kwargs):
|
||||
assert messages == [
|
||||
{"role": "system", "content": case["system_text"]},
|
||||
{"role": "user", "content": '{"observed": "20°"}'},
|
||||
]
|
||||
assert kwargs == {
|
||||
"tokenize": True,
|
||||
"add_generation_prompt": True,
|
||||
"enable_thinking": False,
|
||||
}
|
||||
return {"input_ids": [1, 2, 3]}
|
||||
|
||||
result = build_decision_canvas(
|
||||
ChatTokenizer(reference["encodings"]),
|
||||
record,
|
||||
scaffold_ids=case["head"],
|
||||
turn_close_id=106 if family == "gemma" else 151643,
|
||||
pad_id=0 if family == "gemma" else 151643,
|
||||
vocab_size=262144 if family == "gemma" else 152064,
|
||||
)
|
||||
assert result.prompt_ids == (1, 2, 3)
|
||||
assert result.targets == tuple(record["labels"].values())
|
||||
|
||||
|
||||
def test_soft_score_canvas_opt_in_ordinal_metadata(monkeypatch):
|
||||
import axolotl.integrations.diffusion_decision.preprocessing as preprocessing
|
||||
|
||||
normalized = normalize_record(
|
||||
"jsonl",
|
||||
{
|
||||
"id": "soft-score",
|
||||
"source": "score_eval",
|
||||
"group": "soft-score",
|
||||
"state": {"evidence": "text"},
|
||||
"questions": {
|
||||
"q1": {
|
||||
"type": "score",
|
||||
"instructions": "",
|
||||
"levels": ["low", "medium", "high"],
|
||||
}
|
||||
},
|
||||
"labels": {"q1": {"kind": "dist", "probs": [0.2, 0.5, 0.3]}},
|
||||
},
|
||||
training=False,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
preprocessing,
|
||||
"resolve_template",
|
||||
lambda *_args, **_kwargs: (
|
||||
[9, 31, 11],
|
||||
[{"pos": 1, "label_ids": [31, 32, 33]}],
|
||||
),
|
||||
)
|
||||
kwargs = dict(
|
||||
prompt_ids=(1,),
|
||||
scaffold_ids=(),
|
||||
turn_close_id=2,
|
||||
pad_id=0,
|
||||
vocab_size=64,
|
||||
width=8,
|
||||
)
|
||||
default = build_decision_canvas(object(), normalized, **kwargs)
|
||||
assert default.ordinal_metadata == ()
|
||||
canvas = build_decision_canvas(
|
||||
object(), normalized, include_ordinal_metadata=True, **kwargs
|
||||
)
|
||||
assert canvas.ordinal_metadata == (
|
||||
OrdinalMetadata(
|
||||
levels=("low", "medium", "high"),
|
||||
source_ids=("0", "1", "2"),
|
||||
candidate_ranks=(0, 1, 2),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,100 @@
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.adapters.procedural import normalize
|
||||
|
||||
|
||||
def test_soft_bool_preserved():
|
||||
r = {
|
||||
"id": "x:1",
|
||||
"state": "s",
|
||||
"questions": json.dumps({"q": {"type": "noul", "instructions": "x"}}),
|
||||
"answers": json.dumps({"q": {"noul": 2 / 3}}),
|
||||
}
|
||||
x = normalize(r)
|
||||
assert x["labels"]["q"]["kind"] == "dist"
|
||||
assert x["labels"]["q"]["probs"] == pytest.approx([2 / 3, 1 / 3])
|
||||
|
||||
|
||||
def test_choice_identity():
|
||||
r = {
|
||||
"id": "x:1",
|
||||
"state": "s",
|
||||
"questions": json.dumps(
|
||||
{"q": {"type": "choice", "criteria": {"b": "b", "a": "a"}}}
|
||||
),
|
||||
"answers": json.dumps({"q": {"probabilities": {"b": 0, "a": 1}}}),
|
||||
}
|
||||
assert normalize(r)["labels"]["q"]["gold_idx"] == 1
|
||||
|
||||
|
||||
def test_missing_noul_rejected():
|
||||
r = {
|
||||
"id": "x:1",
|
||||
"state": "s",
|
||||
"questions": '{"q":{"type":"noul"}}',
|
||||
"answers": '{"q":{}}',
|
||||
}
|
||||
with pytest.raises(ValueError, match="missing noul"):
|
||||
normalize(r)
|
||||
|
||||
|
||||
def test_choice_description_retained():
|
||||
r = {
|
||||
"id": "x:1",
|
||||
"state": "s",
|
||||
"questions": '{"q":{"type":"choice","criteria":{"a":"alpha","b":"beta"}}}',
|
||||
"answers": '{"q":{"probabilities":{"a":1,"b":0}}}',
|
||||
}
|
||||
assert normalize(r)["questions"]["q"]["options"] == [
|
||||
{"name": "a", "description": "alpha"},
|
||||
{"name": "b", "description": "beta"},
|
||||
]
|
||||
|
||||
|
||||
def test_noul_criteria_retained():
|
||||
r = {
|
||||
"id": "x:1",
|
||||
"state": "s",
|
||||
"questions": '{"q":{"type":"noul","criteria":{"true":"yes desc","false":"no desc"}}}',
|
||||
"answers": '{"q":{"noul":1}}',
|
||||
}
|
||||
assert normalize(r)["questions"]["q"]["criteria"]["true"] == "yes desc"
|
||||
|
||||
|
||||
def test_source_id_is_the_group_without_an_invented_task_family():
|
||||
result = normalize(
|
||||
{
|
||||
"id": "arithmetic:train:7",
|
||||
"state": "s",
|
||||
"questions": '{"q":{"type":"noul"}}',
|
||||
"answers": '{"q":{"noul":1}}',
|
||||
}
|
||||
)
|
||||
assert result["group"] == "arithmetic:train:7"
|
||||
assert "family" not in result
|
||||
assert result["source_metadata"] == {
|
||||
"task": "arithmetic",
|
||||
"official_split": "train",
|
||||
}
|
||||
|
||||
|
||||
def test_score_legend_identity_overrides_dictionary_order():
|
||||
row = {
|
||||
"id": "task:train:1",
|
||||
"state": "{}",
|
||||
"questions": {"q": {"type": "score", "criteria": ["low", "high"]}},
|
||||
"answers": {
|
||||
"q": {
|
||||
"legend": {"0": "high", "1": "low"},
|
||||
"probabilities": {"0": 0.8, "1": 0.2},
|
||||
}
|
||||
},
|
||||
}
|
||||
result = normalize(row)
|
||||
assert result["labels"]["q"]["probs"] == [0.2, 0.8]
|
||||
assert result["questions"]["q"]["levels"] == ["low", "high"]
|
||||
row["answers"]["q"]["legend"]["1"] = "high"
|
||||
with pytest.raises(ValueError, match="exactly once"):
|
||||
normalize(row)
|
||||
@@ -0,0 +1,75 @@
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision import prompts
|
||||
|
||||
|
||||
class RecordingTokenizer:
|
||||
def __init__(self, output: Any):
|
||||
self.output = output
|
||||
self.messages: Any = None
|
||||
self.kwargs: dict[str, Any] | None = None
|
||||
|
||||
def apply_chat_template(self, messages, **kwargs):
|
||||
self.messages = messages
|
||||
self.kwargs = kwargs
|
||||
return self.output
|
||||
|
||||
|
||||
def test_serializes_state_like_pinned_djev():
|
||||
assert prompts.serialize_state("raw °") == "raw °"
|
||||
assert prompts.serialize_state({"temperature": "20°"}) == '{"temperature": "20°"}'
|
||||
with pytest.raises(ValueError, match="state is required"):
|
||||
prompts.serialize_state(None)
|
||||
|
||||
|
||||
def test_structured_content_and_dict_tokenizer_output():
|
||||
tokenizer = RecordingTokenizer({"input_ids": [[4, 5, 6]]})
|
||||
|
||||
ids = prompts.decision_prompt_ids(
|
||||
tokenizer,
|
||||
"system",
|
||||
{"value": "20°"},
|
||||
thinking=True,
|
||||
structured_content=True,
|
||||
)
|
||||
|
||||
assert ids == (4, 5, 6)
|
||||
assert tokenizer.messages == [
|
||||
{"role": "system", "content": [{"type": "text", "text": "system"}]},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [{"type": "text", "text": '{"value": "20°"}'}],
|
||||
},
|
||||
]
|
||||
assert tokenizer.kwargs == {
|
||||
"tokenize": True,
|
||||
"add_generation_prompt": True,
|
||||
"enable_thinking": True,
|
||||
}
|
||||
|
||||
|
||||
def test_defaults_to_source_faithful_string_content():
|
||||
tokenizer = RecordingTokenizer([7, 8])
|
||||
|
||||
assert prompts.decision_prompt_ids(tokenizer, "system", "raw state") == (7, 8)
|
||||
assert tokenizer.messages == [
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "user", "content": "raw state"},
|
||||
]
|
||||
|
||||
|
||||
def test_rejects_multiple_prompts_or_non_token_output():
|
||||
with pytest.raises(ValueError, match="more than one prompt"):
|
||||
prompts.decision_prompt_ids(
|
||||
RecordingTokenizer([[1], [2]]), "system", "state", structured_content=False
|
||||
)
|
||||
with pytest.raises(TypeError, match="token ids"):
|
||||
prompts.decision_prompt_ids(
|
||||
RecordingTokenizer(object()), "system", "state", structured_content=False
|
||||
)
|
||||
with pytest.raises(TypeError, match="integer token ids"):
|
||||
prompts.decision_prompt_ids(
|
||||
RecordingTokenizer([1.5]), "system", "state", structured_content=False
|
||||
)
|
||||
@@ -0,0 +1,154 @@
|
||||
import hashlib
|
||||
import json
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from datasets import Dataset
|
||||
|
||||
SCRIPT = Path(__file__).parents[3] / "scripts/diffusion_lm/build_public_decision_mix.py"
|
||||
SPEC = spec_from_file_location("build_public_decision_mix", SCRIPT)
|
||||
assert SPEC and SPEC.loader
|
||||
MODULE = module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(MODULE)
|
||||
|
||||
|
||||
def _raw(config: str, split: str, index: int) -> dict:
|
||||
return {
|
||||
"id": f"{config}:{split}:{index}",
|
||||
"state": json.dumps({"config": config, "split": split, "index": index}),
|
||||
"questions": json.dumps(
|
||||
{"ok": {"type": "noul", "instructions": "", "criteria": {}}}
|
||||
),
|
||||
"answers": json.dumps({"ok": {"noul": 1.0}}),
|
||||
}
|
||||
|
||||
|
||||
def test_build_public_mix_is_deterministic_and_decontaminates_heldouts(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
def fake_load_dataset(dataset, name, split, revision):
|
||||
assert (dataset, revision) == (MODULE.DATASET, MODULE.REVISION)
|
||||
count = (
|
||||
MODULE.TRAIN_PER_CONFIG + 1 if split == "train" else MODULE.DEV_PER_CONFIG
|
||||
)
|
||||
return Dataset.from_list([_raw(name, split, index) for index in range(count)])
|
||||
|
||||
monkeypatch.setattr(MODULE, "load_dataset", fake_load_dataset)
|
||||
protected = tmp_path / "protected.jsonl"
|
||||
colliding_id = min(
|
||||
(f"arithmetic:train:{index}" for index in range(MODULE.TRAIN_PER_CONFIG + 1)),
|
||||
key=lambda record_id: hashlib.sha256(
|
||||
f"{MODULE.SEED}\0arithmetic\0{record_id}".encode()
|
||||
).hexdigest(),
|
||||
)
|
||||
colliding_index = int(colliding_id.rsplit(":", 1)[1])
|
||||
protected.write_text(
|
||||
"\n".join(
|
||||
json.dumps(row)
|
||||
for row in (
|
||||
{"source": "eval_a", "family": "heldout", "state": {"x": "j"}},
|
||||
{
|
||||
"source": "eval_a",
|
||||
"family": "heldout",
|
||||
"state": {
|
||||
"config": "arithmetic",
|
||||
"split": "train",
|
||||
"index": colliding_index,
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
first = MODULE.build(tmp_path / "first", [protected])
|
||||
second = MODULE.build(tmp_path / "second", [protected])
|
||||
|
||||
assert first["files"] == second["files"]
|
||||
assert first["protected_decontamination"]["status"] == "performed"
|
||||
assert first["protected_decontamination"]["protected_rows_by_source"] == {
|
||||
"eval_a": 2
|
||||
}
|
||||
assert (
|
||||
first["files"]["train"]["lines"]
|
||||
== len(MODULE.CONFIGS) * MODULE.TRAIN_PER_CONFIG
|
||||
)
|
||||
assert first["files"]["dev"]["lines"] == len(MODULE.CONFIGS) * MODULE.DEV_PER_CONFIG
|
||||
assert (
|
||||
first["files"]["train"]["sha256"]
|
||||
== hashlib.sha256((tmp_path / "first" / "train.jsonl").read_bytes()).hexdigest()
|
||||
)
|
||||
assert '"source":"eval_a"' not in (tmp_path / "first" / "train.jsonl").read_text()
|
||||
train_ids = {
|
||||
json.loads(line)["id"]
|
||||
for line in (tmp_path / "first" / "train.jsonl").read_text().splitlines()
|
||||
}
|
||||
assert colliding_id not in train_ids
|
||||
assert len(train_ids) == first["files"]["train"]["lines"]
|
||||
|
||||
|
||||
def test_build_public_mix_is_usable_without_protected_rows(tmp_path, monkeypatch):
|
||||
def fake_load_dataset(dataset, name, split, revision):
|
||||
count = MODULE.TRAIN_PER_CONFIG if split == "train" else MODULE.DEV_PER_CONFIG
|
||||
return Dataset.from_list([_raw(name, split, index) for index in range(count)])
|
||||
|
||||
monkeypatch.setattr(MODULE, "load_dataset", fake_load_dataset)
|
||||
contract = MODULE.build(tmp_path / "out")
|
||||
assert contract["protected_decontamination"]["status"] == "not_requested"
|
||||
|
||||
|
||||
def _wide_choice(count):
|
||||
options = {str(index): f"option {index}" for index in range(count)}
|
||||
return {
|
||||
"id": "arithmetic:train:0",
|
||||
"state": json.dumps({"x": 1}),
|
||||
"questions": json.dumps(
|
||||
{"choice": {"type": "choice", "instructions": "", "criteria": options}}
|
||||
),
|
||||
"answers": json.dumps(
|
||||
{
|
||||
"choice": {
|
||||
"probabilities": {
|
||||
name: 0.5 if name in {"0", "1"} else 0.0 for name in options
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_normalize_uses_recipe_codebook(monkeypatch):
|
||||
rows = [_wide_choice(26)]
|
||||
monkeypatch.setattr(
|
||||
MODULE, "load_dataset", lambda *args, **kwargs: Dataset.from_list(rows)
|
||||
)
|
||||
record = MODULE._normalize("arithmetic", "train")[0]
|
||||
assert record["labels"]["choice"]["probs"][0] == 0.5
|
||||
|
||||
rows[:] = [_wide_choice(27)]
|
||||
with pytest.raises(ValueError, match="at most 26 alternatives"):
|
||||
MODULE._normalize("arithmetic", "train")
|
||||
|
||||
|
||||
def test_source_group_overlap_is_rejected():
|
||||
record = {"source": "public_procedural.arithmetic", "group": "origin-1"}
|
||||
with pytest.raises(ValueError, match="source/group"):
|
||||
MODULE._assert_source_group_isolation({"train": [record], "dev": [record]})
|
||||
|
||||
|
||||
def test_build_public_mix_rejects_duplicate_source_ids(tmp_path, monkeypatch):
|
||||
def fake_load_dataset(dataset, name, split, revision):
|
||||
count = MODULE.TRAIN_PER_CONFIG if split == "train" else MODULE.DEV_PER_CONFIG
|
||||
rows = [_raw(name, split, index) for index in range(count)]
|
||||
if name == "arithmetic" and split == "train":
|
||||
rows[-1]["id"] = rows[0]["id"]
|
||||
return Dataset.from_list(rows)
|
||||
|
||||
monkeypatch.setattr(MODULE, "load_dataset", fake_load_dataset)
|
||||
protected = tmp_path / "protected.jsonl"
|
||||
protected.write_text(
|
||||
json.dumps({"source": "eval_a", "family": "heldout", "state": {"x": "j"}})
|
||||
+ "\n"
|
||||
)
|
||||
with pytest.raises(ValueError, match="duplicate public source ids"):
|
||||
MODULE.build(tmp_path / "out", [protected])
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Reject malformed canvas metadata before invoking a model or serving API."""
|
||||
|
||||
from dataclasses import replace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from axolotl.integrations.diffusion_decision.readers.base import (
|
||||
restricted_probabilities,
|
||||
validate_canvas,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
make_canvas,
|
||||
)
|
||||
|
||||
|
||||
def canvas():
|
||||
return make_canvas(
|
||||
(1, 2),
|
||||
(3, 4, 5, 0),
|
||||
(1,),
|
||||
allowed=(6, 7),
|
||||
question_ids=("answer",),
|
||||
template_length=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes,message",
|
||||
[
|
||||
({"prompt_ids": ()}, "requires a prompt"),
|
||||
({"canvas_ids": (3, -1, 5, 0)}, "nonnegative"),
|
||||
({"allowed_ids": ((True, 7),)}, "nonnegative"),
|
||||
({"template_length": 4}, "template_length"),
|
||||
({"label_positions": (2,)}, "outside"),
|
||||
({"slot_mask": (False, True, False, False)}, "latent slots"),
|
||||
({"question_ids": ("",)}, "distinct nonempty"),
|
||||
],
|
||||
)
|
||||
def test_invalid_canvas_rejected(changes, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
validate_canvas(replace(canvas(), **changes))
|
||||
|
||||
|
||||
def test_duplicate_question_ids_rejected():
|
||||
with pytest.raises(ValueError, match="distinct nonempty"):
|
||||
validate_canvas(replace(canvas(), question_ids=("answer", "answer")))
|
||||
|
||||
|
||||
def test_full_width_pad_tokens_remain_semantically_visible():
|
||||
assert validate_canvas(canvas()) == 4
|
||||
|
||||
|
||||
def test_candidate_mask_does_not_extract_tensor_scalars(monkeypatch):
|
||||
logits = torch.log_softmax(torch.tensor([[1.0, 2.0, 3.0], [3.0, 2.0, 1.0]]), -1)
|
||||
|
||||
def fail(*_args, **_kwargs):
|
||||
raise AssertionError("candidate mask extracted a tensor scalar")
|
||||
|
||||
with monkeypatch.context() as patch:
|
||||
for name in ("item", "__bool__", "__int__"):
|
||||
patch.setattr(torch.Tensor, name, fail)
|
||||
_, mask, probabilities = restricted_probabilities(logits, ((0, 2), (1,)))
|
||||
assert mask.tolist() == [[True, True], [True, False]]
|
||||
torch.testing.assert_close(
|
||||
probabilities[0], torch.softmax(torch.tensor([1.0, 3.0]), 0)
|
||||
)
|
||||
torch.testing.assert_close(probabilities[1], torch.tensor([1.0, 0.0]))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token", [-1, 3])
|
||||
def test_candidate_token_outside_vocabulary_rejected(token):
|
||||
with pytest.raises(ValueError, match="outside the model vocabulary"):
|
||||
restricted_probabilities(torch.zeros(1, 3), ((token,),))
|
||||
|
||||
|
||||
def test_validate_canvas_rejects_pinned_label_positions():
|
||||
pinned = list(canvas().pinned_mask)
|
||||
pinned[1] = True
|
||||
with pytest.raises(ValueError, match="pinned tokens cannot be label positions"):
|
||||
validate_canvas(replace(canvas(), pinned_mask=tuple(pinned)))
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Slot plans affect only declared decision-canvas regions."""
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.template import SchemaError
|
||||
from axolotl.model_support import (
|
||||
DiffusionNoise,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
build_canvas,
|
||||
free_plan_seed,
|
||||
make_slot_plan,
|
||||
)
|
||||
|
||||
|
||||
def _plan(mode, *, count=2, ids=(), noise=DiffusionNoise.UNIFORM):
|
||||
return make_slot_plan(
|
||||
mode, count=count, ids=ids, noise=noise, seed=free_plan_seed(mode, noise)
|
||||
)
|
||||
|
||||
|
||||
def test_none_slot_plan_keeps_existing_canvas_bytes_even_with_explicit_tags():
|
||||
baseline = build_canvas()
|
||||
none = build_canvas(_plan("none", count=0))
|
||||
|
||||
assert none == baseline
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "ids", "placement", "expected_slots"),
|
||||
[
|
||||
("pad", (), "after_turn", (0, 0)),
|
||||
("pinned", (7,), "thought", (7, 7)),
|
||||
("pinned", (7, 8), "thought", (7, 8)),
|
||||
("learned", (7, 8), "thought", (7, 8)),
|
||||
("prompt", (7, 8), "prompt", (7, 8)),
|
||||
],
|
||||
)
|
||||
def test_slot_modes_preserve_labels_and_mark_only_canvas_slots(
|
||||
mode, ids, placement, expected_slots
|
||||
):
|
||||
plan = _plan(mode, ids=ids)
|
||||
canvas = build_canvas(plan)
|
||||
baseline = build_canvas()
|
||||
|
||||
assert (
|
||||
canvas.canvas_ids[canvas.label_positions[0]]
|
||||
== baseline.canvas_ids[baseline.label_positions[0]]
|
||||
)
|
||||
assert canvas.allowed_ids == baseline.allowed_ids
|
||||
assert canvas.targets == baseline.targets
|
||||
assert len(canvas.canvas_ids) == len(baseline.canvas_ids) == 128
|
||||
if placement == "thought":
|
||||
assert canvas.canvas_ids[:5] == (70, 71, *expected_slots, 72)
|
||||
assert [index for index, value in enumerate(canvas.slot_mask) if value] == [
|
||||
2,
|
||||
3,
|
||||
]
|
||||
assert canvas.pinned_mask[2:4] == (True, True)
|
||||
elif placement == "after_turn":
|
||||
assert canvas.canvas_ids[canvas.template_length] == 106
|
||||
assert (
|
||||
canvas.canvas_ids[canvas.template_length + 1 : canvas.template_length + 3]
|
||||
== expected_slots
|
||||
)
|
||||
assert [index for index, value in enumerate(canvas.slot_mask) if value] == [
|
||||
canvas.template_length + 1,
|
||||
canvas.template_length + 2,
|
||||
]
|
||||
assert all(
|
||||
canvas.pinned_mask[index]
|
||||
for index, value in enumerate(canvas.slot_mask)
|
||||
if value
|
||||
)
|
||||
else:
|
||||
assert canvas.prompt_ids == (*expected_slots, 99)
|
||||
assert canvas.prompt_slot_mask == (True, True, False)
|
||||
assert not any(canvas.slot_mask)
|
||||
assert canvas.canvas_ids == baseline.canvas_ids
|
||||
if placement != "prompt":
|
||||
assert canvas.prompt_slot_mask == ()
|
||||
assert all(canvas.semantic_mask)
|
||||
assert all(not canvas.slot_mask[position] for position in canvas.label_positions)
|
||||
|
||||
|
||||
def test_mask_and_free_slots_follow_noise_specific_initialization_and_pin_policy():
|
||||
masked = build_canvas(
|
||||
_plan("mask", noise=DiffusionNoise.ABSORBING), noise_kind="absorbing"
|
||||
)
|
||||
free_plan = _plan("free")
|
||||
free = build_canvas(free_plan)
|
||||
|
||||
assert masked.canvas_ids[:5] == (70, 71, 9, 9, 72)
|
||||
assert masked.pinned_mask[2:4] == (True, True)
|
||||
assert masked.canvas_ids[masked.label_positions[0]] == 9
|
||||
assert free.slot_mask[2:4] == (True, True)
|
||||
assert free.pinned_mask[2:4] == (False, False)
|
||||
assert free.canvas_ids[2:4] == free_plan.ids
|
||||
|
||||
|
||||
def test_fixed_slots_stay_pinned_at_one_read_and_non_slot_behavior_stays_source_faithful():
|
||||
pinned = build_canvas(_plan("pinned", ids=(7,)), steps=1)
|
||||
repeated = build_canvas(_plan("pinned", ids=(7,)), steps=2)
|
||||
|
||||
assert pinned.pinned_mask[2:4] == (True, True)
|
||||
assert pinned.pinned_mask[pinned.label_positions[0]] is False
|
||||
assert repeated.pinned_mask[pinned.label_positions[0]] is False
|
||||
assert all(
|
||||
repeated.pinned_mask[index] == (index not in repeated.label_positions)
|
||||
for index in range(128)
|
||||
)
|
||||
|
||||
|
||||
def test_distinct_pinned_slots_match_learned_canvas_coordinates_without_trainable_rows():
|
||||
pinned_plan = _plan("pinned", ids=(7, 8))
|
||||
learned_plan = _plan("learned", ids=(7, 8))
|
||||
pinned = build_canvas(pinned_plan, steps=2)
|
||||
learned = build_canvas(learned_plan, steps=2)
|
||||
|
||||
assert pinned.canvas_ids == learned.canvas_ids
|
||||
assert pinned.label_positions == learned.label_positions
|
||||
assert pinned.slot_mask == learned.slot_mask
|
||||
assert pinned.pinned_mask == learned.pinned_mask
|
||||
assert pinned_plan.trainable_token_ids == ()
|
||||
assert learned_plan.trainable_token_ids == (7, 8)
|
||||
|
||||
|
||||
def test_slot_budget_overflow_raises_schema_error_without_truncation():
|
||||
with pytest.raises(SchemaError, match="canvas"):
|
||||
build_canvas(_plan("learned", ids=(7, 8)), width=7)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Fixed decision slots retain their canvas and collation invariants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
DecisionTrainingCollator,
|
||||
)
|
||||
from axolotl.model_support import (
|
||||
DiffusionLayout,
|
||||
DiffusionNoise,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
build_canvas,
|
||||
make_record,
|
||||
make_rows,
|
||||
make_slot_plan,
|
||||
make_spec,
|
||||
slot_token_ids,
|
||||
)
|
||||
|
||||
|
||||
def _canvas(identifier: str, mode: str, noise: DiffusionNoise):
|
||||
return build_canvas(
|
||||
make_slot_plan(mode, ids=slot_token_ids(mode), noise=noise),
|
||||
record=make_record(identifier, state=f"state-{identifier}"),
|
||||
prompt_ids=(90 + int(identifier),),
|
||||
noise=noise,
|
||||
)
|
||||
|
||||
|
||||
def _rows(mode: str, noise: DiffusionNoise):
|
||||
canvases = tuple(_canvas(str(index), mode, noise) for index in range(2))
|
||||
return canvases, make_rows(canvases)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "noise", "expected_ids"),
|
||||
[
|
||||
("pinned", DiffusionNoise.UNIFORM, (7, 7)),
|
||||
("learned", DiffusionNoise.UNIFORM, (7, 8)),
|
||||
("mask", DiffusionNoise.ABSORBING, (9, 9)),
|
||||
("pad", DiffusionNoise.UNIFORM, (0, 0)),
|
||||
("prompt", DiffusionNoise.UNIFORM, (7, 8)),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"layout", [DiffusionLayout.FULL_SEQUENCE, DiffusionLayout.ENCODER_CANVAS]
|
||||
)
|
||||
def test_fixed_slot_collation_keeps_slots_pinned_and_out_of_canvas_loss(
|
||||
mode: str,
|
||||
noise: DiffusionNoise,
|
||||
expected_ids: tuple[int, ...],
|
||||
layout: DiffusionLayout,
|
||||
):
|
||||
canvases, rows = _rows(mode, noise)
|
||||
batch = DecisionTrainingCollator(make_spec(layout=layout, noise=noise))(rows)
|
||||
|
||||
for canvas in canvases:
|
||||
slot_positions = tuple(
|
||||
index for index, value in enumerate(canvas.slot_mask) if value
|
||||
)
|
||||
assert set(slot_positions).isdisjoint(canvas.label_positions)
|
||||
if mode == "prompt":
|
||||
assert canvas.prompt_ids[: len(expected_ids)] == expected_ids
|
||||
assert canvas.prompt_slot_mask == (True, True, False)
|
||||
assert not any(canvas.slot_mask)
|
||||
else:
|
||||
assert (
|
||||
tuple(canvas.canvas_ids[index] for index in slot_positions)
|
||||
== expected_ids
|
||||
)
|
||||
assert all(canvas.pinned_mask[index] for index in slot_positions)
|
||||
|
||||
if layout is DiffusionLayout.FULL_SEQUENCE:
|
||||
_assert_full_sequence_contract(batch, canvases, mode, expected_ids)
|
||||
else:
|
||||
_assert_encoder_canvas_contract(
|
||||
batch["diffusion_batch"], canvases, mode, expected_ids
|
||||
)
|
||||
|
||||
|
||||
def _assert_full_sequence_contract(batch, canvases, mode, expected_ids):
|
||||
input_ids = batch["input_ids"][0]
|
||||
pinned = batch["canvas_input_pinned_mask"][0]
|
||||
loss = batch["canvas_loss_mask"][0]
|
||||
corruptible = batch["canvas_corruptible_mask"][0]
|
||||
update = batch["canvas_update_mask"][0]
|
||||
offset = 0
|
||||
for document, canvas in enumerate(canvases):
|
||||
prompt_length = len(canvas.prompt_ids)
|
||||
canvas_start = offset + prompt_length
|
||||
slot_positions = tuple(
|
||||
index for index, value in enumerate(canvas.slot_mask) if value
|
||||
)
|
||||
fixed_positions = (
|
||||
tuple(range(offset, offset + len(expected_ids)))
|
||||
if mode == "prompt"
|
||||
else tuple(canvas_start + index for index in slot_positions)
|
||||
)
|
||||
assert (
|
||||
tuple(input_ids[position].item() for position in fixed_positions)
|
||||
== expected_ids
|
||||
)
|
||||
assert torch.all(pinned[list(fixed_positions)])
|
||||
assert not torch.any(loss[list(fixed_positions)])
|
||||
assert not torch.any(corruptible[list(fixed_positions)])
|
||||
assert not torch.any(update[list(fixed_positions)])
|
||||
assert batch["document_ids"][
|
||||
0, offset : offset + prompt_length + len(canvas.canvas_ids)
|
||||
].tolist() == [document] * (prompt_length + len(canvas.canvas_ids))
|
||||
assert batch["position_ids"][
|
||||
0, offset : offset + prompt_length + len(canvas.canvas_ids)
|
||||
].tolist() == list(range(prompt_length + len(canvas.canvas_ids)))
|
||||
if mode == "prompt":
|
||||
assert max(fixed_positions) < canvas_start
|
||||
assert canvas.prompt_slot_mask == (True, True, False)
|
||||
offset += prompt_length + len(canvas.canvas_ids)
|
||||
|
||||
|
||||
def _assert_encoder_canvas_contract(diffusion_batch, canvases, mode, expected_ids):
|
||||
for row, canvas in enumerate(canvases):
|
||||
slot_positions = tuple(
|
||||
index for index, value in enumerate(canvas.slot_mask) if value
|
||||
)
|
||||
if mode == "prompt":
|
||||
fixed_positions = tuple(range(len(expected_ids)))
|
||||
assert canvas.prompt_slot_mask == (True, True, False)
|
||||
assert (
|
||||
tuple(
|
||||
diffusion_batch.encoder_input_ids[
|
||||
row, list(fixed_positions)
|
||||
].tolist()
|
||||
)
|
||||
== expected_ids
|
||||
)
|
||||
assert torch.all(
|
||||
diffusion_batch.encoder_validity[row, list(fixed_positions)]
|
||||
)
|
||||
assert not torch.any(
|
||||
diffusion_batch.encoder_ar_valid_mask[row, list(fixed_positions)]
|
||||
)
|
||||
assert diffusion_batch.encoder_ar_valid_mask[row, len(expected_ids)]
|
||||
else:
|
||||
fixed_positions = slot_positions
|
||||
assert (
|
||||
tuple(
|
||||
diffusion_batch.canvas_clean_ids[
|
||||
row, list(fixed_positions)
|
||||
].tolist()
|
||||
)
|
||||
== expected_ids
|
||||
)
|
||||
assert torch.all(
|
||||
diffusion_batch.canvas_input_pinned_mask[row, list(slot_positions)]
|
||||
)
|
||||
assert not torch.any(
|
||||
diffusion_batch.canvas_loss_mask[row, list(slot_positions)]
|
||||
)
|
||||
assert not torch.any(
|
||||
diffusion_batch.canvas_corruptible_mask[row, list(slot_positions)]
|
||||
)
|
||||
assert not torch.any(
|
||||
diffusion_batch.canvas_update_mask[row, list(slot_positions)]
|
||||
)
|
||||
assert not torch.any(
|
||||
diffusion_batch.canvas_sc_eligible_mask[row, list(slot_positions)]
|
||||
)
|
||||
assert torch.all(
|
||||
diffusion_batch.canvas_read_only_mask[row, list(slot_positions)]
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Learned decision slots wire only audited embedding rows into PEFT."""
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from peft import LoraConfig, TaskType, get_peft_model
|
||||
from transformers import LlamaConfig, LlamaForCausalLM
|
||||
|
||||
from axolotl.integrations.diffusion_decision.args import DiffusionDecisionConfig
|
||||
from axolotl.integrations.diffusion_decision.plugin import DiffusionDecisionPlugin
|
||||
from axolotl.integrations.diffusion_decision.slot_runtime import (
|
||||
merge_trainable_slot_indices,
|
||||
resolve_trainable_slot_runtime,
|
||||
)
|
||||
from axolotl.model_support import (
|
||||
DiffusionNoise,
|
||||
FirstPositionAlignment,
|
||||
ReductionScope,
|
||||
TimeWeighting,
|
||||
)
|
||||
from axolotl.utils.dict import DictDefault
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
make_spec,
|
||||
)
|
||||
|
||||
|
||||
def _spec():
|
||||
return make_spec(
|
||||
noise=DiffusionNoise.ABSORBING,
|
||||
first_position_alignment=FirstPositionAlignment.REQUIRES_PREDECESSOR,
|
||||
max_canvas=None,
|
||||
max_context=64,
|
||||
default_time_weighting=TimeWeighting.INV_T,
|
||||
reduction_scope=ReductionScope.GLOBAL_WINDOW,
|
||||
)
|
||||
|
||||
|
||||
def _model():
|
||||
config = LlamaConfig(
|
||||
vocab_size=131072,
|
||||
hidden_size=16,
|
||||
intermediate_size=32,
|
||||
num_hidden_layers=1,
|
||||
num_attention_heads=2,
|
||||
num_key_value_heads=2,
|
||||
pad_token_id=None,
|
||||
bos_token_id=1,
|
||||
eos_token_id=11,
|
||||
)
|
||||
config.mask_token_id = 100
|
||||
return LlamaForCausalLM(config)
|
||||
|
||||
|
||||
def _cfg(*, mode="learned", ids=(936, 937), existing=None):
|
||||
return DictDefault(
|
||||
{
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"diffusion_lm": {"from_causal_lm": False},
|
||||
"diffusion_decision": {
|
||||
"latent": {"mode": mode, "num_slots": len(ids), "token_ids": ids}
|
||||
},
|
||||
"peft_trainable_token_indices": existing,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _decision(cfg):
|
||||
return DiffusionDecisionConfig.model_validate(cfg.diffusion_decision)
|
||||
|
||||
|
||||
def test_pre_lora_merges_learned_rows_before_real_peft_embedding_wrap():
|
||||
torch.manual_seed(0)
|
||||
cfg = _cfg(existing=[900])
|
||||
model = _model()
|
||||
base_weight = model.get_input_embeddings().weight
|
||||
outside_before = base_weight.detach().clone()
|
||||
|
||||
DiffusionDecisionPlugin().pre_lora_load(cfg, model)
|
||||
|
||||
assert cfg.peft_trainable_token_indices == [900, 936, 937]
|
||||
wrapped = get_peft_model(
|
||||
model,
|
||||
LoraConfig(
|
||||
task_type=TaskType.CAUSAL_LM,
|
||||
target_modules=["q_proj"],
|
||||
r=2,
|
||||
lora_alpha=2,
|
||||
trainable_token_indices=cfg.peft_trainable_token_indices,
|
||||
),
|
||||
)
|
||||
optimizer = torch.optim.SGD(
|
||||
[parameter for parameter in wrapped.parameters() if parameter.requires_grad],
|
||||
lr=0.1,
|
||||
)
|
||||
loss = wrapped(
|
||||
input_ids=torch.tensor([[936, 937, 5]]),
|
||||
labels=torch.tensor([[-100, -100, 5]]),
|
||||
).loss
|
||||
loss.backward()
|
||||
token_deltas = [
|
||||
parameter
|
||||
for name, parameter in wrapped.named_parameters()
|
||||
if "trainable_tokens_delta" in name
|
||||
]
|
||||
assert token_deltas
|
||||
assert any(
|
||||
parameter.grad is not None and torch.count_nonzero(parameter.grad) > 0
|
||||
for parameter in token_deltas
|
||||
)
|
||||
optimizer.step()
|
||||
assert base_weight.requires_grad is False
|
||||
assert torch.equal(base_weight.detach(), outside_before)
|
||||
|
||||
|
||||
def test_prompt_rows_use_actual_embedding_path_and_preserve_mapping_indices():
|
||||
cfg = _cfg(mode="prompt", existing={"model.embed_tokens": [900]})
|
||||
model = _model()
|
||||
|
||||
runtime = resolve_trainable_slot_runtime(cfg, model, _decision(cfg), spec=_spec())
|
||||
assert runtime is not None
|
||||
assert runtime.embedding_module_path == "model.embed_tokens"
|
||||
assert runtime.plan.trainable_token_ids == (936, 937)
|
||||
merge_trainable_slot_indices(cfg, runtime)
|
||||
|
||||
assert cfg.peft_trainable_token_indices == {"model.embed_tokens": [900, 936, 937]}
|
||||
|
||||
|
||||
def test_nonlearned_modes_do_not_change_peft_embedding_rows():
|
||||
cfg = _cfg(mode="pinned", ids=(936,), existing=[900])
|
||||
model = _model()
|
||||
|
||||
DiffusionDecisionPlugin().pre_lora_load(cfg, model)
|
||||
|
||||
assert cfg.peft_trainable_token_indices == [900]
|
||||
|
||||
|
||||
def test_distinct_pinned_rows_do_not_change_peft_embedding_rows():
|
||||
cfg = _cfg(mode="pinned", ids=(936, 937), existing=[900])
|
||||
model = _model()
|
||||
|
||||
DiffusionDecisionPlugin().pre_lora_load(cfg, model)
|
||||
|
||||
assert cfg.peft_trainable_token_indices == [900]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("token_id", (1, 11, 100))
|
||||
def test_active_model_control_ids_are_rejected_for_trainable_slots(token_id):
|
||||
cfg = _cfg(ids=(token_id, 936))
|
||||
|
||||
with pytest.raises(ValueError, match="active pad, bos, eos, or mask"):
|
||||
resolve_trainable_slot_runtime(cfg, _model(), _decision(cfg), spec=_spec())
|
||||
|
||||
|
||||
def test_mapping_without_actual_embedding_path_fails_explicitly():
|
||||
cfg = _cfg(existing={"other.embedding": [900]})
|
||||
|
||||
runtime = resolve_trainable_slot_runtime(
|
||||
cfg, _model(), _decision(cfg), spec=_spec()
|
||||
)
|
||||
assert runtime is not None
|
||||
with pytest.raises(ValueError, match="actual input embedding path"):
|
||||
merge_trainable_slot_indices(cfg, runtime)
|
||||
|
||||
|
||||
def test_runtime_uses_embedding_vocab_not_model_config_vocab():
|
||||
model = _model()
|
||||
model.config.vocab_size = 4
|
||||
cfg = _cfg(ids=(936, 937))
|
||||
|
||||
runtime = resolve_trainable_slot_runtime(cfg, model, _decision(cfg), spec=_spec())
|
||||
|
||||
assert runtime is not None
|
||||
assert runtime.plan.ids == (936, 937)
|
||||
|
||||
|
||||
def test_multiple_eos_ids_are_controls_without_requiring_a_pad_token():
|
||||
model = _model()
|
||||
model.config.eos_token_id = [1, 106]
|
||||
|
||||
runtime = resolve_trainable_slot_runtime(
|
||||
_cfg(ids=(936, 937)), model, _decision(_cfg(ids=(936, 937))), spec=_spec()
|
||||
)
|
||||
|
||||
assert runtime is not None
|
||||
assert runtime.plan.ids == (936, 937)
|
||||
with pytest.raises(ValueError, match="active pad, bos, eos, or mask"):
|
||||
cfg = _cfg(ids=(106, 936))
|
||||
resolve_trainable_slot_runtime(cfg, model, _decision(cfg), spec=_spec())
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Pure maximum-canvas projection tests for sampled decision slot counts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.slot_sampling import (
|
||||
UNIFORM_INCLUSIVE_V1,
|
||||
DecisionDraw,
|
||||
project_max_canvas,
|
||||
project_slot_plan,
|
||||
sample_slot_count,
|
||||
)
|
||||
from axolotl.integrations.diffusion_decision.slots import SlotPlan
|
||||
from axolotl.model_support import (
|
||||
DiffusionNoise,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
build_canvas,
|
||||
free_plan_seed,
|
||||
make_slot_plan,
|
||||
slot_token_ids,
|
||||
)
|
||||
|
||||
|
||||
def _maximum_plan(mode: str, noise: DiffusionNoise) -> SlotPlan:
|
||||
return make_slot_plan(
|
||||
mode,
|
||||
count=3,
|
||||
ids=slot_token_ids(mode, 3),
|
||||
noise=noise,
|
||||
seed=free_plan_seed(mode, noise),
|
||||
)
|
||||
|
||||
|
||||
def _canvas(plan: SlotPlan, *, noise: DiffusionNoise, width: int = 128):
|
||||
return build_canvas(plan, noise=noise, width=width, steps=2)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "noise"),
|
||||
[
|
||||
("pad", DiffusionNoise.UNIFORM),
|
||||
("pinned", DiffusionNoise.UNIFORM),
|
||||
("learned", DiffusionNoise.UNIFORM),
|
||||
("prompt", DiffusionNoise.UNIFORM),
|
||||
("mask", DiffusionNoise.ABSORBING),
|
||||
("free", DiffusionNoise.UNIFORM),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("count", [0, 1, 3])
|
||||
def test_projection_matches_direct_builder_for_all_supported_placements(
|
||||
mode: str, noise: DiffusionNoise, count: int
|
||||
):
|
||||
maximum_plan = _maximum_plan(mode, noise)
|
||||
maximum_canvas = _canvas(maximum_plan, noise=noise)
|
||||
|
||||
projected_plan = project_slot_plan(maximum_plan, count)
|
||||
projected_canvas = project_max_canvas(
|
||||
maximum_canvas,
|
||||
maximum_plan,
|
||||
count,
|
||||
pad_token_id=0,
|
||||
padding_pinned=True,
|
||||
)
|
||||
direct = _canvas(projected_plan, noise=noise)
|
||||
|
||||
assert projected_canvas == direct
|
||||
assert projected_canvas.targets == maximum_canvas.targets
|
||||
assert projected_canvas.allowed_ids == maximum_canvas.allowed_ids
|
||||
assert projected_canvas.question_ids == maximum_canvas.question_ids
|
||||
assert all(
|
||||
not projected_canvas.slot_mask[position]
|
||||
for position in projected_canvas.label_positions
|
||||
)
|
||||
assert all(not value for value in projected_plan.loss_mask)
|
||||
if count == len(maximum_plan.ids):
|
||||
assert projected_plan is maximum_plan
|
||||
assert projected_canvas is maximum_canvas
|
||||
|
||||
|
||||
def test_projection_preserves_free_slot_update_mask_and_typed_metadata():
|
||||
maximum_plan = _maximum_plan("free", DiffusionNoise.UNIFORM)
|
||||
maximum_canvas = _canvas(maximum_plan, noise=DiffusionNoise.UNIFORM)
|
||||
|
||||
plan = project_slot_plan(maximum_plan, 1)
|
||||
canvas = project_max_canvas(
|
||||
maximum_canvas,
|
||||
maximum_plan,
|
||||
1,
|
||||
pad_token_id=0,
|
||||
padding_pinned=True,
|
||||
)
|
||||
|
||||
assert plan.update_mask == (True,)
|
||||
assert plan.pinned_mask == (False,)
|
||||
assert canvas.ordinal_metadata == maximum_canvas.ordinal_metadata
|
||||
slot_position = canvas.slot_mask.index(True)
|
||||
assert canvas.pinned_mask[slot_position] is False
|
||||
assert canvas.canvas_ids[slot_position] == maximum_plan.ids[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"seed": True, "epoch": 0, "global_draw_ordinal": 0, "max_slots": 3},
|
||||
{"seed": 1, "epoch": -1, "global_draw_ordinal": 0, "max_slots": 3},
|
||||
{
|
||||
"seed": 1,
|
||||
"epoch": 0,
|
||||
"global_draw_ordinal": 0,
|
||||
"max_slots": 3,
|
||||
"min_slots": 4,
|
||||
},
|
||||
{
|
||||
"seed": 1,
|
||||
"epoch": 0,
|
||||
"global_draw_ordinal": 0,
|
||||
"max_slots": 3,
|
||||
"policy": "other",
|
||||
},
|
||||
{"seed": 1, "epoch": 0, "global_draw_ordinal": 0, "max_slots": 1 << 256},
|
||||
],
|
||||
)
|
||||
def test_slot_count_sampler_rejects_invalid_contracts(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
sample_slot_count(**kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"index": -1, "epoch": 0, "global_draw_ordinal": 0},
|
||||
{"index": 0, "epoch": True, "global_draw_ordinal": 0},
|
||||
{"index": 0, "epoch": 0, "global_draw_ordinal": -1},
|
||||
],
|
||||
)
|
||||
def test_decision_draw_rejects_invalid_stable_identity(kwargs):
|
||||
with pytest.raises(ValueError):
|
||||
DecisionDraw(**kwargs)
|
||||
|
||||
|
||||
def test_slot_count_sampler_is_stateless_replayable_and_uniform(monkeypatch):
|
||||
def forbidden_random(*_args, **_kwargs):
|
||||
raise AssertionError("global RNG")
|
||||
|
||||
monkeypatch.setattr(random, "Random", forbidden_random)
|
||||
|
||||
first = [
|
||||
sample_slot_count(seed=41, epoch=2, global_draw_ordinal=index, max_slots=4)
|
||||
for index in range(10_000)
|
||||
]
|
||||
second = [
|
||||
sample_slot_count(seed=41, epoch=2, global_draw_ordinal=index, max_slots=4)
|
||||
for index in range(10_000)
|
||||
]
|
||||
changed_epoch = sample_slot_count(
|
||||
seed=41, epoch=3, global_draw_ordinal=0, max_slots=4
|
||||
)
|
||||
counts = [first.count(value) for value in range(5)]
|
||||
|
||||
assert first == second
|
||||
assert 0 <= changed_epoch <= 4
|
||||
assert all(abs(value - 2_000) < 180 for value in counts)
|
||||
assert UNIFORM_INCLUSIVE_V1 == "uniform_inclusive_v1"
|
||||
|
||||
|
||||
def test_projection_rejects_invalid_count_and_malformed_maximum_canvas():
|
||||
plan = _maximum_plan("learned", DiffusionNoise.UNIFORM)
|
||||
canvas = _canvas(plan, noise=DiffusionNoise.UNIFORM)
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds"):
|
||||
project_slot_plan(plan, 4)
|
||||
with pytest.raises(ValueError, match="exceeds"):
|
||||
project_max_canvas(canvas, plan, 4, pad_token_id=0, padding_pinned=True)
|
||||
malformed = SlotPlan(
|
||||
ids=plan.ids,
|
||||
placement=plan.placement,
|
||||
pinned_mask=plan.pinned_mask,
|
||||
update_mask=plan.update_mask,
|
||||
loss_mask=(True, False, False),
|
||||
trainable_token_ids=plan.trainable_token_ids,
|
||||
)
|
||||
with pytest.raises(ValueError, match="cannot carry loss"):
|
||||
project_max_canvas(canvas, malformed, 1, pad_token_id=0, padding_pinned=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["learned", "pad"])
|
||||
def test_projection_handles_exact_fit_maximum_canvases(mode: str):
|
||||
noise = DiffusionNoise.UNIFORM
|
||||
plan = _maximum_plan(mode, noise)
|
||||
padded = _canvas(plan, noise=noise)
|
||||
width = padded.template_length + 1 + (len(plan.ids) if mode == "pad" else 0)
|
||||
maximum = _canvas(plan, noise=noise, width=width)
|
||||
assert maximum.slot_mask[-1] or maximum.canvas_ids[-1] != 0
|
||||
|
||||
projected = project_max_canvas(
|
||||
maximum, plan, 1, pad_token_id=0, padding_pinned=True
|
||||
)
|
||||
direct = _canvas(project_slot_plan(plan, 1), noise=noise, width=width)
|
||||
|
||||
assert projected == direct
|
||||
assert projected.canvas_ids[-2:] == (0, 0)
|
||||
assert projected.slot_mask[-2:] == (False, False)
|
||||
assert projected.pinned_mask[-2:] == (True, True)
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Slot initialization contracts stay model-agnostic and pure."""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from axolotl.integrations.diffusion_decision.slots import SlotInit
|
||||
from axolotl.model_support import (
|
||||
DiffusionNoise,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
make_slot_init,
|
||||
make_spec,
|
||||
)
|
||||
|
||||
|
||||
def _init(mode, *, count=3, ids=(), noise=DiffusionNoise.UNIFORM, **kwargs):
|
||||
return make_slot_init(
|
||||
mode,
|
||||
count=count,
|
||||
ids=ids,
|
||||
vocab_size=32,
|
||||
spec=make_spec(noise=noise, self_conditioning=noise is DiffusionNoise.UNIFORM),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_none_has_no_ids_and_no_slot_masks():
|
||||
slot = SlotInit()
|
||||
|
||||
assert slot.ids == ()
|
||||
assert slot.build().ids == ()
|
||||
assert slot.build().placement == "none"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "ids", "placement", "expected", "trainable"),
|
||||
[
|
||||
("pad", (), "after_turn", (0, 0, 0), ()),
|
||||
("pinned", (7,), "thought", (7, 7, 7), ()),
|
||||
("pinned", (7, 8, 10), "thought", (7, 8, 10), ()),
|
||||
("learned", (7, 8, 9), "thought", (7, 8, 9), (7, 8, 9)),
|
||||
("prompt", (7, 8, 9), "prompt", (7, 8, 9), (7, 8, 9)),
|
||||
],
|
||||
)
|
||||
def test_fixed_slot_modes_expose_placement_pinning_and_no_loss(
|
||||
mode, ids, placement, expected, trainable
|
||||
):
|
||||
plan = _init(mode, ids=ids).build()
|
||||
|
||||
assert plan.ids == expected
|
||||
assert plan.placement == placement
|
||||
assert plan.pinned_mask == (True, True, True)
|
||||
assert plan.update_mask == (False, False, False)
|
||||
assert plan.loss_mask == (False, False, False)
|
||||
assert plan.trainable_token_ids == trainable
|
||||
|
||||
|
||||
def test_mask_is_absorbing_only_and_never_updates_or_contributes_loss():
|
||||
plan = _init("mask", noise=DiffusionNoise.ABSORBING).build()
|
||||
|
||||
assert plan.ids == (9, 9, 9)
|
||||
assert plan.placement == "thought"
|
||||
assert plan.pinned_mask == (True, True, True)
|
||||
assert plan.update_mask == (False, False, False)
|
||||
assert plan.loss_mask == (False, False, False)
|
||||
with pytest.raises(ValueError, match="absorbing"):
|
||||
_init("mask").build()
|
||||
|
||||
|
||||
def test_free_uniform_is_seeded_and_free_absorbing_starts_masked():
|
||||
uniform = _init("free").build(seed=17)
|
||||
generator = random.Random(17)
|
||||
|
||||
assert uniform.ids == tuple(generator.randrange(32) for _ in range(3))
|
||||
assert uniform.placement == "thought"
|
||||
assert uniform.pinned_mask == (False, False, False)
|
||||
assert uniform.update_mask == (True, True, True)
|
||||
assert uniform.loss_mask == (False, False, False)
|
||||
assert _init("free").build(seed=17) == uniform
|
||||
with pytest.raises(ValueError, match="explicit integer seed"):
|
||||
_init("free").build()
|
||||
with pytest.raises(ValueError, match="either seed or generator"):
|
||||
_init("free").build(seed=17, generator=random.Random(17))
|
||||
|
||||
absorbing = _init("free", noise=DiffusionNoise.ABSORBING).build()
|
||||
assert absorbing.ids == (9, 9, 9)
|
||||
assert absorbing.pinned_mask == (False, False, False)
|
||||
assert absorbing.update_mask == (True, True, True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("slot", "message"),
|
||||
[
|
||||
(SlotInit("none", token_ids=(1,)), "mode=none"),
|
||||
(_init("pad", ids=(1,)), "does not accept"),
|
||||
(_init("pinned", ids=(1, 2)), "exactly 1 token_id or exactly num_slots"),
|
||||
(_init("pinned", ids=(1, 1, 2)), "distinct token_ids"),
|
||||
(_init("learned", ids=(1, 1, 2)), "distinct"),
|
||||
(_init("prompt", ids=(1, 2)), "exactly 3"),
|
||||
(_init("free", ids=(1,)), "does not accept"),
|
||||
(_init("mask", ids=(1,), noise=DiffusionNoise.ABSORBING), "does not accept"),
|
||||
(_init("pinned", ids=(32,)), "within the model vocabulary"),
|
||||
(_init("pinned", count=0, ids=(1,)), "positive"),
|
||||
],
|
||||
)
|
||||
def test_slot_modes_reject_invalid_counts_ids_and_vocab(slot, message):
|
||||
with pytest.raises(ValueError, match=message):
|
||||
slot.build()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
from torch.utils.data import DataLoader, SequentialSampler
|
||||
|
||||
from axolotl.integrations.diffusion_decision.datasets import DecisionDataset
|
||||
from axolotl.integrations.diffusion_decision.records import DecisionCanvas
|
||||
from axolotl.integrations.diffusion_decision.slot_sampling import DecisionDraw
|
||||
from axolotl.integrations.diffusion_decision.training_collator import (
|
||||
DecisionTrainingCollator,
|
||||
)
|
||||
from axolotl.model_support.diffusion import (
|
||||
DiffusionLayout,
|
||||
FirstPositionAlignment,
|
||||
)
|
||||
|
||||
from tests.integrations.diffusion_decision.helpers import (
|
||||
make_canvas,
|
||||
make_rows,
|
||||
make_spec,
|
||||
)
|
||||
|
||||
|
||||
def _spec(layout):
|
||||
return make_spec(
|
||||
layout=layout,
|
||||
first_position_alignment=FirstPositionAlignment.REQUIRES_PREDECESSOR,
|
||||
max_canvas=8 if layout is DiffusionLayout.ENCODER_CANVAS else None,
|
||||
max_context=None,
|
||||
)
|
||||
|
||||
|
||||
def _canvas(prompt, canvas, positions, name):
|
||||
return make_canvas(
|
||||
prompt,
|
||||
canvas,
|
||||
positions,
|
||||
allowed=(1, 2),
|
||||
question_ids=tuple(f"{name}{i}" for i in range(len(positions))),
|
||||
)
|
||||
|
||||
|
||||
def _rows():
|
||||
canvases = (
|
||||
_canvas((4, 5), (6, 7, 8, 9), (1, 3), "a"),
|
||||
_canvas((10, 11, 12), (13, 14, 15, 16), (2,), "b"),
|
||||
)
|
||||
return make_rows(canvases, weights=(1, 2))
|
||||
|
||||
|
||||
def test_full_sequence_concat_preserves_document_and_label_coordinates():
|
||||
batch = DecisionTrainingCollator(_spec(DiffusionLayout.FULL_SEQUENCE))(_rows())
|
||||
assert batch["input_ids"].tolist() == [
|
||||
[4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
|
||||
]
|
||||
assert batch["document_ids"].tolist() == [[0] * 6 + [1] * 7]
|
||||
assert batch["position_ids"].tolist() == [[0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 6]]
|
||||
assert batch["decision_label_rows"].tolist() == [[0, 0], [0, -1]]
|
||||
assert batch["decision_label_positions"].tolist() == [[3, 5], [11, -1]]
|
||||
assert batch["decision_question_mask"].tolist() == [[True, True], [True, False]]
|
||||
assert torch.equal(
|
||||
batch["canvas_update_mask"],
|
||||
batch["canvas_corruptible_mask"] & ~batch["canvas_input_pinned_mask"],
|
||||
)
|
||||
assert [q.position for e in batch["decision_examples"] for q in e.questions] == [
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
]
|
||||
assert batch["decision_sources"] == ("source-0", "source-1")
|
||||
assert batch["decision_slot_counts"].tolist() == [0, 0]
|
||||
assert batch["decision_draws"] == (None, None)
|
||||
|
||||
|
||||
def test_full_sequence_nested_packing_preserves_documents_labels_and_attention():
|
||||
rows = _rows()
|
||||
batch = DecisionTrainingCollator(_spec(DiffusionLayout.FULL_SEQUENCE))(
|
||||
[[rows[0]], [rows[1]]]
|
||||
)
|
||||
|
||||
assert batch["document_ids"].tolist() == [[0] * 6 + [1] * 7]
|
||||
assert batch["position_ids"].tolist() == [[0, 1, 2, 3, 4, 5, 0, 1, 2, 3, 4, 5, 6]]
|
||||
assert batch["decision_label_rows"].tolist() == [[0, 0], [0, -1]]
|
||||
assert batch["decision_label_positions"].tolist() == [[3, 5], [11, -1]]
|
||||
|
||||
from axolotl.core.trainers.diffusion_lm.backends.full_sequence import (
|
||||
FullSequenceBackend,
|
||||
)
|
||||
|
||||
packed = FullSequenceBackend(mask_token_id=99).pack(
|
||||
batch["input_ids"],
|
||||
batch["document_ids"],
|
||||
batch["semantic_validity"],
|
||||
batch["position_ids"],
|
||||
)
|
||||
attention = packed["attention_mask"]
|
||||
assert attention[0, 0, 0, 5]
|
||||
assert attention[0, 0, 6, 12]
|
||||
assert not attention[0, 0, 0, 6]
|
||||
assert not attention[0, 0, 6, 5]
|
||||
|
||||
|
||||
def test_multipack_dataloader_covers_each_decision_once():
|
||||
from axolotl.utils.samplers import MultipackBatchSampler, get_dataset_lengths
|
||||
|
||||
rows = [
|
||||
{**row, "length": len(row["canvas"].prompt_ids) + len(row["canvas"].canvas_ids)}
|
||||
for row in _rows()
|
||||
]
|
||||
dataset = DecisionDataset(rows, {"per_batch_stratified": False})
|
||||
sampler = MultipackBatchSampler(
|
||||
SequentialSampler(dataset),
|
||||
lengths=get_dataset_lengths(dataset),
|
||||
batch_max_len=20,
|
||||
batch_size=1,
|
||||
bin_size=20,
|
||||
group_size=2,
|
||||
num_processes=1,
|
||||
sequential=True,
|
||||
drop_last=True,
|
||||
)
|
||||
batches = list(
|
||||
DataLoader(
|
||||
dataset,
|
||||
batch_sampler=sampler,
|
||||
collate_fn=DecisionTrainingCollator(_spec(DiffusionLayout.FULL_SEQUENCE)),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(batches) == 1
|
||||
assert batches[0]["decision_sources"] == ("source-0", "source-1")
|
||||
assert batches[0]["document_ids"].tolist() == [[0] * 6 + [1] * 7]
|
||||
|
||||
|
||||
def test_encoder_canvas_keeps_logical_positions_for_postpack_remap():
|
||||
batch = DecisionTrainingCollator(_spec(DiffusionLayout.ENCODER_CANVAS))(_rows())
|
||||
logical = batch["diffusion_batch"]
|
||||
assert logical.encoder_input_ids.tolist() == [[4, 5, 0], [10, 11, 12]]
|
||||
assert batch["decision_logical_rows"].tolist() == [[0, 0], [1, -1]]
|
||||
assert batch["decision_label_positions"].tolist() == [[1, 3], [2, -1]]
|
||||
assert batch["decision_label_rows"].tolist() == [[-1, -1], [-1, -1]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout", [DiffusionLayout.FULL_SEQUENCE, DiffusionLayout.ENCODER_CANVAS]
|
||||
)
|
||||
def test_collator_preserves_sampled_slot_counts_and_draw_identity(layout):
|
||||
rows = _rows()
|
||||
draws = (DecisionDraw(0, 2, 17), DecisionDraw(1, 2, 18))
|
||||
for row, count, draw in zip(rows, (1, 3), draws, strict=True):
|
||||
row["decision_slot_count"] = count
|
||||
row["decision_draw"] = draw
|
||||
batch = DecisionTrainingCollator(_spec(layout))(rows)
|
||||
assert batch["decision_slot_counts"].tolist() == [1, 3]
|
||||
assert batch["decision_draws"] == draws
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout", [DiffusionLayout.FULL_SEQUENCE, DiffusionLayout.ENCODER_CANVAS]
|
||||
)
|
||||
def test_collator_emits_prompt_slot_metadata_for_sampled_counts(layout):
|
||||
rows = _rows()
|
||||
for row, count, ids in zip(rows, (1, 3), ((7,), (8, 9, 10)), strict=True):
|
||||
canvas = row["canvas"]
|
||||
row["canvas"] = DecisionCanvas(
|
||||
prompt_ids=(*ids, *canvas.prompt_ids),
|
||||
canvas_ids=canvas.canvas_ids,
|
||||
label_positions=canvas.label_positions,
|
||||
allowed_ids=canvas.allowed_ids,
|
||||
question_ids=canvas.question_ids,
|
||||
targets=canvas.targets,
|
||||
pinned_mask=canvas.pinned_mask,
|
||||
semantic_mask=canvas.semantic_mask,
|
||||
slot_mask=canvas.slot_mask,
|
||||
template_length=canvas.template_length,
|
||||
prompt_slot_mask=(True,) * count + (False,) * len(canvas.prompt_ids),
|
||||
)
|
||||
row["decision_slot_count"] = count
|
||||
row["decision_draw"] = DecisionDraw(count, 0, count)
|
||||
batch = DecisionTrainingCollator(_spec(layout))(rows)
|
||||
|
||||
if layout is DiffusionLayout.FULL_SEQUENCE:
|
||||
assert batch["decision_prompt_slot_mask"].tolist() == [
|
||||
[True, False, False, False, False, False, False, True, True, True]
|
||||
+ [False] * 7
|
||||
]
|
||||
assert torch.all(
|
||||
batch["decision_prompt_slot_mask"] <= batch["canvas_input_pinned_mask"]
|
||||
)
|
||||
else:
|
||||
assert batch["decision_prompt_slot_mask"].tolist() == [
|
||||
[True, False, False, False, False, False],
|
||||
[True, True, True, False, False, False],
|
||||
]
|
||||
assert torch.equal(
|
||||
batch["decision_prompt_input_pinned_mask"],
|
||||
batch["diffusion_batch"].encoder_validity,
|
||||
)
|
||||
assert not torch.any(
|
||||
batch["decision_prompt_slot_mask"]
|
||||
& batch["diffusion_batch"].encoder_ar_valid_mask
|
||||
)
|
||||
assert batch["diffusion_batch"].encoder_ar_valid_mask[0, 1]
|
||||
|
||||
|
||||
def test_builder_compatible_constructor_accepts_standard_collator_options():
|
||||
collator = DecisionTrainingCollator(
|
||||
SimpleNamespace(pad_token_id=7),
|
||||
spec=_spec(DiffusionLayout.FULL_SEQUENCE),
|
||||
padding=True,
|
||||
max_length=None,
|
||||
pad_to_multiple_of=64,
|
||||
label_pad_token_id=-100,
|
||||
return_tensors="pt",
|
||||
)
|
||||
assert collator.pad_token_id == 7
|
||||
assert collator.pad_to_multiple_of == 64
|
||||
|
||||
|
||||
def test_full_sequence_rejects_microbatch_above_payload_capacity():
|
||||
collator = DecisionTrainingCollator(
|
||||
_spec(DiffusionLayout.FULL_SEQUENCE), physical_payload_capacity=12
|
||||
)
|
||||
with pytest.raises(ValueError, match="physical payload capacity"):
|
||||
collator(_rows())
|
||||
@@ -0,0 +1,202 @@
|
||||
"""Native multi-question schemas preserve option semantics and soft targets."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import torch
|
||||
|
||||
from axolotl.integrations.diffusion_decision.adapters import normalize_record
|
||||
from axolotl.integrations.diffusion_decision.adapters.typed_decisions import normalize
|
||||
from axolotl.integrations.diffusion_decision.loss import (
|
||||
DecisionLabelExample,
|
||||
DecisionLabelQuestion,
|
||||
decision_label_loss,
|
||||
label_target_from_mapping,
|
||||
)
|
||||
|
||||
|
||||
def sample():
|
||||
return {
|
||||
"id": "a",
|
||||
"state": '{"trace":"observed"}',
|
||||
"workflow": "trace_review",
|
||||
"questions": json.dumps(
|
||||
{
|
||||
"s": {"type": "score", "criteria": ["low", "high"]},
|
||||
"c": {
|
||||
"type": "choice",
|
||||
"criteria": {"act": "Proceed", "wait": "Delay"},
|
||||
},
|
||||
"b": {
|
||||
"type": "noul",
|
||||
"criteria": {"true": "Supported", "false": "Refuted"},
|
||||
},
|
||||
}
|
||||
),
|
||||
"gold": json.dumps(
|
||||
{
|
||||
"s": {"probabilities": {"1": 0.8, "0": 0.2}},
|
||||
"c": {"probabilities": {"wait": 0.4, "act": 0.6}},
|
||||
"b": {"probabilities": {"false": 1, "true": 0}},
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def test_native_multi_question_preserves_all_semantics():
|
||||
result = normalize(sample())
|
||||
assert result["questions"]["s"]["levels"] == ["low", "high"]
|
||||
assert result["labels"]["s"] == {
|
||||
"kind": "dist",
|
||||
"candidate_indices": [0, 1],
|
||||
"candidate_ids": ["0", "1"],
|
||||
"probs": [0.2, 0.8],
|
||||
"other_probability": 0.0,
|
||||
}
|
||||
assert result["questions"]["c"]["options"] == [
|
||||
{"name": "act", "description": "Proceed"},
|
||||
{"name": "wait", "description": "Delay"},
|
||||
]
|
||||
assert result["labels"]["c"]["probs"] == [0.6, 0.4]
|
||||
assert result["labels"]["b"] == {
|
||||
"kind": "dist",
|
||||
"candidate_indices": [1],
|
||||
"candidate_ids": ["no"],
|
||||
"probs": [1.0],
|
||||
"other_probability": 0.0,
|
||||
}
|
||||
assert result["questions"]["b"]["criteria"]["true"] == "Supported"
|
||||
assert result["family"] == "trace_review"
|
||||
assert result["state"] == {"trace": "observed"}
|
||||
assert result["source_metadata"]["provenance"] == {
|
||||
"dataset": "LocalLLaMA/typed-decisions",
|
||||
"workflow": "trace_review",
|
||||
"split": None,
|
||||
"row_split": None,
|
||||
"source_id": "a",
|
||||
}
|
||||
|
||||
|
||||
def test_missing_probability_rejected_without_inventing_mass():
|
||||
row = sample()
|
||||
gold = json.loads(row["gold"])
|
||||
del gold["s"]["probabilities"]["0"]
|
||||
row["gold"] = json.dumps(gold)
|
||||
with pytest.raises(ValueError, match="each alternative"):
|
||||
normalize(row)
|
||||
|
||||
|
||||
def test_more_than_four_soft_alternatives_preserve_full_distribution():
|
||||
row = sample()
|
||||
questions = json.loads(row["questions"])
|
||||
gold = json.loads(row["gold"])
|
||||
questions["s"]["criteria"] = ["a", "b", "c", "d", "e"]
|
||||
gold["s"]["probabilities"] = {"0": 0.1, "1": 0.2, "2": 0.3, "3": 0.15, "4": 0.25}
|
||||
row["questions"] = json.dumps(questions)
|
||||
row["gold"] = json.dumps(gold)
|
||||
target = normalize(row)["labels"]["s"]
|
||||
assert target == {
|
||||
"kind": "dist",
|
||||
"candidate_indices": [0, 1, 2, 3, 4],
|
||||
"candidate_ids": ["0", "1", "2", "3", "4"],
|
||||
"probs": [0.1, 0.2, 0.3, 0.15, 0.25],
|
||||
"other_probability": 0.0,
|
||||
}
|
||||
result = decision_label_loss(
|
||||
torch.zeros((1, 1, 5)),
|
||||
[
|
||||
DecisionLabelExample(
|
||||
(
|
||||
DecisionLabelQuestion(
|
||||
0,
|
||||
(0, 1, 2, 3, 4),
|
||||
label_target_from_mapping(target),
|
||||
),
|
||||
)
|
||||
)
|
||||
],
|
||||
torch.tensor([[True]]),
|
||||
label_softmax="both",
|
||||
)
|
||||
assert torch.isfinite(result.loss)
|
||||
|
||||
|
||||
def test_missing_question_target_rejected():
|
||||
row = sample()
|
||||
row["gold"] = "{}"
|
||||
with pytest.raises(ValueError, match="exactly one target"):
|
||||
normalize(row)
|
||||
|
||||
|
||||
def test_training_is_fail_closed_to_declared_train_split():
|
||||
result = normalize_record(
|
||||
"diffusion_decision.typed_decisions",
|
||||
sample(),
|
||||
training=True,
|
||||
source_split="train",
|
||||
)
|
||||
assert result["source_metadata"]["provenance"]["split"] == "train"
|
||||
with pytest.raises(ValueError, match="declared train split"):
|
||||
normalize_record(
|
||||
"diffusion_decision.typed_decisions",
|
||||
sample(),
|
||||
training=True,
|
||||
source_split="test",
|
||||
)
|
||||
|
||||
|
||||
def test_row_split_mismatch_is_rejected():
|
||||
row = sample()
|
||||
row["split"] = "test"
|
||||
with pytest.raises(ValueError, match="disagrees"):
|
||||
normalize_record(
|
||||
"diffusion_decision.typed_decisions",
|
||||
row,
|
||||
training=True,
|
||||
source_split="train",
|
||||
)
|
||||
|
||||
|
||||
def test_native_hard_smoothing_preserves_question_semantics():
|
||||
row = sample()
|
||||
gold = json.loads(row["gold"])
|
||||
gold["b"]["smoothing"] = 0.1
|
||||
row["gold"] = json.dumps(gold)
|
||||
baseline = normalize(sample())
|
||||
result = normalize(row)
|
||||
assert result["questions"] == baseline["questions"]
|
||||
assert result["labels"]["s"] == baseline["labels"]["s"]
|
||||
assert result["labels"]["c"] == baseline["labels"]["c"]
|
||||
assert result["labels"]["b"] == {"kind": "hard", "gold_idx": 1, "smoothing": 0.1}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("smoothing", [True, -0.1, 1.0, float("nan")])
|
||||
def test_native_hard_smoothing_validates_metadata(smoothing):
|
||||
row = sample()
|
||||
gold = json.loads(row["gold"])
|
||||
gold["b"]["smoothing"] = smoothing
|
||||
row["gold"] = json.dumps(gold)
|
||||
with pytest.raises(ValueError, match="hard smoothing"):
|
||||
normalize(row)
|
||||
|
||||
|
||||
def test_native_hard_smoothing_rejects_soft_targets():
|
||||
row = sample()
|
||||
gold = json.loads(row["gold"])
|
||||
gold["s"]["smoothing"] = 0.1
|
||||
row["gold"] = json.dumps(gold)
|
||||
with pytest.raises(ValueError, match="one-hot"):
|
||||
normalize(row)
|
||||
|
||||
|
||||
def test_native_hard_smoothing_uses_one_hot_probability_not_gold_label():
|
||||
row = sample()
|
||||
gold = json.loads(row["gold"])
|
||||
gold["b"]["label"] = "true"
|
||||
gold["b"]["smoothing"] = 0.1
|
||||
row["gold"] = json.dumps(gold)
|
||||
assert normalize(row)["labels"]["b"] == {
|
||||
"kind": "hard",
|
||||
"gold_idx": 1,
|
||||
"smoothing": 0.1,
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Avoid expensive tokenizer vocabulary scans when model metadata is available."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.diffusion_decision import datasets
|
||||
|
||||
|
||||
def test_canvas_row_uses_model_vocabulary_without_counting_tokenizer(monkeypatch):
|
||||
class Tokenizer:
|
||||
eos_token_id = 11
|
||||
pad_token_id = 11
|
||||
|
||||
def __len__(self):
|
||||
raise AssertionError("unexpected full tokenizer vocabulary count")
|
||||
|
||||
captured = {}
|
||||
|
||||
def build(_tokenizer, _record, **kwargs):
|
||||
captured.update(kwargs)
|
||||
return SimpleNamespace(prompt_ids=(1,), canvas_ids=(100,) * 128)
|
||||
|
||||
monkeypatch.setattr(datasets, "build_decision_canvas", build)
|
||||
monkeypatch.setattr(datasets, "permute_record", lambda record, **kwargs: record)
|
||||
monkeypatch.setattr(
|
||||
datasets, "decision_example_from_canvas", lambda *args, **kwargs: None
|
||||
)
|
||||
cfg = {
|
||||
"model_config": {"vocab_size": 131072},
|
||||
"diffusion_lm": {"mask_token_id": 100},
|
||||
"diffusion_decision": {},
|
||||
}
|
||||
row = datasets._canvas_row(
|
||||
Tokenizer(),
|
||||
{"source": "test"},
|
||||
cfg,
|
||||
1.0,
|
||||
spec=SimpleNamespace(max_canvas=128, noise=datasets.DiffusionNoise.ABSORBING),
|
||||
)
|
||||
assert captured["vocab_size"] == 131072
|
||||
assert row["length"] == 129
|
||||
|
||||
|
||||
def test_missing_model_metadata_counts_vocabulary_once_per_pool(monkeypatch):
|
||||
class Tokenizer:
|
||||
calls = 0
|
||||
|
||||
def __len__(self):
|
||||
self.calls += 1
|
||||
return 131072
|
||||
|
||||
tokenizer = Tokenizer()
|
||||
seen = []
|
||||
|
||||
def build_row(_tokenizer, record, cfg, weight, **kwargs):
|
||||
seen.append(kwargs["vocab_size"])
|
||||
return record
|
||||
|
||||
monkeypatch.setattr(datasets, "_canvas_row", build_row)
|
||||
rows = [{"source": "test", "id": str(index)} for index in range(20)]
|
||||
actual, dropped = datasets._canvas_rows(tokenizer, rows, {}, {}, None)
|
||||
assert actual == rows
|
||||
assert dropped == 0
|
||||
assert seen == [131072] * 20
|
||||
assert tokenizer.calls == 1
|
||||
@@ -819,6 +819,56 @@ def test_trainable_token_adapter_routes_to_efficient_merge(monkeypatch, tmp_path
|
||||
adapter_has_trainable_token_deltas(missing_delta)
|
||||
|
||||
|
||||
def test_merged_export_preserves_valid_decision_manifest(tmp_path, monkeypatch):
|
||||
from collections import OrderedDict
|
||||
from types import SimpleNamespace
|
||||
|
||||
from axolotl.integrations.base import PluginManager
|
||||
from axolotl.integrations.diffusion_decision.manifest import build_decision_manifest
|
||||
from axolotl.integrations.diffusion_decision.plugin import DiffusionDecisionPlugin
|
||||
|
||||
adapter = tmp_path / "adapter"
|
||||
adapter.mkdir()
|
||||
cfg = DictDefault(
|
||||
{
|
||||
"base_model": "nvidia/Nemotron-Labs-Diffusion-3B",
|
||||
"revision_of_model": "0d51902da1f8869f83413ce642fab402fa5641e0",
|
||||
"model_config_type": "nemotron_labs_diffusion",
|
||||
"output_dir": str(tmp_path),
|
||||
"diffusion_lm": {"canvas_width": 128, "mask_token_id": 100},
|
||||
"diffusion_decision": {
|
||||
"layout": "prompt_slots",
|
||||
"latent": {"mode": "none", "num_slots": 0},
|
||||
},
|
||||
}
|
||||
)
|
||||
tokenizer = SimpleNamespace(
|
||||
bos_token_id=1,
|
||||
eos_token_id=2,
|
||||
pad_token_id=0,
|
||||
unk_token_id=3,
|
||||
mask_token_id=100,
|
||||
all_special_ids=[0, 1, 2, 3, 100],
|
||||
)
|
||||
model = SimpleNamespace(
|
||||
config=SimpleNamespace(
|
||||
mask_token_id=100,
|
||||
_commit_hash="0d51902da1f8869f83413ce642fab402fa5641e0",
|
||||
)
|
||||
)
|
||||
manifest = build_decision_manifest(cfg, tokenizer=tokenizer, model=model)
|
||||
source = manifest.write_to(adapter)
|
||||
merged = tmp_path / "merged"
|
||||
merged.mkdir()
|
||||
monkeypatch.setattr(
|
||||
PluginManager.get_instance(),
|
||||
"plugins",
|
||||
OrderedDict({"diffusion_decision": DiffusionDecisionPlugin()}),
|
||||
)
|
||||
PluginManager.get_instance().post_lora_merge(cfg, str(adapter), str(merged))
|
||||
assert (merged / source.name).read_bytes() == source.read_bytes()
|
||||
|
||||
|
||||
def test_native_generation_dispatches_and_preserves_legacy_seam():
|
||||
from types import SimpleNamespace
|
||||
|
||||
|
||||
Reference in New Issue
Block a user