Skip to content

feat(tokenizers): Jinja loop.previtem / loop.nextitem - #411

Open
jamesburton wants to merge 2 commits into
devfrom
issue/399-jinja-loop-previtem-nextitem
Open

feat(tokenizers): Jinja loop.previtem / loop.nextitem#411
jamesburton wants to merge 2 commits into
devfrom
issue/399-jinja-loop-previtem-nextitem

Conversation

@jamesburton

Copy link
Copy Markdown
Owner

Summary

  • Adds loop.previtem / loop.nextitem to the Jinja evaluator's per-iteration loop context (src/DotLLM.Tokenizers/ChatTemplates/JinjaEvaluator.cs). Both are simply omitted from the loop dict at the first/last iteration respectively, so member access on them falls through the evaluator's existing Undefined convention (EvalMemberAccess already returns Undefined for an absent dict key) instead of a new sentinel or a throw — matches Jinja2 semantics, where previtem/nextitem are falsy-undefined at the boundaries, never an error.
  • Also adds the "undefined" test to EvalIsTest (x is undefined / x is not undefined), mirroring the existing IsDefinedExpr path. Required, not optional scope creep: Qwen3.8-27B's real template checks preserve_thinking is undefined in its assistant-message branch, which sits on the path to every tool message — without this the acceptance-criterion test can't execute at all.

Important caveat

The verbatim template excerpt used in the new integration test deliberately drops the template's unconditional reasoning-effort prelude (lines 42-87 of the real file), which uses a parenthesized tuple literal (not in ('xhigh', 'medium', 'low')) that the Jinja parser doesn't support yet — a pre-existing, unrelated gap, filed separately as #409.

The full, unmodified Qwen3.8-27B template still fails to parse until #409 lands — so this PR fixes a necessary condition for tool calling on that model, not a sufficient one. Don't read this as "tool calling now works end-to-end for Qwen3.8-27B."

Tests

  • JinjaEvaluatorTests: discriminating previtem/nextitem cases (actual neighbour values — not "always current item" or "always undefined"), explicit first/last boundary checks via is defined/is not defined, and new is undefined/is not undefined cases.
  • Qwen38ChatTemplateTests (new, tests/DotLLM.Tests.Integration): renders the real Qwen/Qwen3.8-27B chat_template.jinja tool-response branch verbatim (the render_content macro + the multi-step-tool loop + the per-message render loop + generation-prompt tail — lines 1-41 and 88-170 of the file fetched from HF 2026-08-14, unmodified) and asserts on actual merge behaviour: consecutive role == "tool" messages merge into a single <|im_start|>user ... <|im_end|> turn, and a lone tool message opens+closes in the same loop iteration.

Verified

  • dotnet test (Release) on DotLLM.Tests.Unit filtered to JinjaEvaluatorTests/JinjaChatTemplateTests: 114 passed, 0 failed.
  • dotnet test (Release) on DotLLM.Tests.Integration filtered to ChatTemplate: 6 passed, 3 skipped (the pre-existing Mach-1 tests skip because that fixture isn't staged locally — unrelated to this change), 0 failed.
  • Both test projects build clean in Release (transitively builds every src/ project referenced by them — no separate top-level solution file in this repo).

Not verified

Closes #399

Add loop.previtem and loop.nextitem to the Jinja evaluator's per-iteration
loop context. Both are simply omitted from the loop dict at the first/last
iteration respectively, so member access on them falls through the
evaluator's existing Undefined convention (EvalMemberAccess already returns
Undefined for an absent dict key) rather than a new sentinel or a throw --
matching Jinja2 semantics where previtem/nextitem are falsy-undefined at the
boundaries, never an error.

Also adds the "undefined" test to EvalIsTest (`x is undefined` / `x is not
undefined`), mirroring the existing IsDefinedExpr path. This is required
because Qwen3.8-27B's real chat_template.jinja checks
`preserve_thinking is undefined` in its assistant-message branch, which sits
on the path to every tool message -- without it the acceptance-criterion
test below cannot execute at all.

Tests:
- JinjaEvaluatorTests: discriminating previtem/nextitem cases (actual
  neighbour values, not "always current" or "always undefined"), explicit
  first/last boundary checks via `is defined`/`is not`, and the new
  `is undefined` cases.
- Qwen38ChatTemplateTests (new): renders the real Qwen/Qwen3.8-27B
  chat_template.jinja tool-response branch verbatim (macro + message loop
  lines 1-41/88-170 of the file fetched from HF, unmodified) and asserts on
  actual merge behaviour for consecutive tool_response messages, plus the
  single-tool-response open+close-in-one-iteration case.

Note: the verbatim excerpt above deliberately drops the template's
unconditional reasoning-effort prelude (lines 42-87), which uses a
parenthesized tuple literal (`not in ('xhigh', 'medium', 'low')`) that the
parser does not support -- a pre-existing, unrelated gap tracked separately
as #409. The full, unmodified template still fails to parse until #409
lands, so tool calling for this model is not yet fully unblocked end-to-end;
this PR fixes a necessary condition for it, not a sufficient one.

Closes #399

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
Copilot AI lite review requested due to automatic review settings August 14, 2026 22:25

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

Adds missing Jinja loop-context and test semantics needed to render real-world chat templates (notably Qwen3.8’s tool-response branch) within DotLLM.Tokenizers’ Jinja2-subset interpreter.

Changes:

  • Extend loop context in for evaluation with loop.previtem / loop.nextitem, omitted at boundaries to reuse the evaluator’s existing Undefined behavior.
  • Add support for the is undefined / is not undefined test in EvalIsTest (aligned with existing Undefined sentinel semantics).
  • Add unit + integration coverage, including a verbatim excerpt test of Qwen3.8-27B’s tool-response branch behavior.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.

File Description
src/DotLLM.Tokenizers/ChatTemplates/JinjaEvaluator.cs Implements loop.previtem/loop.nextitem via boundary key omission and adds is undefined test handling using the existing Undefined sentinel.
tests/DotLLM.Tests.Unit/Tokenizers/ChatTemplates/JinjaEvaluatorTests.cs Adds focused unit tests verifying neighbor correctness and boundary undefined/defined behavior, plus is undefined/is not undefined cases.
tests/DotLLM.Tests.Integration/Tokenizers/ChatTemplates/Qwen38ChatTemplateTests.cs Adds an integration test rendering Qwen3.8’s real tool-response branch excerpt and asserting correct tool-message merge behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

)

Independent review of PR #411 mutation-tested the loop.previtem/nextitem
suite (7 mutants) and validated against real Jinja2 3.1.6. Six of seven
mutants died; two behaviors it verified empirically had no guard in the repo:

- `is undefined` returning true for a legitimately null value survived the
  suite. The shipped code is already correct (Jinja2: a key present with
  value null is defined, not undefined), but nothing asserted it. Add
  IsUndefined_False_WhenVariableIsNull. This is what keeps a legitimately-null
  loop.previtem distinguishable from an omitted (undefined) one -- the whole
  basis of the omission-instead-of-sentinel design for those two fields.

- Nested for-loops: the inner loop's own "loop" variable must not leak into
  the outer loop's previtem/nextitem once the inner loop finishes. Add
  ForLoop_NestedLoop_DoesNotLeakIntoOuterLoopPrevNextItem, asserting the
  outer loop's previtem/nextitem are restored correctly after an inner loop
  completes within the same outer iteration body.

Both additive; 116/116 unit tests pass (Release).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DzekWWxE4d52Hpa31WBYfX
@jamesburton

Copy link
Copy Markdown
Owner Author

Update: mutation-testing follow-up

Independent review mutation-tested the loop.previtem/loop.nextitem suite (7 deliberate mutants) against real Jinja2 3.1.6, byte-diffed the excised template excerpt against the live HF file (identical), and confirmed 6/7 mutants died. Added two tests to close the gaps the review found (pushed in 4388dfe6):

  • IsUndefined_False_WhenVariableIsNull — kills the surviving mutant (is undefined returning true for a legitimately null value). The shipped code was already correct here, but nothing asserted it; this is the property that keeps a legitimately-null previtem distinguishable from an omitted (undefined) one.
  • ForLoop_NestedLoop_DoesNotLeakIntoOuterLoopPrevNextItem — confirms an inner for-loop's own loop variable doesn't clobber the outer loop's previtem/nextitem once the inner loop finishes.

Both additive; 116/116 unit tests pass (Release).

Two other findings from that review are not addressed here — filed/tracked separately, out of this issue's scope:

  • Attribute access on Undefined returning Undefined where Jinja2 raises (real silent-failure risk).
  • Tuple stringification.

CI note

CI currently shows Integration Tests failing on this branch — not caused by this change. 9/12 failures are Unknown tokenizer.ggml.pre type: 'qwen2' (the #373 gate firing; fixed by gap PR #410), the other 3 are the fixture-skip issue fixed by PR #412. This branch goes green once those land — no rebase needed here beyond a CI rerun.

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