●API — The Gemini API release notes still end at Lyria 3.5 on September 3. Nothing new had appeared as of September 10●CLI — Gemini CLI v0.47.0 nightly adds documentation and migration commands for the Antigravity CLI. Worth remembering that this is a nightly build●GARDEN — Claude Fable 5.1 has joined Model Garden on the Gemini Enterprise Agent Platform, alongside updated embedding SKUs and agent metering prices●DEPRECATION — gemini-omni-flash-preview retires on September 30, twenty days out. The path forward is gemini-omni-1.1-flash, which reached GA on August 27●SIRI — iOS 27 ships on September 14. Its rebuilt Siri was reportedly developed with help from Google's Gemini models, though the specifics remain unconfirmed●PRICE — The $0.75 / $3.75 per MTok on Gemini 3.8 Flash and 3.7 Flash is introductory. From January 1, 2027 it becomes $1.50 / $7.50●API — The Gemini API release notes still end at Lyria 3.5 on September 3. Nothing new had appeared as of September 10●CLI — Gemini CLI v0.47.0 nightly adds documentation and migration commands for the Antigravity CLI. Worth remembering that this is a nightly build●GARDEN — Claude Fable 5.1 has joined Model Garden on the Gemini Enterprise Agent Platform, alongside updated embedding SKUs and agent metering prices●DEPRECATION — gemini-omni-flash-preview retires on September 30, twenty days out. The path forward is gemini-omni-1.1-flash, which reached GA on August 27●SIRI — iOS 27 ships on September 14. Its rebuilt Siri was reportedly developed with help from Google's Gemini models, though the specifics remain unconfirmed●PRICE — The $0.75 / $3.75 per MTok on Gemini 3.8 Flash and 3.7 Flash is introductory. From January 1, 2027 it becomes $1.50 / $7.50
Matching name variants in a print catalog: normalize first, then ask Gemini for candidates
When the same artist appears under three spellings, asking Gemini whether two names are the same person quietly merges different generations. Here is the two-layer alternative — deterministic normalization first, model-generated candidates second — with working code and a cost estimate.
I was reading through the catalog one evening, before a batch of ukiyo-e wallpapers went out, when I noticed the same artist sitting in three different rows. "歌川広重", "安藤広重", "Utagawa Hiroshige". None of them is wrong. To my script, they were three different people.
I scan old woodblock prints and repair the creases and stains by hand, one sheet at a time. That part of the work has not changed tools. The metadata I attach afterwards — artist name, title, series — is typed by a person, so it drifts.
My first attempt was to have Gemini absorb that drift. It took me a while to understand why that was the wrong shape.
What happened the day I asked "are these the same person?"
The first function I wrote handed two names to the model and asked whether they referred to the same artist. Most of the time the answer was right. The problem lived in the handful of cases where it was not.
Hiroshige and the pupil who later took the name as the second Hiroshige came back as one person. The answer arrived as a flat "yes, the same artist," so in a spreadsheet it looked exactly like every correct answer. I shipped a few prints with the wrong attribution before I caught it.
The model supplies material; I make the call. Getting to that line helped, but stopping the yes/no question was not enough on its own. The real waste was the number of calls. I was asking a language model to re-derive the same spelling drift, row after row.
Layer one: normalization belongs in code
Most of the drift is not a question of meaning. Full-width and half-width characters, old and new kanji forms, Hepburn long vowels, parenthetical notes — all of it is mechanical. There is no reason to spend inference on it.
# normalize.py — collapse everything a rule can collapse, before any API callimport reimport unicodedata# old/variant kanji forms -> modern forms (I add entries as the catalog surfaces them)KYUJI = str.maketrans({ "廣": "広", "國": "国", "齋": "斎", "澤": "沢", "溪": "渓", "藝": "芸", "驛": "駅", "豐": "豊",})NUM_FIX = [("拾", "十"), ("卅", "三十")]# parentheses, trailing role markers, circled numeralsDECOR = re.compile(r"[((\[][^))\]]*[))\]]|画$|筆$|[①-⑳]")def normalize(name: str) -> str: s = unicodedata.normalize("NFKC", name) # unify full-width and half-width forms s = s.translate(KYUJI) for a, b in NUM_FIX: s = s.replace(a, b) s = DECOR.sub("", s) s = re.sub(r"[\s・,.'’\-]", "", s) # drop separators entirely return s.lower()def romaji_key(name: str) -> str: """Collapse long-vowel spellings: Hiroshige / Hirōshige / Hiroshige.""" s = unicodedata.normalize("NFKD", name) s = "".join(c for c in s if not unicodedata.combining(c)) # ō -> o s = re.sub(r"(ou|oo|uu)", lambda m: m.group(0)[0], s.lower()) return re.sub(r"[^a-z]", "", s)if __name__ == "__main__": for a, b in [("歌川廣重", "歌川広重"), ("Utagawa Hirōshige", "Utagawa Hiroshige")]: key = normalize if re.search(r"[ぁ-んァ-ヶ一-龥]", a) else romaji_key print(a, b, key(a) == key(b)) # expected output: # 歌川廣重 歌川広重 True # Utagawa Hirōshige Utagawa Hiroshige True
In my catalog, this function alone dropped most of the variants onto a single key. What survived were pairs like "歌川広重" and "安藤広重", which no amount of character folding will ever connect. That is where the model earns its place.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You will be able to draw the line yourself between what normalization should handle and what the model should handle, before a name-matching job breaks in a way nobody notices
✦You will have a concrete way to stabilize output using response_schema and system_instruction, instead of relying on a temperature setting that is no longer honored
✦You will know how to run a thousand-row reconciliation for around one dollar, mostly by cutting the number of calls you make at all
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
For the names that make it past layer one, I ask Gemini exactly one thing: list the alternate names, art names, and pre-succession names used by the artist known under this name. I never ask whether two names match.
# alias_lookup.py — candidates and context, never a verdictimport jsonimport osfrom google import genaifrom google.genai import typesclient = genai.Client(api_key=os.environ["GEMINI_API_KEY"])SCHEMA = { "type": "object", "properties": { "canonical": {"type": "string"}, "aliases": {"type": "array", "items": {"type": "string"}}, "generation": {"type": "string", "enum": ["first", "later", "unknown"]}, "note": {"type": "string"}, }, "required": ["canonical", "aliases", "generation"],}SYSTEM = """You help build an index of ukiyo-e artist names.- List only alternate names, art names (go), and pre-succession names used by the artist.- If more than one person has held this name, set generation to "later" and explain in note.- If you cannot confirm anything, return an empty aliases array. Never invent a name.- Do not translate or transliterate names. Return them in their original script."""def lookup(name: str) -> dict: res = client.models.generate_content( model="gemini-3.8-flash", contents=f"name: {name}", config=types.GenerateContentConfig( system_instruction=SYSTEM, response_mime_type="application/json", response_schema=SCHEMA, ), ) return json.loads(res.text)if __name__ == "__main__": out = lookup("安藤広重") print(out["canonical"], out["generation"], out["aliases"][:3]) # shape of the expected output: # 歌川広重 unknown ['安藤重右衛門', '一遊斎', '一幽斎']
The note field is for a human to read; it never feeds the matching logic. I also decline to ask the model for a confidence score. The moment a number lands in the payload, I want to threshold on it — and thresholding on a self-reported score is the yes/no question wearing a different hat.
Why lowering temperature does not settle the output
When output wobbles, the instinct is to push temperature toward zero. I did that first. Nothing changed.
On the Gemini 3.x Flash family, temperature, top_p, and top_k are deprecated: the request is accepted and the values are ignored. No error, no warning. The only person affected is the one who believes the setting is doing something.
Stability comes from three other places.
Fix the shape with response_schema. Declare array item types and mark fields required.
Close the vocabulary with enum. While generation was a free-text field, I got answers like "probably the first" and "second generation, I believe", and the branch downstream broke on both.
Permit emptiness in the system instruction. If an empty array is not allowed, the model will fill the shape by inventing a name.
Goal
What does not work
Where it actually lands
Consistent vocabulary
Lowering temperature
Closing the set with enum
Consistent shape
Asking for JSON in the prompt
response_mime_type plus response_schema
No invented data
Writing "be accurate" more forcefully
Explicitly allowing an empty array
A negative dictionary for succession names
Even with two layers, one hole remains: the aliases array can contain names belonging to a different person. Artists who inherited a studio name appear under that name throughout the literature, so they surface naturally in any list of candidates.
So alongside the positive alias table, I keep a set of pairs that must never share a row.
# guard.py — trim the model output against my own prohibition tableNEVER_SAME = { frozenset({"歌川広重", "二代広重"}), frozenset({"歌川広重", "三代広重"}), frozenset({"歌川豊国", "三代豊国"}), frozenset({"歌川国貞", "三代豊国"}), # attribution is contested; never auto-merge}GENERATION_HINT = ("二代", "三代", "四代", "門人", "襲名")def filter_aliases(query: str, result: dict) -> dict: kept, dropped = [], [] for a in result.get("aliases", []): if frozenset({query, a}) in NEVER_SAME or any(h in a for h in GENERATION_HINT): dropped.append(a) else: kept.append(a) result["aliases"] = kept result["needs_review"] = dropped # keep them; route them to a human column return result
The pitfall worth naming: if you discard dropped, you lose the ability to ask later why a candidate disappeared. I learned that by researching the same pair twice, two weeks apart. Recording the reason for a rejection is one line of code that pays a later version of you.
Three boxes for the result
Once both layers have run, every row in the catalog lands in one of three places, and the split is what makes this practical to operate.
Matched by normalization. Accepted automatically. Nobody reads it.
Matched through an alias. Accepted, with a column recording which alias made the link.
Undecided. A human reads it. This is the only manual work left.
Box
Input state
Model call
What follows
1
Normalized keys agree
None
Straight into shipping metadata
2
Linked via an alias
Yes, listing only
Accepted with provenance column
3
Succession suspected, or no candidates
Yes, listing only
I check it against the source
When box three gets very small, I treat that as a warning rather than a win. Either the negative dictionary has loosened or normalization is folding too aggressively, and tidiness has started pulling against correctness.
Cost and time at a thousand rows
Calls only happen for rows layer one could not settle, which in my catalog is a fraction of the total. Even if every row went to the model, the order of magnitude would not change.
At roughly 400 input and 200 output tokens per row, a thousand rows is 400K input and 200K output tokens. With the gemini-3.8-flash introductory pricing of $0.75 input and $3.75 output per MTok, that is $0.30 and $0.75 — a little over a dollar.
One deadline is worth pinning down. The introductory pricing runs through December 31, 2026; from January 1, 2027 it becomes $1.50 and $7.50 per MTok. If your estimate crosses the new year, write it in two columns. For the same reason, I recommend keeping the model ID in a config file rather than in the source: preview endpoints come with retirement dates, and hardcoded IDs stop working on those dates. I wrote separately about picking a generation in choosing 3.8 Flash now that the same price buys more tokens.
If you are carrying a similar reconciliation, write normalize() first, run it over your own data, and count how many rows collapse onto a shared key. Designing the model call can wait until you have that number in front of you. In my case, counting showed that most of the calls I had planned were unnecessary.
Repairing an old sheet of paper and attaching a name to it are two different kinds of work. The first one I keep doing by hand. The second one shrinks when you give it structure, and being able to draw that line myself is the part of this two-layer setup I value most.
If this saves you one wrong attribution, the writing was worth it.
Share
Thank You for Reading
Gemini Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.