<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: jack</title>
    <description>The latest articles on DEV Community by jack (@jack_e2a413ec5d0bba216).</description>
    <link>https://dev.to/jack_e2a413ec5d0bba216</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4137922%2F418719d4-7c90-44de-8fe1-5751972dd457.jpg</url>
      <title>DEV Community: jack</title>
      <link>https://dev.to/jack_e2a413ec5d0bba216</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jack_e2a413ec5d0bba216"/>
    <language>en</language>
    <item>
      <title>AI cost modelling: measure your own token counts, the published table was off by 2.3x</title>
      <dc:creator>jack</dc:creator>
      <pubDate>Thu, 24 Sep 2026 16:32:50 +0000</pubDate>
      <link>https://dev.to/jack_e2a413ec5d0bba216/ai-cost-modelling-measure-your-own-token-counts-the-published-table-was-off-by-23x-2dop</link>
      <guid>https://dev.to/jack_e2a413ec5d0bba216/ai-cost-modelling-measure-your-own-token-counts-the-published-table-was-off-by-23x-2dop</guid>
      <description>&lt;p&gt;We resell image generation, so we have to know what a generation costs us before we can price it. That turned out to be a lot harder than reading the pricing page, and the two times it bit us were both cases where a number looked authoritative and was not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ratios taken from the published table were off by 2.3x on one tier
&lt;/h2&gt;

&lt;p&gt;The provider charges per token. Tokens per image depend on resolution and on a quality setting, and the pricing page does not list tokens per image. It points you at a calculator in the docs, built from a table of token counts for a 1024×1024 image.&lt;/p&gt;

&lt;p&gt;So we took our quality multipliers from that table. Low costs about 0.26 of Medium, High costs about 3.94 of Medium, price accordingly, move on.&lt;/p&gt;

&lt;p&gt;To be fair to the provider, that table is not wrong. It describes one configuration, correctly. The mistake was ours: we treated ratios from a single 1024×1024 case as if they were the model's quality multipliers everywhere.&lt;/p&gt;

&lt;p&gt;Then we measured. Nine generations, three resolutions, three quality settings, reading the token counts back out of our own usage records:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;derived from the published table&lt;/th&gt;
&lt;th&gt;measured&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;low ÷ medium&lt;/td&gt;
&lt;td&gt;0.258&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.112&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;high ÷ medium&lt;/td&gt;
&lt;td&gt;3.94&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;4.00&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;High was close enough. Low cost us less than half of what we had assumed, so we had been pricing our cheapest tier as if it were 2.3 times more expensive than it actually is.&lt;/p&gt;

&lt;p&gt;The thing that convinced me the measurement was real and not noise was that the ratios held across resolutions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;low  ÷ medium:  0.112, 0.112, 0.111
high ÷ medium:  3.998, 3.999, 3.999
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three different resolutions, same ratio to three decimal places. That is a parameter, not a sample. If those numbers had come back 0.11, 0.19, 0.14 I would have assumed measurement error and kept the published figure.&lt;/p&gt;

&lt;p&gt;That is the check I would suggest to anybody doing this. A cost ratio that is stable across conditions you varied is a real multiplier you can build a price on. One that moves around is either noise or evidence that your model has the wrong shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  A new model version at identical prices can still cost a quarter as much
&lt;/h2&gt;

&lt;p&gt;The second one is stranger, and we caught it before it shipped rather than after.&lt;/p&gt;

&lt;p&gt;The provider released a new generation of the same image model. Same price per token on every axis: image input, cached input, text input, output, all identical to the previous generation. Reading the pricing page, there is no change at all.&lt;/p&gt;

&lt;p&gt;But the quality tiers went from three named levels to five, and a third-party benchmark on a fixed 2048×1152 image reported:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;output tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;old model, &lt;code&gt;high&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;1,413&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;new model, &lt;code&gt;high&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;367&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same tier name, same price per token, about a quarter of the tokens.&lt;/p&gt;

&lt;p&gt;I could not reproduce that directly, because at the time we had not integrated the new model. What made me believe it was that it agreed with a number we had measured ourselves for a completely different reason. Our own internal figure was high ÷ medium = 4.00 on the old model. 1,413 ÷ 3.85 lands on 367. So the third-party claim reduces to "the new model's &lt;code&gt;high&lt;/code&gt; is roughly the old model's &lt;code&gt;medium&lt;/code&gt;", and that is a statement our own independent measurement supports.&lt;/p&gt;

&lt;p&gt;Worth being explicit about that reasoning, because it is the only reason I trusted a number I had not produced. A third-party benchmark you cannot reproduce is worth something if it agrees with a ratio you measured yourself for unrelated reasons. It is worth nothing on its own.&lt;/p&gt;

&lt;p&gt;The provider did document this, sort of. Their model page says the five quality levels are not direct equivalents of the previous model's levels, and recommends evaluating with your own prompts. That sentence reads like standard hedging. It is actually a spec change with a number attached, and the number is not in it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it matters more if you resell
&lt;/h2&gt;

&lt;p&gt;If you are the end user of the API this is a pleasant surprise. You get the same work for less money, or you move up a tier.&lt;/p&gt;

&lt;p&gt;If you resell, it is a trap, and the trap is shaped like a one-line config change.&lt;/p&gt;

&lt;p&gt;Our users pick a quality tier and pay accordingly. High costs them four times what Medium costs. That multiple exists because High really did consume about four times the compute.&lt;/p&gt;

&lt;p&gt;Swap the model ID to the new generation and nothing else, and the same button still sends &lt;code&gt;quality=high&lt;/code&gt;, still charges four times, and now comes back with roughly what Medium used to produce. Nobody sees an error. The image still looks fine. The only thing that changed is that the customer is paying a 4× premium for something that is no longer 4× the work.&lt;/p&gt;

&lt;p&gt;That is not a performance regression you can file and get to later. The thing you sold is not the thing you delivered. We ended up leaving the old model on its own row, untouched, and adding the new ones as separate options with their own tiers, measured separately, specifically so that nobody could make that change by editing one field.&lt;/p&gt;

&lt;h2&gt;
  
  
  The smaller one that nearly got us
&lt;/h2&gt;

&lt;p&gt;Unrelated to models, same category of mistake.&lt;/p&gt;

&lt;p&gt;We had a cost baseline that turned out to be wrong, $0.0021 where it should have been $0.00233, and three derived figures computed from it sitting in the same document. Fixing the baseline was a two-character edit. The three derived numbers stayed exactly where they were, looking correct, being wrong. They needed to go 4.7 / 2.4 / 2.2 to 4.3 / 2.1 / 1.9 and they were not going to do that on their own.&lt;/p&gt;

&lt;p&gt;Derived numbers do not update themselves. They just keep being confidently wrong in a table somebody will read next quarter.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we do now
&lt;/h2&gt;

&lt;p&gt;We do not build a cost model on a vendor's published example table. It is illustrative, it is often derived from one configuration, and in our case the ratios taken from it were off by 2.3× on a tier we actually sell. Our own usage records are the only source that describes what we are actually being charged for.&lt;/p&gt;

&lt;p&gt;Measure ratios, not absolutes, and vary something while you do it. A multiplier that holds steady across three resolutions is something you can price against. An absolute cost per image is out of date the next time anything changes.&lt;/p&gt;

&lt;p&gt;Treat "the tiers are not direct equivalents" as a breaking change, not a disclaimer. Vendors write that sentence in the voice of a footnote.&lt;/p&gt;

&lt;p&gt;And if you resell, put a measurement step between "new model is available" and "new model is live", because the config change that skips it is one line and reads as harmless.&lt;/p&gt;

&lt;p&gt;We do this for &lt;a href="https://fontvibe.ai" rel="noopener noreferrer"&gt;fontvibe.ai&lt;/a&gt;, which is where the numbers came from. The reasoning is not specific to images or to any one provider.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>architecture</category>
      <category>api</category>
      <category>llm</category>
    </item>
    <item>
      <title>Image prompting lessons: stop describing the image, hand the model a reference sheet</title>
      <dc:creator>jack</dc:creator>
      <pubDate>Thu, 24 Sep 2026 16:10:01 +0000</pubDate>
      <link>https://dev.to/jack_e2a413ec5d0bba216/image-prompting-lessons-stop-describing-the-image-hand-the-model-a-reference-sheet-l1k</link>
      <guid>https://dev.to/jack_e2a413ec5d0bba216/image-prompting-lessons-stop-describing-the-image-hand-the-model-a-reference-sheet-l1k</guid>
      <description>&lt;p&gt;Thirteen rounds, one afternoon, one word. The word was ONLINE and I wanted it drawn in a particular chunky cartoon style. I have the whole log because I was writing down what changed each round, which turned out to be the only reason I can now say anything useful about it.&lt;/p&gt;

&lt;p&gt;Short version up front, since it isn't a twist: no amount of rewording got me there. I ended up drawing a reference sheet by hand and pointing at it.&lt;/p&gt;

&lt;p&gt;I picked letters for this on purpose. Most image work gets judged on taste and those arguments go nowhere, but "is that an R" has an answer, and so does "is that corner curved". You can be wrong in a way somebody can check.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scorer was happy the entire time
&lt;/h2&gt;

&lt;p&gt;There was an aesthetic scorer in the loop. Every round it came back somewhere between 8.5 and 9.0. My own rating of the same images started around 3 and ended around 9.&lt;/p&gt;

&lt;p&gt;Round 1, scored 8.5:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzqr31mh3pim8bq0ac9v.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpzqr31mh3pim8bq0ac9v.jpeg" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I had asked for small torn notches at the tips of the strokes. This has cracks across the entire body of every letter, plus a gloss I never mentioned.&lt;/p&gt;

&lt;p&gt;The scorer was not malfunctioning. It is a decent-looking image. It answers "is this good" and I was asking "is this the thing I described", and once your model is competent enough to produce non-garbage those two questions stop being related. If I had let the score gate anything I would have stopped at round 1 and shipped a picture of broken glass.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rounds 1 to 3 were aimed at something that was not there
&lt;/h2&gt;

&lt;p&gt;Here is the part I am least pleased about.&lt;/p&gt;

&lt;p&gt;I had looked at my reference at normal size, as a whole word, and read it as rounded letters with torn spikes poking out. So I spent three rounds describing torn spikes.&lt;/p&gt;

&lt;p&gt;There were no spikes. When I finally cropped out single letters and blew them up, the letterforms were polygons made of straight facet cuts, and the things I had read as spikes were just the corners where two straight edges met. Nothing in that reference was torn, ripped, or broken. Three rounds of careful prompt surgery, aimed at reproducing a defect in my own eyesight.&lt;/p&gt;

&lt;p&gt;Zoom in on one element before you write a single word about it. Whole-word at thumbnail size gives you the vibe and lies to you about the geometry.&lt;/p&gt;

&lt;h2&gt;
  
  
  The word you picked is arguing with you
&lt;/h2&gt;

&lt;p&gt;This is the expensive one, and it happened three separate times with three different words.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;I wrote&lt;/th&gt;
&lt;th&gt;I meant&lt;/th&gt;
&lt;th&gt;I got&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;chamfer&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a flat angled corner&lt;/td&gt;
&lt;td&gt;a 3D bevel, side faces lit and everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;puzzle-piece-like&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;letters sitting tightly together&lt;/td&gt;
&lt;td&gt;jigsaw tabs. Actual ones.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;paper cutout&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;flat, no depth&lt;/td&gt;
&lt;td&gt;flat, with fold creases&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu5gav59ryrx4qpa7r9wo.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fu5gav59ryrx4qpa7r9wo.jpeg" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That is round 10. I got precisely what I typed and nothing I meant.&lt;/p&gt;

&lt;p&gt;The part that cost me rounds is that you can't fix this by pushing harder in the other direction. By round 8 the prompt carried "no 3D", "no bevel", "no side faces", "no internal lines", and the extrusion came back anyway, because &lt;code&gt;chamfer&lt;/code&gt; and &lt;code&gt;cut&lt;/code&gt; both point hard at "something was sliced here, show the marks". A pile of denials does not outvote the word itself. The word has to go.&lt;/p&gt;

&lt;p&gt;So when a constraint keeps losing, stop reinforcing the constraint. Go find what it is losing to.&lt;/p&gt;

&lt;h2&gt;
  
  
  Say what it is, not how it got made
&lt;/h2&gt;

&lt;p&gt;Two phrasings that mean the same thing to a person:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;corners cut at irregular angles             # how it got made

the outer silhouette of every letter is an
irregular polygon, straight edges meeting
at sharp corners of varying length          # what it is
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first kept giving me cut marks. Seams inside the letter body, creases, and the little lit faces you would see if something really had been sliced through. The second gave me the shape and stopped.&lt;/p&gt;

&lt;p&gt;My guess is that a verb names an event, and pictures of events come with the mess the event leaves behind. I would not push that too hard as a theory. As a habit it has held up: when junk keeps appearing inside the subject that you never asked for, go read your prompt for verbs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Words about the room change the letters
&lt;/h2&gt;

&lt;p&gt;The prompt is split into a subject half and a scene half. I had assumed that split meant something.&lt;/p&gt;

&lt;p&gt;Material vocabulary in the scene half, describing the room, leaks onto the subject every time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;edge&lt;/code&gt;, &lt;code&gt;core&lt;/code&gt;, &lt;code&gt;depth&lt;/code&gt; used about the environment, and the flat letters grow 3D extrusion&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;voxel&lt;/code&gt; or &lt;code&gt;cube&lt;/code&gt;, and the letter edges go blocky and stair-stepped&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;plastic&lt;/code&gt;, &lt;code&gt;toy&lt;/code&gt;, &lt;code&gt;brick&lt;/code&gt;, and the letters soften and round off&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of those were about the text. There's no compartment in there for "this clause is scenery". I ended up banning material words from the scene half entirely, objects and space and light only, and the drift stopped.&lt;/p&gt;

&lt;h2&gt;
  
  
  Giving up and drawing it
&lt;/h2&gt;

&lt;p&gt;By round 11 the shape of the problem was obvious. Every fix moved something else. Tighten the silhouette, gloss comes back. Kill the gloss, the shape goes wrong. Fix the shape, a metaphor drags in creases. I was not converging, I was circling.&lt;/p&gt;

&lt;p&gt;There is also an embarrassing middle stretch I have skipped over, where I landed a version I liked at round 5, showed it, and was told round 4 had been closer. Rounds 6 through 8 were me trying to get back to something I had already had.&lt;/p&gt;

&lt;p&gt;So I drew the thing instead: a full A-Z and 0-9 sheet in the target style, one image. The prompt shrank to a pointer, roughly "follow the letterform skeleton in the reference, make the surface cleaner than the reference."&lt;/p&gt;

&lt;p&gt;Eleven rounds of problems went away in one go. Not gradually. The 3D, the seams, the gloss, the jigsaw tabs, the creases, all of it, in a single generation.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdczzltmvoji11ob7qdi.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fwdczzltmvoji11ob7qdi.jpeg" alt=" " width="800" height="600"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Yes, drawing the sheet was work. It was less work than eleven rounds, and I still have the sheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  One thing the reference could not do
&lt;/h2&gt;

&lt;p&gt;It locked the shape, which also meant I could not change anything.&lt;/p&gt;

&lt;p&gt;The last round wanted that exact shape with a glossier finish. Asked for in one sentence, the gloss dragged the 3D back in, same as round 8. Split across two sentences it behaved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The 2D silhouette of every letter must stay identical to the reference.
No change to the letterform shape itself, no extrusion, no added bevel geometry.

Within that unchanged shape, push the surface material to a glossy,
high-shine polished enamel finish.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Identical information. Two sentences. I don't have an explanation for why that matters and I am slightly suspicious of it, but it has reproduced every time I h&lt;br&gt;
&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2pvy5qhvz6u2wy6urt6.jpeg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe2pvy5qhvz6u2wy6urt6.jpeg" alt=" " width="800" height="600"&gt;&lt;/a&gt;ave needed to hold one property and move another, so I keep doing it.&lt;/p&gt;

&lt;p&gt;If you take one thing from this: text is fine for a couple of constraints and it falls apart once you need five of them true at the same time. That is not a prompting skill issue, it's the channel. When you notice yourself circling, the question to ask is whether the thing you are describing could just be handed over instead.&lt;/p&gt;

&lt;p&gt;This came out of building typeface generators, which live at &lt;a href="https://fontvibe.ai" rel="noopener noreferrer"&gt;fontvibe.ai&lt;/a&gt;. Nothing above needs them.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>machinelearning</category>
      <category>promptengineering</category>
      <category>designpatterns</category>
    </item>
    <item>
      <title>Unicode normalization is not a deduplication key</title>
      <dc:creator>jack</dc:creator>
      <pubDate>Tue, 22 Sep 2026 17:29:47 +0000</pubDate>
      <link>https://dev.to/jack_e2a413ec5d0bba216/unicode-normalization-is-not-a-deduplication-key-4c6d</link>
      <guid>https://dev.to/jack_e2a413ec5d0bba216/unicode-normalization-is-not-a-deduplication-key-4c6d</guid>
      <description>&lt;p&gt;I had 84,000 kaomoji to deduplicate and a key that looked obviously correct:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;key = "".join(unicodedata.normalize("NFKC", text).split())
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;Strip the whitespace, normalize the Unicode, group by the result. Every&lt;br&gt;
deduplication tutorial I have ever read says to normalize first. It took me one&lt;br&gt;
afternoon of reviewing the output to realize that this key had quietly decided&lt;br&gt;
&lt;code&gt;(╥_╥)&lt;/code&gt; and &lt;code&gt;(╥﹏╥)&lt;/code&gt; were the same record.&lt;/p&gt;

&lt;p&gt;They are two different faces. The wavy mouth is a harder cry than the flat one.&lt;/p&gt;
&lt;h2&gt;
  
  
  NFKC folds four characters that matter here
&lt;/h2&gt;

&lt;p&gt;NFKC is &lt;em&gt;compatibility&lt;/em&gt; normalization. The K stands for compatibility, and the&lt;br&gt;
point of it is to fold characters that exist in Unicode only for round-tripping&lt;br&gt;
with older encodings: the fullwidth Latin letters, the halfwidth katakana, the&lt;br&gt;
circled numbers, the ligatures. It maps them onto their "normal" equivalents so&lt;br&gt;
that &lt;code&gt;ｈｅｌｌｏ&lt;/code&gt; and &lt;code&gt;hello&lt;/code&gt; compare equal.&lt;/p&gt;

&lt;p&gt;That is correct when the text is prose and you want &lt;code&gt;Ｅ－ｍａｉｌ&lt;/code&gt; to match&lt;br&gt;
&lt;code&gt;E-mail&lt;/code&gt;. It stops being correct once the characters are the content themselves.&lt;br&gt;
Here is what it does to four characters that carry meaning in a kaomoji:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Codepoint&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;th&gt;NFKC result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;U+FE4F&lt;/td&gt;
&lt;td&gt;WAVY LOW LINE &lt;code&gt;﹏&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;_&lt;/code&gt; (U+005F)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+FF3C&lt;/td&gt;
&lt;td&gt;FULLWIDTH REVERSE SOLIDUS &lt;code&gt;＼&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;\&lt;/code&gt; (U+005C)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+FF64&lt;/td&gt;
&lt;td&gt;HALFWIDTH IDEOGRAPHIC COMMA &lt;code&gt;､&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;、&lt;/code&gt; (U+3001)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;U+3000&lt;/td&gt;
&lt;td&gt;IDEOGRAPHIC SPACE &lt;code&gt;　&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;&lt;/code&gt; (U+0020)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first one is the crying face. &lt;code&gt;﹏&lt;/code&gt; is a wavy mouth and &lt;code&gt;_&lt;/code&gt; is a flat one, and&lt;br&gt;
NFKC folds them together because one of them was originally a typesetting variant&lt;br&gt;
of the other. The second one is a pair of arms. &lt;code&gt;＼(^o^)／&lt;/code&gt; is a wide celebratory&lt;br&gt;
shrug, &lt;code&gt;\(^o^)/&lt;/code&gt; is a narrow one, and after NFKC they are the same string.&lt;/p&gt;

&lt;p&gt;The third one is a case where NFKC is &lt;em&gt;right&lt;/em&gt;. A fullwidth and a halfwidth&lt;br&gt;
ideographic comma really are the same punctuation mark, and &lt;code&gt;(≖、≖╬)&lt;/code&gt; and&lt;br&gt;
&lt;code&gt;(≖､≖╬)&lt;/code&gt; really are one entry. That is what makes this hard. The normalization&lt;br&gt;
is not simply wrong. It is right about maybe a third of what it touches, and you&lt;br&gt;
can't tell which third without looking.&lt;/p&gt;
&lt;h2&gt;
  
  
  233 proposed merges, or 6,944
&lt;/h2&gt;

&lt;p&gt;This is easy to measure on a published dataset, so you do not have to take my word&lt;br&gt;
for it. The file below is the merged corpus, 82,109 entries, already deduplicated&lt;br&gt;
on exact string match. Every collision found here is a &lt;em&gt;proposed&lt;/em&gt; merge between&lt;br&gt;
two entries that a human previously decided were different.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;unicodedata&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ud&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;keyfn&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;buckets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/kaomoji.jsonl.gz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;keyfn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;buckets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;values&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;v&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;v&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;strip_only&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;with_nfkc&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;

&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;strip_only&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# (233, 500)
&lt;/span&gt;&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;groups&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;with_nfkc&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;    &lt;span class="c1"&gt;# (6944, 15038)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Stripping whitespace alone proposes 233 merges. Adding NFKC proposes 6,944,&lt;br&gt;
covering 15,038 of the 82,109 entries. Auto-merging on that key deletes about&lt;br&gt;
8,000 records, and you never find out which ones, because a merge leaves no&lt;br&gt;
trace.&lt;/p&gt;

&lt;p&gt;Some of what it finds is genuinely useful. These are real groups from that run:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(╥_╥)              (╥﹏╥)
(・∀・)ノ            (･∀･)ノ
(╯°□°)╯︵ ┻━┻       (╯°□°）╯︵ ┻━┻
(T_T)              (T＿T)          (Ｔ＿Ｔ)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The second row is a real duplicate. Halfwidth versus fullwidth katakana middle&lt;br&gt;
dot is not something anyone chose, it is an artifact of which IME the author was&lt;br&gt;
typing in. The third row is a table flip where one of the two parentheses came&lt;br&gt;
out fullwidth, which is also an accident. The first row is two different faces.&lt;br&gt;
The fourth row is three faces if you care about stroke weight and one face if you&lt;br&gt;
don't.&lt;/p&gt;

&lt;p&gt;There is no rule that separates those four rows. I tried several: character&lt;br&gt;
class, width consistency within a single entry, whether the fold lands in the&lt;br&gt;
mouth region. All of them got the first row wrong in one direction or the other.&lt;/p&gt;

&lt;h2&gt;
  
  
  Whitespace carries pose information
&lt;/h2&gt;

&lt;p&gt;Stripping whitespace has the same problem on a smaller scale, because whitespace&lt;br&gt;
is load-bearing in this data:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ʕ·ᴥ·ʔ          bear, facing you
ʕ·ᴥ·　ʔ         bear, looking left
ʕ　·ᴥ·ʔ         bear, looking right

( •_•)&amp;gt;⌐■-■    before the sunglasses
(•_•)&amp;gt;⌐■-■     also before the sunglasses, typed by someone else
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;The first three are three entries. The last two are one. Whether a given space is&lt;br&gt;
a pose or a typo is not recoverable from the string, and I don't think it is&lt;br&gt;
recoverable at all without knowing what the thing depicts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three keys, one of which is allowed to merge
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;key_exact&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;                       &lt;span class="c1"&gt;# byte-identical; safe to auto-merge
&lt;/span&gt;&lt;span class="n"&gt;key_loose&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;      &lt;span class="c1"&gt;# whitespace only; PROPOSES merges
&lt;/span&gt;&lt;span class="n"&gt;key_nfkc&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ud&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;normalize&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NFKC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;   &lt;span class="c1"&gt;# output field only
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;key_exact&lt;/code&gt; collisions merge automatically with no review. &lt;code&gt;key_loose&lt;/code&gt; collisions&lt;br&gt;
go to a human queue, and 233 groups is an afternoon. &lt;code&gt;key_nfkc&lt;/code&gt; is computed,&lt;br&gt;
stored as a field in the dataset, and never consulted by the merge pipeline.&lt;br&gt;
Consumers who want aggressive folding for search or for their own matching can&lt;br&gt;
have it. It just doesn't get to delete anything.&lt;/p&gt;

&lt;p&gt;That last key is the part I got wrong twice. My first instinct after it burned me&lt;br&gt;
was to rip it out entirely, which would have been the opposite mistake, because&lt;br&gt;
the folding is genuinely useful for recall. If somebody searches for &lt;code&gt;(╥﹏╥)&lt;/code&gt; you&lt;br&gt;
want the flat-mouthed one in the results too. So I kept computing it and took&lt;br&gt;
away its ability to destroy a record.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the queue small enough that you will actually read it
&lt;/h2&gt;

&lt;p&gt;Normalization tells you which records might be the same. Whether they are the&lt;br&gt;
same is a different question, and I am&lt;br&gt;
not sure that one has an algorithmic answer.&lt;/p&gt;

&lt;p&gt;So normalize to find candidates, keep the raw string as the only identity, and&lt;br&gt;
put a human in front of anything that would destroy a record. If the human queue&lt;br&gt;
is too big to review, the candidate key is too aggressive and needs tightening.&lt;br&gt;
Nobody reviews 6,944 groups. They trust the key and move on, which is exactly how&lt;br&gt;
the records disappear without anyone noticing.&lt;/p&gt;




&lt;p&gt;The corpus is on GitHub with the raw JSONL and the verification scripts, and on&lt;br&gt;
npm and PyPI as &lt;code&gt;kaomoji-dataset&lt;/code&gt; if you just want the data:&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;npm i kaomoji-dataset
pip install kaomoji-dataset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;&lt;a href="https://github.com/Funovate/fontvibe-kaomoji" rel="noopener noreferrer"&gt;https://github.com/Funovate/fontvibe-kaomoji&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;If you want to look at the entries rather than the code, the browsable version is&lt;br&gt;
at &lt;a href="https://fontvibe.ai/tools/kaomoji" rel="noopener noreferrer"&gt;fontvibe.ai/tools/kaomoji&lt;/a&gt;. Data is CC BY&lt;br&gt;
4.0.&lt;/p&gt;

</description>
      <category>unicode</category>
      <category>datascience</category>
      <category>python</category>
      <category>programming</category>
    </item>
    <item>
      <title>I merged the seven public kaomoji collections. They barely overlap.</title>
      <dc:creator>jack</dc:creator>
      <pubDate>Tue, 22 Sep 2026 16:02:59 +0000</pubDate>
      <link>https://dev.to/jack_e2a413ec5d0bba216/i-merged-the-seven-public-kaomoji-collections-they-barely-overlap-3jag</link>
      <guid>https://dev.to/jack_e2a413ec5d0bba216/i-merged-the-seven-public-kaomoji-collections-they-barely-overlap-3jag</guid>
      <description>&lt;p&gt;I wanted a complete list of kaomoji, the &lt;code&gt;( ͡° ͜ʖ ͡°)&lt;/code&gt; and &lt;code&gt;¯\_(ツ)_/¯&lt;/code&gt; family. There isn't one.&lt;br&gt;
So I merged the public collections instead, and the merge turned up something I&lt;br&gt;
wasn't expecting: they hardly overlap at all.&lt;/p&gt;
&lt;h2&gt;
  
  
  Seven collections, 21 pairs, 0.3% median overlap
&lt;/h2&gt;

&lt;p&gt;The ones I found:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://github.com/fdw/rofimoji" rel="noopener noreferrer"&gt;fdw/rofimoji&lt;/a&gt;, the biggest English-side set&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/Allaman/emoji.nvim" rel="noopener noreferrer"&gt;Allaman/emoji.nvim&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/aoguai/rime_kaomoji_dict" rel="noopener noreferrer"&gt;aoguai/rime_kaomoji_dict&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/mtripg6666tdr/Kaomoji_proj" rel="noopener noreferrer"&gt;mtripg6666tdr/Kaomoji_proj&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;the Japanese IME dictionaries&lt;/li&gt;
&lt;li&gt;kaosute&lt;/li&gt;
&lt;li&gt;an older list of my own&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Across the 21 pairwise combinations the median Jaccard overlap is 0.3%. Four&lt;br&gt;
pairs share not a single entry. One pair clears 5%, rofimoji and emoji.nvim,&lt;br&gt;
which are both English-side Linux pickers, so that one is no surprise.&lt;/p&gt;

&lt;p&gt;The IME dictionaries are where most kaomoji actually live, and they share almost&lt;br&gt;
nothing with any of the English sets.&lt;/p&gt;

&lt;p&gt;Each entry in the merged file keeps the sources it came from, so you can check&lt;br&gt;
this yourself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;

&lt;span class="n"&gt;by&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;collections&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;defaultdict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;set&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;line&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;gzip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;data/kaomoji.jsonl.gz&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;line&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sources&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

&lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;itertools&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;combinations&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;median&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;o&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# 0.003  0.079  4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the collections is close to complete, and after seeing those numbers I&lt;br&gt;
understand why.&lt;/p&gt;

&lt;h2&gt;
  
  
  Coverage checked at run time
&lt;/h2&gt;

&lt;p&gt;Saying "this has everything" is not worth much on its own, so&lt;br&gt;
&lt;code&gt;scripts/verify_coverage.py&lt;/code&gt; downloads each upstream file when you run it and&lt;br&gt;
diffs it against the merged set. Nothing is precomputed.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Upstream&lt;/th&gt;
&lt;th&gt;Entries&lt;/th&gt;
&lt;th&gt;Covered&lt;/th&gt;
&lt;th&gt;Missing&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rofimoji&lt;/td&gt;
&lt;td&gt;1,562&lt;/td&gt;
&lt;td&gt;100.0%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;emoji.nvim&lt;/td&gt;
&lt;td&gt;2,016&lt;/td&gt;
&lt;td&gt;99.9%&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;rime_kaomoji_dict&lt;/td&gt;
&lt;td&gt;959&lt;/td&gt;
&lt;td&gt;99.4%&lt;/td&gt;
&lt;td&gt;6&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Kaomoji_proj&lt;/td&gt;
&lt;td&gt;2,166&lt;/td&gt;
&lt;td&gt;97.8%&lt;/td&gt;
&lt;td&gt;48&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;It prints all 57 misses, so you can look at them and disagree with me about what&lt;br&gt;
counts as a kaomoji. Three rounds of that took rofimoji from 93.0% to 100.0%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Labels, and where they fall down
&lt;/h2&gt;

&lt;p&gt;Deduplicated it comes to 82,109 entries. Around 70k carry emotion, intent and&lt;br&gt;
subject labels from a controlled vocabulary in en, ja, zh, es, pt and de, so&lt;br&gt;
嬉しい, 开心, feliz and happy return the same entries instead of whichever English&lt;br&gt;
word the original author happened to use.&lt;/p&gt;

&lt;p&gt;Three things worth knowing before you use it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The labels are coarse. 69,679 labelled entries share only 605 distinct label
combinations, because they were applied in bundles rather than judged one at a
time. Fine for pulling up a mood, not for finding one specific face.&lt;/li&gt;
&lt;li&gt;About 15% is left unlabelled rather than guessed at.&lt;/li&gt;
&lt;li&gt;Most of the corpus is 顔文字＋セリフ, a face plus a line of Japanese dialogue,
which is not much use if you cannot read it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last one is why the English-language picker at&lt;br&gt;
&lt;a href="https://fontvibe.ai/tools/kaomoji" rel="noopener noreferrer"&gt;fontvibe.ai/tools/kaomoji&lt;/a&gt; shows 11,020, the&lt;br&gt;
subset that renders safely outside Japanese contexts, while the full 82,109 sits&lt;br&gt;
in the dataset and on the Japanese page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm i kaomoji-dataset
pip &lt;span class="nb"&gt;install &lt;/span&gt;kaomoji-dataset
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both read a gzip off disk, so they are Node and Python only for now. They will&lt;br&gt;
not bundle for a browser. The repo has the raw JSONL and a CSV as well:&lt;br&gt;
&lt;a href="https://github.com/Funovate/fontvibe-kaomoji" rel="noopener noreferrer"&gt;github.com/Funovate/fontvibe-kaomoji&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;106 of the entries are not collected from anywhere. I drew them, for feelings&lt;br&gt;
that had no kaomoji yet: 躺平, 社死, saudade, mamihlapinatapai. Those are CC0.&lt;/p&gt;

&lt;p&gt;If you know of a collection I missed, say so and I will run the coverage script&lt;br&gt;
against it.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>showdev</category>
      <category>python</category>
      <category>javascript</category>
    </item>
  </channel>
</rss>
