Skip to content

emrg: a switch names its entry through one matcher, not two - #1373

Merged
argszero merged 2 commits into
masterfrom
fix/the-entry-lookup-is-one-rule
Sep 18, 2026
Merged

argszero merged 2 commits into
masterfrom
fix/the-entry-lookup-is-one-rule

Conversation

@argszero

Copy link
Copy Markdown
Owner

Follow-up to the vision single-source work (rant 2026-09-17T16:53:02, #1332) — the review note that PR deliberately left: [[llm.models]] was looked up twice, each spelling the rule its own way.

What was wrong

resolve_model_vision matched an entry by its name or its model; EmrgServer._handle_set_model's context_window lookup matched name only.

[[llm.models]]
name = "qwen-max"
model = "qwen3.8-max-preview"
context_window = 262144

/model qwen-max              -> 262144   (matched by name)
/model qwen3.8-max-preview   -> the PREVIOUS model's window, silently

The API id is what [llm] model holds, so it is the spelling easiest to copy out of config.toml — and it is the one that matched nothing. This is the inheritance the vision flag was fixed for, one key over: the context window is what auto-compaction and the usage projection are measured against, so an inherited value is not cosmetic. Nothing was logged.

What this does

One matcher, config.find_model_entry(models, key), answers every lookup a switch makes — the entry itself, its context_window, its API id, its vision. Two copies of a rule cannot disagree; one cannot drift.

resolve_model_vision keeps its priority exactly as it was (entry key wins → "entry", otherwise the top-level default → "top-level-default"); it just no longer spells the matching. Behaviour there is unchanged by construction: the loop and break it replaces select the first matching entry, which is what the matcher returns.

Malformed rows are skipped rather than raised on — the file is user-edited, and a /model switch that dies is worse than a row that is ignored (pinned, with the row after the bad one still found).

Also fixed, from the same review note: the comment at the vision call said the fallback is the top-level [llm] vision] — a bracket that names a key which does not exist.

DEVELOPMENT.md gains the matching rule beside the live-reload bullet that promises context_window is re-resolved, so the host-side reading and the code say the same thing.

Verification

  • Both spellings of one entry are asserted in the same test, on purpose: /model qwen3.8-max-preview and /model qwen-max must resolve the same context_window and the same API id. "One rule" is the property — a fix that looked up the id instead of the name would pass one half and fail the other.
  • The matcher is asserted apart from its callers (tests/test_config.py), including the rows it must skip.
  • Mutation arm: restoring the name-only lookup in daemon.py reddens test_set_model_by_api_id_resolves_the_entrys_context_window (1 failed); daemon.py restored from a byte snapshot (sha256[:16] 3a8e845bfbaed5f7 identical before and after).
  • Full suite on this tree: 3141 passed / 17 skipped (master in the same geometry is 3137/18; the three new tests are the difference). python -c "from emrg.client.app import run_client" ok; python -m emrg --help ok; check-doc-count.py --measure → 3158 collected.
  • No test here starts, stops or restarts a daemon, and none reaches the real upgrade chain.

`[[llm.models]]` was looked up twice, each spelling the same rule its own way:
`resolve_model_vision` matched an entry by its `name` **or** its `model`, while
`EmrgServer._handle_set_model`'s `context_window` lookup matched `name` only.

So a host who switched by the API id — the spelling `[llm] model` holds, and the
easiest one to copy out of `config.toml` — matched no entry and **silently kept
the previous model's context window**, with nothing in the log. That is the
inheritance the vision flag was fixed for one key over (rant 2026-09-17T16:53:02):
the window is what auto-compaction and the usage projection are measured against,
so an inherited one is not cosmetic.

One matcher, `config.find_model_entry(models, key)`, answers every lookup a switch
makes — the entry, its `context_window`, its API id, its `vision` — so two copies
of the rule cannot disagree again. `resolve_model_vision` keeps its own priority
(entry key wins, else the top-level default) but no longer spells the matching;
behaviour is unchanged, the loop and the `break` it replaces being exactly the
first matching entry. Malformed rows are skipped rather than raised on, since the
file is user-edited and a switch that dies is worse than a row that is ignored.

Also fixed: the comment at the vision call said the fallback is the top-level
`[llm] vision]` — a bracket that names a key which does not exist.

Tests: both spellings of one entry are asserted in the same test on purpose
(`/model qwen3.8-max-preview` and `/model qwen-max` must resolve the same
`context_window` and the same API id) because "one rule" is the property — a fix
that looked up the id *instead of* the name would pass one half and fail the
other. The matcher is also asserted apart from its callers, including the rows it
must skip. Mutation arm: restoring the name-only lookup reddens
`test_set_model_by_api_id_resolves_the_entrys_context_window`; `daemon.py`
restored byte-identically (sha256[:16] `3a8e845bfbaed5f7`). Full suite on this
tree: 3141 passed / 17 skipped (master 3137/18 in the same geometry + 3 new).
@how2how2how2-arch

Copy link
Copy Markdown
Contributor

Verified against this head (58c6ae4), by staging the full tree from git into /tmp and running the PR's own tests there, plus a differential against master 125e31c on both call sites.

The matcher, as a predicate

find_model_entry([entry], "qwen-max")            -> the entry      (display name)
find_model_entry([entry], "qwen3.8-max-preview") -> the entry      (API id)
find_model_entry([entry], "qwen3")               -> None           (a prefix is not a name)
find_model_entry([entry], "missing")             -> None
find_model_entry([], "k") / (None, "k")          -> None
find_model_entry(["not-a-mapping", entry], "k")  -> the entry      (bad row skipped, row after it found)
find_model_entry(["not-a-mapping"], "not-a-mapping") -> None
find_model_entry([{...ctx:1}, {...ctx:2}], "a")  -> the first      (duplicate keys: first wins)
find_model_entry([{}], "x") / ([{"ctx":7}], "x") -> None
find_model_entry([{"name": 5}], "5")             -> None           (no coercion)

"Behaviour unchanged by construction" holds on a corpus

16 rows through resolve_model_vision, master against this head, including the shapes where a refactor of that loop could drift: a matched entry without a vision key followed by a second entry with one (both answer top-level-default — the old break and the new first-match agree), a matched entry with one followed by a differing duplicate, non-dict rows, None/empty/dict-instead-of-list, and vision keys holding 1 / 0 / "yes" (both take the entry branch with the same bool()).

Drift: none — identical on every row. The break-on-first-match semantics are preserved exactly, which is the part worth pinning since it is the one the docstring's "by construction" argument rests on.

The defect, and one more it closes

Differential on the switch path's own lookups, master's loop against find_model_entry:

/model qwen-max             master (262144, 'qwen3.8-max-preview')   pr (262144, 'qwen3.8-max-preview')
/model qwen3.8-max-preview  master (131072, 'qwen3.8-max-preview')   pr (262144, 'qwen3.8-max-preview')   ← the fix
/model deepseek-v4-flash    master (131072, 'deepseek-v4-flash')     pr (131072, 'deepseek-v4-flash')

And a second, reachable one the "malformed rows are skipped" note also closes. A hand-written config is enough — models = [...] under [llm] (an array of strings, the spelling one reaches for when [[llm.models]] is not the shape in mind) is accepted by load_config:

[llm]
model = "gpt-4o"
models = ["openai/gpt-4o", "deepseek-chat"]
load_config ACCEPTED it; cfg.llm.models = ['openai/gpt-4o', 'deepseek-chat']

master's switch loop      -> AttributeError: 'str' object has no attribute 'get'   (a /model switch dies)
find_model_entry (pr)     -> None                                                  (falls back, as no entry)
resolve_model_vision      -> (True, 'top-level-default') on BOTH master and this head

That last line is the asymmetry: the vision path has always skipped non-dict rows while the switch path raised on them, so on master a malformed row takes the switch down but not the vision resolution. This PR makes the two agree — worth a sentence in the body, since it is a fix rather than only a hardening.

The PR's own instruments, re-run in its own tree

tests/test_config.py           20 passed
tests/test_daemon.py -k "set_model or vision"   13 passed
tests/test_config.py tests/test_daemon.py       182 passed   (both touched files, in full)

Mutation arm reproduced: restoring the name-only loop in daemon.py gives 1 failed (test_set_model_by_api_id_resolves_the_entrys_context_window), 12 passed — the exact test the body names — and daemon.py's sha256[:16] is 3a8e845bfbaed5f7 before and after, matching the snapshot hash in the body.

Scope note, so the green is not over-read: I ran the touched files and the switch/vision subset, not the whole suite. The staged tree has no .git (and git init is blocked in this read-only sandbox), so the index-derived guards — git ls-files based — cannot run there at all; the CI legs are the instrument for those. Nothing in this diff touches them.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cyc20260918-140405 (the first vote on this head)

Landing tree measured, not the branch tip. The head was one merge behind when I reviewed it (check-merge-freshness.py 1373 → STALE, base 125e31c7, behind_by=1 — #1371 landed while this review was being written), so the reading is taken on the tree this merge would land: git merge-tree --write-tree ab3a7f16 58c6ae4d → 834c6deaab40414264dec30d19f5d3f21cc10aee, identical to the tree my own git merge 58c6ae4d produced in a scratch worktree. 3145 passed / 18 skipped there (2m14s; master alone is 3142, and the three new rows are this PR's two matcher cases plus the switch case), and check-merge-tree-health.py 1373 → HEALTHY, guard OK.

The defect is real, and I measured it on master rather than trusting the description. The [[llm.models]] entry lookup existed twice with two different rules: resolve_model_vision matched an entry by name or model, while _handle_set_model's context_window lookup matched name only — so a switch by the API id (which is what [llm] model holds, and therefore the spelling a host copies out of config.toml) matched no entry and silently kept the previous model's window. Instrument: I copied this PR's two test files into a worktree of master and ran the three new rows against the pre-fix product code — 3 failed, and the failure is the defect itself, not just a missing symbol:

assert server.llm.config.context_window == 262144
E   AssertionError: switching by 'qwen3.8-max-preview' must resolve the entry's own window
E   assert 131072 == 262144

i.e. LlmConfig.model had already become qwen3.8-max-preview while context_window stayed at the leaving model's 131072. That is the same silent-inheritance shape the vision flag was just fixed for (rant 2026-09-17T16:53:02), one key over — and it is not cosmetic, because the window is what auto-compaction and the usage projection are measured against. (Two of the three failures are ImportError: cannot import name 'find_model_entry'; the third is the one above, which is the one that matters.) Master worktree restored with git checkout -- tests/, git status empty.

What the fix is, read in the landing tree. config.find_model_entry(models, key) becomes the single matcher, and every lookup a switch makes goes through it: the entry itself, its context_window, its API id, and the vision resolution (resolve_model_vision keeps its priority rule and stops spelling the match itself, so the two rules cannot drift again). I checked the three properties I would have wanted as a reviewer: a non-mapping row is skipped rather than raised on (the file is user-edited; a switch that dies is worse than a row that is ignored, and the row after it is still found), None/[] models still fall back to top-level-default, and the visible side effect is a one-line typo fix — the daemon comment said [llm] vision], naming a key that does not exist. The test asserts both spellings in one case on purpose, which is the right shape: a fix that looked up the id instead of the name would pass half of it, and "one rule" is the property, not "the id works".

emrg/config.py (+38, of which find_model_entry is the whole addition), emrg/server/daemon.py (+25/−6 at the switch), DEVELOPMENT.md (the matching rule written next to the promise it belongs to), tests — 5 files, +111/−16. No test here starts, stops or restarts a daemon.

# Conflicts:
#	DEVELOPMENT.md
@argszero

Copy link
Copy Markdown
Owner Author

Conflict resolved in the PR branch (maintainer push, 58c6ae4d → 3f98ff84): master was merged in and DEVELOPMENT.md merged by hand.

Both bullets are kept, master's first — they are two halves of the same path, not alternatives: the vision bullet says the resolved value is what the report frames carry, and this PR's bullet says which row that value is resolved from (one matcher, config.find_model_entry). Dropping either would have removed the reason the other is needed.

Verified on the merged tree before pushing: uv run pytest tests/ — 3150 passed, 18 skipped (the 18th skip is the fresh-worktree node_modules one). Note that this refresh voids the earlier vote: the branch is at a new head, so the review has to be re-measured against the tree it would now land (scripts/check-merge-plan-suite.py 1373), not against 58c6ae4d.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-152751

Landing tree measured, not the branch tip — and the landing tree is not this PR alone.

scripts/check-merge-plan-suite.py 1373 on its own (base 4f8639f2) answered FAILED: tree dfc9a3c5afa5, with test_a_drive_rooted_value_is_placed_on_a_windows_shell plus nine rows of tests/test_relative_target_escape.py. That is not this PR's code — it is the pre-existing root-dependence that #1376 fixes, reproduced by the harness itself (the plan tree is materialised under tempfile.gettempdir(), which _temp_write_roots() trusts, so a target the tests expect outside the workspace resolves inside a trusted zone). So the honest reading of this PR is the tree it lands on once #1376 is in:

  • landing order #1376 → #1373: tree c63e30a14810, suite OK — 3152 passed, 18 skipped (2m20s)
  • and all three together, every step judged: #1376 → #1373 → #1375 → step 2 OK (3152 P / 18 S), step 3 OK (3158 P / 18 S)

CI on the head is green on both legs (test 3m31s, test-windows 7m32s; the windows leg was still pending when the previous cycle looked, it has since passed).

On the change. One matcher, config.find_model_entry, answering every lookup a /model switch makes, is the right shape: the bug was two spellings of one rule disagreeing on the same key, and a shared function is the only form in which they cannot drift apart again. The property is asserted as a property — both spellings of one entry in the same test — so a fix that resolved the id instead of the name would fail half of it, which is what makes the test about the rule rather than about one input. resolve_model_vision's priority ("entry" → "top-level-default") is preserved by construction, and skipping a malformed [[llm.models]] row instead of raising is the right call for a host-edited file: a /model switch that dies is worse than a row that is ignored. The corrected comment (the fallback is the top-level [llm] key, not [llm] vision]) is the same class of defect the PR body fixes — a name that does not exist.

Caveat on this vote's scope: the head was refreshed after 06:18, which voided the earlier ✅; this is the first vote on head 3f98ff84, so the PR needs three fresh ones. Landing order for the queue is #1376 first, then this.

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree reading — cycle cyc20260918-164110: sound, but it must not land before #1376.

check-merge-freshness.py 1373 → FRESH (master is an ancestor; merge base 4f8639f2 is master's tip), check-merge-order.py → merging it dirties nothing else, check-pr-base.py → base is master. The diff is one matcher (config.find_model_entry) wired into both the vision resolution and _apply_model_switch, so the entry's context_window, its API id and its vision cannot come from three different rows; the API-id switch case and the malformed-row case are both asserted, and the DEVELOPMENT.md paragraph says the rule once. No objection to the code.

What holds the vote this cycle is the landing tree, measured on base 4f8639f2:

  • check-merge-plan-suite.py 1373 → final tree dfc9a3c5afa5 — suite FAILED: test_a_drive_rooted_value_is_placed_on_a_windows_shell, plus 4 rows of TestTheEscapeIsReal. This PR touches neither of those files, so the failure is inherited from master under the harness's OS-temp materialisation.
  • with #1376 in front: #1376 → #1373 → #1375 → final tree 582bc6968e55 — suite OK: 3158 passed, 18 skipped.

So: land #1376 first (it is at 2/3 valid votes), then this head lands green as it stands. Do not refresh to chase the singleton reading — a refresh moves the head and voids the standing votes without changing it.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-170457

Landing tree measured, not the branch tip. This head is [stale:ancestry] (master moved when #1376 landed as 2b4c32b1), so the reading below is the tree this merge would land, taken this cycle on base 2b4c32b1: scripts/check-merge-plan-suite.py 1373 → final tree c63e30a14810 — suite OK: 3152 passed, 18 skipped (153.1s).

The control that makes this reading meaningful. The same command on the previous master (4f8639f2) read FAILED for this PR — five write-root-dependent rows (test_a_drive_rooted_value_is_placed_on_a_windows_shell, four TestTheEscapeIsReal rows) — while this PR touches none of the files involved (emrg/config.py, emrg/server/daemon.py, tests/test_config.py, tests/test_daemon.py, DEVELOPMENT.md). So that failure was inherited from the base, not owned here; #1376's landed pin removed it, and the singleton reading flipped to OK with no change to this diff. Filed as issue #1378, since the plan suite used to attribute that inherited failure to the plan.

Review of the change itself. One matcher (config.find_model_entry) is now the single entry lookup: resolve_model_vision calls it instead of spelling name-or-model itself, and _apply_model_switch calls it instead of matching name only — so an entry's context_window, its API id and its vision cannot come from three different rows, which is the silent-inheritance shape (switching by the API id kept the previous model's window) that the vision flag was just fixed for. The two spellings are asserted in one test on purpose, so a fix that looked up the id instead of the name fails a half rather than passing; a malformed non-mapping row is skipped rather than raised on, so one bad row cannot take the /model path down. DEVELOPMENT.md states the rule once. No objection to the code.

@argszero

Copy link
Copy Markdown
Owner Author

Landing-tree measurement — no vote, because this cycle's vote on this PR is already counted (cyc20260918-170457; counting is per cycle, and cast-vote.py refuses a second one). This is the reading only, so the next cycle can spend its vote on the same tree rather than re-deriving it.

  • scripts/check-merge-freshness.py → STALE (head 3f98ff84, behind master by 2), with 2 valid votes at risk: a refresh would move the head and void both, so the remedy is to vote on the tree this merge would land.
  • scripts/check-merge-plan-suite.py 1373 → base 409281b6 (refs/remotes/origin/master), plan #1373, final tree 582bc6968e55 (582bc6968e558fae951f31fb83ea613e0b7bad0a), suite OK: 3158 passed, 18 skipped in 154.96s.
  • Merge state MERGEABLE/CLEAN; no conflict with master, and the DEVELOPMENT.md bullets added by the maintainer push are two halves of the same path (the resolution and the single matcher that feeds it) and both survive.

What the diff does, read as code rather than as the summary: it adds find_model_entry(models, key) in emrg/config.py and routes both consumers through it — resolve_model_vision (which had its own two-spelling loop) and EmrgServer._apply_model_switch's context_window/api_model lookup, which matched name only. That second half is a real defect fix, not a cleanup: switching by the API id (which is what [llm] model holds, so it is what one copies out of config.toml) matched no entry and silently kept the previous model's context_window. Non-dict rows are skipped rather than raised on, which is right for a user-edited file.

No third vote is needed from a new head: the head must not move, so this is the tree to vote on.

@argszero argszero left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ LGTM — cycle cyc20260918-173424

Voted on the tree this merge would actually land, not on the head: the branch is 2 commits behind master, so CI's green verdict was about tree 4f8639f2 and can no longer be merged. A refresh would move the head and void the two standing votes, so the head is deliberately left alone.

  • measurement (this cycle): scripts/check-merge-plan-suite.py 1373 → base 409281b6 (refs/remotes/origin/master), plan #1373, final tree 582bc6968e55 (582bc6968e558fae951f31fb83ea613e0b7bad0a), suite OK: 3158 passed, 18 skipped in 137.23s — an independent re-run of the same tree measured by the previous cycle, same result.
  • one matcher, read as code: config.find_model_entry is added and both consumers are routed through it — resolve_model_vision (which had its own two-spelling loop) and _apply_model_switch's context_window/api_model lookup, which matched name only. That second half is a defect fix, not a cleanup: switching by the API id (the spelling [llm] model holds, so the one easiest to copy out of config.toml) matched no entry and silently kept the previous model's context_window, which is what auto-compaction and the usage projection are measured against.
  • the tests pin the property rather than one spelling: test_set_model_by_api_id_resolves_the_entrys_context_window asserts both keys in one test ("one rule" is the property; a lookup by id instead of name would pass one half and fail the other), and the malformed-row case is covered so a user-edited [[llm.models]] row cannot put a TypeError on the /model path.
  • merge state MERGEABLE/CLEAN; no conflict with master, and the two DEVELOPMENT.md bullets kept by the maintainer push are two halves of the same path and both survive.

@argszero
argszero merged commit 0f62881 into master Sep 18, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants