snowflake-ml-python 2.2.0__py3-none-any.whl → 2.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (192) hide show
  1. snowflake/cortex/_classify_text.py +51 -5
  2. snowflake/cortex/_complete.py +41 -9
  3. snowflake/cortex/_embed_text_1024.py +11 -5
  4. snowflake/cortex/_embed_text_768.py +11 -5
  5. snowflake/cortex/_extract_answer.py +54 -12
  6. snowflake/cortex/_sentiment.py +50 -10
  7. snowflake/cortex/_summarize.py +11 -5
  8. snowflake/cortex/_translate.py +11 -5
  9. snowflake/cortex/_util.py +35 -0
  10. snowflake/ml/experiment/_entities/run.py +14 -6
  11. snowflake/ml/experiment/callback/catboost.py +75 -0
  12. snowflake/ml/experiment/experiment_tracking.py +41 -24
  13. snowflake/ml/feature_store/feature_store.py +55 -0
  14. snowflake/ml/feature_store/tile_sql_generator.py +40 -31
  15. snowflake/ml/jobs/job.py +19 -3
  16. snowflake/ml/model/_client/model/batch_inference_job_specs.py +81 -1
  17. snowflake/ml/model/_client/model/batch_inference_task.py +24 -6
  18. snowflake/ml/model/_client/model/model_version_impl.py +97 -5
  19. snowflake/ml/model/_client/ops/model_ops.py +253 -6
  20. snowflake/ml/model/_client/ops/service_ops.py +5 -4
  21. snowflake/ml/model/_client/service/inference_job_service_spec.py +1 -1
  22. snowflake/ml/model/_client/sql/model.py +11 -3
  23. snowflake/ml/model/_signatures/core.py +9 -4
  24. snowflake/ml/model/model_signature.py +85 -0
  25. snowflake/ml/modeling/_internal/model_specifications.py +10 -4
  26. snowflake/ml/modeling/_internal/snowpark_implementations/distributed_hpo_trainer.py +0 -2
  27. snowflake/ml/modeling/calibration/calibrated_classifier_cv.py +2 -1
  28. snowflake/ml/modeling/cluster/affinity_propagation.py +2 -1
  29. snowflake/ml/modeling/cluster/agglomerative_clustering.py +2 -1
  30. snowflake/ml/modeling/cluster/birch.py +2 -1
  31. snowflake/ml/modeling/cluster/bisecting_k_means.py +2 -1
  32. snowflake/ml/modeling/cluster/dbscan.py +2 -1
  33. snowflake/ml/modeling/cluster/feature_agglomeration.py +2 -1
  34. snowflake/ml/modeling/cluster/k_means.py +2 -1
  35. snowflake/ml/modeling/cluster/mean_shift.py +2 -1
  36. snowflake/ml/modeling/cluster/mini_batch_k_means.py +2 -1
  37. snowflake/ml/modeling/cluster/optics.py +2 -1
  38. snowflake/ml/modeling/cluster/spectral_biclustering.py +2 -1
  39. snowflake/ml/modeling/cluster/spectral_clustering.py +2 -1
  40. snowflake/ml/modeling/cluster/spectral_coclustering.py +2 -1
  41. snowflake/ml/modeling/compose/column_transformer.py +2 -1
  42. snowflake/ml/modeling/compose/transformed_target_regressor.py +2 -1
  43. snowflake/ml/modeling/covariance/elliptic_envelope.py +2 -1
  44. snowflake/ml/modeling/covariance/empirical_covariance.py +2 -1
  45. snowflake/ml/modeling/covariance/graphical_lasso.py +2 -1
  46. snowflake/ml/modeling/covariance/graphical_lasso_cv.py +2 -1
  47. snowflake/ml/modeling/covariance/ledoit_wolf.py +2 -1
  48. snowflake/ml/modeling/covariance/min_cov_det.py +2 -1
  49. snowflake/ml/modeling/covariance/oas.py +2 -1
  50. snowflake/ml/modeling/covariance/shrunk_covariance.py +2 -1
  51. snowflake/ml/modeling/decomposition/dictionary_learning.py +2 -1
  52. snowflake/ml/modeling/decomposition/factor_analysis.py +2 -1
  53. snowflake/ml/modeling/decomposition/fast_ica.py +2 -1
  54. snowflake/ml/modeling/decomposition/incremental_pca.py +2 -1
  55. snowflake/ml/modeling/decomposition/kernel_pca.py +2 -1
  56. snowflake/ml/modeling/decomposition/mini_batch_dictionary_learning.py +2 -1
  57. snowflake/ml/modeling/decomposition/mini_batch_sparse_pca.py +2 -1
  58. snowflake/ml/modeling/decomposition/pca.py +2 -1
  59. snowflake/ml/modeling/decomposition/sparse_pca.py +2 -1
  60. snowflake/ml/modeling/decomposition/truncated_svd.py +2 -1
  61. snowflake/ml/modeling/discriminant_analysis/linear_discriminant_analysis.py +2 -1
  62. snowflake/ml/modeling/discriminant_analysis/quadratic_discriminant_analysis.py +2 -1
  63. snowflake/ml/modeling/ensemble/ada_boost_classifier.py +2 -1
  64. snowflake/ml/modeling/ensemble/ada_boost_regressor.py +2 -1
  65. snowflake/ml/modeling/ensemble/bagging_classifier.py +2 -1
  66. snowflake/ml/modeling/ensemble/bagging_regressor.py +2 -1
  67. snowflake/ml/modeling/ensemble/extra_trees_classifier.py +2 -1
  68. snowflake/ml/modeling/ensemble/extra_trees_regressor.py +2 -1
  69. snowflake/ml/modeling/ensemble/gradient_boosting_classifier.py +2 -1
  70. snowflake/ml/modeling/ensemble/gradient_boosting_regressor.py +2 -1
  71. snowflake/ml/modeling/ensemble/hist_gradient_boosting_classifier.py +2 -1
  72. snowflake/ml/modeling/ensemble/hist_gradient_boosting_regressor.py +2 -1
  73. snowflake/ml/modeling/ensemble/isolation_forest.py +2 -1
  74. snowflake/ml/modeling/ensemble/random_forest_classifier.py +2 -1
  75. snowflake/ml/modeling/ensemble/random_forest_regressor.py +2 -1
  76. snowflake/ml/modeling/ensemble/stacking_regressor.py +2 -1
  77. snowflake/ml/modeling/ensemble/voting_classifier.py +2 -1
  78. snowflake/ml/modeling/ensemble/voting_regressor.py +2 -1
  79. snowflake/ml/modeling/feature_selection/generic_univariate_select.py +2 -1
  80. snowflake/ml/modeling/feature_selection/select_fdr.py +2 -1
  81. snowflake/ml/modeling/feature_selection/select_fpr.py +2 -1
  82. snowflake/ml/modeling/feature_selection/select_fwe.py +2 -1
  83. snowflake/ml/modeling/feature_selection/select_k_best.py +2 -1
  84. snowflake/ml/modeling/feature_selection/select_percentile.py +2 -1
  85. snowflake/ml/modeling/feature_selection/sequential_feature_selector.py +2 -1
  86. snowflake/ml/modeling/feature_selection/variance_threshold.py +2 -1
  87. snowflake/ml/modeling/gaussian_process/gaussian_process_classifier.py +2 -1
  88. snowflake/ml/modeling/gaussian_process/gaussian_process_regressor.py +2 -1
  89. snowflake/ml/modeling/impute/iterative_imputer.py +2 -1
  90. snowflake/ml/modeling/impute/knn_imputer.py +2 -1
  91. snowflake/ml/modeling/impute/missing_indicator.py +2 -1
  92. snowflake/ml/modeling/kernel_approximation/additive_chi2_sampler.py +2 -1
  93. snowflake/ml/modeling/kernel_approximation/nystroem.py +2 -1
  94. snowflake/ml/modeling/kernel_approximation/polynomial_count_sketch.py +2 -1
  95. snowflake/ml/modeling/kernel_approximation/rbf_sampler.py +2 -1
  96. snowflake/ml/modeling/kernel_approximation/skewed_chi2_sampler.py +2 -1
  97. snowflake/ml/modeling/kernel_ridge/kernel_ridge.py +2 -1
  98. snowflake/ml/modeling/lightgbm/lgbm_classifier.py +2 -1
  99. snowflake/ml/modeling/lightgbm/lgbm_regressor.py +2 -1
  100. snowflake/ml/modeling/linear_model/ard_regression.py +2 -1
  101. snowflake/ml/modeling/linear_model/bayesian_ridge.py +2 -1
  102. snowflake/ml/modeling/linear_model/elastic_net.py +2 -1
  103. snowflake/ml/modeling/linear_model/elastic_net_cv.py +2 -1
  104. snowflake/ml/modeling/linear_model/gamma_regressor.py +2 -1
  105. snowflake/ml/modeling/linear_model/huber_regressor.py +2 -1
  106. snowflake/ml/modeling/linear_model/lars.py +2 -1
  107. snowflake/ml/modeling/linear_model/lars_cv.py +2 -1
  108. snowflake/ml/modeling/linear_model/lasso.py +2 -1
  109. snowflake/ml/modeling/linear_model/lasso_cv.py +2 -1
  110. snowflake/ml/modeling/linear_model/lasso_lars.py +2 -1
  111. snowflake/ml/modeling/linear_model/lasso_lars_cv.py +2 -1
  112. snowflake/ml/modeling/linear_model/lasso_lars_ic.py +2 -1
  113. snowflake/ml/modeling/linear_model/linear_regression.py +2 -1
  114. snowflake/ml/modeling/linear_model/logistic_regression.py +2 -1
  115. snowflake/ml/modeling/linear_model/logistic_regression_cv.py +2 -1
  116. snowflake/ml/modeling/linear_model/multi_task_elastic_net.py +2 -1
  117. snowflake/ml/modeling/linear_model/multi_task_elastic_net_cv.py +2 -1
  118. snowflake/ml/modeling/linear_model/multi_task_lasso.py +2 -1
  119. snowflake/ml/modeling/linear_model/multi_task_lasso_cv.py +2 -1
  120. snowflake/ml/modeling/linear_model/orthogonal_matching_pursuit.py +2 -1
  121. snowflake/ml/modeling/linear_model/passive_aggressive_classifier.py +2 -1
  122. snowflake/ml/modeling/linear_model/passive_aggressive_regressor.py +2 -1
  123. snowflake/ml/modeling/linear_model/perceptron.py +2 -1
  124. snowflake/ml/modeling/linear_model/poisson_regressor.py +2 -1
  125. snowflake/ml/modeling/linear_model/ransac_regressor.py +2 -1
  126. snowflake/ml/modeling/linear_model/ridge.py +2 -1
  127. snowflake/ml/modeling/linear_model/ridge_classifier.py +2 -1
  128. snowflake/ml/modeling/linear_model/ridge_classifier_cv.py +2 -1
  129. snowflake/ml/modeling/linear_model/ridge_cv.py +2 -1
  130. snowflake/ml/modeling/linear_model/sgd_classifier.py +2 -1
  131. snowflake/ml/modeling/linear_model/sgd_one_class_svm.py +2 -1
  132. snowflake/ml/modeling/linear_model/sgd_regressor.py +2 -1
  133. snowflake/ml/modeling/linear_model/theil_sen_regressor.py +2 -1
  134. snowflake/ml/modeling/linear_model/tweedie_regressor.py +2 -1
  135. snowflake/ml/modeling/manifold/isomap.py +2 -1
  136. snowflake/ml/modeling/manifold/mds.py +2 -1
  137. snowflake/ml/modeling/manifold/spectral_embedding.py +2 -1
  138. snowflake/ml/modeling/manifold/tsne.py +2 -1
  139. snowflake/ml/modeling/metrics/classification.py +1 -0
  140. snowflake/ml/modeling/metrics/metrics_utils.py +6 -0
  141. snowflake/ml/modeling/metrics/ranking.py +3 -0
  142. snowflake/ml/modeling/metrics/regression.py +3 -0
  143. snowflake/ml/modeling/mixture/bayesian_gaussian_mixture.py +2 -1
  144. snowflake/ml/modeling/mixture/gaussian_mixture.py +2 -1
  145. snowflake/ml/modeling/model_selection/grid_search_cv.py +2 -0
  146. snowflake/ml/modeling/model_selection/randomized_search_cv.py +2 -0
  147. snowflake/ml/modeling/multiclass/one_vs_one_classifier.py +2 -1
  148. snowflake/ml/modeling/multiclass/one_vs_rest_classifier.py +2 -1
  149. snowflake/ml/modeling/multiclass/output_code_classifier.py +2 -1
  150. snowflake/ml/modeling/naive_bayes/bernoulli_nb.py +2 -1
  151. snowflake/ml/modeling/naive_bayes/categorical_nb.py +2 -1
  152. snowflake/ml/modeling/naive_bayes/complement_nb.py +2 -1
  153. snowflake/ml/modeling/naive_bayes/gaussian_nb.py +2 -1
  154. snowflake/ml/modeling/naive_bayes/multinomial_nb.py +2 -1
  155. snowflake/ml/modeling/neighbors/k_neighbors_classifier.py +2 -1
  156. snowflake/ml/modeling/neighbors/k_neighbors_regressor.py +2 -1
  157. snowflake/ml/modeling/neighbors/kernel_density.py +2 -1
  158. snowflake/ml/modeling/neighbors/local_outlier_factor.py +2 -1
  159. snowflake/ml/modeling/neighbors/nearest_centroid.py +2 -1
  160. snowflake/ml/modeling/neighbors/nearest_neighbors.py +2 -1
  161. snowflake/ml/modeling/neighbors/neighborhood_components_analysis.py +2 -1
  162. snowflake/ml/modeling/neighbors/radius_neighbors_classifier.py +2 -1
  163. snowflake/ml/modeling/neighbors/radius_neighbors_regressor.py +2 -1
  164. snowflake/ml/modeling/neural_network/bernoulli_rbm.py +2 -1
  165. snowflake/ml/modeling/neural_network/mlp_classifier.py +2 -1
  166. snowflake/ml/modeling/neural_network/mlp_regressor.py +2 -1
  167. snowflake/ml/modeling/pipeline/pipeline.py +6 -1
  168. snowflake/ml/modeling/preprocessing/polynomial_features.py +2 -1
  169. snowflake/ml/modeling/semi_supervised/label_propagation.py +2 -1
  170. snowflake/ml/modeling/semi_supervised/label_spreading.py +2 -1
  171. snowflake/ml/modeling/svm/linear_svc.py +2 -1
  172. snowflake/ml/modeling/svm/linear_svr.py +2 -1
  173. snowflake/ml/modeling/svm/nu_svc.py +2 -1
  174. snowflake/ml/modeling/svm/nu_svr.py +2 -1
  175. snowflake/ml/modeling/svm/svc.py +2 -1
  176. snowflake/ml/modeling/svm/svr.py +2 -1
  177. snowflake/ml/modeling/tree/decision_tree_classifier.py +2 -1
  178. snowflake/ml/modeling/tree/decision_tree_regressor.py +2 -1
  179. snowflake/ml/modeling/tree/extra_tree_classifier.py +2 -1
  180. snowflake/ml/modeling/tree/extra_tree_regressor.py +2 -1
  181. snowflake/ml/modeling/xgboost/xgb_classifier.py +2 -1
  182. snowflake/ml/modeling/xgboost/xgb_regressor.py +2 -1
  183. snowflake/ml/modeling/xgboost/xgbrf_classifier.py +2 -1
  184. snowflake/ml/modeling/xgboost/xgbrf_regressor.py +2 -1
  185. snowflake/ml/registry/_manager/model_manager.py +78 -0
  186. snowflake/ml/registry/_manager/model_parameter_reconciler.py +7 -7
  187. snowflake/ml/version.py +1 -1
  188. {snowflake_ml_python-2.2.0.dist-info → snowflake_ml_python-2.4.0.dist-info}/METADATA +71 -13
  189. {snowflake_ml_python-2.2.0.dist-info → snowflake_ml_python-2.4.0.dist-info}/RECORD +192 -191
  190. {snowflake_ml_python-2.2.0.dist-info → snowflake_ml_python-2.4.0.dist-info}/WHEEL +0 -0
  191. {snowflake_ml_python-2.2.0.dist-info → snowflake_ml_python-2.4.0.dist-info}/licenses/LICENSE.txt +0 -0
  192. {snowflake_ml_python-2.2.0.dist-info → snowflake_ml_python-2.4.0.dist-info}/top_level.txt +0 -0
@@ -1,9 +1,9 @@
1
1
  import contextlib
2
2
  import functools
3
3
  import json
4
+ import logging
4
5
  import sys
5
6
  import traceback
6
- import warnings
7
7
  from collections.abc import Callable
8
8
  from types import TracebackType
9
9
  from typing import Any
@@ -32,6 +32,8 @@ from snowflake.snowpark import functions
32
32
 
33
33
  DEFAULT_EXPERIMENT_NAME = sql_identifier.SqlIdentifier("DEFAULT")
34
34
 
35
+ logger = logging.getLogger(__name__)
36
+
35
37
 
36
38
  class ExperimentTracking:
37
39
  """
@@ -52,6 +54,8 @@ class ExperimentTracking:
52
54
  database_name: str | None = None,
53
55
  schema_name: str | None = None,
54
56
  capture_source_info: bool = True,
57
+ model_registry: registry.Registry | None = None,
58
+ warnings_as_errors: bool = False,
55
59
  ) -> None:
56
60
  """
57
61
  Initializes experiment tracking within a pre-created schema.
@@ -68,20 +72,27 @@ class ExperimentTracking:
68
72
  of the enclosing ML job when running inside one. Set to False to skip collection
69
73
  entirely. Collection is always non-fatal and never blocks run creation. Defaults to
70
74
  True.
75
+ model_registry: An optional Registry instance to use for ``log_model``. When provided,
76
+ models are logged to the database and schema owned by this registry instead of the
77
+ experiment's database and schema. This is useful when the experiment lives in one
78
+ schema (e.g. ``analytics.experiments``) but models should be stored in another
79
+ (e.g. ``ml.models``). If None, models are logged to the same location as the
80
+ experiment. Defaults to None.
81
+ warnings_as_errors: If True, conditions that are normally reported as warnings are raised
82
+ instead.
71
83
 
72
84
  Raises:
73
85
  ValueError: If no database is provided and no active database exists in the session.
74
86
  """
75
87
  # Applied on every construction (even when reusing the singleton)
76
88
  self._capture_source_info = capture_source_info
89
+ self._warnings_as_errors = warnings_as_errors
77
90
 
78
91
  if hasattr(self, "_initialized"):
79
- warnings.warn(
92
+ logger.info(
80
93
  "ExperimentTracking is a singleton class. Reusing the existing instance, which has the setting:\n"
81
94
  f" Database: {self._database_name}, Schema: {self._schema_name}\n"
82
- "To change the database or schema, use the database_name and schema_name arguments to set_experiment.",
83
- UserWarning,
84
- stacklevel=2,
95
+ "To change the database or schema, use the database_name and schema_name arguments to set_experiment."
85
96
  )
86
97
  return
87
98
 
@@ -109,11 +120,7 @@ class ExperimentTracking:
109
120
  database_name=self._database_name,
110
121
  schema_name=self._schema_name,
111
122
  )
112
- self._registry = registry.Registry(
113
- session=session,
114
- database_name=self._database_name,
115
- schema_name=self._schema_name,
116
- )
123
+ self._registry = model_registry
117
124
  self._session = session
118
125
 
119
126
  # The experiment in context
@@ -139,7 +146,10 @@ class ExperimentTracking:
139
146
  else:
140
147
  state = dict(self.__dict__)
141
148
 
142
- # Remove unpicklable attributes
149
+ # Remove unpicklable attributes; save registry coordinates for reconstruction.
150
+ if self._registry is not None:
151
+ state["_registry_database_name"] = self._registry._database_name
152
+ state["_registry_schema_name"] = self._registry._schema_name
143
153
  state["_session"] = None
144
154
  state["_sql_client"] = None
145
155
  state["_registry"] = None
@@ -168,11 +178,14 @@ class ExperimentTracking:
168
178
  database_name=self._database_name,
169
179
  schema_name=self._schema_name,
170
180
  )
171
- self._registry = registry.Registry(
172
- session=self._session,
173
- database_name=self._database_name,
174
- schema_name=self._schema_name,
175
- )
181
+ registry_db = state.pop("_registry_database_name", None)
182
+ registry_schema = state.pop("_registry_schema_name", None)
183
+ if registry_db is not None:
184
+ self._registry = registry.Registry(
185
+ session=self._session,
186
+ database_name=registry_db,
187
+ schema_name=registry_schema,
188
+ )
176
189
  if self._run:
177
190
  self._patch_stdout_and_stderr()
178
191
  # _patch_stdout_and_stderr sets _logging_context if live logging is enabled
@@ -277,8 +290,17 @@ class ExperimentTracking:
277
290
  **kwargs: Any,
278
291
  ) -> ml_model.ModelVersion:
279
292
  run = self._get_or_start_run()
293
+ target_registry = (
294
+ self._registry
295
+ if self._registry is not None
296
+ else registry.Registry(
297
+ session=self._session,
298
+ database_name=self._database_name,
299
+ schema_name=self._schema_name,
300
+ )
301
+ )
280
302
  with experiment_info.ExperimentInfoPatcher(experiment_info=run._get_experiment_info()):
281
- return self._registry.log_model(model, model_name=model_name, **kwargs)
303
+ return target_registry.log_model(model, model_name=model_name, **kwargs)
282
304
 
283
305
  def list_model_versions(self, run_name: str | None = None) -> list[ml_model.ModelVersion]:
284
306
  """
@@ -475,7 +497,7 @@ class ExperimentTracking:
475
497
  )
476
498
  except snowpark.exceptions.SnowparkSQLException as e:
477
499
  if e.sql_error_code == 400003: # EXPERIMENT_RUN_PROPERTY_SIZE_LIMIT_EXCEEDED
478
- run._warn_about_run_metadata_size(e.message)
500
+ run._handle_run_metadata_size_limit(e.message)
479
501
  else:
480
502
  raise
481
503
 
@@ -520,7 +542,7 @@ class ExperimentTracking:
520
542
  )
521
543
  except snowpark.exceptions.SnowparkSQLException as e:
522
544
  if e.sql_error_code == 400003: # EXPERIMENT_RUN_PROPERTY_SIZE_LIMIT_EXCEEDED
523
- run._warn_about_run_metadata_size(e.message)
545
+ run._handle_run_metadata_size_limit(e.message)
524
546
  else:
525
547
  raise
526
548
 
@@ -784,11 +806,6 @@ class ExperimentTracking:
784
806
  database_name=database_name,
785
807
  schema_name=schema_name,
786
808
  )
787
- self._registry = registry.Registry(
788
- session=self._session,
789
- database_name=database_name,
790
- schema_name=schema_name,
791
- )
792
809
 
793
810
  def _print_urls(
794
811
  self,
@@ -1770,6 +1770,8 @@ class FeatureStore:
1770
1770
  error_code=error_codes.INVALID_ARGUMENT, original_exception=e
1771
1771
  ) from e
1772
1772
 
1773
+ self._validate_online_target_lag_change(feature_view, online_config)
1774
+
1773
1775
  if updated_feature_df is not None:
1774
1776
  if not feature_view.append_only:
1775
1777
  raise snowml_exceptions.SnowflakeMLException(
@@ -4543,6 +4545,59 @@ class FeatureStore:
4543
4545
  rollback_operations: list[tuple[str, str | FeatureView]]
4544
4546
  final_config: fv_mod.OnlineConfig | None
4545
4547
 
4548
+ @staticmethod
4549
+ def _validate_online_target_lag_change(feature_view: FeatureView, online_config: fv_mod.OnlineConfig) -> None:
4550
+ """Reject a target_lag change on an online feature table backed by Postgres.
4551
+
4552
+ Postgres-backed online feature tables accept the target lag they are created with but
4553
+ not a later change to it: the ALTER is acknowledged without being applied. Rejecting the
4554
+ change here keeps the call from reporting a success that did not happen.
4555
+
4556
+ Only a change that would otherwise be attempted is rejected. Enabling online storage
4557
+ creates the table with the requested lag, disabling it ignores the lag, and a lag equal
4558
+ to the current one is already a no-op.
4559
+
4560
+ Args:
4561
+ feature_view: The registered feature view being updated.
4562
+ online_config: The caller-supplied target online configuration.
4563
+
4564
+ Raises:
4565
+ SnowflakeMLException: [ValueError] The feature view's online store is Postgres and
4566
+ the requested target_lag differs from the current one.
4567
+ """
4568
+ existing_config = feature_view.online_config
4569
+ if online_config.target_lag is None or online_config.enable is False:
4570
+ return
4571
+ if not feature_view.online or existing_config is None:
4572
+ return
4573
+ if existing_config.store_type != OnlineStoreType.POSTGRES:
4574
+ return
4575
+ deployed_lag = existing_config.target_lag
4576
+ if deployed_lag is None:
4577
+ return
4578
+
4579
+ # Neither lag is canonical: the table may report "300 SECONDS" where the caller asked
4580
+ # for "5 minutes", so an unchanged lag is only recognizable on seconds. An unreadable
4581
+ # lag lets the call through, because OnlineConfig accepts any non-empty string and
4582
+ # reporting malformed intervals is not this check's job.
4583
+ try:
4584
+ if interval_utils.interval_to_seconds(online_config.target_lag) == interval_utils.interval_to_seconds(
4585
+ deployed_lag
4586
+ ):
4587
+ return
4588
+ except ValueError:
4589
+ return
4590
+
4591
+ raise snowml_exceptions.SnowflakeMLException(
4592
+ error_code=error_codes.INVALID_ARGUMENT,
4593
+ original_exception=ValueError(
4594
+ f"target_lag cannot be changed for a feature view whose online store is "
4595
+ f"{OnlineStoreType.POSTGRES.name}; it is fixed at "
4596
+ f"'{existing_config.target_lag}'. Delete and re-register the feature view with "
4597
+ f"the desired target_lag."
4598
+ ),
4599
+ )
4600
+
4546
4601
  def _plan_online_update(
4547
4602
  self, feature_view: FeatureView, online_config: fv_mod.OnlineConfig | None
4548
4603
  ) -> _OnlineUpdateStrategy:
@@ -1143,6 +1143,34 @@ class MergingSqlGenerator:
1143
1143
 
1144
1144
  return cte_name, cte_body.strip()
1145
1145
 
1146
+ def _left_join_subqueries_on_bounds(self, subqueries: list[tuple[str, list[str]]]) -> str:
1147
+ """LEFT JOIN each per-subquery result onto the unique (entity, boundary) rows.
1148
+
1149
+ Driving from UNIQUE_BOUNDS rather than from the first subquery keeps row
1150
+ presence independent of any single window or feature: each subquery applies
1151
+ its own row-level filter, so one with no qualifying tiles must contribute
1152
+ NULLs for its own columns instead of dropping the entity from the CTE and
1153
+ taking every other subquery's results with it.
1154
+
1155
+ Args:
1156
+ subqueries: One (subquery_sql, output_columns) pair per window group or
1157
+ feature. Each subquery must be grouped by the join keys plus
1158
+ TILE_BOUNDARY so the joins stay one-to-one.
1159
+
1160
+ Returns:
1161
+ SQL string selecting the join keys plus every subquery's output columns.
1162
+ """
1163
+ group_by_cols = list(self._join_keys) + [_TILE_BOUNDARY_COL]
1164
+ projected = [f"UB.{col}" for col in group_by_cols]
1165
+ for index, (_, output_cols) in enumerate(subqueries):
1166
+ projected.extend(f"sq{index}.{col}" for col in output_cols)
1167
+
1168
+ result = f"SELECT {', '.join(projected)}\n FROM UNIQUE_BOUNDS_FV{self._fv_index} UB"
1169
+ for index, (query, _) in enumerate(subqueries):
1170
+ join_cond = " AND ".join(f"UB.{col} = sq{index}.{col}" for col in group_by_cols)
1171
+ result += f"\n LEFT JOIN {query} sq{index}\n ON {join_cond}"
1172
+ return result
1173
+
1146
1174
  def _generate_secondary_key_merged_cte(self) -> tuple[str, str]:
1147
1175
  """Generate the CTE for secondary-key aggregations.
1148
1176
 
@@ -1315,22 +1343,13 @@ class MergingSqlGenerator:
1315
1343
  if len(subqueries) == 1:
1316
1344
  cte_body = subqueries[0][2].strip()
1317
1345
  else:
1318
- # Multiple (sk, window) groups — LEFT JOIN on (entity_keys, boundary).
1319
- _, _, first_query = subqueries[0]
1320
- projected: list[str] = [f"sq0.{col}" for col in group_by_cols]
1321
- projected.append(f"sq0.{subqueries[0][0]}")
1322
- for col in subqueries[0][1]:
1323
- projected.append(f"sq0.{col}")
1324
- for i, (keys_col, value_cols, _) in enumerate(subqueries[1:], 1):
1325
- projected.append(f"sq{i}.{keys_col}")
1326
- for col in value_cols:
1327
- projected.append(f"sq{i}.{col}")
1328
-
1329
- result = f"SELECT {', '.join(projected)}\n FROM {first_query} sq0"
1330
- for i, (_, _, query) in enumerate(subqueries[1:], 1):
1331
- join_cond = " AND ".join(f"sq0.{col} = sq{i}.{col}" for col in group_by_cols)
1332
- result += f"\n LEFT JOIN {query} sq{i}\n ON {join_cond}"
1333
- cte_body = result
1346
+ # Multiple (sk, window) groups: a group whose window covers no tiles
1347
+ # emits no row, so the chain is driven from the unique (entity,
1348
+ # boundary) rows to keep each group's arrays independent. Missing
1349
+ # groups surface as [] via the combined CTE's COALESCE.
1350
+ cte_body = self._left_join_subqueries_on_bounds(
1351
+ [(query, [keys_col, *value_cols]) for keys_col, value_cols, query in subqueries]
1352
+ )
1334
1353
 
1335
1354
  return cte_name, cte_body.strip()
1336
1355
 
@@ -1574,21 +1593,11 @@ class MergingSqlGenerator:
1574
1593
  if len(feature_subqueries) == 1:
1575
1594
  return feature_subqueries[0][1]
1576
1595
 
1577
- # Multiple list features: join them together
1578
- first_name, first_query = feature_subqueries[0]
1579
- result = "SELECT sq0.*, "
1580
- for i, (name, _) in enumerate(feature_subqueries[1:], 1):
1581
- result += f"sq{i}.{name}"
1582
- if i < len(feature_subqueries) - 1:
1583
- result += ", "
1584
-
1585
- result += f"\n FROM {first_query} sq0"
1586
-
1587
- for i, (_name, query) in enumerate(feature_subqueries[1:], 1):
1588
- join_cond = " AND ".join(f"sq0.{col} = sq{i}.{col}" for col in group_by_cols)
1589
- result += f"\n LEFT JOIN {query} sq{i}\n ON {join_cond}"
1590
-
1591
- return result
1596
+ # Multiple list features: a feature with no non-NULL values in the window
1597
+ # emits no row, so the chain is driven from the unique (entity, boundary)
1598
+ # rows to keep each feature's array independent of the others. A missing
1599
+ # feature stays NULL, which is this path's "no data" encoding.
1600
+ return self._left_join_subqueries_on_bounds([(query, [name]) for name, query in feature_subqueries])
1592
1601
 
1593
1602
  def _generate_combined_cte(self) -> tuple[str, str]:
1594
1603
  """Generate the final CTE that combines and expands results.
snowflake/ml/jobs/job.py CHANGED
@@ -362,13 +362,29 @@ class MLJob(Generic[T], SerializableSessionMixin):
362
362
  """
363
363
  if self._result is None:
364
364
  self.wait(timeout)
365
+ # Retrying would push the call past the caller's timeout, so only retry when unbounded.
366
+ remaining = _INSTANCE_RECORD_TIMEOUT_SECONDS if timeout < 0 else 0
365
367
  try:
366
- self._result = interop_utils.load(
367
- self._result_path, session=self._session, path_transform=self._transform_path
368
- )
368
+ while True:
369
+ try:
370
+ self._result = interop_utils.load(
371
+ self._result_path, session=self._session, path_transform=self._transform_path
372
+ )
373
+ break
374
+ except (OSError, json.JSONDecodeError) as error:
375
+ if remaining <= 0:
376
+ raise
377
+ logger.warning(
378
+ "job result retrieval: result is not yet readable: %s; retrying in %d second(s)",
379
+ error,
380
+ _INSTANCE_RECORD_POLL_INTERVAL_SECONDS,
381
+ )
382
+ time.sleep(_INSTANCE_RECORD_POLL_INTERVAL_SECONDS)
383
+ remaining -= _INSTANCE_RECORD_POLL_INTERVAL_SECONDS
369
384
  except Exception as e:
370
385
  raise RuntimeError(f"Failed to retrieve result for job, error: {e!r}") from e
371
386
 
387
+ assert self._result is not None
372
388
  return cast(T, self._result.get_value())
373
389
 
374
390
  @telemetry.send_api_usage_telemetry(project=_PROJECT, func_params_to_log=["timeout"])
@@ -132,6 +132,20 @@ class EngineOptions(BaseModel):
132
132
  return engine.name if engine is not None else None
133
133
 
134
134
 
135
+ class AdapterSpec(BaseModel):
136
+ """Serialized adapter identity nested under ``inference.adapters``.
137
+
138
+ ``alias`` is optional. Dict keys from the Python API set it; list items and
139
+ adapter-as-target omit it so the server can default to ``{name}/VERSIONS/{version}``.
140
+ """
141
+
142
+ model_config = ConfigDict(extra="forbid")
143
+
144
+ name: str
145
+ version: str
146
+ alias: str | None = None
147
+
148
+
135
149
  class InferenceSpec(BaseModel):
136
150
  """Inference block of the batch inference job specification.
137
151
 
@@ -141,13 +155,79 @@ class InferenceSpec(BaseModel):
141
155
  max_batch_rows (Optional[int]): Maximum number of rows to process in a single batch.
142
156
  Auto determined if None. Larger values may improve throughput.
143
157
  engine_options (Optional[EngineOptions]): Options for a custom inference engine.
158
+ adapters (Optional[dict[str, ModelVersion] | list[ModelVersion]]): Adapter versions for
159
+ this job. A dict maps job alias (``input.params.model``) to adapter versions; keys
160
+ become ``alias`` on each emitted list item. A nonempty list and adapter-as-target omit
161
+ ``alias``. Empty ``{}`` / ``[]`` are treated as unset so the YAML key is omitted.
162
+ ``run_batch`` serializes these to name/version identities before writing the job spec.
144
163
  """
145
164
 
146
- model_config = ConfigDict(extra="forbid")
165
+ model_config = ConfigDict(arbitrary_types_allowed=True, extra="forbid")
147
166
 
148
167
  num_workers: int | None = None
149
168
  max_batch_rows: int | None = None
150
169
  engine_options: EngineOptions | None = None
170
+ adapters: dict[str, Any] | list[Any] | None = None
171
+
172
+ @field_validator("adapters", mode="before")
173
+ @classmethod
174
+ def _empty_adapters_to_none(cls, value: Any) -> Any:
175
+ if value == [] or value == {}:
176
+ return None
177
+ return value
178
+
179
+ @field_serializer("adapters")
180
+ def _serialize_adapters(self, value: Any) -> list[dict[str, Any]] | None:
181
+ if not value:
182
+ return None
183
+ if not isinstance(value, list) or not all(isinstance(item, AdapterSpec) for item in value):
184
+ raise ValueError(
185
+ "batch inference: adapter versions cannot be written into the job spec until they are "
186
+ "resolved to name and version."
187
+ )
188
+ return [item.model_dump(mode="json", exclude_none=True) for item in value]
189
+
190
+
191
+ def split_inference_adapters(
192
+ inference_spec: InferenceSpec | None,
193
+ ) -> tuple[InferenceSpec | None, dict[str, Any] | list[Any] | None]:
194
+ """Peel ``adapters`` off ``inference_spec`` without mutating the caller object.
195
+
196
+ Args:
197
+ inference_spec: Existing inference block, or None.
198
+
199
+ Returns:
200
+ The spec with ``adapters`` cleared (or None if that leaves an empty spec), and the
201
+ peeled adapters value (or None when unset).
202
+ """
203
+ if inference_spec is None or inference_spec.adapters is None:
204
+ return inference_spec, None
205
+ raw = inference_spec.adapters
206
+ stripped = inference_spec.model_copy(update={"adapters": None})
207
+ if not stripped.model_dump(exclude_none=True):
208
+ return None, raw
209
+ return stripped, raw
210
+
211
+
212
+ def inference_spec_with_adapters(
213
+ inference_spec: InferenceSpec | None,
214
+ adapters: dict[str, Any] | list[Any] | None,
215
+ ) -> InferenceSpec | None:
216
+ """Return ``inference_spec`` with ``adapters`` set when nonempty.
217
+
218
+ Args:
219
+ inference_spec: Existing inference block, or None.
220
+ adapters: Adapter versions or serialized identities. Empty or None leaves
221
+ ``inference_spec`` unchanged.
222
+
223
+ Returns:
224
+ The original spec when ``adapters`` is empty, otherwise a spec that includes them.
225
+ """
226
+ if not adapters:
227
+ return inference_spec
228
+ if inference_spec is None:
229
+ return InferenceSpec(adapters=adapters)
230
+ return inference_spec.model_copy(update={"adapters": adapters})
151
231
 
152
232
 
153
233
  class ImageBuildSpec(BaseModel):
@@ -79,7 +79,13 @@ class BatchInferenceTask(DAGTask):
79
79
  one of ``query`` or ``input_stage_location``.
80
80
  input_spec: Optional input block.
81
81
  resources_spec: Optional resources block.
82
- inference_spec: Optional inference block.
82
+ inference_spec: Optional inference block. Optional ``adapters`` is a mapping from
83
+ job alias (``input.params.model``) to adapter ``ModelVersion``s, or a list of
84
+ adapter versions. Dict keys become ``alias`` on each emitted list item. A
85
+ nonempty list and adapter-as-target omit ``alias``. Omit, ``None``, ``{}``, or
86
+ ``[]`` attaches none. Constructing the task on an adapter version is equivalent
87
+ to running the lineage pin with a one-item list, and defaults job-level
88
+ ``params.model`` to the defaulted alias when unset.
83
89
  image_build_spec: Optional image build block.
84
90
  function_name: Model function name. Resolved against the model's function list
85
91
  when omitted.
@@ -125,24 +131,36 @@ class BatchInferenceTask(DAGTask):
125
131
  if (self._query is None) == (self._input_stage_location is None):
126
132
  raise ValueError("Exactly one of query or input_stage_location must be provided.")
127
133
 
128
- target_function_info = self._model_version._validate_batch_inference_request(
134
+ inference_spec, raw_adapters = batch_inference_job_specs.split_inference_adapters(self._inference_spec)
135
+ deploy_target, adapters_map, input_spec = self._model_version._prepare_lora_batch(
136
+ adapters=raw_adapters,
129
137
  input_spec=self._input_spec,
138
+ input_spec_cls=batch_inference_job_specs.InputSpec,
139
+ )
140
+
141
+ target_function_info = deploy_target._validate_batch_inference_request(
142
+ input_spec=input_spec,
130
143
  resources_spec=self._resources_spec,
131
144
  function_name=self._function_name,
132
145
  )
133
146
 
147
+ inference_spec = batch_inference_job_specs.inference_spec_with_adapters(
148
+ inference_spec,
149
+ deploy_target._serialize_batch_adapters(adapters_map) if adapters_map else None,
150
+ )
151
+
134
152
  return service_ops.build_batch_inference_task_definition(
135
153
  session=self._model_version._service_ops._session,
136
- model_fqn=self._model_version.fully_qualified_model_name,
137
- version_name=self._model_version.version_name,
154
+ model_fqn=deploy_target.fully_qualified_model_name,
155
+ version_name=deploy_target.version_name,
138
156
  compute_pool=self._compute_pool,
139
157
  function_name=target_function_info["target_method"],
140
158
  query=self._query,
141
159
  input_stage_location=self._input_stage_location,
142
- input_spec=self._input_spec,
160
+ input_spec=input_spec,
143
161
  output_spec=self._output_spec,
144
162
  resources_spec=self._resources_spec,
145
- inference_spec=self._inference_spec,
163
+ inference_spec=inference_spec,
146
164
  image_build_spec=self._image_build_spec,
147
165
  replicas=self._replicas,
148
166
  )
@@ -688,7 +688,14 @@ class ModelVersion(lineage_node.LineageNode):
688
688
  ``<stage_location>/<job_name>/``.
689
689
  input_spec: Input block.
690
690
  resources_spec: Resources block.
691
- inference_spec: Inference block.
691
+ inference_spec: Inference block. Optional ``adapters`` is a mapping from job alias
692
+ (``input.params.model``) to adapter ``ModelVersion``s, or a list of adapter
693
+ versions. Dict keys become ``alias`` on each emitted list item. A nonempty list
694
+ and adapter-as-target omit ``alias`` (the server defaults each to
695
+ ``<FQN>/VERSIONS/<version>``). Omit, ``None``, ``{}``, or ``[]`` attaches none.
696
+ Calling ``run_batch`` on an adapter version is equivalent to running the lineage
697
+ pin with a one-item list, and defaults job-level ``params.model`` to that
698
+ defaulted alias when unset.
692
699
  image_build_spec: Image build block.
693
700
  function_name: Model function name. Resolved against the model's
694
701
  function list when omitted.
@@ -763,6 +770,29 @@ class ModelVersion(lineage_node.LineageNode):
763
770
  if (X is None) == (input_stage_location is None):
764
771
  raise ValueError("Exactly one of X or input_stage_location must be provided.")
765
772
 
773
+ inference_spec, raw_adapters = batch_inference_job_specs.split_inference_adapters(inference_spec)
774
+ deploy_target, adapters_map, input_spec = self._prepare_lora_batch(
775
+ adapters=raw_adapters,
776
+ input_spec=input_spec,
777
+ input_spec_cls=batch_inference_job_specs.InputSpec,
778
+ )
779
+ if deploy_target is not self:
780
+ forwarded = [mv for _, mv in adapters_map] if adapters_map else None
781
+ return deploy_target.run_batch(
782
+ X,
783
+ input_stage_location=input_stage_location,
784
+ compute_pool=compute_pool,
785
+ output_spec=output_spec,
786
+ input_spec=input_spec,
787
+ resources_spec=resources_spec,
788
+ inference_spec=batch_inference_job_specs.inference_spec_with_adapters(inference_spec, forwarded),
789
+ image_build_spec=image_build_spec,
790
+ function_name=function_name,
791
+ job_name=job_name,
792
+ replicas=replicas,
793
+ async_=async_,
794
+ )
795
+
766
796
  target_function_info = self._validate_batch_inference_request(
767
797
  input_spec=input_spec,
768
798
  resources_spec=resources_spec,
@@ -770,6 +800,11 @@ class ModelVersion(lineage_node.LineageNode):
770
800
  statement_params=statement_params,
771
801
  )
772
802
 
803
+ inference_spec = batch_inference_job_specs.inference_spec_with_adapters(
804
+ inference_spec,
805
+ self._serialize_batch_adapters(adapters_map) if adapters_map else None,
806
+ )
807
+
773
808
  return self._service_ops.execute_inference_job_service(
774
809
  X=X,
775
810
  input_stage_location=input_stage_location,
@@ -1238,11 +1273,13 @@ class ModelVersion(lineage_node.LineageNode):
1238
1273
  return caps(features={}).is_lora_adapters_enabled()
1239
1274
 
1240
1275
  @staticmethod
1241
- def _require_lora_adapters_enabled() -> None:
1276
+ def _require_lora_adapters_enabled(
1277
+ *,
1278
+ unavailable_what: str = "Creating a service with adapters",
1279
+ ) -> None:
1242
1280
  if not ModelVersion._lora_adapters_enabled():
1243
1281
  ModelVersion._adapter_invalid_argument(
1244
- "Creating a service with adapters is unavailable because ENABLE_LORA_ADAPTERS is not enabled "
1245
- "for this account."
1282
+ f"{unavailable_what} is unavailable because ENABLE_LORA_ADAPTERS is not enabled " "for this account."
1246
1283
  )
1247
1284
 
1248
1285
  def _is_peft_adapter_version(self) -> bool:
@@ -1263,7 +1300,7 @@ class ModelVersion(lineage_node.LineageNode):
1263
1300
 
1264
1301
  def _normalize_adapters(
1265
1302
  self,
1266
- adapters: dict[str, "ModelVersion"] | list["ModelVersion"] | None,
1303
+ adapters: dict[str, Any] | list[Any] | None,
1267
1304
  ) -> list[tuple[str | None, "ModelVersion"]] | None:
1268
1305
  if adapters is None:
1269
1306
  return None
@@ -1300,6 +1337,61 @@ class ModelVersion(lineage_node.LineageNode):
1300
1337
  for alias, adapter_mv in adapters
1301
1338
  ]
1302
1339
 
1340
+ @staticmethod
1341
+ def _serialize_batch_adapters(
1342
+ adapters: list[tuple[str | None, "ModelVersion"]],
1343
+ ) -> list[batch_inference_job_specs.AdapterSpec]:
1344
+ return [
1345
+ batch_inference_job_specs.AdapterSpec(
1346
+ name=adapter_mv.fully_qualified_model_name,
1347
+ version=adapter_mv.version_name,
1348
+ alias=alias,
1349
+ )
1350
+ for alias, adapter_mv in adapters
1351
+ ]
1352
+
1353
+ @staticmethod
1354
+ def _default_adapter_alias(mv: "ModelVersion") -> str:
1355
+ return f"{mv.fully_qualified_model_name}/VERSIONS/{mv.version_name}"
1356
+
1357
+ @staticmethod
1358
+ def _with_default_batch_params_model(
1359
+ input_spec: Any | None,
1360
+ *,
1361
+ alias: str,
1362
+ spec_cls: Any,
1363
+ ) -> Any:
1364
+ if input_spec is not None and input_spec.params and input_spec.params.get("model") is not None:
1365
+ return input_spec
1366
+ params = dict(input_spec.params) if input_spec and input_spec.params else {}
1367
+ params["model"] = alias
1368
+ if input_spec is None:
1369
+ return spec_cls(params=params)
1370
+ return input_spec.model_copy(update={"params": params})
1371
+
1372
+ def _prepare_lora_batch(
1373
+ self,
1374
+ *,
1375
+ adapters: dict[str, Any] | list[Any] | None,
1376
+ input_spec: Any | None,
1377
+ input_spec_cls: Any,
1378
+ ) -> tuple["ModelVersion", list[tuple[str | None, "ModelVersion"]] | None, Any]:
1379
+ adapters_items = self._normalize_adapters(adapters)
1380
+ if adapters_items:
1381
+ self._require_lora_adapters_enabled(unavailable_what="Running a batch job with adapters")
1382
+ if self._is_peft_adapter_version():
1383
+ self._require_lora_adapters_enabled(unavailable_what="Running a batch job on a LoRA adapter")
1384
+ if adapters_items:
1385
+ self._adapter_invalid_argument(
1386
+ "batch inference: an adapter version does not accept adapters on the inference spec. "
1387
+ "The adapter is attached under its default FQN/VERSIONS alias."
1388
+ )
1389
+ pin = self._resolve_adapter_pin()
1390
+ alias = self._default_adapter_alias(self)
1391
+ input_spec = self._with_default_batch_params_model(input_spec, alias=alias, spec_cls=input_spec_cls)
1392
+ return (pin, [(None, self)], input_spec)
1393
+ return (self, adapters_items, input_spec)
1394
+
1303
1395
  @overload
1304
1396
  def create_service(
1305
1397
  self,