Edit on GitHub

featurizer

 1# coding: utf-8
 2
 3from .featurizer import Featurizer
 4from .imputation import (
 5    MISSING_INDICATOR_SUFFIX,
 6    impute_arrow,
 7    impute_features,
 8)
 9from .sharding import (
10    DEFAULT_MAX_COLUMNS_PER_GROUP,
11    ColumnGroupSharder,
12    GroupedQueries,
13)
14from .validation import (
15    ValidationError,
16    ValidationResult,
17    ValidationWarning,
18    validate_config,
19)
20from .viz import FeaturizerViz
21
22__all__ = [
23    "Featurizer",
24    "FeaturizerViz",
25    "impute_features",
26    "impute_arrow",
27    "MISSING_INDICATOR_SUFFIX",
28    "ColumnGroupSharder",
29    "GroupedQueries",
30    "DEFAULT_MAX_COLUMNS_PER_GROUP",
31    "validate_config",
32    "ValidationResult",
33    "ValidationError",
34    "ValidationWarning",
35]
class Featurizer:
  72class Featurizer:
  73    """
  74    PostgreSQL implementation of the DFS algorithm (adapted for temporal data sets).
  75
  76    Coordinates configuration loading, feature planning, SQL rendering, and optional
  77    database execution.
  78    """
  79
  80    def __init__(
  81        self,
  82        config_file: str,
  83        *,
  84        debug: bool = False,
  85        validate: bool = True,
  86        materialize_threshold: int | None = None,
  87        connection: Any = None,
  88    ) -> None:
  89        """Initialize Featurizer from a YAML configuration file.
  90
  91        Args:
  92            config_file: Path to YAML configuration file
  93            debug: Enable debug logging with icecream. Can also be set via FEATURIZER_DEBUG env var.
  94            validate: Run enhanced validation checks (default: True)
  95            materialize_threshold: Column width above which an oversized
  96                non-target child CTE is materialized into TEMP-table shards
  97                (issue #7). Defaults to PostgreSQL's hard 1664-entry limit; lower
  98                it to materialize earlier (advanced / testing).
  99            connection: Optional psycopg connection used only to resolve a
 100                ``role: categorical`` direct variable's vocabulary from its
 101                PostgreSQL ``ENUM`` labels when no ``vocabulary`` is declared in
 102                the config. When omitted, one is opened from ``DATABASE_URL`` /
 103                ``PG*`` if any categorical actually needs it; a declared
 104                vocabulary needs no database (``query`` / ``--show-sql`` stay
 105                DB-free). The connection is never used to scan data.
 106
 107        Raises:
 108            FileNotFoundError: If config file doesn't exist
 109            ValueError: If config is missing required keys or has invalid values,
 110                or a ``role: categorical`` variable has neither a declared
 111                ``vocabulary`` nor an introspectable PostgreSQL ``ENUM``.
 112        """
 113        config = self._load_config(config_file, validate=validate)
 114        self._materialize_threshold = materialize_threshold
 115
 116        self._debug_enabled: bool = debug or self._env_debug_enabled()
 117        if self._debug_enabled:
 118            ic.configureOutput(prefix="[Featurizer] ", includeContext=True)
 119
 120        self.max_depth: int = config["max_depth"]
 121        self.intervals: List[str] = config["intervals"]
 122        # Point-in-time boundary mode: ``inclusive`` (default, ``<=``) keeps an
 123        # event dated exactly on the as_of_date knowable; ``exclusive`` (``<``)
 124        # treats it as not-yet-knowable. Validated in ConfigValidator.
 125        self.as_of_boundary: AsOfBoundary = config.get(
 126            "as_of_boundary", DEFAULT_BOUNDARY
 127        )
 128
 129        # Paired cohort (issue #10): the column of the caller's as_of_dates
 130        # table that holds the target id. Absent -> the dense cohort, every
 131        # target row under every as-of date, rendered exactly as before.
 132        self.cohort_id_column: Optional[str] = (config.get("as_of_dates") or {}).get(
 133            "id_column"
 134        )
 135
 136        self.graph: ERGraph = ERGraph(
 137            config["entities"],
 138            config["relationships"],
 139            config.get("spatial_relationships"),
 140            config.get("graph_relationships"),
 141        )
 142        self.target: Entity = self._get_entity(config["target"])
 143
 144        # Resolve fixed vocabularies for the target's role: categorical direct
 145        # variables (declared list, else introspected ENUM) before planning so
 146        # the planner stays DB-free. Fails loud if a categorical can be neither.
 147        self._resolve_categorical_vocabularies(connection)
 148
 149        # Primitive selection: config may override the active set; an absent or
 150        # null key applies the curated module defaults. An explicit empty list
 151        # suppresses that layer instead: `aggregations: []` builds zero
 152        # aggregation features, and `transformations: []` passes features
 153        # through unchanged — spelled as identity because the transform CTE
 154        # also feeds child aggregations, so a truly empty transformer set
 155        # would drop every feature column. Unknown names raise in get_* (and
 156        # are caught earlier with suggestions by the validator when
 157        # validate=True).
 158        agg_names = config.get("aggregations")
 159        if agg_names is None:
 160            agg_names = DEFAULT_AGGREGATIONS
 161        tx_names = config.get("transformations")
 162        if tx_names is None:
 163            tx_names = DEFAULT_TRANSFORMATIONS
 164        elif not tx_names:
 165            tx_names = ("identity",)
 166        self.aggregations: AggregationRegistry = get_aggregations(agg_names)
 167        self.transformations: TransformationRegistry = get_transformers(tx_names)
 168
 169        planner = FeaturePlanner(
 170            graph=self.graph,
 171            target_alias=self.target.alias,
 172            max_depth=self.max_depth,
 173            intervals=self.intervals,
 174            aggregations=self.aggregations,
 175            transformations=self.transformations,
 176            boundary=self.as_of_boundary,
 177            debug=self._debug_enabled,
 178            cohort_id_column=self.cohort_id_column,
 179        )
 180        self._plan: PlannerResult = planner.plan()
 181
 182        self.features: Dict[str, Set[Feature]] = {
 183            alias: set(features) for alias, features in self._plan.features.items()
 184        }
 185        self.ctes: List[str] = list(self._plan.ctes)
 186        self.joins: Dict[str, List[str]] = {
 187            alias: list(joins) for alias, joins in self._plan.joins.items()
 188        }
 189
 190        self._renderer: SQLRenderer = SQLRenderer()
 191        self._executor: QueryExecutor = QueryExecutor()
 192
 193    def _resolve_categorical_vocabularies(self, connection: Any) -> None:
 194        """Bake a fixed vocabulary onto each target ``role: categorical`` variable.
 195
 196        Declared vocabularies need no database. If any categorical lacks one, a
 197        connection is required to read its PostgreSQL ``ENUM`` labels: the passed
 198        ``connection`` is used, else one is opened from ``DATABASE_URL`` / ``PG*``;
 199        if neither resolves, :func:`resolve_vocabulary` raises a loud, actionable
 200        error. The data is never scanned for distinct values (split-blind).
 201        """
 202        pending = [
 203            feature
 204            for feature in self.target.features
 205            if isinstance(feature, Variable) and feature.role == ROLE_CATEGORICAL
 206        ]
 207        if not pending:
 208            return
 209
 210        needs_db = any(not var.vocabulary for var in pending)
 211        conn = connection
 212        own_connection: Any = None
 213        if needs_db and conn is None:
 214            own_connection = conn = self._maybe_env_connection()
 215        try:
 216            for var in pending:
 217                var.vocabulary = resolve_vocabulary(var, self.target, conn)
 218        finally:
 219            if own_connection is not None:
 220                own_connection.close()
 221
 222    @staticmethod
 223    def _maybe_env_connection() -> Any:
 224        """A psycopg connection from the environment, or ``None`` if unconfigured.
 225
 226        Mirrors the connection sourcing used by the Arrow/Parquet output paths
 227        (``DATABASE_URL`` / ``PG*``). Returns ``None`` rather than raising when no
 228        database is configured, so the caller can produce the precise
 229        declare-vocabulary-or-ENUM error instead of a generic connection error.
 230        """
 231        from .arrow import default_connection
 232
 233        try:
 234            return default_connection()
 235        except RuntimeError:
 236            return None
 237
 238    # ------------------------------------------------------------------ #
 239    # Public API
 240    # ------------------------------------------------------------------ #
 241
 242    @property
 243    def entities(self) -> Iterable[Entity]:
 244        """Return all entities in the graph."""
 245        return self.graph.entities.values()
 246
 247    @property
 248    def relationships(self) -> List[Any]:
 249        """Return all relationships in the graph."""
 250        return self.graph.relationships
 251
 252    @property
 253    def feature_manifest(self) -> "List[ManifestEntry]":
 254        """Map every output column to its full, untruncated intended name.
 255
 256        One row per output feature column (in output order), each carrying the
 257        rendered ``column`` name, the human-readable ``label`` (recovered even
 258        when the 63-byte identifier cap truncated the column name), a
 259        ``truncated`` flag, the ``kind`` (``one_hot`` | ``variable`` |
 260        ``derived``), the owning ``entity``, and — for one-hot columns — the
 261        ``source_column`` and ``value`` they encode. Useful for human/partner
 262        labels, plot legends, and joining readable names back onto the matrix.
 263        """
 264        from .manifest import build_feature_manifest
 265
 266        return build_feature_manifest(self._plan.target_output_features)
 267
 268    def manifest_dataframe(self) -> "pd.DataFrame":
 269        """The feature manifest as a pandas DataFrame (table / plots / joins)."""
 270        from .manifest import manifest_dataframe
 271
 272        return manifest_dataframe(self.feature_manifest)
 273
 274    def manifest_matching(
 275        self, pattern: str, *, allow_empty: bool = False
 276    ) -> "List[ManifestEntry]":
 277        """Manifest entries whose full ``label`` matches a shell-style glob.
 278
 279        Match against the **label**, never the physical column name: names
 280        longer than PostgreSQL's 63-byte identifier limit are hash-truncated
 281        from the tail, so a pattern targeting an inner fragment
 282        (``*(inspections.kw_*``) misses every truncated column. The label is
 283        the only lossless surface — ``definition`` inherits the parent's
 284        truncation and drops the interval, so windowed variants share one.
 285
 286        Args:
 287            pattern: Glob matched against ``label``. Case-sensitive. ``_`` is a
 288                literal here (unlike SQL ``LIKE`` — see
 289                :func:`~featurizer.manifest.glob_to_like` if you are querying
 290                the persisted manifest table by hand).
 291            allow_empty: Return ``[]`` instead of raising when nothing matches.
 292
 293        Returns:
 294            Matching entries in output order.
 295
 296        Raises:
 297            LookupError: When nothing matches and ``allow_empty`` is False —
 298                a silently empty selection is the failure this helper exists to
 299                prevent. The message suggests near-miss labels.
 300
 301        Example:
 302            >>> f.manifest_matching("*kw_rodent*")[0].column
 303            'CUM_SUM(facilities.MEAN(inspections.ABS(inspections.kw_rod~978dcf98'
 304        """
 305        from .manifest import filter_manifest
 306
 307        return filter_manifest(self.feature_manifest, pattern, allow_empty=allow_empty)
 308
 309    def columns_matching(self, pattern: str, *, allow_empty: bool = False) -> List[str]:
 310        """Physical column names whose full ``label`` matches a glob, in output order.
 311
 312        The projection of :meth:`manifest_matching` onto the rendered column
 313        name — what you put in a ``select`` list, a feature-group definition, or
 314        a DataFrame projection.
 315
 316        Building a triage ``feature_groups.definitions`` entry from physical
 317        names directly will abort the run on every truncated column ("matches no
 318        feature_groups.definitions glob"); resolve it here first::
 319
 320            definitions = {"cardiac": f.columns_matching("*frecuencia_cardiaca*")}
 321
 322        Args:
 323            pattern: Glob matched against ``label``. Case-sensitive.
 324            allow_empty: Return ``[]`` instead of raising when nothing matches.
 325
 326        Returns:
 327            The rendered (possibly truncated) column names, in output order.
 328
 329        Raises:
 330            LookupError: When nothing matches and ``allow_empty`` is False.
 331        """
 332        return [
 333            entry.column
 334            for entry in self.manifest_matching(pattern, allow_empty=allow_empty)
 335        ]
 336
 337    @property
 338    def query(self) -> str:
 339        """Generate the single SQL query for this featurizer configuration.
 340
 341        Raises:
 342            ValueError: If the configuration is too wide to render as one valid
 343                query — the ``<target>_transform`` CTE (or an intermediate CTE)
 344                would exceed PostgreSQL's 1664-entry target-list limit. Use
 345                :attr:`query_groups` / :meth:`to_parquet` / :meth:`to_arrow`
 346                (column-group sharding, issue #7) instead. The matrix is never
 347                silently truncated.
 348        """
 349        sharder = self._sharder()
 350        if not sharder.fits_single_group:
 351            n_groups = sharder.n_groups
 352            raise ValueError(
 353                f"Feature matrix for target '{self.target.alias}' is too wide for a "
 354                "single query: it exceeds PostgreSQL's 1664-entry target-list limit "
 355                f"and partitions into {n_groups} column groups. Use "
 356                "`.query_groups` (group_id -> SQL), `.to_parquet(dir)` (one Parquet "
 357                "per group), or `.to_arrow()` (list of tables); all groups re-join "
 358                "on (as_of_date, id). `.to_arrow`/`.to_parquet`/`.to_dataframe` also "
 359                "materialize any oversized child CTE automatically (issue #7). See "
 360                "docs/adr/0005-column-group-sharding.md."
 361            )
 362        return self._renderer.render(self._plan)
 363
 364    def _sharder(
 365        self, *, max_columns_per_group: int | None = None
 366    ) -> "ColumnGroupSharder":
 367        """A ColumnGroupSharder for this plan honouring ``materialize_threshold``.
 368
 369        ``max_columns_per_group`` overrides the default per-group column cap —
 370        used by :meth:`to_tables` to downshift the partition when the default
 371        cap would produce a heap row over PostgreSQL's ~8160-byte page limit.
 372        """
 373        from .sharding import (
 374            DEFAULT_MAX_COLUMNS_PER_GROUP,
 375            PG_MAX_TARGET_LIST,
 376            ColumnGroupSharder,
 377        )
 378
 379        threshold = (
 380            self._materialize_threshold
 381            if self._materialize_threshold is not None
 382            else PG_MAX_TARGET_LIST
 383        )
 384        return ColumnGroupSharder(
 385            self._plan,
 386            max_columns_per_group=(
 387                max_columns_per_group
 388                if max_columns_per_group is not None
 389                else DEFAULT_MAX_COLUMNS_PER_GROUP
 390            ),
 391            materialize_threshold=threshold,
 392        )
 393
 394    def _grouped(self) -> "GroupedQueries":
 395        """The grouped queries + any temp-table materialization preamble.
 396
 397        A config that fits one valid query short-circuits to a single
 398        ``group_000`` equal to :attr:`query` (no preamble). A wide or
 399        oversized-child config returns the partitioned/rewritten group queries
 400        and, when a child CTE had to be materialized, the preamble on
 401        ``GroupedQueries.materialization`` (issue #7).
 402        """
 403        from collections import OrderedDict
 404
 405        from .sharding import GroupedQueries
 406
 407        sharder = self._sharder()
 408        sharder.warn_oversized()
 409        sharder.warn_plan_size()  # pre-flight PG planner-blowup prediction
 410        if sharder.fits_single_group:
 411            return GroupedQueries(
 412                queries=OrderedDict([("group_000", self._renderer.render(self._plan))]),
 413                key_columns=sharder.key_columns,
 414                fits_single=True,
 415                materialization=None,
 416            )
 417        return sharder.build()
 418
 419    def _grouped_for_tables(
 420        self,
 421    ) -> "Tuple[GroupedQueries, OrderedDict[str, List[str]]]":
 422        """Grouped queries for :meth:`to_tables` + the matching column→group map.
 423
 424        The CTAS path differs from the SELECT/fetch paths in one way: each
 425        group becomes a heap *table*, and a heap tuple must fit one 8 KiB page
 426        (~8160 bytes). A group that is a perfectly valid query (≤1664 output
 427        columns) can still fail ``create table … as`` with ``row is too big``
 428        once ~1000+ mostly fixed-width feature columns land on a page.
 429        Pre-flight: estimate every group's row width
 430        (:func:`~featurizer.sharding.estimate_heap_row_width` — 8 bytes per
 431        column + header + null bitmap); when any group exceeds
 432        :data:`~featurizer.sharding.HEAP_ROW_BUDGET_BYTES`, re-partition with a
 433        per-group cap sized to the budget instead of letting PostgreSQL fail.
 434
 435        The returned mapping is the exact partition the queries were rendered
 436        from, so the manifest's ``feature_group`` tags always name a
 437        really-persisted table (see :meth:`_write_manifest_table`).
 438        """
 439        from collections import OrderedDict
 440
 441        from .sharding import (
 442            HEAP_ROW_BUDGET_BYTES,
 443            GroupedQueries,
 444            estimate_heap_row_width,
 445            max_heap_safe_columns,
 446        )
 447
 448        sharder = self._sharder()
 449        sharder.warn_oversized()
 450        sharder.warn_plan_size()
 451        n_keys = len(sharder.key_columns)
 452        groups = sharder.column_groups()
 453        oversized = {
 454            gid: estimate_heap_row_width(len(cols) + n_keys)
 455            for gid, cols in groups.items()
 456            if estimate_heap_row_width(len(cols) + n_keys) > HEAP_ROW_BUDGET_BYTES
 457        }
 458        if oversized:
 459            cap = max_heap_safe_columns(n_keys)
 460            worst_gid, worst_width = max(oversized.items(), key=lambda kv: kv[1])
 461            logger.warning(
 462                "to_tables heap-row pre-flight: {} of {} column group(s) "
 463                "estimate over the {}-byte page budget (worst: {} ≈ {} bytes; "
 464                "PostgreSQL rejects heap rows over ~8160 bytes with 'row is "
 465                "too big'). Re-partitioning with max {} feature columns per "
 466                "group — more, narrower tables; SELECT/fetch paths are "
 467                "unaffected.",
 468                len(oversized),
 469                len(groups),
 470                HEAP_ROW_BUDGET_BYTES,
 471                worst_gid,
 472                worst_width,
 473                cap,
 474            )
 475            sharder = self._sharder(max_columns_per_group=cap)
 476            groups = sharder.column_groups()
 477
 478        if sharder.fits_single_group and len(groups) == 1:
 479            # Same short-circuit as _grouped(): one group, the plain query.
 480            grouped = GroupedQueries(
 481                queries=OrderedDict([("group_000", self._renderer.render(self._plan))]),
 482                key_columns=sharder.key_columns,
 483                fits_single=True,
 484                materialization=None,
 485            )
 486        else:
 487            # NOTE: also taken when the config fits a single *query* but
 488            # partitions into >1 groups (e.g. >cap output columns under 1664,
 489            # or the heap downshift above) — the persisted tables must follow
 490            # the partition or the manifest's feature_group tags would name
 491            # tables that were never written.
 492            grouped = sharder.build()
 493        return grouped, groups
 494
 495    @property
 496    def query_groups(self) -> "OrderedDict[str, str]":
 497        """SQL for each column group: ``group_<NNN>`` -> self-contained query.
 498
 499        Partitions the (possibly very wide) feature matrix into ordered column
 500        groups, each a valid query whose every CTE tuple is under PostgreSQL's
 501        1664-entry limit (issue #7). A config that fits in one query returns a
 502        single ``group_000`` entry equal to :attr:`query`. Every group leads
 503        with ``(as_of_date, <target id>)`` so the groups re-join into the full
 504        matrix.
 505
 506        When an oversized non-target child CTE had to be materialized, these
 507        queries reference TEMP-table shards and **presuppose**
 508        :attr:`materialization_ddl` was executed first on the same session;
 509        :meth:`to_arrow` / :meth:`to_parquet` do that automatically.
 510        """
 511        return self._grouped().queries
 512
 513    @property
 514    def materialization_ddl(self) -> List[str]:
 515        """The ``CREATE TEMP TABLE`` preamble (issue #7) that :attr:`query_groups`
 516        presupposes, or ``[]`` when no oversized child CTE needs materializing.
 517
 518        Run these statements on the same connection/session before executing the
 519        grouped queries. :meth:`to_arrow` / :meth:`to_parquet` / :meth:`to_dataframe`
 520        run them for you.
 521        """
 522        mplan = self._grouped().materialization
 523        return list(mplan.ddl) if mplan is not None else []
 524
 525    def to_dataframe(
 526        self, *, connection: Any = None, impute: bool = False, **impute_kwargs: Any
 527    ) -> pd.DataFrame:
 528        """Execute the query and return results as a DataFrame.
 529
 530        Args:
 531            connection: An open psycopg connection to run on (required when the
 532                query references session ``TEMP`` tables — the integration
 533                harness). When ``None``, the fast single-query path uses
 534                ``records`` and the grouped/materialized path builds its own
 535                connection from the environment and closes it afterwards.
 536            impute: When True, run the opt-in imputation pass (count-like
 537                features → 0, measures left NULL unless ``measure_strategy`` is
 538                given, with ``<feature>__missing`` indicator columns). The
 539                default keeps the raw NULLs, since missingness is signal.
 540            **impute_kwargs: Forwarded to
 541                :func:`featurizer.imputation.impute_features`.
 542
 543        A config that fits one valid query uses the fast (single-query) path; a
 544        wide or oversized-child config (issue #7) runs the column-group queries —
 545        and any TEMP-table materialization preamble — on one connection and
 546        re-joins them on ``(as_of_date, target_id)`` into the same indexed frame.
 547        Passing ``connection`` forces the one-connection path (so it can see
 548        session TEMP tables) regardless of width.
 549
 550        Returns:
 551            DataFrame indexed by ['as_of_date', target_id]
 552
 553        Raises:
 554            ValueError: If target entity doesn't define a primary ID
 555        """
 556        if self.target.id is None:
 557            raise ValueError(
 558                f"Target entity '{self.target.alias}' does not define a primary id."
 559            )
 560        grouped = self._grouped()
 561        if grouped.fits_single and connection is None:
 562            df = self._executor.to_dataframe(self.query, self.target.id.name)
 563        else:
 564            df = self._executor.to_dataframe_materialized(
 565                preamble_ddl=(
 566                    grouped.materialization.ddl
 567                    if grouped.materialization is not None
 568                    else []
 569                ),
 570                group_queries=grouped.queries,
 571                target_id=self.target.id.name,
 572                connection=connection,
 573                key_columns=grouped.key_columns,
 574            )
 575        if impute:
 576            from .imputation import guard_full_matrix_fit, impute_features
 577
 578            # Engine path: this fits over the whole returned matrix, so gate the
 579            # leaky measure strategies (ADR-0001). The pure impute_features helper
 580            # stays ungated for callers that pre-split their own data.
 581            guard_full_matrix_fit(
 582                impute_kwargs.get("measure_strategy", "none"),
 583                allow_full_matrix_fit=bool(
 584                    impute_kwargs.pop("allow_full_matrix_fit", False)
 585                ),
 586                caller="to_dataframe",
 587            )
 588            df = impute_features(df, **impute_kwargs)
 589        return df
 590
 591    def to_arrow(
 592        self,
 593        *,
 594        connection: Any = None,
 595        numeric_as_float: bool = True,
 596        impute: bool = False,
 597        **impute_kwargs: Any,
 598    ) -> "Any":
 599        """Execute the query and return Arrow output, no pandas hop.
 600
 601        Streams the result out of PostgreSQL with binary ``COPY`` and decodes it
 602        column-by-column into Arrow, so SQL NULLs are preserved as Arrow nulls
 603        (never coerced to ``NaN``) and the full result set never materializes as
 604        a pandas frame. ``as_of_date`` and the target id are ordinary leading
 605        columns (no index), unlike :meth:`to_dataframe`.
 606
 607        Sharding (issue #7): when the matrix fits in one query a single
 608        :class:`pyarrow.Table` is returned. When it is too wide for a single
 609        valid query (over PostgreSQL's 1664-entry target-list limit), an
 610        ``OrderedDict[str, pyarrow.Table]`` of column groups is returned instead
 611        — ``group_<NNN>`` -> table. Every group table leads with
 612        ``(as_of_date, <target id>)`` and the groups re-join on those keys to
 613        reconstruct the full matrix.
 614
 615        Args:
 616            connection: An open psycopg connection to run ``COPY`` on. Required
 617                when the rendered query references session ``TEMP`` tables (the
 618                integration harness). When ``None``, a connection is built from
 619                ``DATABASE_URL`` / ``PG*`` and closed afterwards. A single
 620                connection is reused across all groups.
 621            numeric_as_float: Cast PostgreSQL ``numeric`` aggregate columns to
 622                ``float64`` (ML-ready, ``to_dataframe``-comparable). Set ``False``
 623                to keep exact ``decimal128``. NULLs are preserved either way.
 624            impute: When True, apply the Arrow-native imputation pass
 625                (:func:`featurizer.imputation.impute_arrow`) to each group:
 626                count-like features → 0, measures left null unless
 627                ``measure_strategy`` is given, with stable ``<feature>__missing``
 628                indicator columns. ``as_of_date`` and the target id are passed as
 629                ``key_columns`` and left untouched.
 630            **impute_kwargs: Forwarded to ``impute_arrow``. ``measure_strategy`` in
 631                ``{"mean","median"}`` additionally requires
 632                ``allow_full_matrix_fit=True`` (ADR-0001 leakage gate).
 633
 634        Returns:
 635            A ``pyarrow.Table`` for a single-group config, otherwise an
 636            ``OrderedDict[str, pyarrow.Table]`` keyed by group id.
 637
 638        Raises:
 639            ImportError: If pyarrow (the ``[parquet]`` extra) is not installed.
 640            ValueError: If the target entity does not define a primary id, or a
 641                leaky measure strategy is requested without the opt-in.
 642        """
 643        if self.target.id is None:
 644            raise ValueError(
 645                f"Target entity '{self.target.alias}' does not define a primary id."
 646            )
 647        groups = self._arrow_groups(
 648            connection=connection,
 649            numeric_as_float=numeric_as_float,
 650            impute=impute,
 651            **impute_kwargs,
 652        )
 653        if len(groups) == 1:
 654            # Single-group config: preserve the original single-Table contract.
 655            return next(iter(groups.values()))
 656        return groups
 657
 658    def to_parquet(
 659        self,
 660        path: str,
 661        *,
 662        connection: Any = None,
 663        numeric_as_float: bool = True,
 664        impute: bool = False,
 665        **impute_kwargs: Any,
 666    ) -> None:
 667        """Execute the query and write the result to Parquet.
 668
 669        Thin wrapper over the Arrow path plus ``pyarrow.parquet.write_table``;
 670        all arguments (including the imputation contract and its ADR-0001 leakage
 671        gate) behave exactly as in :meth:`to_arrow`. NULLs are written as Parquet
 672        nulls.
 673
 674        Sharding (issue #7): when the matrix fits in one query, a single Parquet
 675        file is written at ``path``. When it is too wide for one valid query,
 676        ``path`` is treated as a **directory** and one Parquet file per column
 677        group is written under it as ``group_<NNN>.parquet``. All group files
 678        re-join on ``(as_of_date, <target id>)`` to reconstruct the full matrix.
 679
 680        Args:
 681            path: Destination ``.parquet`` file (single group) or output
 682                directory (multiple groups; created if absent).
 683            connection: See :meth:`to_arrow`.
 684            numeric_as_float: See :meth:`to_arrow`.
 685            impute: See :meth:`to_arrow`.
 686            **impute_kwargs: See :meth:`to_arrow`.
 687
 688        Raises:
 689            ImportError: If pyarrow (the ``[parquet]`` extra) is not installed.
 690        """
 691        import pyarrow.parquet as pq  # pyright: ignore[reportMissingImports]
 692
 693        groups = self._arrow_groups(
 694            connection=connection,
 695            numeric_as_float=numeric_as_float,
 696            impute=impute,
 697            **impute_kwargs,
 698        )
 699        if len(groups) == 1:
 700            pq.write_table(next(iter(groups.values())), path)
 701            return
 702
 703        import os as _os
 704
 705        _os.makedirs(path, exist_ok=True)
 706        for gid, table in groups.items():
 707            pq.write_table(table, _os.path.join(path, f"{gid}.parquet"))
 708        logger.info(
 709            "Wrote {} column-group Parquet files to {} (re-join on {}).",
 710            len(groups),
 711            path,
 712            ("as_of_date", self.target.id.name) if self.target.id else "(as_of_date,)",
 713        )
 714
 715    def to_tables(
 716        self,
 717        schema: str,
 718        *,
 719        connection: Any = None,
 720        table_prefix: str | None = None,
 721        create_schema: bool = True,
 722    ) -> List["FeatureGroupTable"]:
 723        """Persist the feature matrix as triage-style feature-group tables.
 724
 725        Writes each column group as a persistent table
 726        ``"<schema>"."<stem>_group_<NNN>"`` keyed on ``(as_of_date, <target id>)``,
 727        the feature-group contract triage-pg consumes (issue #7). A config that
 728        fits one query writes a single ``<stem>_group_000``; a wide or
 729        oversized-child config writes one table per column group, all re-joinable
 730        on the keys. The issue-#7 intermediate shards stay ephemeral ``TEMP``
 731        tables — only the final groups are persisted.
 732
 733        Idempotent: each target table is ``DROP TABLE IF EXISTS`` + ``CREATE TABLE
 734        … AS`` so a re-run replaces it cleanly.
 735
 736        Alongside the group tables, the feature manifest is persisted as
 737        ``"<schema>"."<stem>_manifest"`` — one row per output column (label,
 738        lineage, generated description, and the ``feature_group`` it landed
 739        in), joinable to the group tables by column name. The returned list
 740        contains the feature-group tables only, as before.
 741
 742        Args:
 743            schema: Destination schema (created if absent unless
 744                ``create_schema=False``).
 745            connection: An open psycopg connection to write on. When supplied the
 746                caller owns the transaction (nothing is committed here — the
 747                integration harness verifies within its rolled-back transaction);
 748                when ``None`` a connection is built from the environment, committed
 749                so the tables persist, and closed.
 750            table_prefix: Table-name stem; defaults to the target alias
 751                (``stores`` -> ``stores_group_000``).
 752            create_schema: Run ``CREATE SCHEMA IF NOT EXISTS`` first.
 753
 754        Returns:
 755            The ordered manifest of created :class:`FeatureGroupTable`s.
 756
 757        Raises:
 758            ValueError: If the target entity does not define a primary id.
 759        """
 760        if self.target.id is None:
 761            raise ValueError(
 762                f"Target entity '{self.target.alias}' does not define a primary id."
 763            )
 764        from .arrow import default_connection
 765        from .sharding import FeatureGroupTable
 766
 767        grouped, column_groups = self._grouped_for_tables()
 768        preamble = (
 769            grouped.materialization.ddl if grouped.materialization is not None else []
 770        )
 771        stem = table_prefix or self.target.alias
 772        keys = list(grouped.key_columns)
 773
 774        own_connection = connection is None
 775        conn = connection if connection is not None else default_connection()
 776        tables: List["FeatureGroupTable"] = []
 777        try:
 778            # jit off from the preamble on (issue #53); a caller's value is put
 779            # back before their transaction goes on.
 780            with jit_disabled(conn):
 781                with conn.cursor() as cur:
 782                    if create_schema:
 783                        cur.execute(f'create schema if not exists "{schema}"')
 784                    for ddl in preamble:
 785                        cur.execute(ddl)
 786                analyze_as_of_dates(conn)  # planner-stats optimization (see executor)
 787                if own_connection:  # PLANNER_TUNING never reaches a caller's
 788                    apply_planner_tuning(conn)
 789                with conn.cursor() as cur:
 790                    for gid, sql in grouped.queries.items():
 791                        name = f'"{schema}"."{stem}_{gid}"'
 792                        cur.execute(f"drop table if exists {name}")
 793                        cur.execute(f"create table {name} as\n{sql}")
 794                        tables.append(
 795                            FeatureGroupTable(
 796                                name=name, group=gid, key_columns=list(keys)
 797                            )
 798                        )
 799                    self._write_manifest_table(cur, schema, stem, column_groups)
 800            if own_connection:
 801                conn.commit()
 802        finally:
 803            if own_connection:
 804                conn.close()
 805        logger.info(
 806            "Persisted {} feature-group table(s) + manifest to schema {!r} "
 807            "(re-join on {}).",
 808            len(tables),
 809            schema,
 810            tuple(keys),
 811        )
 812        return tables
 813
 814    def _write_manifest_table(
 815        self,
 816        cur: Any,
 817        schema: str,
 818        stem: str,
 819        column_groups: "OrderedDict[str, List[str]]",
 820    ) -> None:
 821        """Persist the feature manifest as ``"<schema>"."<stem>_manifest"``.
 822
 823        One row per output feature column, including which feature-group table
 824        the column landed in (``feature_group``, joinable back to the
 825        ``<stem>_group_<NNN>`` tables by column name). Same contracts as the
 826        group tables: idempotent DROP+CREATE, and the caller owns the
 827        transaction. Values are inserted parameterized — labels and definitions
 828        contain quotes and arbitrary SQL text.
 829
 830        ``column_groups`` is the exact partition the group tables were written
 831        from (:meth:`_grouped_for_tables`) — never a freshly computed one, so
 832        the ``feature_group`` tags cannot drift from the persisted tables. A
 833        manifest column absent from the partition raises rather than being
 834        silently mis-tagged: triage consumes this table for lineage, and a
 835        wrong ``feature_group`` corrupts it downstream.
 836        """
 837        column_to_group: Dict[str, str] = {}
 838        for gid, names in column_groups.items():
 839            for column_name in names:
 840                column_to_group[column_name.replace('"', "")] = gid
 841
 842        orphaned = [
 843            entry.column
 844            for entry in self.feature_manifest
 845            if entry.column not in column_to_group
 846        ]
 847        if orphaned:
 848            raise RuntimeError(
 849                f"Feature manifest column(s) map to no column group: "
 850                f"{orphaned[:5]!r}{' …' if len(orphaned) > 5 else ''} "
 851                f"(available groups: {', '.join(column_groups) or '(none)'}). "
 852                "Persisting would mis-tag feature_group in "
 853                f'"{schema}"."{stem}_manifest" and corrupt lineage for '
 854                "downstream consumers (triage joins on it). This is a "
 855                "featurizer bug — the manifest and the column partition must "
 856                "describe the same plan; please report it with your config."
 857            )
 858
 859        name = f'"{schema}"."{stem}_manifest"'
 860        cur.execute(f"drop table if exists {name}")
 861        cur.execute(f"""
 862            create table {name} (
 863                "column_name"   text not null,
 864                "label"         text not null,
 865                "truncated"     boolean not null,
 866                "kind"          text not null,
 867                "entity"        text,
 868                "source_alias"  text,
 869                "depth"         integer not null,
 870                "parents"       text[] not null,
 871                "interval"      text,
 872                "source_column" text,
 873                "value"         text,
 874                "description"   text not null,
 875                "definition"    text,
 876                "feature_group" text not null
 877            )
 878            """)
 879        rows = [
 880            (
 881                entry.column,
 882                entry.label,
 883                entry.truncated,
 884                entry.kind,
 885                entry.entity,
 886                entry.source_alias,
 887                entry.depth,
 888                entry.parents,
 889                entry.interval,
 890                entry.source_column,
 891                entry.value,
 892                entry.description,
 893                entry.definition,
 894                column_to_group[entry.column],
 895            )
 896            for entry in self.feature_manifest
 897        ]
 898        if rows:
 899            cur.executemany(
 900                f"insert into {name} values "
 901                "(%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)",
 902                rows,
 903            )
 904
 905    def _arrow_groups(
 906        self,
 907        *,
 908        connection: Any = None,
 909        numeric_as_float: bool = True,
 910        impute: bool = False,
 911        **impute_kwargs: Any,
 912    ) -> "OrderedDict[str, Any]":
 913        """Run every column group through the Arrow exporter on one connection.
 914
 915        Always returns an ``OrderedDict`` (a single-group config yields one
 916        entry). Imputation, when requested, runs per group with the same
 917        ADR-0001 leakage gate as the single-query path; the gate is checked once
 918        up front so a leaky strategy is refused before any query runs.
 919        """
 920        from .arrow import ArrowExporter
 921
 922        if impute:
 923            from .imputation import guard_full_matrix_fit
 924
 925            guard_full_matrix_fit(
 926                impute_kwargs.get("measure_strategy", "none"),
 927                allow_full_matrix_fit=bool(impute_kwargs.get("allow_full_matrix_fit")),
 928                caller="to_arrow",
 929            )
 930
 931        # Grouped queries + any TEMP-table materialization preamble (issue #7).
 932        # The single-group short-circuit + oversized warning live in _grouped().
 933        grouped = self._grouped()
 934        preamble = (
 935            grouped.materialization.ddl if grouped.materialization is not None else []
 936        )
 937
 938        exporter = ArrowExporter()
 939        own_connection = connection is None
 940        conn = connection if connection is not None else exporter.open_connection()
 941        try:
 942            from collections import OrderedDict as _OrderedDict
 943
 944            # Run the CREATE TEMP TABLE preamble first, on the *same* connection,
 945            # so the group queries' shard references resolve. The connection is
 946            # non-autocommit (default_connection / the harness), so ON COMMIT DROP
 947            # shards live for the whole transaction and drop when it closes.
 948            tables: "OrderedDict[str, Any]" = _OrderedDict()
 949            # jit off from the preamble on (issue #53); a caller's value is put
 950            # back before their transaction goes on.
 951            with jit_disabled(conn):
 952                if preamble:
 953                    with conn.cursor() as cur:
 954                        for ddl in preamble:
 955                            cur.execute(ddl)
 956                analyze_as_of_dates(conn)  # planner-stats optimization (see executor)
 957                if own_connection:  # PLANNER_TUNING never reaches a caller's
 958                    apply_planner_tuning(conn)
 959
 960                for gid, sql in grouped.queries.items():
 961                    table = exporter.to_arrow(
 962                        sql, connection=conn, numeric_as_float=numeric_as_float
 963                    )
 964                    if impute:
 965                        table = self._impute_group(table, **impute_kwargs)
 966                    tables[gid] = table
 967            return tables
 968        finally:
 969            if own_connection:
 970                conn.close()
 971
 972    def _impute_group(self, table: Any, **impute_kwargs: Any) -> Any:
 973        """Apply the Arrow imputation pass to one group's table.
 974
 975        The leakage gate is already checked in :meth:`_arrow_groups`; strip the
 976        consumed ``allow_full_matrix_fit`` flag so ``impute_arrow`` does not see
 977        an unexpected keyword.
 978        """
 979        from .imputation import impute_arrow
 980
 981        kwargs = dict(impute_kwargs)
 982        kwargs.pop("allow_full_matrix_fit", None)
 983        assert self.target.id is not None  # guarded by callers
 984        return impute_arrow(
 985            table,
 986            key_columns=("as_of_date", self.target.id.name),
 987            **kwargs,
 988        )
 989
 990    # ------------------------------------------------------------------ #
 991    # Internal helpers
 992    # ------------------------------------------------------------------ #
 993
 994    def _get_entity(self, alias: str) -> Entity:
 995        """Get an entity by alias from the graph.
 996
 997        Args:
 998            alias: Entity alias to look up
 999
1000        Returns:
1001            Entity with the given alias
1002
1003        Raises:
1004            ValueError: If entity with alias doesn't exist
1005        """
1006        entity = self.graph.entities.get(alias)
1007        if entity is None:
1008            raise ValueError(f"Unknown target entity alias '{alias}'.")
1009        return entity
1010
1011    @staticmethod
1012    def _env_debug_enabled() -> bool:
1013        """Check if debug mode is enabled via environment variable."""
1014        value = os.getenv("FEATURIZER_DEBUG", "")
1015        return value.lower() in {"1", "true", "yes", "on"}
1016
1017    @staticmethod
1018    def _load_config(config_file: str, validate: bool = True) -> Dict[str, Any]:
1019        """Load and validate configuration from YAML file.
1020
1021        Args:
1022            config_file: Path to YAML configuration file
1023            validate: Run enhanced validation checks
1024
1025        Returns:
1026            Validated configuration dictionary
1027
1028        Raises:
1029            FileNotFoundError: If config file doesn't exist
1030            ValueError: If config is invalid or missing required keys
1031        """
1032        try:
1033            with open(config_file) as f:
1034                config = yaml.safe_load(f) or {}
1035        except FileNotFoundError as exc:
1036            raise FileNotFoundError(f"Config file not found: {config_file}") from exc
1037        except yaml.YAMLError as exc:
1038            raise ValueError(f"Invalid YAML in config file {config_file}") from exc
1039
1040        # Run enhanced validation if enabled
1041        if validate:
1042            validator = ConfigValidator(mode="strict")
1043            result = validator.validate(config)
1044
1045            if not result.is_valid:
1046                raise ValueError(
1047                    f"Configuration validation failed:\n{result.format_errors()}"
1048                )
1049
1050            # Log warnings
1051            for warning in result.warnings:
1052                location = f"[{warning.location}] " if warning.location else ""
1053                logger.warning(f"{location}{warning.message}")
1054
1055        # Backwards compatibility: Basic validation
1056        required_keys = {"target", "max_depth", "intervals", "entities"}
1057        missing = [key for key in required_keys if key not in config]
1058        if missing:
1059            raise ValueError(f"Config missing required keys: {', '.join(missing)}")
1060
1061        if not isinstance(config["target"], str) or not config["target"].strip():
1062            raise ValueError("'target' must be a non-empty string.")
1063
1064        if not isinstance(config["max_depth"], int) or config["max_depth"] < 1:
1065            raise ValueError("'max_depth' must be a positive integer.")
1066
1067        if not isinstance(config["entities"], list) or not config["entities"]:
1068            raise ValueError("Config must declare at least one entity in 'entities'.")
1069
1070        if not isinstance(config["intervals"], list):
1071            raise ValueError("'intervals' must be a list of interval strings.")
1072
1073        relationships = config.get("relationships")
1074        if relationships is None:
1075            logger.debug(
1076                "No relationships defined in config; defaulting to empty list."
1077            )
1078            config["relationships"] = []
1079        elif not isinstance(relationships, list):
1080            raise ValueError("'relationships' must be a list when provided.")
1081
1082        return config

PostgreSQL implementation of the DFS algorithm (adapted for temporal data sets).

Coordinates configuration loading, feature planning, SQL rendering, and optional database execution.

Featurizer( config_file: str, *, debug: bool = False, validate: bool = True, materialize_threshold: int | None = None, connection: Any = None)
 80    def __init__(
 81        self,
 82        config_file: str,
 83        *,
 84        debug: bool = False,
 85        validate: bool = True,
 86        materialize_threshold: int | None = None,
 87        connection: Any = None,
 88    ) -> None:
 89        """Initialize Featurizer from a YAML configuration file.
 90
 91        Args:
 92            config_file: Path to YAML configuration file
 93            debug: Enable debug logging with icecream. Can also be set via FEATURIZER_DEBUG env var.
 94            validate: Run enhanced validation checks (default: True)
 95            materialize_threshold: Column width above which an oversized
 96                non-target child CTE is materialized into TEMP-table shards
 97                (issue #7). Defaults to PostgreSQL's hard 1664-entry limit; lower
 98                it to materialize earlier (advanced / testing).
 99            connection: Optional psycopg connection used only to resolve a
100                ``role: categorical`` direct variable's vocabulary from its
101                PostgreSQL ``ENUM`` labels when no ``vocabulary`` is declared in
102                the config. When omitted, one is opened from ``DATABASE_URL`` /
103                ``PG*`` if any categorical actually needs it; a declared
104                vocabulary needs no database (``query`` / ``--show-sql`` stay
105                DB-free). The connection is never used to scan data.
106
107        Raises:
108            FileNotFoundError: If config file doesn't exist
109            ValueError: If config is missing required keys or has invalid values,
110                or a ``role: categorical`` variable has neither a declared
111                ``vocabulary`` nor an introspectable PostgreSQL ``ENUM``.
112        """
113        config = self._load_config(config_file, validate=validate)
114        self._materialize_threshold = materialize_threshold
115
116        self._debug_enabled: bool = debug or self._env_debug_enabled()
117        if self._debug_enabled:
118            ic.configureOutput(prefix="[Featurizer] ", includeContext=True)
119
120        self.max_depth: int = config["max_depth"]
121        self.intervals: List[str] = config["intervals"]
122        # Point-in-time boundary mode: ``inclusive`` (default, ``<=``) keeps an
123        # event dated exactly on the as_of_date knowable; ``exclusive`` (``<``)
124        # treats it as not-yet-knowable. Validated in ConfigValidator.
125        self.as_of_boundary: AsOfBoundary = config.get(
126            "as_of_boundary", DEFAULT_BOUNDARY
127        )
128
129        # Paired cohort (issue #10): the column of the caller's as_of_dates
130        # table that holds the target id. Absent -> the dense cohort, every
131        # target row under every as-of date, rendered exactly as before.
132        self.cohort_id_column: Optional[str] = (config.get("as_of_dates") or {}).get(
133            "id_column"
134        )
135
136        self.graph: ERGraph = ERGraph(
137            config["entities"],
138            config["relationships"],
139            config.get("spatial_relationships"),
140            config.get("graph_relationships"),
141        )
142        self.target: Entity = self._get_entity(config["target"])
143
144        # Resolve fixed vocabularies for the target's role: categorical direct
145        # variables (declared list, else introspected ENUM) before planning so
146        # the planner stays DB-free. Fails loud if a categorical can be neither.
147        self._resolve_categorical_vocabularies(connection)
148
149        # Primitive selection: config may override the active set; an absent or
150        # null key applies the curated module defaults. An explicit empty list
151        # suppresses that layer instead: `aggregations: []` builds zero
152        # aggregation features, and `transformations: []` passes features
153        # through unchanged — spelled as identity because the transform CTE
154        # also feeds child aggregations, so a truly empty transformer set
155        # would drop every feature column. Unknown names raise in get_* (and
156        # are caught earlier with suggestions by the validator when
157        # validate=True).
158        agg_names = config.get("aggregations")
159        if agg_names is None:
160            agg_names = DEFAULT_AGGREGATIONS
161        tx_names = config.get("transformations")
162        if tx_names is None:
163            tx_names = DEFAULT_TRANSFORMATIONS
164        elif not tx_names:
165            tx_names = ("identity",)
166        self.aggregations: AggregationRegistry = get_aggregations(agg_names)
167        self.transformations: TransformationRegistry = get_transformers(tx_names)
168
169        planner = FeaturePlanner(
170            graph=self.graph,
171            target_alias=self.target.alias,
172            max_depth=self.max_depth,
173            intervals=self.intervals,
174            aggregations=self.aggregations,
175            transformations=self.transformations,
176            boundary=self.as_of_boundary,
177            debug=self._debug_enabled,
178            cohort_id_column=self.cohort_id_column,
179        )
180        self._plan: PlannerResult = planner.plan()
181
182        self.features: Dict[str, Set[Feature]] = {
183            alias: set(features) for alias, features in self._plan.features.items()
184        }
185        self.ctes: List[str] = list(self._plan.ctes)
186        self.joins: Dict[str, List[str]] = {
187            alias: list(joins) for alias, joins in self._plan.joins.items()
188        }
189
190        self._renderer: SQLRenderer = SQLRenderer()
191        self._executor: QueryExecutor = QueryExecutor()

Initialize Featurizer from a YAML configuration file.

Arguments:
  • config_file: Path to YAML configuration file
  • debug: Enable debug logging with icecream. Can also be set via FEATURIZER_DEBUG env var.
  • validate: Run enhanced validation checks (default: True)
  • materialize_threshold: Column width above which an oversized non-target child CTE is materialized into TEMP-table shards (issue #7). Defaults to PostgreSQL's hard 1664-entry limit; lower it to materialize earlier (advanced / testing).
  • connection: Optional psycopg connection used only to resolve a role: categorical direct variable's vocabulary from its PostgreSQL ENUM labels when no vocabulary is declared in the config. When omitted, one is opened from DATABASE_URL / PG* if any categorical actually needs it; a declared vocabulary needs no database (query / --show-sql stay DB-free). The connection is never used to scan data.
Raises:
  • FileNotFoundError: If config file doesn't exist
  • ValueError: If config is missing required keys or has invalid values, or a role: categorical variable has neither a declared vocabulary nor an introspectable PostgreSQL ENUM.
max_depth: int
intervals: List[str]
as_of_boundary: Literal['inclusive', 'exclusive']
cohort_id_column: Optional[str]
aggregations: Dict[str, Callable[..., Any]]
transformations: Dict[str, Callable[..., Any]]
features: Dict[str, Set[featurizer.primitives.abstractions.Feature]]
ctes: List[str]
joins: Dict[str, List[str]]
entities: Iterable[featurizer.primitives.abstractions.Entity]
242    @property
243    def entities(self) -> Iterable[Entity]:
244        """Return all entities in the graph."""
245        return self.graph.entities.values()

Return all entities in the graph.

relationships: List[Any]
247    @property
248    def relationships(self) -> List[Any]:
249        """Return all relationships in the graph."""
250        return self.graph.relationships

Return all relationships in the graph.

feature_manifest: List[featurizer.manifest.ManifestEntry]
252    @property
253    def feature_manifest(self) -> "List[ManifestEntry]":
254        """Map every output column to its full, untruncated intended name.
255
256        One row per output feature column (in output order), each carrying the
257        rendered ``column`` name, the human-readable ``label`` (recovered even
258        when the 63-byte identifier cap truncated the column name), a
259        ``truncated`` flag, the ``kind`` (``one_hot`` | ``variable`` |
260        ``derived``), the owning ``entity``, and — for one-hot columns — the
261        ``source_column`` and ``value`` they encode. Useful for human/partner
262        labels, plot legends, and joining readable names back onto the matrix.
263        """
264        from .manifest import build_feature_manifest
265
266        return build_feature_manifest(self._plan.target_output_features)

Map every output column to its full, untruncated intended name.

One row per output feature column (in output order), each carrying the rendered column name, the human-readable label (recovered even when the 63-byte identifier cap truncated the column name), a truncated flag, the kind (one_hot | variable | derived), the owning entity, and — for one-hot columns — the source_column and value they encode. Useful for human/partner labels, plot legends, and joining readable names back onto the matrix.

def manifest_dataframe(self) -> pandas.core.frame.DataFrame:
268    def manifest_dataframe(self) -> "pd.DataFrame":
269        """The feature manifest as a pandas DataFrame (table / plots / joins)."""
270        from .manifest import manifest_dataframe
271
272        return manifest_dataframe(self.feature_manifest)

The feature manifest as a pandas DataFrame (table / plots / joins).

def manifest_matching( self, pattern: str, *, allow_empty: bool = False) -> List[featurizer.manifest.ManifestEntry]:
274    def manifest_matching(
275        self, pattern: str, *, allow_empty: bool = False
276    ) -> "List[ManifestEntry]":
277        """Manifest entries whose full ``label`` matches a shell-style glob.
278
279        Match against the **label**, never the physical column name: names
280        longer than PostgreSQL's 63-byte identifier limit are hash-truncated
281        from the tail, so a pattern targeting an inner fragment
282        (``*(inspections.kw_*``) misses every truncated column. The label is
283        the only lossless surface — ``definition`` inherits the parent's
284        truncation and drops the interval, so windowed variants share one.
285
286        Args:
287            pattern: Glob matched against ``label``. Case-sensitive. ``_`` is a
288                literal here (unlike SQL ``LIKE`` — see
289                :func:`~featurizer.manifest.glob_to_like` if you are querying
290                the persisted manifest table by hand).
291            allow_empty: Return ``[]`` instead of raising when nothing matches.
292
293        Returns:
294            Matching entries in output order.
295
296        Raises:
297            LookupError: When nothing matches and ``allow_empty`` is False —
298                a silently empty selection is the failure this helper exists to
299                prevent. The message suggests near-miss labels.
300
301        Example:
302            >>> f.manifest_matching("*kw_rodent*")[0].column
303            'CUM_SUM(facilities.MEAN(inspections.ABS(inspections.kw_rod~978dcf98'
304        """
305        from .manifest import filter_manifest
306
307        return filter_manifest(self.feature_manifest, pattern, allow_empty=allow_empty)

Manifest entries whose full label matches a shell-style glob.

Match against the label, never the physical column name: names longer than PostgreSQL's 63-byte identifier limit are hash-truncated from the tail, so a pattern targeting an inner fragment (*(inspections.kw_*) misses every truncated column. The label is the only lossless surface — definition inherits the parent's truncation and drops the interval, so windowed variants share one.

Arguments:
  • pattern: Glob matched against label. Case-sensitive. _ is a literal here (unlike SQL LIKE — see ~featurizer.manifest.glob_to_like() if you are querying the persisted manifest table by hand).
  • allow_empty: Return [] instead of raising when nothing matches.
Returns:

Matching entries in output order.

Raises:
  • LookupError: When nothing matches and allow_empty is False — a silently empty selection is the failure this helper exists to prevent. The message suggests near-miss labels.
Example:
>>> f.manifest_matching("*kw_rodent*")[0].column
'CUM_SUM(facilities.MEAN(inspections.ABS(inspections.kw_rod~978dcf98'
def columns_matching(self, pattern: str, *, allow_empty: bool = False) -> List[str]:
309    def columns_matching(self, pattern: str, *, allow_empty: bool = False) -> List[str]:
310        """Physical column names whose full ``label`` matches a glob, in output order.
311
312        The projection of :meth:`manifest_matching` onto the rendered column
313        name — what you put in a ``select`` list, a feature-group definition, or
314        a DataFrame projection.
315
316        Building a triage ``feature_groups.definitions`` entry from physical
317        names directly will abort the run on every truncated column ("matches no
318        feature_groups.definitions glob"); resolve it here first::
319
320            definitions = {"cardiac": f.columns_matching("*frecuencia_cardiaca*")}
321
322        Args:
323            pattern: Glob matched against ``label``. Case-sensitive.
324            allow_empty: Return ``[]`` instead of raising when nothing matches.
325
326        Returns:
327            The rendered (possibly truncated) column names, in output order.
328
329        Raises:
330            LookupError: When nothing matches and ``allow_empty`` is False.
331        """
332        return [
333            entry.column
334            for entry in self.manifest_matching(pattern, allow_empty=allow_empty)
335        ]

Physical column names whose full label matches a glob, in output order.

The projection of manifest_matching() onto the rendered column name — what you put in a select list, a feature-group definition, or a DataFrame projection.

Building a triage feature_groups.definitions entry from physical names directly will abort the run on every truncated column ("matches no feature_groups.definitions glob"); resolve it here first::

definitions = {"cardiac": f.columns_matching("*frecuencia_cardiaca*")}
Arguments:
  • pattern: Glob matched against label. Case-sensitive.
  • allow_empty: Return [] instead of raising when nothing matches.
Returns:

The rendered (possibly truncated) column names, in output order.

Raises:
  • LookupError: When nothing matches and allow_empty is False.
query: str
337    @property
338    def query(self) -> str:
339        """Generate the single SQL query for this featurizer configuration.
340
341        Raises:
342            ValueError: If the configuration is too wide to render as one valid
343                query — the ``<target>_transform`` CTE (or an intermediate CTE)
344                would exceed PostgreSQL's 1664-entry target-list limit. Use
345                :attr:`query_groups` / :meth:`to_parquet` / :meth:`to_arrow`
346                (column-group sharding, issue #7) instead. The matrix is never
347                silently truncated.
348        """
349        sharder = self._sharder()
350        if not sharder.fits_single_group:
351            n_groups = sharder.n_groups
352            raise ValueError(
353                f"Feature matrix for target '{self.target.alias}' is too wide for a "
354                "single query: it exceeds PostgreSQL's 1664-entry target-list limit "
355                f"and partitions into {n_groups} column groups. Use "
356                "`.query_groups` (group_id -> SQL), `.to_parquet(dir)` (one Parquet "
357                "per group), or `.to_arrow()` (list of tables); all groups re-join "
358                "on (as_of_date, id). `.to_arrow`/`.to_parquet`/`.to_dataframe` also "
359                "materialize any oversized child CTE automatically (issue #7). See "
360                "docs/adr/0005-column-group-sharding.md."
361            )
362        return self._renderer.render(self._plan)

Generate the single SQL query for this featurizer configuration.

Raises:
  • ValueError: If the configuration is too wide to render as one valid query — the <target>_transform CTE (or an intermediate CTE) would exceed PostgreSQL's 1664-entry target-list limit. Use query_groups / to_parquet() / to_arrow() (column-group sharding, issue #7) instead. The matrix is never silently truncated.
query_groups: collections.OrderedDict[str, str]
495    @property
496    def query_groups(self) -> "OrderedDict[str, str]":
497        """SQL for each column group: ``group_<NNN>`` -> self-contained query.
498
499        Partitions the (possibly very wide) feature matrix into ordered column
500        groups, each a valid query whose every CTE tuple is under PostgreSQL's
501        1664-entry limit (issue #7). A config that fits in one query returns a
502        single ``group_000`` entry equal to :attr:`query`. Every group leads
503        with ``(as_of_date, <target id>)`` so the groups re-join into the full
504        matrix.
505
506        When an oversized non-target child CTE had to be materialized, these
507        queries reference TEMP-table shards and **presuppose**
508        :attr:`materialization_ddl` was executed first on the same session;
509        :meth:`to_arrow` / :meth:`to_parquet` do that automatically.
510        """
511        return self._grouped().queries

SQL for each column group: group_<NNN> -> self-contained query.

Partitions the (possibly very wide) feature matrix into ordered column groups, each a valid query whose every CTE tuple is under PostgreSQL's 1664-entry limit (issue #7). A config that fits in one query returns a single group_000 entry equal to query. Every group leads with (as_of_date, <target id>) so the groups re-join into the full matrix.

When an oversized non-target child CTE had to be materialized, these queries reference TEMP-table shards and presuppose materialization_ddl was executed first on the same session; to_arrow() / to_parquet() do that automatically.

materialization_ddl: List[str]
513    @property
514    def materialization_ddl(self) -> List[str]:
515        """The ``CREATE TEMP TABLE`` preamble (issue #7) that :attr:`query_groups`
516        presupposes, or ``[]`` when no oversized child CTE needs materializing.
517
518        Run these statements on the same connection/session before executing the
519        grouped queries. :meth:`to_arrow` / :meth:`to_parquet` / :meth:`to_dataframe`
520        run them for you.
521        """
522        mplan = self._grouped().materialization
523        return list(mplan.ddl) if mplan is not None else []

The CREATE TEMP TABLE preamble (issue #7) that query_groups presupposes, or [] when no oversized child CTE needs materializing.

Run these statements on the same connection/session before executing the grouped queries. to_arrow() / to_parquet() / to_dataframe() run them for you.

def to_dataframe( self, *, connection: Any = None, impute: bool = False, **impute_kwargs: Any) -> pandas.core.frame.DataFrame:
525    def to_dataframe(
526        self, *, connection: Any = None, impute: bool = False, **impute_kwargs: Any
527    ) -> pd.DataFrame:
528        """Execute the query and return results as a DataFrame.
529
530        Args:
531            connection: An open psycopg connection to run on (required when the
532                query references session ``TEMP`` tables — the integration
533                harness). When ``None``, the fast single-query path uses
534                ``records`` and the grouped/materialized path builds its own
535                connection from the environment and closes it afterwards.
536            impute: When True, run the opt-in imputation pass (count-like
537                features → 0, measures left NULL unless ``measure_strategy`` is
538                given, with ``<feature>__missing`` indicator columns). The
539                default keeps the raw NULLs, since missingness is signal.
540            **impute_kwargs: Forwarded to
541                :func:`featurizer.imputation.impute_features`.
542
543        A config that fits one valid query uses the fast (single-query) path; a
544        wide or oversized-child config (issue #7) runs the column-group queries —
545        and any TEMP-table materialization preamble — on one connection and
546        re-joins them on ``(as_of_date, target_id)`` into the same indexed frame.
547        Passing ``connection`` forces the one-connection path (so it can see
548        session TEMP tables) regardless of width.
549
550        Returns:
551            DataFrame indexed by ['as_of_date', target_id]
552
553        Raises:
554            ValueError: If target entity doesn't define a primary ID
555        """
556        if self.target.id is None:
557            raise ValueError(
558                f"Target entity '{self.target.alias}' does not define a primary id."
559            )
560        grouped = self._grouped()
561        if grouped.fits_single and connection is None:
562            df = self._executor.to_dataframe(self.query, self.target.id.name)
563        else:
564            df = self._executor.to_dataframe_materialized(
565                preamble_ddl=(
566                    grouped.materialization.ddl
567                    if grouped.materialization is not None
568                    else []
569                ),
570                group_queries=grouped.queries,
571                target_id=self.target.id.name,
572                connection=connection,
573                key_columns=grouped.key_columns,
574            )
575        if impute:
576            from .imputation import guard_full_matrix_fit, impute_features
577
578            # Engine path: this fits over the whole returned matrix, so gate the
579            # leaky measure strategies (ADR-0001). The pure impute_features helper
580            # stays ungated for callers that pre-split their own data.
581            guard_full_matrix_fit(
582                impute_kwargs.get("measure_strategy", "none"),
583                allow_full_matrix_fit=bool(
584                    impute_kwargs.pop("allow_full_matrix_fit", False)
585                ),
586                caller="to_dataframe",
587            )
588            df = impute_features(df, **impute_kwargs)
589        return df

Execute the query and return results as a DataFrame.

Arguments:
  • connection: An open psycopg connection to run on (required when the query references session TEMP tables — the integration harness). When None, the fast single-query path uses records and the grouped/materialized path builds its own connection from the environment and closes it afterwards.
  • impute: When True, run the opt-in imputation pass (count-like features → 0, measures left NULL unless measure_strategy is given, with <feature>__missing indicator columns). The default keeps the raw NULLs, since missingness is signal.
  • **impute_kwargs: Forwarded to featurizer.imputation.impute_features().

A config that fits one valid query uses the fast (single-query) path; a wide or oversized-child config (issue #7) runs the column-group queries — and any TEMP-table materialization preamble — on one connection and re-joins them on (as_of_date, target_id) into the same indexed frame. Passing connection forces the one-connection path (so it can see session TEMP tables) regardless of width.

Returns:

DataFrame indexed by ['as_of_date', target_id]

Raises:
  • ValueError: If target entity doesn't define a primary ID
def to_arrow( self, *, connection: Any = None, numeric_as_float: bool = True, impute: bool = False, **impute_kwargs: Any) -> Any:
591    def to_arrow(
592        self,
593        *,
594        connection: Any = None,
595        numeric_as_float: bool = True,
596        impute: bool = False,
597        **impute_kwargs: Any,
598    ) -> "Any":
599        """Execute the query and return Arrow output, no pandas hop.
600
601        Streams the result out of PostgreSQL with binary ``COPY`` and decodes it
602        column-by-column into Arrow, so SQL NULLs are preserved as Arrow nulls
603        (never coerced to ``NaN``) and the full result set never materializes as
604        a pandas frame. ``as_of_date`` and the target id are ordinary leading
605        columns (no index), unlike :meth:`to_dataframe`.
606
607        Sharding (issue #7): when the matrix fits in one query a single
608        :class:`pyarrow.Table` is returned. When it is too wide for a single
609        valid query (over PostgreSQL's 1664-entry target-list limit), an
610        ``OrderedDict[str, pyarrow.Table]`` of column groups is returned instead
611        — ``group_<NNN>`` -> table. Every group table leads with
612        ``(as_of_date, <target id>)`` and the groups re-join on those keys to
613        reconstruct the full matrix.
614
615        Args:
616            connection: An open psycopg connection to run ``COPY`` on. Required
617                when the rendered query references session ``TEMP`` tables (the
618                integration harness). When ``None``, a connection is built from
619                ``DATABASE_URL`` / ``PG*`` and closed afterwards. A single
620                connection is reused across all groups.
621            numeric_as_float: Cast PostgreSQL ``numeric`` aggregate columns to
622                ``float64`` (ML-ready, ``to_dataframe``-comparable). Set ``False``
623                to keep exact ``decimal128``. NULLs are preserved either way.
624            impute: When True, apply the Arrow-native imputation pass
625                (:func:`featurizer.imputation.impute_arrow`) to each group:
626                count-like features → 0, measures left null unless
627                ``measure_strategy`` is given, with stable ``<feature>__missing``
628                indicator columns. ``as_of_date`` and the target id are passed as
629                ``key_columns`` and left untouched.
630            **impute_kwargs: Forwarded to ``impute_arrow``. ``measure_strategy`` in
631                ``{"mean","median"}`` additionally requires
632                ``allow_full_matrix_fit=True`` (ADR-0001 leakage gate).
633
634        Returns:
635            A ``pyarrow.Table`` for a single-group config, otherwise an
636            ``OrderedDict[str, pyarrow.Table]`` keyed by group id.
637
638        Raises:
639            ImportError: If pyarrow (the ``[parquet]`` extra) is not installed.
640            ValueError: If the target entity does not define a primary id, or a
641                leaky measure strategy is requested without the opt-in.
642        """
643        if self.target.id is None:
644            raise ValueError(
645                f"Target entity '{self.target.alias}' does not define a primary id."
646            )
647        groups = self._arrow_groups(
648            connection=connection,
649            numeric_as_float=numeric_as_float,
650            impute=impute,
651            **impute_kwargs,
652        )
653        if len(groups) == 1:
654            # Single-group config: preserve the original single-Table contract.
655            return next(iter(groups.values()))
656        return groups

Execute the query and return Arrow output, no pandas hop.

Streams the result out of PostgreSQL with binary COPY and decodes it column-by-column into Arrow, so SQL NULLs are preserved as Arrow nulls (never coerced to NaN) and the full result set never materializes as a pandas frame. as_of_date and the target id are ordinary leading columns (no index), unlike to_dataframe().

Sharding (issue #7): when the matrix fits in one query a single pyarrow.Table is returned. When it is too wide for a single valid query (over PostgreSQL's 1664-entry target-list limit), an OrderedDict[str, pyarrow.Table] of column groups is returned instead — group_<NNN> -> table. Every group table leads with (as_of_date, <target id>) and the groups re-join on those keys to reconstruct the full matrix.

Arguments:
  • connection: An open psycopg connection to run COPY on. Required when the rendered query references session TEMP tables (the integration harness). When None, a connection is built from DATABASE_URL / PG* and closed afterwards. A single connection is reused across all groups.
  • numeric_as_float: Cast PostgreSQL numeric aggregate columns to float64 (ML-ready, to_dataframe-comparable). Set False to keep exact decimal128. NULLs are preserved either way.
  • impute: When True, apply the Arrow-native imputation pass (featurizer.imputation.impute_arrow()) to each group: count-like features → 0, measures left null unless measure_strategy is given, with stable <feature>__missing indicator columns. as_of_date and the target id are passed as key_columns and left untouched.
  • **impute_kwargs: Forwarded to impute_arrow. measure_strategy in {"mean","median"} additionally requires allow_full_matrix_fit=True (ADR-0001 leakage gate).
Returns:

A pyarrow.Table for a single-group config, otherwise an OrderedDict[str, pyarrow.Table] keyed by group id.

Raises:
  • ImportError: If pyarrow (the [parquet] extra) is not installed.
  • ValueError: If the target entity does not define a primary id, or a leaky measure strategy is requested without the opt-in.
def to_parquet( self, path: str, *, connection: Any = None, numeric_as_float: bool = True, impute: bool = False, **impute_kwargs: Any) -> None:
658    def to_parquet(
659        self,
660        path: str,
661        *,
662        connection: Any = None,
663        numeric_as_float: bool = True,
664        impute: bool = False,
665        **impute_kwargs: Any,
666    ) -> None:
667        """Execute the query and write the result to Parquet.
668
669        Thin wrapper over the Arrow path plus ``pyarrow.parquet.write_table``;
670        all arguments (including the imputation contract and its ADR-0001 leakage
671        gate) behave exactly as in :meth:`to_arrow`. NULLs are written as Parquet
672        nulls.
673
674        Sharding (issue #7): when the matrix fits in one query, a single Parquet
675        file is written at ``path``. When it is too wide for one valid query,
676        ``path`` is treated as a **directory** and one Parquet file per column
677        group is written under it as ``group_<NNN>.parquet``. All group files
678        re-join on ``(as_of_date, <target id>)`` to reconstruct the full matrix.
679
680        Args:
681            path: Destination ``.parquet`` file (single group) or output
682                directory (multiple groups; created if absent).
683            connection: See :meth:`to_arrow`.
684            numeric_as_float: See :meth:`to_arrow`.
685            impute: See :meth:`to_arrow`.
686            **impute_kwargs: See :meth:`to_arrow`.
687
688        Raises:
689            ImportError: If pyarrow (the ``[parquet]`` extra) is not installed.
690        """
691        import pyarrow.parquet as pq  # pyright: ignore[reportMissingImports]
692
693        groups = self._arrow_groups(
694            connection=connection,
695            numeric_as_float=numeric_as_float,
696            impute=impute,
697            **impute_kwargs,
698        )
699        if len(groups) == 1:
700            pq.write_table(next(iter(groups.values())), path)
701            return
702
703        import os as _os
704
705        _os.makedirs(path, exist_ok=True)
706        for gid, table in groups.items():
707            pq.write_table(table, _os.path.join(path, f"{gid}.parquet"))
708        logger.info(
709            "Wrote {} column-group Parquet files to {} (re-join on {}).",
710            len(groups),
711            path,
712            ("as_of_date", self.target.id.name) if self.target.id else "(as_of_date,)",
713        )

Execute the query and write the result to Parquet.

Thin wrapper over the Arrow path plus pyarrow.parquet.write_table; all arguments (including the imputation contract and its ADR-0001 leakage gate) behave exactly as in to_arrow(). NULLs are written as Parquet nulls.

Sharding (issue #7): when the matrix fits in one query, a single Parquet file is written at path. When it is too wide for one valid query, path is treated as a directory and one Parquet file per column group is written under it as group_<NNN>.parquet. All group files re-join on (as_of_date, <target id>) to reconstruct the full matrix.

Arguments:
  • path: Destination .parquet file (single group) or output directory (multiple groups; created if absent).
  • connection: See to_arrow().
  • numeric_as_float: See to_arrow().
  • impute: See to_arrow().
  • **impute_kwargs: See to_arrow().
Raises:
  • ImportError: If pyarrow (the [parquet] extra) is not installed.
def to_tables( self, schema: str, *, connection: Any = None, table_prefix: str | None = None, create_schema: bool = True) -> List[featurizer.sharding.FeatureGroupTable]:
715    def to_tables(
716        self,
717        schema: str,
718        *,
719        connection: Any = None,
720        table_prefix: str | None = None,
721        create_schema: bool = True,
722    ) -> List["FeatureGroupTable"]:
723        """Persist the feature matrix as triage-style feature-group tables.
724
725        Writes each column group as a persistent table
726        ``"<schema>"."<stem>_group_<NNN>"`` keyed on ``(as_of_date, <target id>)``,
727        the feature-group contract triage-pg consumes (issue #7). A config that
728        fits one query writes a single ``<stem>_group_000``; a wide or
729        oversized-child config writes one table per column group, all re-joinable
730        on the keys. The issue-#7 intermediate shards stay ephemeral ``TEMP``
731        tables — only the final groups are persisted.
732
733        Idempotent: each target table is ``DROP TABLE IF EXISTS`` + ``CREATE TABLE
734        … AS`` so a re-run replaces it cleanly.
735
736        Alongside the group tables, the feature manifest is persisted as
737        ``"<schema>"."<stem>_manifest"`` — one row per output column (label,
738        lineage, generated description, and the ``feature_group`` it landed
739        in), joinable to the group tables by column name. The returned list
740        contains the feature-group tables only, as before.
741
742        Args:
743            schema: Destination schema (created if absent unless
744                ``create_schema=False``).
745            connection: An open psycopg connection to write on. When supplied the
746                caller owns the transaction (nothing is committed here — the
747                integration harness verifies within its rolled-back transaction);
748                when ``None`` a connection is built from the environment, committed
749                so the tables persist, and closed.
750            table_prefix: Table-name stem; defaults to the target alias
751                (``stores`` -> ``stores_group_000``).
752            create_schema: Run ``CREATE SCHEMA IF NOT EXISTS`` first.
753
754        Returns:
755            The ordered manifest of created :class:`FeatureGroupTable`s.
756
757        Raises:
758            ValueError: If the target entity does not define a primary id.
759        """
760        if self.target.id is None:
761            raise ValueError(
762                f"Target entity '{self.target.alias}' does not define a primary id."
763            )
764        from .arrow import default_connection
765        from .sharding import FeatureGroupTable
766
767        grouped, column_groups = self._grouped_for_tables()
768        preamble = (
769            grouped.materialization.ddl if grouped.materialization is not None else []
770        )
771        stem = table_prefix or self.target.alias
772        keys = list(grouped.key_columns)
773
774        own_connection = connection is None
775        conn = connection if connection is not None else default_connection()
776        tables: List["FeatureGroupTable"] = []
777        try:
778            # jit off from the preamble on (issue #53); a caller's value is put
779            # back before their transaction goes on.
780            with jit_disabled(conn):
781                with conn.cursor() as cur:
782                    if create_schema:
783                        cur.execute(f'create schema if not exists "{schema}"')
784                    for ddl in preamble:
785                        cur.execute(ddl)
786                analyze_as_of_dates(conn)  # planner-stats optimization (see executor)
787                if own_connection:  # PLANNER_TUNING never reaches a caller's
788                    apply_planner_tuning(conn)
789                with conn.cursor() as cur:
790                    for gid, sql in grouped.queries.items():
791                        name = f'"{schema}"."{stem}_{gid}"'
792                        cur.execute(f"drop table if exists {name}")
793                        cur.execute(f"create table {name} as\n{sql}")
794                        tables.append(
795                            FeatureGroupTable(
796                                name=name, group=gid, key_columns=list(keys)
797                            )
798                        )
799                    self._write_manifest_table(cur, schema, stem, column_groups)
800            if own_connection:
801                conn.commit()
802        finally:
803            if own_connection:
804                conn.close()
805        logger.info(
806            "Persisted {} feature-group table(s) + manifest to schema {!r} "
807            "(re-join on {}).",
808            len(tables),
809            schema,
810            tuple(keys),
811        )
812        return tables

Persist the feature matrix as triage-style feature-group tables.

Writes each column group as a persistent table "<schema>"."<stem>_group_<NNN>" keyed on (as_of_date, <target id>), the feature-group contract triage-pg consumes (issue #7). A config that fits one query writes a single <stem>_group_000; a wide or oversized-child config writes one table per column group, all re-joinable on the keys. The issue-#7 intermediate shards stay ephemeral TEMP tables — only the final groups are persisted.

Idempotent: each target table is DROP TABLE IF EXISTS + CREATE TABLE … AS so a re-run replaces it cleanly.

Alongside the group tables, the feature manifest is persisted as "<schema>"."<stem>_manifest" — one row per output column (label, lineage, generated description, and the feature_group it landed in), joinable to the group tables by column name. The returned list contains the feature-group tables only, as before.

Arguments:
  • schema: Destination schema (created if absent unless create_schema=False).
  • connection: An open psycopg connection to write on. When supplied the caller owns the transaction (nothing is committed here — the integration harness verifies within its rolled-back transaction); when None a connection is built from the environment, committed so the tables persist, and closed.
  • table_prefix: Table-name stem; defaults to the target alias (stores -> stores_group_000).
  • create_schema: Run CREATE SCHEMA IF NOT EXISTS first.
Returns:

The ordered manifest of created FeatureGroupTables.

Raises:
  • ValueError: If the target entity does not define a primary id.
class FeaturizerViz:
12class FeaturizerViz:
13    """Visualization toolkit for featurizer output DataFrames.
14
15    The matrix produced by :meth:`Featurizer.to_dataframe` is indexed by
16    ``(as_of_date, <entity id column>)`` where the second level is the target
17    entity's actual id column name (e.g. ``customer_id``), not the literal
18    ``"entity_id"``. This class accepts either that MultiIndex form or a flat
19    frame; index levels matching ``as_of_col`` / ``entity_col`` are moved to
20    columns automatically. Prefer :meth:`from_featurizer`, which wires the
21    correct column names for you.
22
23    Args:
24        df: Feature matrix. May be indexed by (as_of_date, entity) or flat.
25        as_of_col: Name of the as-of date column / index level.
26        entity_col: Name of the entity id column / index level.
27    """
28
29    def __init__(
30        self,
31        df: pd.DataFrame,
32        *,
33        as_of_col: str = "as_of_date",
34        entity_col: str = "entity_id",
35    ) -> None:
36        # Normalize: if as_of/entity live in the index (the to_dataframe shape),
37        # move them to columns so every method can treat them uniformly. Work on
38        # a copy — never mutate the caller's frame.
39        index_names = [n for n in (df.index.names or []) if n is not None]
40        if as_of_col in index_names or entity_col in index_names:
41            df = df.reset_index()
42        self.df = df
43        self.as_of_col = as_of_col
44        self.entity_col = entity_col
45        self._feature_cols: list[str] | None = None
46
47    @classmethod
48    def from_featurizer(
49        cls,
50        featurizer: Featurizer,
51        df: pd.DataFrame | None = None,
52    ) -> FeaturizerViz:
53        """Build a ``FeaturizerViz`` with column names taken from the Featurizer.
54
55        Resolves ``entity_col`` from the target entity's id column (so the
56        ``entity_id`` default never silently treats the id as a feature). When
57        ``df`` is None, executes ``featurizer.to_dataframe()``.
58
59        Raises:
60            ValueError: if the target entity defines no primary id.
61        """
62        if featurizer.target.id is None:
63            raise ValueError(
64                f"Target entity '{featurizer.target.alias}' defines no primary "
65                f"id; cannot determine the entity column for visualization."
66            )
67        entity_col = featurizer.target.id.name
68        if df is None:
69            df = featurizer.to_dataframe()
70        return cls(df, as_of_col="as_of_date", entity_col=entity_col)
71
72    @property
73    def feature_cols(self) -> list[str]:
74        if self._feature_cols is None:
75            self._feature_cols = [
76                c for c in self.df.columns if c not in {self.as_of_col, self.entity_col}
77            ]
78        return self._feature_cols
79
80    # Methods are defined as module-level functions (first arg ``self``) and
81    # bound here as class attributes, grouped by source file.
82    from .correlation import plot_correlation_clustermap, plot_redundancy_graph
83    from .distributions import feature_summary_table, plot_feature_distributions
84    from .importance import plot_feature_importance, plot_feature_variance
85    from .missing import plot_missing_heatmap, plot_missing_over_time
86    from .similarity import plot_entity_dendrogram, plot_entity_embedding
87    from .temporal import plot_entity_feature_heatmap, plot_feature_timeseries

Visualization toolkit for featurizer output DataFrames.

The matrix produced by Featurizer.to_dataframe() is indexed by (as_of_date, <entity id column>) where the second level is the target entity's actual id column name (e.g. customer_id), not the literal "entity_id". This class accepts either that MultiIndex form or a flat frame; index levels matching as_of_col / entity_col are moved to columns automatically. Prefer from_featurizer(), which wires the correct column names for you.

Arguments:
  • df: Feature matrix. May be indexed by (as_of_date, entity) or flat.
  • as_of_col: Name of the as-of date column / index level.
  • entity_col: Name of the entity id column / index level.
FeaturizerViz( df: pandas.core.frame.DataFrame, *, as_of_col: str = 'as_of_date', entity_col: str = 'entity_id')
29    def __init__(
30        self,
31        df: pd.DataFrame,
32        *,
33        as_of_col: str = "as_of_date",
34        entity_col: str = "entity_id",
35    ) -> None:
36        # Normalize: if as_of/entity live in the index (the to_dataframe shape),
37        # move them to columns so every method can treat them uniformly. Work on
38        # a copy — never mutate the caller's frame.
39        index_names = [n for n in (df.index.names or []) if n is not None]
40        if as_of_col in index_names or entity_col in index_names:
41            df = df.reset_index()
42        self.df = df
43        self.as_of_col = as_of_col
44        self.entity_col = entity_col
45        self._feature_cols: list[str] | None = None
df
as_of_col
entity_col
@classmethod
def from_featurizer( cls, featurizer: Featurizer, df: pandas.core.frame.DataFrame | None = None) -> FeaturizerViz:
47    @classmethod
48    def from_featurizer(
49        cls,
50        featurizer: Featurizer,
51        df: pd.DataFrame | None = None,
52    ) -> FeaturizerViz:
53        """Build a ``FeaturizerViz`` with column names taken from the Featurizer.
54
55        Resolves ``entity_col`` from the target entity's id column (so the
56        ``entity_id`` default never silently treats the id as a feature). When
57        ``df`` is None, executes ``featurizer.to_dataframe()``.
58
59        Raises:
60            ValueError: if the target entity defines no primary id.
61        """
62        if featurizer.target.id is None:
63            raise ValueError(
64                f"Target entity '{featurizer.target.alias}' defines no primary "
65                f"id; cannot determine the entity column for visualization."
66            )
67        entity_col = featurizer.target.id.name
68        if df is None:
69            df = featurizer.to_dataframe()
70        return cls(df, as_of_col="as_of_date", entity_col=entity_col)

Build a FeaturizerViz with column names taken from the Featurizer.

Resolves entity_col from the target entity's id column (so the entity_id default never silently treats the id as a feature). When df is None, executes featurizer.to_dataframe().

Raises:
  • ValueError: if the target entity defines no primary id.
feature_cols: list[str]
72    @property
73    def feature_cols(self) -> list[str]:
74        if self._feature_cols is None:
75            self._feature_cols = [
76                c for c in self.df.columns if c not in {self.as_of_col, self.entity_col}
77            ]
78        return self._feature_cols
def plot_correlation_clustermap( self, method: str = 'spearman', threshold: float | None = 0.95, figsize: tuple[int, int] = (14, 12), cmap: str = 'RdBu_r') -> 'matplotlib.figure.Figure':
12def plot_correlation_clustermap(
13    self,
14    method: str = "spearman",
15    threshold: float | None = 0.95,
16    figsize: tuple[int, int] = (14, 12),
17    cmap: str = "RdBu_r",
18) -> matplotlib.figure.Figure:
19    """Plot hierarchically-clustered correlation heatmap.
20
21    Args:
22        method: Correlation method ('spearman', 'pearson', 'kendall').
23        threshold: If set, annotate pairs above this absolute correlation.
24        figsize: Figure size.
25        cmap: Colormap name.
26
27    Returns:
28        matplotlib Figure.
29    """
30    _require("seaborn")
31    _require("matplotlib")
32    import seaborn as sns
33
34    matrix = _get_feature_matrix(self.df, self.feature_cols)
35    corr = matrix.corr(method=method)
36    # A constant or (near-)all-NULL feature has undefined correlation with
37    # everything; scipy's linkage rejects a distance matrix with non-finite
38    # values, so drop those features from the clustermap instead of crashing.
39    finite = corr.columns[corr.notna().sum() > 1]
40    dropped = len(corr.columns) - len(finite)
41    if dropped:
42        print(
43            f"correlation clustermap: dropped {dropped} constant/all-NULL "
44            "feature(s) with undefined correlations."
45        )
46    corr = corr.loc[finite, finite].fillna(0.0)
47
48    g = sns.clustermap(
49        corr,
50        cmap=cmap,
51        vmin=-1,
52        vmax=1,
53        figsize=figsize,
54        linewidths=0.5,
55        dendrogram_ratio=(0.1, 0.1),
56    )
57    g.fig.suptitle(f"Feature Correlation ({method.title()})", y=1.02, fontsize=14)
58
59    if threshold is not None:
60        import numpy as np
61
62        mask = np.triu(np.ones_like(corr, dtype=bool), k=1)
63        high_corr = corr.where(mask & (corr.abs() > threshold)).stack()
64        if not high_corr.empty:
65            print(f"\n{len(high_corr)} feature pairs above |{threshold}|:")
66            for (f1, f2), val in high_corr.items():
67                print(f"  {f1} <-> {f2}: {val:.3f}")
68
69    return g.fig

Plot hierarchically-clustered correlation heatmap.

Arguments:
  • method: Correlation method ('spearman', 'pearson', 'kendall').
  • threshold: If set, annotate pairs above this absolute correlation.
  • figsize: Figure size.
  • cmap: Colormap name.
Returns:

matplotlib Figure.

def plot_redundancy_graph(self, threshold: float = 0.95, method: str = 'spearman') -> object:
 72def plot_redundancy_graph(
 73    self,
 74    threshold: float = 0.95,
 75    method: str = "spearman",
 76) -> object:
 77    """Plot network graph of highly correlated features.
 78
 79    Args:
 80        threshold: Absolute correlation threshold for edges.
 81        method: Correlation method.
 82
 83    Returns:
 84        plotly Figure (interactive).
 85    """
 86    _require("plotly")
 87    _require("networkx")
 88    import networkx as nx
 89    import numpy as np
 90    import plotly.graph_objects as go
 91
 92    matrix = _get_feature_matrix(self.df, self.feature_cols)
 93    corr = matrix.corr(method=method)
 94
 95    G = nx.Graph()
 96    mask = np.triu(np.ones_like(corr, dtype=bool), k=1)
 97    high_corr = corr.where(mask & (corr.abs() > threshold)).stack()
 98
 99    for (f1, f2), val in high_corr.items():
100        G.add_edge(f1, f2, weight=abs(val), correlation=val)
101
102    if len(G.nodes) == 0:
103        print(f"No feature pairs above |{threshold}| — try lowering threshold.")
104        return None
105
106    pos = nx.spring_layout(G, seed=42)
107
108    edge_x: list[float | None] = []
109    edge_y: list[float | None] = []
110    for u, v in G.edges():
111        x0, y0 = pos[u]
112        x1, y1 = pos[v]
113        edge_x.extend([x0, x1, None])
114        edge_y.extend([y0, y1, None])
115
116    edge_trace = go.Scatter(
117        x=edge_x,
118        y=edge_y,
119        line=dict(width=0.5, color="#888"),
120        hoverinfo="none",
121        mode="lines",
122    )
123
124    node_x = [pos[n][0] for n in G.nodes()]
125    node_y = [pos[n][1] for n in G.nodes()]
126    node_text = [f"{n} (degree={G.degree(n)})" for n in G.nodes()]
127    node_color = [G.degree(n) for n in G.nodes()]
128
129    node_trace = go.Scatter(
130        x=node_x,
131        y=node_y,
132        mode="markers+text",
133        hoverinfo="text",
134        text=[n.split("(")[-1].rstrip(")") if "(" in n else n[:15] for n in G.nodes()],
135        textposition="top center",
136        textfont=dict(size=8),
137        hovertext=node_text,
138        marker=dict(
139            showscale=True,
140            colorscale="YlOrRd",
141            color=node_color,
142            size=10,
143            colorbar=dict(thickness=15, title="Degree"),
144        ),
145    )
146
147    fig = go.Figure(
148        data=[edge_trace, node_trace],
149        layout=go.Layout(
150            title=f"Feature Redundancy Graph (|corr| > {threshold})",
151            showlegend=False,
152            xaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
153            yaxis=dict(showgrid=False, zeroline=False, showticklabels=False),
154        ),
155    )
156    return fig

Plot network graph of highly correlated features.

Arguments:
  • threshold: Absolute correlation threshold for edges.
  • method: Correlation method.
Returns:

plotly Figure (interactive).

def feature_summary_table(self) -> 'pd.DataFrame':
65def feature_summary_table(self) -> pd.DataFrame:
66    """Generate summary statistics table for all features.
67
68    Returns:
69        DataFrame (one row per numeric feature) with columns
70        ``mean``, ``std``, ``skewness``, ``pct_missing``.
71    """
72    _require("pandas")
73    import pandas as pd
74
75    matrix = _get_feature_matrix(self.df, self.feature_cols)
76    summary = pd.DataFrame(
77        {
78            "mean": matrix.mean(),
79            "std": matrix.std(),
80            "skewness": matrix.skew(),
81            "pct_missing": matrix.isnull().mean() * 100.0,
82        }
83    )
84    summary.index.name = "feature"
85    return summary

Generate summary statistics table for all features.

Returns:

DataFrame (one row per numeric feature) with columns mean, std, skewness, pct_missing.

def plot_feature_distributions( self, features: list[str] | None = None, kind: str = 'violin', figsize: tuple[int, int] = (14, 8)) -> 'matplotlib.figure.Figure':
13def plot_feature_distributions(
14    self,
15    features: list[str] | None = None,
16    kind: str = "violin",
17    figsize: tuple[int, int] = (14, 8),
18) -> matplotlib.figure.Figure:
19    """Plot feature distributions across entities.
20
21    Args:
22        features: Features to plot. If None, selects top 12 by variance.
23        kind: Plot type ('violin', 'box', 'hist').
24        figsize: Figure size.
25
26    Returns:
27        matplotlib Figure.
28    """
29    _require("matplotlib")
30    _require("seaborn")
31    import matplotlib.pyplot as plt
32    import seaborn as sns
33
34    matrix = _get_feature_matrix(self.df, self.feature_cols)
35    if features is None:
36        features = _top_by_variance(matrix, 12)
37    else:
38        features = [f for f in features if f in matrix.columns]
39    if not features:
40        fig, ax = plt.subplots(figsize=figsize)
41        ax.text(0.5, 0.5, "No numeric features to plot", ha="center", va="center")
42        return fig
43
44    subset = matrix[features]
45    fig, ax = plt.subplots(figsize=figsize)
46
47    if kind == "hist":
48        subset.plot.hist(ax=ax, bins=30, alpha=0.5)
49        ax.set_xlabel("Value")
50        ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=7)
51    else:
52        melted = subset.melt(var_name="feature", value_name="value").dropna()
53        if kind == "box":
54            sns.boxplot(data=melted, x="feature", y="value", ax=ax)
55        else:  # default: violin
56            sns.violinplot(data=melted, x="feature", y="value", ax=ax)
57        ax.set_xlabel("")
58        plt.setp(ax.get_xticklabels(), rotation=90, fontsize=7)
59
60    ax.set_title(f"Feature Distributions ({kind})")
61    plt.tight_layout()
62    return fig

Plot feature distributions across entities.

Arguments:
  • features: Features to plot. If None, selects top 12 by variance.
  • kind: Plot type ('violin', 'box', 'hist').
  • figsize: Figure size.
Returns:

matplotlib Figure.

def plot_feature_importance( self, target_col: str, kind: str = 'mutual_info', top_n: int = 30, figsize: tuple[int, int] = (12, 10)) -> 'matplotlib.figure.Figure':
12def plot_feature_importance(
13    self,
14    target_col: str,
15    kind: str = "mutual_info",
16    top_n: int = 30,
17    figsize: tuple[int, int] = (12, 10),
18) -> matplotlib.figure.Figure:
19    """Plot ranked feature importance.
20
21    Args:
22        target_col: Target variable column name.
23        kind: Method ('mutual_info', 'f_classif', 'f_regression').
24        top_n: Number of top features to show.
25        figsize: Figure size.
26
27    Returns:
28        matplotlib Figure.
29    """
30    _require("matplotlib")
31    _require("sklearn")
32    import matplotlib.pyplot as plt
33    import pandas as pd
34    from sklearn.feature_selection import (
35        f_classif,
36        f_regression,
37        mutual_info_classif,
38        mutual_info_regression,
39    )
40
41    if target_col not in self.df.columns:
42        raise ValueError(f"target_col '{target_col}' not found in DataFrame columns.")
43
44    matrix = _get_feature_matrix(self.df, self.feature_cols)
45    X = matrix.drop(columns=[target_col], errors="ignore")
46    y_raw = self.df[target_col]
47
48    # Drop rows with a missing target; median-impute the features locally.
49    mask = y_raw.notna()
50    X = _impute_median(X[mask.to_numpy()])
51    y_raw = y_raw[mask]
52
53    # Decide regression vs classification.
54    y_numeric = pd.api.types.is_numeric_dtype(y_raw)
55    if kind == "f_regression":
56        regression = True
57    elif kind == "f_classif":
58        regression = False
59    else:  # mutual_info: infer from the target
60        regression = y_numeric and y_raw.nunique() > 20
61
62    y = y_raw if regression else pd.factorize(y_raw)[0]
63
64    scorers = {
65        ("mutual_info", True): mutual_info_regression,
66        ("mutual_info", False): mutual_info_classif,
67        ("f_regression", True): f_regression,
68        ("f_classif", False): f_classif,
69    }
70    key = (kind if kind in ("f_regression", "f_classif") else "mutual_info", regression)
71    scorer = scorers[key]
72
73    raw = scorer(X, y)
74    scores = raw[0] if kind.startswith("f_") else raw  # f_* returns (F, pvalue)
75    ranked = pd.Series(scores, index=X.columns).sort_values(ascending=False).head(top_n)
76
77    fig, ax = plt.subplots(figsize=figsize)
78    ax.barh(ranked.index[::-1], ranked.to_numpy()[::-1], color="steelblue")
79    ax.set_title(f"Feature Importance ({kind}, target={target_col})")
80    ax.set_xlabel("Score")
81    plt.tight_layout()
82    return fig

Plot ranked feature importance.

Arguments:
  • target_col: Target variable column name.
  • kind: Method ('mutual_info', 'f_classif', 'f_regression').
  • top_n: Number of top features to show.
  • figsize: Figure size.
Returns:

matplotlib Figure.

def plot_feature_variance( self, top_n: int = 50, figsize: tuple[int, int] = (12, 10)) -> 'matplotlib.figure.Figure':
 85def plot_feature_variance(
 86    self,
 87    top_n: int = 50,
 88    figsize: tuple[int, int] = (12, 10),
 89) -> matplotlib.figure.Figure:
 90    """Plot feature variance ranking (no target needed).
 91
 92    Args:
 93        top_n: Number of top features to show.
 94        figsize: Figure size.
 95
 96    Returns:
 97        matplotlib Figure.
 98    """
 99    _require("matplotlib")
100    import matplotlib.pyplot as plt
101
102    matrix = _get_feature_matrix(self.df, self.feature_cols)
103    ranked = matrix.var().sort_values(ascending=False).head(top_n)
104
105    fig, ax = plt.subplots(figsize=figsize)
106    ax.barh(ranked.index[::-1], ranked.to_numpy()[::-1], color="darkorange")
107    ax.set_title(f"Feature Variance (top {top_n})")
108    ax.set_xlabel("Variance")
109    plt.tight_layout()
110    return fig

Plot feature variance ranking (no target needed).

Arguments:
  • top_n: Number of top features to show.
  • figsize: Figure size.
Returns:

matplotlib Figure.

def plot_missing_heatmap( self, as_of_date: str | None = None, figsize: tuple[int, int] = (16, 10), cmap: str = 'YlOrRd', max_entities: int = 50) -> 'matplotlib.figure.Figure':
12def plot_missing_heatmap(
13    self,
14    as_of_date: str | None = None,
15    figsize: tuple[int, int] = (16, 10),
16    cmap: str = "YlOrRd",
17    max_entities: int = 50,
18) -> matplotlib.figure.Figure:
19    """Plot binary heatmap of missing values (entities x features).
20
21    Args:
22        as_of_date: Filter to a specific as_of_date. If None, uses latest.
23        figsize: Figure size.
24        cmap: Colormap for the heatmap.
25        max_entities: Maximum entities to show (samples if exceeded).
26
27    Returns:
28        matplotlib Figure.
29    """
30    _require("seaborn")
31    _require("matplotlib")
32    import matplotlib.pyplot as plt
33    import seaborn as sns
34
35    df = self.df.copy()
36    if as_of_date is not None:
37        df = df[df[self.as_of_col] == as_of_date]
38    else:
39        latest = df[self.as_of_col].max()
40        df = df[df[self.as_of_col] == latest]
41        as_of_date = str(latest)
42
43    if self.entity_col in df.columns:
44        df = df.set_index(self.entity_col)
45
46    feature_df = df[self.feature_cols]
47    if len(feature_df) > max_entities:
48        feature_df = feature_df.sample(max_entities, random_state=42)
49
50    missing = feature_df.isnull().astype(int)
51
52    fig, ax = plt.subplots(figsize=figsize)
53    sns.heatmap(
54        missing,
55        cmap=cmap,
56        cbar_kws={"label": "Missing (1=yes)"},
57        xticklabels=True,
58        yticklabels=True,
59        ax=ax,
60    )
61    ax.set_title(f"Missing Data Pattern (as_of_date={as_of_date})")
62    ax.set_xlabel("Features")
63    ax.set_ylabel("Entities")
64    plt.xticks(rotation=90, fontsize=7)
65    plt.yticks(fontsize=7)
66    plt.tight_layout()
67    return fig

Plot binary heatmap of missing values (entities x features).

Arguments:
  • as_of_date: Filter to a specific as_of_date. If None, uses latest.
  • figsize: Figure size.
  • cmap: Colormap for the heatmap.
  • max_entities: Maximum entities to show (samples if exceeded).
Returns:

matplotlib Figure.

def plot_missing_over_time( self, figsize: tuple[int, int] = (14, 8), top_n: int = 20) -> 'matplotlib.figure.Figure':
 70def plot_missing_over_time(
 71    self,
 72    figsize: tuple[int, int] = (14, 8),
 73    top_n: int = 20,
 74) -> matplotlib.figure.Figure:
 75    """Plot percentage of missing values per feature across as_of_dates.
 76
 77    Args:
 78        figsize: Figure size.
 79        top_n: Show only top N features with most missingness.
 80
 81    Returns:
 82        matplotlib Figure.
 83    """
 84    _require("matplotlib")
 85    import matplotlib.pyplot as plt
 86
 87    grouped = self.df.groupby(self.as_of_col)[self.feature_cols].apply(
 88        lambda x: x.isnull().mean()
 89    )
 90
 91    avg_missing = grouped.mean().sort_values(ascending=False)
 92    top_features = avg_missing.head(top_n).index.tolist()
 93
 94    if not top_features:
 95        fig, ax = plt.subplots()
 96        ax.text(0.5, 0.5, "No missing data found", ha="center", va="center")
 97        return fig
 98
 99    fig, ax = plt.subplots(figsize=figsize)
100    subset = grouped[top_features]
101    subset.plot(ax=ax, linewidth=1.5, alpha=0.8)
102    ax.set_title(f"Missing Data Over Time (top {top_n} features)")
103    ax.set_xlabel("As-of Date")
104    ax.set_ylabel("Fraction Missing")
105    ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=7)
106    ax.set_ylim(0, 1)
107    plt.tight_layout()
108    return fig

Plot percentage of missing values per feature across as_of_dates.

Arguments:
  • figsize: Figure size.
  • top_n: Show only top N features with most missingness.
Returns:

matplotlib Figure.

def plot_entity_dendrogram( self, as_of_date: str | None = None, figsize: tuple[int, int] = (14, 8)) -> 'matplotlib.figure.Figure':
101def plot_entity_dendrogram(
102    self,
103    as_of_date: str | None = None,
104    figsize: tuple[int, int] = (14, 8),
105) -> matplotlib.figure.Figure:
106    """Plot hierarchical clustering dendrogram of entities.
107
108    Args:
109        as_of_date: Filter to specific date. If None, uses latest.
110        figsize: Figure size.
111
112    Returns:
113        matplotlib Figure.
114    """
115    _require("matplotlib")
116    _require("scipy")
117    import matplotlib.pyplot as plt
118    from scipy.cluster.hierarchy import dendrogram, linkage
119
120    matrix, sliced, resolved = _entity_matrix(self, as_of_date)
121    fig, ax = plt.subplots(figsize=figsize)
122    if len(matrix) < 2:
123        ax.text(0.5, 0.5, "Need >=2 entities to cluster", ha="center", va="center")
124        return fig
125
126    if self.entity_col in sliced.columns:
127        labels = sliced.loc[matrix.index, self.entity_col].astype(str).tolist()
128    else:
129        labels = [str(i) for i in matrix.index]
130
131    linkage_matrix = linkage(matrix.to_numpy(), method="ward")
132    dendrogram(linkage_matrix, labels=labels, ax=ax, leaf_rotation=90, leaf_font_size=7)
133    ax.set_title(f"Entity Dendrogram (as_of_date={resolved})")
134    ax.set_ylabel("Ward distance")
135    plt.tight_layout()
136    return fig

Plot hierarchical clustering dendrogram of entities.

Arguments:
  • as_of_date: Filter to specific date. If None, uses latest.
  • figsize: Figure size.
Returns:

matplotlib Figure.

def plot_entity_embedding( self, as_of_date: str | None = None, method: str = 'umap', color_by: str | None = None, figsize: tuple[int, int] = (10, 8)) -> 'matplotlib.figure.Figure':
25def plot_entity_embedding(
26    self,
27    as_of_date: str | None = None,
28    method: str = "umap",
29    color_by: str | None = None,
30    figsize: tuple[int, int] = (10, 8),
31) -> matplotlib.figure.Figure:
32    """Plot UMAP/t-SNE/PCA scatter of entity feature vectors.
33
34    Args:
35        as_of_date: Filter to specific date. If None, uses latest.
36        method: Dimensionality reduction method ('umap', 'tsne', 'pca').
37        color_by: Column to color points by.
38        figsize: Figure size.
39
40    Returns:
41        matplotlib Figure.
42    """
43    _require("matplotlib")
44    import matplotlib.pyplot as plt
45
46    matrix, sliced, resolved = _entity_matrix(self, as_of_date)
47    fig, ax = plt.subplots(figsize=figsize)
48    n = len(matrix)
49    if n < 3:
50        ax.text(
51            0.5, 0.5, f"Need >=3 entities to embed (got {n})", ha="center", va="center"
52        )
53        return fig
54
55    if method == "umap":
56        _require("umap")
57        import umap
58
59        coords = umap.UMAP(n_components=2, random_state=42).fit_transform(matrix)
60    elif method == "tsne":
61        _require("sklearn")
62        from sklearn.manifold import TSNE
63
64        perplexity = min(30, max(2, n - 1))
65        coords = TSNE(
66            n_components=2, random_state=42, perplexity=perplexity, init="pca"
67        ).fit_transform(matrix)
68    elif method == "pca":
69        _require("sklearn")
70        from sklearn.decomposition import PCA
71
72        coords = PCA(n_components=2, random_state=42).fit_transform(matrix)
73    else:
74        raise ValueError(f"Unknown method '{method}'; use 'umap', 'tsne', or 'pca'.")
75
76    if color_by is not None and color_by in sliced.columns:
77        import pandas as pd
78
79        values = sliced.loc[matrix.index, color_by]
80        if pd.api.types.is_numeric_dtype(values):
81            sc = ax.scatter(coords[:, 0], coords[:, 1], c=values, cmap="viridis", s=30)
82            fig.colorbar(sc, ax=ax, label=color_by)
83        else:
84            codes, labels = pd.factorize(values)
85            sc = ax.scatter(coords[:, 0], coords[:, 1], c=codes, cmap="tab10", s=30)
86            handles = [
87                plt.Line2D([], [], marker="o", linestyle="", label=str(lab))
88                for lab in labels
89            ]
90            ax.legend(handles=handles, title=color_by, fontsize=7)
91    else:
92        ax.scatter(coords[:, 0], coords[:, 1], s=30, color="steelblue")
93
94    ax.set_title(f"Entity Embedding ({method}, as_of_date={resolved})")
95    ax.set_xlabel("dim 1")
96    ax.set_ylabel("dim 2")
97    plt.tight_layout()
98    return fig

Plot UMAP/t-SNE/PCA scatter of entity feature vectors.

Arguments:
  • as_of_date: Filter to specific date. If None, uses latest.
  • method: Dimensionality reduction method ('umap', 'tsne', 'pca').
  • color_by: Column to color points by.
  • figsize: Figure size.
Returns:

matplotlib Figure.

def plot_entity_feature_heatmap( self, entity_id: str | int, figsize: tuple[int, int] = (16, 10)) -> 'matplotlib.figure.Figure':
 67def plot_entity_feature_heatmap(
 68    self,
 69    entity_id: str | int,
 70    figsize: tuple[int, int] = (16, 10),
 71) -> matplotlib.figure.Figure:
 72    """Plot features x time z-scored heatmap for a single entity.
 73
 74    Args:
 75        entity_id: Entity to plot.
 76        figsize: Figure size.
 77
 78    Returns:
 79        matplotlib Figure.
 80    """
 81    _require("matplotlib")
 82    _require("seaborn")
 83    import matplotlib.pyplot as plt
 84    import seaborn as sns
 85
 86    sub = self.df[self.df[self.entity_col] == entity_id].sort_values(self.as_of_col)
 87    fig, ax = plt.subplots(figsize=figsize)
 88    if sub.empty:
 89        ax.text(0.5, 0.5, f"No rows for entity {entity_id}", ha="center", va="center")
 90        return fig
 91
 92    matrix = _get_feature_matrix(sub, self.feature_cols)
 93    # z-score each feature across this entity's timepoints, then features-as-rows.
 94    data = _zscore(matrix).T
 95    data.columns = sub[self.as_of_col].astype(str).to_numpy()
 96
 97    sns.heatmap(data, cmap="RdBu_r", center=0, ax=ax, cbar_kws={"label": "z-score"})
 98    ax.set_title(f"Feature x Time (entity={entity_id})")
 99    ax.set_xlabel(self.as_of_col)
100    ax.set_ylabel("Feature")
101    plt.setp(ax.get_xticklabels(), rotation=90, fontsize=7)
102    plt.setp(ax.get_yticklabels(), fontsize=6)
103    plt.tight_layout()
104    return fig

Plot features x time z-scored heatmap for a single entity.

Arguments:
  • entity_id: Entity to plot.
  • figsize: Figure size.
Returns:

matplotlib Figure.

def plot_feature_timeseries( self, entity_id: str | int, features: list[str] | None = None, normalize: bool = False, figsize: tuple[int, int] = (14, 8)) -> 'matplotlib.figure.Figure':
12def plot_feature_timeseries(
13    self,
14    entity_id: str | int,
15    features: list[str] | None = None,
16    normalize: bool = False,
17    figsize: tuple[int, int] = (14, 8),
18) -> matplotlib.figure.Figure:
19    """Plot feature values over time for a single entity.
20
21    Args:
22        entity_id: Entity to plot.
23        features: Features to plot. If None, selects top 8 by variance.
24        normalize: Z-score normalize features for comparison.
25        figsize: Figure size.
26
27    Returns:
28        matplotlib Figure.
29    """
30    _require("matplotlib")
31    import matplotlib.pyplot as plt
32
33    sub = self.df[self.df[self.entity_col] == entity_id].sort_values(self.as_of_col)
34    fig, ax = plt.subplots(figsize=figsize)
35    if sub.empty:
36        ax.text(0.5, 0.5, f"No rows for entity {entity_id}", ha="center", va="center")
37        return fig
38
39    matrix = _get_feature_matrix(sub, self.feature_cols)
40    if features is None:
41        features = _top_by_variance(matrix, 8)
42    else:
43        features = [f for f in features if f in matrix.columns]
44    if not features:
45        ax.text(0.5, 0.5, "No numeric features to plot", ha="center", va="center")
46        return fig
47
48    data = matrix[features]
49    if normalize:
50        data = _zscore(data)
51
52    x = sub[self.as_of_col].to_numpy()
53    for col in features:
54        ax.plot(
55            x, data[col].to_numpy(), marker="o", markersize=3, linewidth=1, label=col
56        )
57
58    ax.set_title(f"Feature Time Series (entity={entity_id})")
59    ax.set_xlabel(self.as_of_col)
60    ax.set_ylabel("z-score" if normalize else "value")
61    ax.legend(bbox_to_anchor=(1.05, 1), loc="upper left", fontsize=7)
62    plt.setp(ax.get_xticklabels(), rotation=45, ha="right")
63    plt.tight_layout()
64    return fig

Plot feature values over time for a single entity.

Arguments:
  • entity_id: Entity to plot.
  • features: Features to plot. If None, selects top 8 by variance.
  • normalize: Z-score normalize features for comparison.
  • figsize: Figure size.
Returns:

matplotlib Figure.

def impute_features( df: pandas.core.frame.DataFrame, *, count_fill_zero: bool = True, measure_strategy: str = 'none', add_missing_indicators: bool = True, count_like_prefixes: Sequence[str] = ('COUNT', 'SUM', 'NUNIQUE', 'N_', 'EVENT_RATE'), columns: Sequence[str] | None = None) -> pandas.core.frame.DataFrame:
103def impute_features(
104    df: pd.DataFrame,
105    *,
106    count_fill_zero: bool = True,
107    measure_strategy: str = "none",
108    add_missing_indicators: bool = True,
109    count_like_prefixes: Sequence[str] = DEFAULT_COUNT_LIKE_PREFIXES,
110    columns: Sequence[str] | None = None,
111) -> pd.DataFrame:
112    """Impute a feature matrix, preserving the missingness signal.
113
114    Args:
115        df: Feature matrix. The entity/as-of keys are expected to live in the
116            index (the ``Featurizer.to_dataframe`` shape); pass ``columns`` to
117            restrict which columns are treated as features otherwise.
118        count_fill_zero: Fill count-like features with 0 (structural zero).
119        measure_strategy: Fill for non-count numeric features — one of
120            ``"none"`` (leave NULL), ``"median"``, ``"mean"``.
121        add_missing_indicators: Emit a ``<feature>__missing`` 0/1 column for
122            each column that had NULLs, recorded before filling.
123        count_like_prefixes: Aggregation-name prefixes treated as count-like.
124        columns: Explicit feature columns to operate on (default: all columns).
125
126    Returns:
127        A new DataFrame (the input is never mutated).
128    """
129    _validate_measure_strategy(measure_strategy)
130
131    result = df.copy()
132    target_cols = (
133        list(result.columns)
134        if columns is None
135        else [c for c in columns if c in result.columns]
136    )
137
138    # 1. Missing indicators — recorded BEFORE any fill, only where NULLs exist.
139    if add_missing_indicators:
140        indicators = {
141            f"{c}{MISSING_INDICATOR_SUFFIX}": result[c].isnull().astype(int)
142            for c in target_cols
143            if bool(result[c].isnull().to_numpy().any())
144        }
145        for name, series in indicators.items():
146            result[name] = series
147
148    # 2. Fill — count-like to 0; measures left NULL unless a strategy is given.
149    count_cols = [c for c in target_cols if _is_count_like(c, count_like_prefixes)]
150    measure_cols = [c for c in target_cols if c not in count_cols]
151
152    if count_fill_zero and count_cols:
153        result[count_cols] = result[count_cols].fillna(0)
154
155    if measure_strategy != "none" and measure_cols:
156        numeric_measures = result[measure_cols].select_dtypes(include="number").columns
157        if len(numeric_measures):
158            fill = (
159                result[numeric_measures].median()
160                if measure_strategy == "median"
161                else result[numeric_measures].mean()
162            )
163            result[numeric_measures] = result[numeric_measures].fillna(fill)
164
165    return result

Impute a feature matrix, preserving the missingness signal.

Arguments:
  • df: Feature matrix. The entity/as-of keys are expected to live in the index (the Featurizer.to_dataframe shape); pass columns to restrict which columns are treated as features otherwise.
  • count_fill_zero: Fill count-like features with 0 (structural zero).
  • measure_strategy: Fill for non-count numeric features — one of "none" (leave NULL), "median", "mean".
  • add_missing_indicators: Emit a <feature>__missing 0/1 column for each column that had NULLs, recorded before filling.
  • count_like_prefixes: Aggregation-name prefixes treated as count-like.
  • columns: Explicit feature columns to operate on (default: all columns).
Returns:

A new DataFrame (the input is never mutated).

def impute_arrow( table: pyarrow.lib.Table, *, key_columns: Sequence[str] = (), count_fill_zero: bool = True, measure_strategy: str = 'none', add_missing_indicators: bool = True, count_like_prefixes: Sequence[str] = ('COUNT', 'SUM', 'NUNIQUE', 'N_', 'EVENT_RATE')) -> pyarrow.lib.Table:
168def impute_arrow(
169    table: "pa.Table",
170    *,
171    key_columns: Sequence[str] = (),
172    count_fill_zero: bool = True,
173    measure_strategy: str = "none",
174    add_missing_indicators: bool = True,
175    count_like_prefixes: Sequence[str] = DEFAULT_COUNT_LIKE_PREFIXES,
176) -> "pa.Table":
177    """Arrow-native mirror of :func:`impute_features`.
178
179    Applies the same NULL-preserving contract to a :class:`pyarrow.Table` so the
180    Parquet/Arrow output path behaves identically to ``to_dataframe(impute=...)``:
181    count-like features are filled with the structural zero, measures stay null
182    unless an explicit ``measure_strategy`` is requested, and a stable
183    ``<feature>__missing`` 0/1 column is emitted (recorded *before* any fill) for
184    every feature column that had nulls.
185
186    Unlike the pandas path (where ``as_of_date`` and the target id live in the
187    index), an Arrow table carries the keys as ordinary columns; pass them in
188    ``key_columns`` so they are never treated as features.
189
190    The prefix logic and naming are shared with :func:`impute_features` — the
191    count-like classification (:func:`_is_count_like`) and the
192    :data:`MISSING_INDICATOR_SUFFIX` are not duplicated.
193
194    Args:
195        table: The feature matrix as a pyarrow.Table (e.g. from
196            :class:`featurizer.arrow.ArrowExporter`).
197        key_columns: Columns to leave untouched (``as_of_date``, target id).
198        count_fill_zero: Fill count-like features with 0 (structural zero).
199        measure_strategy: Fill for non-count numeric features — one of
200            ``"none"`` (leave null), ``"median"``, ``"mean"``. NOTE: ``mean`` /
201            ``median`` fit over the whole table; the engine path gates this
202            behind :func:`guard_full_matrix_fit` (ADR-0001 leakage rule).
203        add_missing_indicators: Emit a ``<feature>__missing`` 0/1 column for each
204            feature column that had nulls, recorded before filling.
205        count_like_prefixes: Aggregation-name prefixes treated as count-like.
206
207    Returns:
208        A new pyarrow.Table (the input is never mutated).
209    """
210    import pyarrow as pa  # local import: only needed on the Arrow path
211    import pyarrow.compute as pc
212
213    _validate_measure_strategy(measure_strategy)
214
215    keys = set(key_columns)
216    feature_cols = [n for n in table.column_names if n not in keys]
217
218    columns: dict[str, Any] = {n: table.column(n) for n in table.column_names}
219    appended: list[tuple[str, Any]] = []
220
221    for name in feature_cols:
222        col = table.column(name)
223        had_nulls = col.null_count > 0
224
225        # 1. Missing indicator — recorded BEFORE any fill, only where nulls exist.
226        if add_missing_indicators and had_nulls:
227            # pyarrow.compute functions are generated at import time, so the type
228            # checker cannot see them statically (matches the [bridge] idiom).
229            is_null = pc.is_null(col)  # pyright: ignore[reportAttributeAccessIssue]
230            indicator = pc.cast(is_null, pa.int64())
231            appended.append((f"{name}{MISSING_INDICATOR_SUFFIX}", indicator))
232
233        if not had_nulls:
234            continue
235
236        # 2. Fill — count-like to 0; measures left null unless a strategy is set.
237        if count_fill_zero and _is_count_like(name, count_like_prefixes):
238            columns[name] = _fill_arrow(col, pa.scalar(0), pa)
239        elif measure_strategy != "none" and pa.types.is_floating(col.type):
240            stat = (
241                pc.mean(col)  # pyright: ignore[reportAttributeAccessIssue]
242                if measure_strategy == "mean"
243                else _arrow_median(col, pc)
244            )
245            if stat.is_valid:
246                columns[name] = _fill_arrow(col, stat, pa)
247
248    arrays = [columns[n] for n in table.column_names] + [a for _, a in appended]
249    names = list(table.column_names) + [n for n, _ in appended]
250    return pa.table(dict(zip(names, arrays)))

Arrow-native mirror of impute_features().

Applies the same NULL-preserving contract to a pyarrow.Table so the Parquet/Arrow output path behaves identically to to_dataframe(impute=...): count-like features are filled with the structural zero, measures stay null unless an explicit measure_strategy is requested, and a stable <feature>__missing 0/1 column is emitted (recorded before any fill) for every feature column that had nulls.

Unlike the pandas path (where as_of_date and the target id live in the index), an Arrow table carries the keys as ordinary columns; pass them in key_columns so they are never treated as features.

The prefix logic and naming are shared with impute_features() — the count-like classification (_is_count_like()) and the MISSING_INDICATOR_SUFFIX are not duplicated.

Arguments:
  • table: The feature matrix as a pyarrow.Table (e.g. from featurizer.arrow.ArrowExporter).
  • key_columns: Columns to leave untouched (as_of_date, target id).
  • count_fill_zero: Fill count-like features with 0 (structural zero).
  • measure_strategy: Fill for non-count numeric features — one of "none" (leave null), "median", "mean". NOTE: mean / median fit over the whole table; the engine path gates this behind guard_full_matrix_fit() (ADR-0001 leakage rule).
  • add_missing_indicators: Emit a <feature>__missing 0/1 column for each feature column that had nulls, recorded before filling.
  • count_like_prefixes: Aggregation-name prefixes treated as count-like.
Returns:

A new pyarrow.Table (the input is never mutated).

MISSING_INDICATOR_SUFFIX = '__missing'
class ColumnGroupSharder:
174class ColumnGroupSharder:
175    """Partitions a :class:`PlannerResult` into joinable column-group queries."""
176
177    def __init__(
178        self,
179        plan: PlannerResult,
180        *,
181        max_columns_per_group: int = DEFAULT_MAX_COLUMNS_PER_GROUP,
182        materialize_threshold: int = PG_MAX_TARGET_LIST,
183        max_window_fns_per_group: int = DEFAULT_MAX_WINDOW_FNS_PER_GROUP,
184    ) -> None:
185        if max_columns_per_group < 1:
186            raise ValueError("max_columns_per_group must be a positive integer.")
187        if max_window_fns_per_group < 1:
188            raise ValueError("max_window_fns_per_group must be a positive integer.")
189        self.plan = plan
190        self.max_columns_per_group = max_columns_per_group
191        self.materialize_threshold = materialize_threshold
192        self.max_window_fns_per_group = max_window_fns_per_group
193
194        self.target_alias = plan.target.alias
195        self.transform_name = f"{self.target_alias}_transform"
196        self.synth_name = f"{self.target_alias}_synth"
197
198        transform_spec = plan.cte_specs.get(self.transform_name)
199        synth_spec = plan.cte_specs.get(self.synth_name)
200        if transform_spec is None or synth_spec is None:
201            raise ValueError(
202                "Planner did not record sharding metadata for the target's "
203                f"synth/transform CTEs ({self.synth_name!r} / "
204                f"{self.transform_name!r}). The plan must come from "
205                "FeaturePlanner.plan(); sharding cannot proceed."
206            )
207        self.transform_spec: ShardableCTE = transform_spec
208        self.synth_spec: ShardableCTE = synth_spec
209
210        # Identifier columns the final query carries (as_of_date is added by the
211        # wrapper). The target id is the first identifier column.
212        self._id_columns = list(self.synth_spec.key_columns)
213        self._key_columns = ["as_of_date"] + [
214            self._bare(name) for name in self._id_columns
215        ]
216
217        # Agg CTEs that feed the *target's* synth — the only aggs that are pruned
218        # per group (their columns are target synth columns). Deeper-chain agg
219        # CTEs (e.g. ``items_aggs_for_orders`` when ``orders`` is not the target)
220        # feed a *child* synth, carry no target synth columns, and so must be
221        # emitted whole. Identified by being the source CTE of a target synth
222        # column with ``kind == "aggs"``.
223        self._target_agg_ctes: Set[str] = {
224            cte_name
225            for cte_name, _ in plan.synth_column_source.values()
226            if plan.cte_specs.get(cte_name) is not None
227            and plan.cte_specs[cte_name].kind == "aggs"
228        }
229
230        # All registered CTE names (for the reachability name scan).
231        self._all_cte_names = list(plan.cte_order)
232        self._name_scanner = (
233            _cte_name_scanner(self._all_cte_names) if self._all_cte_names else None
234        )
235        # CTE bodies are immutable once planned, so the name-scan of each body
236        # is computed once and reused across groups (reachability runs per
237        # group for both rendering and the plan-size report).
238        self._body_refs_cache: Dict[str, Set[str]] = {}
239        self._agg_refs_cache: Dict[str, Tuple[Set[str], Dict[str, Set[str]]]] = {}
240
241        # Temp-table materialization for oversized non-target child CTEs (issue
242        # #7). Built lazily and cached: a ``MaterializationPlan`` when the plan
243        # has child CTEs over the threshold, else ``None`` (the common case).
244        self._materializer = MaterializationPlanner(
245            plan, materialize_threshold=materialize_threshold
246        )
247        self._materialization_built = False
248        self._materialization_cache: "MaterializationPlan | None" = None
249
250    def materialization(self) -> "MaterializationPlan | None":
251        """The temp-table preamble plan, or ``None`` when no child CTE is over
252        the threshold. Cached after first call."""
253        if not self._materialization_built:
254            self._materialization_cache = (
255                self._materializer.build()
256                if self._materializer.oversized_ctes()
257                else None
258            )
259            self._materialization_built = True
260        return self._materialization_cache
261
262    # ------------------------------------------------------------------ #
263    # Public API
264    # ------------------------------------------------------------------ #
265
266    @property
267    def key_columns(self) -> List[str]:
268        """The leading columns every group projects and re-joins on
269        (``["as_of_date", <target id>, …]``)."""
270        return list(self._key_columns)
271
272    @property
273    def fits_single_group(self) -> bool:
274        """True when the unsharded query is valid and needs no materialization.
275
276        This is the test ``Featurizer.query`` uses, judged against the *hard*
277        PostgreSQL limit (``PG_MAX_TARGET_LIST``), independent of the (smaller,
278        headroom-leaving) ``max_columns_per_group`` partition size: a single
279        valid query requires the target transform tuple **and** every
280        intermediate CTE tuple to be ≤ the limit. A 1500-column matrix is one
281        valid query even though it would shard into two groups for output.
282
283        A config with any child CTE over ``materialize_threshold`` does **not**
284        fit a single query — it needs the TEMP-table preamble (issue #7) — so it
285        is reported as not-single here too. With the default threshold (the hard
286        1664 limit) this matches the old behaviour exactly; a lower threshold
287        (advanced / testing) forces the materialized path.
288        """
289        target_width = len(self.transform_spec.key_columns) + len(
290            self.transform_spec.columns
291        )
292        return (
293            target_width <= PG_MAX_TARGET_LIST
294            and not self._materializer.oversized_ctes()
295        )
296
297    @property
298    def n_groups(self) -> int:
299        """Number of column groups the target transform partitions into."""
300        return len(self._partition_columns())
301
302    def group_queries(self) -> "OrderedDict[str, str]":
303        """Render every column group to a self-contained SQL string."""
304        return self.build().queries
305
306    def column_groups(self) -> "OrderedDict[str, List[str]]":
307        """Map ``group_<NNN>`` -> the feature column names that group carries.
308
309        Mirrors :meth:`_partition_columns` exactly (same order, same chunking),
310        so the mapping matches what :meth:`build` renders. Key columns
311        (``as_of_date`` + the target id) are carried by *every* group and are
312        not listed. Used by ``Featurizer.to_tables`` to record, per manifest
313        row, which feature-group table the column landed in.
314        """
315        mapping: "OrderedDict[str, List[str]]" = OrderedDict()
316        for idx, group_columns in enumerate(self._partition_columns()):
317            mapping[f"group_{idx:03d}"] = [c.name for c in group_columns]
318        return mapping
319
320    def plan_size_report(self) -> "OrderedDict[str, int]":
321        """Map ``group_<NNN>`` -> the CTE-closure size of that group's query.
322
323        The closure is exactly the set of CTEs :meth:`build` would emit for the
324        group (target synth/transform included, temp-materialized CTEs
325        excluded), computed without rendering any SQL. PostgreSQL's planner
326        memory and planning time grow with this number, so it is the pre-flight
327        predictor of planner blowup — see :meth:`warn_plan_size`.
328        """
329        mplan = self.materialization()
330        materialized: AbstractSet[str] = (
331            mplan.materialized_ctes if mplan is not None else frozenset()
332        )
333        report: "OrderedDict[str, int]" = OrderedDict()
334        for idx, group_columns in enumerate(self._partition_columns()):
335            _, _, reachable = self._group_dependencies(group_columns, materialized)
336            report[f"group_{idx:03d}"] = len(reachable)
337        return report
338
339    def warn_plan_size(self) -> None:
340        """Log a loud, actionable warning when a group query's CTE closure
341        predicts a PostgreSQL planner blowup.
342
343        Evidence (2026-07-10, live donorschoose `wide`): group queries closing
344        over ~450–980 CTEs took 27–43s of *planning* each (5,300+ plan nodes)
345        and OOM-killed the backend during a plain ``EXPLAIN`` on a 12 GiB
346        server — with a 3,000-row cohort, so data volume was irrelevant.
347        Configs that completed (dirtyduck/chicago311 wide) stayed materially
348        smaller. The warning fires per run, not per group, and names the worst
349        offenders so the user can shrink the config (fewer transformers /
350        intervals / entities) before burning minutes on a doomed run.
351        """
352        report = self.plan_size_report()
353        offenders = {gid: n for gid, n in report.items() if n > PLAN_SIZE_WARN_CLOSURE}
354        if not offenders:
355            return
356        worst = sorted(offenders.items(), key=lambda kv: -kv[1])[:5]
357        logger.warning(
358            "Plan-size risk: {} of {} column-group queries carry a CTE closure "
359            "over {} (worst: {}). PostgreSQL planning time/memory grows with "
360            "closure size — measured on live data, ~1000-CTE groups took 30-45s "
361            "of planning EACH and OOM-killed the backend regardless of row "
362            "count. Reduce the config's transformer/interval/entity breadth, or "
363            "expect very long runs and possible 'server closed the connection "
364            "unexpectedly' failures on memory-constrained servers.",
365            len(offenders),
366            len(report),
367            PLAN_SIZE_WARN_CLOSURE,
368            ", ".join(f"{gid}={n}" for gid, n in worst),
369        )
370
371    def build(self) -> GroupedQueries:
372        """Partition + render. Returns the ordered group queries and join keys.
373
374        When the plan has oversized non-target child CTEs, the materialization
375        preamble is computed once and every group query is rendered to reference
376        the resulting temp-table shards (the materialized CTEs and their now-dead
377        upstreams are dropped from each group's ``with``)."""
378        mplan = self.materialization()
379        materialized: Set[str] = mplan.materialized_ctes if mplan is not None else set()
380        groups = self._partition_columns()
381        queries: "OrderedDict[str, str]" = OrderedDict()
382        for idx, group_columns in enumerate(groups):
383            gid = f"group_{idx:03d}"
384            queries[gid] = self._render_group(group_columns, materialized, mplan)
385        return GroupedQueries(
386            queries=queries,
387            key_columns=list(self._key_columns),
388            fits_single=self.fits_single_group,
389            materialization=mplan,
390        )
391
392    # ------------------------------------------------------------------ #
393    # Partitioning
394    # ------------------------------------------------------------------ #
395
396    def _partition_columns(self) -> List[List[ColumnSpec]]:
397        """Split the target transform columns into ordered ≤-limit groups,
398        clustering columns that share a dependency lineage.
399
400        Columns are bucketed by their *source-CTE signature* — the set of CTEs
401        their ``depends_on`` synth columns come from — and buckets are packed
402        in sorted-signature order. Same-lineage columns therefore land in the
403        same group (up to the size limit), so each group's CTE closure stays
404        small and a companion pre-aggregation CTE is emitted/executed by the
405        few groups that need it instead of most of them. Measured on the
406        donorschoose ``wide`` config (27 groups): max closure 979 → 287 CTEs,
407        total closure 11,338 → 2,428, duplicated companion instances 899 → 18,
408        emitted SQL 29.2 MB → 17.4 MB — the difference between OOM-killing the
409        backend during planning and a plannable query set.
410
411        Deterministic: signatures sort lexicographically and columns keep
412        their emission order within a bucket, so the same plan always yields
413        the same partition (``column_groups`` / the manifest rely on this).
414        """
415        columns = list(self.transform_spec.columns)
416        if not columns:
417            return [[]]
418
419        def signature(col: ColumnSpec) -> Tuple[str, ...]:
420            sources = {
421                source[0]
422                for dep in col.depends_on
423                if (source := self.plan.synth_column_source.get(dep)) is not None
424            }
425            return tuple(sorted(sources))
426
427        buckets: "OrderedDict[Tuple[str, ...], List[ColumnSpec]]" = OrderedDict()
428        for col in columns:
429            buckets.setdefault(signature(col), []).append(col)
430
431        # Dual budget: the column limit keeps the tuple under PostgreSQL's
432        # target-list cap; the window-function limit keeps *planning* off the
433        # superlinear memory cliff (see DEFAULT_MAX_WINDOW_FNS_PER_GROUP — a
434        # ~1350-window select list OOM-killed the backend at a plain EXPLAIN).
435        per = self.max_columns_per_group
436        max_windows = self.max_window_fns_per_group
437        groups: List[List[ColumnSpec]] = []
438        current: List[ColumnSpec] = []
439        current_windows = 0
440        for sig in sorted(buckets.keys()):
441            for col in buckets[sig]:
442                n_windows = self._n_window_fns(col)
443                if current and (
444                    len(current) == per or current_windows + n_windows > max_windows
445                ):
446                    groups.append(current)
447                    current = []
448                    current_windows = 0
449                current.append(col)
450                current_windows += n_windows
451        if current:
452            groups.append(current)
453        return groups
454
455    @staticmethod
456    def _n_window_fns(col: ColumnSpec) -> int:
457        """Window-function count of one projection (``over (`` occurrences)."""
458        return len(_WINDOW_FN_RE.findall(col.projection))
459
460    # ------------------------------------------------------------------ #
461    # Per-group rendering
462    # ------------------------------------------------------------------ #
463
464    def _group_dependencies(
465        self,
466        group_columns: List[ColumnSpec],
467        materialized: AbstractSet[str] = frozenset(),
468    ) -> Tuple[Set[str], List[str], Set[str]]:
469        """One group's pruned dependencies: (needed synth columns, kept joins,
470        reachable CTE closure). Shared by :meth:`_render_group` (which renders
471        the closure) and :meth:`plan_size_report` (which only counts it)."""
472        # 1. Synth columns this group needs = union of its columns' deps.
473        needed_synth: Set[str] = set()
474        for col in group_columns:
475            needed_synth.update(col.depends_on)
476
477        # A pathological fan-out (transformers referencing many synth columns)
478        # could push the pruned synth tuple over the limit even though the
479        # transform tuple is within budget. Fail fast with context rather than
480        # emit a query PostgreSQL will reject.
481        synth_width = len(self.synth_spec.key_columns) + len(needed_synth)
482        if synth_width > PG_MAX_TARGET_LIST:
483            raise ValueError(
484                f"A column group's pruned synth CTE would project {synth_width} "
485                f"columns, over PostgreSQL's {PG_MAX_TARGET_LIST}-entry limit: the "
486                f"group's {len(group_columns)} transform columns depend on "
487                f"{len(needed_synth)} distinct synth columns. Lower "
488                "max_columns_per_group so each group's synth fan-out stays under "
489                "the limit."
490            )
491
492        # 2. Joins + upstream CTEs feeding those synth columns. Base-table
493        #    variables have no source entry (they come from the target table).
494        kept_joins: List[str] = []
495        seen_joins: Set[str] = set()
496        kept_upstream: Set[str] = set()
497        for col in sorted(needed_synth):
498            source = self.plan.synth_column_source.get(col)
499            if source is None:
500                continue  # base-table variable, available from the target table
501            cte_name, join_sql = source
502            kept_upstream.add(cte_name)
503            if join_sql not in seen_joins:
504                seen_joins.add(join_sql)
505                kept_joins.append(join_sql)
506
507        # 3. Reachability: pull in every CTE transitively referenced by the
508        #    target synth/transform, the kept upstream CTEs, or the kept joins.
509        #    Materialized CTEs are temp tables, so they (and any upstream reachable
510        #    only through them) are excluded — the rewrite reads their shards.
511        reachable = self._reachable_ctes(
512            kept_upstream, kept_joins, materialized, needed_synth
513        )
514        return needed_synth, kept_joins, reachable
515
516    def _render_group(
517        self,
518        group_columns: List[ColumnSpec],
519        materialized: AbstractSet[str] = frozenset(),
520        mplan: "MaterializationPlan | None" = None,
521    ) -> str:
522        """Build the self-contained query for one column group.
523
524        ``materialized`` names the CTEs backed by temp tables (issue #7): they are
525        dropped from the ``with`` list and references to them are rewritten to
526        their shards. ``mplan`` carries the shard mapping for the rewrite."""
527        needed_synth, kept_joins, reachable = self._group_dependencies(
528            group_columns, materialized
529        )
530
531        # Render the CTE list in original emission order.
532        rendered_ctes = self._render_ctes(
533            reachable, group_columns, needed_synth, kept_joins, materialized, mplan
534        )
535
536        return self._wrap(rendered_ctes, group_columns)
537
538    def _reachable_ctes(
539        self,
540        kept_upstream: Set[str],
541        kept_joins: List[str],
542        materialized: AbstractSet[str] = frozenset(),
543        needed_synth: AbstractSet[str] = frozenset(),
544    ) -> Set[str]:
545        """Transitive closure of CTE names a group's query references.
546
547        Seeds: target synth + transform (always present), the upstream CTEs
548        feeding kept synth columns, and any CTE named in a kept join's text
549        (e.g. an as-of lateral join reads ``<source>_transform``). The frontier
550        then expands by scanning each *non-target* reached CTE's own body for
551        further CTE names.
552
553        The target synth/transform are deliberately **not** body-scanned: they
554        are re-rendered per group with pruned joins, so their dependencies are
555        exactly ``kept_upstream`` + ``kept_joins`` — scanning their full-width
556        ``rendered`` text would spuriously pull in agg/peer CTEs this group
557        dropped.
558
559        A target-level agg CTE is likewise emitted *pruned* to the group's
560        ``needed_synth`` columns (:meth:`_render_agg`), so its refs are the
561        union of its surviving columns' refs, not the full-width body's —
562        otherwise a companion pre-aggregation CTE whose only consumer columns
563        landed in other groups would ride along as a dead (unreferenced) CTE
564        in every group that touches the relationship at all.
565        """
566        target_ctes = {self.synth_name, self.transform_name}
567        reachable: Set[str] = set(target_ctes)
568
569        seeds: Set[str] = {n for n in kept_upstream if n in self.plan.cte_order}
570        for join_sql in kept_joins:
571            seeds.update(self._names_in(join_sql))
572        seeds -= target_ctes
573        seeds -= materialized  # temp tables, not emitted as CTEs
574
575        frontier = list(seeds)
576        reachable.update(seeds)
577        while frontier:
578            name = frontier.pop()
579            if name in self._target_agg_ctes:
580                refs = self._agg_refs(name, needed_synth)
581            else:
582                refs = self._body_refs(name)
583            for ref in refs:
584                if (
585                    ref not in reachable
586                    and ref not in target_ctes
587                    and ref not in materialized
588                ):
589                    reachable.add(ref)
590                    frontier.append(ref)
591        return reachable
592
593    def _agg_refs(self, name: str, needed_synth: AbstractSet[str]) -> Set[str]:
594        """CTE refs of a target agg CTE as :meth:`_render_agg` will emit it.
595
596        Per-column projection refs are scanned once and cached (the name-scan
597        regex is a >2000-alternation on wide plans; re-scanning a rendered
598        multi-hundred-column body per group measured ~30s on the donorschoose
599        wide config). A group's refs = the CTE's frame (prefix/suffix: the
600        ``from <child>_transform`` scan etc.) + its surviving columns' refs.
601        """
602        cached = self._agg_refs_cache.get(name)
603        if cached is None:
604            spec = self.plan.cte_specs[name]
605            frame = self._names_in(spec.prefix + spec.suffix)
606            for key_col in spec.key_columns:
607                frame |= self._names_in(key_col)
608            per_column = {c.name: self._names_in(c.projection) for c in spec.columns}
609            cached = (frame, per_column)
610            self._agg_refs_cache[name] = cached
611        frame, per_column = cached
612        refs = set(frame)
613        for col_name, col_refs in per_column.items():
614            if col_name in needed_synth:
615                refs |= col_refs
616        return refs
617
618    def _body_refs(self, name: str) -> Set[str]:
619        """CTE names referenced by ``name``'s body (memoized — bodies are
620        immutable once planned, and reachability runs once per group)."""
621        cached = self._body_refs_cache.get(name)
622        if cached is None:
623            body = self._cte_body(name)
624            cached = self._names_in(body) if body is not None else set()
625            self._body_refs_cache[name] = cached
626        return cached
627
628    def _render_ctes(
629        self,
630        reachable: Set[str],
631        group_columns: List[ColumnSpec],
632        needed_synth: Set[str],
633        kept_joins: List[str],
634        materialized: AbstractSet[str] = frozenset(),
635        mplan: "MaterializationPlan | None" = None,
636    ) -> List[str]:
637        """Render each reachable CTE in emission order, pruning the target's.
638
639        Materialized CTEs are never emitted (they are temp tables); each emitted
640        CTE's references to a materialized CTE are rewritten to read its shards.
641        """
642        out: List[str] = []
643        for name in self.plan.cte_order:
644            if name not in reachable or name in materialized:
645                continue
646            if name == self.transform_name:
647                text = self._render_transform(group_columns)
648            elif name == self.synth_name:
649                text = self._render_synth(needed_synth, kept_joins)
650            elif name in self._target_agg_ctes:
651                # A target-level agg CTE: prune to the surviving target synth
652                # columns it feeds.
653                text = self._render_agg(self.plan.cte_specs[name], needed_synth)
654            else:
655                # A bounded / non-target CTE (verbatim string, a deeper-chain
656                # agg, or a full-width child synth/transform): emit whole.
657                text = self._cte_body(name) or ""
658            if mplan is not None:
659                # e.g. a target-level agg's ``from <child>_transform`` becomes a
660                # re-join over that transform's materialized shards.
661                text = self._materializer.rewrite_group_body(text, mplan)
662            out.append(text)
663        return out
664
665    def _render_transform(self, group_columns: List[ColumnSpec]) -> str:
666        spec = self.transform_spec
667        projections = spec.sql_keys + [c.projection for c in group_columns]
668        return spec.prefix + ",\n        ".join(projections) + spec.suffix
669
670    def _render_synth(self, needed_synth: Set[str], kept_joins: List[str]) -> str:
671        spec = self.synth_spec
672        surviving = [c.projection for c in spec.columns if c.name in needed_synth]
673        projections = spec.sql_keys + surviving
674        select_list = ",\n        ".join(projections)
675        joins_sql = ""
676        if kept_joins:
677            joins_sql = "\n        left join " + "\n        left join ".join(kept_joins)
678        # The paired-cohort predicate (issue #10) follows whatever joins this
679        # group kept. Empty for the default dense cohort.
680        where_sql = f"\n        {spec.where}" if spec.where else ""
681        return (
682            spec.prefix
683            + select_list
684            + spec.suffix
685            + joins_sql
686            + where_sql
687            + "\n        )\n        "
688        )
689
690    def _render_agg(self, spec: ShardableCTE, needed_synth: Set[str]) -> str:
691        surviving = [c.projection for c in spec.columns if c.name in needed_synth]
692        # The group only reaches this agg CTE because it keeps ≥1 of its
693        # columns, so ``surviving`` is non-empty here.
694        projections = spec.sql_keys + surviving
695        return spec.prefix + ",\n        ".join(projections) + spec.suffix
696
697    def _wrap(self, rendered_ctes: List[str], group_columns: List[ColumnSpec]) -> str:
698        """Wrap the CTEs in the lateral-join shell, selecting the group output.
699
700        Identical shape to :class:`featurizer.sql.SQLRenderer`: ``t.*`` over the
701        pruned ``<target>_transform`` CTE, which is built to lead with the
702        identifier columns (the target id first) followed by this group's
703        feature columns — so every group's output is ``(as_of_date, <id>,
704        <group features…>)`` and the groups re-join on ``(as_of_date, id)``.
705
706        ``t.*`` (not an explicit ``t."col"`` list) is deliberate: generated
707        feature identifiers can exceed PostgreSQL's 63-byte limit and a verbatim
708        long name would not resolve against the (server-truncated) projected
709        column. The transform CTE already fixes the column order, so ``*`` is
710        both correct and limit-safe.
711        """
712        ctes = ",".join(rendered_ctes)
713        # Same two switches as SQLRenderer (issue #10); bare defaults otherwise.
714        spine = as_of_dates_source(paired=self.plan.cohort_id_column is not None)
715        post_filter = (
716            f" {self.plan.cohort_post_filter}" if self.plan.cohort_post_filter else ""
717        )
718        return f"""
719        select aod.as_of_date, t.*
720        from {spine} as aod
721        cross join lateral (
722
723        with
724
725        {ctes}
726
727        select * from {self.transform_name}{post_filter}
728        ) as t
729
730        order by aod.as_of_date
731        """
732
733    # ------------------------------------------------------------------ #
734    # Limit diagnostics
735    # ------------------------------------------------------------------ #
736
737    def _oversized_intermediate_ctes(self) -> Dict[str, int]:
738        """CTEs that sharding cannot shrink yet still exceed the limit.
739
740        These are the *documented limitation*: a single child entity whose own
741        transform/synth tuple (or a deeper-chain agg CTE) already exceeds 1664
742        cannot be made to fit by sharding the *target's* output — pruning
743        operates per target column group, and such a CTE is reused whole across
744        groups. The target's own ``transform``/``synth`` and its per-child
745        ``aggs`` are pruned per group, so they are *not* counted here even when
746        their full width is over the limit. We surface the rest rather than
747        silently truncating.
748        """
749        prunable = {self.transform_name, self.synth_name} | self._target_agg_ctes
750        oversized: Dict[str, int] = {}
751        for name, spec in self.plan.cte_specs.items():
752            if name in prunable:
753                continue  # re-rendered (pruned) per group, never over-limit there
754            width = len(spec.key_columns) + len(spec.columns)
755            if width > PG_MAX_TARGET_LIST:
756                oversized[name] = width
757        return oversized
758
759    def warn_oversized(self) -> None:
760        """Log a clear warning for any oversized intermediate CTE that **cannot**
761        be materialized into temp-table shards (issue #7).
762
763        An oversized child CTE with a recorded join key is handled by the
764        materialization path (``MaterializationPlanner``), so it no longer warns.
765        Only a CTE with no materialization key — an id-less entity's synth that
766        cannot be re-joined — remains an un-fixable bound worth surfacing.
767        """
768        for name, width in self._oversized_intermediate_ctes().items():
769            if name in self.plan.materialization_keys:
770                continue  # handled by temp-table materialization
771            logger.warning(
772                "Sharding bound: intermediate CTE {!r} projects {} columns, over "
773                "PostgreSQL's {}-entry target-list limit, and cannot be "
774                "materialized (no join key — its entity has no id to re-join on). "
775                "Reduce the child entity's primitive/interval breadth, give it an "
776                "id, or raise the relationship that produces it to the target.",
777                name,
778                width,
779                PG_MAX_TARGET_LIST,
780            )
781
782    # ------------------------------------------------------------------ #
783    # Small helpers
784    # ------------------------------------------------------------------ #
785
786    def _cte_body(self, name: str) -> str | None:
787        """The full-width rendered text for a CTE (verbatim or shardable).
788
789        Non-target CTEs are emitted byte-for-byte identical to the single-query
790        renderer (the planner stashed each one's full text), so a non-target
791        child synth keeps its own joins and a single-group config reproduces
792        ``Featurizer.query`` exactly. Only the *target's* synth/transform/aggs
793        are re-rendered (pruned) per group, handled by the callers.
794        """
795        if name in self.plan.verbatim_ctes:
796            return self.plan.verbatim_ctes[name]
797        spec = self.plan.cte_specs.get(name)
798        if spec is None:
799            return None
800        return spec.rendered
801
802    def _names_in(self, text: str) -> Set[str]:
803        if self._name_scanner is None:
804            return set()
805        return set(self._name_scanner.findall(text))
806
807    @staticmethod
808    def _bare(name: str) -> str:
809        """The unqualified column reference for ``t.<col>`` selection.
810
811        ``key_columns`` arrive table-qualified (``customers.customer_id``); the
812        outer select references them by their projected (bare) name. Quoted
813        feature identifiers are passed through unchanged.
814        """
815        if name.startswith('"'):
816            return name
817        return name.rsplit(".", 1)[-1]

Partitions a PlannerResult into joinable column-group queries.

ColumnGroupSharder( plan: featurizer.planner.PlannerResult, *, max_columns_per_group: int = 1400, materialize_threshold: int = 1664, max_window_fns_per_group: int = 500)
177    def __init__(
178        self,
179        plan: PlannerResult,
180        *,
181        max_columns_per_group: int = DEFAULT_MAX_COLUMNS_PER_GROUP,
182        materialize_threshold: int = PG_MAX_TARGET_LIST,
183        max_window_fns_per_group: int = DEFAULT_MAX_WINDOW_FNS_PER_GROUP,
184    ) -> None:
185        if max_columns_per_group < 1:
186            raise ValueError("max_columns_per_group must be a positive integer.")
187        if max_window_fns_per_group < 1:
188            raise ValueError("max_window_fns_per_group must be a positive integer.")
189        self.plan = plan
190        self.max_columns_per_group = max_columns_per_group
191        self.materialize_threshold = materialize_threshold
192        self.max_window_fns_per_group = max_window_fns_per_group
193
194        self.target_alias = plan.target.alias
195        self.transform_name = f"{self.target_alias}_transform"
196        self.synth_name = f"{self.target_alias}_synth"
197
198        transform_spec = plan.cte_specs.get(self.transform_name)
199        synth_spec = plan.cte_specs.get(self.synth_name)
200        if transform_spec is None or synth_spec is None:
201            raise ValueError(
202                "Planner did not record sharding metadata for the target's "
203                f"synth/transform CTEs ({self.synth_name!r} / "
204                f"{self.transform_name!r}). The plan must come from "
205                "FeaturePlanner.plan(); sharding cannot proceed."
206            )
207        self.transform_spec: ShardableCTE = transform_spec
208        self.synth_spec: ShardableCTE = synth_spec
209
210        # Identifier columns the final query carries (as_of_date is added by the
211        # wrapper). The target id is the first identifier column.
212        self._id_columns = list(self.synth_spec.key_columns)
213        self._key_columns = ["as_of_date"] + [
214            self._bare(name) for name in self._id_columns
215        ]
216
217        # Agg CTEs that feed the *target's* synth — the only aggs that are pruned
218        # per group (their columns are target synth columns). Deeper-chain agg
219        # CTEs (e.g. ``items_aggs_for_orders`` when ``orders`` is not the target)
220        # feed a *child* synth, carry no target synth columns, and so must be
221        # emitted whole. Identified by being the source CTE of a target synth
222        # column with ``kind == "aggs"``.
223        self._target_agg_ctes: Set[str] = {
224            cte_name
225            for cte_name, _ in plan.synth_column_source.values()
226            if plan.cte_specs.get(cte_name) is not None
227            and plan.cte_specs[cte_name].kind == "aggs"
228        }
229
230        # All registered CTE names (for the reachability name scan).
231        self._all_cte_names = list(plan.cte_order)
232        self._name_scanner = (
233            _cte_name_scanner(self._all_cte_names) if self._all_cte_names else None
234        )
235        # CTE bodies are immutable once planned, so the name-scan of each body
236        # is computed once and reused across groups (reachability runs per
237        # group for both rendering and the plan-size report).
238        self._body_refs_cache: Dict[str, Set[str]] = {}
239        self._agg_refs_cache: Dict[str, Tuple[Set[str], Dict[str, Set[str]]]] = {}
240
241        # Temp-table materialization for oversized non-target child CTEs (issue
242        # #7). Built lazily and cached: a ``MaterializationPlan`` when the plan
243        # has child CTEs over the threshold, else ``None`` (the common case).
244        self._materializer = MaterializationPlanner(
245            plan, materialize_threshold=materialize_threshold
246        )
247        self._materialization_built = False
248        self._materialization_cache: "MaterializationPlan | None" = None
plan
max_columns_per_group
materialize_threshold
max_window_fns_per_group
target_alias
transform_name
synth_name
def materialization(self) -> featurizer.sharding.MaterializationPlan | None:
250    def materialization(self) -> "MaterializationPlan | None":
251        """The temp-table preamble plan, or ``None`` when no child CTE is over
252        the threshold. Cached after first call."""
253        if not self._materialization_built:
254            self._materialization_cache = (
255                self._materializer.build()
256                if self._materializer.oversized_ctes()
257                else None
258            )
259            self._materialization_built = True
260        return self._materialization_cache

The temp-table preamble plan, or None when no child CTE is over the threshold. Cached after first call.

key_columns: List[str]
266    @property
267    def key_columns(self) -> List[str]:
268        """The leading columns every group projects and re-joins on
269        (``["as_of_date", <target id>, …]``)."""
270        return list(self._key_columns)

The leading columns every group projects and re-joins on (["as_of_date", <target id>, …]).

fits_single_group: bool
272    @property
273    def fits_single_group(self) -> bool:
274        """True when the unsharded query is valid and needs no materialization.
275
276        This is the test ``Featurizer.query`` uses, judged against the *hard*
277        PostgreSQL limit (``PG_MAX_TARGET_LIST``), independent of the (smaller,
278        headroom-leaving) ``max_columns_per_group`` partition size: a single
279        valid query requires the target transform tuple **and** every
280        intermediate CTE tuple to be ≤ the limit. A 1500-column matrix is one
281        valid query even though it would shard into two groups for output.
282
283        A config with any child CTE over ``materialize_threshold`` does **not**
284        fit a single query — it needs the TEMP-table preamble (issue #7) — so it
285        is reported as not-single here too. With the default threshold (the hard
286        1664 limit) this matches the old behaviour exactly; a lower threshold
287        (advanced / testing) forces the materialized path.
288        """
289        target_width = len(self.transform_spec.key_columns) + len(
290            self.transform_spec.columns
291        )
292        return (
293            target_width <= PG_MAX_TARGET_LIST
294            and not self._materializer.oversized_ctes()
295        )

True when the unsharded query is valid and needs no materialization.

This is the test Featurizer.query uses, judged against the hard PostgreSQL limit (PG_MAX_TARGET_LIST), independent of the (smaller, headroom-leaving) max_columns_per_group partition size: a single valid query requires the target transform tuple and every intermediate CTE tuple to be ≤ the limit. A 1500-column matrix is one valid query even though it would shard into two groups for output.

A config with any child CTE over materialize_threshold does not fit a single query — it needs the TEMP-table preamble (issue #7) — so it is reported as not-single here too. With the default threshold (the hard 1664 limit) this matches the old behaviour exactly; a lower threshold (advanced / testing) forces the materialized path.

n_groups: int
297    @property
298    def n_groups(self) -> int:
299        """Number of column groups the target transform partitions into."""
300        return len(self._partition_columns())

Number of column groups the target transform partitions into.

def group_queries(self) -> collections.OrderedDict[str, str]:
302    def group_queries(self) -> "OrderedDict[str, str]":
303        """Render every column group to a self-contained SQL string."""
304        return self.build().queries

Render every column group to a self-contained SQL string.

def column_groups(self) -> collections.OrderedDict[str, typing.List[str]]:
306    def column_groups(self) -> "OrderedDict[str, List[str]]":
307        """Map ``group_<NNN>`` -> the feature column names that group carries.
308
309        Mirrors :meth:`_partition_columns` exactly (same order, same chunking),
310        so the mapping matches what :meth:`build` renders. Key columns
311        (``as_of_date`` + the target id) are carried by *every* group and are
312        not listed. Used by ``Featurizer.to_tables`` to record, per manifest
313        row, which feature-group table the column landed in.
314        """
315        mapping: "OrderedDict[str, List[str]]" = OrderedDict()
316        for idx, group_columns in enumerate(self._partition_columns()):
317            mapping[f"group_{idx:03d}"] = [c.name for c in group_columns]
318        return mapping

Map group_<NNN> -> the feature column names that group carries.

Mirrors _partition_columns() exactly (same order, same chunking), so the mapping matches what build() renders. Key columns (as_of_date + the target id) are carried by every group and are not listed. Used by Featurizer.to_tables to record, per manifest row, which feature-group table the column landed in.

def plan_size_report(self) -> collections.OrderedDict[str, int]:
320    def plan_size_report(self) -> "OrderedDict[str, int]":
321        """Map ``group_<NNN>`` -> the CTE-closure size of that group's query.
322
323        The closure is exactly the set of CTEs :meth:`build` would emit for the
324        group (target synth/transform included, temp-materialized CTEs
325        excluded), computed without rendering any SQL. PostgreSQL's planner
326        memory and planning time grow with this number, so it is the pre-flight
327        predictor of planner blowup — see :meth:`warn_plan_size`.
328        """
329        mplan = self.materialization()
330        materialized: AbstractSet[str] = (
331            mplan.materialized_ctes if mplan is not None else frozenset()
332        )
333        report: "OrderedDict[str, int]" = OrderedDict()
334        for idx, group_columns in enumerate(self._partition_columns()):
335            _, _, reachable = self._group_dependencies(group_columns, materialized)
336            report[f"group_{idx:03d}"] = len(reachable)
337        return report

Map group_<NNN> -> the CTE-closure size of that group's query.

The closure is exactly the set of CTEs build() would emit for the group (target synth/transform included, temp-materialized CTEs excluded), computed without rendering any SQL. PostgreSQL's planner memory and planning time grow with this number, so it is the pre-flight predictor of planner blowup — see warn_plan_size().

def warn_plan_size(self) -> None:
339    def warn_plan_size(self) -> None:
340        """Log a loud, actionable warning when a group query's CTE closure
341        predicts a PostgreSQL planner blowup.
342
343        Evidence (2026-07-10, live donorschoose `wide`): group queries closing
344        over ~450–980 CTEs took 27–43s of *planning* each (5,300+ plan nodes)
345        and OOM-killed the backend during a plain ``EXPLAIN`` on a 12 GiB
346        server — with a 3,000-row cohort, so data volume was irrelevant.
347        Configs that completed (dirtyduck/chicago311 wide) stayed materially
348        smaller. The warning fires per run, not per group, and names the worst
349        offenders so the user can shrink the config (fewer transformers /
350        intervals / entities) before burning minutes on a doomed run.
351        """
352        report = self.plan_size_report()
353        offenders = {gid: n for gid, n in report.items() if n > PLAN_SIZE_WARN_CLOSURE}
354        if not offenders:
355            return
356        worst = sorted(offenders.items(), key=lambda kv: -kv[1])[:5]
357        logger.warning(
358            "Plan-size risk: {} of {} column-group queries carry a CTE closure "
359            "over {} (worst: {}). PostgreSQL planning time/memory grows with "
360            "closure size — measured on live data, ~1000-CTE groups took 30-45s "
361            "of planning EACH and OOM-killed the backend regardless of row "
362            "count. Reduce the config's transformer/interval/entity breadth, or "
363            "expect very long runs and possible 'server closed the connection "
364            "unexpectedly' failures on memory-constrained servers.",
365            len(offenders),
366            len(report),
367            PLAN_SIZE_WARN_CLOSURE,
368            ", ".join(f"{gid}={n}" for gid, n in worst),
369        )

Log a loud, actionable warning when a group query's CTE closure predicts a PostgreSQL planner blowup.

Evidence (2026-07-10, live donorschoose wide): group queries closing over ~450–980 CTEs took 27–43s of planning each (5,300+ plan nodes) and OOM-killed the backend during a plain EXPLAIN on a 12 GiB server — with a 3,000-row cohort, so data volume was irrelevant. Configs that completed (dirtyduck/chicago311 wide) stayed materially smaller. The warning fires per run, not per group, and names the worst offenders so the user can shrink the config (fewer transformers / intervals / entities) before burning minutes on a doomed run.

def build(self) -> GroupedQueries:
371    def build(self) -> GroupedQueries:
372        """Partition + render. Returns the ordered group queries and join keys.
373
374        When the plan has oversized non-target child CTEs, the materialization
375        preamble is computed once and every group query is rendered to reference
376        the resulting temp-table shards (the materialized CTEs and their now-dead
377        upstreams are dropped from each group's ``with``)."""
378        mplan = self.materialization()
379        materialized: Set[str] = mplan.materialized_ctes if mplan is not None else set()
380        groups = self._partition_columns()
381        queries: "OrderedDict[str, str]" = OrderedDict()
382        for idx, group_columns in enumerate(groups):
383            gid = f"group_{idx:03d}"
384            queries[gid] = self._render_group(group_columns, materialized, mplan)
385        return GroupedQueries(
386            queries=queries,
387            key_columns=list(self._key_columns),
388            fits_single=self.fits_single_group,
389            materialization=mplan,
390        )

Partition + render. Returns the ordered group queries and join keys.

When the plan has oversized non-target child CTEs, the materialization preamble is computed once and every group query is rendered to reference the resulting temp-table shards (the materialized CTEs and their now-dead upstreams are dropped from each group's with).

def warn_oversized(self) -> None:
759    def warn_oversized(self) -> None:
760        """Log a clear warning for any oversized intermediate CTE that **cannot**
761        be materialized into temp-table shards (issue #7).
762
763        An oversized child CTE with a recorded join key is handled by the
764        materialization path (``MaterializationPlanner``), so it no longer warns.
765        Only a CTE with no materialization key — an id-less entity's synth that
766        cannot be re-joined — remains an un-fixable bound worth surfacing.
767        """
768        for name, width in self._oversized_intermediate_ctes().items():
769            if name in self.plan.materialization_keys:
770                continue  # handled by temp-table materialization
771            logger.warning(
772                "Sharding bound: intermediate CTE {!r} projects {} columns, over "
773                "PostgreSQL's {}-entry target-list limit, and cannot be "
774                "materialized (no join key — its entity has no id to re-join on). "
775                "Reduce the child entity's primitive/interval breadth, give it an "
776                "id, or raise the relationship that produces it to the target.",
777                name,
778                width,
779                PG_MAX_TARGET_LIST,
780            )

Log a clear warning for any oversized intermediate CTE that cannot be materialized into temp-table shards (issue #7).

An oversized child CTE with a recorded join key is handled by the materialization path (MaterializationPlanner), so it no longer warns. Only a CTE with no materialization key — an id-less entity's synth that cannot be re-joined — remains an un-fixable bound worth surfacing.

@dataclass(frozen=True)
class GroupedQueries:
126@dataclass(frozen=True)
127class GroupedQueries:
128    """Result of sharding: the ordered group queries plus the join keys.
129
130    ``queries`` maps ``group_<NNN>`` -> the self-contained SQL for that group.
131    ``key_columns`` are the leading columns every group projects and on which
132    all groups re-join (``["as_of_date", <target id>]``). ``fits_single`` is
133    True when the whole matrix fits in one group (the single-query fast path).
134    ``materialization`` carries the TEMP-table preamble (issue #7) when an
135    oversized non-target child CTE had to be materialized; the group ``queries``
136    then reference those temp tables and presuppose ``materialization.ddl`` ran
137    first on the same connection. ``None`` for the common (no-preamble) case.
138    """
139
140    queries: "OrderedDict[str, str]"
141    key_columns: List[str]
142    fits_single: bool
143    materialization: "MaterializationPlan | None" = None

Result of sharding: the ordered group queries plus the join keys.

queries maps group_<NNN> -> the self-contained SQL for that group. key_columns are the leading columns every group projects and on which all groups re-join (["as_of_date", <target id>]). fits_single is True when the whole matrix fits in one group (the single-query fast path). materialization carries the TEMP-table preamble (issue #7) when an oversized non-target child CTE had to be materialized; the group queries then reference those temp tables and presuppose materialization.ddl ran first on the same connection. None for the common (no-preamble) case.

GroupedQueries( queries: collections.OrderedDict[str, str], key_columns: List[str], fits_single: bool, materialization: featurizer.sharding.MaterializationPlan | None = None)
queries: collections.OrderedDict[str, str]
key_columns: List[str]
fits_single: bool
materialization: featurizer.sharding.MaterializationPlan | None = None
DEFAULT_MAX_COLUMNS_PER_GROUP = 1400
def validate_config( config_path: str, mode: str = 'strict') -> ValidationResult:
1503def validate_config(config_path: str, mode: str = "strict") -> ValidationResult:
1504    """Validate a configuration file.
1505
1506    Args:
1507        config_path: Path to YAML configuration file
1508        mode: Validation mode - "strict" or "permissive"
1509
1510    Returns:
1511        ValidationResult with any errors and warnings
1512
1513    Raises:
1514        FileNotFoundError: If config file doesn't exist
1515        yaml.YAMLError: If YAML is malformed
1516    """
1517    with open(config_path) as f:
1518        config = yaml.safe_load(f) or {}
1519
1520    validator = ConfigValidator(mode=mode)
1521    return validator.validate(config)

Validate a configuration file.

Arguments:
  • config_path: Path to YAML configuration file
  • mode: Validation mode - "strict" or "permissive"
Returns:

ValidationResult with any errors and warnings

Raises:
  • FileNotFoundError: If config file doesn't exist
  • yaml.YAMLError: If YAML is malformed
@dataclass
class ValidationResult:
53@dataclass
54class ValidationResult:
55    """Result of configuration validation."""
56
57    errors: List[ValidationError] = field(default_factory=list)
58    warnings: List[ValidationWarning] = field(default_factory=list)
59
60    @property
61    def is_valid(self) -> bool:
62        """Check if configuration is valid (no errors)."""
63        return len(self.errors) == 0
64
65    def format_errors(self) -> str:
66        """Format all errors for display."""
67        if not self.errors:
68            return "Configuration is valid."
69
70        lines = [f"Configuration has {len(self.errors)} error(s):\n"]
71
72        for i, error in enumerate(self.errors, 1):
73            lines.append(f"  {i}. {error.format()}")
74
75        if self.warnings:
76            lines.append(f"\n{len(self.warnings)} warning(s):")
77            for warning in self.warnings:
78                location = f"[{warning.location}] " if warning.location else ""
79                lines.append(f"  ⚠ {location}{warning.message}")
80
81        return "\n".join(lines)

Result of configuration validation.

ValidationResult( errors: List[ValidationError] = <factory>, warnings: List[ValidationWarning] = <factory>)
errors: List[ValidationError]
warnings: List[ValidationWarning]
is_valid: bool
60    @property
61    def is_valid(self) -> bool:
62        """Check if configuration is valid (no errors)."""
63        return len(self.errors) == 0

Check if configuration is valid (no errors).

def format_errors(self) -> str:
65    def format_errors(self) -> str:
66        """Format all errors for display."""
67        if not self.errors:
68            return "Configuration is valid."
69
70        lines = [f"Configuration has {len(self.errors)} error(s):\n"]
71
72        for i, error in enumerate(self.errors, 1):
73            lines.append(f"  {i}. {error.format()}")
74
75        if self.warnings:
76            lines.append(f"\n{len(self.warnings)} warning(s):")
77            for warning in self.warnings:
78                location = f"[{warning.location}] " if warning.location else ""
79                lines.append(f"  ⚠ {location}{warning.message}")
80
81        return "\n".join(lines)

Format all errors for display.

@dataclass
class ValidationError:
21@dataclass
22class ValidationError:
23    """A single validation error with context."""
24
25    message: str
26    location: Optional[str] = None
27    suggestion: Optional[str] = None
28    line_number: Optional[int] = None
29
30    def format(self) -> str:
31        """Format error for display."""
32        parts = []
33
34        if self.location:
35            parts.append(f"[{self.location}]")
36
37        parts.append(self.message)
38
39        if self.suggestion:
40            parts.append(f"\n  → {self.suggestion}")
41
42        return " ".join(parts)

A single validation error with context.

ValidationError( message: str, location: Optional[str] = None, suggestion: Optional[str] = None, line_number: Optional[int] = None)
message: str
location: Optional[str] = None
suggestion: Optional[str] = None
line_number: Optional[int] = None
def format(self) -> str:
30    def format(self) -> str:
31        """Format error for display."""
32        parts = []
33
34        if self.location:
35            parts.append(f"[{self.location}]")
36
37        parts.append(self.message)
38
39        if self.suggestion:
40            parts.append(f"\n  → {self.suggestion}")
41
42        return " ".join(parts)

Format error for display.

@dataclass
class ValidationWarning:
45@dataclass
46class ValidationWarning:
47    """A validation warning (non-fatal)."""
48
49    message: str
50    location: Optional[str] = None

A validation warning (non-fatal).

ValidationWarning(message: str, location: Optional[str] = None)
message: str
location: Optional[str] = None