Skip to content

Result-set comparator - #239

Open
vishalkalbi27 wants to merge 4 commits into
AH-100-golden-dataset-readerfrom
AH-099-result-set-comparator
Open

Result-set comparator#239
vishalkalbi27 wants to merge 4 commits into
AH-100-golden-dataset-readerfrom
AH-099-result-set-comparator

Conversation

@vishalkalbi27

Copy link
Copy Markdown
Collaborator

Spec: AH-099

Stacked on #237 (AH-100-golden-dataset-reader), which this targets as its base. It imports
MatchLevel and GoldenBounds from that branch's golden.py. Merge #237 first, then this
retargets cleanly onto main. Review only this branch's four commits — golden.py belongs to #237.

Summary

Decide whether two SQL statements returned the same answer, in code, at the strictness the dataset
author asked for. Given the answer key's result set and the one the agent's generated SQL produced,
return an ItemScore. This is what makes golden scoring deterministic execution accuracy rather
than a model's opinion — it is the reason the eval is reproducible.

Pure function. It executes nothing, writes nothing, opens no connection, and has no way to make a
model call at all.

Changes

  • packages/agami-core/src/semantic_model/comparator.py (new) — one public entry point,
    compare_result_sets(golden, generated, *, match, golden_sql, bounds, dialect) -> ItemScore,
    over four layers: cell canonicalization, top-level ORDER BY detection, column matching, and the
    five match levels. MatchLevel and GoldenBounds are imported from golden.py, never
    redefined; ExecResult comes from execute_sql.
  • tests/test_ah099_comparator.py (new) — 190 tests.

Three properties are load-bearing, and each is pinned by tests:

Columns match on their values — never on name, never on position. A correct statement that
renames and reorders its projection is still correct, and that is the common case a name-matched
comparator fails.

Cells canonicalize to a (type_tag, value) key. Not stylistic: Python collapses True, 1,
1.0 and Decimal(1) into a single Counter bucket, so without the tag a boolean column would
compare equal to an integer column of 0s and 1s. NaN gets a sentinel for the same reason — two
distinct NaN objects bucket separately while the same object collapses, so row counts would
otherwise depend on driver object identity.

Ordering is read from the golden statement alone, parsed rather than regex-matched, so a
generated statement that drops the ORDER BY still scores order-sensitively. ORDER BY inside a
subquery, a CTE, an OVER (…) or an aggregate does not make a result ordered.

Decisions made during the build

Recorded in the spec's ## Decisions; the spec's only [NEEDS DECISION] (tolerance) is resolved
in place.

  1. Tolerance is relative 1e-9, implemented as a rounding bucket at 9 significant digits
    not as a tolerance. Rows compare as a multiset, which needs hashable exactly-equal keys, and a
    tolerance is not an equivalence relation. Whole numbers skip the bucket so an identifier or a
    count is never merged with its neighbour. Two values straddling a bucket edge compare unequal
    however close they are; that is stated rather than hidden.
  2. An item passes only at exactly 1.0. The match level already encodes how loose the
    comparison is, so a second threshold would let two places disagree about one verdict.
  3. unscored and error carry accuracy=None, never 0.0 — zero is a score an item can
    legitimately earn.
  4. bounded gained an authored bounds field, added to Golden dataset reader #237 while it was open. As shipped,
    match: bounded was writable and its band was not, so one of the contract's five levels could
    never do anything.
  5. Greedy column matching, not augmenting-path. Columns pair on equality of value vectors,
    equality is transitive, so candidate sets are equivalence classes and partners within one are
    interchangeable — there is no augmenting path to find. The spec originally asserted the
    opposite; the build disproved it and the code carries the argument in a comment so it is not
    "fixed" back.
  6. An unparseable golden statement is treated as ordered, with the reason in notes. The
    permissive reading would silently stop checking an ordering the author asked for.

Findings from review

Two review passes ran over the diff. Eight must-fixes, all reproduced, all fixed and re-verified.
Two were serious:

  • A top-level ORDER BY could vanish silently. sqlglot wraps some statements in nodes carrying
    no order arg, so SELECT n FROM t ORDER BY n; followed by a trailing comment scored
    order-insensitively with no note — a full 1.0 on a result the answer key says is wrong. Same
    for EXPLAIN …, SET …; SELECT …, and a parenthesized statement. Now the root node is checked
    and anything that cannot carry an order falls through to assumed-ordered plus a note.
  • The both-empty guard defeated nonempty and bounded. Those levels have no answer key by
    design, so their golden side is legitimately empty — and an agent returning no rows, the
    failure they exist to catch, was scored unscored and dropped from the run instead of 0.0. The
    guard now applies only to the levels that consult the answer key.

Also fixed: values quantization was up to 10× wider than documented and passed genuinely
different identifiers above ~1e9; a differing row count was reported as a missing column,
which corrupted the field the run report reads and would have told a reader a present column was
absent; compare_result_sets could raise on an unknown dialect or deeply nested parentheses,
falsifying the totality the caller is told to rely on; and a bounded item carrying both a row band
and a value band could never reach its row band.

Three defects were caught before review, during the build, and are worth naming because each
would have been invisible in production: Decimal.normalize() reads the process-global decimal
context, so an unrelated caller mutating getcontext().prec would have changed comparison keys;
round(accuracy, 3) turned 4002 of 4004 matching rows into exactly 1.0, converting a failure into
a pass at the only threshold that matters; and the augmenting-path matching in decision 5 was
unjustified complexity.

One data-hygiene fix worth flagging: a fixture used the literal 01100170109835, which is a
real California Department of Education CDS code carried over from the comparator being ported —
not a synthetic identifier. It was replaced with a synthetic value here and scrubbed from the
spec
, which is where it originated, so it cannot be reintroduced by the next slice. It never
reached a push.

Pre-existing, not fixed here: GoldenBounds permits a row band and a value band together with
no cross-check (parent branch), and ExecResult does not validate that a row is as wide as its
column list — the latter is handled defensively via RaggedRow.

Checklist

  • Spec referenced (Spec: AH-099)
  • Every spec success criterion implemented and covered by a test (13 of 13, plus the feature's
    E2E items and REQ-006)
  • Full suite green — 4912 passed, 12 skipped, 0 failed
  • ruff check clean; gitleaks clean
  • No test weakened or deleted. Three were modified deliberately, each because a fix changed its
    subject (the nonempty fixture's fabricated golden; two accuracy assertions after rounding
    was removed) — the regressions they guard are preserved.
  • No real customer, organization, table, or question names — fixtures are synthetic over the
    shipped sample store
  • No spec ids in source, comments, or commit messages
  • Reviewed via the panel (correctness/tests, structural rubric, security/disclosure); all
    findings dispositioned
  • No new dependency — sqlglot is already under the model extra
  • semantic_model/__init__.py untouched; comparator.py correctly absent from the stdlib-only
    vendored mirror, and imported by no plugin script

🤖 Generated with Claude Code

vishalkalbi27 and others added 4 commits August 25, 2026 15:12
…an be compared

Raw cells cannot be compared. True == 1 == 1.0 == Decimal(1) and all four hash
alike, so a Counter reads a boolean column as an integer column of zeros and
ones; float('nan') != itself yet dict's identity fast-path collapses the same
NaN object anyway, making a row count depend on driver object reuse; and
Decimal('0.1') != 0.1, so one number read through two drivers disagrees.

Every cell becomes a hashable (type_tag, value) key instead. Numbers normalise
through an explicit decimal context (the ambient one is process-global and any
caller can narrow it), a float goes through Decimal(repr(x)) rather than
Decimal(x), dates canonicalise to ISO text with an aware value read as an
instant in UTC, and text is left exactly as it came — folding it would hide the
difference a comparator exists to find.

Only the canonicalisation layer; comparison and scoring sit on top of it later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… they share

Three steps on top of the canonical cell keys, kept separate because each is
wrong in its own way.

Is the result ordered? Read off the top-level node's own `order` argument and
never with a search for an Order anywhere in the tree: a subquery's ORDER BY, a
CTE's, an OVER (ORDER BY ...) and an array_agg(x ORDER BY x) all order
something other than the result, and a search finds every one of them. The
union cases come out right in both directions for free — an ORDER BY after a
UNION hangs off the Union node and is a total order, one inside a single arm is
not. The dialect is threaded through because a generic parse of a
backtick-quoting statement raises rather than merely losing detail. A statement
that is missing, empty or unparseable is read as ORDERED: the permissive
reading would silently stop checking an ordering the author asked for, and a
visible false failure is recoverable where a silent weakening is not.

Which column answers which? By VALUES only — never by name, never by position.
A generated statement that aliases the total and selects it second still
answered the question, and one that reused the golden name for a different
value did not. Pairing is an augmenting-path matching so that columns carrying
identical values cannot collapse onto one partner.

How far do the rows agree? As a multiset unless the author ordered them: a row
returned twice where the answer key has it once is a different answer, usually
a join that fanned out, and a set comparison is exactly the one that hides it.
A row narrower than its column list — which ExecResult never validates — is
raised as this module's own RaggedRow, so the caller can report the case rather
than field an IndexError from a projection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`compare_result_sets` is the way in: it reads whether the answer key ordered
its rows, dispatches to the level the author asked for, and hands back a frozen
`ItemScore`. It is total — a ragged row, a malformed result, or a band that
cannot be applied come back with an error status rather than as an exception,
so one bad item costs that item and not the run.

The score carries verdicts, row counts and column names. Never a cell and never
the answer key's SQL: the payload being judged here is result data, and a score
travels further than the run that produced it.

Two empty results are UNSCORED rather than a pass — they agree about nothing,
and calling that a full match is how a statement returning nothing gates like a
right one. And accuracy is capped just short of the 1.0 pass mark unless the
overlap is complete: rounding to three places alone would hand a pass to 4002
of 4004 rows.

Also replaces the augmenting-path column matching with a greedy scan. Columns
pair on EQUALITY of their value vectors, and equality is transitive, so the
candidates are equivalence classes and partners inside one are interchangeable
— there is no augmenting path to find. The matching tests are unchanged and
still pass.
A review of the scoring call found six places where it reported a verdict
weaker, or plainer wrong, than the one it had actually reached:

* A top-level ORDER BY was read off `args['order']` of whatever node parsed
  out. A trailing comment, a leading SET, a parenthesised statement or an
  EXPLAIN all parse to nodes that carry no such argument, so the answer key's
  ordering was silently dropped and the item scored a full 1.0 on reversed
  rows with no note. A parenthesised statement is now unwrapped, and a root
  that cannot carry an order takes the assumed-ordered path the module's own
  comment demands.
* An unknown dialect raises ValueError and a deeply nested statement a
  RecursionError, neither of them a SqlglotError, and that read happens
  outside the totality net — so a call documented as never raising did.
* The both-empty guard ran before the level dispatch, which dropped exactly
  the failure `nonempty` and `bounded` exist to catch: those levels have no
  answer key by design, so their golden side is legitimately empty.
* `values` rounded whole numbers to nine significant digits, so any id or
  count above ~1e9 compared equal to its neighbours. Whole numbers carry no
  floating-point tail to forgive and are no longer bucketed; the comment and
  docstring now say bucket rather than tolerance, which is what it is.
* A row-count difference was laundered through the unmatched-column branch
  and reported as "no generated column carries the values of: id" — the
  string a person reads for the most common regression there is. It is
  checked before any column is paired now, and names rows.
* A band with both a row half and a value half could never reach the row
  half: a result the value band could not be applied to short-circuited as an
  error, leaving the item permanently unjudgeable. It falls through to the
  row band, and an empty result scores 0.0 rather than erroring.

Also: the accuracy is the raw share rather than a rounded-then-capped one
(three decimals belong to the report renderer), `__all__` names only the two
symbols that are the interface, and the value band's inclusive edges are
pinned by tests that a `<`/`<=` mutation now fails.

The tests' zero-padded identifier fixture was a real deployment's code
carried over from the comparator this ports; it is synthetic now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

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 a deterministic, pure-Python result-set comparator to score golden-eval items by comparing an answer key’s result set to a generated SQL result set at configurable strictness levels. This lives in the semantic_model layer and is exercised by an extensive new test suite.

Changes:

  • Introduces semantic_model/comparator.py with compare_result_sets(...) -> ItemScore, including cell canonicalization, top-level ORDER BY detection, value-based column matching, and scoring across five match levels.
  • Adds tests/test_ah099_comparator.py to pin canonicalization behavior (bool/num/NaN/date), ordering detection, column pairing semantics, and end-to-end scoring behavior.

Reviewed changes

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

File Description
packages/agami-core/src/semantic_model/comparator.py Implements the result-set comparator and scoring API used for deterministic golden evaluation.
tests/test_ah099_comparator.py Adds comprehensive unit tests validating canonicalization, ordering detection, column matching, and scoring rules.
Suppressed comments (2)

packages/agami-core/src/semantic_model/comparator.py:514

  • The row-count mismatch reason string is missing a verb ("the generated result 3"), which makes diagnostics harder to read. Include "has" in the message.
        return _Verdict(
            "scored", 0.0,
            f"the answer key has {len(golden.rows)} rows and the generated result "
            f"{len(generated.rows)}",
        )

packages/agami-core/src/semantic_model/comparator.py:520

  • The column-count mismatch reason string is missing a verb ("the generated result 3"), which makes diagnostics harder to read. Include "has" in the message.
        return _Verdict(
            "scored", 0.0,
            f"the answer key has {len(golden.columns)} columns and the generated result "
            f"{len(generated.columns)}",
        )

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

# test is on the VALUE and not on the Python type, because the same id arrives as an int from
# one driver and a Decimal from another, and exempting only one of them would fail two
# identical numbers.
if quantize and dec != dec.to_integral_value():
Comment on lines +458 to +462
return _Verdict(
"scored", 0.0,
f"the answer key has {len(golden.rows)} rows and the generated result "
f"{len(generated.rows)}",
)
Comment on lines +485 to +489
return _Verdict(
"scored", accuracy,
f"{overlap} of the answer key's {golden_count} rows matched, "
f"out of {generated_count} the generated statement returned",
)
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