Living document. Every agent/harness picking up this project should start here. Also, you must keep updating as you go further.
pico-type is a tiny (~1.5M params), byte-level, multi-head content classifier. Input: up to 1024 raw bytes (clipboard text, file bytes, image header, etc.). Output: structured label set in one forward pass.
Built per the locked-in plan in docs/PLAN.md (recovered from the original opencode session — see §11).
Existing clipboard tools are regex-only (ClipGate, 13 types) or LLM-powered (needs Ollama, GB-scale). Existing tiny classifiers do one job. No model does all of them in one sub-5MB forward pass with a multi-head output.
- HuggingFace model (Apache-2.0) with 4 Matryoshka tiers ✅ (ONNX exported)
- Python CLI (
picotype) ✅ - Gradio Space app ✅ (
gradio_app.py) - MCP server ✅ (
model/pico_type/mcp_server.py) - pytest smoke tests ✅ (
tests/test_smoke.py) - HF model card ✅ (
MODEL_CARD.md) - Badge'd README ✅
- Rust CLI (
crates/picotype/) — pending - Rust MCP server (
crates/picotype-mcp/) — pending - Browser extension, Raycast/Alfred/VSCode extensions — pending
- arXiv paper ✅ (arXiv:2608.14658 —
paper/main.tex— updated with v0.2 real-data results, PDF compiles)
Inputs (≤1024 UTF-8 bytes, masked/padded)
│
├─ ByteEmbed (256 → 96d, learned)
│
├─ 3× Conv1D block (kernel 3, 5, 7) + GELU + residual → 192d
│
├─ 2× BiAttention block (d=192, 4 heads, RoPE θ=500k)
│
├─ Pool = [mean ‖ max ‖ std] → 576d shared trunk
│
└─ 7 Matryoshka heads (Linear at 16/64/192/576 dim slices)
├─ h_coarse (12) — primary type
├─ h_modality (8) — textual / binary-image / …
├─ h_subtype (24) — JSON/YAML/CSV/HTML… (if coarse ∈ {config, markup, data})
├─ h_code_lang (62) — if coarse=code, + "undetected" fallback
├─ h_text_lang (30) — if coarse=text, + "undetected" fallback
├─ h_file_mime (90) — if coarse ∈ {image, file, archive, binary} or modality=binary_*, + "undetected"
└─ h_risk (6) — sigmoid multi-label: api_key, jwt, ssh_key, password, email, phone
| Tier | Dim slice | Params (actual) | INT8 size |
|---|---|---|---|
pico-type-tiny |
16 | 1.43M | 1.37 MB |
pico-type-small |
64 | 1.45M | 1.38 MB |
pico-type-base |
192 | 1.48M | 1.41 MB |
pico-type-pro |
576 | 1.56M | 1.49 MB |
Actual sizes came in under the plan's targets (0.5/1.5/3.5/8 MB INT8). Trunk dominates; if we need to shrink tiny further, reduce trunk_dim 192→128 or num_attn_layers 2→1.
{
"coarse": "code",
"modality": "textual",
"subtype": null,
"code_language": "python",
"text_language": null,
"file_mime": null,
"risk_flags": [],
"confidence": 0.94,
"modality_confidence": 0.91,
"model_tier": "base"
}classifier-model/
├── .venv/ # Python 3.11 venv (torch, numpy<2, safetensors, pyyaml)
├── .git/
├── checkpoints/ # best.pt, ONNX models, eval results
├── docs/
│ └── PLAN.md # Full architecture plan
├── gradio_app.py # Gradio Space app
├── model/
│ └── pico_type/
│ ├── __init__.py # re-exports public API
│ ├── labels.py # vocabularies + decode_output
│ ├── arch.py # PicoType model
│ ├── data.py # synthetic generator
│ ├── train.py # multi-task trainer
│ ├── eval.py # eval harness
│ ├── distill.py # KD pipeline
│ ├── export.py # ONNX export
│ ├── cli.py # Python CLI (picotype)
│ └── mcp_server.py # MCP server (stdio)
├── tests/
│ └── test_smoke.py # pytest smoke tests
├── spaces/
│ └── requirements.txt # HF Space dependencies
├── MODEL_CARD.md # HF model card
├── README.md # badge'd README
├── LICENSE # Apache-2.0
├── pyproject.toml
└── walkthrough.md # this file
from model.pico_type import (
PicoType, PicoTypeConfig, TIERS, # model
COARSE_LABELS, MODALITY_LABELS, SUBTYPE_LABELS,
CODE_LANG_LABELS, TEXT_LANG_LABELS,
FILE_MIME_LABELS, RISK_LABELS,
UNDETECTED, decode_output, # labels + decoder
)
from model.pico_type.arch import encode_bytes # bytes → (tokens, mask) tensorssource .venv/bin/activate
python -m model.pico_type.archThese are tied to the locked-in plan and the on-disk checkpoints. Changing them means re-training + re-publishing.
| Head | Size | Plan target |
|---|---|---|
| coarse | 12 | 12 ✓ |
| modality | 8 | 8 ✓ |
| subtype | 24 | 24 ✓ |
| code_lang | 62 | 62 ✓ |
| text_lang | 30 | 30 ✓ |
| file_mime | 90 | 90 ✓ |
| risk | 6 | 6 ✓ |
subtypeis only valid whencoarse ∈ {config, markup, data}code_langis only valid whencoarse == codetext_langis only valid whencoarse == textfile_mimeis only valid whencoarse ∈ {image, file, archive, binary}ormodalitystarts withbinary_riskis always valid (multi-label)
- For
code_lang/text_lang/file_mime: the model has N logits (62/30/90), no separate "undetected" class. Ifmax_softmax < undetected_threshold(default 0.4), the decoder returns"undetected"instead of the argmax label. - For
risk: per-class sigmoid; class is flagged ifsigmoid(logit) ≥ risk_threshold(default 0.5).
The shared trunk emits a 576d vector. Each MatryoshkaHead slices x[..., :tier_dim] then applies its tier-specific nn.Linear(tier_dim, num_classes). All 4 tier linears live in the model (so a single checkpoint contains all tiers); at inference, only the chosen tier's linears are loaded — parameter_count(tier) reflects this.
0is the pad byte (matches the 0th row of the embedding)max_bytesdefault 1024- Inputs longer than
max_bytesare truncated (not rejected) - Mask is 1 for real bytes, 0 for pad — passed to attention and pool
# venv already created at .venv
source .venv/bin/activate
python --version # 3.11
python -c "import torch, numpy, safetensors, yaml; print('ok')"Python 3.14 was tried first but has no torch wheels — we use Python 3.11. This is documented because the failed attempt is in the opencode session history.
- Training: 1700 steps completed. eval_loss improved 6.33 (step 0) → 2.72 (step 800) → 1.97 (step 1700, best.pt). Subtype/code_lang accuracy dipped (overfitting), text_lang/risk improved.
- v0.2 training (real data): 6700 steps (1700 synthetic + 5000 mixed), best eval_loss 1.95 at step 6500. code_lang 60.3% (The Heap), text_lang 98.2% (Wikipedia).
- ONNX export: All 4 tiers re-exported from step 1700 best.pt (~200KB each, FP32, opset 18).
- ONNX single-file + IR 8:
scripts/make_single_onnx.pymerges external weights and lowers IR to 8 (onnxruntime-web WASM rejects IR ≥ 9). Identical outputs vs original (verified). Uploaded toeulogik/pico-type-v02(old.onnx.datafiles deleted; gradio app no longer downloads them). - HF Model:
huggingface.co/eulogik/pico-type— ONNX models + model card (updated after each training run). - HF Space:
huggingface.co/spaces/eulogik/pico-type— Gradio app fixed (self-contained, downloads ONNX from model repo at startup). Label drift fixed: gradio's label tables had drifted from the trained model (file_mime88 vs 90, wrong order;text_langtail wrong) —scripts/gen_labels_artifacts.pynow generatesLABELS.py+workers/src/labels.jsfrommodel/pico_type/labels.py(single source of truth). Space RUNNING, verified via gradio API. - Cloudflare Worker (
workers/): free HTTP API for all 4 tiers — onnxruntime-web 1.16.3 (WASM), single-file models fetched from HF with Cache API edge caching, CORS open. Verified byte-identical to Python (Node harness + parity check: all labels match, confidences within 0.002). Deploy:npx wrangler login && npx wrangler deploy(needs user's CF account). Caveats: localwrangler devhas no wasm on this Mac (workerd), and free CF plan ~10ms CPU quota is likely too small for ~100ms inference → paid $5/mo for reliable serving. - PyPI:
pico-typev0.1.0 published. v0.1.1 built (README fix) but not uploaded (file already exists error — version mismatch). - GitHub:
github.com/eulogik/pico-type—mainbranch +v0.1tag. CI passes (pytest + ruff).
- MPS OOM: batch_size 64 causes MPS OOM (19+ GiB allocated). Fixed by reducing to batch_size=16 and
train_tiers=('base',). - MPS graph cache: Writes to system
/tmp, was filling disk when free space <1GB. ~9GB now available, OK. - Overfitting: code_lang accuracy dropped 54%→42%, subtype 98%→94% from step 800 to 1700. May need more data diversity or lower LR.
- All 7 vocabularies (sizes match plan exactly, asserted at import time)
decode_output(logits, tier, undetected_threshold, risk_threshold)— respects all gating rules, applies UNDETECTED thresholdHEAD_NUM_CLASSESdict for heads that need to query class countslabel_for(head, idx)helper- Constants:
UNDETECTED,ALL_HEADS,SUBTYPE_GATED_BY,CODE_LANG_GATED_BY,TEXT_LANG_GATED_BY,FILE_MIME_GATED_BY
PicoTypeConfigdataclass with all hyperparamsByteEmbed—nn.Embedding(256, 96)init normal std=0.02ConvBlock—Conv1d → LayerNorm → GELU → Dropout, residual via 1×1 projection when dims changeRotaryPosEmb— precomputed cos/sin cache, auto-grows if seq exceeds cacheAttnBlock— pre-norm, fused QKV, RoPE on Q/K,F.scaled_dot_product_attention, MLP w/ 4× expansionPool—mean ‖ max ‖ stdover masked positions (handles padding correctly)MatryoshkaHead—nn.ModuleDictofnn.Linearper tierPicoType— top-level modelencode_bytes(data, max_len, pad)—bytes → (LongTensor[B, L], LongTensor[B, L])smoke_test()— instantiates model, runs a forward, returns param counts__main__block runs the smoke test- NaN fix in AttnBlock:
F.scaled_dot_product_attentionwith a boolean mask where all entries are False (sample has no padding) produces NaN on CPU. Fixed by converting to float (-inffor masked positions, 0 for valid) and guarding withmask.all().item(). SeeAttnBlock.forwardfor the guard.
SyntheticGenerator(seed)— generates one balanced sample at a time from 11 buckets: code, text, config, markup, data, link, error, image, file, secret, archive, binary- 11 generator methods (
_gen_code,_gen_text,_gen_config,_gen_markup,_gen_data,_gen_link,_gen_error,_gen_image,_gen_file,_gen_secret,_gen_archive,_gen_binary) Sampledataclass withdata: bytes, label fields (int for single-label heads,list[int]for risk),IGNORE_INDEX = -100for gated headsSyntheticDataset(generator, size)— wraps generator forDataLoadercompatibility- Code templates for all 62 languages across 18 syntax groups (Python-like, C-like, JS-like, Lisp-like, etc.) — uses
re.subwith${kind}placeholders - Word lists for all 30 text languages
- Binary magic-byte headers for PDF, ZIP, GZIP, ELF, SQLite, Parquet, TIFF, PNG, JPEG, WASM, DEB, TTF, plus archive formats (7z, RAR, TAR, XZ, BZ2)
_detect_riskruns on text samples (AWS key, JWT, SSH key, password detection)label_counts()returns class distribution for debuggingsmoke_test()generates 500 samples and prints coverage per head
- Re-exports the public API (already present in the repo when we recovered)
TrainConfigdataclass — lr, warmup, total_steps, batch_size, grad_clip, per-head weights, etc.collate_fn(batch)— pads variable-length samples, createsinput_ids,attention_mask,labelsdictMultiTaskLoss(weights)— CE per head (ignore_index=-100 for gated heads) + BCE for risk. Skips any head with zero valid labels in batch (returns 0.0). Applies per-head weights (coarse=3.0, modality=2.0, code_lang=1.5, text_lang=1.5, others=1.0).get_lr(step, config)— linear warmup → cosine decaytrain(config)— full training loop:SyntheticGenerator+SyntheticDatasetfor train/eval- AdamW, separate param groups (trunk w/ weight_decay, Matryoshka heads w/o)
- BF16 AMP (CUDA) or FP32 (CPU/MPS)
- Gradient clipping at 1.0
- Logs every
log_everysteps, eval everyeval_every, save everysave_every - Saves
best.pt(lowest eval loss),final.pt, plus periodicstep_{N}.pt
load_checkpoint(path, model, optimizer)— loads state dict- Known issues fixed: NaN in SDP with all-valid mask (use float
-infinstead of boolean mask); NaN from CE on all-ignore labels (skip head); Python 3.14 has no torch wheels (use 3.11)
EvalConfigdataclass — checkpoint, tier, eval_size, batch_sizeevaluate(config)— generates synthetic eval set, runs forward pass for all 7 heads- Per-head
HeadMetrics: accuracy, per-class precision/recall/F1, confusion matrix RiskMetrics: per-class average precision (sklearn-free implementation)run_eval()— CLI:python -m model.pico_type.eval --eval-size 1000 --checkpoint checkpoints/best.pt_average_precision(y_true, y_scores)— area under PR curve via trapezoidal rule
| # | File | What it does | Status |
|---|---|---|---|---|
| 1 | data.py | Synthetic generator + dataset for multi-head training. 11 buckets, all 12 coarse classes, code/word templates for all 62/30 langs. | ✅ done |
| 2 | train.py | Multi-task trainer. AdamW + cosine, bf16, per-head loss weighting, gradient clipping, checkpoint save/load. resume_from field for continuing training. | ✅ done |
| 3 | eval.py | Eval harness: per-head accuracy/PRF1, confusion matrix, risk AP, inference timing. CLI entry point. | ✅ done |
| 4 | distill.py | KD from per-head teachers (deberta-v3-small, CodeBERTa-lang-id, xlm-roberta-lang-detect). T=2.0, α=0.7. | ✅ done |
| 5 | export.py | ONNX export (opset 18), int8, tract, gguf. | ✅ done |
| 6 | cli.py | Python CLI (picotype) — stdin/file/clipboard input → ONNX inference → JSON output | ✅ done |
| 7 | mcp_server.py | MCP server (stdio transport) for Claude/Cursor/VSCode | ✅ done |
| 8 | gradio_app.py | Gradio Space app for HF Spaces | ✅ done |
| 9 | tests/test_smoke.py | pytest smoke tests (8 tests: arch, data, ONNX, CLI, labels) | ✅ done |
| 10 | MODEL_CARD.md | HuggingFace model card with eval results | ✅ done |
| 11 | README.md | Overhauled with badges, perf table, deploy links | ✅ done |
| 12 | spaces/requirements.txt | Dependencies for HF Space deployment | ✅ done |
| 13 | HF Model + Space | Published to huggingface.co/eulogik/pico-type (model) and /spaces/eulogik/pico-type (Space) | ✅ done |
| 14 | PyPI publish | pico-type v0.1.0 on PyPI (README not rendering; v0.1.1 built) | ✅ done |
| 15 | crates/picotype/ | Rust CLI w/ ONNX runtime. | ✅ done |
| 16 | crates/picotype-mcp/ | Rust MCP server (stdio + Streamable HTTP). | pending |
| 17 | extensions/* | Chrome MV3 scaffolded, Raycast, Alfred, VSCode. | pending |
| 18 | workers/ | Cloudflare Worker HTTP API (free tier) — all 4 tiers, parity-verified vs Python. Deploy: npx wrangler login && npx wrangler deploy. | ✅ done (not yet deployed) |
| 18 | paper/ | arXiv LaTeX (paper/main.tex) — updated to v0.2 with real-data results (code_lang 60.3%, text_lang 98.2%), 95% CIs, per-language tables, comparisons vs fastText/CLD2/Linguist/Pygments, data distribution, 20 references. PDF compiles clean. | ✅ done |
| 19 | Training | 6700 steps completed (1700 synthetic + 5000 mixed), best eval_loss 1.95 (step 6500), MPS (batch=16, base tier). Real data: 8709 code + 5000 text samples. ONNX exported to eulogik/pico-type-v02. | ✅ done |
| 20 | arXiv | Submitted Thu 30 Jul 2026 (cs.AI primary, cross-listed cs.CL/cs.CR/cs.IR/cs.LG, CC BY 4.0). | ✅ done |
| 21 | Docs correction | Size/latency claims fixed across paper (main.tex v2 draft), README.md, MODEL_CARD.md, paper/v02_card.md, HF org card, docs page: single-file FP32 sizes 9.09–9.61 MB (was "203–206 KB" graph-only); latency ~18 ms on M2 CPU (was "5.5–9.8 ms / <12 ms"); comparisons corrected (vs Linguist 1.6×, vs Pygments ~5×). HF main repo artifacts swapped to single-file IR-8, .onnx.data deleted; docs page 95.2% → 98.3%. | ✅ done |
- HF handle:
pico-type(dash) for model card,picotype(no dash) for CLI binary. Proposed, not confirmed. - Tier naming:
tiny/small/base/pro(matches Sentence-Transformers convention). Proposed, not confirmed. - License: Apache-2.0 (matches CommonLingua base). Proposed, not confirmed.
- arXiv target:
cs.CL(primary) +cs.LG. Co-authors: open question. - Tagline: "One tiny model, one forward pass, every clipboard." Proposed, not confirmed.
from model.pico_type import PicoType, PicoTypeConfig
cfg = PicoTypeConfig(max_bytes=1024)
model = PicoType(cfg)
print(model.tier_sizes()) # {tiny: 1434344, small: 1445480, base: 1475176, pro: 1564264}from model.pico_type.arch import encode_bytes
from model.pico_type.labels import decode_output
model.eval()
x, mask = encode_bytes(b'def hi(): return 1', max_len=1024)
with torch.no_grad():
logits = model(x, mask, tier='base')
out = decode_output(logits, tier='base')
# {'coarse': ..., 'modality': ..., 'subtype': ..., 'code_language': ...,
# 'text_language': ..., 'file_mime': ..., 'risk_flags': [...],
# 'confidence': ..., 'modality_confidence': ..., 'model_tier': 'base'}Use parameter_count(tier) to get the param count for that tier. To build a release checkpoint, you would: train full model → for each tier, save only trunk.* + heads.*.linears.{tier}.* → export.
The user was working on this project in opencode. The session (ses_16dd3d39fffer9xnBUQYBS3u5z — "Tiny model for clipboard content classification") crashed mid-execution while writing model/pico_type/arch.py. Opencode had to be re-installed; the user thought files might be lost.
They weren't. The full session data was recovered intact from:
~/.local/share/opencode/opencode.db(262MB SQLite)~/.local/share/opencode/storage/session_diff/ses_16dd3d39fffer9xnBUQYBS3u5z.json(128MB JSON)- The trash (
~/.Trash/opencode) only contained opencode Desktop app data (different product, irrelevant to the CLI session).
From the recovery we:
- Extracted the full plan →
docs/PLAN.md - Re-wrote
arch.py(the file the crashed session was aborting on) andlabels.py(never written in original session) - Wrote this
walkthrough.mdso the next agent/harness has full context
- "I want to build a really tiny model which categorises/classifies content. eg if we pass clipboard copied content, it should classify that as text, image, rich text, link, code(with language name), file with file type etc. Deep research the existing models on huggingface etc. go through new research papers and find an opportunity / gap to make this model in the most efficient manner plus make it really popular. feel free to suggest anything"
- "continue asking questions and ahead. btw, the text language should be identified too like code language. if language not detected, it should simply return text + undetected or code undetected"
- "go"
- 23 messages, 95 parts
- Plan had been finalized (10KB markdown)
- Approved with "go"
- Switched to
buildagent - Set up Python 3.11 venv, installed torch/numpy/safetensors/pyyaml
- Created the full directory tree (
model/pico_type/,model/configs/,crates/picotype/, etc.) - Wrote
model/pico_type/__init__.py✅ - Wrote
arch.py(the file the user originally saw being written) — wait, the original session was aborted on the arch.py write. The__init__.pyis in the repo. We re-wrote arch.py from scratch using the plan + the small preview from the original write tool input.
- Python 3.14 has no torch wheels. Always use the venv's Python 3.11.
- Don't double-count Matryoshka head params when iterating
named_modules()—ModuleDictis visited separately from its children. Usenamed_parameters()and check.linears.{tier}.in the name. - Gating heads are not always-on.
subtype,code_lang,text_lang,file_mimemust mask their loss when not applicable. The decoder handles this; the trainer must too. UNK/undetected is a decoder-side decision, not a model class. The model has N logits; the decoder thresholds.