# How tweet-factory scores drafts

tweet-factory does not run Phoenix. It cannot know a hypothetical viewer’s predicted probabilities. After the model returns tweet text, `src/rank/score.ts` scores each variant on this machine. The 0–100 number is that local score.

The weights are the published defaults in `src/rank/weights.ts`, copied from [`xai-org/x-algorithm`](https://github.com/xai-org/x-algorithm) commit [`cb45b55`](https://github.com/xai-org/x-algorithm/commit/cb45b55). The same module builds `OPTIMIZER_SYSTEM` in `src/prompts/optimizer.ts`, so the prompt and the scorer share one table. The long write-up is [`X_ALGORITHM_BOOST_PLAYBOOK.md`](https://github.com/abhirupghosh/tweet-factory/blob/main/X_ALGORITHM_BOOST_PLAYBOOK.md). [The weights page](x-algorithm-engagement-weights.md) is the ranker. This page is the draft score.

## What `tf generate` asks for

`src/llm/generate.ts` builds one user message: your persona, measured voice stats, optional flush and Notion text, a calibration block from `tweets.db`, and an instruction to return JSON. The system prompt is `voiceSystem()` plus `OPTIMIZER_SYSTEM`. `voiceSystem()` ends with the style rules from `src/style/tells.ts` (no em dashes, no AI tells). Before scoring, every variant goes through the [style check](commands.md#style-check), so the score is computed on the rewritten text.

The default ask is 4 ideas (`DEFAULT_IDEA_COUNT`). `--n` changes that count. Each idea has 2 or 3 variants, aimed at different heads (copy-link share, reply, quote, or follow). The prompt tells the model to leave `score` at 0 and to post one variant per idea.

Zod accepts `ideas[]` (each variant has `text`, plus optional `aim` and `note`) or a legacy flat `drafts[]`. Any score in that object is discarded. `src/rank/bundle.ts` keeps at most 3 variants per idea, drops empty text, and sorts variants and ideas by the local score, then by weighted total. It writes a flat `drafts` array back with `text`, `score`, `signal` (the strongest positive head), and a note.

`onboarding.json` stores `ideas`, that `drafts` array, and `calibration` (`reliable`, `reason`, `window`).

Direct API calls use temperature 0.8 and a token cap of 8,000 so four ideas fit. The default backend is Claude Code (`claude -p --output-format json --tools ""`), with a 240 second timeout and no API key. Details are on [LLM providers](llm.md). Scraped tweets stay inside a reference fence, and the subprocess has no built-in tools.

## The 0–100 formula

For each head, the scorer assigns an editorial strength from 0 to 1, then multiplies by that head’s published weight. The weighted total is the sum of those contributions. It can go negative when a negative head fires.

The displayed score is that total divided by the sum of the positive weights in effect, times 100, rounded, and clamped to 0–100.

Text that does not start with `@` is treated as an original a mutual follow would see. Reply’s weight is then 20 (the published 5 plus the +15 mutual-original boost), and the positive-weight sum is 55.5. Text that starts with `@` is a reply: reply’s weight is 5, the format is `reply`, and the positive-weight sum is 40.5.

Author diversity (a second post from you in one viewer’s pool is ×0.625) and the 0.75 factor on out-of-network posts and on in-network replies/reposts are serving rules. They are not extra multipliers inside `scoreDraft`. The drafts file tells you to post one variant because of them. [How a For You request is built](how-the-x-algorithm-works.md) is where those factors are defined.

Heads in the local table, in the order the file prints them:

| Head | Published weight |
|---|---:|
| Copy-link share | 20 |
| Reply | 5, or 20 on a mutual original |
| DM share | 5 |
| Quote | 5 |
| Follow | 4 |
| Repost | 1 |
| Like | 0.5 |
| Report | −234 |
| Mute | −58.8 |
| Not interested | −47.52 |
| Block | −31.2 |

Dwell, click, open-link, photo expand, and video open are published ranker heads. They are not rows in this local score. The prompt says open-link is a small positive in the ranker (+0.2) and that a URL does not zero the post. Bookmarks are not a head.

Strengths come from the draft text. A specific question, and the word “you” or “your”, raises reply strength. “thoughts?”, “agree?”, “who agrees”, and “hot take?” count as a generic question and raise it much less. A first line of 40–200 characters with at least six words, and a digit, raise copy-link strength. A line break and a post of 12–70 words raise DM-share strength. First person raises follow strength. A digit and a post of 6–40 words raise repost strength. Like strength starts at 0.4 for any non-empty draft.

Engagement-bait phrases and rage words raise report, mute, or block strength. Three or more `#` characters raise report, mute, and not-interested strength, and six or more add more report strength. Under four words raises not-interested and lowers several positive strengths. A URL (`http`, `https`, or `t.co`) withholds a 0.1 copy-link bonus. It does not apply a fixed link penalty.

If report or mute strength is at least 0.05, the note says those weights dwarf a reply when that probability is not tiny. At a score of 70 or above, the note names the strongest positive head. Below 70, it names the weakest. The CLI keeps every variant. 70 is a label in the quip, not a filter.

`X_ALGORITHM_BOOST_PLAYBOOK.md` also prints a writing checklist (reply pull 24, quote/copy-link 22, and the rest of that 100-point table) and marks it **[heuristic]**. `tf generate` does not add those points. The number in `drafts/` is the per-head formula.

## Calibration from your own posts

`src/rank/calibrate.ts` computes the rates. `src/rank/calibrateDb.ts` reads rows with `source='own'` in `tweets.db`. It does not change the published weights. When the history is thick enough, it scales four strengths — reply, quote, repost, and like — by that format’s per-impression rate divided by the account-wide rate, clamped to 0.6–1.6. The product is clamped to 0–1 again.

- Fewer than 8 own posts with `impression_count > 0`: `reliable` is false, `window` is `none`, and every scale stays at 1. The file says scoring uses the published weights with no account adjustment.
- At least 8 of those posts older than 48 hours: rates use that settled window. 48 hours is the same span as `AgeFilter`.
- Fewer than 8 settled posts, but at least 8 posts with impressions: rates use every impressed post, including ones still inside the age gate (`window` `all`).
- A format is scaled only when the account is reliable and that format has at least 4 impressed posts. Format comes from `referenced_type`: reply, quote, repost, or original.
- Bookmarks are printed as a per-impression rate. They do not scale a head.
- Up to three of your posts with at least 100 impressions, the highest reply-per-impression first, are copied into the prompt (text cut to 140 characters) and labeled as examples not to copy.

`roast()` in `src/analyze.ts` calls `scoreDraft` with no calibration. `tf analyze` prints whether the history is thick enough to calibrate. `tf analyze --roast` scores recent posts with that same uncalibrated function and prints the calibration reason above them.

## The drafts file

`src/commands/generate.ts` writes `drafts/YYYY-MM-DD.md` through `src/rank/render.ts`. The header says to post one variant, quotes the ×0.625 second-post factor, names commit cb45b55, states the score definition, then prints the calibration reason and the bookmark note.

The shape, with sample figures (this is the file layout, not a measurement from an account):

```
# Drafts — YYYY-MM-DD

## the idea

### Variant 1 — score 72 — weighted 31.40
Aim: reply

the tweet

> why this variant

| Head | Strength | Weight | Contribution |
| Reply (mutual original, +15) | 0.82 | 20.0 | 16.40 |

Weighted total 31.40. Score 72/100. strongest head: reply (mutual original, +15).
```

When calibration moved a strength, a line under the table names the format and the reply, quote, repost, and like scales.

An optional skill at `~/.claude/skills/twitter-algorithm-optimizer/` can stand in for the playbook when a person runs that skill. `tf generate` does not load it. The binary scores with `src/rank/`.

## Browser checker

[tweetfactory.sh/score](https://tweetfactory.sh/score/) is a free page that reuses `src/rank/weights.ts` and the weighted sum in `src/rank/score.ts` (`rollup`). Its strengths are a separate heuristic for a pasted draft: a question or CTA raises reply, a number or a standalone first line raises copy-link and DM share, and hot-take wording raises mute and not-interested. Hashtags do not move it. A link adds `open_link` at +0.2 and does not subtract from the other heads. Toggles apply the out-of-network factor (0.75), the mutual-original reply boost, a media flag (`photo_expand` 0.05), and the second-post factor (0.625). Signed out, the draft stays in the browser. Sign in with X reads recent post metrics, scales those four heads with the same `calibratePosts` rates the CLI uses, and stores drafts you save. The page does not call a model. Details are on the [privacy page](https://tweetfactory.sh/privacy/).

## Related

- [How the X (Twitter) algorithm works](how-the-x-algorithm-works.md)
- [How to grow on X](x-algorithm-engagement-weights.md)
- [LLM providers](llm.md)

## FAQ

### How does tweet-factory score a draft?

`tf generate` asks your LLM for four ideas, each with two or three variants, and tells it to leave scores at 0. After Zod checks the JSON, `src/rank/score.ts` scores every variant. The CLI writes `drafts/YYYY-MM-DD.md` with a per-head table and writes `onboarding.json` with ideas, drafts, and calibration. The process does not call the X ranker.

### Does the score use the 2026 open-source weights?

The coefficients do. `src/rank/weights.ts` uses the published defaults from x-algorithm commit cb45b55: copy-link share 20, reply 5 (20 on an original a mutual follow sees), DM share 5, quote 5, follow 4, repost 1, like 0.5, and the four negative heads. The 0–1 strength beside each weight is an editorial read of the draft text. It is not Phoenix’s predicted probability for a viewer.

### What does a score of 70 mean?

70 is the quip threshold in `scoreDraft`. At or above it, the note names the strongest positive head. Below it, the note names the weakest. The CLI keeps drafts under 70. The number is 100 times the weighted total, divided by 55.5 for a mutual original or 40.5 for a reply, then rounded and clamped to 0–100.

### Is there a free tweet score calculator?

Yes. [tweetfactory.sh/score](https://tweetfactory.sh/score/) scores a pasted draft in the browser with the same published weights and the same weighted-sum function. It labels the result as a heuristic estimate. Signed out, it uses the published weights with no impression history. Sign in to scale reply, quote, repost, and like from recent posts and to save drafts. Install `tf` for voice-aware drafts from your own LLM.

### When does my own tweet history change the score?

When `tweets.db` has at least eight of your posts with impressions. Reply, quote, repost, and like strengths for a format are then multiplied by that format’s per-impression rate divided by the account-wide rate, and clamped between 0.6 and 1.6, if that format has at least four impressed posts. Posts older than 48 hours are preferred once eight of them exist. Bookmarks are printed and do not scale a head. Fewer than eight impressed posts leaves every scale at 1. On [tweetfactory.sh/score](https://tweetfactory.sh/score/), the same rate math runs after you sign in with X, using recent posts from the X API.
