Repository navigation
Expose Gemma 4 26B-A4B as an optional model tier, park 12B, fix per-model RAM gating - #1981
Conversation
…odel RAM gating (closes #1956) Gemma 4 12B (both ggml-org and Unsloth sources) was re-evaluated end-to-end on adequate hardware via the agent-matrix eval. The upstream peg-gemma4 parser-loop bug is confirmed fixed, but both sources still produce malformed tool-call JSON on complex nested payloads (create_schema) -- a genuine model-capability limitation at Q4_K_M, not a GGUF-source defect. 12B remains parked; min_memory_gb corrected from 16 to 24 on both entries per #1348's OOM evidence. Gemma 4 26B-A4B (MoE, 3.8B active params) is added and exposed as an optional high-RAM tier: it scores below E4B overall (12.3/16 vs 13.7/16 mean across 3 reps) but never exhibits 12B's JSON-corruption failure mode, supporting an active-parameter-footprint explanation for 12B's failure. Both frontend RAM-gating surfaces (model-manager.svelte, ai-chat-model-selector.svelte) previously compared system RAM against a single hardcoded 16GB constant instead of each model's own min_memory_gb -- a latent bug this exposure would otherwise have hit immediately. Both now gate per-model. Also adds NODESPACE_PROMPT_DUMP instrumentation for capturing full LLM conversations during debugging: nlp_engine::chat::prompt_dump wired into ChatEngine::generate_blocking, the single chokepoint every native-path caller (Stage-1 routing, Stage-2 ReAct turns, resolve_query, the routing probe) passes through; agent::local_agent::openai_compat_prompt_dump for the separate OpenAI-compatible HTTP path. Replaces the prior Stage-2-only dump mechanism, which never covered Stage-1 routing. Full details in ADR-056's addendum (nodespace-docs). Related follow-up issues filed during this investigation: - #1977 (agent-matrix eval scenarios don't reflect NodeSpace's actual use case) - #1978 (nlp-engine/agent crate boundary for LLM connectivity code) - #1979 (AI chat nodes can render empty in the UI after a WatchNodes reconnect) - #1980 (Stage-1 routing lacks schema/entity-type context, over-clarifies) Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CwAKk1YU2zyLNzW74mYvH
malibio
left a comment
There was a problem hiding this comment.
Automated pragmatic-code-review verdict: REQUEST CHANGES (posted as a comment — GitHub blocks self-approval/self-request-changes on your own PR).
Pragmatic Code Review — PR #1981 (Initial Review)
Reviewed: issue-1956-gemma-12b → main at commit 4a68acaf
Summary
This PR delivers on issue #1956's re-evaluation mandate and, in the process, surfaces and fixes two real latent bugs (stale min_memory_gb: 16 catalog values, and a flat-16GB frontend RAM gate that ignored per-model requirements). The investigation's conclusions are backed by the actual diff, not just PR-description prose: the exposed-model set, the new catalog entry's metadata, and the frontend gating logic all check out. The prompt-dump split into two parallel, env-var-compatible modules is architecturally justified — nlp-engine's Cargo.toml confirms it has zero internal workspace path dependencies, so the openai_compat engine genuinely cannot reuse it — and the hot per-token loop in ChatEngine::generate_blocking correctly gates the string-accumulation cost behind the dump-enabled check.
Net positive overall, with one live logical inconsistency that this PR's own min_memory_gb fix introduces, which should be resolved before merge.
Scope note: confirmed the diff does not bundle in anything belonging to the separately-filed follow-ups (#1977 eval scenarios, #1978 crate boundaries, #1979 WatchNodes reconnect, #1980 Stage-1 routing context) — it's limited to the 12B re-evaluation, the 26B-A4B addition, the RAM-gating fix, and the prompt-dump refactor, consistent with the PR's own explanation of scope.
Findings
🔴 Critical
packages/agent/src/local_agent/model_manager.rs:249 vs. :151,190 — RAM_THRESHOLD_MEDIUM (16GB) now contradicts the corrected min_memory_gb: 24 for both 12B entries.
This PR correctly raises GEMMA_4_12B and GEMMA_4_12B_UNSLOTH's min_memory_gb from 16 → 24 (per #1348's GPU-OOM evidence), but leaves const RAM_THRESHOLD_MEDIUM: u64 = 16 * 1024 * 1024 * 1024; unchanged. recommended_model_id_for(ModelFamily::Gemma4) (public fn, line 374) still recommends gemma-4-12b-q4km for any machine ≥16GB — including 16–23GB machines that, per this PR's own corrected value, don't meet the model's memory floor. Before this PR the two numbers agreed (16 == 16); this PR's own correctness fix breaks that agreement without updating the threshold to match.
Mitigating factor confirmed in the diff: the actually-used default-selection path, recommended_model_id(), explicitly bypasses this function for Gemma4 with a comment warning future readers not to "simplify" it back, specifically because 12B/31B are parked. So the bug is not reachable through the real default-selection flow today. But recommended_model_id_for is a pub fn with its own direct unit test (recommended_model_id_for_family_returns_family_match) that checks family matching only, not the threshold value — so nothing catches the drift, and any future caller of this public function (or a refactor that "simplifies" the bypass away, which the comment itself anticipates as a risk) inherits a self-contradictory contract.
Fix: raise RAM_THRESHOLD_MEDIUM to 24GB to match the corrected min_memory_gb, or, if this function's tiering logic is meant to be superseded by the parked status, remove/clearly gate it rather than leave a stale threshold live in a public API.
🟡 Improvements
-
packages/nlp-engine/src/chat/mod.rs:625-627—prompt_dump::enabled()is called once per generated token inside the hot sampling loop. Each call does anstd::env::var()lookup even when dumping is disabled (the common case) — the string-accumulation cost is correctly gated, but the check itself isn't hoisted, even though the same loop already computesdump_seqonce before entry (~line 556). Hoist tolet dump_enabled = prompt_dump::enabled();before the loop and reuse the bool. Minor in absolute cost, but avoidable and the pattern to follow is already adjacent in the same function. -
packages/nlp-engine/src/chat/prompt_dump.rsrecords carry no"engine"discriminator, whileopenai_compat_prompt_dump.rs's records do ("engine":"openai_compat"). The openai_compat module's own doc comment frames the two as symmetric ("same record shape... one continuous timeline"), but a merged log can only positively identify openai_compat records — native/nlp-engine records are identifiable only by field absence. Add a positive"engine": "native"(or similar) to the nlp-engine side to match the claimed symmetry. -
packages/nlp-engine/src/chat/prompt_dump.rs:11— doc comment still referencesagent::local_agent::prompt_dump, the module this PR deletes and replaces withagent::local_agent::openai_compat_prompt_dump. Stale cross-reference; update to the new path. -
packages/desktop-app/src/tests/components/model-ram-gating.test.ts— reimplements the gating predicate as a local mirror of the.sveltelogic rather than exercising the component's actual$derivedexpressions, so it would keep passing even if the real logic drifted (e.g., an off-by-one, or a flat constant creeping back in). This follows a documented pre-existing project pattern (per the test file's own comment) so it's not new scope creep from this PR, but it's the primary regression guard for the exact bug just fixed here, so it's worth flagging.
🟢 Nits
- Nit:
chat_models.rsreferences ADR-056 for the E4B lock, whilemodel_manager.rscomments reference ADR-046 for 12B/31B parking status, in adjacent code. Worth a quick sanity check that these two ADR numbers aren't transposed, since they sit side by side and a swap would be easy to miss. - Nit:
openai_compat_prompt_dump.rs:19-22doc-comment phrasing ("Shared... only in name, not value") is a little awkward — the twoSEQcounters don't actually share a name either, each is defined independently. "Parallel, independently-numbered counters, disambiguated by theenginefield" reads clearer.
Verification performed against the diff (not taken on faith from the PR description)
min_memory_gb: confirmed it really was 16 for both 12B entries pre-PR, and the diff changes both to 24 — issue #1956's body claim that the unsloth entry "already declares" 24 was stale; this PR's fix is real and directionally correct.EXPOSED_GGUF_MODEL_IDS: confirmed exact set is["gemma-4-e4b-q4km", "gemma-4-26b-a4b-q8"]; neither 12B id present.- 26B-A4B catalog entry: has a dedicated test asserting family, quantization, size, URL, and
min_memory_gb; catalog-count assertion updated 9→10. - Both prompt_dump modules capture pre-truncation/pre-normalization text,
enabled()/dump_path()short-circuit correctly as a no-op when unset, dumping only happens when the user explicitly setsNODESPACE_PROMPT_DUMP(no default/predictable path), and the twoAtomicU64 SEQcounters are confirmed independent (not shared). nlp-engineleaf-crate claim confirmed viaCargo.toml— no internalnodespace-*path dependencies, so the separateopenai_compatdump module is architecturally necessary, not duplicated effort.- Old Stage-2-only
agent::local_agent::prompt_dumpcleanly removed; no dangling references to deleted items found repo-wide. - No lint suppressions, no new
unwrap()/expect()outside test code, no rawconsole.login touched.sveltefiles, noanytypes in the new TS test file.
Recommendation: REQUEST CHANGES
The single Critical finding is small in scope — likely a one-line constant fix (or removing/gating the stale function) plus a test assertion — but it's a real, self-contradictory public API left behind by this PR's own correctness fix, not a pre-existing issue merely left unaddressed. Everything else (Improvements/Nits) would not by itself block merge. Recommend resolving the RAM_THRESHOLD_MEDIUM/min_memory_gb mismatch before merge; the rest can be addressed in this PR or deferred at the author's discretion.
… check, add engine discriminator Critical (per pragmatic-code-review on PR #1981): this PR's own min_memory_gb: 16 -> 24 correction for both 12B catalog entries left RAM_THRESHOLD_MEDIUM (shared with Ministral, correctly 16GB) as the Gemma4 tiering threshold too, so recommended_model_id_for(Gemma4) would recommend 12B to 16-23GB machines below its own corrected floor. Added a separate RAM_THRESHOLD_GEMMA4_MEDIUM (24GB) instead of overloading the shared constant, and a new test (ram_thresholds_agree_with_the_catalog_entries_they_gate) asserting every RAM threshold constant matches the min_memory_gb of the catalog entry it gates -- can't RAM-mock detect_system_ram() in a unit test, so this is the regression guard. That test also caught a second, pre-existing mismatch it wasn't written to find: RAM_THRESHOLD_LARGE (32GB) never matched GEMMA_4_31B.min_memory_gb (24GB), predating this PR. Since 31B is parked and unevaluated (unlike 26B-A4B, which this PR actually validated), the Gemma4 "large" tier now recommends GEMMA_4_26B_A4B instead of GEMMA_4_31B -- RAM_THRESHOLD_LARGE already equals GEMMA_4_26B_A4B.min_memory_gb (32GB), so no threshold value needed to change, just which model it points at. 31B is no longer reachable from any recommendation path (same status as both 12B entries) until it's separately re-evaluated. Improvements: hoisted prompt_dump::enabled() out of the per-token hot loop in ChatEngine::generate_blocking (was an env var lookup per token even when dumping is disabled); added an "engine":"native" discriminator to nlp_engine::chat::prompt_dump's JSON records to match openai_compat_prompt_dump's "engine":"openai_compat", so a merged dump file can positively identify both instead of only one by field presence; fixed a stale doc-comment reference to the deleted agent::local_agent::prompt_dump module; reworded an awkward doc comment on the two independent SEQ counters. Checked, not changed: ADR-046/ADR-056 references in chat_models.rs and model_manager.rs are correct as written (046 = original 12B/31B parking, 056 = E4B default lock) -- not actually transposed, despite sitting side-by-side. model-ram-gating.test.ts's mirror-logic pattern (asserting against a local copy of the .svelte gating predicate rather than the component itself) is a pre-existing project convention per the test file's own comment; restructuring it is out of scope for this review pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CwAKk1YU2zyLNzW74mYvH
Addressed review feedback🔴 Critical — fixed. Bonus finding from that new test: it also caught a second, pre-existing mismatch it wasn't written to look for — 🟡 Improvements — all addressed:
🟢 Nits:
Skipped: restructuring Re-Review DecisionDecision: RE-REVIEW NEEDED Rationale: the critical fix touches a public function ( |
malibio
left a comment
There was a problem hiding this comment.
Automated re-review of the delta commit 4a68acaf..0381ba7f in response to the prior REQUEST CHANGES review.
Re-Review: PR #1981 (delta 4a68aca..0381ba7)
Previous Review Summary
- Prior verdict: REQUEST CHANGES at
4a68acaf - Findings: 1 Critical (
RAM_THRESHOLD_MEDIUMleft at 16GB aftermin_memory_gbcorrected to 24 for both 12B entries, sorecommended_model_id_for(Gemma4)recommended 12B to machines below its own corrected floor), 4 Improvement (hoistprompt_dump::enabled()out of the per-token hot loop; missing"engine"discriminator on native prompt-dump records; stale doc-comment reference to a deleted module;model-ram-gating.test.tsmirrors component logic instead of testing it directly), 2 Nit (possible ADR-046/ADR-056 mix-up; awkwardSEQdoc comment wording)
Addressed / Not Addressed
- [Fixed, with a caveat] Critical: RAM threshold drift — new
RAM_THRESHOLD_GEMMA4_MEDIUM(24GB) constant added and correctly wired intorecommended_model_id_for's Gemma4 branch; verified by a new regression test that passes. See below for a leftover stale-comment issue this same fix introduced elsewhere. - [Fixed] Improvement:
prompt_dump::enabled()hot-loop call — hoisted todump_enabled, computed once before the loop, single use site inside it. Verified no stale duplicate call remains. - [Fixed] Improvement: missing
"engine"discriminator —"engine": "native"added to bothdump_promptanddump_responseJSON records innlp-engine/src/chat/prompt_dump.rs. - [Fixed] Improvement: stale doc-comment module reference —
prompt_dump.rs's module doc now correctly points atagent::local_agent::openai_compat_prompt_dump. - [Deferred, reasonably] Improvement:
model-ram-gating.test.ts— confirmed untouched by this delta (no diff between4a68acafand0381ba7f); deferred as a pre-existing project convention, disproportionate scope for this pass. Reasonable call. - [Confirmed non-issue] Nit: ADR-046/ADR-056 — investigated, references are correct as written (046 = original 12B/31B parking, 056 = E4B default lock); no code change needed, correctly left alone.
- [Fixed] Nit:
SEQdoc comment reworded, reads clearly now.
Findings
Critical Issues
None. The core threshold/catalog drift is fixed and verified by a passing test.
Suggested Improvements
-
packages/agent/src/local_agent/model_manager.rs,recommended_model_id()doc comment and its adjacent inline comment (around what is now lines 406-419 in the delta commit) — both still describerecommended_model_id_for's Gemma4 large tier as "the 12B and 31B tiers" / "that RAM-tiers into 12B/31B." After this delta,recommended_model_id_for(Gemma4)'s large branch returnsGEMMA_4_26B_A4B.id, notGEMMA_4_31B.id— 31B is no longer reachable from that function at all. This is now a factually stale comment sitting two functions away from the fix, describing behavior the function no longer has. It's exactly the class of drift this whole review cycle exists to catch: the doc comment directly aboverecommended_model_id_foritself (and the one onRAM_THRESHOLD_LARGE, flagged below) were updated correctly, but this third, slightly more distant comment on the neighboring function was missed. The substantive guard the comment protects (never let E4B stop being the pinned default) is still correct and still enforced by the code — this is a documentation-only miss, not a behavioral one — but given the meta-point of this review round, it should be fixed too: replace "12B and 31B tiers" with "12B and 26B-A4B tiers" (or similar) in both the doc comment and the inline comment. -
packages/agent/src/local_agent/model_manager.rs,RAM_THRESHOLD_LARGE's own doc comment (directly aboveconst RAM_THRESHOLD_LARGE: u64 = 32 * 1024 * 1024 * 1024;) — still reads "RAM threshold (in bytes) at or above which the large recommended model (Gemma 4 31B) is selected instead of the mid-tier one." This was not updated as part of the 31B→26B-A4B swap, even though the doc comment onrecommended_model_id_foritself (a few lines below) was correctly updated to say 26B-A4B. Same fix as above: reword to referenceGEMMA_4_26B_A4Binstead ofGEMMA_4_31B.
Neither of these is a functional bug — GEMMA_4_31B remains correctly listed in CATALOG (still downloadable/selectable directly by id, which is intended; it's parked from recommendation, not deleted), and no other call site in the codebase (including model-store.svelte.ts, which references gemma-4-31b-q4km directly by id and is untouched by this delta) assumes it's still the "large" recommendation tier. But leaving two stale comments in the same function/module that this review round was specifically about undercuts the fix's own stated rationale.
Nitpicks
None beyond the items above (both promoted to Improvement given the review's explicit focus on comment/behavior consistency, rather than filed as Nits).
Verification performed against the actual diff/code (not taken on faith)
- Confirmed via
git diff 4a68acaf..0381ba7f --statthat exactly the 4 claimed files changed, no scope creep. - Confirmed
RAM_THRESHOLD_GEMMA4_MEDIUM(24GB) is correctly used inrecommended_model_id_for'sgemma4_mediumbranch, not the old sharedRAM_THRESHOLD_MEDIUM. - Confirmed catalog values:
MINISTRAL_8B.min_memory_gb == 16,GEMMA_4_12B.min_memory_gb == 24,GEMMA_4_26B_A4B.min_memory_gb == 32, all matching their respective threshold constants exactly. - Confirmed
GEMMA_4_31B.min_memory_gb == 24(not 32) — verifies the described pre-existing mismatch with the oldRAM_THRESHOLD_LARGE(32GB) was real, and is now moot since 31B is no longer gated by that threshold. - Built and ran the new test directly:
cargo test -p nodespace-agent ram_thresholds_agree_with_the_catalog_entries_they_gate— passes. Also ran the fullmodel_managertest module (37 tests) — all pass, including the pre-existingrecommended_model_id_for_family_returns_family_matchtest, confirming no regression. - Confirmed the
u8 -> u64cast in the new test's byte-conversion (min_memory_gb as u64 * 1024 * 1024 * 1024) casts before multiplying (correct operator precedence), so no truncation/overflow risk givenmin_memory_gb's max value of 255. - Confirmed
recommended_model_id_forhas no external call sites outsidemodel_manager.rsitself (only its own doc comments/tests andrecommended_model_id()'s guard comment reference it) — nothing else in the codebase could be silently broken by the 31B→26B-A4B swap. - Confirmed
dump_enabledinnlp-engine/src/chat/mod.rsis computed exactly once before the token-generation loop and used at exactly one site inside it; no stale duplicateprompt_dump::enabled()calls remain in the hot path. - Confirmed
"engine": "native"was added to both JSON record shapes built innlp-engine/src/chat/prompt_dump.rs(dump_promptanddump_response), matching the existing"engine": "openai_compat"in the sibling module — full symmetry, not partial. - Confirmed
model-ram-gating.test.tsdoes not appear ingit diff 4a68acaf..0381ba7f's file list — untouched, as claimed. - No lint suppressions, no new
unwrap()/expect(), no unrelated scope creep in the delta.
Recommendation: APPROVE
The Critical finding from the prior review is genuinely fixed and verified by a passing, well-targeted regression test that also caught a second, independent pre-existing bug — a good outcome from writing the test as a general invariant rather than a narrow one-off assertion. All four Improvements and both Nits from the prior review were handled appropriately (fixed, or reasonably deferred/confirmed-non-issue with stated rationale). The two remaining stale-comment spots identified above are real and worth a quick follow-up fix, but they are non-functional (comment-only, not a behavioral bug) and don't rise to blocking severity — they're the kind of thing reasonable to fix in a fast follow-up rather than another review round, especially given this PR has already been through one full REQUEST CHANGES cycle for comment/threshold drift and the substance is sound.
…B swap Per the re-review of the previous address-review commit: RAM_THRESHOLD_LARGE's own doc comment still described it as gating Gemma 4 31B, and recommended_model_id()'s doc/inline comments still described "12B and 31B tiers" as parked -- both now stale after that commit changed the Gemma4 "large" recommendation tier to 26B-A4B. Corrected both to describe the current behavior precisely: only 12B remains parked in the ladder; 26B-A4B is validated and exposed, but recommended_model_id() still bypasses the whole ladder for its single fixed default rather than tiering partially. No functional change -- confirmed via full nodespace-agent test suite (467/467 passing) and clippy (clean). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015CwAKk1YU2zyLNzW74mYvH
Addressed re-review feedbackFixed the one gap the re-review found: two stale doc comments left behind by the 31B→26B-A4B swap in the previous commit.
No functional change — confirmed via the full Re-Review DecisionDecision: NO RE-REVIEW NEEDED Rationale: pure doc-comment correction, zero logic/behavior change, verified by an identical full test suite pass. The substantive delta (the RAM threshold fix and the 31B→26B-A4B recommendation swap) was already re-reviewed and approved in the prior round — this commit only closes the one loose end that re-review flagged. Ready for merge, pending your go-ahead. |
Summary
ggml-organd Unsloth GGUF sources) end-to-end on adequate hardware via the agent-matrix eval. The upstreampeg-gemma4parser-loop bug (Upgrade llama-cpp-sys-2 when Gemma 4 12B peg-gemma4 parser fixes land upstream #1348) is confirmed fixed in code, but both sources still produce malformed tool-call JSON on complex nested payloads (create_schema) — a genuine model-capability limitation at Q4_K_M, not a GGUF-source defect. 12B remains parked;min_memory_gbcorrected from 16 to 24 on both catalog entries per Upgrade llama-cpp-sys-2 when Gemma 4 12B peg-gemma4 parser fixes land upstream #1348's OOM evidence.recommended_model_id()is unchanged.model-manager.svelte,ai-chat-model-selector.svelte): both compared system RAM against a single hardcoded 16GB constant instead of each model's ownmin_memory_gb. Exposing 26B-A4B's real 32GB floor would have hit this immediately. Both now gate per-model.NODESPACE_PROMPT_DUMPinstrumentation for capturing full LLM conversations during debugging:nlp_engine::chat::prompt_dumpwired intoChatEngine::generate_blocking(the single chokepoint every native-path caller — Stage-1 routing, Stage-2 ReAct turns,resolve_query, the routing probe — passes through), andagent::local_agent::openai_compat_prompt_dumpfor the separate OpenAI-compatible HTTP path. Replaces the prior Stage-2-only dump mechanism, which never covered Stage-1 routing.Full investigation writeup in ADR-056's addendum (nodespace-docs, pushed separately — that repo has no PR flow).
Related issues filed during this investigation
Test plan
bun run test(frontend, 180 files / 4292 tests) — cleancargo test --release -p nodespace-agent -p nodespace-nlp-engine— 123/123 passingcargo clippy -p nodespace-agent -p nodespace-nlp-engine -p nodespace-app --all-targets -D warnings— cleantest:all+cargo build --bin nodespaced+test:e2e) — passedNODESPACE_PROMPT_DUMPcaptures full Stage-1 and Stage-2 conversations end-to-endCloses #1956.
🤖 Generated with Claude Code
https://claude.ai/code/session_015CwAKk1YU2zyLNzW74mYvH