Skip to content

fix(gooddata-eval): make MAQL comparison case-insensitive for keywords - #1719

Open
Tomkess wants to merge 1 commit into
masterfrom
fix/normalize-maql-case-insensitive
Open

fix(gooddata-eval): make MAQL comparison case-insensitive for keywords#1719
Tomkess wants to merge 1 commit into
masterfrom
fix/normalize-maql-case-insensitive

Conversation

@Tomkess

@Tomkess Tomkess commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Summary

_normalize_maql/_best_maql_match (metric_skill.py) compare an agent's
generated MAQL against expected_output.maql via exact string equality after
whitespace/wrapper normalization — but MAQL keywords (SELECT, FOR PREVIOUS, WHERE, BY, ...) are case-insensitive at the query-engine level,
while the comparison was fully case-sensitive.

Reproduced live

In gdc-mic-ai-evaluation, after #1718 landed: fixture "Create a metric for
the prior-year value of Active cards"
expects

SELECT {metric/active_card_count_-_txn_-_cutcgco}
  FOR Previous({label/process_date.year})

Agent produced, verbatim:

SELECT {metric/active_card_count_-_txn_-_cutcgco} FOR PREVIOUS({label/process_date.year})

Byte-identical except FOR PREVIOUS vs FOR Previous — scored as a fail.

First approach considered and rejected

Lowercase everything outside {type/id} braces. WrongWHERE-clause
literal values are also outside braces (e.g. WHERE {label/status} = "Active") and are real, case-sensitive data, not keywords. Blindly folding
them creates a new false-positive risk: two genuinely different filter
values would be scored as equal.

Actual fix

Per the MAQL reference,
every literal value in MAQL is quoted, and every identifier lives inside
{..} — both are exhaustive, structural markers. Protecting text inside
either while casefolding everything else needs no keyword list at all,
which matters because MAQL's actual keyword vocabulary is large (SELECT,
BY, WHERE, HAVING, FOR PREVIOUS/NEXT/EACH, WITHOUT PF,
TOP/BOTTOM, WITHIN, the RANK family, the RUNSUM family, IFNULL,
CASE/WHEN, 15+ math functions, ...) — an enumerated list would inevitably
miss one and only partially fix the bug.

_PROTECTED_RE = re.compile(r"\{[^}]*\}|\"[^\"]*\"|'[^']*'")

def _casefold_outside_protected(s: str) -> str:
    """Lowercase MAQL keywords/operators while preserving case-sensitive {type/id}
    identifiers and quoted string literal values (e.g. WHERE {label/x} = "Active")."""
    parts, last = [], 0
    for m in _PROTECTED_RE.finditer(s):
        parts.append(s[last:m.start()].lower())
        parts.append(m.group(0))
        last = m.end()
    parts.append(s[last:].lower())
    return "".join(parts)

Applied as the final step in _normalize_maql, after the existing
whitespace/wrapper normalization.

Test plan

  • test_normalize_maql_is_case_insensitive_for_keywords — the exact
    reproduced case (FOR PREVIOUS vs FOR Previous) now normalizes equal.
  • test_normalize_maql_preserves_identifier_case{metric/Mixed_Case_Id}
    survives untouched.
  • test_normalize_maql_preserves_quoted_literal_case — the test that
    would have caught the rejected first draft: WHERE x = "Active" vs
    WHERE x = "active" must stay a genuine mismatch, not a false positive.
  • Updated test_normalize_maql_strips_whitespace, whose expected value
    assumed no case normalization ever happens.
  • Full gooddata-eval suite: 274 passed, 9 pre-existing unrelated
    failures (missing openai extra in this test env; two unrelated test
    files) — identical count to before this change.
  • ruff check / ruff format --check clean.

Related

Found while re-testing #1718's fix against real production fixtures — see
that PR's description for the broader investigation this follows from.

_normalize_maql/_best_maql_match compare an agent's generated MAQL against
expected_output.maql via exact string equality after whitespace/wrapper
normalization -- but MAQL keywords (SELECT, FOR PREVIOUS, WHERE, BY, ...) are
case-insensitive at the query-engine level (confirmed against the MAQL
reference), while the comparison itself was fully case-sensitive.

Reproduced live in gdc-mic-ai-evaluation, post the #1718 fix: fixture
"Create a metric for the prior-year value of Active cards" expects
  SELECT {metric/active_card_count_-_txn_-_cutcgco}
    FOR Previous({label/process_date.year})
Agent produced, verbatim:
  SELECT {metric/active_card_count_-_txn_-_cutcgco} FOR PREVIOUS({label/process_date.year})
Byte-identical except FOR PREVIOUS vs FOR Previous -- scored as a fail.

First fix attempt considered and rejected: lowercase everything outside
{type/id} braces. That's wrong -- WHERE-clause literal values are ALSO
outside braces (e.g. WHERE {label/status} = "Active") and are real,
case-sensitive data, not keywords; blindly folding them would create a new
false-positive risk (two genuinely different filter values scored as equal).

Actual fix: per the MAQL reference, every literal value is quoted and every
identifier lives inside {..} -- both are exhaustively structural markers, so
protecting text inside either while casefolding everything else needs no
keyword list at all (which would risk being incomplete against MAQL's large
vocabulary: SELECT, BY, WHERE, HAVING, FOR PREVIOUS/NEXT/EACH, WITHOUT PF,
TOP/BOTTOM, WITHIN, RANK family, RUNSUM family, IFNULL, CASE/WHEN, 15+ math
functions, ...). Added _casefold_outside_protected(), applied as the final
step in _normalize_maql.

Tests added:
- keyword case-insensitivity on the exact reproduced case (FOR PREVIOUS vs
  FOR Previous)
- identifier case preserved ({metric/Mixed_Case_Id} untouched)
- quoted literal case preserved AND still distinguishes real differences
  (WHERE x = "Active" vs WHERE x = "active" must stay a genuine mismatch --
  this is the test that would have caught the rejected first draft)
Updated the one existing test whose expected value assumed no case
normalization ever happens (SELECT -> select).

Full gooddata-eval suite: 274 passed, 9 pre-existing unrelated failures
(missing openai extra in this test env; two unrelated test files) --
identical count to before this change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@Tomkess
Tomkess requested review from hkad98, lupko and pcerny as code owners August 6, 2026 21:32
@coderabbitai

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@Tomkess, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 13 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ee80736-48d0-43ec-9c4c-4e90b0f8daaa

📥 Commits

Reviewing files that changed from the base of the PR and between d1ab1ad and 731af8d.

📒 Files selected for processing (2)
  • packages/gooddata-eval/src/gooddata_eval/core/agentic/metric_skill.py
  • packages/gooddata-eval/tests/test_agentic_metric_skill.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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.

1 participant