1Обзор
TokForge — config-driven corpus tokenization and shard distribution framework. Сервер скачивает source-файлы датасетов из HuggingFace, токенизирует их токенизатором HuggingFaceTB/SmolLM2-1.7B, пишет raw-шарды uint16 без NPY-заголовка, заливает в HF bucket domofon/datasets, ведёт реестр (корпуса, версии, шарды, source-диапазоны) и выдаёт Modal-аккаунтам персональные наборы шардов (allocation) с lease/TTL, отчётами о consumption и возвратом неиспользованного в пул.
Публичная документация: Swagger UI · ReDoc · OpenAPI JSON: /openapi.json. Все страницы доступны по HTTP без авторизации; все API-запросы, кроме /healthz, требуют заголовок Authorization: Bearer <TOKFORGE_API_KEY>.
| Параметр | Значение |
|---|---|
| Base URL (внешний) | http://193.148.56.44:8787 |
| Base URL (локальный) | http://127.0.0.1:8787 |
| API key по умолчанию | tokforge-local-7f3a9c21d4e6 |
| Bucket | domofon/datasets (публичный) |
| Прямые ссылки на файлы bucket | https://huggingface.co/buckets/domofon/datasets/resolve/<path> (GET 200, Range 206) |
| Токенизатор | HuggingFaceTB/SmolLM2-1.7B, vocab 49152, tokenizer_sha e6d44ded9564218b, max id 49151 < 65536 |
| Шард по умолчанию | 125 000 000 токенов (~250MB), raw uint16, size == tokens*2 |
2Быстрый старт
# 1. health (без auth)
curl -s http://193.148.56.44:8787/healthz
# → {"ok":true,"db":true,"bucket":true}
# 2. авторизованный запрос
curl -s -H 'Authorization: Bearer tokforge-local-7f3a9c21d4e6' \
http://193.148.56.44:8787/api/v1/jobs
# 3. создать run
curl -s -H 'Authorization: Bearer tokforge-local-7f3a9c21d4e6' -H 'Content-Type: application/json' \
-X POST http://193.148.56.44:8787/api/v1/runs -d '{"config":{...run-конфиг...}}'
# 4. создать job (тело оборачивается в {"config": ...})
curl -s -H 'Authorization: Bearer tokforge-local-7f3a9c21d4e6' -H 'Content-Type: application/json' \
-X POST http://193.148.56.44:8787/api/v1/jobs -d '{"config":{...job-конфиг...}}'
3Формат ошибок
Все ошибки — JSON вида:
{"error": {"code": "not_enough_shards", "message": "недостаточно свободных шардов для allocation", "details": {"available_tokens": 200031}}}
| HTTP | code | Ситуация |
|---|---|---|
| 400 | invalid_job_config, invalid_run_config, requested_tokens_too_small/large | схема не прошла Pydantic-валидацию; диапазоны, prefix, лимиты |
| 401 | unauthorized | нет/неверный Bearer-токен |
| 403 | account_mismatch | account в запросе не совпадает с владельцем allocation |
| 404 | job_not_found, run_not_found, allocation_not_found, corpus_not_found | объект не найден |
| 409 | job_exists, run_exists, job_status_conflict, not_enough_shards, allocation_expired, allocation_status_conflict, consumption_conflict, allocation_renew_failed | конфликт состояния/ресурсов |
| 422 | — | ошибка валидации тела запроса FastAPI (стандартный detail) |
| 502 | hf_unavailable | HF Hub недоступен (live-справочник) |
| 500 | internal_error | необработанное исключение |
4Jobs — токенизация датасетов
Жизненный цикл job: validating (POST) → префлайт worker'ом (токенизатор, source-range, overlap, prefix) → queued → running (CAS) → done / failed / cancelled. Один активный job; остальные — FIFO по created_at. Heartbeat каждые 10 с; если старше 10 минут — API помечает failed (code worker_heartbeat_lost).
validating. Тяжёлый префлайт делает worker. Ответ 202. Поддержан idempotency_key.// запрос: тело = {"config": {...}, "idempotency_key": "opt"}
{"config": {
"schema": "tokforge.job/v1", "job_id": "cosmo2-v5", "corpus": "cosmo2",
"labels": ["pretrain", "synthetic", "en"],
"source": {"type": "hf_dataset", "dataset": "HuggingFaceTB/smollm-corpus",
"config": "cosmopedia-v2", "split": "train", "text_field": "text",
"files": {"from": 75, "to": 90}},
"tokenizer": {"repo_id": "HuggingFaceTB/SmolLM2-1.7B", "revision": null},
"output": {"format": "npy-raw", "dtype": "uint16", "shard_tokens": 125000000,
"bucket": "domofon/datasets", "prefix": "cosmo2-v5", "version": "v5",
"tags": {"license": "Apache-2.0"}},
"limits": {"target_tokens": 6000000000, "max_source_files": 20},
"options": {"workers": 10, "batch_size": 2048, "chunk_size": 512}
}}
// ответ 202
{"job_id":"cosmo2-v5","status":"validating","queued_at":"2026-08-15T22:00:00Z"}
validating/queued/running (CAS). Worker останавливается между файлами/шардами. Временные файлы НЕ удаляются.failed/cancelled. attempt += 1, статус queued, прогресс сохраняется (resume идемпотентен: чекпоинт после каждого файла и шарда, загруженные шарды не дублируются)./config/, -config-, prefix config/; если config-фильтр пуст — берутся все parquet) и по split (basename split-…); диапазон [from, to) не должен пересекаться с завершёнными job'ами того же (dataset, config, split); prefix должен быть свободен в bucket и в БД; vocab токенизатора обязан помещаться в uint16 (< 65536); tokenizer_sha фиксируется в progress.5Runs — политика аллокаций
{"config": {…}}). Ответ {"run_id":"domofon-v5","status":"active"}.allocations_total, tokens_granted, tokens_consumed.{"patch": {…}}; можно менять default_requested_tokens, lease_ttl_s, allow_partial_reissue. Применяется к НОВЫМ allocation.// run-конфиг (examples/run_domofon_v5.json)
{"schema": "tokforge.run/v1", "run_id": "domofon-v5", "description": "Domofon-1B v5 rotation",
"allocation": {
"default_requested_tokens": 1500000000, "min_requested_tokens": 125000000,
"max_requested_tokens": 5000000000, "strategy": "balanced",
"groups": ["cosmo2-v5", "fineweb-edu-v5", "finemath-v5", "kaggle-v3", "chatml-v2"],
"lease_ttl_s": 21600, "renew_before_s": 3600, "allow_partial_reissue": false}}
6Allocations — персональные наборы шардов
Жизненный цикл: reserved (создана, TTL пошёл) → active (activate при старте train, TTL продлевается) → отчёты fully_consumed/partial → completed (complete) / released (release) / expired (TTL истёк, фоновый sweeper раз в 60 с). Один шард не может быть в двух активных allocation. Неиспользованные шарды возвращаются в пул (available), частично использованные остаются partial и автоматически не выдаются.
balanced — round-robin по группам из groups, внутри группы по возрастанию idx, перебор на один шард допускается; выбор и резервирование — в одной SQLite-транзакции (BEGIN IMMEDIATE). requested_tokens = override или default_requested_tokens, проверка min/max. При нехватке — 409 not_enough_shards с available_tokens.// запрос
{"run_id":"domofon-v5","client":"modal","account":"hexecaci65",
"attempt_id":"run5-att141","requested_tokens_override":1500000000,
"idempotency_key":"run5-att141-prepare-187800"}
// ответ 201
{"allocation_id":"alloc_9f2c11ab","status":"reserved","expires_at":"2026-08-16T01:00:00Z",
"granted_tokens":1500000000,
"shards":[{"path":"cosmo2-v5/data-00000-of-00008.npy",
"url":"https://huggingface.co/buckets/domofon/datasets/resolve/cosmo2-v5/data-00000-of-00008.npy",
"tokens":125000000,"bytes":250000000,"sha256":"abc…"}],
"dataset_selection":{"corpora":[{"group":"cosmo2-v5","shards":{"list":[0,1,2,3]}}],
"order":"interleave_default"},
"fingerprint":"ds1:1a2b3c4d5e6f7081"}
shards: {"list":[…]} (явный персональный список, не range/all), совместим с kd/dataset_resolve.py. fingerprint = ds1: + sha256(canon), canon = строки path\tbytes по всем шардам, отсортированным по path.{"account":…,"attempt_id":…}. reserved→active, expires_at = now + lease_ttl_s. Чужой account → 403.allocation_expired.fully_consumed → шард consumed навсегда; partial → шард partial с partial_tokens, в пул автоматически не выдаётся. Для каждого события пишется shard_events. Шард обязан принадлежать allocation, иначе 409.{"account":"hexecaci65","attempt_id":"run5-att141","reported_at":"2026-08-15T22:00:00Z",
"events":[{"path":"cosmo2-v5/data-00000-of-00008.npy","event":"fully_consumed",
"tokens_consumed":125000000,"step_from":187800,"step_to":188300,
"meta":{"source":"source_mix_callback"}},
{"path":"cosmo2-v5/data-00001-of-00008.npy","event":"partial",
"tokens_consumed":30000000,"step_from":188300,"step_to":188500}]}
available, allocation → completed, история в shard_events. release — то же, но статус released (для «упал до старта», steps_completed=0).idempotency_key в теле (jobs/allocations) — повторный запрос возвращает сохранённый ответ (24 часа, таблица idempotency).7Inventory — реестр
error. Ничего не удаляет и не перезаписывает.8Форматы данных
8.1 .npy-raw (шард)
dtype=uint16, БЕЗ NPY-заголовка: первый байт файла — первый токен. Инвариант после seal: size == tokens * 2. Читается np.memmap(path, dtype=np.uint16). Каждый документ кодируется целиком (add_special_tokens=False, BOS не добавляется), после каждого документа — trailing EOS id 0. Максимальный id < 65536. Финальные имена data-00000-of-NNNNN.npy присваиваются только после завершения токенизации всего job (total всегда фактический).
8.2 Sidecar <prefix>/_meta/<shard>.json
{"engine":"tokforge","engine_version":"0.1.0","shard":"data-00000-of-00008.npy",
"path":"cosmo2-v5/data-00000-of-00008.npy","format":"npy-raw","dtype":"uint16",
"num_docs":173000,"tokens":125000000,"size":250000000,"sha256":"abc…",
"eos_token_id":0,"max_token_id":49151,"tokenizer":"HuggingFaceTB/SmolLM2-1.7B",
"tokenizer_revision":null,"tokenizer_sha":"e6d44ded9564218b",
"corpus":"cosmo2","version":"v5","labels":["pretrain","synthetic","en"],
"source_range":[75,90],"license":"Apache-2.0"}
8.3 Агрегаты
<prefix>/_aggregate.json: engine, prefix, corpus, version, shards, docs, tokens, bytes, files[], source_range, source_dataset, source_config, created_at. <prefix>/_tags.json: corpus, version, labels, status=complete, license, created_by. tokforge/registry.json в корне bucket — read-only снимок SQLite (корпуса, shards_total, tokens_total, updated_at). Источник истины — SQLite; bucket-файлы — артефакты.
9CLI
tokforge submit examples/job_cosmo2_v5.json # отправить job
tokforge jobs | job <job_id> | cancel <job_id> | retry <job_id>
tokforge inventory [--dataset D] [--config C]
tokforge shards [--prefix X] [--status S]
tokforge runs [--create run.json]
tokforge allocate --run domofon-v5 --account acct1 --attempt-id att1 [--requested-tokens N]
tokforge worker | reconcile | stats
Берёт TOKFORGE_BASE_URL/TOKFORGE_API_KEY из окружения или из configs/server.json. Установка: ./venv/bin/pip install -e /srv/tokforge.
10Деплой и доступ
Сервисы запущены в tmux (systemd в контейнере degraded; unit-файлы готовы в deploy/):
tmux new-session -d -s tokforge-api 'cd /srv/tokforge && ./venv/bin/uvicorn tokforge.api.app:app --host 0.0.0.0 --port 8787'
tmux new-session -d -s tokforge-worker 'cd /srv/tokforge && ./venv/bin/python -m tokforge.worker'
Слушает 0.0.0.0:8787; фаервол не блокирует (ufw inactive, iptables ACCEPT). Проверено через публичный IP: http://193.148.56.44:8787/healthz → 200. Без TLS.
11Правила безопасности и инварианты
- Запрещены любые деструктивные операции (rm/unlink) в коде и командах; очистка staging — только перемещением в
staging/archive/<job_id>/. - Все записи файлов атомарные (
<name>.tmp→os.replace); перезапись bucket-артефактов запрещена (конфликт sha256/размера → fail job). - Локальный source-файл считается готовым только при размере > 1MB; битые файлы перемещаются в
archive/<job>/corrupt/. - Смена статусов — только CAS (UPDATE … WHERE status=? + rowcount); все мутации пишут events; никаких DELETE.
- Секреты (HF-токен, API key) в логи не пишутся (проверено grep'ом: 0 вхождений).
- Resume идемпотентен: чекпоинт после каждого файла/шарда; загруженный шард с совпавшим sha256 не загружается повторно; незавершённый tmp — в
archive/<job>/interrupted/.
12Статус сборки
- Тесты:
pytest -q→ 43 passed (включая реальный smoke ingest: stanfordnlp/imdb → bucket). - Реальные артефакты в
domofon/datasets: prefixtokforge-test-v1…v13(116+ шардов, sidecar/aggregate/tags у каждого). - kill -9 worker в середине job → heartbeat_lost → retry → resume без дублей (проверено реально на v11: 20 шардов, единый of-00020, 42 файла в bucket).
- tokenizer_sha
e6d44ded9564218bсовпадает со спецификацией.