<?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: Niv Dvir</title>
    <description>The latest articles on DEV Community by Niv Dvir (@nivdvir).</description>
    <link>https://dev.to/nivdvir</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%2F3838336%2Fdb2e5a42-2c89-4772-88e1-ce7f6e4aa813.png</url>
      <title>DEV Community: Niv Dvir</title>
      <link>https://dev.to/nivdvir</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/nivdvir"/>
    <language>en</language>
    <item>
      <title>What broke when my Claude Code projects started talking to each other</title>
      <dc:creator>Niv Dvir</dc:creator>
      <pubDate>Tue, 01 Sep 2026 22:59:26 +0000</pubDate>
      <link>https://dev.to/nivdvir/what-broke-when-my-claude-code-projects-started-talking-to-each-other-595l</link>
      <guid>https://dev.to/nivdvir/what-broke-when-my-claude-code-projects-started-talking-to-each-other-595l</guid>
      <description>&lt;p&gt;I run about a dozen Claude Code projects on one laptop. Each owns its own domain, and they open sessions against each other to ask questions they aren't allowed to answer themselves. The rules governing that are a separate piece. This one is the plumbing: six things that broke while making those rules actually run, and what each one cost to diagnose.&lt;/p&gt;

&lt;p&gt;Everything below was observed on Claude Code builds 2.1.234 through 2.1.241 in August 2026. Two items have since been re-verified on 2.1.252 and are marked; the rest have not been re-tested, so read them as dated observations rather than permanent properties. Where a fix is a workaround rather than a cure, I say so.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two different stalls that look identical
&lt;/h2&gt;

&lt;p&gt;A session that tries to touch anything outside its own working tree raises a permission dialog. Unattended, nobody answers it. The session hangs, and a hung session is visually indistinguishable from one that is thinking hard.&lt;/p&gt;

&lt;p&gt;Separately, shell commands the permission system cannot statically verify (a loop with variable expansion, say) prompt every time, with the same silent hang.&lt;/p&gt;

&lt;p&gt;These look like one failure and need different cures. Pre-approving read-only tools at launch and forbidding shell use in the request itself, sub-agents included, cures the shell class. It does not touch the cross-tree class: pre-approving the Read tool authorises the &lt;em&gt;tool&lt;/em&gt;, not paths outside the session's tree. A session launched under that same recipe still blocked forever when asked to read one file in another project.&lt;/p&gt;

&lt;p&gt;Only confinement cures the second one. Give the receiving session nothing that requires leaving its own project, and there is no dialog to hang on.&lt;/p&gt;

&lt;p&gt;One consolation, and it is a real one: a stalled session is recoverable. Attach to it from a terminal and the pending dialog renders. Either answer resolves it, and the session then drains every message that queued while it was blocked. Nothing is lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  A trust prompt that fires before your message arrives
&lt;/h2&gt;

&lt;p&gt;A project whose MCP servers nobody has approved yet blocks at startup, before it receives anything at all. Your message never lands, and nothing tells you that. From the sending side it looks exactly like a session that is ignoring you.&lt;/p&gt;

&lt;p&gt;A human at a terminal can clear it one session at a time. The only fix that scales is a standing trust decision for that server.&lt;/p&gt;

&lt;h2&gt;
  
  
  The receiving model refusing on safety grounds
&lt;/h2&gt;

&lt;p&gt;One request came back refused because several terms in it read as security-related in combination. The receiving agent classified the request and declined to answer it.&lt;/p&gt;

&lt;p&gt;Re-sending with that section removed worked. That is the one cure I measured; rephrasing in place seems plausible and I did not try it.&lt;/p&gt;

&lt;p&gt;Worth naming because it is a structural property rather than a bug: when the receiver is a model, the content of a request can prevent its delivery. No transport-layer guarantee survives that.&lt;/p&gt;

&lt;h2&gt;
  
  
  The silent stall that looks like working
&lt;/h2&gt;

&lt;p&gt;A background session with no usage credits left accepts your message, prints it, and dies. It goes idle without replying, which is visually identical to still thinking.&lt;/p&gt;

&lt;p&gt;The cause is almost never a real budget problem. A background session does not inherit the model of the session that launched it. It takes the default from user settings. An interactive session running one model therefore spawns background sessions on a different one, and those die on the second model's quota while the first sits untouched.&lt;/p&gt;

&lt;p&gt;Passing the model explicitly at launch fixes it. Before that, read the session's log rather than inferring from its silence: idle-with-no-reply and still-working are the same picture from outside, and only the log distinguishes them.&lt;/p&gt;

&lt;h2&gt;
  
  
  An addressing bug, and how I isolated it
&lt;/h2&gt;

&lt;p&gt;Background sessions print an id at launch. After processing their first task they rename themselves from that task's subject, and the launch id then stops resolving for messaging, reporting the session as unreachable, while every other surface still accepts it: logs, stop, and the id field in the JSON agent listing.&lt;/p&gt;

&lt;p&gt;I isolated it with a negative control. A session that blocked before producing any output never renamed itself, and messaging its launch id worked. I then unblocked that same session, watched it rename, and messaged the identical address again: same session, same process, same build. Rejected. The rename was the only variable.&lt;/p&gt;

&lt;p&gt;That is filed as &lt;a href="https://github.com/anthropics/claude-code/issues/89946" rel="noopener noreferrer"&gt;anthropics/claude-code#89946&lt;/a&gt;. An issue's status is live state owned by someone else, so check the link rather than trusting a sentence I wrote weeks ago.&lt;/p&gt;

&lt;p&gt;The workaround is small: re-discover a session by its current name before any follow-up message.&lt;/p&gt;

&lt;p&gt;One sign the addressing layer is moving. On a newer build, a bad address now returns a disambiguating error naming the candidate sessions instead of a bare "not reachable." That is a different failure from the rename bug, but it shows the surface is being worked on.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-verified on 2.1.252:&lt;/strong&gt; cross-session delivery and typed message exchange still work as described.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one that isn't a bug: a read-only transport can carry a ruling, but not work
&lt;/h2&gt;

&lt;p&gt;The recipe above deliberately strips the receiving session down to read-only tools. That confinement is exactly what makes unattended delivery safe: nothing can prompt, so nothing can hang.&lt;/p&gt;

&lt;p&gt;The cost surfaced on the first real cross-tree job: a project rename whose final stage required edits a confined background session had no way to make. The job finished only because a human instructed a different session to run shell commands inside the other tree. That was outside the protocol, on explicit instruction, and recorded in our own log with the sentence &lt;em&gt;"it worked, and it is not a precedent."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A fair summary of the field state: the mechanics reliably move questions and answers. They have not yet executed a real cross-tree modification end to end.&lt;/p&gt;

&lt;p&gt;The rule that came out of this responds to it rather than solving it. Constrain what a session may &lt;em&gt;touch&lt;/em&gt;, not which tools it holds. Launching a head stripped of the tools it needs and then asking it a question only those tools could answer is the mistake underneath the whole class. Pre-approve what the work requires, bound the scope, and let it refuse if it still cannot answer.&lt;/p&gt;

&lt;p&gt;Whether a full cross-tree modification runs cleanly under that rule is untested.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Re-verified on 2.1.252:&lt;/strong&gt; a session rooted in a directory that contains other projects' trees still reads all of them with no prompt at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the list is for
&lt;/h2&gt;

&lt;p&gt;Two of these six are bugs. Two are consequences of running models rather than services on the receiving end. Two are properties of the permission model that no configuration removes. Only the first pair will be fixed by someone else.&lt;/p&gt;

&lt;p&gt;That distribution is the useful part. When you wire independent agent projects together, the failures you should budget for are not the ones in the transport layer. They are the ones where a session's silence has three possible meanings and the interface shows you the same thing for all three. Most of my diagnosis time went to telling those apart, not to fixing them once told.&lt;/p&gt;

&lt;p&gt;A protocol write-up with no failures in it is a design document wearing field-report clothes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;The rules these sessions were carrying, and who answers to whom, are set out in &lt;a href="https://dev.to/nivdvir/inter-cc-project-communication-ef8"&gt;the companion piece&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aiagents</category>
      <category>claudecode</category>
      <category>debugging</category>
    </item>
    <item>
      <title>A Garbage-Collector Layer Between Claude Code Projects</title>
      <dc:creator>Niv Dvir</dc:creator>
      <pubDate>Sat, 29 Aug 2026 01:29:12 +0000</pubDate>
      <link>https://dev.to/nivdvir/inter-cc-project-communication-ef8</link>
      <guid>https://dev.to/nivdvir/inter-cc-project-communication-ef8</guid>
      <description>&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%2Fsuhq7x5he80mfuv0a405.png" 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%2Fsuhq7x5he80mfuv0a405.png" alt="Painting of trees of different species sharing one glowing golden root network in open grassland, with birds carrying threads of script flying between the canopies under a partly clouded sky." width="799" height="446"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Different species, the same marks. Separate trees, one soil, and the birds carrying what crosses between them.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A request you have made a dozen times, one that used to run at once, now sits and thinks. Tokens burn before a file changes. The forums blame the model, and often they are right. One of the reasons is quieter: the project is working from a picture of your code that stopped being true.&lt;/p&gt;

&lt;p&gt;A Claude Code project keeps a managing layer above its content: instruction files, notes, who owns what. You write that layer once. The content moves every day, and nothing tells the layer. A missing file fails loudly. A stale one fails in silence. The project acts on an old map, learns of changes late, and cannot measure how far it has drifted. That drift is the garbage, and nothing collects it.&lt;/p&gt;

&lt;p&gt;It cannot collect its own. A stale picture cannot know it is stale. Only the owner of the live state can. Change something another project owns and you leave work you cannot see. Asking its owner puts the only one who can spot it in the loop while you are still there, not after it has cost you a week.&lt;/p&gt;

&lt;p&gt;A boss does not fix it, because the worlds differ. One project builds a product, one writes about it, one runs the outreach. The product project knows nothing about publishing. The article project cannot tell which version shipped this morning. Put either in charge and it rules on what it does not understand. Each needs the opposite: what its peers know, taken whole, to be the best in its own field.&lt;/p&gt;

&lt;p&gt;That is what a working collector gives you, and the collector is a rule. No project acts on another's content because it looked right from outside. It asks the owner, who holds what the files do not say, names the work the change drags with it, and knows when someone touched it. Work happens by consent.&lt;/p&gt;

&lt;p&gt;No runtime does this. The six laws that follow are the collector, and the owner of each project does the collecting.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shape: a forest of projects
&lt;/h2&gt;

&lt;p&gt;The unit here is the Claude Code project (CC-project from here on). An agent is one worker inside it. Inside a project, orchestrators already handle hierarchy: one spawns workers, all under one owner. Between projects there is no default. A tree in a forest is not higher than the tree beside it. Hierarchy exists only inside each trunk. Between trunks there is law.&lt;/p&gt;

&lt;p&gt;A directory is a CC-project if and only if it carries its own &lt;code&gt;CLAUDE.md&lt;/code&gt;. That file gives it a head, the one owner that answers for everything under it. Everything else is content of the nearest CC-project above. A subproject passes the same test and is a whole unit, the way a subsidiary is a whole company.&lt;/p&gt;

&lt;p&gt;Two kinds of edge exist. Inside a trunk, head to sub-head: commands down, answers owed up. Between trunks, head to head: asks across, with no power to command.&lt;/p&gt;

&lt;p&gt;Containment shows ownership but does not prove it. A file can sit in one tree and belong to another's domain. A scheduled job belongs to the domain it serves. Where the layout and the record disagree, the record decides. A contested entry stays contested until the dispatcher or the human rules, and a project that settles its own contested entry has ruled in its own favour.&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%2Fsumhjk0ek0diw0ocf0c7.png" 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%2Fsumhjk0ek0diw0ocf0c7.png" alt="Diagram of project A probing project B: a red blocked line trying to read B’s directory, which sits inside a solid guarded boundary, next to two green successful paths reaching B’s GitHub repo and public status inside a larger dashed boundary the permission system cannot see." width="800" height="441"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;The permission system can see the solid line. The domain is the dashed one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;One entity sits above the trees. The dispatcher owns the working method and no content, serves every project, commands none, and insists on correct conduct. Gaps between projects end there: a contradiction that will not dissolve, an owner nobody can reach, a remit nobody recorded. A ruling inside a tree does not. With three CC-projects you hold that role yourself.&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%2Flda7pzn8q4tnc36by865.png" 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%2Flda7pzn8q4tnc36by865.png" alt="Diagram of three CC-project folders side by side. Inside the first, black arrows run down from its head to a sub-project and back up, with the labels commands and answers to. Between the separate folders, teal arrows carry the label asks, cannot command. A dashed box below holds the dispatcher." width="800" height="457"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two kinds of edge, and only two: commands down inside a trunk, asks across between them.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The six laws
&lt;/h2&gt;

&lt;p&gt;Each law covers one dimension, so you can read or apply any of them alone. Only the person these projects serve may amend one. Together they are the collector: the first tells you whose a subject is, the second stamps what comes back with its kind, the third carries the request and the answer, the fourth says what a request may not carry, the fifth gates what it may cause, and the sixth keeps two paths honest with each other. The walk-through after them shows it running.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Ownership
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every domain has one owner: a head.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Ownership is three inseparable things: the authority to rule on the domain, the responsibility for its records, and the capability to refresh them. Capability exercised through a human’s gated hands (a login, a credential, a click they must perform) still counts; it is mediated, not missing. If a leg is genuinely missing, the ownership itself is broken: fix that, never the symptom. Tree containment is evidence of ownership, never proof: an ownership recorded as contested in the registry stands contested until the dispatcher or the human rules, whatever tree holds the files. OS-level artifacts (a LaunchAgent, a cron entry) belong to the domain they serve, not the directory that contains them. The shared configuration directory is neutral ground, stewarded by the dispatcher. The registry is ownership’s map: read it before any cross-project work; keep your own row true; never write another’s; two heads whose subjects touch must recognize each other in both directions. (Standing rulings, 2026-08: the subject of a piece, never the platform, decides its ownership; every head carries its own publication capability, and no head manages publication for others.) Nobody else touches, verifies, or rules on a domain. They ask.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The mechanism is a shared registry, the thing FIPA calls a Directory Facilitator and describes as a yellow pages service.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Read it before any cross-project work.&lt;/li&gt;
&lt;li&gt;Keep your own row true.&lt;/li&gt;
&lt;li&gt;Never write another head's row.&lt;/li&gt;
&lt;li&gt;Two heads whose subjects touch must recognise each other in both directions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Who wrote an entry matters. One written by the head it describes is authoritative. You may use anyone else's entry about a head, and it carries no authority. An owner who cannot refresh a class of fact says so and stops. A caveat does not stand in for an answer. What this removes is the confident wrong answer.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Records
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Every record is one of five kinds, and naming the kind is part of writing it:&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
a fact, true about the world, refreshable from a source; a positioning choice, a deliberate presentation (a CV emphasis, an outbound stance), which may differ from fact without contradiction, set and recorded by whoever owns the presentation, the human’s own presentation only by the human, and never invented by a head; a ruling, a decision by the human or an owner, which exists only once written into the file that governs the behaviour it changes, dated and sourced; a snapshot, a dated copy taken for one task, expiring with the task, never authority, and a snapshot of another head’s live state is taken with that owner’s knowledge, stamped with source, and discarded when the task ends; a draft, what was true when written, an archive, never a queue. A record’s kind is fixed when it is written; reclassifying your own record under challenge is resolving the dispute in your own favour: the defect, not a defence. Authority has exactly one home per kind: conduct here, ownership in the registry, procedure in the mechanics file, each domain’s rules in its owner’s governing files. Everything else anywhere, memories, boxes, histories, seeded context, is a pointer or a snapshot. A fix that has not landed in its single home has not happened. When two records disagree, check their kinds first: most contradictions are a positioning choice read as a fact, and dissolve. A genuine contradiction between owners, including a dispute about a record’s kind, is handled one way: tell the peer first, record both readings openly, neither side acts on either, until it is ruled. The dispatcher rules, except where the question is about the person all these projects serve, whose identity, history, or intent only they answer. Holding it open is correct behaviour; quietly resolving it is the defect.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Hold a pointer, not a copy. For any fact another head owns and can change, store the address and ask when you need it. When you must copy, stamp it with date and source and let it expire with the task.&lt;/p&gt;

&lt;p&gt;Going stale is the small problem. The large one is the owner hearing their own record quoted back at them. And a ruling agreed in conversation has not happened, because the next session reads the governing file and not the conversation.&lt;/p&gt;

&lt;p&gt;The figure shows the shape of it: two projects disagreed about where a third one lived, both held snapshots, and naming the kinds settled it. What the laws do not yet do is notify. Nothing told those projects the path had moved, and a rule that dissolves the contradiction after the fact leaves room for one that announces the change before it.&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%2Fqqv65bsse6wbsqerbo9z.png" 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%2Fqqv65bsse6wbsqerbo9z.png" alt="Two-panel diagram. Left, headed What it looks like: head A's record says the project lives at tilde slash old-name, head B's says new-name, with a red band between them reading contradiction, one of them must be wrong. Right, headed What it is: the same two lines tagged SNAPSHOT and FACT, with a green band reading no contradiction." width="800" height="327"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;One is a snapshot, the other the owner's live fact.&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Communication
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;You reach a domain only through its owner.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Enter at the head; the request descends one level at a time, and every node on the path is a participant, free to amend, answer, or refuse, never a relay. The answer returns up the same path, hop by hop. Name the subject, never another tree’s internals. One session per coherent request, and the test is: would the answer to one change the answer to the other? Yes, one session; no, separate sessions. A request is one of three kinds, named explicitly: a ruling (is this true, is this yours), evidence (give me your material, and the holder decides what leaves its scope), or capability (operate your tool for me, tiered by what the tool does, never by who asks). Asking is how facts cross boundaries. Never verify another’s domain yourself, and never impose a constraint that forbids an owner from refreshing its own facts. Constraints bind scope, not tools. An owner who cannot refresh a fact class says so and stops; a caveat is not a substitute for an answer. An owner who cannot be reached at all is a gap: log it to the dispatcher’s queue and stop, because self-service is not a fallback, and urgency that cannot wait goes to the human.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Skipping the middle costs. A node that holds two commitments, each visible to only one of its children, is the only one placed to see that they conflict.&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%2F050i0d7y77t6iyvqenz8.png" 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%2F050i0d7y77t6iyvqenz8.png" alt="A request path diagram. An initiator box on the left points into a target CC-project: the head, marked may amend, constrain, refuse, then one hop down to an intermediate level that adapts it, then one hop down to the owner of the content, who answers. A dashed line climbs back up, annotated at every hop, and a single consolidated reply returns to the initiator from the head only." width="800" height="408"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every node on the path may amend or refuse the request. The answer climbs back annotated.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You never need another tree's internals. Name the subject, address the head, and let the chain place it. If you find yourself naming a sub-subproject, you have left the protocol.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Authority
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A request carries the requester’s need, never its permission.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Laundering is the circumvention of a refusal, actual or reasonably anticipated: if you were denied something, or never asked because you expected denial, a peer doing it for you is the confused deputy, and it goes to the human, however large the efficiency argument. Providing a capability you legitimately own to a peer who was never refused is service, exceptional, not routine, and it is gated like any other action, by its consequence (Law 5). The complement of service: a deliverable’s substance is produced inside the domain responsible for it, and a peer supplies evidence and capability, never your work. If you cannot tell whether you are looking at service or at a refusal being routed around, you are looking at the second. Ask.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the confused deputy, arriving as a polite message from a peer. The law makes the deputy check the authority instead of inheriting it. The harder half in practice is the complement of service: the substance of a deliverable comes from the domain that answers for it, and a peer supplies evidence and capability. Claude Code now ships the first half of this rule itself. A message from another session does not count as consent, and a session must not ask a peer for an action its own permissions denied.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Consequence
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The check precedes the act, always: a ruling that arrives after delivery is damage control.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Then gate every action by its consequence. Reversible, inside your own domain: proceed (GREEN / AMBER). Reversible, inside another’s domain: its owner decides. Irreversible, outbound, money, credentials, deletion, in whoever’s domain: the human, never automatic (RED). A recorded ruling outranks this ladder wherever it is stricter, and a standing gate binds even inside your own domain. And where an intervention risks real harm to a domain and rational weighing gives no sharp answer, the question returns to the human. Ordinary cross-project gaps are not that: they go to the dispatcher’s queue, not to the human’s desk. Operational tests: reversible means the same actor can restore the prior state, at the same tier, with no residue. Outbound means it reaches any party other than the human. Real harm means severe enough that the human would have preferred the act avoided or undone. GREEN, AMBER and RED are the official names of these rungs; satellite files that speak tier language are speaking this law.&lt;/p&gt;
&lt;/blockquote&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%2F15rg2l0uoypzbxfuei68.png" 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%2F15rg2l0uoypzbxfuei68.png" alt="Three stacked rungs of a consequence ladder. Top, red: to the human, tagged RED, for anything irreversible or outbound. Middle, grey: its owner decides, for a reversible action inside another head’s domain. Bottom, green: proceed, tagged GREEN slash AMBER, for a reversible action inside your own. A dashed box above says a recorded ruling outranks every rung. A dashed box below says an ordinary cross-project gap goes to the dispatcher’s queue, not the human’s desk." width="800" height="418"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Every action lands on one rung. A recorded ruling outranks the ladder wherever it is stricter.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Write the operational tests next to the gates, because a gate is only as good as the predicate under it.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. Convergence
&lt;/h3&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Two lanes serving one goal are not independent, however unrelated their subjects look.&lt;/strong&gt;&lt;br&gt;&lt;br&gt;
Each must know the other’s live state, in both directions, or neither can price its own urgency. Say so explicitly the moment you discover a convergence, and keep the state flowing.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;They are alternative routes to one outcome, so neither can price its urgency alone: progress on one changes what the other should spend. Detecting a convergence is the hard part. Two projects with different subjects, owners and vocabularies have no natural moment at which either notices. It is the law used least and found late most often.&lt;/p&gt;

&lt;h2&gt;
  
  
  One request, end to end
&lt;/h2&gt;

&lt;p&gt;Project A needs a fact that project B owns.&lt;/p&gt;

&lt;p&gt;A reads the registry, learns the subject is B's, and sends an evidence request into a session inside B. It addresses B's head, not the node A suspects holds the file, and names the subject in A's own terms. B's head decides the request is B's and places it one hop down. Each node answers or passes it on, until the owner of the content decides what leaves its scope. On the way back up, the middle node adds the one thing only it can see: a commitment its sibling made last week bounds the answer. B's head consolidates and replies once. A records the reply as a snapshot with source and date, keeps the pointer to B, and asks again next time.&lt;/p&gt;

&lt;p&gt;When A wants B to do something, it sends a capability request naming the tool's effect, and B applies the gates. What A may not do is take a refusal from B and send the same action to project C because C has the same tool and no history of saying no. A records the refusal, stops, and takes it to the human.&lt;/p&gt;

&lt;p&gt;When A and B contradict each other, they check the kinds first, because most of these are a snapshot read as a fact and dissolve on sight. If the contradiction survives, neither side resolves it. A tells B, both readings go on record with their authors, neither acts, and the dispatcher rules. Holding it open is the correct state until then.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it costs, and what it does not do yet
&lt;/h2&gt;

&lt;p&gt;This is a set of files and behaviours, and nothing in it intercepts a call. Only behaviour enforces it. The files exist to make correct behaviour cheap to look up. Renumbering a live standard has a price too: every note that cites a bare number then cites nothing, so write the concordance before you delete the old numbering.&lt;/p&gt;

&lt;p&gt;Three limits stand today.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The mechanics move questions and answers between heads and have not yet carried a full cross-tree modification from request to landed change.&lt;/li&gt;
&lt;li&gt;One lane asserted convergence with another, and nobody has confirmed it from the other side.&lt;/li&gt;
&lt;li&gt;The laws are new, checked on paper and not by months of use.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What broke in the tooling is a separate field-notes piece. A protocol write-up with no failures in it is a design document in field-report clothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test your rules like code
&lt;/h2&gt;

&lt;p&gt;Rules a system runs on are code, and "it reads better now" is not a test. You wrote the sentences, so you are the worst judge of whether a stranger can follow them. Three checks catch what the others miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Blind adjudication.&lt;/strong&gt; Take a dozen decisions your projects have already made and strip every trace of how they came out. Hand them, with the rule text and nothing else, to two or three agents that have never seen your system, and let each rule alone. A divergence from your verdict is either a real disagreement about what the rule should say or a sentence that reads two ways. You want both before the rules go live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An inventory of undefined predicates.&lt;/strong&gt; List every term the rules lean on without defining, then attach a test to each or delete it. Gates fail on undefined predicates, because everyone applying one is sure they know what the word means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;An adversarial read.&lt;/strong&gt; Set an agent on the draft with no brief except to break it, and watch for a tidier definition that decides an open question while claiming to reorganise. A clean-up pass produces that failure more than any other.&lt;/p&gt;

&lt;p&gt;The bar is not that every situation has a precomputed answer. It is that no situation lacks a lawful next move: following the rules always gives you something you may do, even when that is to hold the question open, tell the other side, and act on neither reading. Holding open is lawful. Resolving in private is the defect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the ideas come from
&lt;/h2&gt;

&lt;p&gt;Almost every piece of this has a published name already.&lt;/p&gt;

&lt;p&gt;A tree, one head plus everything under it, is a bounded context in Evans and a holon in Koestler. The forest of independent trees interacting as peers is Koestler's holarchy, and distributed systems call it a federated architecture. The shared roster of who owns what is FIPA's Directory Facilitator, a yellow pages service, and Evans's context map. The dispatcher is a platform team in Skelton and Pais. Never modifying another tree's content, only asking, is Evans's anticorruption layer. A request that carries no authority of its own is Hardy's confused deputy.&lt;/p&gt;

&lt;p&gt;Koestler describes hierarchies whose branches interlock with other hierarchies to form horizontal networks, which is the forest, half a century early. Agent communication languages typed requests by act long before this, with KQML's &lt;code&gt;ask-if&lt;/code&gt; and FIPA's &lt;code&gt;query-if&lt;/code&gt; and &lt;code&gt;request&lt;/code&gt;. Entering at the head is Evans's aggregate root. Gating by reversibility is Amazon's one-way and two-way doors.&lt;/p&gt;

&lt;p&gt;Two pieces are in no source I reached. The intermediate node as an active participant, free to amend a request in transit, where the closest specified behaviour requires a faithful relay. And marking a registry entry by who wrote it, so that anyone may use an entry written about a head, and it carries no authority, where FIPA's registry disclaims responsibility for what anyone registers and restricts nobody from registering anything.&lt;/p&gt;

&lt;p&gt;Since this was written, part of it has shipped. Claude Code's cross-session messaging gives independent sessions a channel and a refusal switch, and its docs carry the fourth law: a message from another session does not count as consent, and a session must not ask a peer for an action its own permissions denied. The docs carry no notion of ownership, jurisdiction, or a request typed by what it asks for. Single-agent collectors exist too. Self-GC prunes a long-running agent's context, and context-diet audits persistent context for staleness. Each collects inside one agent. None collects between projects.&lt;/p&gt;

&lt;h2&gt;
  
  
  What transfers
&lt;/h2&gt;

&lt;p&gt;If you run one agent, none of this applies. If you run several with real capability over shared resources, four things do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Only behaviour enforces ownership.&lt;/strong&gt; No configuration makes the permission guard and the ownership boundary coincide. "It did not prompt me" is not "it was mine to do."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Ask, don't verify.&lt;/strong&gt; When another head owns a fact, ask for a per-claim verdict of confirmed, wrong, or not stated, and close with "what did I get backwards?"&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hold a pointer, not a copy.&lt;/strong&gt; Store the address of anything that can change, and stamp any copy you take with its date and source.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test your rules the way you test code.&lt;/strong&gt; Blind adjudication is an afternoon, and it is the only way to learn whether a stranger reads your rules the way you meant them.&lt;/p&gt;

&lt;p&gt;The literature is in domain-driven design, Team Topologies, Koestler, and a 1988 note about a compiler tricked into writing a file it should not have touched. Read those before you write your eleventh clause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading, most useful first
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Team Topologies&lt;/strong&gt; (Skelton and Pais, 2019). Settles the platform-team role, and is the only source built for this problem shape rather than adapted to it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Domain-Driven Design&lt;/strong&gt; (Evans, 2003). Settles domain ownership and the seams between domains. The condensed &lt;em&gt;DDD Reference&lt;/em&gt; is enough if time is short.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Koestler on the holon.&lt;/strong&gt; Settles the forest shape. Two texts get confused: "Some General Properties of Self-Regulating Open Hierarchic Order" is Appendix I of &lt;em&gt;The Ghost in the Machine&lt;/em&gt; (1967), and "Beyond Atomism and Holism: The Concept of the Holon" is the 1969 Alpbach paper. Check which you want before citing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hardy, "The Confused Deputy" (1988).&lt;/strong&gt; Four pages, and the failure mode the anti-laundering rule exists to prevent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;FIPA's specifications are worth reading for the Directory Facilitator definition. The live &lt;code&gt;fipa.org&lt;/code&gt; domain no longer serves them, so use the Internet Archive copies.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nivdvir.github.io" rel="noopener noreferrer"&gt;https://nivdvir.github.io&lt;/a&gt; on September 8, 2026.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>aiagents</category>
      <category>architecture</category>
      <category>claudecode</category>
    </item>
    <item>
      <title>max_pixels is a token budget in disguise — and the right cap depends on the size of what you're looking for</title>
      <dc:creator>Niv Dvir</dc:creator>
      <pubDate>Thu, 11 Jun 2026 07:38:21 +0000</pubDate>
      <link>https://dev.to/nivdvir/maxpixels-is-a-token-budget-in-disguise-and-the-right-cap-depends-on-the-size-of-what-youre-l0c</link>
      <guid>https://dev.to/nivdvir/maxpixels-is-a-token-budget-in-disguise-and-the-right-cap-depends-on-the-size-of-what-youre-l0c</guid>
      <description>&lt;p&gt;Run the same image through the same Qwen2.5-VL model on different runtimes, and it can cost anywhere from &lt;strong&gt;8 to 16,384 visual tokens&lt;/strong&gt; — a 2,000× spread — depending on which inference stack you picked. Nobody changed the model. They just disagree about one config value: &lt;code&gt;max_pixels&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This post is three things: a one-line identity that makes &lt;code&gt;max_pixels&lt;/code&gt; legible, a measured set of accuracy-vs-budget curves showing that the optimal cap &lt;strong&gt;depends on the size of the thing you're trying to find&lt;/strong&gt;, and a survey of what every major runtime defaults to.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — &lt;code&gt;max_pixels ÷ 28²&lt;/code&gt; is your image-token budget. The vendor recommends ≤ 1,280 tokens; most runtimes silently ship 16,384. Measured on real 4K screenshots: &lt;strong&gt;big targets (panels) peak at 1,280 — more tokens makes them &lt;em&gt;worse&lt;/em&gt;. Tiny targets (icons) never stop gaining.&lt;/strong&gt; Set the cap to the size of what you're hunting.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Decoding max_pixels: one line of math
&lt;/h2&gt;

&lt;p&gt;Qwen-VL models turn pixels into tokens in two stages. The vision transformer slices the image into non-overlapping &lt;strong&gt;14×14 px patches&lt;/strong&gt;; then a small MLP merges every &lt;strong&gt;2×2 block&lt;/strong&gt; of neighboring patches into one token. Compose the two and each LLM token owns a contiguous &lt;strong&gt;28×28 px&lt;/strong&gt; square of the image:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;factor = patch_size × merge_size          # 28 for Qwen2/2.5-VL, 32 for Qwen3-VL
tokens = (W × H) / factor²                # area ÷ area-per-token
&lt;/code&gt;&lt;/pre&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.amazonaws.com%2Fuploads%2Farticles%2Fb4odnc85p6qbk2suqky5.png" 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.amazonaws.com%2Fuploads%2Farticles%2Fb4odnc85p6qbk2suqky5.png" alt="How 14px patches merge 2x2 into one 28x28px visual token" width="800" height="453"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Which means the &lt;code&gt;max_pixels&lt;/code&gt; value every config ships is just a token cap wearing a big number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;max_pixels = max_tokens × factor²
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Decode the Qwen2.5-VL checkpoint default with it: &lt;code&gt;max_pixels = 12,845,056 = 16,384 × 28²&lt;/code&gt;. That's a &lt;strong&gt;16,384-token&lt;/strong&gt; image budget. The model card's own recommended range is &lt;strong&gt;256–1,280 tokens&lt;/strong&gt;. The shipped default is 12.8× the recommended ceiling — and because &lt;code&gt;smart_resize&lt;/code&gt; only downscales &lt;em&gt;to&lt;/em&gt; the cap, every sufficiently large image silently runs at a resolution the model wasn't tuned for.&lt;/p&gt;

&lt;p&gt;(The recommended &lt;em&gt;token&lt;/em&gt; budget is the same across the whole Qwen-VL line; only &lt;code&gt;factor&lt;/code&gt; changes per model — 28 vs 32. So Qwen3-VL's recommended pixel cap is &lt;code&gt;1280 × 32² = 1,310,720&lt;/code&gt;, not 1,003,520. The token number is the portable one; the pixel number is not.)&lt;/p&gt;

&lt;h2&gt;
  
  
  So what's the right cap? Wrong question — right for &lt;em&gt;what target&lt;/em&gt;?
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.to/nivdvir/building-a-real-time-screen-reader-on-macos-that-actually-works-471"&gt;An earlier article&lt;/a&gt; established that this cap is the load-bearing preprocessing variable for grounding (capping at 1,280 alone lifted mean IoU from 0.54 to 0.74 on high-res screenshots — Bug #5 there). This post asks the next question: &lt;strong&gt;is 1,280 right for everything?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I first ran the obvious follow-up — sweep the budget, find the knee — and got a clean answer that fell apart the moment I controlled for one variable: &lt;strong&gt;how big the target is relative to the frame.&lt;/strong&gt; So here is the experiment that holds up.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Setup.&lt;/strong&gt; Qwen2.5-VL-7B-4bit on Apple Silicon (mlx-vlm), grounding on real ~4K screenshots from &lt;a href="https://arxiv.org/abs/2504.07981" rel="noopener noreferrer"&gt;ScreenSpot-Pro&lt;/a&gt; (26 professional apps). 200 target elements spanning the size spectrum — from sub-0.1% icons (ScreenSpot-Pro's own human-annotated targets) up to &amp;gt;5% panels and canvases. Budgets 256 / 1,280 / 4,096 / 16,384 tokens; metric is IoU against the reference box.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Where the large-target references come from&lt;/strong&gt; (ScreenSpot-Pro has essentially no large-target annotations — its biggest is 4.7% of frame):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The model &lt;strong&gt;proposes&lt;/strong&gt; named regions it recognizes on each screen.&lt;/li&gt;
&lt;li&gt;Each proposal is &lt;strong&gt;re-localized&lt;/strong&gt; by an independent zoom-in pass at native resolution.&lt;/li&gt;
&lt;li&gt;Every element passes an &lt;strong&gt;ambiguity screen&lt;/strong&gt;: if its description also matches &lt;em&gt;another&lt;/em&gt; region on the same screen, or the box fails crop-level verification, it's out.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;That screen removed &lt;strong&gt;64% of candidates&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The screen isn't pedantry — it's load-bearing. An ambiguous description doesn't just add noise; it &lt;em&gt;systematically fakes&lt;/em&gt; a high-budget penalty: at a low budget the model picks the one salient candidate, at a high budget it can suddenly resolve the other match and box that instead. On the contaminated subset the high-budget drop measures −30%. On the cleaned set, −18%. The cleaned number is the claim.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The result — peaks march right as targets shrink:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;target size&lt;/th&gt;
&lt;th&gt;n&lt;/th&gt;
&lt;th&gt;GT source&lt;/th&gt;
&lt;th&gt;256&lt;/th&gt;
&lt;th&gt;1,280&lt;/th&gt;
&lt;th&gt;4,096&lt;/th&gt;
&lt;th&gt;16,384&lt;/th&gt;
&lt;th&gt;optimum&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;large (&amp;gt;5% of frame)&lt;/td&gt;
&lt;td&gt;36&lt;/td&gt;
&lt;td&gt;audited&lt;/td&gt;
&lt;td&gt;0.343&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.643&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.574&lt;/td&gt;
&lt;td&gt;0.526&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,280&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;medium (1–5%)&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;td&gt;audited&lt;/td&gt;
&lt;td&gt;0.221&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.295&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.279&lt;/td&gt;
&lt;td&gt;0.277&lt;/td&gt;
&lt;td&gt;1,280, then flat&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;small (0.1–1%)&lt;/td&gt;
&lt;td&gt;9&lt;/td&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;0.029&lt;/td&gt;
&lt;td&gt;0.111&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.179&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;0.154&lt;/td&gt;
&lt;td&gt;~4,096&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tiny (&amp;lt;0.1%)&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;human&lt;/td&gt;
&lt;td&gt;0.003&lt;/td&gt;
&lt;td&gt;0.040&lt;/td&gt;
&lt;td&gt;0.056&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0.079&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;still climbing at 16,384&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.amazonaws.com%2Fuploads%2Farticles%2F312ahkc5lnhko7wdrvg5.png" 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.amazonaws.com%2Fuploads%2Farticles%2F312ahkc5lnhko7wdrvg5.png" alt="Four IoU-vs-token-budget curves, one per target-size bin; peaks shift right as targets shrink" width="800" height="514"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two failure directions, and they belong to different size regimes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Large targets degrade above ~1,280 tokens&lt;/strong&gt; (−18% IoU by 16k, audited — and 9.7s → 62s per query on these 4K images, a 6× latency tax). The failure mode is specific: the model still &lt;em&gt;finds&lt;/em&gt; the panel (click-through-center holds at 0.86 → 0.75) but the predicted box drifts past the true boundary. More tokens, worse geometry.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tiny targets never stop gaining.&lt;/strong&gt; Their curve is still rising at the model's architectural ceiling. An icon a few dozen pixels wide on a 4K frame dissolves at any budget the large-target regime would call sane.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Why would more detail &lt;em&gt;hurt&lt;/em&gt;? The budget isn't a compute limit — it's a distribution limit: 16,384 tokens is 12× past the range the model was tuned on, and long-range box geometry is what goes first when position statistics drift out of that range.&lt;/p&gt;

&lt;p&gt;So the model card's 1,280 isn't a universal sweet spot — it's the &lt;strong&gt;large-target optimum&lt;/strong&gt;, and the crossover point where the two regimes trade places. If your targets are panels, 1,280 is exactly right. If they're icons on 4K screens, no flat cap is right, which is why the current research wave (more below) zooms instead of raising resolution.&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.amazonaws.com%2Fuploads%2Farticles%2Fdt63pd7gyv8vb580xxz3.png" 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.amazonaws.com%2Fuploads%2Farticles%2Fdt63pd7gyv8vb580xxz3.png" alt="All 420 individual tests as disks: x=budget, y=IoU, color=target size" width="800" height="486"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Honest scope notes: one model family, one task family (UI grounding), and the large-target references are model-anchored (validated, but not third-party human labels — those don't exist for large UI regions; the small/tiny rows are pure human GT). The medium/small bins are thin (n=8/9). And one finding about the method itself: asked to propose elements, the model essentially cannot name &lt;em&gt;tiny&lt;/em&gt; ones uniquely — only 2 of the 148 elements it proposed were tiny — so the tiny bin rides on ScreenSpot-Pro's human annotations.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is an old law in new units
&lt;/h2&gt;

&lt;p&gt;If "optimal scale depends on target size" sounds familiar, it should — other fields measured it years ago. Object detection: &lt;a href="https://arxiv.org/abs/1711.08189" rel="noopener noreferrer"&gt;SNIP (CVPR 2018)&lt;/a&gt; showed large objects "become too big to be correctly classified" at high resolution, and HRDNet measured large-category AP &lt;em&gt;dropping&lt;/em&gt; 7.6 points when input resolution doubled. Aerial imagery: &lt;a href="https://arxiv.org/abs/2202.06934" rel="noopener noreferrer"&gt;SAHI&lt;/a&gt; tiles images for small objects but has to add full-frame inference back &lt;em&gt;specifically to recover large ones&lt;/em&gt;. The principle is scale-space theory's "characteristic scale" (&lt;a href="https://people.kth.se/~tony/papers/cvap198.pdf" rel="noopener noreferrer"&gt;Lindeberg, IJCV 1998&lt;/a&gt;): every structure has one scale at which it's best detected.&lt;/p&gt;

&lt;p&gt;What hasn't existed — as far as I can find — is this curve in &lt;em&gt;VLM token-budget units&lt;/em&gt;, binned by target size, on screens. The pieces are published separately: &lt;a href="https://arxiv.org/abs/2507.23779" rel="noopener noreferrer"&gt;Phi-Ground&lt;/a&gt; measured token-budget ablations (plateau past ~2k, no size bins); &lt;a href="https://arxiv.org/abs/2603.17441" rel="noopener noreferrer"&gt;AdaZoom-GUI&lt;/a&gt; measured unconditional zoom &lt;em&gt;hurting&lt;/em&gt; easy targets; Qwen's own report (&lt;a href="https://arxiv.org/abs/2409.12191" rel="noopener noreferrer"&gt;Table 7&lt;/a&gt;) saw the off-distribution mechanism from the upscaling side; I reported the shipped-default discrepancy in &lt;a href="https://github.com/Blaizzy/mlx-vlm/issues/1175" rel="noopener noreferrer"&gt;mlx-vlm #1175&lt;/a&gt;. This post adds the size-binned curve that connects them. Connecting dots, not discovering them.&lt;/p&gt;

&lt;p&gt;It also explains why the 2025–26 GUI-grounding literature (&lt;a href="https://arxiv.org/abs/2512.05941" rel="noopener noreferrer"&gt;ZoomClick&lt;/a&gt;, &lt;a href="https://arxiv.org/abs/2604.14113" rel="noopener noreferrer"&gt;UI-Zoomer&lt;/a&gt;, &lt;a href="https://arxiv.org/abs/2511.13087" rel="noopener noreferrer"&gt;MEGA-GUI&lt;/a&gt;, AdaZoom-GUI) converged on coarse-pass-then-conditional-zoom: a first pass at a moderate cap &lt;em&gt;is&lt;/em&gt; the large-target optimum and localizes coarsely; the zoom pass gives small targets high effective resolution inside a crop. The curve above is, in effect, the operating-point table those policies have been picking by ad-hoc sweep.&lt;/p&gt;

&lt;h2&gt;
  
  
  What every runtime actually defaults to
&lt;/h2&gt;

&lt;p&gt;Every major stack implements the &lt;em&gt;same&lt;/em&gt; resize algorithm (HF's &lt;code&gt;smart_resize&lt;/code&gt;, sometimes imported verbatim) — and then they all pick different budgets:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;runtime&lt;/th&gt;
&lt;th&gt;effective default budget&lt;/th&gt;
&lt;th&gt;notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;transformers&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,384 tokens&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;the checkpoint config wins over the class default of 1,280&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,384&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;inherits the checkpoint; profiling reserves 16,384/image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SGLang&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,384&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;env default &lt;code&gt;SGLANG_IMAGE_MAX_PIXELS = 16384·28²&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TensorRT-LLM / NeMo&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;16,384&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;reuse the HF processor as-is&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;llama.cpp&lt;/td&gt;
&lt;td&gt;max 4,096, &lt;strong&gt;min 8 (!)&lt;/strong&gt;
&lt;/td&gt;
&lt;td&gt;so low it logs a warning telling you to pass &lt;code&gt;--image-min-tokens 1024&lt;/code&gt; for grounding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ollama&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;~1,280&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;hardcoded ~1 MP cap — closest to the recommended value&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;mlx-swift-lm&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1,280&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;defaults to the card's recommendation as of &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/243" rel="noopener noreferrer"&gt;PR #243&lt;/a&gt;, override per request&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Same model, same algorithm, defaults spanning three orders of magnitude. If your Qwen-VL grounding accuracy differs across serving stacks, check this value before suspecting the weights. The inflated default is dangerous precisely because it only bites on &lt;em&gt;large&lt;/em&gt; inputs: test on 1080p screenshots and everything looks fine; feed a Retina capture hunting for a panel and the boxes quietly bloat.&lt;/p&gt;

&lt;h2&gt;
  
  
  What to actually do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Know your size regime, then set the cap from the table.&lt;/strong&gt; Panels, windows, layout regions: &lt;code&gt;max_pixels = 1280 × factor²&lt;/code&gt; — the vendor number, now with a measured reason. Buttons and fields on ordinary screenshots: headroom to ~2–4k tokens. Icons on 4K: no flat cap saves you — budget what latency allows, or use a coarse-then-zoom pass like the papers above.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never let large images run uncapped into the ≥5k zone when your targets are large&lt;/strong&gt; — the curve says you pay 6× latency to &lt;em&gt;lose&lt;/em&gt; accuracy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per runtime:&lt;/strong&gt; transformers/vLLM/SGLang — pass &lt;code&gt;max_pixels&lt;/code&gt; explicitly (e.g. &lt;code&gt;mm_processor_kwargs&lt;/code&gt;) rather than trusting the checkpoint config. llama.cpp — raise the floor (&lt;code&gt;--image-min-tokens 1024&lt;/code&gt;) exactly as its warning says. Ollama and current mlx-swift-lm — you're already at the large-target optimum; raise deliberately for small-target workloads.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Don't copy pixel numbers across models&lt;/strong&gt; — Qwen3's factor is 32, so the same token budget is a different &lt;code&gt;max_pixels&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Provenance
&lt;/h2&gt;

&lt;p&gt;This grew out of an earlier project getting &lt;a href="https://dev.to/nivdvir/building-a-real-time-screen-reader-on-macos-that-actually-works-471"&gt;Qwen2.5-VL grounding working natively in Swift on macOS&lt;/a&gt;, where the pixel cap turned out to be the load-bearing preprocessing variable. The default-to-recommended-budget change is merged upstream in mlx-swift-lm &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/243" rel="noopener noreferrer"&gt;#243&lt;/a&gt;. The sweep harness, the element set with its audit verdicts, and the raw per-test results are reproducible; the budget math is checkable from any model's &lt;code&gt;preprocessor_config.json&lt;/code&gt; in two lines.&lt;/p&gt;

&lt;p&gt;If you maintain a runtime that serves Qwen-VL models and ships the 16,384 default — the fix is one line, and your users' large-target grounding gets better and 6× faster.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>ai</category>
      <category>llm</category>
      <category>performance</category>
    </item>
    <item>
      <title>Your parity gate must enforce the number you publish: a testing methodology for porting ML models across runtimes</title>
      <dc:creator>Niv Dvir</dc:creator>
      <pubDate>Fri, 08 May 2026 16:28:08 +0000</pubDate>
      <link>https://dev.to/nivdvir/your-parity-gate-must-enforce-the-number-you-publish-a-testing-methodology-for-porting-ml-models-j4g</link>
      <guid>https://dev.to/nivdvir/your-parity-gate-must-enforce-the-number-you-publish-a-testing-methodology-for-porting-ml-models-j4g</guid>
      <description>&lt;p&gt;You ship an ML model port from Python to another runtime, say Swift, C++ or ONNX. The build succeeds. The model loads. You feed it the canonical test image and the output looks right: valid JSON, sensible structure, plausible coordinates.&lt;/p&gt;

&lt;p&gt;Then you measure pixel-for-pixel against the Python reference. One bbox edge is off by 9 px. Or 200 px. Deterministically. On every test image. It has been that way for weeks, because your test gate ran at a 30 px tolerance and never flagged it.&lt;/p&gt;

&lt;p&gt;Silent drift like this hits any cross-runtime ML port that doesn't make a strict parity gate a first-class part of the build. Here is the four-component setup that catches it, generalized for your own port.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your parity gate has to enforce the number you publish.&lt;/strong&gt; A gate looser than the claim is not a gate. It's a decoration that gives you false confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 30 px gate that hid a 9 px bug
&lt;/h2&gt;

&lt;p&gt;The published claim was "≤ 2 px parity on all 8 edges of both panels" against the Python &lt;code&gt;mlx-vlm&lt;/code&gt; reference. The gate enforcing that claim ran at &lt;code&gt;TOLERANCE=30 px&lt;/code&gt;, and had run at 30 px since early prototyping, when &lt;em&gt;anything&lt;/em&gt; sub-50-px was a win.&lt;/p&gt;

&lt;p&gt;Two of three canonical test images were drifting by ~9 / ~8 / ~5 px on outer edges. Nobody noticed, because the gate was 15× looser than the published number it was supposed to enforce.&lt;/p&gt;

&lt;p&gt;Preparing to upstream the patch forced the forensic re-measurement, which put the actual measurements next to the published prose and exposed the gate as a decorative artifact rather than a check.&lt;/p&gt;

&lt;p&gt;The fix wasn't a single bug, though there &lt;em&gt;was&lt;/em&gt; a bug, in chat-template content ordering. The deeper fix was recognizing that the test infrastructure no longer matched the claim it was supposed to enforce. Once a strict gate was wired to the actual published number, the bug surfaced on the first run.&lt;/p&gt;

&lt;h2&gt;
  
  
  Identical weights do not mean identical output
&lt;/h2&gt;

&lt;p&gt;A port keeps the weights and the architecture, and that does not guarantee the same output. The places where sub-pixel error accumulates:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Image preprocessing.&lt;/strong&gt; Every framework's "Lanczos" is a different Lanczos. PIL, Core Image, OpenCV, and TensorFlow's &lt;code&gt;tf.image.resize&lt;/code&gt; all produce subtly different output for the same input. A 1- or 2-level difference at 8-bit precision in a few hundred edge pixels propagates through attention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Numerical precision boundaries.&lt;/strong&gt; When the model mixes FP16/BF16 in some places and FP32 in others, the exact ordering of &lt;code&gt;cast → multiply → sum&lt;/code&gt; can move a least-significant bit. Across a few thousand multiplies, the LSBs add up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tokenizer / chat-template subtleties.&lt;/strong&gt; If your runtime emits image-token positions one off from what the reference does, attention is one position off everywhere. The output looks plausible because the model degrades gracefully. It just degrades wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Initialization timing.&lt;/strong&gt; Anything that persists between calls: caches, position-ID buffers, RoPE state. A buffer that resets in Python but persists in your port produces different output on the second image than the first.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each alone produces small drift; stacked, they produce wildly wrong output. None of them throws an error: they all pass shape checks and they all return something.&lt;/p&gt;

&lt;p&gt;You only catch them if you compare your runtime's output against a known-correct reference, &lt;strong&gt;at the strictness you care about, on every edge of every output, every time the gate runs.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Four things have to exist together
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. A reference that pins three things
&lt;/h3&gt;

&lt;p&gt;The reference is the source of truth. Every other piece compares against it.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;canonical_baselines.json&lt;/code&gt; (the name in this project; call it whatever) holds the Python reference output for every canonical test image, generated at temperature=0 with a pinned model snapshot:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"model"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"mlx-community/Qwen2.5-VL-7B-Instruct-4bit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"snapshot_hash"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fdcc572e8b05ba9daeaf71be8c9e4267c826ff9b"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mlx_vlm_version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"0.4.3"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"max_edge_delta_allowed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"prompt"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Detect these two UI panels..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"images"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"leetcode_test"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"_zero_px_test/leetcode_test.png"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"size_px"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3078&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2114&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"model_resize"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1260&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;868&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"panels"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"question"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;146&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;421&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;626&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"editor"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;421&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;146&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;881&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;626&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This file pins three things: the model snapshot hash, the reference framework's version, and the input dimensions / preprocessing parameters that produced the output. &lt;strong&gt;All three are required.&lt;/strong&gt; Change one and the saved output stops being a valid reference, so the gate must be re-baselined deliberately rather than passing trivially.&lt;/p&gt;

&lt;p&gt;The published claim, &lt;em&gt;"≤ 2 px on every edge"&lt;/em&gt;, sits in that file as &lt;code&gt;max_edge_delta_allowed: 2&lt;/code&gt;. The number now lives in code, not in prose: publishing a different number means editing this file, and the gate adjusts with it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Compare every edge, not every box
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;strict_2px_gate.py&lt;/code&gt; runs the freshly-built non-reference binary on every canonical image, parses the model's output, compares each edge of each output element against the saved reference, and exits non-zero if any edge exceeds the allowed delta.&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="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;spec&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;images&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_runtime&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;binary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ipath&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rh&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;panels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parse_bboxes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;out&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;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ref_bbox&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;panels&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;panels&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;missing&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edge_name&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;x1&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;y1&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;x2&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;y2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]):&lt;/span&gt;
            &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;ref_bbox&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;max_allowed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;img&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;edge_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two non-obvious things this gate gets right:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Per-edge, not per-bbox.&lt;/strong&gt; A bbox can average an IoU of 0.95 against the reference and still be wrong by 12 px on one specific edge, which makes IoU too forgiving as the headline metric. Per-edge maxes catch the asymmetric drift that the most subtle preprocessing bugs produce.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Missing outputs are failures.&lt;/strong&gt; A bbox for &lt;code&gt;"question"&lt;/code&gt; but none for &lt;code&gt;"editor"&lt;/code&gt; is not "no measurement." It is a failure, and the gate flags it loudly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Prove the patch lines landed
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;setup_and_verify.sh&lt;/code&gt; is the &lt;em&gt;only sanctioned way&lt;/em&gt; to set up a parity-test clone. It fresh-clones the upstream library at the pinned commit, applies your patch, builds the reproducer with the production-equivalent toolchain, and runs the gate, aborting loudly on any failure.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# After git apply, sanity-check the patch landed the canonical fix line:&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'message.images.map { _ in ["type": "image"] }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        Libraries/MLXVLM/Models/Qwen2VL.swift&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
    &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Patch applied but Qwen2VL.swift is missing the image-first chat-template fix."&lt;/span&gt;
    &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"The patch file is incomplete — re-derive from working tree."&lt;/span&gt;
    &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The grep-the-patched-checkout trick catches the most insidious version of this problem: a patch that &lt;em&gt;applies cleanly&lt;/em&gt; but is missing a fix line, because the patch file itself was generated incompletely. The patched build looks fine and runs to completion, and it produces output that's wrong by 9 px. Your gate would have to be tight enough to catch that.&lt;/p&gt;

&lt;p&gt;The grep makes the gate stricter by proving that the patch landed &lt;em&gt;the specific lines that fix the bug you claim to have fixed&lt;/em&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Cross-check adapters at zero tolerance
&lt;/h3&gt;

&lt;p&gt;If your project has more than one consumer (an SDK, an MCP server, a dynamic-library entry point, an alternative runtime path), wire them all to one cross-adapter gate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scripts/parity/cross_adapter_gate.sh — runs the same image through every adapter&lt;/span&gt;
&lt;span class="nv"&gt;A_BBOX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./run_adapter_a &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;B_BBOX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./run_adapter_b &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;C_BBOX&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;./run_adapter_c &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$IMAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$A_BBOX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$B_BBOX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"A vs B"&lt;/span&gt;
diff &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$A_BBOX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$C_BBOX&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; fail &lt;span class="s2"&gt;"A vs C"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This catches &lt;em&gt;integration&lt;/em&gt; drift: one adapter holding stale state, taking a different image preprocessing path, or drifting from the canonical implementation. The expected result here is &lt;strong&gt;0 px tolerance&lt;/strong&gt;. All your adapters call the same model with the same preprocessing, so any difference is a bug.&lt;/p&gt;

&lt;p&gt;In this project it runs at zero tolerance across every adapter that ships, catching anyone who breaks adapter parity before the field does.&lt;/p&gt;

&lt;h2&gt;
  
  
  There is no fuzzy pass
&lt;/h2&gt;

&lt;p&gt;The gate runs in this order on every parity check:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Fresh-clone the reference library at the pinned commit. (No reusing dirty trees.)&lt;/li&gt;
&lt;li&gt;Apply your patch. Fail if it doesn't apply cleanly.&lt;/li&gt;
&lt;li&gt;Grep the patched tree for the literal lines your patch was supposed to introduce. Fail if any are missing.&lt;/li&gt;
&lt;li&gt;Build with the production-equivalent toolchain (in this project's case &lt;code&gt;xcodebuild&lt;/code&gt;, not &lt;code&gt;swift build&lt;/code&gt;, which takes different code paths).&lt;/li&gt;
&lt;li&gt;Run the binary on every canonical image.&lt;/li&gt;
&lt;li&gt;Compare every edge of every output element to the saved reference.&lt;/li&gt;
&lt;li&gt;Run the cross-adapter gate.&lt;/li&gt;
&lt;li&gt;Pass = no edge of any output exceeds &lt;code&gt;max_edge_delta_allowed&lt;/code&gt; AND every adapter agrees at zero tolerance.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Any step that fails aborts the whole gate with a specific error. There is no "warning"; there is no "fuzzy pass." There is "the published number holds" or "the published number does not hold." Binary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Build the gate before the bug-hunt
&lt;/h2&gt;

&lt;p&gt;Build the gate &lt;em&gt;first&lt;/em&gt;, before the bug-hunt, because the bugs you find in your port are the ones the gate flags. A gate at the right tolerance turns "this is mysteriously wrong" into "this specific edge of this specific output exceeds the threshold by N px on this specific image." That's debuggable. A gate at the wrong tolerance produces no information at all.&lt;/p&gt;

&lt;p&gt;The specific scripts above are tuned for a Swift port of Qwen2.5-VL on Apple Silicon with &lt;code&gt;mlx-swift-lm&lt;/code&gt; as the runtime, and they replaced the 30 px gate. The methodology is platform-agnostic.&lt;/p&gt;

&lt;p&gt;If you're porting an ML model across runtimes, the minimum-viable parity setup looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One reference output file&lt;/strong&gt;, pinned to a specific reference-framework version and model snapshot, with the number you'd publish embedded as a hard limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One gate script&lt;/strong&gt; that runs the non-reference binary on every test case and compares per-output-element, per-edge (or per-token, per-pixel, whatever the units of your model output are) against the reference, with a strict tolerance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One bootstrap script&lt;/strong&gt; that builds the binary under test from a clean checkout with the canonical patch fully applied, and aborts if any patch line is missing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One cross-adapter sanity check&lt;/strong&gt; if you have multiple consumers.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Four components: a few short scripts plus a JSON file. It runs in a few minutes after the first build, which dominates wall time anyway.&lt;/p&gt;

&lt;p&gt;The parity gate must enforce the number you publish. A gate that's looser than the claim is not a gate. Build it first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Provenance
&lt;/h2&gt;

&lt;p&gt;The bug-hunt war story (failed model approaches, the bug list, what eventually worked) is on dev.to as &lt;a href="https://dev.to/nivdvir/building-a-real-time-screen-reader-on-macos-that-actually-works-471"&gt;On-Device Document Grounding on macOS: Getting Qwen2.5-VL to Actually Work in Swift&lt;/a&gt;. This post is the testing-methodology companion.&lt;/p&gt;

&lt;p&gt;The scripts I described (&lt;code&gt;canonical_baselines.json&lt;/code&gt;, &lt;code&gt;strict_2px_gate.py&lt;/code&gt;, &lt;code&gt;setup_and_verify.sh&lt;/code&gt;, &lt;code&gt;cross_adapter_gate.sh&lt;/code&gt;) live in the project's &lt;code&gt;scripts/parity/&lt;/code&gt; directory if you want to adapt them.&lt;/p&gt;

</description>
      <category>machinelearning</category>
      <category>testing</category>
      <category>swift</category>
      <category>ai</category>
    </item>
    <item>
      <title>On-Device Document Grounding on macOS: Getting Qwen2.5-VL to Actually Work in Swift</title>
      <dc:creator>Niv Dvir</dc:creator>
      <pubDate>Sat, 18 Apr 2026 04:05:36 +0000</pubDate>
      <link>https://dev.to/nivdvir/building-a-real-time-screen-reader-on-macos-that-actually-works-471</link>
      <guid>https://dev.to/nivdvir/building-a-real-time-screen-reader-on-macos-that-actually-works-471</guid>
      <description>&lt;p&gt;&lt;em&gt;The models that failed, the bugs that took weeks, and the architecture that survived.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  ◆ &lt;u&gt;What I Wanted to Achieve&lt;/u&gt;
&lt;/h2&gt;

&lt;p&gt;I wanted to read what was on my &lt;em&gt;own&lt;/em&gt; screen — a long Wikipedia article, an arXiv PDF, a release note — and render an overlay on top with content annotations (summary bullets, section anchors, corner-to-corner perspective lines). Everything local on Apple Silicon. No cloud, no audio capture, no hidden assistance — just "here's a document on screen, understand it in-device, draw something useful on top."&lt;/p&gt;

&lt;p&gt;On paper this is a well-defined pipeline: detect content regions in a screenshot, OCR them, accumulate text across scroll positions, render guide markers over the panels. In practice every step broke in a way I hadn't expected, and the working combination took months to find.&lt;/p&gt;




&lt;h2&gt;
  
  
  ◆ &lt;u&gt;What I Tried (and Why It Didn't Work)&lt;/u&gt;
&lt;/h2&gt;

&lt;p&gt;This section might be the most useful part of this article. Each of these approaches cost days to weeks of effort. If you are building anything similar on macOS, you can skip all of them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Florence-2
&lt;/h3&gt;

&lt;p&gt;Microsoft's Florence-2 was the first vision model I tried. It supports grounding tasks out of the box -- you give it an image and ask "where is the text panel?" and it returns bounding box coordinates. On paper, perfect for UI panel detection.&lt;/p&gt;

&lt;p&gt;In practice, Florence-2 cannot run on macOS with Apple Silicon. The model uses a custom architecture that requires &lt;code&gt;trust_remote_code=True&lt;/code&gt;, depends on flash-attention (a CUDA-only library), and cannot be converted to CoreML. There is no MLX port. I spent two days trying different conversion paths before accepting that this model simply does not exist on Apple's platform.&lt;/p&gt;

&lt;p&gt;If you are searching for a grounding-capable vision model on macOS, remove Florence-2 from your list immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ferret-UI
&lt;/h3&gt;

&lt;p&gt;Apple's own UI understanding model seemed like the obvious choice for an Apple Silicon project. Ferret-UI was specifically designed to understand user interfaces -- element detection, widget classification, spatial reasoning about UI layouts.&lt;/p&gt;

&lt;p&gt;It was a dead end. Ferret-UI requires CUDA flash-attention, which means it needs an NVIDIA GPU. Apple's own UI understanding model does not run on Apple's own hardware without significant porting effort. Beyond the runtime issue, the model's grounding output was not usable for my task -- I needed precise pixel-coordinate bounding boxes, and the model's output format did not map cleanly to that.&lt;/p&gt;

&lt;p&gt;The irony of Apple publishing a UI model that cannot run on macOS was not lost on me.&lt;/p&gt;

&lt;h3&gt;
  
  
  Qwen2.5-VL-3B (the Small One)
&lt;/h3&gt;

&lt;p&gt;After the first two dead ends, I found that Qwen2.5-VL had an MLX port via the &lt;code&gt;mlx-vlm&lt;/code&gt; library. The 3B parameter variant (4-bit quantized) was only 2.9GB, loaded in 1.9 seconds, and ran inference in 3-7 seconds. Fast and light.&lt;/p&gt;

&lt;p&gt;But too weak. The 3B model could identify that UI elements existed in an image -- it would say "there is a text panel on the left" -- but the bounding box coordinates it returned were hallucinated. Boxes would be off by hundreds of pixels, overlap incorrectly, or enclose regions that contained nothing. For panel detection where you need to know "the question text lives between pixels (0, 120) and (900, 800)," a model that hallucinates coordinates is worse than no model at all.&lt;/p&gt;

&lt;p&gt;The 7B variant turned out to be the sweet spot. More on that in the next section.&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.amazonaws.com%2Fuploads%2Farticles%2F6kd9o21gut4mr1ekfv83.png" 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.amazonaws.com%2Fuploads%2Farticles%2F6kd9o21gut4mr1ekfv83.png" alt="3B model hallucinated bounding boxes vs 7B accurate detection" width="799" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Pixel-Edge Detection (No ML)
&lt;/h3&gt;

&lt;p&gt;Before committing to a VLM, I tried the traditional computer vision approach. Each UI panel has a uniform background color. The question panel might be &lt;code&gt;rgb(53, 67, 83)&lt;/code&gt;, the editor panel &lt;code&gt;rgb(22, 43, 54)&lt;/code&gt;. In theory, you can find panel boundaries by detecting where the background color changes.&lt;/p&gt;

&lt;p&gt;The algorithm worked on test screenshots. Then I tested it on a page where both panels used similar background colors. The panel border was a thin 1-pixel line that blended into the surrounding regions. Same-color-background UIs -- which are increasingly common with modern design trends -- broke the approach entirely.&lt;/p&gt;

&lt;p&gt;Pixel-edge detection is fragile because it depends on an assumption (panels have visually distinct backgrounds) that is not guaranteed. A VLM can detect panel boundaries semantically -- it understands "this is a question panel" regardless of what color it is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Accessibility API (AX API)
&lt;/h3&gt;

&lt;p&gt;macOS has a built-in accessibility API that lets you programmatically read UI elements. For a screen reader, this sounds ideal.&lt;/p&gt;

&lt;p&gt;The problem is that the Accessibility API cannot see inside web content rendered in Chrome. The browser exposes high-level structural elements -- the window, the tab bar, the content area -- but not individual text lines, panel layouts, or the DOM structure within the page. You get a single "web area" element that says "this is a web view" with no ability to drill into it.&lt;/p&gt;

&lt;p&gt;If your target is a native macOS application, the AX API might work. For reading web-based UIs through the browser, it is a dead end.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spawning a New Python VLM Process Per Inference
&lt;/h3&gt;

&lt;p&gt;My initial integration spawned a new Python process for each VLM inference call. The Python script imported &lt;code&gt;mlx-vlm&lt;/code&gt;, loaded the Qwen2.5-VL-7B model (5.3GB of weights), ran inference on one image, printed the result, and exited. The next cycle, 15 seconds later, spawned a new process that loaded the 5.3GB model again.&lt;/p&gt;

&lt;p&gt;After three or four cycles, the Mac froze. Each process was loading the full model into unified memory, and the previous processes had not fully released their allocations before the next one started. OOM within minutes.&lt;/p&gt;

&lt;p&gt;The fix was a persistent server architecture: load once, serve many.&lt;/p&gt;




&lt;h2&gt;
  
  
  ◆ &lt;u&gt;The Architecture That Worked&lt;/u&gt;
&lt;/h2&gt;

&lt;p&gt;Here is the system that survived. Each component earned its place by being the last option standing after everything else failed.&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.amazonaws.com%2Fuploads%2Farticles%2Fw469k0ytylzov2plorp3.png" 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.amazonaws.com%2Fuploads%2Farticles%2Fw469k0ytylzov2plorp3.png" alt="Architecture diagram — screen reader pipeline" width="799" height="376"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Panel Detection: Qwen2.5-VL-7B via MLX
&lt;/h3&gt;

&lt;p&gt;The 7B parameter Qwen2.5-VL model, 4-bit quantized, is the sweet spot for UI panel detection on Apple Silicon. The 3B model hallucinates bounding boxes. Larger models (14B+) are too slow for interactive use. The 7B variant reliably returns accurate panel coordinates when prompted correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why MLX matters.&lt;/strong&gt; Apple Silicon's unified memory architecture means the CPU and GPU share the same physical RAM. MLX exploits this -- the model weights live in unified memory once and are accessed by both the CPU (for attention computations) and the GPU (for matrix multiplications) without copying. The 4-bit quantized model shows ~238MB resident memory in Activity Monitor, not the full weight file size, because MLX memory-maps the weights and pages them in on demand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The prompt that works.&lt;/strong&gt; After testing dozens of prompt variations, this format reliably produces usable output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Detect the following UI panels in this screenshot and output their
bounding box coordinates in JSON format:
1. The "question" panel (problem description text area)
2. The "editor" panel (code editor area)

Return JSON with format: [{"label": "question", "bbox_2d": [x1,y1,x2,y2]},
{"label": "editor", "bbox_2d": [x1,y1,x2,y2]}]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key details: ask for each panel by name on a numbered list (the model sometimes merges panels into one bbox if you describe them in a single sentence), and specify the exact JSON format you want (the model follows format instructions well).&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.amazonaws.com%2Fuploads%2Farticles%2Fq4jmygeos0szcv6urzhr.png" 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.amazonaws.com%2Fuploads%2Farticles%2Fq4jmygeos0szcv6urzhr.png" alt="Before and after: raw screenshot vs VLM panel detection" width="799" height="288"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The initial architecture: a persistent Python server.&lt;/strong&gt; The model takes ~12 seconds to load. Rather than paying that cost every cycle, I built a Python server process that loads the model at startup and accepts requests over a simple stdin/stdout protocol:&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="c1"&gt;# Server: load model once, serve forever
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;sys&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mlx_vlm&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;generate&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mlx_vlm.prompt_utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;apply_chat_template&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mlx_vlm.utils&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_config&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mlx-community/Qwen2.5-VL-7B-Instruct-4bit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_config&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mlx-community/Qwen2.5-VL-7B-Instruct-4bit&lt;/span&gt;&lt;span class="sh"&gt;"&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;sys&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stdin&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;request&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="n"&gt;prompt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;apply_chat_template&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                  &lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                                  &lt;span class="n"&gt;num_images&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;processor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prompt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                      &lt;span class="n"&gt;image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;request&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;image_path&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                      &lt;span class="n"&gt;max_tokens&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;512&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;verbose&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&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;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;}),&lt;/span&gt; &lt;span class="n"&gt;flush&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Swift host process spawns this server once, sends JSON requests on stdin, and reads JSON responses from stdout. No HTTP server, no sockets, no serialization framework -- just newline-delimited JSON over pipes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Coordinate conversion.&lt;/strong&gt; The model returns bounding boxes in the coordinate space of the resized image (max 1280px on the longest side, rounded to multiples of 28). To get screen pixels:&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="n"&gt;screen_x&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model_x&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original_width&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;resized_width&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;retina_scale&lt;/span&gt;
&lt;span class="n"&gt;screen_y&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;model_y&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;original_height&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;resized_height&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;retina_scale&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On a Retina display, &lt;code&gt;retina_scale&lt;/code&gt; is 2.0. Forgetting this division is a common source of bounding boxes that are exactly 2x too large.&lt;/p&gt;

&lt;h3&gt;
  
  
  From Python Server to Native Swift
&lt;/h3&gt;

&lt;p&gt;The Python persistent server worked. But it had friction: a Python subprocess to manage, a PIL resize helper, stdin/stdout JSON marshaling, and ~50ms of overhead per inference just from process communication. For an interactive-latency pipeline I wanted everything native.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;mlx-swift-lm&lt;/code&gt; library promised exactly this -- a Swift implementation of the MLX model runtime, including Qwen2.5-VL. Load the model in Swift, run inference in Swift, no Python anywhere. In theory, a single-binary solution.&lt;/p&gt;

&lt;p&gt;In practice, the Swift implementation had 10 bugs (the 10th surfaced after this article was published — see the postscript). Finding and fixing them took weeks. But the result was worth it: a fully native Swift binary that runs Qwen2.5-VL-7B with zero Python dependencies, matching the Python reference's grounding accuracy on canonical test images — and exceeding it on high-resolution screenshots once the 1280-pixel cap from Bug #5 is in place.&lt;/p&gt;

&lt;h3&gt;
  
  
  The 9 Bugs in mlx-swift-lm's Qwen2.5-VL (plus a 10th found after publication)
&lt;/h3&gt;

&lt;p&gt;This section documents those weeks. The bugs collectively made the model produce wrong bounding boxes. Fixing them was the difference between "the model hallucinates" and "the model returns grounding boxes within bench tolerance of the Python reference on every panel of every canonical test image."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. MROPE section selection (split-select vs slice-replace).&lt;/strong&gt; Multi-Resolution Rotary Position Embedding (MROPE) assigns different frequency bands to temporal, height, and width dimensions. The Swift implementation split the frequency tensor into three parts using modulo indexing (&lt;code&gt;i % 3&lt;/code&gt;), which interleaves the frequencies. Python's implementation starts with temporal frequencies and overwrites height/width slices in-place: &lt;code&gt;[T_0-15, H_16-39, W_40-63]&lt;/code&gt;. The layouts are completely different, and the wrong layout produces subtly wrong attention patterns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Chat template ordering.&lt;/strong&gt; The Swift message generator placed text before the image token in the content array. The Python implementation puts the image first: &lt;code&gt;&amp;lt;|vision_start|&amp;gt;&amp;lt;|image_pad|&amp;gt;&amp;lt;|vision_end|&amp;gt;PROMPT&lt;/code&gt;. This ordering matters because the model's attention patterns are position-dependent -- putting text before the image means the text tokens attend to positions where image features have not yet been injected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. invFreq registered as a Module weight.&lt;/strong&gt; The &lt;code&gt;invFreq&lt;/code&gt; tensor was declared as a property on an &lt;code&gt;Attention&lt;/code&gt; class that inherits from &lt;code&gt;Module&lt;/code&gt;. MLX's weight-loading mechanism scans all &lt;code&gt;Module&lt;/code&gt; properties and tries to load matching weights from the checkpoint. Since &lt;code&gt;invFreq&lt;/code&gt; is a computed constant (not a learned weight), the loader either threw &lt;code&gt;keyNotFound&lt;/code&gt; errors or silently overwrote it with garbage. The fix was wrapping it in a non-&lt;code&gt;Module&lt;/code&gt; class to hide it from reflection.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. rope_deltas unused during autoregressive generation.&lt;/strong&gt; After the prefill pass, the code cleared the cached position IDs but never applied &lt;code&gt;rope_deltas&lt;/code&gt; during subsequent token generation. The correct computation is &lt;code&gt;positionIds = cache_offset + rope_deltas + arange(seqLen)&lt;/code&gt;. Without the deltas, position embeddings drifted with each generated token, degrading output quality progressively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Image resize using 1800px max instead of 1280px.&lt;/strong&gt; The Swift code resized input images to a maximum of 1800 pixels on the longest side, producing 2688 visual tokens. A follow-up sweep showed 1280 isn't an arbitrary number — it's the empirical accuracy peak for this model on high-resolution UI screenshots. Cap-sweep on five 3000+-px canonical test images: &lt;strong&gt;1280 → 0.736 mean IoU vs 0.542 at the model's default unconstrained smart_resize&lt;/strong&gt; (the same ranking holds on UI-Vision N=20: 0.435 → 0.407). Log-probability also peaks at 1280, cross-validating the IoU signal. The training resolution and the accuracy peak coincide, which is presumably no accident — feeding the model 1800-px images put visual token positions outside its training distribution and dropped accuracy substantially.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;6. Prompt format for single bbox output.&lt;/strong&gt; Using a single sentence asking for both panels caused the model to sometimes return one combined bounding box. Switching to a numbered list with explicit labels ("1. question panel" / "2. editor panel") reliably produced two separate bboxes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;7. maxTokens not set.&lt;/strong&gt; Without an explicit &lt;code&gt;max_tokens&lt;/code&gt; parameter, the model generated tokens until hitting an internal limit or running out of memory. For a task that should return ~100 tokens of JSON, this caused multi-second waits and occasionally produced thousands of tokens of hallucinated output.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;8. MROPE state not reset between successive images.&lt;/strong&gt; The cached position IDs and rope deltas from one image persisted into the next inference call. When processing a new screenshot, the model's position embeddings started from where the previous image left off instead of resetting. This caused progressively worse results on the second, third, and subsequent images.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;9. Vision attention mask ignored -- the ROOT CAUSE.&lt;/strong&gt; This was the single bug most responsible for bounding box inaccuracy. The vision encoder's self-attention uses a mask to implement windowed attention (the model processes the image in patches, and each patch should only attend to patches within its window). The Swift code passed &lt;code&gt;mask: .none&lt;/code&gt; to the scaled dot-product attention call instead of &lt;code&gt;mask: .array(floatMask)&lt;/code&gt;. Without the mask, every patch attended to every other patch globally, destroying the spatial locality that the model relies on for precise coordinate prediction.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WRONG -- ignores the attention mask entirely&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;attnOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scaledDotProductAttention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;values&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="nv"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="k"&gt;none&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;// CORRECT -- applies the windowed attention mask&lt;/span&gt;
&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;attnOutput&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;scaledDotProductAttention&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;k&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;values&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="nv"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;scale&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;floatMask&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After fixing all 9 bugs (and a 10th, surfaced after publication), the Swift implementation produces grounding output that matches the Python reference's accuracy on the canonical test set — and exceeds it on high-resolution images once the 1280-pixel cap from Bug #5 is in place (mean IoU 0.736 vs 0.542 at unconstrained smart_resize). The model was not hallucinating; the implementation was broken. Canonical preprocessing now lives in &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/243" rel="noopener noreferrer"&gt;PR #243&lt;/a&gt;; the rest of the fixes are in &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/222" rel="noopener noreferrer"&gt;#222&lt;/a&gt; and its splits #238/#239/#242.&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.amazonaws.com%2Fuploads%2Farticles%2Fwltz3rc5q9ac2urslj2c.png" 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.amazonaws.com%2Fuploads%2Farticles%2Fwltz3rc5q9ac2urslj2c.png" alt="Python vs Swift convergence — 0px delta after 9 bug fixes" width="800" height="520"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Upstream status.&lt;/strong&gt; The 8 bugs that live inside &lt;code&gt;mlx-swift-lm&lt;/code&gt; (all of the above except #6 prompt format and #7 maxTokens, which belong in consumer code) are submitted upstream as the omnibus &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/222" rel="noopener noreferrer"&gt;&lt;strong&gt;#222&lt;/strong&gt;&lt;/a&gt; plus four isolated splits per upstream review feedback: &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/238" rel="noopener noreferrer"&gt;&lt;strong&gt;#238&lt;/strong&gt;&lt;/a&gt; (vision attention mask), &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/239" rel="noopener noreferrer"&gt;&lt;strong&gt;#239&lt;/strong&gt;&lt;/a&gt; (MROPE + rope_deltas + invFreq + state-reset), &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/242" rel="noopener noreferrer"&gt;&lt;strong&gt;#242&lt;/strong&gt;&lt;/a&gt; (chat-template image-first), and &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/243" rel="noopener noreferrer"&gt;&lt;strong&gt;#243&lt;/strong&gt;&lt;/a&gt; (preprocessing: 1280-pixel cap with CIImage Bicubic resize).&lt;/p&gt;

&lt;p&gt;These patterns aren't specific to Qwen2.5-VL. The same &lt;code&gt;mask: .none&lt;/code&gt; attention bug appears in &lt;code&gt;Qwen2VL.swift&lt;/code&gt; and &lt;code&gt;GlmOcr.swift&lt;/code&gt;; the MROPE plumbing (invFreq, rope_deltas, section selection) is shared across all MROPE-based VLMs in the library (&lt;code&gt;Qwen2VL&lt;/code&gt;, &lt;code&gt;Qwen25VL&lt;/code&gt;, &lt;code&gt;Qwen3VL&lt;/code&gt;, &lt;code&gt;Qwen35&lt;/code&gt;, &lt;code&gt;GlmOcr&lt;/code&gt;). PR #222 is the flagship fix with model-by-model follow-ups coming as each is validated against the Python reference.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-architecture validation.&lt;/strong&gt; To sanity-check that the combined patch really does something model-independent, I ran the same A/B on &lt;code&gt;mlx-community/UI-TARS-1.5-7B-4bit&lt;/code&gt; — ByteDance's click-prediction model, which shares &lt;code&gt;Qwen2_5_VLForConditionalGeneration&lt;/code&gt; architecture, same hidden size, same special tokens. Same deterministic input image, same model, only the source of &lt;code&gt;Qwen25VL.swift&lt;/code&gt; differs.&lt;/p&gt;

&lt;p&gt;With the PR applied: 200 tokens generated, 9+ distinct coordinates tracking actual content positions. Without the PR: 52 tokens generated, output collapses to two entries at identical coordinates &lt;code&gt;(141, 141)&lt;/code&gt; — a clean signature of broken position encoding. Two independent Qwen2.5-VL-family models, same failure mode when the fixes aren't present. The &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/222#issuecomment-4283420555" rel="noopener noreferrer"&gt;A/B reproducer is attached to PR #222&lt;/a&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  ◆ &lt;u&gt;Combining Everything Into a Working Pipeline&lt;/u&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  OCR: Apple Vision Framework
&lt;/h3&gt;

&lt;p&gt;Apple's Vision framework provides on-device OCR that runs on the Neural Engine at ~300ms per frame.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Recognition levels are confusing.&lt;/strong&gt; The API has two recognition levels: level 0 and level 1. Intuitively, you might assume level 0 is the baseline (fast) and level 1 is the premium (accurate). It is the opposite. Level 0 is accurate (slower, higher quality). Level 1 is fast (lower quality). I ran with level 1 for weeks thinking I was getting the best results, then discovered I was using the fast path the entire time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;RecognizeDocumentsRequest vs VNRecognizeTextRequest.&lt;/strong&gt; Apple's Vision framework has two OCR APIs, and they behave very differently on code content. &lt;code&gt;RecognizeDocumentsRequest&lt;/code&gt; (the newer, WWDC25 API) is optimized for documents -- prose, forms, receipts. It silently drops lines that look like code: indented lines with brackets, semicolons, and unusual formatting. For a code editor panel, it would capture 15 out of 20 visible lines, silently losing the rest.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;VNRecognizeTextRequest&lt;/code&gt; (the older API) captures everything -- every line, regardless of formatting. For reading code from screen, use &lt;code&gt;VNRecognizeTextRequest&lt;/code&gt;. I discovered this after weeks of mysterious "missing lines" that turned out to be the newer API being too clever about what constitutes document text.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bounded OCR.&lt;/strong&gt; Rather than scanning the entire screen (which picks up menu bars, dock icons, and other noise), the OCR is bounded to the panel regions detected by the VLM. This reduces both processing time and false positives -- you only extract text from the panel you care about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Scroll Accumulator
&lt;/h3&gt;

&lt;p&gt;Most non-trivial content does not fit in a single viewport. A problem description might be 40 lines long, but only 15 are visible at once. The scroll accumulator solves this by scrolling through the content in steps, OCR-ing each viewport, and stitching the results into a complete transcript.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The stitching problem.&lt;/strong&gt; Adjacent viewports overlap. When you scroll down by 100 pixels, the bottom 80% of the previous viewport is still visible. Naive concatenation produces massive duplication. The accumulator uses Levenshtein distance to fuzzy-match each incoming OCR line against all accumulated lines.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Threshold tuning.&lt;/strong&gt; A line is classified as "already seen" if its Levenshtein similarity to any accumulated line exceeds 60%. I tested thresholds from 40% to 80%:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;40%: too permissive -- novel lines were classified as duplicates and dropped&lt;/li&gt;
&lt;li&gt;80%: too strict -- lines with minor OCR variations were classified as novel and added twice&lt;/li&gt;
&lt;li&gt;60%: best F1 score for the duplicate-vs-novel classification task&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Metal GPU Overlay Rendering
&lt;/h3&gt;

&lt;p&gt;The overlay renders detected text and annotations as a transparent window on top of the target application.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight swift"&gt;&lt;code&gt;&lt;span class="k"&gt;let&lt;/span&gt; &lt;span class="nv"&gt;window&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSWindow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nv"&gt;contentRect&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;screenFrame&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;styleMask&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;borderless&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;backing&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;buffered&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="nv"&gt;defer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;level&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kt"&gt;NSWindow&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="kt"&gt;Level&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;rawValue&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isOpaque&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;backgroundColor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;clear&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ignoresMouseEvents&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="n"&gt;window&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;hasShadow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Self-exclusion from screen capture.&lt;/strong&gt; This is critical: the overlay must not appear in its own screenshots. If it does, the next VLM inference cycle sees the overlay text, interprets it as UI content, and the system enters a feedback loop where it reads its own annotations. The fix is &lt;code&gt;captureScreenExcluding(windowID:)&lt;/code&gt;, which tells ScreenCaptureKit to exclude the overlay window from the captured frame.&lt;/p&gt;




&lt;h2&gt;
  
  
  ◆ &lt;u&gt;The Final Result&lt;/u&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  A Taste of How It Looks
&lt;/h3&gt;

&lt;p&gt;Here's the system running in reader mode — detecting the main content region, reading text across scroll positions, and rendering a summary overlay with perspective lines anchored to the source:&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.amazonaws.com%2Fuploads%2Farticles%2F4sy2b58i45otlk7w89we.gif" 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.amazonaws.com%2Fuploads%2Farticles%2F4sy2b58i45otlk7w89we.gif" alt="GroundingKit reader mode — summary overlay on a long article with corner-anchor perspective lines" width="719" height="467"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Performance Numbers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Latency&lt;/th&gt;
&lt;th&gt;Resource&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;VLM model loading&lt;/td&gt;
&lt;td&gt;~3s&lt;/td&gt;
&lt;td&gt;Unified memory (one-time)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VLM panel detection&lt;/td&gt;
&lt;td&gt;~18s per inference&lt;/td&gt;
&lt;td&gt;GPU (MLX unified memory)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OCR per frame&lt;/td&gt;
&lt;td&gt;~300ms&lt;/td&gt;
&lt;td&gt;Neural Engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Overlay render&lt;/td&gt;
&lt;td&gt;&amp;lt;16ms (60fps)&lt;/td&gt;
&lt;td&gt;Metal GPU&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Full scroll accumulation&lt;/td&gt;
&lt;td&gt;~40s (20 steps)&lt;/td&gt;
&lt;td&gt;Combined&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model resident memory&lt;/td&gt;
&lt;td&gt;~5.5GB peak&lt;/td&gt;
&lt;td&gt;Unified memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The VLM inference is the bottleneck at ~18 seconds, but it only needs to run when the panel layout changes (e.g., navigating to a new page). During normal operation, the OCR and overlay run continuously at ~300ms per cycle while the VLM-detected panel bounds remain cached. On an M1 Pro with 16GB, the system runs comfortably alongside Chrome and other applications.&lt;/p&gt;




&lt;h4&gt;
  
  
  A note on "native Swift vs Python"
&lt;/h4&gt;

&lt;p&gt;Worth being honest about what "native Swift" does and doesn't buy you here. The VLM forward pass is the same Metal kernels in either language, so a one-shot &lt;code&gt;mlx-vlm&lt;/code&gt; Python benchmark on the same image finishes within a few percent of the Swift equivalent. Swift doesn't make the model run faster.&lt;/p&gt;

&lt;p&gt;What Swift changes is everything &lt;em&gt;around&lt;/em&gt; the model:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cold start ~3 s vs ~15 s&lt;/strong&gt; — no interpreter, no PyTorch import, just mmap the weights.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero-IPC pipeline&lt;/strong&gt; — &lt;code&gt;CGWindowListCreateImage&lt;/code&gt; → VLM → &lt;code&gt;VNRecognizeTextRequest&lt;/code&gt; → Metal overlay all run in one process with shared memory. A Python pipeline has to serialize each screenshot across the subprocess boundary, adding 30–100 ms per cycle on top of inference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real-time frame work becomes viable&lt;/strong&gt; — a per-frame 16 ms budget has room for actual OCR and overlay redraw; it doesn't fit a round-trip to a Python worker.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bounded memory over long sessions&lt;/strong&gt; — &lt;code&gt;autoreleasepool&lt;/code&gt; around CGImage ops keeps a 100-minute session at ~5.5 GB peak. The earlier Python-subprocess version leaked ~900 MB over the same duration through PyObjC bridging.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The headline 18-second number is the same either way. The difference is whether you can wrap that around a responsive app — startable in 3 seconds, no IPC between stages, 60 fps overlay — rather than a command-line script.&lt;/p&gt;

&lt;h3&gt;
  
  
  How to Reproduce This
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Requirements:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;macOS 14+ on Apple Silicon (M1/M2/M3/M4)&lt;/li&gt;
&lt;li&gt;Xcode 16+&lt;/li&gt;
&lt;li&gt;~16GB unified memory (8GB minimum, 16GB comfortable)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Model:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mlx-community/Qwen2.5-VL-7B-Instruct-4bit&lt;/code&gt; from Hugging Face (~5.3GB)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Key dependencies:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mlx-swift-lm&lt;/code&gt; (Swift package, for native VLM inference)&lt;/li&gt;
&lt;li&gt;Apple Vision framework (built into macOS)&lt;/li&gt;
&lt;li&gt;Metal (built into macOS)&lt;/li&gt;
&lt;li&gt;ScreenCaptureKit (built into macOS)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Source Code
&lt;/h3&gt;

&lt;p&gt;GroundingKit is the open-source macOS app extracted from this project. Clone it, build it, and try panel detection on your own screen:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://github.com/NivDvir/screen-overlay-toolkit" rel="noopener noreferrer"&gt;github.com/NivDvir/screen-overlay-toolkit&lt;/a&gt;&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/NivDvir/screen-overlay-toolkit.git
&lt;span class="nb"&gt;cd &lt;/span&gt;screen-overlay-toolkit
bash build-app.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The build script uses &lt;code&gt;xcodebuild&lt;/code&gt; (&lt;code&gt;swift build&lt;/code&gt; doesn't link MLX's Metal kernel library properly — the binary builds but the model returns broken output). Produces a &lt;code&gt;.app&lt;/code&gt; bundle you can open from Finder. Menu bar app, runs entirely local on Apple Silicon.&lt;/p&gt;







&lt;h2&gt;
  
  
  ◆ &lt;u&gt;Postscript&lt;/u&gt;
&lt;/h2&gt;

&lt;h3&gt;
  
  
  2026-04-25  —  10th bug + reframing
&lt;/h3&gt;

&lt;p&gt;After publication, while preparing PR splits per a maintainer's review request, forensic re-measurement found the Swift output drifting +9 px from the Python reference on a canonical LeetCode test image — despite this article's original "0 px on all 8 edges" claim. Two things came out of the follow-up work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A 10th bug was hiding.&lt;/strong&gt; &lt;code&gt;Qwen2VLMessageGenerator&lt;/code&gt; in &lt;code&gt;Qwen2VL.swift&lt;/code&gt; ordered chat-template content as &lt;code&gt;[text, image]&lt;/code&gt;, but HuggingFace's template for Qwen2.5-VL emits &lt;code&gt;&amp;lt;|vision_start|&amp;gt;&amp;lt;|image_pad|&amp;gt;&amp;lt;|vision_end|&amp;gt;{text}&lt;/code&gt; — image content first. Swapping the order removes a deterministic +9 / +8 / +5 px bbox shift. Fix filed upstream as &lt;a href="https://github.com/ml-explore/mlx-swift-lm/pull/242" rel="noopener noreferrer"&gt;&lt;strong&gt;mlx-swift-lm #242&lt;/strong&gt;&lt;/a&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Bug #5 turned out to be the load-bearing fix — not the choice of resampler.&lt;/strong&gt; The original preprocessing PR (#243) carried a custom PIL-byte-exact Lanczos resampler so that the Swift output could pass a strict ≤2 px parity check against the Python reference. A 2×2 ablation (resampler × cap) on the canonical test set + UI-Vision N=20 then showed the resampler choice (Bicubic vs Lanczos) is no-op for IoU within bench noise — the &lt;strong&gt;1280-pixel cap is the entire driver of accuracy&lt;/strong&gt; (0.542 → 0.736 mean IoU on five high-resolution canonical images; 0.407 → 0.435 on UI-Vision N=20; both vs HF defaults). PR #243 was simplified accordingly: dropped &lt;code&gt;_pilLanczosCore&lt;/code&gt; (~157 lines), switched to upstream's existing CIImage Bicubic chain. The preprocessing change is now +15/-5 in a single file.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The patch file (&lt;a href="https://github.com/NivDvir/screen-overlay-toolkit/blob/main/patches/mlx-swift-lm-mrope-fixes.patch" rel="noopener noreferrer"&gt;&lt;code&gt;patches/mlx-swift-lm-mrope-fixes.patch&lt;/code&gt;&lt;/a&gt;) covers both &lt;code&gt;Qwen25VL.swift&lt;/code&gt; and &lt;code&gt;Qwen2VL.swift&lt;/code&gt;. The omnibus PR #222 has four isolated companion PRs (#238, #239, #242, #243) per the maintainer's split-friendly review preference.&lt;/p&gt;

&lt;h3&gt;
  
  
  2026-05-25  —  upstream &lt;code&gt;max_pixels&lt;/code&gt; fix landed in mlx-vlm
&lt;/h3&gt;

&lt;p&gt;A separate finding: the same bug, but upstream. The &lt;code&gt;mlx-community/Qwen2.5-VL-7B-Instruct-4bit&lt;/code&gt; snapshot's &lt;code&gt;preprocessor_config.json&lt;/code&gt; shipped &lt;code&gt;max_pixels: 12,845,056&lt;/code&gt; — 12× the model's ~1.04 M training resolution. On a 5-image high-resolution canonical set this drove grounding from 0.736 (with a 1280-pixel external cap) down to 0.542 (HF defaults). I filed &lt;a href="https://github.com/Blaizzy/mlx-vlm/issues/1175" rel="noopener noreferrer"&gt;Blaizzy/mlx-vlm #1175&lt;/a&gt; on 2026-05-12; Blaizzy landed a fix in &lt;a href="https://github.com/Blaizzy/mlx-vlm/pull/1213" rel="noopener noreferrer"&gt;PR #1213&lt;/a&gt; on 2026-05-23.&lt;/p&gt;

&lt;p&gt;The article's working claim is unchanged: pixel cap is the load-bearing variable. What's new is that Python mlx-vlm users now get correct behavior out of the box. Swift / mlx-swift-lm still needs the cap applied externally because its image path resamples before any HF-config check.&lt;/p&gt;




&lt;h2&gt;
  
  
  ◆ &lt;u&gt;Closing&lt;/u&gt;
&lt;/h2&gt;

&lt;p&gt;Building this system produced more failure than success. Six major approaches failed before the working architecture emerged, and even the working approach required fixing 10 implementation bugs in a third-party library before it produced correct output (the 10th surfaced after publication). The total development time from "I want to read a panel from the screen" to "this reliably works" was measured in weeks, not days.&lt;/p&gt;

&lt;p&gt;The experience of building this on-device overlay led directly to a testing methodology I call CCSV (Cross-Channel Spatiotemporal Verification) -- the idea that you can verify a UI by reading it through two completely independent channels (DOM and pixels) and comparing what they see. That methodology is described in a companion article.&lt;/p&gt;

&lt;p&gt;If you are building something similar -- local VLMs on Apple Silicon, on-device document grounding, overlay rendering -- I would like to hear what you have tried and what worked. The failure modes are not well documented anywhere, and the community benefits from sharing them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Niv Dvir is a software developer who builds tools at the intersection of computer vision and UI automation. You can find him on &lt;a href="https://github.com/NivDvir" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>swift</category>
      <category>macos</category>
      <category>machinelearning</category>
      <category>ai</category>
    </item>
    <item>
      <title>How I Built a Cochlear Spiral Spectrogram That Visualizes Music Like the Inner Ear</title>
      <dc:creator>Niv Dvir</dc:creator>
      <pubDate>Sun, 22 Mar 2026 12:23:13 +0000</pubDate>
      <link>https://dev.to/nivdvir/how-i-built-a-cochlear-spiral-spectrogram-that-visualizes-music-like-the-inner-ear-3k49</link>
      <guid>https://dev.to/nivdvir/how-i-built-a-cochlear-spiral-spectrogram-that-visualizes-music-like-the-inner-ear-3k49</guid>
      <description>&lt;p&gt;What if you could see music the way your inner ear hears it?&lt;/p&gt;

&lt;p&gt;I built a visualization system that maps audio frequencies onto a &lt;strong&gt;Fermat spiral&lt;/strong&gt; — the same geometric curve that describes how the human cochlea arranges its frequency-sensitive hair cells. The result reveals the hidden geometry of harmony: you can literally &lt;em&gt;see&lt;/em&gt; the difference between a major and minor chord.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/66RiYBl7aQY"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  The Core Idea
&lt;/h2&gt;

&lt;p&gt;Traditional spectrograms show frequency vs. time as a rectangular heatmap. They're useful but clinical — they don't capture the &lt;em&gt;feeling&lt;/em&gt; of music.&lt;/p&gt;

&lt;p&gt;The cochlea (your inner ear) isn't rectangular. It's a spiral. Low frequencies resonate at the outer end, high frequencies at the inner end — logarithmically spaced, just like musical octaves.&lt;/p&gt;

&lt;p&gt;So I asked: &lt;strong&gt;what if we visualize frequencies on an actual spiral?&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How It Works
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Audio Analysis (scipy FFT)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;381 logarithmically-spaced frequency bins (20 Hz — 8 kHz)&lt;/li&gt;
&lt;li&gt;ISO 226 equal-loudness contours for perceptual accuracy&lt;/li&gt;
&lt;li&gt;60 FPS frame-by-frame analysis
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# Simplified core: FFT → cochlear frequency mapping
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;numpy&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;scipy.fft&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;rfft&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rfftfreq&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;analyze_frame&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;44100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_bins&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;381&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;spectrum&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;rfft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="n"&gt;freqs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;rfftfreq&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;samples&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;sample_rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="c1"&gt;# Logarithmic bins: 20 Hz to 8 kHz (cochlear range)
&lt;/span&gt;    &lt;span class="n"&gt;bin_edges&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;logspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log10&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;n_bins&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;amplitudes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;zeros&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_bins&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;i&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;n_bins&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;mask&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;freqs&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;bin_edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;freqs&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;bin_edges&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&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;if&lt;/span&gt; &lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;any&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
            &lt;span class="n"&gt;amplitudes&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;spectrum&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;mask&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;mean&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;amplitudes&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  2. Spiral Mapping (Fermat Spiral)
&lt;/h3&gt;

&lt;p&gt;Each frequency bin gets a position on a Fermat spiral: &lt;strong&gt;r = sqrt(θ)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Low frequencies sit at the outer edge (like the cochlea's apex), high frequencies spiral inward.&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="c1"&gt;# Map frequency bins to spiral coordinates
&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;linspace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pi&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_bins&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sqrt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;x&lt;/span&gt; &lt;span class="o"&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cos&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;y&lt;/span&gt; &lt;span class="o"&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;np&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;theta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  3. Chromesthesia Color Mapping
&lt;/h3&gt;

&lt;p&gt;Colors follow a &lt;strong&gt;chromesthesia&lt;/strong&gt; mapping — the neurological phenomenon where people "see" sounds as colors:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Low frequencies (bass) → warm reds/oranges&lt;/li&gt;
&lt;li&gt;Mid frequencies (voice, guitar) → greens/yellows&lt;/li&gt;
&lt;li&gt;High frequencies (cymbals, harmonics) → cool blues/cyans&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Temporal Features (The Secret Sauce)
&lt;/h3&gt;

&lt;p&gt;Static spectrograms miss the &lt;em&gt;movement&lt;/em&gt; of music. I added 5 temporal features, each validated across &lt;strong&gt;1,704 audio samples&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Optimal parameter&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Melodic trails&lt;/td&gt;
&lt;td&gt;Short glowing trails following melody&lt;/td&gt;
&lt;td&gt;10 frames, 0.70 decay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Rhythm pulses&lt;/td&gt;
&lt;td&gt;Radial pulse on beat hits&lt;/td&gt;
&lt;td&gt;0.50 intensity, 0.25 decay&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Harmonic auras&lt;/td&gt;
&lt;td&gt;Sustained glow for held chords&lt;/td&gt;
&lt;td&gt;4.0s blend time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Atmospheric context&lt;/td&gt;
&lt;td&gt;Background mood from 60s window&lt;/td&gt;
&lt;td&gt;0.35 influence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Harmonic connections&lt;/td&gt;
&lt;td&gt;Lines between harmonically related notes&lt;/td&gt;
&lt;td&gt;Octave + fifth detection&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Why Harmony Looks Beautiful
&lt;/h2&gt;

&lt;p&gt;This is the magical part. When notes are &lt;strong&gt;harmonically related&lt;/strong&gt; (octaves, fifths, thirds), they land at &lt;strong&gt;symmetric positions&lt;/strong&gt; on the spiral. A major chord creates a visually balanced, symmetric pattern. Dissonance creates asymmetric, chaotic (but still beautiful) patterns.&lt;/p&gt;

&lt;p&gt;Different musical traditions create remarkably different visual signatures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Classical harmony&lt;/strong&gt; → orderly radial symmetry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Arabic maqam&lt;/strong&gt; → quarter-tone asymmetry with unique geometric beauty&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;EDM/electronic&lt;/strong&gt; → explosive, pulsing energy patterns&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/bhgEEtMXEJ0"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;h2&gt;
  
  
  Try It: The Wellspring
&lt;/h2&gt;

&lt;p&gt;I also built a crowdsourcing platform called &lt;a href="https://synesthesia-labeler.onrender.com" rel="noopener noreferrer"&gt;&lt;strong&gt;The Wellspring&lt;/strong&gt;&lt;/a&gt; where people can rate how well these visualizations capture the music. The goal: build an open dataset for AI-powered audio visualization evaluation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tech Stack
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audio analysis:&lt;/strong&gt; scipy (FFT), librosa&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rendering:&lt;/strong&gt; PIL (2D), PyVista (3D optional)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Video encoding:&lt;/strong&gt; FFmpeg (H.264, CRF 18, 60 FPS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Web platform:&lt;/strong&gt; React 18 + TypeScript, Node/Express, PostgreSQL&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;I'm working on browser-based creation tools so anyone can create their own audio-visual harmony — no installation needed. The vision: a global community of creators exploring the intersection of sound and moving image.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The ancient dance between rhythm and movement, renewed with modern tools.&lt;/strong&gt;&lt;/p&gt;




&lt;p&gt;Channel: &lt;a href="https://www.youtube.com/@NivDvir-ND" rel="noopener noreferrer"&gt;youtube.com/@NivDvir-ND&lt;/a&gt;&lt;br&gt;
The Wellspring: &lt;a href="https://synesthesia-labeler.onrender.com" rel="noopener noreferrer"&gt;synesthesia-labeler.onrender.com&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I'd love to hear your thoughts — especially from anyone working on audio visualization, creative coding, or signal processing!&lt;/p&gt;

</description>
      <category>audio</category>
    </item>
  </channel>
</rss>
