Skip to content

DEV-1480: structured Column.sampled_values + distinct_count#149

Merged
ZmeiGorynych merged 3 commits into
mainfrom
egor/dev-1480-slayercolumn-add-structured-sampled_values-alongside-sampled
May 27, 2026
Merged

DEV-1480: structured Column.sampled_values + distinct_count#149
ZmeiGorynych merged 3 commits into
mainfrom
egor/dev-1480-slayercolumn-add-structured-sampled_values-alongside-sampled

Conversation

@ZmeiGorynych
Copy link
Copy Markdown
Member

@ZmeiGorynych ZmeiGorynych commented May 27, 2026

Summary

  • Adds Column.sampled_values: Optional[List[str]] (top-50-by-frequency list) and Column.distinct_count: Optional[int] (true cardinality) alongside the existing Column.sampled text. Bumps SlayerModel.version 6 → 7 (no-op forward migration).
  • Categorical profiling rewritten: per-value count, SQL-side ORDER BY _count DESC, value ASC, LIMIT max_values + 2 to absorb a NULL row, and a secondary count_distinct via ModelExtension on overflow > 50 — bypasses Column.allowed_aggregations whitelist + Column.filter so the persisted total is the column's raw cardinality.
  • inspect_model JSON gains sampled_values + distinct_count; markdown table unchanged. Cache validity rule _is_sample_cached makes v6 legacy rows re-profile on next call. _collect_measure_profile restricted to numeric/temporal types; row construction switched to key-presence (empty-string sampled no longer falls through to the measure-profile fallback).

Motivation

Resolves DEV-1480. The text sampled is ambiguous for values containing commas (e.g. "R$ 1,000–3,000") — downstream consumers (DEV-1478's literal-existence validator) need an unambiguous structured list to compare predicate literals against. The structured field also lets validators surface the true cardinality and the most-common-first ordering for high-cardinality columns.

Test plan

  • Full non-integration suite green: poetry run pytest -m "not integration" — 3200 passed / 4 skipped / 0 failed.
  • Lint clean: poetry run ruff check slayer/ tests/.
  • New tests/test_v7_migration.py pins the v6→v7 no-op forward and the new field defaults.
  • tests/test_engine_profiling.py extended with frequency ordering, NULL absorption at LIMIT boundary, overflow with allowed_aggregations whitelist, overflow ignoring Column.filter, tie-break determinism, comma-containing values, ColumnSample return shape, refresh path passing all three kwargs.
  • tests/test_storage_sampled.py round-trips all three fields through YAML / SQLite / JoinSync.
  • tests/test_search_render.py pins content_hash stability for the new fields and empty-string skip.
  • tests/integration/test_mcp_inspect.py extended with JSON-output coverage, v6 stale-cache re-profile, all-NULL → empty string (not "all NULL"), > 10 categorical columns all profiled, measure_profile type restriction, and a targeted truthiness regression with a monkeypatched fallback.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Categorical columns now provide structured lists of top-50 distinct values in addition to text summaries
    • Added exact cardinality counts for categorical columns
    • Enhanced model inspection output with more detailed categorical profiling information
  • Documentation

    • Updated column profiling documentation to describe new distinct value tracking and structured sample fields

Review Change Stack

Adds two new optional Column fields alongside the existing sampled text:
sampled_values (top-50-by-frequency list) and distinct_count (true
cardinality at profile time). Lets consumers compare predicate literals
against actual stored values without ambiguity from comma-containing
values (e.g. "R$ 1,000-3,000"). SlayerModel version bumps 6 -> 7 with a
no-op forward migration.

Categorical profiling rewritten to order by per-value count desc with
SQL-side alphabetical tie-break and absorb a NULL row via LIMIT + 2. On
overflow > 50 distinct, fires a secondary count_distinct query via
ModelExtension so the persisted total is exact, not capped, and the
column's allowed_aggregations whitelist + filter don't interfere. Text
sampled format gains a "top20 ... (N distinct)" suffix on overflow.

inspect_model lives on the new ColumnSample shape, uses _is_sample_cached
for cache validity (categorical needs sampled_values is not None so v6
legacy rows re-profile), removes the max_dims=10 cap on the persistence
path, and uses key-presence not truthiness in row construction so empty
strings don't fall through. _collect_measure_profile is restricted to
numeric/temporal types. JSON output gains sampled_values + distinct_count;
markdown table is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@linear
Copy link
Copy Markdown

linear Bot commented May 27, 2026

DEV-1480

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

Warning

Review limit reached

@ZmeiGorynych, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 37 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

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.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 324be38d-b452-4bcb-918d-a41db50ddf24

📥 Commits

Reviewing files that changed from the base of the PR and between 2d71028 and f785520.

📒 Files selected for processing (5)
  • slayer/engine/profiling.py
  • slayer/mcp/server.py
  • tests/integration/test_mcp_inspect.py
  • tests/test_engine_profiling.py
  • tests/test_storage_type_refinement.py
📝 Walkthrough

Walkthrough

This PR implements DEV-1480 structured categorical sampling: it extends the Column schema with sampled_values (top-50 categorical list) and distinct_count (true cardinality), refactors profiling to detect overflow via secondary count-distinct queries, updates storage backends to persist the new fields, creates a v6→v7 schema migration, and updates MCP inspection to emit structured sample metadata while maintaining backward-compatible rendering.

Changes

Structured Categorical Sampling (DEV-1480)

Layer / File(s) Summary
Schema Definition and Column Sample Type
slayer/core/models.py, slayer/engine/profiling.py
Introduces ColumnSample NamedTuple with sampled, sampled_values, and distinct_count fields; extends Column with the two new optional fields and bumps SlayerModel.version to 7.
Profiling Engine: Categorical Overflow and Cardinality
slayer/engine/profiling.py
Refactors profile_column to return ColumnSample, implements frequency-ordered categorical sampling (top 50 with alphabetical tie-break), detects overflow via LIMIT+2, executes secondary count-distinct queries on overflow via ModelExtension, and introduces cache-validity logic keyed on sampled_values presence.
Storage Backend Updates for Sample Fields
slayer/storage/base.py, slayer/storage/sqlite_storage.py, slayer/storage/yaml_storage.py, slayer/storage/join_sync.py
Adds _write_sample_fields helper and extends StorageBackend.update_column_sampled signature with sampled_values and distinct_count parameters across all backends.
Schema Migration: v6 to v7
slayer/storage/migrations.py, slayer/storage/v7_migration.py
Bumps CURRENT_VERSIONS["SlayerModel"] to 7 and creates v6→v7 no-op migration that preserves existing payload while relying on Pydantic defaults for new fields.
MCP Inspection: Profile Reading and JSON Emission
slayer/mcp/server.py
Refactors inspect_model to track and persist sampled_values and distinct_count alongside sampled, calls profile_column for uncached columns, emits all three fields in JSON while preserving markdown backward compatibility.
Search Rendering and Documentation Updates
slayer/search/render.py, CLAUDE.md, docs/concepts/models.md, docs/concepts/search.md
Tightens render_column_text to skip "Sample values" when sampled is falsy; updates documentation to describe cached sample fields, cache validity, and refresh behavior.
Profiling and Storage Unit Tests
tests/test_engine_profiling.py, tests/test_storage_sampled.py
Comprehensive coverage for ColumnSample return type, categorical frequency ordering, overflow classification, true cardinality, top-20 text joining, all-NULL handling, and round-trip persistence through storage backends.
Model Schema and Migration Tests
tests/test_models.py, tests/test_v7_migration.py
Tests for new Column fields (defaults, acceptance, serialization), v6→v7 no-op migration, v6 payload upgrade with fields defaulting to None, v7 round-trips with populated fields, and v5→v7 backward compatibility.
Search Rendering and Type Refinement Tests
tests/test_search_render.py, tests/test_storage_type_refinement.py
Tests for render_column_text behavior with empty strings and overflow suffixes, independence from structured fields, and type refinement version assertions after v6→v7 migration.
Legacy Migration Compatibility Tests
tests/test_migrations.py, tests/test_v4_migration.py, tests/test_v6_migration.py
Decouples existing migration tests from orchestrator target versions by invoking specific migration steps directly and validating transformations without asserting final version values.
Integration Tests: End-to-End MCP Inspection
tests/integration/test_mcp_inspect.py
End-to-end validation that JSON output includes sampled_values/distinct_count for categoricals, markdown remains unchanged, v6 models get re-profiled, all-NULL categoricals emit correct structured output, measure profiling excludes text/boolean, and empty-string samples are not overwritten.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • MotleyAI/slayer#57: Introduces the CURRENT_VERSIONS/_REGISTRY-based migration framework that the v6→v7 schema migration in this PR builds directly on.
  • MotleyAI/slayer#70: Modifies slayer/mcp/server.py's inspect_model implementation to gate profiling output emission, which interacts with the new structured sample fields added in this PR.

🐰 Structured samples hop along with pride,
Counting distinct values far and wide,
Overflow detected, no SQL despair,
Categorical clarity, catalogued with care!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.12% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and clearly describes the main change: introducing two new optional Column fields (sampled_values and distinct_count) referenced in the DEV-1480 ticket, which is the primary objective of the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch egor/dev-1480-slayercolumn-add-structured-sampled_values-alongside-sampled

Warning

Review ran into problems

🔥 Problems

Git: Failed to clone repository. Please run the @coderabbitai full review command to re-trigger a full review. If the issue persists, set path_filters to include or exclude specific files.


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

@ZmeiGorynych
Copy link
Copy Markdown
Member Author

@coderabbitai review this now!

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 27, 2026

@ZmeiGorynych Sure, kicking off the review right now!

(ノ◕ヮ◕)ノ*:・゚✧

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
docs/concepts/models.md (1)

41-42: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update stale SlayerModel version references to 7.

This page still says current SlayerModel version is 6, but this PR introduces v7. Please update both mentions to avoid conflicting migration guidance.

Also applies to: 420-420

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/concepts/models.md` around lines 41 - 42, Update the stale SlayerModel
schema version from 6 to 7 wherever the `version` field is documented (e.g., the
table row showing `version` and any textual references to "SlayerModel version
6"); change the int value and any explanatory text to `7` so the docs reflect
the new v7 schema and avoid conflicting migration guidance.
🧹 Nitpick comments (3)
tests/test_storage_type_refinement.py (1)

286-287: ⚡ Quick win

Move migrations import to module scope.

mig is imported inside test bodies twice; per repo convention, imports should be at the top of the file.

♻️ Suggested cleanup
 import pytest
 import sqlalchemy as sa
 import yaml
 
 from slayer.core.enums import DataType
 from slayer.core.models import DatasourceConfig
+from slayer.storage import migrations as mig
 from slayer.storage.yaml_storage import YAMLStorage
@@
-        from slayer.storage import migrations as mig
-
         await storage_with_v4_model["storage"].get_model("items", data_source="live")
@@
-        from slayer.storage import migrations as mig
         assert raw["version"] == mig.CURRENT_VERSIONS["SlayerModel"]

As per coding guidelines: **/*.py: Place imports at the top of files.

Also applies to: 442-443

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage_type_refinement.py` around lines 286 - 287, The tests
import "mig" (from slayer.storage import migrations as mig) inside test
functions twice; move that import to module scope by adding "from slayer.storage
import migrations as mig" at the top of tests/test_storage_type_refinement.py,
remove the duplicate in-function imports, and run tests to ensure no shadowing
or name conflicts with other top-level imports.
tests/integration/test_mcp_inspect.py (2)

649-671: ⚡ Quick win

Also pin that re-profiling refreshes sampled.

This test only proves the new structured fields are repopulated. If the legacy text sample survives, JSON/markdown can still serve stale profile text and this would still pass.

Suggested assertion
         server = create_mcp_server(storage=storage)
-        await self._call_json(server, model_name="orders")
+        payload = await self._call_json(server, model_name="orders")
+        status_payload = next(c for c in payload["columns"] if c["name"] == "status")
+        assert status_payload["sampled"] != "legacy text from v6"
         # After inspect_model runs, the structured field has been populated.
         reloaded = await storage.get_model("orders", data_source="test_sqlite")
         status = reloaded.get_column("status")
+        assert status.sampled != "legacy text from v6"
         assert status.sampled_values is not None
         assert status.distinct_count == 3
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_mcp_inspect.py` around lines 649 - 671, Add an
assertion to ensure the legacy text field `sampled` is refreshed when
re-profiling: after reloading the model in
test_v6_stale_cache_re_profiles_to_populate_sampled_values (and after calling
storage.update_column_sampled/inspect_model), assert that the column's `sampled`
attribute is not the original legacy string (e.g., not "legacy text from v6")
and/or is cleared/updated alongside `sampled_values`, so the old freeform sample
text cannot leak through.

718-763: ⚡ Quick win

Assert distinct_count on the wide-model path too.

Right now this only proves sampled_values persists past the old cap. If columns 11+ stop writing distinct_count, DEV-1480 regresses and the test still passes.

Suggested assertion
         for i in range(col_count):
             col = reloaded.get_column(f"c{i}")
             assert col.sampled_values is not None, (
                 f"c{i}.sampled_values is None — max_dims cap not removed"
             )
+            assert col.distinct_count == 1, (
+                f"c{i}.distinct_count was not persisted"
+            )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/integration/test_mcp_inspect.py` around lines 718 - 763, The test
test_profiles_more_than_10_categorical_columns currently only asserts
col.sampled_values persists; add an assertion that the persisted
Column.distinct_count is also present for every categorical column after
reloading. Locate the loop that calls reloaded.get_column(f"c{i}") and add a
check such as assert col.distinct_count is not None (or > 0 if appropriate)
alongside the existing sampled_values assertion to ensure columns 11+ still
write distinct_count.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/concepts/models.md`:
- Around line 41-42: Update the stale SlayerModel schema version from 6 to 7
wherever the `version` field is documented (e.g., the table row showing
`version` and any textual references to "SlayerModel version 6"); change the int
value and any explanatory text to `7` so the docs reflect the new v7 schema and
avoid conflicting migration guidance.

---

Nitpick comments:
In `@tests/integration/test_mcp_inspect.py`:
- Around line 649-671: Add an assertion to ensure the legacy text field
`sampled` is refreshed when re-profiling: after reloading the model in
test_v6_stale_cache_re_profiles_to_populate_sampled_values (and after calling
storage.update_column_sampled/inspect_model), assert that the column's `sampled`
attribute is not the original legacy string (e.g., not "legacy text from v6")
and/or is cleared/updated alongside `sampled_values`, so the old freeform sample
text cannot leak through.
- Around line 718-763: The test test_profiles_more_than_10_categorical_columns
currently only asserts col.sampled_values persists; add an assertion that the
persisted Column.distinct_count is also present for every categorical column
after reloading. Locate the loop that calls reloaded.get_column(f"c{i}") and add
a check such as assert col.distinct_count is not None (or > 0 if appropriate)
alongside the existing sampled_values assertion to ensure columns 11+ still
write distinct_count.

In `@tests/test_storage_type_refinement.py`:
- Around line 286-287: The tests import "mig" (from slayer.storage import
migrations as mig) inside test functions twice; move that import to module scope
by adding "from slayer.storage import migrations as mig" at the top of
tests/test_storage_type_refinement.py, remove the duplicate in-function imports,
and run tests to ensure no shadowing or name conflicts with other top-level
imports.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 347542fb-c101-46bc-8229-a02154026d36

📥 Commits

Reviewing files that changed from the base of the PR and between 0b21a79 and 2d71028.

📒 Files selected for processing (23)
  • CLAUDE.md
  • docs/concepts/models.md
  • docs/concepts/search.md
  • slayer/core/models.py
  • slayer/engine/profiling.py
  • slayer/mcp/server.py
  • slayer/search/render.py
  • slayer/storage/base.py
  • slayer/storage/join_sync.py
  • slayer/storage/migrations.py
  • slayer/storage/sqlite_storage.py
  • slayer/storage/v7_migration.py
  • slayer/storage/yaml_storage.py
  • tests/integration/test_mcp_inspect.py
  • tests/test_engine_profiling.py
  • tests/test_migrations.py
  • tests/test_models.py
  • tests/test_search_render.py
  • tests/test_storage_sampled.py
  • tests/test_storage_type_refinement.py
  • tests/test_v4_migration.py
  • tests/test_v6_migration.py
  • tests/test_v7_migration.py

Group A (slayer/engine/profiling.py):
- Codex: _profile_categorical_with_total now drops sampled_values to None
  when the secondary count_distinct query fails so the column is
  classified as cache-miss and retried next call (instead of being
  permanently stuck with distinct_count=None).
- Sonar S3776: extract _refresh_one_column helper to reduce cognitive
  complexity in refresh_table_backed_model_sampled below the 15-cap.

Group B (slayer/mcp/server.py):
- Codex: split inspect_model live-miss loop into categorical (per-column
  via profile_column) and numeric/temporal (one batched min/max via
  _profile_numeric_temporal_columns). Restores pre-DEV-1480 batching for
  wide models so N numeric columns no longer cost N round trips.

Group C (tests):
- Sonar S1481: rename unused 'i' to '_' in test_engine_profiling.py.
- Sonar S7503: NOSONAR on test_mcp_inspect.py injected_measure_profile
  (must be async to monkeypatch the async _collect_measure_profile).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@sonarqubecloud
Copy link
Copy Markdown

- slayer/mcp/server.py: in inspect_model, seed profile_by_name with the
  legacy ``sampled`` text when a column is classified uncached. v6-upgraded
  categorical columns (sampled set, sampled_values=None) now retain a
  visible cell if the live re-profile fails for transient backend reasons.
  Codex finding.
- tests/test_storage_type_refinement.py: hoist
  ``from slayer.storage import migrations as mig`` from two inline test
  bodies to the module-level imports per
  ``feedback_no_inline_imports.md``. CodeRabbit nitpick.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ZmeiGorynych ZmeiGorynych merged commit 2d5649b into main May 27, 2026
6 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.

1 participant