tabint 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. tabint/__init__.py +16 -0
  2. tabint/_ducktable.py +264 -0
  3. tabint/_serialize.py +59 -0
  4. tabint/analytics/__init__.py +4 -0
  5. tabint/analytics/_prep.py +141 -0
  6. tabint/analytics/association.py +451 -0
  7. tabint/analytics/basket.py +135 -0
  8. tabint/analytics/causal.py +182 -0
  9. tabint/analytics/clustering.py +180 -0
  10. tabint/analytics/cohort.py +238 -0
  11. tabint/analytics/compare.py +109 -0
  12. tabint/analytics/descriptive.py +236 -0
  13. tabint/analytics/dimreduction.py +107 -0
  14. tabint/analytics/feature_computation.py +434 -0
  15. tabint/analytics/insights.py +126 -0
  16. tabint/analytics/interpretation.py +189 -0
  17. tabint/analytics/supervised.py +360 -0
  18. tabint/analytics/timeseries.py +300 -0
  19. tabint/cli.py +518 -0
  20. tabint/connectors/__init__.py +11 -0
  21. tabint/connectors/base.py +57 -0
  22. tabint/connectors/contract.py +81 -0
  23. tabint/connectors/stripe.py +184 -0
  24. tabint/entitlement.py +138 -0
  25. tabint/honesty.py +121 -0
  26. tabint/identity.py +105 -0
  27. tabint/jobs/__init__.py +1 -0
  28. tabint/jobs/registry.py +89 -0
  29. tabint/jobs/runner.py +48 -0
  30. tabint/loader.py +22 -0
  31. tabint/mcp_server.py +1162 -0
  32. tabint/persistence.py +167 -0
  33. tabint/platform.py +159 -0
  34. tabint/relationships.py +208 -0
  35. tabint/results.py +30 -0
  36. tabint/scratchpad.py +68 -0
  37. tabint/session.py +256 -0
  38. tabint/store.py +171 -0
  39. tabint/validation/__init__.py +1 -0
  40. tabint/validation/assumptions.py +116 -0
  41. tabint/validation/dtypes.py +102 -0
  42. tabint/workspace.py +540 -0
  43. tabint-0.1.1.dist-info/METADATA +499 -0
  44. tabint-0.1.1.dist-info/RECORD +48 -0
  45. tabint-0.1.1.dist-info/WHEEL +5 -0
  46. tabint-0.1.1.dist-info/entry_points.txt +5 -0
  47. tabint-0.1.1.dist-info/licenses/LICENSE +201 -0
  48. tabint-0.1.1.dist-info/top_level.txt +1 -0
tabint/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """tabint — a deterministic, reproducible intelligence layer for single-table data.
2
+
3
+ Public API is intentionally small. See docs/architecture.md for the design and
4
+ docs/roadmap.md for what's implemented vs. planned.
5
+ """
6
+ from tabint.session import Session
7
+ from tabint.results import Result
8
+ from tabint.workspace import Workspace, Table
9
+ from tabint.relationships import RelationshipGraph, Relationship
10
+ from tabint import persistence
11
+
12
+ __all__ = [
13
+ "Session", "Result", "Workspace", "Table",
14
+ "RelationshipGraph", "Relationship", "persistence",
15
+ ]
16
+ __version__ = "0.1.0"
tabint/_ducktable.py ADDED
@@ -0,0 +1,264 @@
1
+ """Low-level DuckDB table mechanics shared by Store and Workspace/Table.
2
+
3
+ Both the single-table Store and the multi-table Workspace need the same three
4
+ primitives: load a CSV into a table stamped with a stable row id, read it back in
5
+ that order, and write a computed column back by position. Keeping that logic here
6
+ means there is exactly one implementation of the ``_ti_row`` machinery.
7
+
8
+ Every table is stored as:
9
+ - an internal table ``<internal>`` carrying a 0-based ``_ti_row`` id column
10
+ - a view ``<view>`` = the internal table minus ``_ti_row`` (what callers query)
11
+
12
+ All identifiers (table, view, column names) are double-quoted before being spliced
13
+ into SQL, so names containing spaces, punctuation, or reserved words are handled
14
+ safely; the CSV path is single-quote-escaped for the same reason.
15
+
16
+ All functions take an ibis DuckDB backend; ``backend.con`` is the underlying
17
+ duckdb connection used for the low-level writes ibis doesn't cover.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import re
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+ import numpy as np
26
+
27
+
28
+ def quote_ident(name: str) -> str:
29
+ """Quote a SQL identifier, escaping embedded double quotes."""
30
+ return '"' + str(name).replace('"', '""') + '"'
31
+
32
+
33
+ def _quote_literal(value: str) -> str:
34
+ """Escape a value for use inside a single-quoted SQL string literal."""
35
+ return value.replace("'", "''")
36
+
37
+
38
+ def load_csv(backend: Any, csv_path: Path, internal: str, view: str) -> None:
39
+ """Create the internal table (with _ti_row) and its public view from a CSV."""
40
+ path_literal = _quote_literal(str(csv_path))
41
+ create_from_query(backend, f"SELECT * FROM read_csv_auto('{path_literal}')", internal, view)
42
+
43
+
44
+ def create_from_query(backend: Any, select_sql: str, internal: str, view: str) -> None:
45
+ """Materialize a SELECT as a new internal table (with _ti_row) plus its view.
46
+
47
+ Used for CSV loads and for derived tables such as joins — anything that
48
+ produces rows and needs the same stable-row-id + hidden-view treatment.
49
+ """
50
+ qi, qv = quote_ident(internal), quote_ident(view)
51
+ con = backend.con
52
+ con.execute(
53
+ f"CREATE TABLE {qi} AS "
54
+ f"SELECT row_number() OVER () - 1 AS _ti_row, * FROM ({select_sql})"
55
+ )
56
+ con.execute(f"CREATE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
57
+
58
+
59
+ def create_empty(backend: Any, columns: list[tuple[str, str]], internal: str, view: str) -> None:
60
+ """Create an empty structured table (with auto-assigned _ti_row) plus its view.
61
+
62
+ Unlike create_from_query, this table starts with no rows: it defines a schema
63
+ the caller then fills with insert_select. A DuckDB sequence supplies _ti_row
64
+ so ordinary INSERTs that omit it get a monotonically increasing id for free,
65
+ keeping the table compatible with the frame_in_order / write_back machinery.
66
+
67
+ Args:
68
+ columns: (name, sql_type) pairs. Types must already be validated.
69
+ """
70
+ qi, qv = quote_ident(internal), quote_ident(view)
71
+ seq = quote_ident(f"_seq_{internal}")
72
+ seq_literal = _quote_literal(f"_seq_{internal}")
73
+ con = backend.con
74
+ coldefs = ", ".join(f"{quote_ident(name)} {sql_type}" for name, sql_type in columns)
75
+ con.execute(f"CREATE SEQUENCE {seq}")
76
+ con.execute(
77
+ f"CREATE TABLE {qi} (_ti_row BIGINT DEFAULT nextval('{seq_literal}'), {coldefs})"
78
+ )
79
+ con.execute(f"CREATE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
80
+
81
+
82
+ def insert_select(backend: Any, internal: str, source_sql: str) -> int:
83
+ """Append rows into an existing table from a SELECT/VALUES query; return the count.
84
+
85
+ The column list (every column except the auto-assigned _ti_row) is spliced in
86
+ explicitly so source_sql maps positionally to the table's own columns and the
87
+ sequence default fills _ti_row. Returns the number of rows inserted.
88
+ """
89
+ qi = quote_ident(internal)
90
+ con = backend.con
91
+ cols = [row[0] for row in con.execute(f"DESCRIBE {qi}").fetchall() if row[0] != "_ti_row"]
92
+ col_list = ", ".join(quote_ident(c) for c in cols)
93
+ before = con.execute(f"SELECT COUNT(*) FROM {qi}").fetchone()[0]
94
+ con.execute(f"INSERT INTO {qi} ({col_list}) {source_sql}")
95
+ after = con.execute(f"SELECT COUNT(*) FROM {qi}").fetchone()[0]
96
+ return int(after - before)
97
+
98
+
99
+ # Persistent registry of derived (computed) columns — cluster labels, outlier
100
+ # flags, predictions, etc. — that must be excluded from feature matrices. It
101
+ # lives in the same DuckDB file as the data so it survives reopen/_reattach; keyed
102
+ # by (table_key, column_name) where table_key is the public table/view name.
103
+ _DERIVED_REGISTRY = "_ti_derived_columns"
104
+
105
+
106
+ def _ensure_derived_registry(con: Any) -> None:
107
+ con.execute(
108
+ f"CREATE TABLE IF NOT EXISTS {quote_ident(_DERIVED_REGISTRY)} "
109
+ f"(table_key VARCHAR, column_name VARCHAR)"
110
+ )
111
+
112
+
113
+ def register_derived(backend: Any, table_key: str, column_name: str) -> None:
114
+ """Mark a column as derived (a non-feature annotation) for the given table."""
115
+ con = backend.con
116
+ q = quote_ident(_DERIVED_REGISTRY)
117
+ _ensure_derived_registry(con)
118
+ con.execute(
119
+ f"DELETE FROM {q} WHERE table_key = ? AND column_name = ?", [table_key, column_name]
120
+ )
121
+ con.execute(f"INSERT INTO {q} VALUES (?, ?)", [table_key, column_name])
122
+
123
+
124
+ def unregister_derived(backend: Any, table_key: str, column_name: str) -> None:
125
+ """Clear any derived mark on a column (e.g. when it is (re)written as a feature)."""
126
+ con = backend.con
127
+ _ensure_derived_registry(con)
128
+ con.execute(
129
+ f"DELETE FROM {quote_ident(_DERIVED_REGISTRY)} "
130
+ f"WHERE table_key = ? AND column_name = ?",
131
+ [table_key, column_name],
132
+ )
133
+
134
+
135
+ def derived_columns(backend: Any, table_key: str) -> set[str]:
136
+ """Return the set of columns marked derived for the given table."""
137
+ con = backend.con
138
+ _ensure_derived_registry(con)
139
+ rows = con.execute(
140
+ f"SELECT column_name FROM {quote_ident(_DERIVED_REGISTRY)} WHERE table_key = ?",
141
+ [table_key],
142
+ ).fetchall()
143
+ return {r[0] for r in rows}
144
+
145
+
146
+ # Tokens that must never appear in a feature expression: statement/DDL keywords,
147
+ # subquery starters, and functions that reach the filesystem or catalog. Matched
148
+ # as whole words, case-insensitively — the guard for agent-authored SQL that runs
149
+ # server-side over massive tables (see add_computed_column).
150
+ _SQL_EXPR_BLOCKLIST = frozenset({
151
+ "select", "insert", "update", "delete", "drop", "alter", "create", "attach",
152
+ "detach", "copy", "install", "load", "pragma", "call", "export", "import",
153
+ "set", "reset", "read_csv", "read_csv_auto", "read_parquet", "read_json",
154
+ "read_json_auto", "read_text", "read_blob", "glob", "system", "getvariable",
155
+ })
156
+ _WORD_RE = re.compile(r"[A-Za-z_][A-Za-z_0-9]*")
157
+
158
+
159
+ def _validate_sql_expression(backend: Any, view: str, expression: str) -> None:
160
+ """Reject anything beyond a single scalar expression over the table's columns.
161
+
162
+ Layered guard: no statement chaining (``;``), no blocklisted keywords/functions
163
+ (subqueries, DDL/DML, file & catalog readers), then a zero-row bind check so
164
+ the expression must parse as a scalar over the real columns. Raises ValueError.
165
+ """
166
+ if not expression or not expression.strip():
167
+ raise ValueError("Feature expression is empty.")
168
+ if ";" in expression:
169
+ raise ValueError("Feature expression must be a single expression (no ';').")
170
+ lowered_words = {w.lower() for w in _WORD_RE.findall(expression)}
171
+ hit = lowered_words & _SQL_EXPR_BLOCKLIST
172
+ if hit:
173
+ raise ValueError(
174
+ f"Feature expression may not use {sorted(hit)} — only a scalar computation "
175
+ f"over the table's columns is allowed (no subqueries, DDL, or file access)."
176
+ )
177
+ # Bind-only check: WHERE false computes nothing but still parses/binds the
178
+ # expression and its column references. Cheap even on a huge table.
179
+ qv = quote_ident(view)
180
+ try:
181
+ backend.con.execute(f"SELECT ({expression}) FROM {qv} WHERE false")
182
+ except Exception as exc:
183
+ raise ValueError(f"Invalid feature expression: {exc}") from exc
184
+
185
+
186
+ def add_computed_column(backend: Any, internal: str, view: str, name: str, expression: str) -> int:
187
+ """Add (or replace) a column computed by a SQL scalar expression, in-database.
188
+
189
+ Rebuilds the internal table with the expression as a new column and refreshes
190
+ the public view, preserving ``_ti_row``. Runs entirely inside DuckDB — no rows
191
+ are materialized in the app. Returns the count of non-null values produced.
192
+ """
193
+ _validate_sql_expression(backend, view, expression)
194
+ con = backend.con
195
+ qi, qv, qn = quote_ident(internal), quote_ident(view), quote_ident(name)
196
+ existing = {row[0] for row in con.execute(f"DESCRIBE {qi}").fetchall()}
197
+ keep = f"* EXCLUDE ({qn})" if name in existing else "*"
198
+ con.execute(f"CREATE OR REPLACE TABLE {qi} AS SELECT {keep}, ({expression}) AS {qn} FROM {qi}")
199
+ con.execute(f"CREATE OR REPLACE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
200
+ return int(con.execute(f"SELECT COUNT({qn}) FROM {qi}").fetchone()[0])
201
+
202
+
203
+ def count_rows(backend: Any, view: str) -> int:
204
+ """Return the table's row count via an in-database COUNT(*) (no materialization)."""
205
+ return int(backend.con.execute(f"SELECT COUNT(*) FROM {quote_ident(view)}").fetchone()[0])
206
+
207
+
208
+ def count_non_null(backend: Any, view: str, column: str) -> int:
209
+ """Return the count of non-NULL values in a column, computed inside DuckDB.
210
+
211
+ Raises KeyError if the column does not exist (COUNT(col) counts non-NULLs).
212
+ """
213
+ con = backend.con
214
+ qv = quote_ident(view)
215
+ existing = {row[0] for row in con.execute(f"DESCRIBE {qv}").fetchall()}
216
+ if column not in existing:
217
+ raise KeyError(f"No column {column!r}. Known: {sorted(existing)}")
218
+ return int(con.execute(f"SELECT COUNT({quote_ident(column)}) FROM {qv}").fetchone()[0])
219
+
220
+
221
+ def frame_in_order(backend: Any, internal: str) -> Any:
222
+ """Return the table as a pandas DataFrame in stable _ti_row order (id excluded)."""
223
+ return backend.sql(
224
+ f"SELECT * EXCLUDE (_ti_row) FROM {quote_ident(internal)} ORDER BY _ti_row"
225
+ ).execute()
226
+
227
+
228
+ def write_back(backend: Any, internal: str, view: str, name: str, values: Any) -> None:
229
+ """Add or replace a column by position (join on _ti_row), then refresh the view.
230
+
231
+ ``values[i]`` is written to the row whose ``_ti_row`` is ``i`` — the same
232
+ order frame_in_order returns, so per-row arrays align without realignment.
233
+
234
+ Raises ValueError if len(values) != the table's row count, since a mismatch
235
+ would silently drop rows through the positional inner join.
236
+ """
237
+ # DuckDB parameter binding can't consume numpy generics — coerce to Python.
238
+ col_list = [v.item() if isinstance(v, np.generic) else v for v in values]
239
+ n = len(col_list)
240
+ con = backend.con
241
+ qi, qv, qn = quote_ident(internal), quote_ident(view), quote_ident(name)
242
+ tmp = quote_ident(f"_wb_{internal}")
243
+
244
+ current = con.execute(f"SELECT COUNT(*) FROM {qi}").fetchone()[0]
245
+ if n != current:
246
+ raise ValueError(
247
+ f"write_back_column expected {current} values (one per row) but got {n}."
248
+ )
249
+
250
+ con.execute(
251
+ f"CREATE OR REPLACE TEMP TABLE {tmp} AS "
252
+ f"SELECT unnest(range({n})) AS _ti_row, unnest(?) AS {qn}",
253
+ [col_list],
254
+ )
255
+ existing = {row[0] for row in con.execute(f"DESCRIBE {qi}").fetchall()}
256
+ src_cols = f"{qi}.* EXCLUDE ({qn})" if name in existing else f"{qi}.*"
257
+
258
+ con.execute(
259
+ f"CREATE OR REPLACE TABLE {qi} AS "
260
+ f"SELECT {src_cols}, {tmp}.{qn} "
261
+ f"FROM {qi} JOIN {tmp} ON {qi}._ti_row = {tmp}._ti_row"
262
+ )
263
+ con.execute(f"CREATE OR REPLACE VIEW {qv} AS SELECT * EXCLUDE (_ti_row) FROM {qi}")
264
+ con.execute(f"DROP TABLE IF EXISTS {tmp}")
tabint/_serialize.py ADDED
@@ -0,0 +1,59 @@
1
+ """Shared JSON serialization for the CLI and MCP surfaces.
2
+
3
+ Both front-ends must turn Result objects (and arbitrary values/metadata dicts that
4
+ may contain numpy scalars or NaN) into strict-JSON-safe structures. Keeping that in
5
+ one place means the two surfaces never diverge on how a result is rendered.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import datetime as _dt
10
+ import math
11
+ from decimal import Decimal
12
+ from typing import Any
13
+
14
+ import numpy as np
15
+
16
+
17
+ def jsonable(obj: Any) -> Any:
18
+ """Recursively coerce numpy scalars, NaN, and ±Infinity into JSON-safe values.
19
+
20
+ NaN and infinities are not valid JSON (RFC 8259) and are rejected by strict
21
+ parsers (Node, jq, Go, Rust), so they become null — an agent must never get a
22
+ ``0``-exit success that it then fails to parse.
23
+ """
24
+ if isinstance(obj, dict):
25
+ return {k: jsonable(v) for k, v in obj.items()}
26
+ if isinstance(obj, (list, tuple)):
27
+ return [jsonable(v) for v in obj]
28
+ if isinstance(obj, np.generic):
29
+ obj = obj.item()
30
+ # DuckDB DECIMAL columns come back as Python Decimal, and DATE/TIMESTAMP as
31
+ # datetime objects — none are JSON-serializable, so coerce them here.
32
+ if isinstance(obj, Decimal):
33
+ obj = float(obj)
34
+ elif isinstance(obj, (_dt.date, _dt.datetime, _dt.time)):
35
+ return obj.isoformat()
36
+ if isinstance(obj, float) and (math.isnan(obj) or math.isinf(obj)):
37
+ return None
38
+ return obj
39
+
40
+
41
+ def result_dict(res: Any) -> dict:
42
+ """Render a Result as a plain JSON dict (dropping any non-serializable artifact).
43
+
44
+ Always emits the honesty envelope — a ``trust`` block and a top-level
45
+ ``declined`` flag — so every tool's output carries confidence uniformly. An
46
+ analytic that hasn't set trust reports ``unassessed`` (never a fake ``high``).
47
+ """
48
+ from . import honesty
49
+
50
+ trust = getattr(res, "trust", None) or honesty.unassessed()
51
+ trust_json = trust.model_dump(mode="json")
52
+ return {
53
+ "method": res.method,
54
+ "summary": res.summary,
55
+ "trust": trust_json,
56
+ "declined": bool(trust_json.get("declined", False)),
57
+ "values": jsonable(res.values),
58
+ "metadata": jsonable(res.metadata),
59
+ }
@@ -0,0 +1,4 @@
1
+ """Analytics families: descriptive, association, clustering, supervised, interpretation,
2
+ dimensionality reduction, time series, feature-computation (generic feature
3
+ engineering), and the insight-extraction primitives (insights/key-drivers,
4
+ basket/market-basket, causal, cohort/RFM+retention, compare)."""
@@ -0,0 +1,141 @@
1
+ """Shared feature-preparation helpers for the model-based families.
2
+
3
+ Clustering, dimensionality reduction, and supervised learning all need to turn
4
+ the stored table into a numeric, model-ready matrix. Doing that consistently —
5
+ same column selection, same encoding, same imputation — is what keeps results
6
+ comparable across families, so it lives here rather than being re-derived in each
7
+ module.
8
+
9
+ Column *classification* is never re-decided here; it is delegated to
10
+ validation.dtypes (the single source of truth). This module only decides how a
11
+ classified column is fed to scikit-learn.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ import pandas as pd
18
+ from sklearn.compose import ColumnTransformer
19
+ from sklearn.impute import SimpleImputer
20
+ from sklearn.pipeline import Pipeline
21
+ from sklearn.preprocessing import OneHotEncoder, StandardScaler
22
+
23
+ from ..validation.dtypes import classify_column
24
+
25
+ # Column types (from validation.dtypes) that carry modelling signal and how they
26
+ # should be treated. Identifiers and datetimes are excluded from feature matrices.
27
+ _NUMERIC_TYPES = {"continuous"}
28
+ _CATEGORICAL_TYPES = {"categorical_nominal", "categorical_ordinal"}
29
+
30
+
31
+ def get_frame(store: Any) -> pd.DataFrame:
32
+ """Return the full table in stable (_ti_row) order as a pandas DataFrame."""
33
+ return store.get_frame()
34
+
35
+
36
+ def feature_columns(
37
+ store: Any,
38
+ exclude: tuple[str, ...] = (),
39
+ ) -> tuple[list[str], list[str]]:
40
+ """Split the table's columns into (numeric, categorical) feature lists.
41
+
42
+ Identifier and datetime columns are dropped — they are not features. Any
43
+ names in ``exclude`` (e.g. the supervised target, or a cluster-label column)
44
+ are dropped too.
45
+
46
+ Args:
47
+ store: The Store instance.
48
+ exclude: Column names to omit from both lists.
49
+
50
+ Returns:
51
+ (numeric_columns, categorical_columns).
52
+ """
53
+ # Derived annotations (outlier flags, cluster labels, predictions) are never
54
+ # features — drop them alongside the caller's explicit excludes so they can't
55
+ # leak into a model. reduce_dimensions marks its components feature=True, so
56
+ # those stay eligible.
57
+ get_derived = getattr(store, "derived_columns", None)
58
+ excluded = set(exclude) | (get_derived() if get_derived else set())
59
+
60
+ numeric: list[str] = []
61
+ categorical: list[str] = []
62
+ for name in store._table.schema():
63
+ if name in excluded:
64
+ continue
65
+ kind = classify_column(name, store)
66
+ if kind in _NUMERIC_TYPES:
67
+ numeric.append(name)
68
+ elif kind in _CATEGORICAL_TYPES:
69
+ categorical.append(name)
70
+ # identifier / datetime → intentionally skipped
71
+ return numeric, categorical
72
+
73
+
74
+ def build_preprocessor(
75
+ numeric: list[str],
76
+ categorical: list[str],
77
+ *,
78
+ scale: bool,
79
+ ) -> ColumnTransformer:
80
+ """Build a ColumnTransformer that imputes, (optionally) scales, and one-hot encodes.
81
+
82
+ Args:
83
+ numeric: Numeric (continuous) feature column names.
84
+ categorical: Categorical feature column names.
85
+ scale: Whether to standard-scale numeric columns. Distance-based methods
86
+ (k-means, PCA) need this; tree ensembles do not.
87
+
88
+ Returns:
89
+ An unfitted ColumnTransformer.
90
+ """
91
+ numeric_steps: list[tuple[str, Any]] = [("impute", SimpleImputer(strategy="median"))]
92
+ if scale:
93
+ numeric_steps.append(("scale", StandardScaler()))
94
+ numeric_pipe = Pipeline(numeric_steps)
95
+
96
+ categorical_pipe = Pipeline([
97
+ ("impute", SimpleImputer(strategy="most_frequent")),
98
+ # Dense output: HistGradientBoosting and the manifold methods reject sparse.
99
+ ("onehot", OneHotEncoder(handle_unknown="ignore", sparse_output=False)),
100
+ ])
101
+
102
+ transformers = []
103
+ if numeric:
104
+ transformers.append(("numeric", numeric_pipe, numeric))
105
+ if categorical:
106
+ transformers.append(("categorical", categorical_pipe, categorical))
107
+ return ColumnTransformer(transformers, remainder="drop")
108
+
109
+
110
+ def numeric_matrix(
111
+ store: Any,
112
+ exclude: tuple[str, ...] = (),
113
+ *,
114
+ scale: bool = True,
115
+ ) -> tuple[Any, pd.DataFrame, list[str]]:
116
+ """Materialize a fully numeric feature matrix for distance-based methods.
117
+
118
+ Used by clustering and dimensionality reduction. Numeric columns are scaled
119
+ (by default) and categorical columns one-hot encoded.
120
+
121
+ Args:
122
+ store: The Store instance.
123
+ exclude: Column names to omit (e.g. an existing cluster-label column).
124
+ scale: Whether to standard-scale numeric columns.
125
+
126
+ Returns:
127
+ (X, frame, feature_names) where X is the transformed 2-D array in stable
128
+ row order, frame is the source DataFrame, and feature_names are the
129
+ original feature column names (pre-encoding).
130
+ """
131
+ frame = get_frame(store)
132
+ numeric, categorical = feature_columns(store, exclude=exclude)
133
+ if not numeric and not categorical:
134
+ raise ValueError("No usable feature columns (all identifier/datetime).")
135
+ pre = build_preprocessor(numeric, categorical, scale=scale)
136
+ X = pre.fit_transform(frame)
137
+ # Densify sparse one-hot output so downstream estimators that dislike sparse
138
+ # input (e.g. some sklearn manifold methods) work uniformly.
139
+ if hasattr(X, "toarray"):
140
+ X = X.toarray()
141
+ return X, frame, numeric + categorical