●STUDIO — The planned standalone AI Studio app for Android and iOS has been cancelled, with its app-building tools folded directly into the Gemini app alongside image, video, text and code work●BENCH — Gemini 3.7 Flash moved from 49.0 to 65.3 percent on DeepSWE, a long-horizon coding benchmark, while costing half as much per token●REACH — Beyond the Gemini API, 3.7 Flash is available in Android Studio, Google Antigravity, the Gemini Enterprise Agent Platform, and Spark in the Gemini app●API — The sampling parameters temperature, top_p and top_k are now deprecated. If you relied on them to keep output steady, it is time to decide where reproducibility comes from instead●ROBOTICS — gemini-robotics-er-1.6-preview shuts down on August 31, nine days out. Its successors, er-2-preview and er-2-streaming-preview, accept text, image, video and audio input●ASSISTANT — Replacing Google Assistant with Gemini on Android begins September 4, thirteen days from now. Worth checking any app that leans on App Actions or voice shortcuts●STUDIO — The planned standalone AI Studio app for Android and iOS has been cancelled, with its app-building tools folded directly into the Gemini app alongside image, video, text and code work●BENCH — Gemini 3.7 Flash moved from 49.0 to 65.3 percent on DeepSWE, a long-horizon coding benchmark, while costing half as much per token●REACH — Beyond the Gemini API, 3.7 Flash is available in Android Studio, Google Antigravity, the Gemini Enterprise Agent Platform, and Spark in the Gemini app●API — The sampling parameters temperature, top_p and top_k are now deprecated. If you relied on them to keep output steady, it is time to decide where reproducibility comes from instead●ROBOTICS — gemini-robotics-er-1.6-preview shuts down on August 31, nine days out. Its successors, er-2-preview and er-2-streaming-preview, accept text, image, video and audio input●ASSISTANT — Replacing Google Assistant with Gemini on Android begins September 4, thirteen days from now. Worth checking any app that leans on App Actions or voice shortcuts
Once You Pass Twenty Mediation Groups, How Do You Find the Setting That Went Missing?
As ad mediation groups multiply, missing sources and type drift accumulate quietly. Here is the split I settled on: normalize the settings into one matrix, let code confirm the gaps, and send Gemini only the cells that need judgment.
When I extended mediation on the iOS side and added Liftoff, InMobi, and Unity Ads, the change covered four apps and more than twenty groups. I opened them one at a time in the console, checking that every network appeared in the same shape. Around the tenth group I lost track of where I had already been.
A week later I found that one group was missing a single network. Revenue for that group was slightly lower, but nothing that would register as an anomaly. Missing configuration tends to show up in exactly that quiet way.
So I rebuilt the check. The short version: finding the gaps is a job for code, and what Gemini receives is only the question of whether each gap is deliberate.
"Tell Me What Looks Wrong" Will Not Surface an Absence
My first attempt was the obvious one. I dumped all twenty-odd groups into text and asked Gemini to point out inconsistencies.
What came back were cells where the eCPM was clearly off by an order of magnitude, and group names that broke the naming convention. Both were correct observations. But the one thing I most wanted — a network missing from a single group — never appeared.
Once I thought about why, it made sense. An anomaly in something that exists leaves traces in the text. An absence exists only as nothing written in a particular place. To notice that row 19 lacks an element every other row has, you first collect the union of elements across all rows, then diff each row against it. That is set arithmetic, not reading comprehension.
Rather than push a language model into work it is poorly shaped for, I changed the shape of what I handed it.
Step One: Normalize the Settings Into a Single Matrix
A console export works, and so does hand-written JSON transcribed from screenshots. What matters is getting to a form you can pivot into a group-by-network matrix.
# groups: transcribed from the console. In production, read the export instead.groups = [ {"group": "RWD-iOS-Wallpaper-JP", "app": "wallpaper", "sources": [ {"network": "admob_bidding", "type": "bidding"}, {"network": "applovin", "type": "bidding"}, {"network": "liftoff", "type": "bidding"}, {"network": "inmobi", "type": "bidding"}, {"network": "unity", "type": "waterfall", "ecpm": 4.2}]}, {"group": "RWD-iOS-Wallpaper-US", "app": "wallpaper", "sources": [ {"network": "admob_bidding", "type": "bidding"}, {"network": "applovin", "type": "bidding"}, {"network": "liftoff", "type": "bidding"}, {"network": "unity", "type": "waterfall", "ecpm": 4.2}]}, {"group": "RWD-iOS-Ukiyoe-JP", "app": "ukiyoe", "sources": [ {"network": "admob_bidding", "type": "bidding"}, {"network": "applovin", "type": "bidding"}, {"network": "liftoff", "type": "bidding"}, {"network": "inmobi", "type": "bidding"}, {"network": "unity", "type": "waterfall", "ecpm": 0.42}]}, {"group": "RWD-iOS-Healing-JP", "app": "healing", "sources": [ {"network": "admob_bidding", "type": "bidding"}, {"network": "applovin", "type": "waterfall", "ecpm": 3.9}, {"network": "liftoff", "type": "bidding"}, {"network": "inmobi", "type": "bidding"}, {"network": "unity", "type": "waterfall", "ecpm": 4.2}]},]def build_matrix(groups): """Build a group x network table. Unset cells become None, and those are the candidates for 'missing'.""" networks = sorted({s["network"] for g in groups for s in g["sources"]}) matrix = {} for g in groups: by_net = {s["network"]: s for s in g["sources"]} matrix[g["group"]] = {n: by_net.get(n) for n in networks} return networks, matrixnetworks, matrix = build_matrix(groups)for name, row in matrix.items(): print(name, {n: (row[n]["type"] if row[n] else "-") for n in networks})
One dash. The thing I lost track of at group ten is now sitting in plain view. It is only a reshaping, but the nature of the check changes at this point.
✦
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 split data that contains absences into the part code should settle and the part a language model should judge
✦You will catch a missing ad network during a few minutes of pre-flight checking instead of noticing it weeks later in the revenue numbers
✦You will be able to lift the responseSchema-to-report implementation straight into your own project
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.
Step Two: Let Code Settle the Three Kinds of Drift
With the matrix in hand, take everything that requires no judgment. In my own operation, these were the three I wanted:
Kind
How it is detected
Typical cause
Missing source
A network present in almost every group is absent from one
One group skipped during a rollout
eCPM outlier
A waterfall value far from the median for that same network
Misplaced decimal, leftover test value
Type drift
A network set as waterfall where every peer uses bidding
A migration that stalled halfway
def find_anomalies(networks, matrix): """Extract only the three kinds of drift that need no judgment. This function does not decide what to fix. It states facts.""" found = [] total = len(matrix) # How many groups carry each network coverage = {n: sum(1 for g in matrix if matrix[g][n]) for n in networks} for group, row in matrix.items(): for n in networks: cell = row[n] # (1) Missing: present nearly everywhere, absent here if cell is None and coverage[n] == total - 1: found.append({"group": group, "network": n, "kind": "missing_source", "detail": f"configured in {coverage[n]}/{total} groups"}) continue if cell is None: continue # (2) eCPM outlier: 3x away from the median for this network if cell.get("type") == "waterfall" and "ecpm" in cell: peers = [matrix[o][n]["ecpm"] for o in matrix if o != group and matrix[o][n] and matrix[o][n].get("type") == "waterfall" and "ecpm" in matrix[o][n]] if peers: med = sorted(peers)[len(peers) // 2] if med and (cell["ecpm"] > med * 3 or cell["ecpm"] < med / 3): found.append({"group": group, "network": n, "kind": "ecpm_outlier", "detail": f"{cell['ecpm']} vs median {med}"}) # (3) Type drift: bidding and waterfall mixed for the same network for n in networks: seen = {matrix[g][n]["type"] for g in matrix if matrix[g][n]} if "bidding" in seen and "waterfall" in seen: for group in matrix: cell = matrix[group][n] if cell and cell["type"] == "waterfall": found.append({"group": group, "network": n, "kind": "type_drift", "detail": "peers are configured as bidding"}) return foundfor a in find_anomalies(networks, matrix): print(a)
The output:
{'group': 'RWD-iOS-Wallpaper-US', 'network': 'inmobi', 'kind': 'missing_source', 'detail': 'configured in 3/4 groups'}{'group': 'RWD-iOS-Ukiyoe-JP', 'network': 'unity', 'kind': 'ecpm_outlier', 'detail': '0.42 vs median 4.2'}{'group': 'RWD-iOS-Healing-JP', 'network': 'applovin', 'kind': 'type_drift', 'detail': 'peers are configured as bidding'}
Not a single API call so far. At twenty groups or sixty, this stage takes no perceptible time.
Step Three: Send Gemini Only the Cells That Need Judgment
Those three findings state that something differs. Whether anything should change is a separate question.
InMobi missing from the US group might be an oversight, or it might be a deliberate removal because inventory there was thin. Unity Ads at 0.42 on the ukiyo-e app might be a typo, or it might reflect the genuinely lower rates that app sees.
"Deliberate or accidental" is not written in the settings table. It lives elsewhere — old decision notes, per-app policy, regional performance. That is inference across contexts, and language models are good at it.
So I hand over only the findings, in a structured form.
import jsonfrom google import genaifrom google.genai import typesclient = genai.Client(api_key="YOUR_API_KEY")REVIEW_SCHEMA = { "type": "object", "properties": { "verdicts": { "type": "array", "items": { "type": "object", "properties": { "group": {"type": "string"}, "network": {"type": "string"}, # Locked to three choices: fix / deliberate / not enough info "decision": {"type": "string", "enum": ["fix", "intentional", "needs_info"]}, "reason": {"type": "string"}, # Force the premise into the open so a human can audit it later "assumption": {"type": "string"}, }, "required": ["group", "network", "decision", "reason", "assumption"], }, } }, "required": ["verdicts"],}CONTEXT = """- Four iOS apps built by a solo indie developer (wallpaper, ukiyo-e, healing)- Policy: major networks belong in every group, configured as bidding- Exception: region-specific groups may drop networks with no track record- The ukiyo-e app tends to earn lower rates than the others"""def review(anomalies): prompt = ( "Below is a list of configuration drift extracted mechanically from an " "ad mediation setup. Detection is already done. The only judgment I need " "is whether each item is deliberate under the stated policy, or an " "accident worth fixing. If the settings table lacks the context needed, " "choose needs_info.\n\n" f"# Operating policy\n{CONTEXT}\n" f"# Findings\n{json.dumps(anomalies, indent=2)}" ) res = client.models.generate_content( model="gemini-3.7-flash", contents=prompt, config=types.GenerateContentConfig( response_mime_type="application/json", response_schema=REVIEW_SCHEMA, ), ) return json.loads(res.text)["verdicts"]
Pinning decision to three values is the load-bearing part of this design. Free-form text produces replies like "it would be advisable to confirm," which you cannot act on. Three values flow straight into the next step.
needs_info earns its place too. Force a model to pick between fix and intentional when the evidence is thin, and you get a plausible-sounding guess with a confident rationale attached. Give it an exit and it takes the exit honestly. The credibility of the reason field changed noticeably for me once that option existed.
Step Four: Turn Verdicts Into a Diff Report
Convert the verdicts into something a person reads. The important rule here is not to put fix and intentional on the same surface.
def to_report(verdicts): buckets = {"fix": [], "intentional": [], "needs_info": []} for v in verdicts: buckets.setdefault(v["decision"], []).append(v) lines = [] if buckets["fix"]: lines.append("## Fix now") for v in buckets["fix"]: lines.append(f"- {v['group']} / {v['network']}: {v['reason']}") if buckets["needs_info"]: lines.append("\n## Not enough information (human review)") for v in buckets["needs_info"]: lines.append(f"- {v['group']} / {v['network']}: {v['reason']}") if buckets["intentional"]: lines.append("\n## Judged deliberate (check the premise)") for v in buckets["intentional"]: lines.append(f"- {v['group']} / {v['network']}: {v['assumption']}") return "\n".join(lines)
The intentional bucket prints assumption rather than reason because that bucket is the dangerous one. A verdict of "deliberate" has roughly the same effect as a decision to leave it alone. If the premise was wrong, that item will be waved through on the same grounds at every future check. Surfacing the premise gives you something to flinch at.
Had I kept running the first implementation as written, I would probably have abandoned it after a month. Three things changed once it was on a weekly schedule.
Loosen the threshold and send more items to judgment
Initially I looked for exact absences — present in every group but one. In reality some apps carried three networks and others four, and the strict condition let real gaps slip past.
Relaxing to coverage[n] >= total - 2 sends more items to Gemini for judgment, and that trade bought me fewer misses. The trap runs the other direction: tighten detection and the output goes quiet, but quiet is not the same as clean. I would rather pay a little judgment cost and err on the loose side.
Persist the verdicts and diff against the previous run
When the same item lands on needs_info twice in a row, that is a record of my own avoidance. I will let it slide once. Seeing the identical row a second time is usually enough to make me go look.
Appending JSON was enough. I nearly lost half a day building a proper database for this before backing out — at indie-developer scale, the read frequency is far lower than the urge to over-engineer suggests.
Check on days you changed nothing
Mediation state can shift through console-side changes and automatic optimization even when I have not touched it. Once a week, changes or not, I rebuild the table.
One caveat for production: if the check itself fails, nobody notices. It only became a real safeguard once a failed run sent me a notification. Working solo, an unattended job that dies silently is the same as no job at all.
I already had something watching for sudden revenue drops, but that tells you about damage already done. Knowing About an AdMob Revenue Drop by 8 A.M. and this pre-flight check sit on opposite sides of the same event, and I only felt settled once both existed.
Tabulate First, Then Hand It Over
The transferable part is the split, more than the code.
Language models are good at reading meaning out of what is written. Finding what is not written is set arithmetic. Configuration tables, permission grids, translation files, dependency lists — anything shaped like "these should all match" has the same structure. Before you paste the whole thing into a prompt, reshape it into a table and see how much code can settle on its own.
Run one of your own configurations through build_matrix. If even one column comes back with a dash in it, this split will pay for itself in your setup too.
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.