ap-client 0.7.0.dev9__tar.gz → 0.7.1.dev0__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 (34) hide show
  1. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/PKG-INFO +1 -1
  2. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/api.py +111 -1
  3. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/cli.py +15 -15
  4. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dataset_commands.py +13 -8
  5. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dataset_register.py +1 -2
  6. ap_client-0.7.1.dev0/ap_client/profile_cascade_all.py +149 -0
  7. ap_client-0.7.1.dev0/ap_client/profile_cascade_cli.py +356 -0
  8. ap_client-0.7.1.dev0/ap_client/profile_cascade_display.py +279 -0
  9. ap_client-0.7.1.dev0/ap_client/profile_cascade_state.py +207 -0
  10. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/profile_commands.py +491 -48
  11. ap_client-0.7.1.dev0/ap_client/profile_diagnostics.py +117 -0
  12. ap_client-0.7.1.dev0/ap_client/profile_display.py +172 -0
  13. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/pyproject.toml +1 -1
  14. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/__init__.py +0 -0
  15. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/akpool_commands.py +0 -0
  16. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/config.py +0 -0
  17. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dataset_name.py +0 -0
  18. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/dfs_commands.py +0 -0
  19. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/exporter.py +0 -0
  20. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/fs_commands.py +0 -0
  21. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/fs_storage.py +0 -0
  22. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/fs_transfer.py +0 -0
  23. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/instance_commands.py +0 -0
  24. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/instance_options.py +0 -0
  25. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/irepo_sdk.py +0 -0
  26. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/managed_ak.py +0 -0
  27. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/manifest_view.py +0 -0
  28. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/ossutil_hash.py +0 -0
  29. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/ossutil_io.py +0 -0
  30. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/repo_read_guard.py +0 -0
  31. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/share_api.py +0 -0
  32. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/split_publish.py +0 -0
  33. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/tbb.py +0 -0
  34. {ap_client-0.7.0.dev9 → ap_client-0.7.1.dev0}/ap_client/waiter.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ap-client
3
- Version: 0.7.0.dev9
3
+ Version: 0.7.1.dev0
4
4
  Summary: Agent Platform API Client & CLI
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: irepo-artifact==0.2.4
@@ -2963,6 +2963,8 @@ class APIClient:
2963
2963
  account_pool: Optional[str] = None,
2964
2964
  metadata: Optional[dict] = None,
2965
2965
  tags: Optional[list[str]] = None,
2966
+ parents: Optional[list[str | dict]] = None,
2967
+ auto_follow_parents: Optional[list[str]] = None,
2966
2968
  ) -> dict:
2967
2969
  """Create a new submission profile."""
2968
2970
  body: dict = {"name": name, "spec": spec}
@@ -2974,6 +2976,10 @@ class APIClient:
2974
2976
  body["metadata"] = metadata
2975
2977
  if tags is not None:
2976
2978
  body["tags"] = tags
2979
+ if parents is not None:
2980
+ body["parents"] = parents
2981
+ if auto_follow_parents is not None:
2982
+ body["auto_follow_parents"] = auto_follow_parents
2977
2983
  return self._post(self._PROFILES_BASE, body)
2978
2984
 
2979
2985
  def list_profiles(
@@ -3115,6 +3121,9 @@ class APIClient:
3115
3121
  account_pool: Optional[str] = None,
3116
3122
  metadata: Optional[dict] = None,
3117
3123
  reason: Optional[str] = None,
3124
+ version: Optional[int] = None,
3125
+ parents: Optional[list[str | dict]] = None,
3126
+ auto_follow_parents: Optional[list[str]] = None,
3118
3127
  ) -> dict:
3119
3128
  """Update a submission profile, preserving an omitted default Pool.
3120
3129
 
@@ -3130,7 +3139,108 @@ class APIClient:
3130
3139
  body["metadata"] = metadata
3131
3140
  if reason is not None:
3132
3141
  body["reason"] = reason
3133
- return self._put(f"{self._PROFILES_BASE}/{quote(profile_id)}", body)
3142
+ if version is not None:
3143
+ if type(version) is not int or version < 1:
3144
+ raise ValueError("version must be a positive integer")
3145
+ body["version"] = version
3146
+ if parents is not None:
3147
+ body["parents"] = parents
3148
+ if auto_follow_parents is not None:
3149
+ body["auto_follow_parents"] = auto_follow_parents
3150
+ return self._put(f"{self._PROFILES_BASE}/{quote(profile_id, safe='')}", body)
3151
+
3152
+ def patch_profile(self, profile_id: str, fields: dict) -> dict:
3153
+ """Patch own definition; callers should supply their original version."""
3154
+ return self._request(
3155
+ "PATCH", f"{self._PROFILES_BASE}/{quote(profile_id, safe='')}", json_body=fields
3156
+ )
3157
+
3158
+ def resolve_profile(self, identifier: str, *, version: Optional[int] = None) -> dict:
3159
+ return self._get(
3160
+ f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/resolved",
3161
+ params={"version": version} if version is not None else None,
3162
+ )
3163
+
3164
+ def preview_profile(self, draft: dict) -> dict:
3165
+ return self._post(f"{self._PROFILES_BASE}/resolve", draft)
3166
+
3167
+ def profile_children(
3168
+ self,
3169
+ identifier: str,
3170
+ *,
3171
+ page: int = 1,
3172
+ page_size: int = 20,
3173
+ target_version: Optional[int] = None,
3174
+ eligible_only: bool = False,
3175
+ current_only: bool = False,
3176
+ ) -> dict:
3177
+ params = {"page": page, "page_size": page_size}
3178
+ if target_version is not None:
3179
+ params["target_version"] = target_version
3180
+ if eligible_only:
3181
+ params["eligible_only"] = "true"
3182
+ if current_only:
3183
+ params["current_only"] = "true"
3184
+ return self._get(
3185
+ f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/children",
3186
+ params=params,
3187
+ )
3188
+
3189
+ def profile_diff(
3190
+ self,
3191
+ identifier: str,
3192
+ from_version: Optional[int] = None,
3193
+ to_version: Optional[int] = None,
3194
+ view: str = "own",
3195
+ *,
3196
+ compare_to: Optional[str] = None,
3197
+ ) -> dict:
3198
+ """Compare versions of one Profile, or two Profiles (omitted versions use current)."""
3199
+ params = {"from": from_version, "to": to_version, "view": view}
3200
+ if compare_to is not None:
3201
+ params["compare_to"] = compare_to
3202
+ result = self._get(
3203
+ f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/diff",
3204
+ params=params,
3205
+ )
3206
+ if compare_to is not None:
3207
+ # Older servers ignore unknown query fields. Do not present their
3208
+ # single-Profile response as a successful cross-Profile comparison.
3209
+ for field in ("from_profile", "to_profile"):
3210
+ ref = result.get(field) if isinstance(result, dict) else None
3211
+ if (
3212
+ not isinstance(ref, dict)
3213
+ or not isinstance(ref.get("profile_id"), str)
3214
+ or not ref["profile_id"].startswith("sp-")
3215
+ or type(ref.get("version")) is not int
3216
+ or ref["version"] < 1
3217
+ ):
3218
+ raise APIError(
3219
+ 200,
3220
+ "Server did not confirm cross-Profile diff support; upgrade the server before comparing different Profiles",
3221
+ None,
3222
+ None,
3223
+ )
3224
+ return result
3225
+
3226
+ def cascade_profiles(self, identifier: str, request: dict) -> dict:
3227
+ return self._post(f"{self._PROFILES_BASE}/{quote(identifier, safe='')}/cascades", request)
3228
+
3229
+ def get_profile_cascade(self, operation_id: str) -> dict:
3230
+ return self._get(f"/apis/v1/profile-cascades/{quote(operation_id, safe='')}")
3231
+
3232
+ def profile_cascade_history(
3233
+ self, profile_id: str, *, page: int = 1, page_size: int = 20
3234
+ ) -> dict:
3235
+ return self._get(
3236
+ f"{self._PROFILES_BASE}/{quote(profile_id, safe='')}/cascades",
3237
+ params={"page": page, "page_size": page_size},
3238
+ )
3239
+
3240
+ def retry_profile_cascade(self, operation_id: str, items: list[dict]) -> dict:
3241
+ return self._post(
3242
+ f"/apis/v1/profile-cascades/{quote(operation_id, safe='')}/retry", {"items": items}
3243
+ )
3134
3244
 
3135
3245
  def delete_profile(self, profile_id: str) -> dict:
3136
3246
  """Delete a submission profile."""
@@ -3003,22 +3003,22 @@ def job_create(
3003
3003
  meta_model: Optional[str] = typer.Option(
3004
3004
  None,
3005
3005
  "--meta-model",
3006
- help="Target model or training project this job serves; not the called model or Judge. Values: ap meta-tag get meta-model --format json",
3006
+ help="Meta model tag; omit to leave unset",
3007
3007
  ),
3008
3008
  meta_job_type: Optional[str] = typer.Option(
3009
3009
  None,
3010
3010
  "--meta-job-type",
3011
- help="Business purpose of this job. Values: ap meta-tag get meta-job-type --format json",
3011
+ help="Meta job type tag: Eval-Eval (default) or Others",
3012
3012
  ),
3013
3013
  meta_teacher_direction: Optional[str] = typer.Option(
3014
3014
  None,
3015
3015
  "--meta-teacher-direction",
3016
- help="Teacher direction this job serves; verify any dataset-derived value. Values: ap meta-tag get meta-teacher-direction --format json",
3016
+ help="Meta teacher direction tag; omit to derive from dataset_series; options: ap meta-teacher-direction list",
3017
3017
  ),
3018
3018
  meta_train_stage: Optional[str] = typer.Option(
3019
3019
  None,
3020
3020
  "--meta-train-stage",
3021
- help="Training stage this job serves; verify any dataset-derived value. Values: ap meta-tag get meta-train-stage --format json",
3021
+ help="Meta train stage tag; omit to derive from dataset run_type; options: ap meta-train-stage list",
3022
3022
  ),
3023
3023
  overrides: Optional[str] = typer.Option(None, "--overrides", help="Override config (JSON)"),
3024
3024
  concurrency: Optional[int] = typer.Option(
@@ -3190,7 +3190,7 @@ def job_create(
3190
3190
  benchmark: Optional[str] = typer.Option(
3191
3191
  None,
3192
3192
  "--benchmark",
3193
- help="Prefer the registered benchmark source ID for this run; name is also accepted. Find IDs: ap benchmark list --format json; inspect by name: ap benchmark get NAME --format json",
3193
+ help="Benchmark name from the AP registry (see 'ap benchmark list')",
3194
3194
  ),
3195
3195
  checkpoint_id: Optional[str] = typer.Option(
3196
3196
  None,
@@ -3236,16 +3236,6 @@ def job_create(
3236
3236
  ):
3237
3237
  """Submit a job.
3238
3238
 
3239
- For agents:
3240
- \b
3241
- Before submitting, you MUST ensure these five parameters are explicitly specified:
3242
- benchmark, meta-model, meta-job-type, meta-train-stage, meta-teacher-direction.
3243
- Keep specified values. Fill missing parameters using available Job, Group,
3244
- Profile, Dataset, Template, or other relevant information.
3245
- If a missing value cannot be inferred, use default for benchmark
3246
- or Others for the corresponding meta parameter.
3247
- Do not ask the user for these values.
3248
-
3249
3239
  Model configuration:
3250
3240
  Supply template parameters through --params/-p; select a managed credential with --ak-id.
3251
3241
 
@@ -3280,6 +3270,12 @@ def job_create(
3280
3270
  # Apply per-instance resource settings from a Resource Profile
3281
3271
  ap job create claw-eval-task --dataset claw-eval/v1 --resource-profile rp-123
3282
3272
 
3273
+ Note: benchmark+ckpt metadata (--benchmark/--checkpoint-*/--experiment) on a
3274
+ standalone single job needs no client-side group handling — the server
3275
+ auto-creates a Group (auto_created_benchmark_group) to persist the
3276
+ Group-level fields. Generating a group_id client-side would break
3277
+ idempotent retries: the server rejects a same idempotency_key with a
3278
+ different requested group_id (409 idempotency_conflict).
3283
3279
  """
3284
3280
  if transparency is not None and transparency not in {"whitebox", "blackbox"}:
3285
3281
  raise typer.BadParameter("Choose whitebox or blackbox", param_hint="--transparency")
@@ -6684,6 +6680,10 @@ def main() -> None:
6684
6680
 
6685
6681
 
6686
6682
  def _format_api_error(exc: APIError) -> str:
6683
+ from ap_client.profile_diagnostics import format_profile_error
6684
+
6685
+ if profile_error := format_profile_error(exc):
6686
+ return profile_error
6687
6687
  if isinstance(exc.detail, dict) and exc.detail.get("code") == "blackbox_content_restricted":
6688
6688
  return f"error: Blackbox content is hidden; Group Reader access is required. (request_id={exc.request_id})"
6689
6689
  if str(exc.detail) != _NON_JSON_RESPONSE_DETAIL:
@@ -716,7 +716,9 @@ def _versioned_split(
716
716
  f"Check the dataset name and permission: ap dataset get {dataset}",
717
717
  ) from exc
718
718
  detail = _model_dict(record) if record is not None else None
719
- return detail if detail is not None and _version_value(detail.get("version")) == version else None
719
+ return (
720
+ detail if detail is not None and _version_value(detail.get("version")) == version else None
721
+ )
720
722
 
721
723
 
722
724
  def _versioned_splits(
@@ -1286,8 +1288,9 @@ def dataset_get(
1286
1288
 
1287
1289
  def dataset_create(
1288
1290
  dataset: str = typer.Argument(
1289
- ..., help="Dataset name: L1, L1/L2, or a deeper path "
1290
- "(e.g. my-bench, alibaba/my-bench, or gdpval/synthetic/my-bench)"
1291
+ ...,
1292
+ help="Dataset name: L1, L1/L2, or a deeper path "
1293
+ "(e.g. my-bench, alibaba/my-bench, or gdpval/synthetic/my-bench)",
1291
1294
  ),
1292
1295
  benchmark: str = typer.Option(..., "--benchmark", help="Benchmark name or source_id"),
1293
1296
  description: Optional[str] = typer.Option(None, "--description", help="Dataset description"),
@@ -1946,9 +1949,7 @@ def dataset_split_get(
1946
1949
  resolved_version = _version_value(version)
1947
1950
 
1948
1951
  if resolved_version:
1949
- record = _versioned_split(
1950
- dataset, resolved_version, split, environment=environment
1951
- )
1952
+ record = _versioned_split(dataset, resolved_version, split, environment=environment)
1952
1953
  if record is not None:
1953
1954
  payload = dict(record)
1954
1955
  payload.setdefault("dataset", dataset)
@@ -2151,7 +2152,9 @@ def dataset_split_update(
2151
2152
  ", ".join(
2152
2153
  _split_names_of(
2153
2154
  _versioned_splits(
2154
- dataset, resolved_version, stage="split update precheck",
2155
+ dataset,
2156
+ resolved_version,
2157
+ stage="split update precheck",
2155
2158
  environment=environment,
2156
2159
  )
2157
2160
  )
@@ -2171,7 +2174,9 @@ def dataset_split_update(
2171
2174
  repo = _dataset_repo()
2172
2175
  update_kwargs = _sdk_read_kwargs(repo.splits, "update", environment=env, metadata_model="")
2173
2176
  try:
2174
- repo.splits.update(dataset, split, version=resolved_version, run_type=resolved_run_type, **update_kwargs)
2177
+ repo.splits.update(
2178
+ dataset, split, version=resolved_version, run_type=resolved_run_type, **update_kwargs
2179
+ )
2175
2180
  except Exception as exc: # noqa: BLE001 - 统一走设计 §5.1 的映射
2176
2181
  raise _fail(
2177
2182
  f"Failed to update split {split} of dataset {dataset} version {resolved_version}: {_reason(exc)}",
@@ -121,8 +121,7 @@ def _create_owned_dataset(repo: Any, dataset: str, benchmark_id: str) -> dict:
121
121
  raise datasets._fail(
122
122
  f"Failed to create dataset {dataset}: the name must be one or more "
123
123
  "canonical path segments",
124
- "Use a dataset name such as my-bench, your-team/my-bench, "
125
- "or gdpval/synthetic/my-bench",
124
+ "Use a dataset name such as my-bench, your-team/my-bench, or gdpval/synthetic/my-bench",
126
125
  exit_code=2,
127
126
  )
128
127
 
@@ -0,0 +1,149 @@
1
+ """Bounded all-Child discovery and replayable client-side cascade batches."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import uuid
6
+ from copy import deepcopy
7
+
8
+ from ap_client.api import APIClient
9
+
10
+ BATCH_SIZE = 100
11
+
12
+
13
+ def preview_all(client: APIClient, identifier: str, target_version: int) -> dict:
14
+ batches: list[dict] = []
15
+ after = end = ""
16
+ base_id = None
17
+ seen: set[str] = set()
18
+ while True:
19
+ body = {"dry_run": True, "all": True, "target_version": target_version}
20
+ if after:
21
+ body.update(after=after, selection_end=end)
22
+ value = client.cascade_profiles(base_id or identifier, body)
23
+ if (
24
+ value.get("all") is not True
25
+ or value.get("dry_run") is not True
26
+ or not value.get("base_profile_id")
27
+ or value.get("target_version") != target_version
28
+ or (base_id and value["base_profile_id"] != base_id)
29
+ ):
30
+ raise ValueError(
31
+ "Invalid all-Children preview response; update the server and preview again"
32
+ )
33
+ base_id = value["base_profile_id"]
34
+ next_end = value.get("selection_end", "")
35
+ if after and next_end != end:
36
+ raise ValueError("The all-Children selection boundary changed; preview again")
37
+ end = next_end
38
+ items = value.get("items")
39
+ if not isinstance(items, list) or len(items) > BATCH_SIZE:
40
+ raise ValueError("Invalid preview batch size")
41
+ ids = [item.get("child_id") for item in items]
42
+ if (
43
+ any(not isinstance(i, str) or not i or i <= after or (end and i > end) for i in ids)
44
+ or ids != sorted(ids)
45
+ or len(set(ids)) != len(ids)
46
+ or seen.intersection(ids)
47
+ ):
48
+ raise ValueError("Invalid preview cursor or duplicate Child")
49
+ seen.update(ids)
50
+ if items:
51
+ batches.append(value)
52
+ cursor = value.get("next_cursor", "")
53
+ if not cursor:
54
+ break
55
+ if not ids or cursor != ids[-1] or cursor <= after or cursor >= end:
56
+ raise ValueError("Invalid preview cursor")
57
+ after = cursor
58
+ return {
59
+ "dry_run": True,
60
+ "all": True,
61
+ "base_profile_id": base_id,
62
+ "target_version": target_version,
63
+ "selection_end": end,
64
+ "batches": batches,
65
+ }
66
+
67
+
68
+ def execution_targets(
69
+ items: list[dict], base_id: str, target: int, seen: set[str]
70
+ ) -> tuple[list[dict], int]:
71
+ """Use the same selection rules for manual and all-Child execution."""
72
+ if not isinstance(items, list) or len(items) > BATCH_SIZE:
73
+ raise ValueError("Each preview batch must have at most 100 Children")
74
+ targets, excluded = [], 0
75
+ for item in items:
76
+ if not isinstance(item, dict):
77
+ raise ValueError("Invalid Child in saved preview")
78
+ child_id = item.get("child_id")
79
+ if not isinstance(child_id, str) or not child_id.startswith("sp-") or child_id in seen:
80
+ raise ValueError("Invalid or duplicate Child in saved preview")
81
+ seen.add(child_id)
82
+ if item.get("valid") is not True:
83
+ excluded += 1
84
+ continue
85
+ baseline = item.get("target")
86
+ if not isinstance(baseline, dict) or set(baseline) != {
87
+ "child_id",
88
+ "expected_child_version",
89
+ "expected_parent",
90
+ }:
91
+ raise ValueError(
92
+ "Every valid Child requires a complete target; upgrade the server and preview again"
93
+ )
94
+ parent, version = baseline.get("expected_parent"), baseline.get("expected_child_version")
95
+ if (
96
+ baseline.get("child_id") != child_id
97
+ or type(version) is not int
98
+ or version < 1
99
+ or not isinstance(parent, dict)
100
+ or set(parent) != {"profile_id", "version"}
101
+ or parent.get("profile_id") != base_id
102
+ or type(parent.get("version")) is not int
103
+ or parent["version"] < 1
104
+ ):
105
+ raise ValueError("Every valid Child requires a complete preview baseline")
106
+ if parent["version"] >= target:
107
+ excluded += 1
108
+ continue
109
+ # Send the server's target unchanged, independent of display filters.
110
+ targets.append(deepcopy(baseline))
111
+ return targets, excluded
112
+
113
+
114
+ def validate_preview_identity(preview: dict) -> tuple[str, int]:
115
+ if not isinstance(preview, dict) or preview.get("dry_run") is not True:
116
+ raise ValueError("Expected a saved dry-run preview")
117
+ base_id, target = preview.get("base_profile_id"), preview.get("target_version")
118
+ if (
119
+ not isinstance(base_id, str)
120
+ or not base_id.startswith("sp-")
121
+ or type(target) is not int
122
+ or target < 1
123
+ ):
124
+ raise ValueError("Invalid saved preview identity")
125
+ return base_id, target
126
+
127
+
128
+ def execution_batches(preview: dict, root_key: str) -> tuple[list[dict], int]:
129
+ """Validate the whole saved plan before any writes; each batch gets a stable UUID."""
130
+ root = uuid.UUID(root_key)
131
+ base_id, target = validate_preview_identity(preview)
132
+ if preview.get("all") is not True or not isinstance(preview.get("batches"), list):
133
+ raise ValueError("Expected an --all preview file")
134
+ requests, seen, excluded = [], set(), 0
135
+ for index, batch in enumerate(preview["batches"]):
136
+ if not isinstance(batch, dict) or validate_preview_identity(batch) != (base_id, target):
137
+ raise ValueError("Preview batches must use the same fixed Base and target")
138
+ targets, skipped = execution_targets(batch.get("items"), base_id, target, seen)
139
+ excluded += skipped
140
+ if targets:
141
+ requests.append(
142
+ {
143
+ "dry_run": False,
144
+ "request_key": str(uuid.uuid5(root, f"profile-cascade-batch:{index}")),
145
+ "target_version": target,
146
+ "targets": targets,
147
+ }
148
+ )
149
+ return requests, excluded