HTR — Human Text Reconstruction
Map LLM-rewritten scientific prose back towards the register of pre-2018 human writing.
Public research platform of UQO Working Paper WP-AI-0002 · htr.spboucher.ai
[!IMPORTANT] Outputs are synthetic reconstructions. They are a model's estimate of pre-2018-style prose given an LLM rewrite. They are not the original text and not evidence of human authorship or of AI use. Do not use HTR for AI-text detection, authorship attribution, grading or academic-integrity decisions. The deployed models are Medium-stage research models; their gains over the untouched input are small by construction (reconstruction from a paraphrase is non-identifiable).
Contents
- What it is
- Models
- Architecture
- API reference
- Deployment (MacLustr)
- Shipping a new model
- Development
- Limits and rate limits
- Honest-use notice
- Links
What it is
HTR models are trained on pairs of (LLM rewrite → human original). The originals are rights-verified scientific passages published before 2018. For a rewritten paragraph, the platform:
- protects citations, equations and URLs behind opaque placeholders,
- encodes the protected text (HTR models: 32 k SentencePiece vocabulary, ≤ 640 tokens; pretrained backbones: their own tokenizer, context as a text prefix),
- decodes a reconstruction (greedy or beam 3 for HTR models, greedy for the backbones; seeded and deterministic),
- restores the placeholders and checks numbers, citations, negations, hedges and length against the input. Every deviation is returned as a warning.
The web UI (/) is a workbench with these features:
- a model selector (compact card per model: family, parameters, speed, test Δ chrF++, fidelity, Pangram share; the default highlighted; a caveat for the backbones); the choice is kept in
localStorage, share links and history - input and output panes, with optional context (discipline, era or year, section) and the decoding mode (beam 3 for HTR models only)
- a "loading model…" state when the chosen model is not yet in memory (first call can take a minute)
- Reconstruction / Changes / Checks tabs: the plain text, a word diff, and the preservation checks; the status line names the model used
- copy,
.txtand.jsondownloads; share links (text in the URL hash, never on the server); a history of the last 10 runs inlocalStorage - light and dark themes; responsive down to 360 px; print styles
Models
All metrics are differences in chrF++ versus the identity baseline (B0, return the input unchanged) on the Medium Haiku pairs. They are exploratory, except the confirmatory test value of HTR-Tiny (95 % interval in brackets; ± as reported). Numbers / placeholders are preservation rates on the test split; Pangram is the share of 54 reconstructions the Pangram classifier labels Human.
| id | Name | Family | Params | Val Δ | Test Δ | Held-out domain Δ | Held-out period Δ | Numbers | Placeholders | Pangram Human · mean AI score · flagged | p50 latency* |
|---|---|---|---|---|---|---|---|---|---|---|---|
htr-tiny-0.3.0 |
HTR-Tiny 0.3.0 (paper's M*) | htr |
33.5 M | — | +0.44 [+0.30, +0.58] (confirmatory) | −0.10 | −0.53 | 0.991 | 0.992 | 54/54 · 0.000 · 0 % | 0.3 s |
htr-base-0.1.0 |
HTR-Base 0.1.0 | htr |
335 M | +0.83 | +0.69 ± 0.05 | +0.29 | −0.12 | 0.992 | 0.993 | 53/54 · 0.003 · 1.9 % | 0.7 (1.1 beam 3) s |
flan-t5-large-htr |
Flan-T5-Large-HTR (google/flan-t5-large) | hf-seq2seq |
783 M | +2.04 ± 0.15 | +1.79 ± 0.07 | +1.27 ± 0.18 | +1.09 ± 0.14 | 0.986 | 0.992 | 54/54 · 0.000 · 0 % | 1.5 s |
qwen2.5-1.5b-htr (default) |
Qwen2.5-1.5B-HTR (Qwen/Qwen2.5-1.5B) | hf-causal |
1.54 B | +2.19 ± 0.12 | +1.97 ± 0.06 | +1.78 ± 0.13 | +1.30 ± 0.14 | 0.997 | 0.998 | 48/54 (6 Mixed) · 0.032 · 11.1 % | 2.1 s |
* greedy decoding, ~600-character paragraph, Apple GPU (MPS) on m4mm, measured 2026-11-10 (3 000 characters: Flan-T5 ≈6 s, Qwen ≈8 s); live values at /api/stats.
Detector sensitivity (Pangram 4.1, n = 54 test passages, 2026-11-10). Human originals: 54/54 labelled Human, mean AI-likelihood 0.000; the LLM rewrites given as input: 29 Human / 25 Mixed (46 % flagged), mean 0.188. HTR-Tiny 0.3.0 and Flan-T5-Large-HTR reach 0 % flagged — 54/54 labelled Human, mean AI score 0.000; HTR-Base 53/54 (mean 0.003), Qwen2.5-1.5B-HTR 48/54 (mean 0.032). Each card stores this under metrics.pangram (n, human_share, flagged_share, mean_ai_likelihood, detector, date), and the platform shows it on the selector tiles, the comparison table and every model card. This measures one commercial detector's behaviour on a 54-passage sample. A low AI-likelihood is not evidence of human authorship, and HTR must not be used to evade AI-text detection or for integrity decisions.
Pretrained backbones (Flan-T5, Qwen). Their pretraining corpus may contain pre-2018 scientific text; the memorisation controls of the paper cover only the HTR training pairs (see its threats-to-validity section). Beam search is not offered for them (beam is mapped to greedy and the response says so). Flan-T5 reads at most 512 tokens (about 2 000 characters) and returns a truncation warning beyond that.
Each model lives in models/<id>/ with a tracked model_card.json (schema in models/README.md); the weights are not in git.
Architecture
htr-platform/
├── server.py # stdlib ThreadingHTTPServer: UI, JSON API, model registry, model cards, gzip, CSP, rate limits
├── htr/ # Python package (copy of WP-AI-0002 src/htr): inference pipeline, tokenizer, models, HF backbones
│ └── inference/backbones.py # HFSeq2SeqReconstructor, HFCausalReconstructor, load_reconstructor(dir), ModelRegistry
├── models/ # one directory per model — weights NOT in git, model_card.json (+ README.md) tracked
│ ├── README.md # model list, card schema, how to add one
│ ├── htr-tiny-0.3.0/ htr-base-0.1.0/ # model.safetensors, config.json, tokenizer.model, release_meta.json, README.md
│ ├── flan-t5-large-htr/ qwen2.5-1.5b-htr/ # Hugging Face save_pretrained directories
├── scripts/add_model.sh # laptop → node: rsync weights, restart, check /api/models, commit the card
├── static/ # index.html, app.css, app.js (single CSS + single JS, no framework),
│ │ # card.html, 404.html, favicon.svg, og.svg, site.webmanifest
├── docs/badges/ # static shields-style SVG badges used by this README
└── requirements.txt # torch, transformers, accelerate, sentencepiece, safetensors, … (inference only)- Model registry.
ModelRegistry(models/, device=HTR_DEVICE, max_resident=2, pinned=("htr-tiny-0.3.0",))discovers the sub-directories at start-up. The model flaggeddefaultin its card is loaded immediately (if it fails, HTR-Tiny takes over for the hero demo); the others load on their first request (load times are logged). At most two non-pinned models stay resident (LRU eviction,torch.mps.empty_cache()after eviction); HTR-Tiny is pinned. - Inference runs on the Apple GPU (
--device mps, envHTR_DEVICE) with one lock per model, so a slow Qwen request does not block a Tiny request. Backbone generation is capped atHTR_MAX_GEN_Sseconds (default 55) throughgeneration_config.max_time, so a 3 000-character input finishes in about a minute at most. - No third-party web dependencies. The fonts are system fonts, and the icons, logo and OpenGraph image are inline or self-hosted SVG. The CSP is
default-src 'self'and the page makes no external request. Assets are cache-busted with?v=<sha256>, servedimmutableand gzip-compressed. - Theming. The design tokens use CSS
light-dark(), and the toggle stores your choice inlocalStorage(htr.theme); the model choice ishtr.model.
API reference
Base URL: https://htr.spboucher.ai. No key and no account are needed. All responses are JSON unless noted.
| Method | Path | Description |
|---|---|---|
POST |
/api/reconstruct |
Reconstruct one passage with the chosen model |
GET |
/api/models |
Model cards with loaded, load_s, default, beam_supported, rate_limit_per_min, request counts |
GET |
/api/info |
Default model metadata and metrics, model ids, loaded ids, limits, conditioning flag |
GET |
/api/stats |
Uptime, per-model requests / errors / latency p50, p95, max, limits |
GET |
/api/examples |
Example inputs |
GET |
/health |
Liveness: {"ok": true, "model": …, "default": …, "loaded": […], "requests": …, "version": …} |
GET |
/model-card, /model-card/<id> |
Structured model card of the default model / of <id>: header band, metric tiles, Pangram block, bar comparison vs identity and the other models, training/decoding/limits/call-it sections, then the model's README.md if present (?raw returns the Markdown) |
GET |
/og.svg, /site.webmanifest, /robots.txt |
Metadata assets |
POST /api/reconstruct
Request body:
| Field | Type | Notes |
|---|---|---|
text |
string, required | 1–8 000 characters |
model |
string | model id from /api/models (default: the registry default, qwen2.5-1.5b-htr); unknown id → 400 |
discipline |
string | e.g. economics, biomedicine, finance, physics, … |
pub_year |
int | clamped to 1500–2017 |
section_role |
string | abstract, introduction, methods, results, discussion, … |
beam |
1 | 3 |
greedy (default) or beam 3 — HTR models only; mapped to greedy for the backbones (beam_requested and decoding_note say so) |
The HTR models of architecture
m1ignore context (/api/inforeturns"conditioning": falsewhen one of them is the default); the backbones receive it as a text prefix. The context fields are echoed inmetadata_used.
curl -s https://htr.spboucher.ai/api/reconstruct \
-H 'Content-Type: application/json' \
-d '{"text": "Across all specifications, the estimated elasticity remains positive and statistically significant, ranging from 0.18 to 0.24 [12].", "model": "htr-tiny-0.3.0", "discipline": "economics", "pub_year": 2015, "beam": 3}'import json, urllib.request
req = urllib.request.Request("https://htr.spboucher.ai/api/reconstruct",
data=json.dumps({"text": "…LLM-rewritten passage…", "model": "qwen2.5-1.5b-htr"}).encode(),
headers={"Content-Type": "application/json"})
r = json.load(urllib.request.urlopen(req))
print(r["model"], r["latency_s"], "s"); print(r["reconstruction"]); print(r["warnings"])Response (200):
{
"input_text": "…",
"reconstruction": "…", // synthetic text
"warnings": ["negation count changed by -1"],
"preservation": {
"placeholders_exact": 1.0, "placeholder_recall": 1.0,
"number_recall": 1.0, "number_precision": 1.0,
"negation_delta": 0.0, "hedge_delta": 0.0, "sentence_count_delta": 0.0
},
"metadata_used": {"discipline": "economics", "pub_year": 2015, "arch": "hf-causal"},
"diff_html": "… <del>…</del> <ins>…</ins> …", // word diff, HTML-escaped
"diff_stats": {"words_in": 64, "words_out": 60, "words_added": 21, "words_removed": 22, "word_similarity": 0.68},
"model": "Qwen2.5-1.5B-HTR", "model_id": "qwen2.5-1.5b-htr", "family": "hf-causal", "device": "mps",
"beam": 1, "beam_requested": 3, "decoding": "greedy",
"decoding_note": "beam search is only offered for HTR models; this backbone used greedy decoding",
"load_s": 9.8, // only present when the call had to load the model first
"latency_s": 8.4,
"notice": "Synthetic reconstruction produced by HTR. …"
}Errors return {"error": "…"} with one of these codes: 400 (invalid JSON, empty text or unknown model), 413 (more than 8 000 characters or a body over 64 kB), 429 (rate limit, with Retry-After: 60), 500 (inference failure, with model_id). Every POST response carries X-RateLimit-Limit and X-RateLimit-Remaining for the limit that applies to the chosen model.
model, model_id, family, device, beam_requested, decoding, decoding_note and load_s were added in platform v0.3.0 and are additive: v0.2 clients that omit model get the default model. v0.4.0 adds metrics.pangram to each card and pangram_reference to /api/models.
Deployment (MacLustr)
| Item | Value |
|---|---|
| Node | m4mm (Mac mini M4 Pro, 12 cores, 48 GB, LAN), directory ~/apps/htr (this git clone) |
| Process | PM2 htr-web: .venv/bin/python server.py --port 8540 --host 0.0.0.0 --device mps (env HTR_MODELS_DIR, HTR_DEVICE=mps, OMP_NUM_THREADS=4, max_memory_restart 20G) |
| Public URL | https://htr.spboucher.ai: MacLustr Tunnel (Caddy on BHS64 → WireGuard → m4mm:8540) |
| Manifest | M1M32:~/dispatch/apps/htr.json (placement prefers m4mm and avoids the WP-AI-0002 training nodes) |
| Git origin | spbgit htr-platform.git (gateway M1M32) |
The MacLustr rule is remote-first: edit on the node, restart, verify, then commit and push from the node.
ssh m4mm
export PATH=/opt/homebrew/bin:$PATH; cd ~/apps/htr
# … edit server.py / static/* …
python3 -m py_compile server.py
pm2 restart htr-web --update-env
curl -s https://htr.spboucher.ai/health
git add -A && git commit -m "…" && git push origin mainmld status htr shows the current placement. mld move htr --to <node> migrates the app and repoints the tunnel route (the weights under models/ move with the directory).
Shipping a new model
No code change is needed: the registry discovers models/<id>/ at start-up and the UI builds itself from /api/models.
- Export the model directory on the laptop (HTR release:
model.safetensors,config.json,tokenizer.model,release_meta.json,README.md; backbone: the Hugging Facesave_pretraineddirectory). - Write
model_card.jsonin that directory (fields and example inmodels/README.md:id,name,family∈htr | hf-seq2seq | hf-causal,n_params,base_model,licence,default,speed_hint,max_len/max_new_tokens,metrics,notes(backbones: keep the pretraining-corpus caveat),trained_on,exported_at). - From the laptop:The script validates the card, rsyncs the directory tobash
scripts/add_model.sh <laptop-model-dir> <id> # e.g. scripts/add_model.sh artifacts/runs/medium_llm_qwen25_3b/best qwen2.5-3b-htrm4mm:~/apps/htr/models/<id>/, restartshtr-web, waits until/api/modelslists the id, and commits + pushes the tracked card files from the node. - Check
https://htr.spboucher.ai/model-card/<id>and run onePOST /api/reconstructwith"model": "<id>". Planned ids:flan-t5-xl-htr,qwen2.5-3b-htr,htr-large.
To change the default model, set "default": true in exactly one card (and false in the previous one), then pm2 restart htr-web --update-env.
Development
cd ~/apps/htr # or any clone that has models/ and .venv
.venv/bin/python server.py --port 8541 --host 127.0.0.1 --device mps
open http://127.0.0.1:8541/ # from the laptop: ssh -L 8541:127.0.0.1:8541 m4mm--device cpuruns everything on CPU;--models /path/to/dirserves another models directory;HTR_MAX_GEN_Scaps backbone generation time.- Dependencies:
uv pip install --python .venv/bin/python -r requirements.txt(torch ≥ 2.4, transformers ≥ 4.45, accelerate). - The frontend is plain HTML, CSS and JS: no build step and no bundler. Keep
app.cssplusapp.jsunder 70 kB. - The CSP forbids inline scripts. The server passes page data (examples, demo, info, models) to the frontend in
<script type="application/json" id="boot">.
Limits and rate limits
| Limit | Value |
|---|---|
| Characters per request | 8 000 (HTR models read about 640 tokens, Flan-T5 512; longer inputs are truncated, with a warning for Flan-T5) |
| Request body | 64 kB |
| Rate | 30 requests per minute per client IP (X-Forwarded-For); 10 per minute for the pretrained backbones |
| Concurrency | 1 inference at a time per model (other requests for the same model wait) |
| Generation time | backbones capped at 55 s per request (HTR_MAX_GEN_S) |
| Latency | see the model table above; live per-model p50/p95 at /api/stats |
| Storage | none: the server keeps no texts, cookies or analytics. The log records only client IP, model id, length, latency and warning count |
Honest-use notice
Reconstruction from a paraphrase is non-identifiable: many originals share one rewrite. A model's output is one plausible estimate. The platform is a research artefact for studying how LLM rewriting changes scientific prose, and a suggestion tool for authors who check every change. Verify every number, citation and negation against your source. Never present the output as the original text or as proof of who wrote it. The pretrained backbones add one caveat: their pretraining corpus may contain pre-2018 scientific text, and the paper's memorisation controls cover only the HTR training pairs.
Links
- Paper: Human Text Reconstruction: Learning the Inverse of LLM-Induced Transformations in Scientific Writing, UQO Working Paper WP-AI-0002 (Simon-Pierre Boucher, Université du Québec en Outaouais, 2026)
- Live platform: https://htr.spboucher.ai · model cards: https://htr.spboucher.ai/model-card · https://htr.spboucher.ai/api/models
- Repository: spbgit
htr-platform.git(gitsrv:srv/git/htr-platform.git) - Licence: code and HTR weights under Apache-2.0; the fine-tuned backbones keep the Apache-2.0 licence of
google/flan-t5-largeandQwen/Qwen2.5-1.5B. Use is restricted to non-commercial research where CC-BY-NC sources were included in training (seemodels/<id>/LICENSE-MODEL.txtand the cards).