{
  "$comment": "Conformance vectors for X/Twitter weighted character counting. Expected values are derived from the published rules and stated as arithmetic in each `why`; they are not snapshots of any implementation. See README.md, including how to falsify them.",
  "version": 1,
  "generated": "2026-09-01",
  "categories": {
    "basic": "Plain text in the light ranges. If these fail, nothing else matters.",
    "heavy": "Characters outside the light ranges, which cost 2. CJK is the common case and the reason a 200-character Japanese post does not fit.",
    "emoji": "The one-emoji-one-unit rule. This is where most implementations are wrong, and the failure is always in the same direction: too high.",
    "url": "The flat 23. The WEIGHT is canonical; which substrings are links is where honest implementations differ — see not-a-url.",
    "not-a-url": "Text that must NOT be charged 23. Dotted tokens are everywhere in ordinary prose, and a spurious link is a worse error than a missed one.",
    "normalisation": "The same visible string must cost the same however it was encoded.",
    "boundary": "The exact edges of the published ranges. Off-by-one lives here.",
    "structural": "Empty strings, whitespace, and the degenerate inputs that produce a crash rather than a wrong number."
  },
  "vectors": [
    {
      "name": "empty",
      "category": "structural",
      "text": "",
      "expected": 0,
      "why": "Nothing costs nothing."
    },
    {
      "name": "single-space",
      "category": "structural",
      "text": " ",
      "expected": 1,
      "why": "U+0020 is inside 0x0000–0x10FF, so 1."
    },
    {
      "name": "ascii-hello",
      "category": "basic",
      "text": "hello",
      "expected": 5,
      "why": "Five Latin letters, all weight 1."
    },
    {
      "name": "ascii-sentence",
      "category": "basic",
      "text": "The quick brown fox jumps over the lazy dog.",
      "expected": 44,
      "why": "44 characters, every one inside the first light range."
    },
    {
      "name": "newlines-count",
      "category": "basic",
      "text": "one\ntwo\nthree",
      "expected": 13,
      "why": "Line feeds are U+000A — inside the light range, and they are charged, not ignored."
    },
    {
      "name": "latin-accents-precomposed",
      "category": "basic",
      "text": "café",
      "expected": 4,
      "why": "U+00E9 is a single code point below 0x1100, so 1 like the rest."
    },
    {
      "name": "cyrillic",
      "category": "basic",
      "text": "привет",
      "expected": 6,
      "why": "Cyrillic sits at U+0400–U+04FF, inside the first light range. Weight 1, which surprises people who assume \"non-Latin means 2\"."
    },
    {
      "name": "hebrew",
      "category": "basic",
      "text": "שלום",
      "expected": 4,
      "why": "U+05D0–U+05EA, inside the first light range."
    },
    {
      "name": "thai",
      "category": "basic",
      "text": "สวัสดี",
      "expected": 6,
      "why": "Thai is U+0E00–U+0E7F, inside the light range. Six code points, six units — note this is more than the four grapheme clusters a reader sees."
    },
    {
      "name": "japanese",
      "category": "heavy",
      "text": "こんにちは",
      "expected": 10,
      "why": "Five kana at U+3053 upward, all above 0x10FF, so 2 each."
    },
    {
      "name": "chinese",
      "category": "heavy",
      "text": "你好世界",
      "expected": 8,
      "why": "Four CJK ideographs, 2 each."
    },
    {
      "name": "korean",
      "category": "heavy",
      "text": "안녕하세요",
      "expected": 10,
      "why": "Five precomposed Hangul syllables at U+AC00 upward, 2 each."
    },
    {
      "name": "mixed-latin-cjk",
      "category": "heavy",
      "text": "hello こんにちは",
      "expected": 16,
      "why": "5 Latin (5) + 1 space (1) + 5 kana at 2 (10) = 16."
    },
    {
      "name": "emoji-simple",
      "category": "emoji",
      "text": "😀",
      "expected": 2,
      "why": "One emoji, weight 2. Also the case everyone already passes."
    },
    {
      "name": "emoji-with-variation-selector",
      "category": "emoji",
      "text": "❤️",
      "expected": 2,
      "why": "U+2764 plus U+FE0F. Two code points, ONE emoji, so 2 — not 1+1 and not 2+1."
    },
    {
      "name": "emoji-skin-tone",
      "category": "emoji",
      "text": "👍🏽",
      "expected": 2,
      "why": "Thumbs up plus a skin-tone modifier is one emoji at 2, not two emoji at 4."
    },
    {
      "name": "emoji-flag",
      "category": "emoji",
      "text": "🇨🇴",
      "expected": 2,
      "why": "Two regional indicators forming one flag: 2, not 4. A per-code-point counter reports 4."
    },
    {
      "name": "emoji-zwj-mechanic",
      "category": "emoji",
      "text": "🧑‍🔧",
      "expected": 2,
      "why": "Person + ZWJ + wrench = one emoji at 2. Per code point it is 200+100+200 = 5 after dividing by scale, and 5 is the number reported in twitter-text#369."
    },
    {
      "name": "emoji-zwj-family",
      "category": "emoji",
      "text": "👨‍👩‍👧‍👦",
      "expected": 2,
      "why": "Four people joined by three ZWJs — seven code points, one emoji, 2. Per code point it is (200*4 + 100*3)/100 = 11. If you get 11, you fell through to the per-code-point path. If you get 7, you counted code points and forgot the weights. If you get 11 in Python, `len()` gave you 7 and something else went wrong too."
    },
    {
      "name": "emoji-two-adjacent",
      "category": "emoji",
      "text": "😀😀",
      "expected": 4,
      "why": "Two separate emoji, 2 each. Guards against a cluster walker that swallows neighbours."
    },
    {
      "name": "emoji-in-prose",
      "category": "emoji",
      "text": "ship it 🚀",
      "expected": 10,
      "why": "\"ship it \" is 8 at weight 1, plus one emoji at 2."
    },
    {
      "name": "emoji-keycap",
      "category": "emoji",
      "text": "1️⃣",
      "expected": 2,
      "why": "Digit + U+FE0F + U+20E3 is one keycap emoji at 2, not three units."
    },
    {
      "name": "url-https-short",
      "category": "url",
      "text": "https://x.com",
      "expected": 23,
      "why": "Flat 23 regardless of length — 13 characters charged as 23. Links can cost MORE than they are long."
    },
    {
      "name": "url-https-long",
      "category": "url",
      "text": "https://example.com/a/very/long/path?with=query&and=more#fragment",
      "expected": 23,
      "why": "Same flat 23. 64 characters charged as 23."
    },
    {
      "name": "url-www-no-scheme",
      "category": "url",
      "text": "www.example.com",
      "expected": 23,
      "why": "A www. host with no scheme is still linkified."
    },
    {
      "name": "url-bare-domain",
      "category": "url",
      "text": "example.com",
      "expected": 23,
      "why": "THE ONE THAT UNDERCOUNTS. No scheme, no www., still a link, still 23. A matcher requiring http(s):// or www. reports 11 and tells the author a post fits that the API will reject."
    },
    {
      "name": "url-bare-domain-with-path",
      "category": "url",
      "text": "example.com/pricing",
      "expected": 23,
      "why": "The path is part of the link, so still one 23 rather than 23 plus the path."
    },
    {
      "name": "url-in-prose",
      "category": "url",
      "text": "read this: https://example.com now",
      "expected": 38,
      "why": "\"read this: \" is 11 characters at weight 1, the link is a flat 23, and \" now\" is 4. 11 + 23 + 4 = 38. The text either side of a link must keep its own weighting."
    },
    {
      "name": "url-two-links",
      "category": "url",
      "text": "https://a.com https://b.com",
      "expected": 47,
      "why": "23 + 1 space + 23 = 47."
    },
    {
      "name": "url-adjacent-to-cjk",
      "category": "url",
      "text": "こんにちは https://example.com",
      "expected": 34,
      "why": "5 kana at 2 (10) + 1 space + 23 = 34. Checks that the link split does not lose the weighting of the text around it."
    },
    {
      "name": "not-a-url-eg",
      "category": "not-a-url",
      "text": "e.g. this is prose",
      "expected": 18,
      "why": "\"e.g.\" is not a link. Charging it 23 would report 37 for an 18-unit sentence and send an author rewriting copy that fits."
    },
    {
      "name": "not-a-url-filename",
      "category": "not-a-url",
      "text": "see index.js for details",
      "expected": 24,
      "why": ".js is not in the TLD list, so this is 24 plain characters. A counter matching any dotted token charges 23 for the filename."
    },
    {
      "name": "not-a-url-version",
      "category": "not-a-url",
      "text": "version 2.78.0 shipped",
      "expected": 22,
      "why": "A version string is not a domain. The leading-digit guard is what stops this."
    },
    {
      "name": "not-a-url-sentence-end",
      "category": "not-a-url",
      "text": "It is done.Then we ship.",
      "expected": 24,
      "why": "A missing space after a full stop must not manufacture a domain out of two words."
    },
    {
      "name": "not-a-url-almost-tld",
      "category": "not-a-url",
      "text": "example.coma",
      "expected": 12,
      "why": ".coma is not a TLD, so this is twelve ordinary characters — which means \"example.com\" costs 23 and \"example.coma\" costs 12, and ADDING a character made the post cheaper. Surprising, correct, and the clearest demonstration that link detection is decided on the whole token rather than incrementally."
    },
    {
      "name": "not-a-url-ellipsis",
      "category": "not-a-url",
      "text": "wait...what",
      "expected": 11,
      "why": "Consecutive dots are not a hostname."
    },
    {
      "name": "nfc-decomposed-accent",
      "category": "normalisation",
      "text": "café",
      "expected": 4,
      "why": "\"cafe\" + U+0301 combining acute normalises under NFC to \"café\", four code points at weight 1. Without normalisation this is 5, so the same visible word costs differently depending on which editor produced it."
    },
    {
      "name": "nfc-korean-decomposed",
      "category": "normalisation",
      "text": "가",
      "expected": 2,
      "why": "Two conjoining jamo normalise under NFC to the single syllable 가 (U+AC00), which is above 0x10FF and so costs 2. Unnormalised, the jamo are also above 0x10FF and would cost 4."
    },
    {
      "name": "boundary-last-light-char",
      "category": "boundary",
      "text": "ჿ",
      "expected": 1,
      "why": "U+10FF is the last code point of the first light range. Inclusive, so 1."
    },
    {
      "name": "boundary-first-heavy-char",
      "category": "boundary",
      "text": "ᄀ",
      "expected": 2,
      "why": "U+1100 is one past the range. 2. Together with the previous case this pins the edge in both directions."
    },
    {
      "name": "boundary-general-punctuation-start",
      "category": "boundary",
      "text": " ",
      "expected": 1,
      "why": "U+2000 opens the second light range."
    },
    {
      "name": "boundary-zwj-alone",
      "category": "boundary",
      "text": "‍",
      "expected": 1,
      "why": "A bare ZWJ is the last code point of the second range, weight 1. This is the value that makes the family emoji 11 rather than 14 when counted wrongly, so it is worth pinning on its own."
    },
    {
      "name": "boundary-just-past-second-range",
      "category": "boundary",
      "text": "‎",
      "expected": 2,
      "why": "U+200E, the left-to-right mark, is one past the second range and costs 2. Invisible and expensive."
    },
    {
      "name": "boundary-en-dash",
      "category": "boundary",
      "text": "–",
      "expected": 1,
      "why": "U+2013 is inside 0x2010–0x201F. Dashes and smart quotes are cheap; that is the point of the third range."
    },
    {
      "name": "boundary-prime",
      "category": "boundary",
      "text": "′",
      "expected": 1,
      "why": "U+2032 opens the fourth range, 0x2032–0x2037."
    },
    {
      "name": "boundary-just-past-fourth-range",
      "category": "boundary",
      "text": "‸",
      "expected": 2,
      "why": "One past the last light range."
    },
    {
      "name": "exactly-280-ascii",
      "category": "structural",
      "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "expected": 280,
      "why": "The limit itself. Fits — the comparison is <=, not <."
    },
    {
      "name": "over-by-one",
      "category": "structural",
      "text": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
      "expected": 281,
      "why": "One over. The two cases together pin which way the boundary falls."
    },
    {
      "name": "cjk-140-fills-280",
      "category": "structural",
      "text": "漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢漢",
      "expected": 280,
      "why": "140 ideographs at 2 each is exactly the limit — the arithmetic behind \"a Japanese post gets half as many characters\"."
    }
  ]
}
