@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.
- package/CI-ARTIFACT.md +2 -2
- package/checksums.json +36 -36
- package/package.json +1 -1
- package/release.json +18 -18
- package/runtime/business-client/README.md +66 -3
- package/runtime/business-client/package-lock.json +2 -2
- package/runtime/business-client/package.json +1 -1
- package/runtime/business-client/src/catalog.js +25 -6
- package/runtime/business-client/src/cli.js +90 -6
- package/runtime/business-client/src/http.js +192 -32
- package/skills/feature-management/SKILL.md +17 -7
- package/skills/feature-management/references/commands.md +50 -7
- package/skills/feature-management/references/contracts.md +124 -6
- package/skills/feature-management/references/operator-authoring.md +116 -8
- package/skills/model-deployment-management/SKILL.md +3 -0
- package/skills/model-deployment-management/references/operations.md +14 -1
- package/skills/model-lifecycle-management/SKILL.md +16 -0
- package/skills/model-lifecycle-management/references/discovery.md +18 -3
- package/skills/model-lifecycle-management/references/evaluation.md +133 -0
- package/skills/model-lifecycle-management/references/packaging.md +46 -0
- package/skills/model-lifecycle-management/references/training-contracts.md +172 -1
- package/skills/model-lifecycle-management/references/training.md +26 -0
- package/skills/prediction-report-management/SKILL.md +42 -6
|
@@ -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
|
-
|
|
153
|
+
const { response, done } = await this.open(method, this.url(endpoint, params), { headers, body: requestBody });
|
|
154
|
+
let text;
|
|
49
155
|
try {
|
|
50
|
-
|
|
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
|
|
159
|
+
throw new Error(`platform API request failed: ${method} ${this.url(endpoint, params)}: reading response: ${describeError(error)}`);
|
|
54
160
|
}
|
|
55
161
|
finally {
|
|
56
|
-
|
|
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
|
|
225
|
+
let done = () => { };
|
|
123
226
|
try {
|
|
124
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
250
|
+
throw new Error(`artifact download failed: ${describeError(error)}`);
|
|
151
251
|
}
|
|
152
252
|
finally {
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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.
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
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
|
|
55
|
-
|
|
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
|
|
111
|
-
|
|
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
|
|
259
|
-
also retain `registered_manifest_hash` for FeatureRetrievalSpec binding.
|
|
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
|
-
|
|
384
|
-
|
|
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.
|
|
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
|
|
498
|
-
|
|
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,
|