Skip to content

Give each Minja macro call its own variable scope - #1130

Open
Joel Stidley (joelst) wants to merge 4 commits into
microsoft:mainfrom
joelst:fix/minja-macro-scope
Open

Joel Stidley (joelst) wants to merge 4 commits into
microsoft:mainfrom
joelst:fix/minja-macro-scope

Conversation

@joelst

@joelst Joel Stidley (joelst) commented Oct 4, 2026 •

Copy link
Copy Markdown

Independent of #1129. The two PRs can merge in either order and merge cleanly together. This PR fixes the second Gemma 4 symptom from #1128: an extra <|turn>model after tool responses.

Minimal repro (Jinja2 vs. ORT Extensions before this fix)

Template Jinja2 Before
{% set ns = namespace(v='outer') %}{% macro m() %}{% set ns = namespace(v='inner') %}{{ ns.v }}{% endmacro %}{{ m() }}|{{ ns.v }} inner|outer inner|inner
{% set x = 'outer' %}{% macro m(x) %}{{ x }}{% endmacro %}{{ m('arg') }}|{{ x }} arg|outer arg|arg
{% macro r(n, tag) %}{% if n > 0 %}{{ r(n - 1, 'inner') }}{% endif %}{{ tag }}{% endmacro %}{{ r(2, 'outer') }} innerinnerouter innerinnerinner

Change

One line in shared/api/minja.hpp: each macro call renders in a child context of the defining context instead of the defining context itself. This matches Jinja2 semantics and upstream google/minja (execution_context = Context::make(Value::object(), macro_context)).

Scope / behavior

  • Fixed: macro parameters and {% set %} inside a macro no longer overwrite outer variables. Recursive macros no longer overwrite their callers' arguments.
  • Unchanged: {% set ns.attr = ... %} inside a macro still mutates an outer namespace, because ns is resolved through parent lookup. Default parameter values are still evaluated per call. Templates that don't use macros are unaffected.
  • Possible break: only templates that read a macro's parameters or locals after calling the macro. That relied on this bug and fails in Jinja2.

Validation

  • New OrtxTokenizerTest.MinjaMacroCallsUseLocalScope: the 3 scope assertions fail before the fix (inner|inner, arg|arg, innerinnerinner) and pass after. The namespace-mutation and default-argument assertions pass both before and after (guarding against over-isolation).
  • New OrtxTokenizerTest.Gemma4ToolResponseSuppressesGenerationPrompt, which uses the Gemma 4 template already on main, with the expected string taken from Jinja2. It fails before the fix (trailing <|turn>model) and passes after.
  • All OrtxTokenizerTest.* tests pass (89/89) on macOS arm64.
  • Downstream: ONNX Runtime GenAI rel-0.17.1 built against this change (via FETCHCONTENT_SOURCE_DIR_ONNXRUNTIME_EXTENSIONS) renders the Gemma 4 E2B template byte-identical to Jinja2 for user+tools, no-tools, and tool-call history (when combined with Preserve Gemma 4 tool definitions and add Minja upper filter #1129).
  • Blast radius (differential test): I rendered 45 real chat templates (21 in this repo plus 24 popular HF models: Llama 3.x, Qwen2.5/3, Phi-3.5/4, Mistral, DeepSeek, Granite, SmolLM3, gpt-oss, Gemma 3/4, GLM-4.5, LFM2, Nemotron, Falcon3, OLMo2, Kimi-K2, ERNIE), each in 8 conversation scenarios, before and after this change through GenAI rel-0.17.1. 356 of the 360 renders were byte-identical. The only 4 differences are Gemma 4 tool-call-history cases, which now match Jinja2. Only 7 of the 45 templates use {% macro %} at all.
  • Jinja2 parity: across the macro templates, no case went from matching Jinja2 to not matching. The existing GLM-4.5/Kimi-K2/LFM2 gaps are the same before and after (unrelated Minja limitations).
  • Latency: no measurable change. Gemma 4 renders took about 250 µs each with or without the fix, over 3 rounds of 300 renders.

Reviewer questions

  1. Is aligning macro scoping with Jinja2/upstream Minja acceptable even though a template relying on the old leakage would change output?
  2. Gemma 4 needs both this PR and Preserve Gemma 4 tool definitions and add Minja upper filter #1129 for Jinja2-identical output with tools. They are independent; any merge order works.
  3. Separately, MacroNode captures [&] while upstream captures [this, macro_context] by value. Fold that hardening in here or track it separately?

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

Macro calls rendered directly into the defining context, so parameters and
`set` statements inside a macro overwrote same-named outer variables and
recursive calls clobbered their callers' arguments. Gemma 4's chat template
hits this: `strip_thinking` reassigns `ns`, hiding the outer tool-response
state and producing an extra `<|turn>model` generation prompt.

Create a child context per call, matching Jinja2 and upstream google/minja.
Namespace attribute writes still reach outer namespaces via parent lookup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 4ca47b75-18b7-4168-b67d-aae8529ba961
@joelst
Joel Stidley (joelst) marked this pull request as ready for review October 4, 2026 07:23
@joelst
Joel Stidley (joelst) requested a review from a team as a code owner October 4, 2026 07:23
Copilot AI balanced review requested due to automatic review settings October 4, 2026 07:23

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

The focused implementation matches the intended semantics and is covered by relevant regressions.

Review effort: Balanced
Findings: None

What changed in this PR

Fixes Gemma 4’s extra generation prompt by aligning Minja macro scoping with Jinja2 and upstream Minja.

Changes:

  • Creates an isolated child context for each macro invocation.
  • Adds regression coverage for macro scope, recursion, namespace mutation, defaults, and Gemma 4 tool responses.
File Description
shared/​api/​minja.hpp Isolates macro-call variables from their defining context.
test/​pp_api_test/​test_tokenizer_chat.cc Tests macro semantics and Gemma 4 output.

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

@sayanshaw24

Copy link
Copy Markdown
Collaborator

/azp run onnxruntime-extensions.ci

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Parameter-dependent defaults still read the caller’s scope rather than the newly bound macro arguments.

1 open finding

🧠 Review effort: Balanced

Comment thread shared/api/minja.hpp
Joel Stidley (joelst) and others added 2 commits October 14, 2026 17:00
Evaluate defaults in parameter order within the macro call context, allowing later defaults to resolve bound parameters without seeing unrelated caller locals. This follows Jinja macro semantics and intentionally diverges from upstream google/minja's caller-context evaluation; regressions cover positional, keyword, defaulted, and expression arguments.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: f7c802aa-8c9c-490d-a569-b6e4cda98adf

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟢 Approval recommended

The focused changes address the prior default-argument issue and have targeted regression coverage with no unresolved blocking findings.

No open issues 🎉

1 resolved since last review

Review effort: Balanced

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.

3 participants