Interpretable stylometry for Rust: profile, compare, and explain writing style.
handprint measures how text is written — punctuation habits, sentence rhythm, word choice, character-level fingerprints — and tells you which features drive a result and where in the text they came from. It is an objective function and an explainer. It is not a verdict machine, and it does not rewrite anything.
Three entry points sit on one core:
- Forensic — link or verify authorship. Distances, length-stratified calibrated p-values, per-feature contributions, General Imposters verification with an abstention zone.
- De-AI critic — an agent scores its own draft against an AI-style reference, gets per-feature feedback with byte spans and target bands, rewrites, re-scores.
- Corpus-mimic critic — the same loop, but the target is your own writing: rewrite until the draft falls inside that author's own stylistic variation.
handprint is the objective function; the agent is the optimizer. No LLM calls in core.
crates/handprint-core the library: text, features, references, metrics, critique
crates/handprint-cli the `handprint` binary
crates/handprint-data corpus extractors: agent transcripts, Hacker News
eval/ non-circular evaluation harness (Python)
cargo install --path crates/handprint-cli # the `handprint` binary
cargo install --path crates/handprint-cli --features net # + Hacker News fetchingFit a reference on a corpus, calibrate it against a background population, then ask how unusual a distance is.
# A corpus laid out as one directory per author.
handprint fit corpus/ -o refs/hn.json \
--name hn-2015-2022 --version 2026.07 \
--features punct,sentence,function-words,ngrams
# Calibration turns distances into evidence. Without it, `compare` prints a
# number and nothing else.
handprint calibrate refs/hn.json --background background/ --metric cosine
handprint compare -r refs/hn.json accounts/main.txt accounts/throwaway.txtcosine_delta distance 0.284913 (5120 vs 412 tokens)
p_value_vs_unrelated 0.0031 p_same_author 0.4120 likelihood ratio 18.40
p_value_vs_unrelated is P(distance <= d | different authors). It is NOT the
probability the authors differ.
by family:
punct +0.118204
sentence +0.061553
mfw +0.058017
top contributions:
dimension a b share
punct:em_dash_rate 0.4102 6.8100 +0.041127
sent:len_stddev 9.8800 3.1200 +0.028410
rank scores a query against many candidates; verify runs General Imposters
with a grey zone that abstains rather than guessing.
Fit one reference on the union of an AI corpus and a human corpus, then ask handprint which direction each dimension pulls.
handprint fit union/ -o refs/de-ai.json --features punct,sentence,lexicon,mfw
handprint critique -r refs/de-ai.json --mode contrast --away union/ai draft.mdEach finding names a dimension, what the draft does, what the human side does, and which way to move. Byte spans mean an agent can patch precisely.
handprint fit mine/ -o refs/me.json --features punct,sentence,lexicon,ngrams
handprint calibrate refs/me.json --background background/ --metric burrows
while ! handprint critique -r refs/me.json draft.md > report.json; do
# your agent reads report.json and rewrites draft.md
rewrite draft.md report.json
doneExit codes follow Vale: 0 pass, 1 findings, 2 error. A report looks like this:
{
"handprint": "0.1.0",
"contract": "1",
"reference": { "name": "my-corpus", "version": "2026.07", "kind": "corpus" },
"mode": "corpus-mimic",
"iteration": 3,
"doc": {
"tokens": 412,
"length_tier": "unreliable",
"confidence": { "punct": "ok", "sentence": "low", "lexicon": "ok", "char_ngram": "none" },
"imputed_dims": 4
},
"verdict": {
"pass": false, "gate": "p_same_author >= 0.100",
"p_same_author": 0.031, "p_value_vs_unrelated": 0.412,
"distance": 1.8842, "capped_distance": 1.7710, "metric": "burrows_delta"
},
"findings": [
{ "id": "punct.em_dash_rate", "family": "punct", "severity": "high",
"observed": 8.2, "unit": "per_1k_tokens", "target_band": [0.5, 2.1],
"z": 3.4, "direction": "reduce", "spans": [[120, 123], [338, 341]],
"message": "8.20 per 1k tokens vs reference band 0.50-2.10 - reduce ~74%" },
{ "id": "lex.ai-slop.word.delve", "family": "lexicon", "severity": "high",
"observed": 4.9, "unit": "per_1k_tokens", "target_band": [0.0, 0.0],
"z": 9.7, "direction": "reduce", "spans": [[10, 15]],
"message": "4.90 per 1k tokens vs reference band 0.00-0.00 - reduce ~100%",
"fix": { "kind": "consider_replace", "alternatives": ["dig into", "examine"] } }
],
"findings_total": 23,
"guards": { "canary_ok": true, "drift_ok": true, "content_overlap": 0.87 }
}Note findings_total: everything is scored, only the worst offenders are
reported. That is deliberate — see below.
An optimizer pointed at a scorer will optimize the scorer. Five mechanisms push back:
| Guard | What it does |
|---|---|
| Top-k reporting | Every dimension is scored; ~7 are shown. The agent fixes what it sees; the gate checks what it cannot. |
| Canary family | One family (default: corpus-LM surprisal) is scored and never reported. Reported features improving while it worsens sets canary_ok = false. |
| Holistic gate | Passing is p_same_author >= threshold against the calibrated same-author distribution — never "all findings cleared". |
| Per-dimension cap | No single dimension may hold more than 25% of the distance magnitude when the gate is evaluated. |
| Drift guard | Content-word and character-n-gram overlap against the original draft, so a rewrite that deletes or re-topics the text is caught. |
- Every score decomposes into per-dimension contributions plus at most one documented global normalizer. This rules out non-linear kernels in core.
- Fitted state is data-only and
serde-serializable. Fit once, ship the model, profile anywhere. No closures or trait objects inside fitted structs. - Corpus-relative logic lives only in
fit.transformis pure. - Tokenization is explicit policy, serialized with the model.
- Scaling is a per-metric property. Delta metrics consume z-scores;
MinMax/Ruzicka consumes non-negative relative frequencies. MinMax on z-scores
is ill-defined, so the
Metricdeclares what it needs. - Lexicons and reference profiles are versioned data, never code. AI-ism vocabularies decay per model and per year; every artifact carries a date and a source manifest.
- Core stays light.
serde,unicode-segmentation,unicode-normalization,rand,thiserror. Everything else is feature-gated.
Read this section before using any number from this crate in an argument.
This is not a verdict machine. p_value_vs_unrelated is
P(distance ≤ d | different authors). It is not the probability that two
texts share an author, and reading it that way is the prosecutor's fallacy. A
p-value of 0.01 means a distance this small occurs in 1% of unrelated pairs;
what that is worth depends entirely on how many unrelated candidates exist. The
reported likelihood ratio is closer to what most people want, and is still only
a score-based likelihood ratio.
Topic confound is the dominant failure mode. PAN 2020 found topic similarity
correlated with verifier errors across every method examined; Halvani et al.
concluded that none of the evaluated authorship-verification methods is robust
against topical influence. A low distance under topic-matched conditions is weak
evidence. The robust settings are function-word-restricted vocabularies —
handprint fit --features function-words and handprint contrast --function-words — and character n-grams, which transfer better across topic
and genre than frequent words do.
The effect is easy to see. Contrasting two coding-agent transcript corpora
unrestricted surfaces decompile, ghidra, housing, city: the projects,
not the writers. The same contrast with --function-words surfaces let,
me, now, my on one side and of, your, own, itself on the other —
one model narrating its own actions in the first person, the other writing
nominally in the second. Only the second run is about style.
Short texts are unreliable, and the floors are graded. Under 2,000 words,
whole-profile attribution is below the length the literature supports (Eder
2015/2017); verification accuracy falls to chance between 250 and 500 characters
with unadjusted thresholds (Halvani et al. 2019). handprint reports a
length_tier and per-family confidence, because punctuation stays usable far
below the floor that frequent-word profiles need. Note also that with mixed
feature families the length effect can invert at the short end — families that
are undefined below their floors get imputed to the corpus mean, which makes two
unrelated short texts look alike. Either way: a small distance on a short
text is weak evidence.
The critic mode is an attack on the forensic mode. They are the same machinery pointed in opposite directions. Anything that helps an agent match a target style also helps someone impersonate an author, and the guards above raise the cost of gaming the score without eliminating it. This is stated plainly rather than hedged, because it is the honest description of what this crate is.
Profiles are dated artifacts. "delve" rose roughly fifteenfold in PubMed
abstracts between 2022 and 2024 and then declined after public mockery;
em-dash overuse is a GPT-4-era tell that barely existed in GPT-3.5; the whole
marker class is an RLHF artifact that differs per model and per version. The
built-in ai-slop lexicon is dated 2026.07 and is a seed, not a fixture. Human
reference corpora should be cut around November 2022 — AI-isms are leaking into
genuinely human usage, so a "human" reference fitted on 2024 text quietly
destroys the thing it exists to measure.
Non-native-English writing risks false positives. Detectors of this general family have been measured at over 50% false-positive rates on real TOEFL essays. handprint's features are not a detector, but they are correlated with the same surface properties, and nothing here corrects for it.
Sentence splitting is rule-based. Abbreviations outside the built-in list split early, mid-sentence ellipses split, and unpunctuated chat-register sentences merge. The errors inflate sentence-length variance rather than biasing the mean, and the same splitter runs over reference and query — so relative comparisons stay meaningful while absolute burstiness numbers do not.
Measured on a 140 KB document (cargo bench -p handprint-core):
| Operation | Throughput |
|---|---|
| Sparse L1 distance (merge walk) | ~850 Melem/s, 26× a hashmap implementation |
| Analysis only (tokenize, segment, artifact scan) | ~12 MiB/s |
+ punct |
~7.5 MiB/s |
+ sentence / + mfw |
~10 MiB/s |
+ typed char n-grams (3..=4) |
~3.4 MiB/s |
| All four families together | ~2.8 MiB/s |
The original plan targeted ≥50 MiB/s single-threaded for transform. That
target is not met. Typed character n-grams dominate: at orders 3 and 4 they
emit roughly two classified dimensions per character, and the remaining cost is
in the per-position work rather than in anything obviously wasteful. Closing the
gap would need byte-level extraction with an interned n-gram table rather than
string keys. Corpus fitting parallelizes cleanly and is the obvious next lever
(--features parallel reserves the flag; the implementation is not written).
Phases 1–9 of IDEA.md are implemented and tested: text layer, feature vectors,
eight feature families, references with dual length-stratified calibration, four
decomposable metrics, explanation and the critique contract, corrected Fightin'
Words, General Imposters, the CLI, and the corpus extractors. eval/ holds the
non-circular evaluation harness scaffold; the headline experiment in phase 10 has
not been run.
MIT OR Apache-2.0.