|
| 1 | +--- |
| 2 | +title: 'Twenty-eight models, six surfaces, eight hundred and forty runs' |
| 3 | +status: published |
| 4 | +author: |
| 5 | + name: LibreDB |
| 6 | + picture: '' |
| 7 | +slug: twenty-eight-models-six-surfaces-840-runs |
| 8 | +description: What it took to make a local model drive a database agent, and why the model was almost never the problem. Nearly every time a model failed, the ledger showed our own server holding the sentence that would have unblocked it. |
| 9 | +coverImage: '' |
| 10 | +tags: |
| 11 | + - value: engineering |
| 12 | + label: Engineering |
| 13 | + - value: databases |
| 14 | + label: Databases |
| 15 | +publishedAt: 2026-09-08T20:30:00.000Z |
| 16 | +--- |
| 17 | + |
| 18 | +LibreDB Studio's Agent mode does something ordinary to describe and hard to do: it reads a database and reports what it found, citing the readings it took. |
| 19 | +It can do that with a hosted model. |
| 20 | +The question we set out to answer was whether it could do it with a model running on a laptop, from Ollama, with no API key and no data leaving the machine. |
| 21 | + |
| 22 | +The answer is yes, for twenty-eight models today. |
| 23 | +Getting there took eight hundred and forty runs and, more to the point, it took admitting something uncomfortable: nearly every time a model failed, the ledger showed our own server holding the sentence that would have unblocked it. |
| 24 | + |
| 25 | +This is that story, told from the measurements. |
| 26 | + |
| 27 | +## The protocol, and why it is this strict |
| 28 | + |
| 29 | +A model is not "supported" because it worked once. |
| 30 | +Agent mode has six surfaces, investigate, optimize, assess, operate, analyze and plan, and each one asks for something different. |
| 31 | +A model that lists tables beautifully may be unable to compare two query plans; one that compares plans may never file a report. |
| 32 | + |
| 33 | +So a **cell** is one model on one surface, and a cell is only closed when the model passes **five consecutive runs** on it. |
| 34 | +Six cells, thirty runs, and nothing below 30/30 ships. |
| 35 | +Twenty-eight models at 30/30 is **840 runs, and all 840 passed.** |
| 36 | + |
| 37 | +Three rules keep that number honest: |
| 38 | + |
| 39 | +**A lock is never taken back.** |
| 40 | +Once a cell reads 5/5 it is never re-measured or re-tuned. |
| 41 | +If a later change would alter what that cell was measured under, the cell is read again from scratch rather than inherited. |
| 42 | + |
| 43 | +**Every measured model carries an entry recording the settings its numbers were obtained under**, even when those settings are the defaults. |
| 44 | +A shared value is what has twice cost this project a cell it had already won: a sampling temperature pinned globally won five cells and lost one; a reordering of the workflow rules won nothing and lost one. |
| 45 | +The entry means a later change to the defaults cannot silently invalidate a measurement. |
| 46 | + |
| 47 | +**Four out of five is variance, not a signature.** |
| 48 | +A cell at 4/5 is re-rolled at the same settings, up to three times, before anything is changed. |
| 49 | +Reaching for a knob at 4/5 is how you convince yourself a setting worked when the dice simply landed differently. |
| 50 | + |
| 51 | +## The bug we found eight times |
| 52 | + |
| 53 | +Early on the losses looked like model failures. |
| 54 | +A run would end having produced nothing, the verdict would read `no-report`, and the obvious conclusion was that the model was not good enough. |
| 55 | + |
| 56 | +Then we read the wire recording of a `qwen3:8b` run. |
| 57 | + |
| 58 | +The model called `compose_report`. |
| 59 | +The server refused it with: |
| 60 | + |
| 61 | +> The arguments did not match the shape this tool declares. |
| 62 | +
|
| 63 | +The model called it again, unchanged. |
| 64 | +Same sentence. |
| 65 | +It did that for **thirty-seven turns** until the run ran out of budget and ended having reported nothing. |
| 66 | + |
| 67 | +The sentence is true. |
| 68 | +It is also unusable: it names no field, so there is nothing in it to act on. |
| 69 | +The validation layer knew exactly which path had failed and what was expected there, and was throwing that away before speaking. |
| 70 | + |
| 71 | +Naming the field fixed that run. |
| 72 | +What it did not fix was the shape of the mistake, which we then found again, and again: |
| 73 | + |
| 74 | +**A tool refused a recommendation thirty-two times in one run** over one field whose permitted values were `index` and `rewrite`. |
| 75 | +The refusal said "invalid value", never the two words that would have worked. |
| 76 | +The permitted set was in the schema, in hand, at the moment of refusal. |
| 77 | + |
| 78 | +**A run that stopped without reading anything** was ended in silence. |
| 79 | +The server held a sentence written for exactly that ending, *"Read it yourself. Call inspect_schema for the tables and their columns, and inspect_plan for how a statement will run"*, and sent it only to models whose profile asked for it. |
| 80 | +One profile of twenty-eight did. |
| 81 | +A model nobody has measured has no profile at all, so the model most in need of the sentence was the one guaranteed not to receive it. |
| 82 | +Across one sweep: 300 runs ended `model-stopped` with `no-report`, **half of them had called no tool at all, and 94 of those were ended without the drive saying anything.** |
| 83 | + |
| 84 | +**A reasoning model wrote its thinking into the tool-call argument field**, with valid JSON after it. |
| 85 | +The endpoint could not parse the whole string, reported exactly that, and handed back the model's own text. |
| 86 | +Studio threw that away and killed the run, seventeen seconds into a 630-second budget, with four tools already called. |
| 87 | +The retry it did have was written for a broken connection: it re-sent byte-identical bytes, which asks the model to make the same mistake again, and it does. |
| 88 | + |
| 89 | +**The largest one was the quietest.** |
| 90 | +Across twelve hours of sweeps, `compose_report` was declined 1,530 times, and one issue was **1,110 of them**: |
| 91 | + |
| 92 | +> the fields that did not match, claims: expected array |
| 93 | +
|
| 94 | +True, and still not enough. |
| 95 | +A model that serialized `claims` as a JSON *string* reads that and agrees with it: what it sent is an array, as far as it can tell. |
| 96 | +Nothing in the sentence contradicts it, so it sends the same bytes again. |
| 97 | +We were stating one side of a comparison and calling it a message. |
| 98 | + |
| 99 | +The fix was to name what arrived as well as what was expected: |
| 100 | + |
| 101 | +> claims: expected array, received string |
| 102 | +
|
| 103 | +After that change, that error stopped appearing entirely; the failures moved one level deeper into the schema, where the message was already specific enough to act on. |
| 104 | + |
| 105 | +## What the fixes were worth |
| 106 | + |
| 107 | +The clearest single measurement: `gpt-oss:20b` on the assessment surface. |
| 108 | +Four separate sweeps and two different per-model settings had it at 1/5, 1/5, 2/5, 2/5. |
| 109 | +The cell would not open. |
| 110 | +The cause was not the model, it was the parse failure above, killing runs that had already read what they needed. |
| 111 | + |
| 112 | +With the fix, and **with no per-model setting at all**, the same cell read **5/5 on its first reading.** |
| 113 | + |
| 114 | +Then there is `deepseek-r1:8b`, which our own notes had written off in plain language: *a thinking model spends the turn thinking and never calls a tool.* |
| 115 | +Re-measured on the corrected build, it locked **five of six surfaces, four of them on the first attempt.** |
| 116 | + |
| 117 | +The same thing shows up in the roster we shipped. |
| 118 | +`cogito:32b` spent weeks recorded as unsupported over one cell it could not close, and the cell turned out to be the server's. |
| 119 | + |
| 120 | +That is the finding underneath all of this. |
| 121 | +We had been grading models against a harness that was withholding half of what it knew. |
| 122 | + |
| 123 | +## What a model still has to bring |
| 124 | + |
| 125 | +None of this means every model can do the job, and the measurements are just as clear about that. |
| 126 | + |
| 127 | +Some cannot participate at all. |
| 128 | +Ollama reports no tool capability for `gemma3`, and a planning run drives to `succeeded` having called nothing: the model a reader would most expect on this list is one that cannot drive an agent surface. |
| 129 | + |
| 130 | +Some produce the intent without the protocol. |
| 131 | +`mistral:7b` receives the instruction, understands it, restates it, *"I will first use the `inspect_schema` function... then `inspect_plan`... finally `compose_report`"*, and then writes the call as prose inside a code fence instead of making it. |
| 132 | +Across twenty-five agent runs it called **zero** tools. |
| 133 | +That is a wall no sentence reaches. |
| 134 | + |
| 135 | +And some are held by the clock rather than by ability. |
| 136 | +One model's optimization runs began calling tools the moment its per-turn ceiling was raised, going from zero tool calls to three, four, five, and then spent the entire run budget without finishing. |
| 137 | +That is an honest "not supported at this standard", not a knob to keep turning. |
| 138 | + |
| 139 | +## The hardware, stated plainly |
| 140 | + |
| 141 | +Memory is the binding constraint on a laptop, and it is not the number on the download page. |
| 142 | +**RAM costs roughly twice the size on disk** with a default context window. |
| 143 | + |
| 144 | +We learned that the expensive way. |
| 145 | +A 42 GB model loaded into 86 GB on a 64 GB machine: free memory reached 5%, 782,000 pages went to swap, and the machine was seconds from locking up while its owner was using it. |
| 146 | +The proven ceiling on that hardware is a 27 GB model. |
| 147 | +Capping the context (`OLLAMA_CONTEXT_LENGTH=32768`) is what keeps the larger ones inside it. |
| 148 | + |
| 149 | +For calibration, from the passing runs: |
| 150 | + |
| 151 | +| Position | Model | Median run | |
| 152 | +| --- | --- | --- | |
| 153 | +| Fastest measured | `qwen2.5:7b` | **6s** | |
| 154 | +| Fastest hosted | `gemini-3.5-flash-lite` | 10s | |
| 155 | +| Slowest | `muse-glimmer:latest` | 115s | |
| 156 | + |
| 157 | +A nineteen-fold spread across models that all score identically. |
| 158 | +Every one of them is 30/30; what separates them is how long you wait. |
| 159 | + |
| 160 | +## Twenty-eight, from eight vendors |
| 161 | + |
| 162 | +The roster spans Google, IBM, NVIDIA, Alibaba, Mistral AI, Deep Cogito and others, from 2.5 GB to 27 GB on disk. |
| 163 | +The smallest model on the list, `qwen3:4b` at 2.5 GB, holds all six surfaces. |
| 164 | +So does a 27 GB one. |
| 165 | +Size predicts speed here far better than it predicts capability. |
| 166 | + |
| 167 | +What the roster does not span is our opinion. |
| 168 | +A model is on it because thirty runs passed, and off it because they did not. |
| 169 | + |
| 170 | +## The part worth taking away |
| 171 | + |
| 172 | +We began by asking which models were good enough. |
| 173 | +We ended up rewriting our own server five times, and the pattern was the same on every one of them: **the answer was in the server's hand and it refused without saying it.** |
| 174 | + |
| 175 | +A refusal that names no remedy is not a safety feature. |
| 176 | +It is a wall the model cannot climb, and the run it ends was going to fail anyway, so saying the missing half costs nothing and is sometimes the entire difference between 1/5 and 5/5. |
| 177 | + |
| 178 | +If you are building something a model drives, that is the question worth asking of every error path you own: *does this sentence tell the model what to do differently?* |
| 179 | +If it does not, you are not measuring the model. |
| 180 | +You are measuring your own silence. |
| 181 | + |
| 182 | +Every figure here comes from the run ledger. |
| 183 | +The per-model settings, the protocol, and what each model was measured under are in the repository under `docs/llms/`. |
0 commit comments