Skip to content

fix: soundness round#284

Merged
hachej merged 16 commits into
mainfrom
fix/soundness-round2
Jul 10, 2026
Merged

fix: soundness round#284
hachej merged 16 commits into
mainfrom
fix/soundness-round2

Conversation

@hussainsultan

Copy link
Copy Markdown
Collaborator

No description provided.

hussainsultan and others added 16 commits July 9, 2026 04:24
SemanticJoin/SemanticFilter/SemanticAggregate.join_one defaulted to
"inner" while SemanticModel.join_one, ops.SemanticTableOp.join_one and
api.join_one default to "left". With a right table missing keys, a
semantically no-op .filter() before .join_one() silently flipped the
join type and changed totals (350 -> 300).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_resolve_selector returned [] on any exception, and [] means "index all
dimensions and columns" — so a typo'd selector silently profiled the
whole table. String selectors now validate against merged dimensions +
physical columns (so prefixed names like "flights.carrier" index just
that field instead of falling back to everything), and callable/ibis
selectors propagate their errors. Plain lambdas returning a list of
columns are now resolved by calling them, which the previous select()
path never supported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects in the deferred join_one path:

1. Deferral attached dim labels to entity-grain rows without
   re-aggregating, so group_by on a coarser right-table attribute alone
   (e.g. customers.region) returned one row per entity with duplicate
   dim values (east 220 / west 80 / east 50 instead of east 270 /
   west 80). Deferral now requires the requested group keys to pin the
   entity grain; coarser groupings fall back to the pre-agg path, which
   re-groups correctly.

2. A derived dim referencing another derived dim failed to resolve on
   the raw dim table and 'except Exception: pass' silently dropped the
   requested dimension, returning unlabeled rows at a hidden grain.
   Dependencies are now materialized via
   _mutate_dimensions_with_dependencies, and remaining failures raise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ation

MEAN decomposition built sum(arg)/count(arg) from mean_op.arg and never
consulted mean_op.where, so a conditional mean silently became an
unconditional mean on every joined grain (flat models were unaffected).
Both decomposed legs now carry the where clause.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-agg engine recombines per-table aggregates with plain ==
equi-joins on the group keys. A NULL group key — a real NULL dimension
value, or one minted by the left join for parents with no children —
never satisfies ==, so every table except the join spine lost its value
for the NULL group, and because the spine follows the first listed
measure, the same query returned different numbers depending on measure
order in .aggregate(). join_tables (nested_compile), _rejoin_one and
_left_join_bridge now use identical_to (IS NOT DISTINCT FROM).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
_reagg_op_for_expr defaulted to "sum" for any unrecognized reduction,
so median/stddev/variance and compound base measures (sum()/count()
ratios) were silently summed per join key whenever a cross-table
group-by forced re-aggregation (median 180 vs 90; stddev/var -> NULL;
aov doubled). The classifier now returns None for non-decomposable
expressions, and those measures are aggregated at the exact requested
grain through a (group keys -> join keys) bridge built from the joined
table — each raw row participates once per group it maps to, matching
the decomposable path's join-participation semantics. Unbridgeable
cases raise instead of degrading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
merged_dimensions only carries prefixed names, so a filter written with
a bare derived-dim name (t.size where orders declares size) failed to
resolve on the joined table, was pushed only to its owning table, and
the dim bridge was then built from the UNFILTERED join: sibling tables'
measures ignored the filter and filtered-out groups came back as ghost
rows with NaN measures. The joined-table resolver now exposes bare
aliases for prefixed dims when the suffix is unambiguous and not a
physical column; ambiguous suffixes still hit the loud ownership check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A predicate mixing tables — (t["orders.status"]=="open") & (t.qty>=2) —
has no single owner, so the many side was restricted only through a
join-KEY bridge: every item of any qualifying order survived and items
measures were silently inflated (the same query as two chained
.filter() calls gave different numbers).

Top-level AND chains are now split into legs; each leg's source tables
are determined by field provenance (walking projection values down to
base relations), and single-table legs are inlined to base columns and
re-applied to the owning raw table at row grain. Legs that genuinely
span tables (cross-table OR) raise for many-side tables with requested
measures instead of silently inflating them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SemanticGroupBy inherited SemanticTable.filter, which wrapped the
group-by op in a SemanticFilterOp; aggregate() only recovers keys from
its direct source, so the requested grouping was silently discarded and
one unlabeled global row came back. Filtering between group_by and
aggregate is pre-aggregation row filtering, so SemanticGroupBy.filter
now commutes it below the grouping and re-groups with the same keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two defects:

1. A derived dimension used as a group key was materialized on the raw
   table under its SHORT name, so the pre-agg grain never matched the
   prefixed group-by keys, the dim bridge found no common columns, and
   the key column silently vanished from the output (unlabeled rows);
   with a many-side measure added it crashed instead. Derived group-key
   dims are now materialized under the requested prefixed name so the
   pre-agg grain matches the group-by exactly.

2. Dim lambdas reference sibling dims by bare name (t.region_band), but
   merged dimension maps key them by prefixed name on joins, so
   dependency resolution failed and derived-of-derived dims either
   raised or (pre-fix) knocked the whole join build into the chasm
   fallback. _mutate_dimensions_with_dependencies now aliases
   unambiguous suffixes, and the per-table grain loop resolves
   dependencies before giving up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zero-fact-rows shortcut (#224) probed filters with a tracking proxy
over the raw dimension table, so the qualified spelling BSL's own error
messages recommend (t["customers.region"]) failed the probe, silently
disabled the shortcut, and dimension members with no fact rows vanished
from the result. The probe and the filter application now go through the
table-scoped resolver (bare + prefixed dims), and the validation set
accepts both spellings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
to_tagged/from_tagged of a join_many(...).with_measures()/
with_dimensions() model dropped _source_join — extract_op_tree only
walked source/left/right — so the reconstructed model executed on the
lowered fanned-out join: sums doubled (220 -> 420), means shifted, and
t.all() denominators went back to the fanned-out total, silently
undoing the pre-agg fixes after any round trip. The wrapper extractor
now serializes the join tree under source_join, and reconstruction
rebuilds the SemanticModel around the reconstructed join with
_source_join restored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… swapping

to_tagged tags the LOWERED expression; for queries lowered through the
pre-agg path, _split_join_expr recovers partial-aggregate/key-bridge
tables instead of the raw join leaves. Most shapes crashed with
confusing AttributeErrors, but when shapes aligned the wrong table
landed in a leaf slot and the query silently returned wrong rows
(item_count 4 instead of 2). Reconstructed leaves are now validated:
each declared dimension/measure must resolve against its recovered
table (missing-column failures only), with a clear error pointing at
model-level serialization.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three sinks in the totals machinery could substitute a per-group value
for the grand total when any guarded step failed (every share becomes
1.0 with no error):

- expression rewrite: the unprefixed else-col_name fallback masked the
  unresolved-reference guard, which was dead code for exactly this
  case; only prefixed totals columns are substituted now
- attach_windowed_totals: agg-spec evaluation / windowing failures
  skipped the totals column with a debug log; they now raise
  TotalsNotAvailableError
- calc-of-calc totals: same continue-on-failure, same fix

No natural trigger exists on this branch (fault injection confirmed the
sink), but any future .over() regression or spec-eval failure would have
landed here silently.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
23 tests pinning every confirmed defect from the July 2026 round-2
soundness evaluation against pandas-derived ground truth: A1/A2
(non-decomposable re-agg), A3 (conditional mean), B1/B2 (filter
routing), B3 (grouping through filter), B4 (derived group keys), C1
(NULL group keys, measure-order independence), D1 (join_one defaults),
E1/E2 (serialization round-trips), F1 (deferred-join grain), F2
(dimension-only shortcut), F3 (index selector).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The dimensional-indexing example indexed s.cols("carrier",
"airports__state"), but the join-prefixed field is "airports.state"
(dot, matching the Malloy equivalent). "airports__state" matched no
field. This was masked until the round-2 F3 fix made index() raise on a
no-match selector instead of silently indexing every field, so the
example (run in CI via `make examples`) now fails loudly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@hussainsultan hussainsultan marked this pull request as ready for review July 10, 2026 03:00
@hachej hachej merged commit c671c19 into main Jul 10, 2026
5 checks passed
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