upgini 1.2.167a4220.dev8__tar.gz → 1.2.167a4220.dev9__tar.gz

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 (94) hide show
  1. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/PKG-INFO +1 -1
  2. upgini-1.2.167a4220.dev9/src/upgini/__about__.py +1 -0
  3. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/features_enricher.py +55 -45
  4. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/metadata.py +2 -0
  5. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/report/assemble.py +158 -36
  6. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/report/data.py +6 -0
  7. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/report/html.py +19 -5
  8. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/report/template.html +6 -3
  9. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/search_task.py +25 -0
  10. upgini-1.2.167a4220.dev8/src/upgini/__about__.py +0 -1
  11. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/.gitignore +0 -0
  12. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/LICENSE +0 -0
  13. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/README.md +0 -0
  14. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/pyproject.toml +0 -0
  15. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/__init__.py +0 -0
  16. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/ads.py +0 -0
  17. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/ads_management/__init__.py +0 -0
  18. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/ads_management/ads_manager.py +0 -0
  19. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/__init__.py +0 -0
  20. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/all_operators.py +0 -0
  21. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/binary.py +0 -0
  22. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/date.py +0 -0
  23. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/feature.py +0 -0
  24. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/groupby.py +0 -0
  25. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/operand.py +0 -0
  26. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/operator.py +0 -0
  27. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/__init__.py +0 -0
  28. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/base.py +0 -0
  29. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/cross.py +0 -0
  30. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/delta.py +0 -0
  31. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/lag.py +0 -0
  32. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/numpy_kernels.py +0 -0
  33. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/roll.py +0 -0
  34. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/trend.py +0 -0
  35. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/timeseries/volatility.py +0 -0
  36. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/unary.py +0 -0
  37. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/utils.py +0 -0
  38. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/autofe/vector.py +0 -0
  39. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/data_source/__init__.py +0 -0
  40. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/data_source/data_source_publisher.py +0 -0
  41. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/dataset.py +0 -0
  42. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/errors.py +0 -0
  43. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/http.py +0 -0
  44. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/mdc/__init__.py +0 -0
  45. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/mdc/context.py +0 -0
  46. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/metrics.py +0 -0
  47. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/normalizer/__init__.py +0 -0
  48. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/normalizer/normalize_utils.py +0 -0
  49. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/report/__init__.py +0 -0
  50. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/report/stats.py +0 -0
  51. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/__init__.py +0 -0
  52. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/exceptions.py +0 -0
  53. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/strings.properties +0 -0
  54. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/resource_bundle/strings_widget.properties +0 -0
  55. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/__init__.py +0 -0
  56. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/base.py +0 -0
  57. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/random_under_sampler.py +0 -0
  58. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/sampler/utils.py +0 -0
  59. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/spinner.py +0 -0
  60. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/Roboto-Regular.ttf +0 -0
  61. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/__init__.py +0 -0
  62. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/base_search_key_detector.py +0 -0
  63. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/blocked_time_series.py +0 -0
  64. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/config.py +0 -0
  65. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/country_utils.py +0 -0
  66. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/cpu_utils.py +0 -0
  67. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/custom_loss_utils.py +0 -0
  68. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/cv_utils.py +0 -0
  69. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/datetime_utils.py +0 -0
  70. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/deduplicate_utils.py +0 -0
  71. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/display_utils.py +0 -0
  72. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/email_utils.py +0 -0
  73. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/fallback_progress_bar.py +0 -0
  74. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/feature_info.py +0 -0
  75. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/features_validator.py +0 -0
  76. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/format.py +0 -0
  77. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/hash_utils.py +0 -0
  78. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/ip_utils.py +0 -0
  79. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/mstats.py +0 -0
  80. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/one_hot_encoder.py +0 -0
  81. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/phone_utils.py +0 -0
  82. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/postal_code_utils.py +0 -0
  83. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/progress_bar.py +0 -0
  84. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/psi.py +0 -0
  85. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/pyarrow_utils.py +0 -0
  86. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/sample_utils.py +0 -0
  87. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/search_key_derivations.py +0 -0
  88. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/sklearn_ext.py +0 -0
  89. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/sort.py +0 -0
  90. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/target_utils.py +0 -0
  91. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/track_info.py +0 -0
  92. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/ts_utils.py +0 -0
  93. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/utils/warning_counter.py +0 -0
  94. {upgini-1.2.167a4220.dev8 → upgini-1.2.167a4220.dev9}/src/upgini/version_validator.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: upgini
3
- Version: 1.2.167a4220.dev8
3
+ Version: 1.2.167a4220.dev9
4
4
  Summary: Intelligent data search & enrichment for Machine Learning
5
5
  Project-URL: Bug Reports, https://github.com/upgini/upgini/issues
6
6
  Project-URL: Homepage, https://upgini.com/
@@ -0,0 +1 @@
1
+ __version__ = "1.2.167.a4220.dev9"
@@ -64,6 +64,7 @@ from upgini.metadata import (
64
64
  FeaturesMetadataV2,
65
65
  FileColumnMeaningType,
66
66
  FileColumnMetadata,
67
+ GeneratedFeatureMetadata,
67
68
  ModelTaskType,
68
69
  RuntimeParameters,
69
70
  SearchKey,
@@ -98,7 +99,13 @@ from upgini.utils.deduplicate_utils import (
98
99
  clean_full_duplicates,
99
100
  remove_fintech_duplicates,
100
101
  )
101
- from upgini.report.assemble import assemble_report_data, autofe_rows_for_report, build_search_results
102
+ from upgini.report.assemble import (
103
+ _generated_feature_names,
104
+ _parse_generated_feature,
105
+ assemble_report_data,
106
+ autofe_rows_from_description,
107
+ build_search_results,
108
+ )
102
109
  from upgini.report.html import generate_html_report
103
110
  from upgini.report.stats import make_report_frame
104
111
  from upgini.utils.display_utils import (
@@ -2479,11 +2486,14 @@ class FeaturesEnricher(TransformerMixin):
2479
2486
  generated_names=self._column_name_aliases(
2480
2487
  self.fit_generated_features or [], self.fit_columns_renaming or {}
2481
2488
  ),
2489
+ generated_features=self._search_task.get_all_generated_features() if self._search_task else [],
2490
+ joined_ads_features_count=(
2491
+ self._search_task.get_joined_ads_features_count() if self._search_task else None
2492
+ ),
2493
+ joined_ads_count=self._search_task.get_joined_ads_count() if self._search_task else None,
2482
2494
  )
2483
- autofe = autofe_rows_for_report(
2495
+ autofe = autofe_rows_from_description(
2484
2496
  self.get_autofe_features_description(features_meta=features_meta),
2485
- features_meta,
2486
- self._is_ensemble_feature,
2487
2497
  self.bundle,
2488
2498
  )
2489
2499
  return features, sources, model_shap, summary, autofe
@@ -2503,15 +2513,29 @@ class FeaturesEnricher(TransformerMixin):
2503
2513
  return False
2504
2514
  return bool(aliases & self._column_name_aliases(list(ensemble_names), renaming))
2505
2515
 
2516
+ def _match_autofe_feature_meta(
2517
+ self,
2518
+ generated_meta: GeneratedFeatureMetadata,
2519
+ features_meta: list[FeaturesMetadataV2],
2520
+ used_names: set[str],
2521
+ ) -> FeaturesMetadataV2 | None:
2522
+ available = [meta for meta in features_meta or [] if meta.name not in used_names]
2523
+ by_name = {meta.name: meta for meta in available}
2524
+ for name in _generated_feature_names(generated_meta):
2525
+ matched = by_name.get(name)
2526
+ if matched is not None:
2527
+ return matched
2528
+ return None
2529
+
2506
2530
  def _ensemble_generated_metadata(self):
2507
2531
  autofe_meta = self._search_task.get_autofe_metadata() if self._search_task else None
2508
2532
  if not autofe_meta:
2509
2533
  return []
2510
2534
  matched = []
2511
2535
  for meta in autofe_meta:
2512
- op = EnsembleModel.from_formula(meta.formula.split("(")[0])
2513
- if op is not None:
2514
- matched.append((meta, op))
2536
+ feature = _parse_generated_feature(meta)
2537
+ if feature is not None and isinstance(feature.op, EnsembleModel):
2538
+ matched.append((meta, feature.op))
2515
2539
  return matched
2516
2540
 
2517
2541
  def _ensemble_feature_names_from_metadata(self) -> set[str]:
@@ -5864,6 +5888,7 @@ if response.status_code == 200:
5864
5888
 
5865
5889
  selected_features_meta.sort(key=lambda m: (-m.shap_value, m.name))
5866
5890
  psi_keepers = self._psi_stability_keepers() if self.psi_values is not None else set()
5891
+ single_ensemble = len(self._ensemble_generated_metadata()) == 1
5867
5892
 
5868
5893
  for feature_meta in selected_features_meta:
5869
5894
  original_name = original_names_dict.get(feature_meta.name, feature_meta.name)
@@ -5873,6 +5898,10 @@ if response.status_code == 200:
5873
5898
  ) or original_name in generated_aliases
5874
5899
  is_client_feature = original_name in clients_features_df.columns and not is_generated_feature
5875
5900
 
5901
+ # Model-input ads/AutoFE rows are only for the HTML report; the selected output is the score.
5902
+ if single_ensemble and not is_client_feature and not self._is_ensemble_feature(feature_meta.name):
5903
+ continue
5904
+
5876
5905
  if not is_client_feature and not is_generated_feature:
5877
5906
  self.external_source_feature_names.append(original_name)
5878
5907
 
@@ -5887,8 +5916,6 @@ if response.status_code == 200:
5887
5916
  if original_name in self.unstable_features or feature_meta.name in self.unstable_features:
5888
5917
  continue
5889
5918
 
5890
- # TODO make a decision about selected features based on special flag from mlb
5891
-
5892
5919
  if original_shaps.get(feature_meta.name, 0.0) == 0.0 and not self._should_show_client_feature_in_report(
5893
5920
  is_client_feature, feature_meta.name
5894
5921
  ):
@@ -5961,52 +5988,34 @@ if response.status_code == 200:
5961
5988
  elif features_meta is None:
5962
5989
  features_meta = self._search_task.get_all_features_metadata_v2()
5963
5990
 
5964
- def get_feature_by_name(name: str):
5965
- if not name or not features_meta:
5966
- return None
5967
- for candidate in features_meta:
5968
- if candidate.name == name:
5969
- return candidate
5970
- return None
5971
-
5972
5991
  descriptions = []
5992
+ used_names: set[str] = set()
5973
5993
  for m in autofe_meta:
5974
- orig_to_hashed = {base_column.original_name: base_column.hashed_name for base_column in m.base_columns}
5975
-
5976
- autofe_feature = (
5977
- Feature.from_formula(m.formula)
5978
- .set_display_index(m.display_index)
5979
- .set_alias(m.alias)
5980
- .set_op_params(m.operator_params or {})
5981
- .rename_columns(orig_to_hashed)
5982
- )
5994
+ try:
5995
+ orig_to_hashed = {
5996
+ base_column.original_name: base_column.hashed_name for base_column in m.base_columns
5997
+ }
5998
+ autofe_feature = (
5999
+ Feature.from_formula(m.formula)
6000
+ .set_display_index(m.display_index or None)
6001
+ .set_alias(m.alias)
6002
+ .set_op_params(m.operator_params or {})
6003
+ .rename_columns(orig_to_hashed)
6004
+ )
6005
+ except Exception:
6006
+ self.logger.exception(f"Failed to parse AutoFE formula: {m.formula}")
6007
+ continue
5983
6008
 
5984
6009
  is_ts = isinstance(autofe_feature.op, TimeSeriesBase)
5985
6010
 
5986
6011
  if autofe_feature.op.is_vector and not is_ts:
5987
6012
  continue
5988
6013
 
5989
- description = {}
5990
-
5991
- lookup_names = [autofe_feature.get_display_name(shorten=True, unhash=True)]
5992
- if m.alias:
5993
- lookup_names.extend([m.alias, f"f_autofe_{m.alias}"])
5994
- feature_meta = None
5995
- for lookup_name in lookup_names:
5996
- feature_meta = get_feature_by_name(lookup_name)
5997
- if feature_meta is not None:
5998
- break
5999
- if feature_meta is None and m.alias and features_meta:
6000
- prefix = f"f_autofe_{m.alias}"
6001
- matches = [
6002
- candidate
6003
- for candidate in features_meta
6004
- if candidate.name == prefix or candidate.name.startswith(prefix + "_")
6005
- ]
6006
- if len(matches) == 1:
6007
- feature_meta = matches[0]
6014
+ feature_meta = self._match_autofe_feature_meta(m, features_meta, used_names)
6008
6015
  if feature_meta is None or not feature_meta.shap_value:
6009
6016
  continue
6017
+
6018
+ description = {}
6010
6019
  description["shap"] = feature_meta.shap_value
6011
6020
  source = feature_meta.data_source or ""
6012
6021
  description[self.bundle.get("autofe_descriptions_sources")] = source.replace(
@@ -6028,6 +6037,7 @@ if response.status_code == 200:
6028
6037
  sorted(autofe_feature.get_all_operand_names())
6029
6038
  )
6030
6039
 
6040
+ used_names.add(feature_meta.name)
6031
6041
  descriptions.append(description)
6032
6042
 
6033
6043
  if len(descriptions) == 0:
@@ -346,6 +346,8 @@ class ProviderTaskMetadataV2(BaseModel):
346
346
  zero_hit_rate_search_keys: Optional[List[str]] = None
347
347
  features_used_for_embeddings: Optional[List[str]] = None
348
348
  shuffle_kfold: Optional[bool] = None
349
+ joined_ads_features_count: Optional[int] = None
350
+ joined_ads_count: Optional[int] = None
349
351
  generated_features: Optional[List[GeneratedFeatureMetadata]] = None
350
352
 
351
353
 
@@ -7,7 +7,9 @@ from typing import Callable, Optional
7
7
  import pandas as pd
8
8
 
9
9
  from upgini.__about__ import __version__
10
- from upgini.metadata import FeaturesMetadataV2
10
+ from upgini.autofe.feature import Feature
11
+ from upgini.autofe.vector import CatboostModel, EnsembleModel, OnnxModel
12
+ from upgini.metadata import BaseColumnMetadata, FeaturesMetadataV2, GeneratedFeatureMetadata
11
13
  from upgini.report.data import (
12
14
  FeatureRow,
13
15
  ModelFeatureShap,
@@ -101,6 +103,9 @@ def build_search_results(
101
103
  features_meta: list[FeaturesMetadataV2],
102
104
  is_ensemble: Callable[[str], bool],
103
105
  generated_names: Optional[set[str]] = None,
106
+ generated_features: Optional[list[GeneratedFeatureMetadata]] = None,
107
+ joined_ads_features_count: Optional[int] = None,
108
+ joined_ads_count: Optional[int] = None,
104
109
  ) -> tuple[list[FeatureRow], list[SourceRow], list[ModelFeatureShap], SearchResultsSummary]:
105
110
  generated_names = generated_names or set()
106
111
  model_rows: list[FeatureRow] = []
@@ -123,44 +128,19 @@ def build_search_results(
123
128
  for index, row in enumerate(model_rows, start=1)
124
129
  ]
125
130
  sources = _source_rows(found_rows)
126
- contributed = len({(row.provider, row.source) for row in found_rows if row.provider})
127
- stable = sum(1 for row in model_rows if row.psi is not None and row.psi < _STABILITY_PSI)
128
- summary = SearchResultsSummary(
129
- relevant_features=len(found_rows),
130
- model_features=len(model_rows) or None,
131
- data_sources=len(sources),
132
- stable_features_share=(stable / len(model_rows)) if model_rows else None,
133
- contributed_sources=contributed,
134
- stable_features=stable if model_rows else None,
131
+ summary = _overview_summary(
132
+ features_meta=features_meta,
133
+ model_rows=model_rows,
134
+ found_rows=found_rows,
135
+ sources=sources,
136
+ is_ensemble=is_ensemble,
137
+ generated_features=generated_features or [],
138
+ joined_ads_features_count=joined_ads_features_count,
139
+ joined_ads_count=joined_ads_count,
135
140
  )
136
141
  return model_rows, sources, model_shap, summary
137
142
 
138
143
 
139
- def autofe_rows_for_report(
140
- df: Optional[pd.DataFrame],
141
- features_meta: list[FeaturesMetadataV2],
142
- is_ensemble: Callable[[str], bool],
143
- bundle: ResourceBundle,
144
- ) -> list[dict[str, str]]:
145
- rows = autofe_rows_from_description(df, bundle)
146
- listed = {row["generatedFeature"] for row in rows if row.get("generatedFeature")}
147
- for meta in features_meta:
148
- if is_ensemble(meta.name) or meta.source != "generated":
149
- continue
150
- if not _has_nonzero_shap_value(meta.shap_value) or meta.name in listed:
151
- continue
152
- row = _feature_row(meta.name, meta)
153
- rows.append(
154
- {
155
- "sources": row.source,
156
- "generatedFeature": row.name,
157
- "sourceFeatures": "",
158
- "functions": "",
159
- }
160
- )
161
- return rows
162
-
163
-
164
144
  def autofe_rows_from_description(df: Optional[pd.DataFrame], bundle: ResourceBundle) -> list[dict[str, str]]:
165
145
  if df is None or df.empty:
166
146
  return []
@@ -275,11 +255,20 @@ def _feature_row(
275
255
  drift=meta.drift_score,
276
256
  coverage=meta.hit_rate,
277
257
  provider=info.internal_provider,
278
- source=info.internal_source,
258
+ source=_feature_source(meta, is_generated, info.internal_source),
279
259
  stability_status=_psi_status(meta.psi_value),
280
260
  )
281
261
 
282
262
 
263
+ def _feature_source(meta: FeaturesMetadataV2, is_generated: bool, fallback: str) -> str:
264
+ if is_generated:
265
+ if meta.data_source:
266
+ return meta.data_source
267
+ if meta.data_sources:
268
+ return ", ".join(meta.data_sources)
269
+ return fallback
270
+
271
+
283
272
  def _source_rows(rows: list[FeatureRow]) -> list[SourceRow]:
284
273
  grouped: dict[tuple[str, str], list[FeatureRow]] = {}
285
274
  for row in rows:
@@ -311,3 +300,136 @@ def _psi_status(psi: Optional[float]) -> Optional[str]:
311
300
 
312
301
  def _has_nonzero_shap_value(shap: Optional[float]) -> bool:
313
302
  return shap is not None and not pd.isna(shap) and shap != 0
303
+
304
+
305
+ def _overview_summary(
306
+ *,
307
+ features_meta: list[FeaturesMetadataV2],
308
+ model_rows: list[FeatureRow],
309
+ found_rows: list[FeatureRow],
310
+ sources: list[SourceRow],
311
+ is_ensemble: Callable[[str], bool],
312
+ generated_features: list[GeneratedFeatureMetadata],
313
+ joined_ads_features_count: Optional[int],
314
+ joined_ads_count: Optional[int],
315
+ ) -> SearchResultsSummary:
316
+ top_level = _unique_fold_model_columns(generated_features)
317
+ features_by_name = {meta.name: meta for meta in features_meta}
318
+ autofe_by_name = _autofe_generated_by_name(generated_features, is_ensemble)
319
+ if top_level:
320
+ counts = {"external": 0, "original": 0, "autofe": 0}
321
+ ads_ids: set[str] = set()
322
+ psis: list[Optional[float]] = []
323
+ for column in top_level:
324
+ kind = _top_level_kind(column, features_by_name, autofe_by_name, is_ensemble)
325
+ counts[kind] += 1
326
+ ads_ids.update(_leaf_ads_ids(column, autofe_by_name))
327
+ meta = features_by_name.get(column.hashed_name) or features_by_name.get(column.original_name)
328
+ psis.append(None if meta is None else meta.psi_value)
329
+ model_features = len(top_level)
330
+ external, original, autofe = counts["external"], counts["original"], counts["autofe"]
331
+ contributed = len(ads_ids)
332
+ else:
333
+ model_features = len(model_rows) or None
334
+ external = original = autofe = None
335
+ contributed = len({(row.provider, row.source) for row in found_rows if row.provider})
336
+ psis = [row.psi for row in model_rows]
337
+ share, stable, psi_count = _stability_from_psi(psis)
338
+ return SearchResultsSummary(
339
+ relevant_features=len(found_rows),
340
+ features_found=joined_ads_features_count if joined_ads_features_count is not None else len(found_rows),
341
+ model_features=model_features,
342
+ external_features=external,
343
+ original_features=original,
344
+ autofe_features=autofe,
345
+ data_sources=len(sources),
346
+ joined_sources=joined_ads_count if joined_ads_count is not None else len(sources),
347
+ stable_features_share=share,
348
+ contributed_sources=contributed,
349
+ stable_features=stable,
350
+ psi_features=psi_count,
351
+ )
352
+
353
+
354
+ def _unique_fold_model_columns(generated_features: list[GeneratedFeatureMetadata]) -> list[BaseColumnMetadata]:
355
+ unique: dict[str, BaseColumnMetadata] = {}
356
+ for meta in generated_features:
357
+ feature = _parse_generated_feature(meta)
358
+ if feature is None or not isinstance(feature.op, (CatboostModel, OnnxModel)):
359
+ continue
360
+ for column in meta.base_columns:
361
+ unique.setdefault(column.hashed_name or column.original_name, column)
362
+ return list(unique.values())
363
+
364
+
365
+ def _autofe_generated_by_name(
366
+ generated_features: list[GeneratedFeatureMetadata], is_ensemble: Callable[[str], bool]
367
+ ) -> dict[str, GeneratedFeatureMetadata]:
368
+ by_name: dict[str, GeneratedFeatureMetadata] = {}
369
+ for meta in generated_features:
370
+ feature = _parse_generated_feature(meta)
371
+ if feature is None or isinstance(feature.op, (CatboostModel, EnsembleModel, OnnxModel)):
372
+ continue
373
+ name = feature.get_display_name(shorten=True, unhash=True, cache=False)
374
+ if name and not is_ensemble(name):
375
+ by_name.setdefault(name, meta)
376
+ return by_name
377
+
378
+
379
+ def _parse_generated_feature(meta: GeneratedFeatureMetadata) -> Feature | None:
380
+ try:
381
+ parsed = Feature.from_formula(meta.formula)
382
+ if not isinstance(parsed, Feature):
383
+ return None
384
+ orig_to_hashed = {column.original_name: column.hashed_name for column in meta.base_columns}
385
+ return (
386
+ parsed.set_display_index(meta.display_index or None)
387
+ .set_alias(meta.alias)
388
+ .set_op_params(meta.operator_params or {})
389
+ .rename_columns(orig_to_hashed)
390
+ )
391
+ except Exception:
392
+ return None
393
+
394
+
395
+ def _generated_feature_names(meta: GeneratedFeatureMetadata) -> set[str]:
396
+ feature = _parse_generated_feature(meta)
397
+ if feature is None:
398
+ return set()
399
+ return {feature.get_display_name(shorten=True, unhash=True, cache=False)}
400
+
401
+
402
+ def _top_level_kind(
403
+ column: BaseColumnMetadata,
404
+ features_by_name: dict[str, FeaturesMetadataV2],
405
+ autofe_by_name: dict[str, GeneratedFeatureMetadata],
406
+ is_ensemble: Callable[[str], bool],
407
+ ) -> str:
408
+ names = [column.hashed_name, column.original_name]
409
+ if any(name in autofe_by_name for name in names if name):
410
+ return "autofe"
411
+ meta = next((features_by_name[name] for name in names if name in features_by_name), None)
412
+ if meta is not None and not is_ensemble(meta.name):
413
+ if meta.source == "generated":
414
+ return "autofe"
415
+ if meta.source == "ads":
416
+ return "external"
417
+ if meta.source == "etalon":
418
+ return "original"
419
+ if column.ads_definition_id:
420
+ return "external"
421
+ return "original"
422
+
423
+
424
+ def _leaf_ads_ids(column: BaseColumnMetadata, autofe_by_name: dict[str, GeneratedFeatureMetadata]) -> set[str]:
425
+ autofe = autofe_by_name.get(column.hashed_name) or autofe_by_name.get(column.original_name)
426
+ leaves = autofe.base_columns if autofe is not None else [column]
427
+ return {leaf.ads_definition_id for leaf in leaves if leaf.ads_definition_id}
428
+
429
+
430
+ def _stability_from_psi(psis: list[Optional[float]]) -> tuple[Optional[float], Optional[int], Optional[int]]:
431
+ values = [psi for psi in psis if psi is not None and not pd.isna(psi)]
432
+ if not values:
433
+ return None, None, None
434
+ stable = sum(1 for psi in values if psi < _STABILITY_PSI)
435
+ return stable / len(values), stable, len(values)
@@ -66,11 +66,17 @@ class SourceRow:
66
66
  @dataclass
67
67
  class SearchResultsSummary:
68
68
  relevant_features: Optional[int] = None
69
+ features_found: Optional[int] = None
69
70
  model_features: Optional[int] = None
71
+ external_features: Optional[int] = None
72
+ original_features: Optional[int] = None
73
+ autofe_features: Optional[int] = None
70
74
  data_sources: Optional[int] = None
75
+ joined_sources: Optional[int] = None
71
76
  stable_features_share: Optional[float] = None
72
77
  contributed_sources: Optional[int] = None
73
78
  stable_features: Optional[int] = None
79
+ psi_features: Optional[int] = None
74
80
 
75
81
 
76
82
  @dataclass
@@ -145,16 +145,30 @@ def _summary_cards(summary: SearchResultsSummary) -> list[dict]:
145
145
  if summary.contributed_sources is not None:
146
146
  sources_caption = f"{summary.contributed_sources} contributed"
147
147
  stability_caption = ""
148
- if summary.stable_features is not None and summary.model_features is not None:
149
- stability_caption = f"{summary.stable_features} of {summary.model_features} are stable"
148
+ if summary.stable_features is not None and summary.psi_features is not None:
149
+ stability_caption = f"{summary.stable_features} of {summary.psi_features} are stable"
150
+ features_found = summary.features_found if summary.features_found is not None else summary.relevant_features
151
+ data_sources = summary.joined_sources if summary.joined_sources is not None else summary.data_sources
150
152
  return [
151
- {"label": "Features found", "value": _dash(summary.relevant_features), "caption": ""},
152
- {"label": "Used in model", "value": _dash(summary.model_features), "caption": ""},
153
- {"label": "Data sources", "value": _dash(summary.data_sources), "caption": sources_caption},
153
+ {"label": "Features found", "value": _dash(features_found), "caption": ""},
154
+ {"label": "Used in model", "value": _dash(summary.model_features), "caption": _used_in_model_caption(summary)},
155
+ {"label": "Data sources", "value": _dash(data_sources), "caption": sources_caption},
154
156
  {"label": "Stability", "value": _dash(stability), "caption": stability_caption},
155
157
  ]
156
158
 
157
159
 
160
+ def _used_in_model_caption(summary: SearchResultsSummary) -> str:
161
+ if summary.external_features is None and summary.original_features is None and summary.autofe_features is None:
162
+ return ""
163
+ return " · ".join(
164
+ [
165
+ f"{summary.external_features or 0} external",
166
+ f"{summary.original_features or 0} original",
167
+ f"{summary.autofe_features or 0} AutoFE",
168
+ ]
169
+ )
170
+
171
+
158
172
  def _features_payload(features: list[FeatureRow]) -> list[dict]:
159
173
  return [
160
174
  {
@@ -7,7 +7,7 @@
7
7
  <style>
8
8
  :root{--ink:#15211f;--muted:#6f7976;--line:#dce4e1;--paper:#fff;--bg:#f3f6f5;--blue:#1645ee;--green:#53cc61;--cyan:#20aab4;--warn:#e19132;--danger:#d45b5b;--shadow:0 18px 60px rgba(18,45,39,.08)}
9
9
  *{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--bg);color:var(--ink);font-family:Inter,ui-sans-serif,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-size:14px;line-height:1.45}.shell{max-width:1440px;margin:auto;min-height:100vh}.topbar{height:72px;padding:0 42px;display:flex;align-items:center;gap:25px;background:rgba(255,255,255,.92);backdrop-filter:blur(16px);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:20}.brand{display:flex;align-items:center;gap:14px}.wordmark{font-size:25px;font-weight:800;letter-spacing:-.07em;line-height:1;background:linear-gradient(135deg,#0138f8,#5bd357);-webkit-background-clip:text;background-clip:text;color:transparent}.wordmark b{font-weight:850}.wordmark span{font-weight:650}.wordmark.small{font-size:20px;min-width:66px}.brand-divider{width:1px;height:25px;background:var(--line)}.brand-label{font-weight:700;color:#45514f}.top-meta{display:flex;gap:8px;margin-left:auto;align-items:center}.chip{background:#eef2f1;border:1px solid #e0e7e5;border-radius:999px;padding:7px 11px;color:#53605d;font-size:12px}.button{border:0;border-radius:10px;padding:10px 15px;font:inherit;font-weight:700;cursor:pointer;transition:.18s ease}.button:hover{transform:translateY(-1px)}.button.primary{background:var(--ink);color:white}.button.ghost{background:white;border:1px solid var(--line);color:var(--ink)}.layout{display:grid;grid-template-columns:210px minmax(0,1fr);gap:0}.sidenav{position:sticky;top:72px;height:calc(100vh - 72px);padding:30px 22px;border-right:1px solid var(--line);background:#f7f9f8}.nav-title{font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:#98a29f;margin:0 10px 12px}.nav-link{display:flex;align-items:center;gap:10px;color:#66716f;text-decoration:none;padding:10px;border-radius:9px;margin:2px 0;font-weight:600}.nav-link:hover,.nav-link.active{background:white;color:var(--ink);box-shadow:0 4px 18px rgba(25,49,43,.06)}.nav-dot{width:7px;height:7px;border-radius:50%;background:#c4cecb}.nav-link.active .nav-dot{background:linear-gradient(135deg,var(--blue),var(--green))}.run-note{position:absolute;bottom:24px;left:22px;right:22px;padding:14px;background:white;border:1px solid var(--line);border-radius:12px;font-size:11px;color:var(--muted)}.run-note strong{display:block;color:var(--ink);font-size:12px;margin-bottom:4px}.main{min-width:0;padding:38px 44px 80px}.eyebrow{display:flex;align-items:center;gap:8px;text-transform:uppercase;letter-spacing:.12em;color:#74807d;font-size:11px;font-weight:800}.status-dot{width:8px;height:8px;border-radius:50%;background:var(--green);box-shadow:0 0 0 4px #e7f8e9}.hero-title{display:flex;justify-content:space-between;align-items:flex-end;gap:30px;margin:12px 0 28px}.hero-title h1{font-size:36px;line-height:1.08;letter-spacing:-.04em;margin:0;max-width:720px}.hero-title p{color:var(--muted);margin:10px 0 0;max-width:690px}.run-stamp{text-align:right;min-width:190px;color:var(--muted);font-size:12px}.run-stamp strong{display:block;color:var(--ink);font-size:14px;margin-bottom:3px}.metric-hero{position:relative;overflow:hidden;background:#172421;color:white;border-radius:22px;padding:28px 30px;box-shadow:var(--shadow);margin-bottom:18px}.metric-hero:after{content:"";position:absolute;width:400px;height:400px;right:-150px;top:-250px;border-radius:50%;background:radial-gradient(circle,rgba(71,215,117,.24),transparent 68%)}.metric-head{position:relative;z-index:1;display:flex;justify-content:space-between;gap:20px}.metric-tabs{display:flex;gap:5px;background:rgba(255,255,255,.08);border-radius:11px;padding:4px}.metric-tab{border:0;background:transparent;color:#aebbb7;padding:8px 12px;border-radius:8px;font-weight:700;cursor:pointer}.metric-tab.active{background:white;color:#13201e}.scores{position:relative;z-index:1;display:grid;grid-template-columns:1fr 1fr 1.2fr;gap:25px;margin-top:32px;align-items:end}.score-label{text-transform:uppercase;letter-spacing:.11em;color:#8fa09b;font-size:10px;font-weight:800}.score-value{font-size:39px;line-height:1;margin-top:8px;font-variant-numeric:tabular-nums;letter-spacing:-.04em}.uplift .score-value{color:#73e57b}.uplift p{color:#aebcb8;margin:8px 0 0;font-size:12px}.summary-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:18px 0 34px}.summary{background:white;border:1px solid var(--line);border-radius:15px;padding:17px 18px}.summary .k{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.09em;font-weight:800}.summary .v{font-size:23px;font-weight:750;margin-top:5px;letter-spacing:-.02em}.summary .d{font-size:11px;color:#88928f;margin-top:4px}.section{scroll-margin-top:94px;margin-top:36px}.section-head{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:15px}.section-head h2{font-size:22px;letter-spacing:-.025em;margin:0}.section-head p{color:var(--muted);margin:5px 0 0;font-size:12px}.panel{background:white;border:1px solid var(--line);border-radius:17px;box-shadow:0 4px 20px rgba(18,45,39,.035)}.chart-panel{padding:20px}.chart-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin-bottom:10px}.legend{display:flex;gap:17px;flex-wrap:wrap;font-size:11px;color:var(--muted)}.legend i{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px}.control{border:1px solid var(--line);background:#fafcfb;color:var(--ink);border-radius:9px;padding:8px 10px;font:inherit;font-size:12px}.canvas-wrap{height:280px;position:relative}canvas{width:100%;height:100%}.callout{display:flex;gap:12px;align-items:flex-start;padding:14px 16px;background:#fff8ed;border:1px solid #f1dcc0;border-radius:13px;color:#6b4b20}.callout strong{display:block;color:#4c361a;margin-bottom:2px}.callout-icon{width:27px;height:27px;display:grid;place-items:center;background:#f4c77f;border-radius:8px;font-weight:900;color:#65410c;flex:none}.stability-grid{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(260px,.5fr);gap:14px}.heatmap{overflow:auto;padding:16px}.heat-table{border-collapse:separate;border-spacing:5px;width:100%;font-size:11px}.heat-table th{color:var(--muted);font-weight:600;padding:2px 6px;white-space:nowrap}.heat-table th:first-child{text-align:left}.heat-table td:first-child{font-weight:700;padding-right:12px;white-space:nowrap}.heat{height:30px;min-width:47px;text-align:center;border-radius:6px;font-weight:750}.s1{background:#dbf4de;color:#276b2f}.s2{background:#e9f4cf;color:#526826}.s3{background:#fff0c7;color:#7f641b}.s4{background:#f8d5c8;color:#914634}.stability-side{padding:20px}.donut{width:140px;height:140px;border-radius:50%;margin:8px auto 17px;background:conic-gradient(var(--green) 0 72%,#e6b556 72% 91%,#df7474 91%);position:relative}.donut:after{content:"";position:absolute;inset:25px;background:white;border-radius:50%}.donut-label{position:absolute;inset:0;z-index:1;display:grid;place-items:center;text-align:center;font-weight:800;font-size:21px}.donut-label small{display:block;color:var(--muted);font-size:10px;font-weight:600}.stat-list{display:grid;gap:8px}.stat-row{display:flex;justify-content:space-between;padding-top:8px;border-top:1px solid #edf1ef;color:var(--muted);font-size:12px}.stat-row b{color:var(--ink)}.toolbar{display:flex;gap:9px;flex-wrap:wrap}.search{min-width:230px;border:1px solid var(--line);background:white;border-radius:9px;padding:9px 11px;font:inherit}.table-wrap{overflow:auto}.data-table{width:100%;border-collapse:collapse;min-width:860px}.data-table th{text-align:left;padding:12px 14px;background:#f8faf9;color:#77827f;text-transform:uppercase;letter-spacing:.07em;font-size:10px;border-bottom:1px solid var(--line)}.data-table td{padding:13px 14px;border-bottom:1px solid #edf1ef;font-size:12px}.data-table tr:last-child td{border-bottom:0}.data-table tbody tr:hover{background:#f8fbfa}.feature-name{font-weight:750;color:#25312f}.source-name{color:#74807d}.bar{height:6px;width:90px;background:#edf1ef;border-radius:99px;overflow:hidden}.bar i{height:100%;display:block;background:linear-gradient(90deg,var(--blue),#43c878);border-radius:99px}.tag{display:inline-flex;align-items:center;border-radius:999px;padding:4px 8px;font-size:10px;font-weight:750}.tag.good{background:#e8f7ea;color:#337139}.tag.watch{background:#fff1d9;color:#855d18}.tag.risk{background:#fde7e4;color:#984a40}.source-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.source-card{padding:19px}.source-top{display:flex;justify-content:space-between;gap:12px}.source-icon{width:38px;height:38px;border-radius:11px;display:grid;place-items:center;background:linear-gradient(135deg,#e8eeff,#e8f9e8);font-weight:850;color:var(--blue)}.source-card h3{font-size:14px;margin:12px 0 4px}.source-card p{font-size:11px;color:var(--muted);margin:0}.source-metrics{display:flex;gap:18px;margin-top:17px;padding-top:13px;border-top:1px solid #edf1ef}.source-metrics b{display:block;font-size:16px}.source-metrics span{color:var(--muted);font-size:10px}.notebook{padding:0;overflow:hidden}.cell-head{background:#f8faf9;border-bottom:1px solid var(--line);padding:10px 14px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#68736f;font-size:11px}.cell-output{padding:18px;display:flex;align-items:center;gap:25px;flex-wrap:wrap}.cell-quality{display:flex;gap:24px}.cell-quality span{font-size:11px;color:var(--muted)}.cell-quality b{font-size:17px;color:var(--ink);display:block}.footer{text-align:center;color:#8a9592;font-size:11px;padding:40px 0 10px}.empty{display:none;padding:24px;text-align:center;color:var(--muted)}
10
- .sample-table{min-width:700px}.toggle-legend{margin-bottom:10px}.series-toggle{border:0;background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:11px;padding:5px 7px;border-radius:6px}.series-toggle:not(.active){opacity:.35;text-decoration:line-through}.threshold-legend{display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px}.shap-panel{padding:16px}.shap-row{display:grid;grid-template-columns:minmax(170px,1fr) 2fr 60px;gap:12px;align-items:center;padding:9px 4px;border-bottom:1px solid #edf1ef}.shap-row:last-child{border:0}.provider{font-size:9px;margin-left:6px;padding:3px 6px;border-radius:99px;border:1px solid var(--line);color:var(--muted)}.shap-track{height:10px;background:#edf1ef;border-radius:99px;overflow:hidden}.shap-fill{display:block;height:100%;border-radius:99px}.shap-fill.upgini{background:#1645ee}.shap-fill.user{background:#20aab4}.shap-fill.autofe{background:#53cc61}.shap-fill.external{background:#7b5cff}.result-tabs{display:flex;gap:4px;margin-bottom:12px}.result-tab{border:1px solid var(--line);background:#eef2f1;padding:9px 13px;border-radius:9px;font:inherit;cursor:pointer;color:var(--muted)}.result-tab.active{background:var(--ink);color:#fff}.result-tab b{font-size:10px;margin-left:5px}.result-pane{display:none}.result-pane.active{display:block}.table-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;margin-bottom:10px}.chart-tooltip{position:absolute;display:none;pointer-events:none;background:#172421;color:white;padding:9px 11px;border-radius:8px;font-size:10px;box-shadow:var(--shadow);white-space:nowrap}.sidenav{overflow:auto}.run-note{position:static;margin-top:22px}.nav-link{font-size:12px;padding:8px 10px}
10
+ .sample-table{min-width:700px}.toggle-legend{margin-bottom:10px}.series-toggle{border:0;background:transparent;color:var(--muted);cursor:pointer;font:inherit;font-size:11px;padding:5px 7px;border-radius:6px}.series-toggle:not(.active){opacity:.35;text-decoration:line-through}.threshold-legend{display:flex;gap:8px;justify-content:flex-end;margin-bottom:10px}.shap-panel{padding:16px}.shap-row{display:grid;grid-template-columns:minmax(170px,1fr) 2fr 60px;gap:12px;align-items:center;padding:9px 4px;border-bottom:1px solid #edf1ef}.shap-row:last-child{border:0}.provider{display:inline-block;font-size:9px;margin-left:6px;padding:3px 7px;border-radius:8px;border:1px solid var(--line);color:var(--muted);line-height:1.3;text-align:center;vertical-align:middle;white-space:normal}.shap-track{height:10px;background:#edf1ef;border-radius:99px;overflow:hidden}.shap-fill{display:block;height:100%;border-radius:99px}.shap-fill.upgini{background:#1645ee}.shap-fill.user{background:#20aab4}.shap-fill.autofe{background:#53cc61}.shap-fill.external{background:#7b5cff}.result-tabs{display:flex;gap:4px;margin-bottom:12px}.result-tab{border:1px solid var(--line);background:#eef2f1;padding:9px 13px;border-radius:9px;font:inherit;cursor:pointer;color:var(--muted)}.result-tab.active{background:var(--ink);color:#fff}.result-tab b{font-size:10px;margin-left:5px}.result-pane{display:none}.result-pane.active{display:block}.table-actions{display:flex;gap:8px;justify-content:flex-end;flex-wrap:wrap;margin-bottom:10px}.chart-tooltip{position:absolute;display:none;pointer-events:none;background:#172421;color:white;padding:9px 11px;border-radius:8px;font-size:10px;box-shadow:var(--shadow);white-space:nowrap}.sidenav{overflow:auto}.run-note{position:static;margin-top:22px}.nav-link{font-size:12px;padding:8px 10px}
11
11
  .metric-sample-table{position:relative;z-index:1;margin-top:26px}.metric-sample-head,.metric-sample-row{display:grid;grid-template-columns:minmax(140px,.9fr) 1fr 1fr .8fr .8fr;gap:18px;align-items:center}.metric-sample-ci{display:block;margin-top:6px;color:#8fa09b;font-size:11px;font-weight:600;font-variant-numeric:tabular-nums}.metric-note{color:#8fa09b;font-size:11px;margin-top:6px}.partner-logo{height:30px;width:auto;display:block}.metric-sample-head{padding:0 14px 8px;color:#8fa09b;text-transform:uppercase;letter-spacing:.11em;font-size:10px;font-weight:800}.metric-sample-row{padding:15px 14px;border-top:1px solid rgba(255,255,255,.12)}.metric-sample-name{display:flex;align-items:center;gap:10px;font-size:16px;font-weight:760}.metric-sample-name i{width:9px;height:9px;border-radius:50%;background:var(--blue);box-shadow:0 0 0 4px rgba(22,69,238,.18)}.metric-sample-row[data-sample="eval_1"] .metric-sample-name i{background:var(--cyan);box-shadow:0 0 0 4px rgba(32,170,180,.18)}.metric-sample-row[data-sample="eval_2"] .metric-sample-name i{background:var(--green);box-shadow:0 0 0 4px rgba(83,204,97,.18)}.metric-sample-name small{display:block;color:#8fa09b;font-size:10px;font-weight:650;margin-top:2px}.metric-sample-value{font-size:27px;line-height:1;font-variant-numeric:tabular-nums;letter-spacing:-.035em}.metric-sample-uplift .metric-sample-value{color:#73e57b}.metric-sample-uplift p{margin:5px 0 0;color:#aebcb8;font-size:11px}.tall-chart{height:340px}.chart-tooltip b{font-size:11px}.chart-tooltip .tip-label{color:#aebbb7}.canvas-wrap canvas{cursor:crosshair}.search-id-chip{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;max-width:360px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
12
12
  @media(max-width:980px){.layout{grid-template-columns:1fr}.sidenav{display:none}.main{padding:28px 22px 70px}.topbar{padding:0 22px}.scores{grid-template-columns:1fr 1fr}.uplift{grid-column:1/-1}.summary-grid{grid-template-columns:1fr 1fr}.stability-grid,.sample-grid{grid-template-columns:1fr}.source-cards{grid-template-columns:1fr 1fr}}
13
13
  @media(max-width:650px){.top-meta .chip,.brand-divider,.brand-label{display:none}.topbar{gap:10px}.button{padding:9px 10px}.hero-title{display:block}.run-stamp{text-align:left;margin-top:16px}.hero-title h1{font-size:30px}.metric-head{display:block}.metric-tabs{margin-top:15px;width:max-content}.summary-grid,.source-cards{grid-template-columns:1fr}.scores{gap:14px}.score-value{font-size:32px}.metric-sample-head{display:none}.metric-sample-row{grid-template-columns:1fr 1fr;gap:14px;padding:16px 2px}.metric-sample-name{grid-column:1/-1}.metric-sample-value{font-size:24px}.metric-sample-row>div:not(.metric-sample-name):before{display:block;color:#8fa09b;text-transform:uppercase;letter-spacing:.08em;font-size:8px;font-weight:800;margin-bottom:6px}.metric-sample-row>div:nth-child(2):before{content:"Baseline"}.metric-sample-row>div:nth-child(3):before{content:"Enriched"}.metric-sample-row>div:nth-child(4):before{content:"Uplift, abs"}.metric-sample-row>div:nth-child(5):before{content:"Uplift, %"}.main{padding-left:15px;padding-right:15px}.cell-output{gap:14px}}
@@ -127,6 +127,9 @@ const REPORT_DATA = __REPORT_DATA__;
127
127
  const pct1 = v => (v == null ? '—' : Number(v).toFixed(1) + '%');
128
128
  const signed3 = v => (v >= 0 ? '+' : '') + Number(v).toFixed(3);
129
129
  const setText = (id, text) => { const el = document.getElementById(id); if (el) el.textContent = text; };
130
+ const providerBadge = provider => provider
131
+ ? `<span class="provider">${String(provider).replace(/,\s*/g, '<br>')}</span>`
132
+ : '';
130
133
  function rgba(hex, alpha) {
131
134
  const h = hex.replace('#', '');
132
135
  const n = parseInt(h.length === 3 ? h.split('').map(x => x + x).join('') : h, 16);
@@ -507,7 +510,7 @@ const REPORT_DATA = __REPORT_DATA__;
507
510
  const maxImportance = Math.max(...features.map(f => f.importance)) || 1;
508
511
  const list = shapAll ? features : features.slice(0, (D.shap && D.shap.topN) || features.length);
509
512
  document.getElementById('shapList').innerHTML = list.map((f, i) =>
510
- `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b>${f.provider ? `<span class="provider">${f.provider}</span>` : ''}</div>` +
513
+ `<div class="shap-row"><div><b>#${i + 1} ${f.name}</b>${providerBadge(f.provider)}</div>` +
511
514
  `<div class="shap-track"><i class="shap-fill ${f.shapClass || 'upgini'}" style="width:${(f.importance / maxImportance * 100).toFixed(2)}%"></i></div>` +
512
515
  `<b>${fmt3(Math.abs(f.shap))}</b></div>`).join('');
513
516
  document.getElementById('showAllShap').textContent = shapAll ? `Show top ${(D.shap && D.shap.topN) || features.length}` : `Show all ${features.length}`;
@@ -534,7 +537,7 @@ const REPORT_DATA = __REPORT_DATA__;
534
537
  const body = document.getElementById('featureBody');
535
538
  body.innerHTML = rows.map(f =>
536
539
  `<tr data-name="${f.name}"><td><span class="feature-name" title="${f.name}">${f.name}</span></td>` +
537
- `<td>${f.provider ? `<span class="provider">${f.provider}</span>` : ''}</td><td class="source-name">${f.source}</td>` +
540
+ `<td>${providerBadge(f.provider)}</td><td class="source-name">${f.source}</td>` +
538
541
  `<td>${shapLabel(f.shap)}</td><td><span class="tag ${f.status}">${fmt2(f.psi)}</span></td>` +
539
542
  `<td>${f.drift == null ? '—' : fmt2(f.drift)}</td><td>${pct1(f.coverage)}</td></tr>`).join('');
540
543
  document.getElementById('emptyState').style.display = rows.length ? 'none' : 'block';
@@ -210,6 +210,31 @@ class SearchTask:
210
210
  if meta.generated_features is not None:
211
211
  return meta.generated_features
212
212
 
213
+ def get_all_generated_features(self) -> List[GeneratedFeatureMetadata]:
214
+ if self.provider_metadata_v2 is None:
215
+ return []
216
+ generated: List[GeneratedFeatureMetadata] = []
217
+ for meta in self.provider_metadata_v2:
218
+ if meta.generated_features:
219
+ generated.extend(meta.generated_features)
220
+ return generated
221
+
222
+ def get_joined_ads_features_count(self) -> Optional[int]:
223
+ if self.provider_metadata_v2 is None:
224
+ return None
225
+ for meta in self.provider_metadata_v2:
226
+ if meta.joined_ads_features_count is not None:
227
+ return meta.joined_ads_features_count
228
+ return None
229
+
230
+ def get_joined_ads_count(self) -> Optional[int]:
231
+ if self.provider_metadata_v2 is None:
232
+ return None
233
+ for meta in self.provider_metadata_v2:
234
+ if meta.joined_ads_count is not None:
235
+ return meta.joined_ads_count
236
+ return None
237
+
213
238
  @staticmethod
214
239
  def _get_provider_summaries(summary: SearchTaskSummary) -> List[ProviderTaskSummary]:
215
240
  if summary.status in {
@@ -1 +0,0 @@
1
- __version__ = "1.2.167.a4220.dev8"