@zhuoyuezs/ml-platform 0.2.7 → 0.2.9

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.
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const crypto = require("crypto");
3
4
  const fs = require("fs");
4
5
  const os = require("os");
5
6
  const path = require("path");
@@ -16,14 +17,120 @@ function apiErrorDetail(result, fallback) {
16
17
  return result.detail;
17
18
  return JSON.stringify(result, null, 2);
18
19
  }
20
+ // Render the body of a failed binary download with the same rules as JSON calls,
21
+ // so structured FastAPI `detail` objects never collapse to "[object Object]".
22
+ async function responseErrorDetail(response, signal) {
23
+ let text = "";
24
+ try {
25
+ text = String(await (signal ? abortable(response.text(), signal) : response.text()));
26
+ }
27
+ catch (_) { /* fall back to status text */ }
28
+ let parsed;
29
+ try {
30
+ parsed = JSON.parse(text);
31
+ }
32
+ catch (_) {
33
+ parsed = undefined;
34
+ }
35
+ return apiErrorDetail(parsed, text.trim() || response.statusText || "request failed");
36
+ }
37
+ function safeRelativeArtifactPath(relative) {
38
+ const normalized = String(relative).replace(/\\/g, "/");
39
+ const parts = normalized.split("/");
40
+ if (!normalized || normalized.startsWith("/") || parts.some((part) => part === "" || part === "." || part === "..")) {
41
+ throw new Error(`artifact path escapes output directory: ${JSON.stringify(String(relative))}`);
42
+ }
43
+ return { normalized, parts };
44
+ }
45
+ // Connection-failure policy. A failure is retried only when it happened before
46
+ // any response headers arrived AND either
47
+ // - the method is idempotent (GET/HEAD): transient connection errors and the
48
+ // per-attempt timeout are retried with bounded backoff; or
49
+ // - any method: the TCP connection was never established (connection refused
50
+ // or connect timeout), which proves no request bytes reached the server.
51
+ // Every other failure of a non-idempotent request (for example a reset after
52
+ // the request was written) is reported once, because the server may already
53
+ // have applied it.
54
+ const IDEMPOTENT_METHODS = new Set(["GET", "HEAD"]);
55
+ const UNSENT_CONNECT_CODES = new Set(["ECONNREFUSED", "UND_ERR_CONNECT_TIMEOUT"]);
56
+ const TRANSIENT_CONNECTION_CODES = new Set([
57
+ ...UNSENT_CONNECT_CODES, "UND_ERR_SOCKET", "UND_ERR_CLOSED", "ECONNRESET", "EPIPE", "ETIMEDOUT", "ECONNABORTED",
58
+ "EHOSTUNREACH", "ENETUNREACH", "ENETDOWN", "EAI_AGAIN",
59
+ ]);
60
+ const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000];
61
+ // Error codes along the `cause` chain (undici wraps socket errors as
62
+ // `TypeError: fetch failed` with the real error in `cause`; happy-eyeballs
63
+ // connects may report an AggregateError with per-address errors).
64
+ function errorCodes(error, seen = new Set()) {
65
+ if (!error || typeof error !== "object" || seen.has(error))
66
+ return [];
67
+ seen.add(error);
68
+ const own = typeof error.code === "string" ? [error.code] : [];
69
+ const nested = Array.isArray(error.errors) ? error.errors.flatMap((item) => errorCodes(item, seen)) : [];
70
+ return [...own, ...nested, ...errorCodes(error.cause, seen)];
71
+ }
72
+ function describeError(error) {
73
+ if (!(error instanceof Error))
74
+ return String(error);
75
+ const details = [];
76
+ const seen = new Set([error]);
77
+ let cause = error.cause;
78
+ while (cause && typeof cause === "object" && !seen.has(cause)) {
79
+ seen.add(cause);
80
+ const message = cause.message || (Array.isArray(cause.errors) ? cause.errors.map((item) => item?.message).filter(Boolean).join("; ") : "");
81
+ details.push([cause.code, message].filter(Boolean).join(": "));
82
+ cause = cause.cause;
83
+ }
84
+ const suffix = details.filter(Boolean);
85
+ return suffix.length ? `${error.message} (${suffix.join(" <- ")})` : error.message;
86
+ }
87
+ function retryableFailure(method, error, timedOut) {
88
+ if (IDEMPOTENT_METHODS.has(method))
89
+ return timedOut || errorCodes(error).some((code) => TRANSIENT_CONNECTION_CODES.has(code));
90
+ if (timedOut)
91
+ return false;
92
+ const codes = errorCodes(error);
93
+ return codes.length > 0 && codes.every((code) => UNSENT_CONNECT_CODES.has(code));
94
+ }
19
95
  class PlatformApiClient {
20
- constructor(baseUrl, timeoutSeconds = 30) {
96
+ constructor(baseUrl, timeoutSeconds = 30, { retryDelaysMs = DEFAULT_RETRY_DELAYS_MS } = {}) {
21
97
  this.baseUrl = String(baseUrl).trim().replace(/\/+$/, "");
22
98
  if (!/^https?:\/\//.test(this.baseUrl))
23
99
  throw new Error("server profile api_url must start with http:// or https://");
24
100
  if (!(timeoutSeconds > 0))
25
101
  throw new Error("request timeout must be positive");
26
102
  this.timeoutMs = timeoutSeconds * 1000;
103
+ this.retryDelaysMs = [...retryDelaysMs];
104
+ }
105
+ // Send one request and return once response headers arrive. The caller owns
106
+ // the returned per-attempt timeout and must call `done()` after reading the
107
+ // body. Each request asks for `Connection: close`, so no pooled keep-alive
108
+ // socket is ever reused after the server (uvicorn closes idle sockets after
109
+ // 5 s) or a VPN/NAT hop has dropped it.
110
+ async open(method, url, { headers = {}, body } = {}) {
111
+ for (let attempt = 0;; attempt += 1) {
112
+ const controller = new AbortController();
113
+ let timedOut = false;
114
+ const timer = setTimeout(() => { timedOut = true; controller.abort(); }, this.timeoutMs);
115
+ try {
116
+ const response = await fetch(url, { method, headers: { ...headers, Connection: "close" }, body, signal: controller.signal });
117
+ return { response, signal: controller.signal, done: () => clearTimeout(timer) };
118
+ }
119
+ catch (error) {
120
+ clearTimeout(timer);
121
+ const detail = timedOut ? `request timed out after ${this.timeoutMs / 1000}s (${describeError(error)})` : describeError(error);
122
+ const delay = this.retryDelaysMs[attempt];
123
+ if (delay !== undefined && retryableFailure(method, error, timedOut)) {
124
+ process.stderr.write(`platform API ${method} ${url}: ${detail}; retrying in ${delay}ms (attempt ${attempt + 2}/${this.retryDelaysMs.length + 1})\n`);
125
+ await new Promise((resolve) => setTimeout(resolve, delay));
126
+ continue;
127
+ }
128
+ const attempts = attempt ? ` after ${attempt + 1} attempts` : "";
129
+ const ambiguous = !IDEMPOTENT_METHODS.has(method) && !retryableFailure(method, error, timedOut)
130
+ ? "; not retried because the server may have received it; inspect platform state before resubmitting" : "";
131
+ throw new Error(`platform API request failed: ${method} ${url}: ${detail}${attempts}${ambiguous}`);
132
+ }
133
+ }
27
134
  }
28
135
  url(endpoint, params = {}) {
29
136
  const url = new URL(endpoint.replace(/^\//, ""), `${this.baseUrl}/`);
@@ -34,8 +141,6 @@ class PlatformApiClient {
34
141
  return url;
35
142
  }
36
143
  async request(method, endpoint, { params, payload, body, contentType, requestHeaders } = {}) {
37
- const controller = new AbortController();
38
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
39
144
  const headers = { Accept: "application/json" };
40
145
  Object.assign(headers, requestHeaders || {});
41
146
  let requestBody = body;
@@ -45,17 +150,17 @@ class PlatformApiClient {
45
150
  }
46
151
  else if (contentType)
47
152
  headers["Content-Type"] = contentType;
48
- let response;
153
+ const { response, done } = await this.open(method, this.url(endpoint, params), { headers, body: requestBody });
154
+ let text;
49
155
  try {
50
- response = await fetch(this.url(endpoint, params), { method, headers, body: requestBody, signal: controller.signal });
156
+ text = await response.text();
51
157
  }
52
158
  catch (error) {
53
- throw new Error(`platform API request failed: ${method} ${this.url(endpoint, params)}: ${error.message}`);
159
+ throw new Error(`platform API request failed: ${method} ${this.url(endpoint, params)}: reading response: ${describeError(error)}`);
54
160
  }
55
161
  finally {
56
- clearTimeout(timer);
162
+ done();
57
163
  }
58
- const text = await response.text();
59
164
  let result;
60
165
  try {
61
166
  result = JSON.parse(text);
@@ -115,20 +220,15 @@ class PlatformApiClient {
115
220
  fs.rmSync(target, { recursive: true, force: true });
116
221
  }
117
222
  fs.mkdirSync(target, { recursive: true });
118
- const controller = new AbortController();
119
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
120
223
  const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-platform-artifact-download-"));
121
224
  const temporary = path.join(temporaryDir, "artifact.zip");
122
- let response;
225
+ let done = () => { };
123
226
  try {
124
- response = await fetch(this.url(`/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/download`), { headers: { Accept: "application/zip" }, signal: controller.signal });
227
+ const opened = await this.open("GET", this.url(`/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/download`), { headers: { Accept: "application/zip" } });
228
+ const { response, signal } = opened;
229
+ done = opened.done;
125
230
  if (!response.ok) {
126
- let detail = response.statusText;
127
- try {
128
- detail = JSON.parse(await abortable(response.text(), controller.signal))?.detail ?? detail;
129
- }
130
- catch (_) { /* use status text */ }
131
- throw new Error(`artifact download failed: HTTP ${response.status}: ${detail}`);
231
+ throw new Error(`artifact download failed: HTTP ${response.status}: ${await responseErrorDetail(response, signal)}`);
132
232
  }
133
233
  const total = contentLength(response);
134
234
  let lastReported = -1;
@@ -139,7 +239,7 @@ class PlatformApiClient {
139
239
  }
140
240
  };
141
241
  report(0);
142
- await writeResponseBody(response, temporary, controller.signal, report);
242
+ await writeResponseBody(response, temporary, signal, report);
143
243
  const files = await extractArchive(temporary, target);
144
244
  return { dataset_id: datasetId, manifest_hash: manifestHash, output_dir: target, file_count: files.length, files };
145
245
  }
@@ -147,10 +247,10 @@ class PlatformApiClient {
147
247
  fs.rmSync(target, { recursive: true, force: true });
148
248
  if (error instanceof Error && error.message.startsWith("artifact download failed:"))
149
249
  throw error;
150
- throw new Error(`artifact download failed: ${error.message}`);
250
+ throw new Error(`artifact download failed: ${describeError(error)}`);
151
251
  }
152
252
  finally {
153
- clearTimeout(timer);
253
+ done();
154
254
  fs.rmSync(temporaryDir, { recursive: true, force: true });
155
255
  }
156
256
  }
@@ -183,22 +283,15 @@ class PlatformApiClient {
183
283
  }
184
284
  fs.mkdirSync(path.dirname(destination), { recursive: true });
185
285
  const endpoint = `/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/files/${parts.map(encodeURIComponent).join("/")}`;
186
- const controller = new AbortController();
187
- const timer = setTimeout(() => controller.abort(), this.timeoutMs);
286
+ const { response, signal, done } = await this.open("GET", this.url(endpoint), { headers: { Accept: "application/octet-stream" } });
188
287
  try {
189
- const response = await fetch(this.url(endpoint), { headers: { Accept: "application/octet-stream" }, signal: controller.signal });
190
288
  if (!response.ok) {
191
- let detail = response.statusText;
192
- try {
193
- detail = JSON.parse(await response.text())?.detail ?? detail;
194
- }
195
- catch (_) { /* status text */ }
196
- throw new Error(`artifact file download failed: HTTP ${response.status}: ${detail}`);
289
+ throw new Error(`artifact file download failed: HTTP ${response.status}: ${await responseErrorDetail(response, signal)}`);
197
290
  }
198
291
  fs.writeFileSync(destination, Buffer.from(await response.arrayBuffer()), { flag: "wx" });
199
292
  }
200
293
  finally {
201
- clearTimeout(timer);
294
+ done();
202
295
  }
203
296
  }
204
297
  }
@@ -208,6 +301,71 @@ class PlatformApiClient {
208
301
  }
209
302
  return { project, dataset_id: datasetId, manifest_hash: manifestHash, output_dir: targetRoot, file_count: files.length, files: files.sort() };
210
303
  }
304
+ // Download indexed ModelArtifact/EvaluationResult files one by one and verify
305
+ // each against the sha256 recorded in the registry index before keeping it.
306
+ async downloadLifecycleArtifactFiles(collection, resourceId, index, relativePaths, outputDir, { force = false, project = undefined } = {}) {
307
+ if (!["model-artifacts", "evaluation-results"].includes(collection))
308
+ throw new Error(`unsupported artifact collection: ${collection}`);
309
+ if (!index || typeof index !== "object" || Array.isArray(index))
310
+ throw new Error(`${collection} record has no file index`);
311
+ const available = Object.keys(index).sort();
312
+ const requested = relativePaths && relativePaths.length ? [...new Set(relativePaths.map((value) => String(value)))] : available;
313
+ if (!requested.length)
314
+ throw new Error(`${collection} record indexes no files: ${resourceId}`);
315
+ const plan = requested.map((relative) => safeRelativeArtifactPath(relative));
316
+ const missing = plan.filter((item) => !Object.prototype.hasOwnProperty.call(index, item.normalized)).map((item) => item.normalized);
317
+ if (missing.length)
318
+ throw new Error(`file is not indexed by ${collection}/${resourceId}: ${missing.join(", ")}; available: ${available.join(", ")}`);
319
+ const targetRoot = path.resolve(expandHome(outputDir));
320
+ if (fs.existsSync(targetRoot) && fs.readdirSync(targetRoot).length) {
321
+ if (!force)
322
+ throw new Error(`download output directory is not empty: ${targetRoot}`);
323
+ fs.rmSync(targetRoot, { recursive: true, force: true });
324
+ }
325
+ fs.mkdirSync(targetRoot, { recursive: true });
326
+ const downloaded = [];
327
+ try {
328
+ for (const { normalized, parts } of plan) {
329
+ const destination = path.resolve(targetRoot, ...parts);
330
+ if (!destination.startsWith(`${targetRoot}${path.sep}`))
331
+ throw new Error(`artifact path escapes output directory: ${JSON.stringify(normalized)}`);
332
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
333
+ const endpoint = `/${collection}/${encodeURIComponent(resourceId)}/files/${parts.map(encodeURIComponent).join("/")}`;
334
+ const partial = `${destination}.partial`;
335
+ let done = () => { };
336
+ try {
337
+ const opened = await this.open("GET", this.url(endpoint, { project }), { headers: { Accept: "application/octet-stream" } });
338
+ const { response, signal } = opened;
339
+ done = opened.done;
340
+ if (!response.ok)
341
+ throw new Error(`artifact file download failed: ${normalized}: HTTP ${response.status}: ${await responseErrorDetail(response, signal)}`);
342
+ const hash = crypto.createHash("sha256");
343
+ let size = 0;
344
+ await writeResponseBody(response, partial, signal, () => { }, (chunk) => { hash.update(chunk); size += chunk.length; });
345
+ const actual = `sha256:${hash.digest("hex")}`;
346
+ const expected = String(index[normalized]);
347
+ const served = response.headers?.get?.("x-artifact-sha256");
348
+ if (actual !== expected || (served && served !== expected)) {
349
+ throw new Error(`artifact file checksum mismatch: ${normalized}: registry ${expected}, downloaded ${actual}${served && served !== expected ? `, server header ${served}` : ""}`);
350
+ }
351
+ fs.renameSync(partial, destination);
352
+ downloaded.push({ name: normalized, sha256: actual, size_bytes: size });
353
+ }
354
+ finally {
355
+ done();
356
+ fs.rmSync(partial, { force: true });
357
+ }
358
+ }
359
+ }
360
+ catch (error) {
361
+ fs.rmSync(targetRoot, { recursive: true, force: true });
362
+ if (error instanceof Error && /^artifact (file|path)/.test(error.message))
363
+ throw error;
364
+ throw new Error(`artifact file download failed: ${error instanceof Error ? error.message : String(error)}`);
365
+ }
366
+ downloaded.sort((left, right) => left.name.localeCompare(right.name));
367
+ return { output_dir: targetRoot, file_count: downloaded.length, verified: true, files: downloaded };
368
+ }
211
369
  }
212
370
  function contentLength(response) {
213
371
  const raw = response.headers?.get?.("content-length") ?? response.headers?.["content-length"];
@@ -228,13 +386,15 @@ function abortable(value, signal) {
228
386
  Promise.resolve(value).then((result) => { cleanup(); resolve(result); }, (error) => { cleanup(); reject(error); });
229
387
  });
230
388
  }
231
- async function writeResponseBody(response, outputPath, signal, onProgress) {
389
+ async function writeResponseBody(response, outputPath, signal, onProgress, onChunk = undefined) {
232
390
  const handle = fs.openSync(outputPath, "w");
233
391
  let received = 0;
234
392
  try {
235
393
  const write = (value) => {
236
394
  const chunk = Buffer.from(value);
237
395
  fs.writeSync(handle, chunk);
396
+ if (onChunk)
397
+ onChunk(chunk);
238
398
  received += chunk.length;
239
399
  onProgress(received);
240
400
  };
@@ -82,8 +82,9 @@ Use the global `--profile server` form shown above, before the business
82
82
  command. Do not append `--profile` after a business command. Before discovery,
83
83
  check the installed release with `ml-platform --help` and the relevant
84
84
  subcommand's `--help`; CLI releases may differ in supported flags. In the
85
- deployed 0.6.x CLI, registry `list-*` commands emit JSON by default and do not
86
- accept a `--json` flag.
85
+ current CLI release line (`@zhuoyuezs/ml-platform` 0.8.x; confirm with
86
+ `ml-platform version`), registry `list-*` commands emit JSON by default and
87
+ reject an unknown `--json` flag.
87
88
 
88
89
  If the installed release rejects the global `--profile server` form shown above,
89
90
  record the CLI syntax error and inspect `show-config`/subcommand help before
@@ -116,7 +117,10 @@ Confirm:
116
117
  - dataset mode, read policy, time range, grid, prediction horizon/cutoff contract, and explicit Parameter outputs;
117
118
  - named training/validation/test rowsets, abnormal windows, endpoint-policy scope,
118
119
  and the model context lookback used to decide endpoint eligibility;
119
- - realtime freshness and missing-input policy when serving inference data;
120
+ - realtime freshness and missing-input policy when serving inference data,
121
+ chosen per model and per Parameter from the data owner's answers on
122
+ timestamp meaning, arrival schedule and ingestion-time columns (see the
123
+ realtime policy questions in [references/contracts.md](references/contracts.md));
120
124
  - target profile/API and whether the user authorizes publish and/or build actions.
121
125
 
122
126
  Separate preparation from mutation. Creating files and running local tests or `apply --dry-run` does not authorize publishing a wheel, changing the Registry, or submitting a build Job.
@@ -380,9 +384,15 @@ for the cutoff formula, half-open overlap boundaries, scope behavior, and
380
384
 
381
385
  For realtime inference, declare `realtime_fetch` only when its effective policy
382
386
  must be part of the contract; leaving it unset preserves existing manifest
383
- hashes. Keep `allow_missing=false` unless the user explicitly accepts missing
384
- required inputs. Read [references/contracts.md](references/contracts.md) for the
385
- freshness, tail-edge, gap-fill, and rowset contracts.
387
+ hashes. The environment defaults are strict and suit short-horizon
388
+ forecasting; for a new model whose Features are served online, choose the
389
+ policy deliberately and declare every field it relies on. Keep
390
+ `allow_missing=false` unless the user explicitly accepts missing required
391
+ inputs. Read [references/contracts.md](references/contracts.md) for the
392
+ freshness, tail-edge, gap-fill, and rowset contracts and the policy method.
393
+ An Operator served online, especially one that aggregates a whole period,
394
+ must follow the Online Period Aggregates method in
395
+ [references/operator-authoring.md](references/operator-authoring.md).
386
396
 
387
397
  ## Validate Before Publishing
388
398
 
@@ -405,7 +415,7 @@ Stop on any error. Do not weaken schema validation, fabricate a missing dependen
405
415
 
406
416
  Publish only after the user explicitly approves Registry and wheel changes. Use catalog `apply` so publication follows Parameter -> Operator -> Feature -> FeatureSet -> Dataset order.
407
417
 
408
- After publication, resolve every new DatasetManifest and compare its exact Parameter, Operator, Feature, and FeatureSet versions with the pre-publication mapping. Stop if an affected old key or any unexpected version remains. Resolve the manifest before building. Submit a build only when requested. Before choosing partition controls, use the build command's read-only `--plan` capability described in [commands.md](references/commands.md), inspect its estimates and warnings, and prefer platform automatic sizing over a copied daily example. Explicit overrides need source/resource evidence; preserve the submitted Job's concrete plan when resuming. For the current partitioned materialization runtime, use `--max-parallelism 1` unless a separately verified platform capability says otherwise; do not retry or resubmit a failed Job until its error and already-completed partitions are understood. For server builds, wait for the terminal Job state and download the exact artifact by `dataset_id + manifest_hash`.
418
+ After publication, resolve every new DatasetManifest and compare its exact Parameter, Operator, Feature, and FeatureSet versions with the pre-publication mapping. Stop if an affected old key or any unexpected version remains. Resolve the manifest before building. Submit a build only when requested. Before choosing partition controls, use the build command's read-only `--plan` capability described in [commands.md](references/commands.md), inspect its estimates and warnings, and prefer platform automatic sizing over a copied daily example. Explicit overrides need source/resource evidence; preserve the submitted Job's concrete plan when resuming. For the current partitioned materialization runtime, use `--max-parallelism 1` unless a separately verified platform capability says otherwise. If any selected Operator needs the complete requested window (for example full-window interpolation or smoothing), build with `--partition-duration none --max-parallelism 1` and confirm an unpartitioned `--plan` first, because the CLI default is `auto`; do not retry or resubmit a failed Job until its error and already-completed partitions are understood. For server builds, wait for the terminal Job state and download the exact artifact by `dataset_id + manifest_hash`.
409
419
 
410
420
  Do not delete versioned Registry assets, cancel Jobs, rebuild images, modify Kubernetes, or change service configuration as part of this workflow unless the user separately and explicitly requests that action. An empty Project may be soft-deleted only on an explicit request; rely on the server to reject deletion when resources still exist.
411
421
 
@@ -51,8 +51,9 @@ ml-platform --profile server list-datasets -q <stable-dataset-id>
51
51
  ```
52
52
 
53
53
  Run each command separately. Check `ml-platform --help` and the specific
54
- subcommand help before adding flags. In the deployed 0.6.x CLI, `list-*`
55
- commands print JSON by default and do not accept a `--json` flag.
54
+ subcommand help before adding flags. In the current CLI release line
55
+ (`@zhuoyuezs/ml-platform` 0.8.x; confirm with `ml-platform version`), `list-*`
56
+ commands print JSON by default and reject an unknown `--json` flag.
56
57
  If a deployed release rejects the global `--profile server` prefix, treat that
57
58
  as CLI compatibility drift: record the syntax error, inspect `show-config` and
58
59
  the subcommand help, then retry the same read-only command without the rejected
@@ -107,8 +108,8 @@ empty project. Project commands are server-only and do not modify local storage.
107
108
  - `apply --project <project>` scopes the comparison snapshot the catalog is
108
109
  validated against (omit to compare against `default`);
109
110
  - `list-* --project <project>` filters discovery to that project;
110
- - `get-dataset-artifact` / `download-dataset-artifact` take the project as their
111
- first positional argument (defaults to `default`).
111
+ - `get-dataset-artifact` / `download-dataset-artifact` take `--project <project>`
112
+ (defaults to `default`).
112
113
 
113
114
  Before choosing names, discover within the target project: identical names in a
114
115
  different project are unrelated and must not be reused across projects to mean the
@@ -247,6 +248,25 @@ sizing is not a resource guarantee. Final merge memory is not bounded by a
247
248
  partition's estimated rows. A long prediction horizon is not itself a reason to
248
249
  use daily partitions: inspect the reported source read amplification.
249
250
 
251
+ Partitioned execution gives each partition only its declared
252
+ `history_requirements` lookback and label overlap. An Operator whose result for
253
+ one row depends on the complete requested window (for example full-window
254
+ interpolation, smoothing, normalization or ranking) is not partition-invariant,
255
+ and no Operator field tells the planner so. Build such a manifest with
256
+ `--partition-duration none --max-parallelism 1`, because both build commands
257
+ default to `auto`. First run the same command with `--plan` and confirm
258
+ `execution_spec.partition_duration` is `null`, `estimated_partition_count` is
259
+ `1`, and the reasons report unpartitioned execution; then submit with the same
260
+ flags. Prefer reformulating the Operator as a causal, bounded-lookback
261
+ calculation when the business rule allows it.
262
+
263
+ ```bash
264
+ ml-platform --profile server \
265
+ build-registered-dataset <dataset_id> <dataset_version> \
266
+ --project <project> \
267
+ --partition-duration none --max-parallelism 1 --plan
268
+ ```
269
+
250
270
  `--partition-duration` is an execution control and does not change the
251
271
  DatasetManifest hash. The current implementation is sequential, so keep
252
272
  `--max-parallelism 1`. Completed partitions are checkpointed and resumed by
@@ -255,8 +275,12 @@ resolved partition duration. Changing it requires a new execution, rather than
255
275
  reusing old checkpoint boundaries. Changing partition size does not authorize
256
276
  cancelling or resubmitting an already-running Job.
257
277
 
258
- Record the returned `job_id`, `dataset_id`, and resolved `manifest_hash`; for a registered Dataset
259
- also retain `registered_manifest_hash` for FeatureRetrievalSpec binding. If a separate wait is needed:
278
+ Record the returned `job_id`, `dataset_id`, and the artifact manifest hash; for a registered Dataset
279
+ also retain `registered_manifest_hash` for FeatureRetrievalSpec binding. A succeeded
280
+ `--wait` or `wait-job` result carries `artifact.manifest_hash`, the DatasetArtifact key used by
281
+ `get-dataset-artifact`, `download-dataset-artifact` and training/evaluation dataset bindings. The
282
+ Job's top-level `manifest_hash` identifies the submitted manifest; it is not an artifact key and
283
+ returns `artifact_not_found` there. If a separate wait is needed:
260
284
 
261
285
  ```bash
262
286
  ml-platform --profile server wait-job <job_id>
@@ -294,6 +318,23 @@ write parquet, or publish a DatasetArtifact. Treat HTTP `503` as retryable sourc
294
318
  freshness/missing-data failure and HTTP `400` as a contract error that requires a
295
319
  manifest or request change.
296
320
 
321
+ Before choosing `realtime_fetch` lag budgets or a schedule time, observe how far
322
+ each Parameter's source has arrived:
323
+
324
+ ```bash
325
+ ml-platform --profile server get-parameter-freshness <name> <version> \
326
+ --project <project> [--lookback 7d] [--end 2026-10-28T00:30:00+08:00] [--online]
327
+ ```
328
+
329
+ It reads `[end - lookback, end]` (end inclusive, `lookback` at most `31d`) through
330
+ the Parameter's source adapter without cache, build, or artifact, and returns
331
+ `latest_event_time`, `lag_hours` (the value a realtime read compares with its lag
332
+ budget at that cutoff), `latest_value_event_time`, `missing_trailing_points`,
333
+ `available_at_watermark`/`ingested_at_watermark` when the Parameter declares
334
+ availability, and `source_field_checks`. `--online` reads `online_source` when
335
+ declared. Run it at the intended schedule time on several days; one reading does
336
+ not establish a publication pattern.
337
+
297
338
  For a deployment-bound row or full sequence context, prepare one JSON object
298
339
  containing `feature_retrieval`, `cutoff_time`, and `entity_keys`, then run:
299
340
 
@@ -359,7 +400,9 @@ ml-platform --profile server \
359
400
  ```
360
401
 
361
402
  `--project` defaults to `default`; pass the owning project when the dataset lives
362
- elsewhere, or the lookup returns `artifact_not_found`.
403
+ elsewhere, or the lookup returns `artifact_not_found`. `<manifest_hash>` is the
404
+ artifact manifest hash (`list-dataset-artifacts`, or `artifact.manifest_hash`
405
+ from a succeeded build wait), not the registered manifest hash.
363
406
 
364
407
  The download command writes progress to stderr after response headers arrive.
365
408
  Interpret a timeout with no progress line as a request/header or first-byte
@@ -139,6 +139,17 @@ supported field.
139
139
  当前严格 `as_of` 只接受 `exact` 或有合同支撑的 `upper_bound`;修改这些语义需要发布
140
140
  新的 Parameter 业务版本。
141
141
 
142
+ `time_semantics.ingested_at_field` 和 `event_time_field` 只是标签:读取时不会查询它们,
143
+ 除非 `availability.field` 指向同一列。没有 `availability` 时,读取既没有
144
+ `ingested_at`/`available_at` 证据,严格可见性读取(`as_of` 回放、
145
+ `availability_boundary=cutoff_time`)也会拒绝该 Parameter。只把源表真实存在的入库列写进
146
+ `availability`;源表没有入库时间时,保持 `availability` 为空或使用经数据 owner 确认的
147
+ `fixed_delay`,不要声明不存在的列。发布新 Parameter 时,若 `source.connection_id` 是平台
148
+ 托管连接,平台会检查实际查询的列(`time_column`、`value_column`、metric/unit 列、
149
+ `filters` 键、`availability.field`、`availability.revision_id_field`):缺列直接拒绝
150
+ (`source_fields_missing`),不存在的 `ingested_at_field`/`event_time_field` 只作为警告
151
+ 返回在 `source_field_checks` 中。已发布版本可用 `get-parameter-freshness` 查看同一检查。
152
+
142
153
  未来预报、排程、日历等 known-future 数据允许 `available_at < event_time`。这不是时钟错误:
143
154
  `event_time` 是未来目标时刻,`available_at` 是该修订何时已可读取。模型
144
155
  `feature_lookup` 会读取 cutoff 后的未来事件窗口,但仍严格过滤
@@ -380,8 +391,14 @@ Current schema rules:
380
391
  - Use `parameters` only to request explicit Parameter output columns.
381
392
  - Declare one dataset-wide `prediction` contract for forecast Features. `horizon`
382
393
  accepts non-negative whole-minute durations such as `0min`, `10min`, or `2h`.
383
- The platform always derives `cutoff_times = target_times - horizon` and records
384
- that derivation in resolved lineage; it is not a user-configurable policy.
394
+ For Operator inputs, each materialized row's `event_time` is its causal
395
+ feature cutoff (`context.cutoff_times` equals `context.target_times`), and the
396
+ horizon sets `label_time = event_time + horizon` without moving those reads.
397
+ `endpoint_policy` eligibility is separate and uses the `cutoff = t - h` rule
398
+ documented in its section. The platform records this in resolved
399
+ lineage (`prediction_time_semantics: event_time_is_causal_cutoff`,
400
+ `label_time_derivation: prediction_time_plus_horizon`); it is not a
401
+ user-configurable policy.
385
402
 
386
403
  ### Optional sections
387
404
 
@@ -442,11 +459,14 @@ attempts, rows, and elapsed time land in
442
459
  ```json
443
460
  "realtime_fetch": {
444
461
  "max_workers": 4,
462
+ "deadline_seconds": 30.0,
463
+ "chunk_days": null,
445
464
  "min_lookback": "1h",
446
465
  "validate_freshness": true,
447
466
  "max_source_lag_hours": 3.0,
448
467
  "max_source_lag_hours_by_parameter": {},
449
468
  "allow_missing": false,
469
+ "allow_missing_by_parameter": {},
450
470
  "allow_tail_edge_missing": true,
451
471
  "max_tail_edge_missing": 1,
452
472
  "tail_edge_missing_max_age_minutes": 65.0,
@@ -467,11 +487,109 @@ offline `source` instead.
467
487
 
468
488
  Keep the default strict: `allow_missing=false`. Tail-edge and bounded internal
469
489
  gap-fill tolerance may return a row, but the response must mark
470
- `freshness.degraded=true` and include validation evidence. A freshness or
490
+ `freshness.degraded=true` and include validation evidence.
491
+
492
+ Per-Parameter keys in `max_source_lag_hours_by_parameter` and
493
+ `allow_missing_by_parameter` are full Parameter keys (`project/name:version`);
494
+ other spellings are ignored with a validation warning. `allow_missing_by_parameter`
495
+ overrides `allow_missing` for one Parameter in either direction and is omitted
496
+ from the manifest while empty, so adding the field does not change existing
497
+ hashes. A tolerated Parameter still marks the read degraded, and a deployment
498
+ with `freshness_policy=strict` rejects degraded reads.
499
+
500
+ The tail-edge and internal-gap-fill allowances are absolute lag ceilings measured
501
+ from the cutoff, not additions to the lag budget: a source past its budget is
502
+ tolerated when its lag is at most `max(tail_edge_missing_max_age_minutes,
503
+ max_tail_edge_missing * expected_frequency)` or at most
504
+ `internal_gap_fill_max_age_hours`. With the defaults (5h gap-fill ceiling) a
505
+ Parameter budget below 5h therefore degrades rather than fails; set
506
+ `allow_internal_gap_fill=false` (per manifest) when a budget must be hard.
507
+ Neither allowance changes values: realtime rows use the same alignment and
508
+ Operator code as a batch build, so these settings cannot create
509
+ training/serving skew. Choose budgets from observed arrival with
510
+ `get-parameter-freshness`. A freshness or
471
511
  missing-data condition that may recover returns HTTP `503`; an invalid contract
472
512
  returns HTTP `400`. Declaring `realtime_fetch` changes the manifest hash; leaving
473
513
  it unset preserves older contract hashes and uses environment fallbacks.
474
514
 
515
+ `deadline_seconds` bounds the whole read. `chunk_days` pages long source reads
516
+ and falls back to `source_read.chunk_days` when unset.
517
+
518
+ #### Choosing a realtime fetch policy
519
+
520
+ The values above are also the environment defaults (`DATA_PLATFORM_REALTIME_*`).
521
+ They are strict and suit short-horizon forecasting on minute-level data. They
522
+ are a process fallback, not a decision for a particular model. Choose the
523
+ policy for each model from its data and its use, and per Parameter where
524
+ sources differ.
525
+
526
+ How the checks work:
527
+
528
+ - For each Parameter, the platform takes the newest source row at or before
529
+ the cutoff (the watermark) and computes `lag = cutoff - watermark`.
530
+ - The read is normal when the lag is within `max_source_lag_hours`, or within
531
+ the Parameter's entry in `max_source_lag_hours_by_parameter` (keyed
532
+ `project/name:version`).
533
+ - A larger lag is still accepted, marked `freshness.degraded=true`, when
534
+ `allow_tail_edge_missing` is on and the lag is within the larger of
535
+ `tail_edge_missing_max_age_minutes` and `max_tail_edge_missing` times the
536
+ Parameter's `expected_frequency`, or when `allow_internal_gap_fill` is on and
537
+ the lag is within `internal_gap_fill_max_age_hours`. With the defaults, a
538
+ source up to 5 hours behind is accepted as degraded. Set
539
+ `allow_internal_gap_fill: false` when the lag budget must be a hard limit.
540
+ - A Parameter with no rows in its window fails unless `allow_missing` is true.
541
+ `allow_missing` applies to every Parameter and also accepts any stale
542
+ source; there is no per-Parameter switch. Do not turn it on to tolerate one
543
+ optional input.
544
+ - None of these settings changes values. Holes inside the lookback reach the
545
+ Operator unchanged, so the Operator's frozen missing-data rule
546
+ ([operator-authoring.md](operator-authoring.md), Online Period Aggregates)
547
+ is what keeps batch builds and realtime reads equal.
548
+
549
+ Decide with the user:
550
+
551
+ - Whether a late or missing source should fail visibly (HTTP `503`, rerun after
552
+ the data recovers) or return a degraded row. A long aggregate (for example a
553
+ daily mean) may barely change when one point is late, but a period that is
554
+ missing its last hours because a source stalled usually should fail.
555
+ - When the read runs relative to each source's arrival. The lag budget must
556
+ cover the latest normal arrival: schedule the read after a batch-loaded
557
+ source is complete, or give that Parameter a larger budget.
558
+ - Whether the read finishes within `deadline_seconds`. A long lookback over
559
+ several Parameters may need more than 30 seconds; measure it with
560
+ `fetch-inference-data` at a historical cutoff before choosing.
561
+
562
+ Record the decision in the inference DatasetManifest. Declare every field the
563
+ model relies on, even when it equals the current default: resolution is per
564
+ field, and an omitted field takes the process environment value, which can
565
+ differ between deployments. Note the reason for each value in the catalog
566
+ review. Changing the policy needs a new inference manifest and package
567
+ version, but not retraining. In each response, `policy.resolved_from` shows
568
+ whether a field came from `manifest` or `environment`; check that nothing you
569
+ meant to declare came from `environment`.
570
+
571
+ The `fetch-inference-data` options `--max-source-lag-hours`,
572
+ `--no-validate-freshness` and `--allow-missing` replace the manifest's whole
573
+ `realtime_fetch` section for that request; fields you do not pass fall back to
574
+ the environment, not to the manifest. Use them for diagnosis only.
575
+
576
+ Ask the data owner, and do not guess:
577
+
578
+ 1. What each source timestamp means: interval start, interval end, or the
579
+ measurement instant, and in which timezone. This decides period boundaries
580
+ and the expected lag (an hourly record stamped at the hour start has a
581
+ watermark one hour before a midnight cutoff even when the day is complete).
582
+ 2. How data arrives: streaming or batch loads, when the previous period is
583
+ complete on a normal day, and how late it can be.
584
+ 3. Whether late corrections or backfills are written after first arrival;
585
+ these make a rerun at the same cutoff return a different row.
586
+ 4. Whether the table has a trustworthy ingestion-time column. Confirm the
587
+ column exists (for example with the `data-source-exploration` Skill) before
588
+ declaring `time_semantics.ingested_at_field` or an availability strategy
589
+ that reads it; never copy that field from a template.
590
+ 5. Which inputs may legitimately be absent (for example one faulty sensor) and
591
+ which must never be (for example a state indicator used by a business rule).
592
+
475
593
  `abnormal_windows` excludes target windows whose data must not be trusted. The
476
594
  policy and the lookback apply to the whole window set, not to individual
477
595
  windows: a dataset is either a filtered rowset or a marked full rowset.
@@ -494,9 +612,9 @@ windows: a dataset is either a filtered rowset or a marked full rowset.
494
612
  | `mark_only` | Drop nothing; record what is affected in lineage and validation. |
495
613
 
496
614
  How far past a window to drop is derived from the widest resolved Operator history
497
- requirement. Cutoff-relative history contributes `prediction.horizon + lookback`;
498
- target-time-relative history contributes its declared lookback directly. The
499
- maximum is used for source fetch and abnormal-window propagation. Do not restate
615
+ requirement: the larger of the cutoff-anchored and target-time-anchored
616
+ lookbacks (the horizon is not added, because the Operator cutoff is the row's
617
+ `event_time`). The maximum is used for source fetch and abnormal-window propagation. Do not restate
500
618
  that number unless you want it asserted: an optional `input_lookback_hours` is
501
619
  checked against the derived value and a mismatch is rejected. Note that a window
502
620
  near the end of the target range can have its lookback clipped by the range end,