@zhuoyuezs/ml-platform 0.1.3 → 0.1.5

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 (23) hide show
  1. package/CI-ARTIFACT.md +21 -0
  2. package/DEVELOPMENT.md +7 -5
  3. package/checksums.json +38 -33
  4. package/package.json +6 -3
  5. package/release.json +7 -7
  6. package/runtime/business-client/README.md +17 -0
  7. package/runtime/business-client/package-lock.json +2 -2
  8. package/runtime/business-client/package.json +4 -2
  9. package/runtime/business-client/src/catalog.js +3 -1
  10. package/runtime/business-client/src/cli.js +132 -24
  11. package/runtime/business-client/src/config.js +6 -2
  12. package/runtime/business-client/src/http.js +130 -13
  13. package/skills/feature-management/SKILL.md +178 -10
  14. package/skills/feature-management/assets/catalog-template/datasets/example_temperature_training.v1.json +16 -0
  15. package/skills/feature-management/assets/catalog-template/feature_sets/example_temperature_core.v1.json +1 -0
  16. package/skills/feature-management/assets/catalog-template/features/example_temperature_mean_5m.v1.json +9 -2
  17. package/skills/feature-management/assets/catalog-template/operator_package/tests/test_operator.py +51 -24
  18. package/skills/feature-management/assets/catalog-template/operators/example_temperature_features.v1.json +16 -1
  19. package/skills/feature-management/assets/catalog-template/parameters/example_temperature.v1.json +1 -0
  20. package/skills/feature-management/references/commands.md +29 -0
  21. package/skills/feature-management/references/contracts.md +43 -6
  22. package/skills/feature-management/references/operator-authoring.md +15 -7
  23. package/skills/feature-management/references/platform-capability-guide.md +73 -0
@@ -11,11 +11,15 @@ function expandHome(value) {
11
11
  }
12
12
 
13
13
  function configPath() {
14
- if (process.env.ML_PLATFORM_CONFIG_PATH?.trim()) return path.resolve(expandHome(process.env.ML_PLATFORM_CONFIG_PATH));
14
+ for (const variable of ["ML_PLATFORM_CONFIG_PATH", "DATA_PLATFORM_DEMO_CONFIG_PATH"]) {
15
+ if (process.env[variable]?.trim()) return path.resolve(expandHome(process.env[variable]));
16
+ }
15
17
  const base = process.env.XDG_CONFIG_HOME
16
18
  ? path.resolve(process.env.XDG_CONFIG_HOME)
17
19
  : path.join(os.homedir(), ".config");
18
- return path.join(base, "ml-platform", "config.json");
20
+ const primary = path.join(base, "ml-platform", "config.json");
21
+ const legacy = path.join(base, "data-platform-demo", "config.json");
22
+ return !fs.existsSync(primary) && fs.existsSync(legacy) ? legacy : primary;
19
23
  }
20
24
 
21
25
  function normalizeApiUrl(value) {
@@ -45,6 +45,7 @@ class PlatformApiClient {
45
45
 
46
46
  get(endpoint, params) { return this.request("GET", endpoint, { params }); }
47
47
  post(endpoint, payload, params) { return this.request("POST", endpoint, { payload, params }); }
48
+ put(endpoint, payload, params) { return this.request("PUT", endpoint, { payload, params }); }
48
49
  delete(endpoint, params) { return this.request("DELETE", endpoint, { params }); }
49
50
  uploadOperatorPackage(project, name, version, packagePath) {
50
51
  const resolved = path.resolve(expandHome(packagePath));
@@ -63,30 +64,138 @@ class PlatformApiClient {
63
64
  fs.mkdirSync(target, { recursive: true });
64
65
  const controller = new AbortController();
65
66
  const timer = setTimeout(() => controller.abort(), this.timeoutMs);
67
+ const temporaryDir = fs.mkdtempSync(path.join(os.tmpdir(), "data-platform-artifact-download-"));
68
+ const temporary = path.join(temporaryDir, "artifact.zip");
66
69
  let response;
67
70
  try {
68
71
  response = await fetch(this.url(`/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/download`), { headers: { Accept: "application/zip" }, signal: controller.signal });
72
+ if (!response.ok) {
73
+ let detail = response.statusText;
74
+ try { detail = JSON.parse(await abortable(response.text(), controller.signal))?.detail ?? detail; } catch (_) { /* use status text */ }
75
+ throw new Error(`artifact download failed: HTTP ${response.status}: ${detail}`);
76
+ }
77
+ const total = contentLength(response);
78
+ let lastReported = -1;
79
+ const report = (received, complete = false) => {
80
+ if ((complete && received !== lastReported) || received === 0 || received - lastReported >= 1024 * 1024) {
81
+ reportDownloadProgress(received, total);
82
+ lastReported = received;
83
+ }
84
+ };
85
+ report(0);
86
+ await writeResponseBody(response, temporary, controller.signal, report);
87
+ const files = await extractArchive(temporary, target);
88
+ return { dataset_id: datasetId, manifest_hash: manifestHash, output_dir: target, file_count: files.length, files };
69
89
  } catch (error) {
90
+ fs.rmSync(target, { recursive: true, force: true });
91
+ if (error instanceof Error && error.message.startsWith("artifact download failed:")) throw error;
70
92
  throw new Error(`artifact download failed: ${error.message}`);
71
93
  } finally {
72
94
  clearTimeout(timer);
95
+ fs.rmSync(temporaryDir, { recursive: true, force: true });
96
+ }
97
+ }
98
+
99
+ async downloadDatasetArtifactFiles(project, datasetId, manifestHash, relativePaths, outputDir, force = false) {
100
+ const files = [...new Set((relativePaths || []).map((value) => String(value)))];
101
+ if (!files.length) throw new Error("at least one artifact file is required");
102
+ for (const relative of files) {
103
+ const normalized = relative.replace(/\\/g, "/");
104
+ const parts = normalized.split("/");
105
+ if (!normalized || normalized.startsWith("/") || normalized.endsWith("/") || parts.includes("..")) {
106
+ throw new Error(`artifact path escapes output directory: ${JSON.stringify(relative)}`);
107
+ }
73
108
  }
74
- if (!response.ok) {
75
- let detail = response.statusText;
76
- try { detail = JSON.parse(await response.text())?.detail ?? detail; } catch (_) { /* use status text */ }
77
- throw new Error(`artifact download failed: HTTP ${response.status}: ${detail}`);
109
+ const targetRoot = path.resolve(expandHome(outputDir));
110
+ if (fs.existsSync(targetRoot) && fs.readdirSync(targetRoot).length) {
111
+ if (!force) throw new Error(`download output directory is not empty: ${targetRoot}`);
112
+ fs.rmSync(targetRoot, { recursive: true, force: true });
78
113
  }
79
- const temporary = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "data-platform-artifact-download-")), "artifact.zip");
114
+ fs.mkdirSync(targetRoot, { recursive: true });
80
115
  try {
81
- fs.writeFileSync(temporary, Buffer.from(await response.arrayBuffer()));
82
- const files = await extractArchive(temporary, target);
83
- return { dataset_id: datasetId, manifest_hash: manifestHash, output_dir: target, file_count: files.length, files };
116
+ for (const relative of files) {
117
+ const normalized = relative.replace(/\\/g, "/");
118
+ const parts = normalized.split("/");
119
+ const destination = path.resolve(targetRoot, ...parts);
120
+ if (!normalized || normalized.startsWith("/") || normalized.endsWith("/")
121
+ || parts.includes("..") || destination !== targetRoot && !destination.startsWith(`${targetRoot}${path.sep}`)) {
122
+ throw new Error(`artifact path escapes output directory: ${JSON.stringify(relative)}`);
123
+ }
124
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
125
+ const endpoint = `/dataset-artifacts/${encodeURIComponent(project)}/${encodeURIComponent(datasetId)}/${encodeURIComponent(manifestHash)}/files/${parts.map(encodeURIComponent).join("/")}`;
126
+ const controller = new AbortController();
127
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
128
+ try {
129
+ const response = await fetch(this.url(endpoint), { headers: { Accept: "application/octet-stream" }, signal: controller.signal });
130
+ if (!response.ok) {
131
+ let detail = response.statusText;
132
+ try { detail = JSON.parse(await response.text())?.detail ?? detail; } catch (_) { /* status text */ }
133
+ throw new Error(`artifact file download failed: HTTP ${response.status}: ${detail}`);
134
+ }
135
+ fs.writeFileSync(destination, Buffer.from(await response.arrayBuffer()), { flag: "wx" });
136
+ } finally { clearTimeout(timer); }
137
+ }
84
138
  } catch (error) {
85
- fs.rmSync(target, { recursive: true, force: true });
139
+ fs.rmSync(targetRoot, { recursive: true, force: true });
86
140
  throw error;
87
- } finally {
88
- fs.rmSync(path.dirname(temporary), { recursive: true, force: true });
89
141
  }
142
+ return { project, dataset_id: datasetId, manifest_hash: manifestHash, output_dir: targetRoot, file_count: files.length, files: files.sort() };
143
+ }
144
+ }
145
+
146
+ function contentLength(response) {
147
+ const raw = response.headers?.get?.("content-length") ?? response.headers?.["content-length"];
148
+ const value = Number(raw);
149
+ return Number.isFinite(value) && value >= 0 ? value : null;
150
+ }
151
+
152
+ function reportDownloadProgress(received, total) {
153
+ const suffix = total == null ? "" : `/${total}`;
154
+ process.stderr.write(`artifact download: received ${received}${suffix} bytes\n`);
155
+ }
156
+
157
+ function abortable(value, signal) {
158
+ if (signal.aborted) return Promise.reject(new Error("This operation was aborted"));
159
+ return new Promise((resolve, reject) => {
160
+ const onAbort = () => { cleanup(); reject(new Error("This operation was aborted")); };
161
+ const cleanup = () => signal.removeEventListener("abort", onAbort);
162
+ signal.addEventListener("abort", onAbort, { once: true });
163
+ Promise.resolve(value).then((result) => { cleanup(); resolve(result); }, (error) => { cleanup(); reject(error); });
164
+ });
165
+ }
166
+
167
+ async function writeResponseBody(response, outputPath, signal, onProgress) {
168
+ const handle = fs.openSync(outputPath, "w");
169
+ let received = 0;
170
+ try {
171
+ const write = (value) => {
172
+ const chunk = Buffer.from(value);
173
+ fs.writeSync(handle, chunk);
174
+ received += chunk.length;
175
+ onProgress(received);
176
+ };
177
+ if (response.body?.getReader) {
178
+ const reader = response.body.getReader();
179
+ while (true) {
180
+ const item = await abortable(reader.read(), signal);
181
+ if (item.done) break;
182
+ write(item.value);
183
+ }
184
+ } else if (response.body?.[Symbol.asyncIterator]) {
185
+ const iterator = response.body[Symbol.asyncIterator]();
186
+ while (true) {
187
+ const item = await abortable(iterator.next(), signal);
188
+ if (item.done) break;
189
+ write(item.value);
190
+ }
191
+ } else if (typeof response.arrayBuffer === "function") {
192
+ write(await abortable(response.arrayBuffer(), signal));
193
+ } else {
194
+ throw new Error("artifact download response has no readable body");
195
+ }
196
+ onProgress(received, true);
197
+ } finally {
198
+ fs.closeSync(handle);
90
199
  }
91
200
  }
92
201
 
@@ -111,8 +220,8 @@ async function extractArchive(archivePath, outputDir) {
111
220
  if (next > archive.length || compressedSize === 0xffffffff || size === 0xffffffff || localOffset === 0xffffffff) throw new Error("artifact archive entry metadata is invalid");
112
221
  if (flags & 1) throw new Error("artifact archive contains an encrypted entry");
113
222
  if (![0, 8].includes(method)) throw new Error(`artifact archive compression method is unsupported: ${method}`);
114
- if (!(flags & 0x800)) throw new Error("artifact archive contains a non-UTF-8 filename");
115
- const rawName = archive.subarray(cursor + 46, cursor + 46 + nameLength).toString("utf8");
223
+ const rawNameBytes = archive.subarray(cursor + 46, cursor + 46 + nameLength);
224
+ const rawName = decodeZipFilename(rawNameBytes, flags);
116
225
  const name = rawName.replace(/\\/g, "/"); const parts = name.split("/"); const mode = (attributes >>> 16) & 0xffff;
117
226
  if (!name || name.startsWith("/") || /^[A-Za-z]:/.test(name) || parts.includes("..")) throw new Error(`artifact archive contains unsafe path: ${JSON.stringify(rawName)}`);
118
227
  if ((mode & 0o170000) === 0o120000) throw new Error(`artifact archive contains a symbolic link: ${JSON.stringify(rawName)}`);
@@ -131,6 +240,14 @@ async function extractArchive(archivePath, outputDir) {
131
240
  return files.sort();
132
241
  }
133
242
 
243
+ function decodeZipFilename(bytes, flags) {
244
+ if (flags & 0x800) return bytes.toString("utf8");
245
+ // ZIP producers commonly omit the UTF-8 flag for ASCII entry names. Keep
246
+ // those names interoperable while refusing ambiguous non-ASCII encodings.
247
+ if (bytes.some((value) => value > 0x7f)) throw new Error("artifact archive contains an unsupported non-UTF-8 filename");
248
+ return bytes.toString("ascii");
249
+ }
250
+
134
251
  function findEndOfCentralDirectory(archive) { const minimum = Math.max(0, archive.length - 65557); for (let offset = archive.length - 22; offset >= minimum; offset -= 1) if (archive.readUInt32LE(offset) === 0x06054b50 && offset + 22 + archive.readUInt16LE(offset + 20) === archive.length) return offset; throw new Error("artifact download is not a valid zip archive"); }
135
252
  function crc32(buffer) { let crc = 0xffffffff; for (const byte of buffer) { crc ^= byte; for (let bit = 0; bit < 8; bit += 1) crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1)); } return (crc ^ 0xffffffff) >>> 0; }
136
253
 
@@ -14,13 +14,42 @@ Operator ------> Feature -> FeatureSet -> DatasetManifest -> DatasetArtifact
14
14
 
15
15
  Treat `Recipe` and public `Feature.compute` as removed. Treat `computation_hash` as internal execution metadata, never as a business-managed asset.
16
16
 
17
+ ## Platform Role And Evidence Boundaries
18
+
19
+ The ML Platform is a deterministic data-contract and data-delivery platform. It
20
+ registers versioned source Parameters, runs approved deterministic Operators,
21
+ orders single-column Features into FeatureSets, resolves DatasetManifests, and
22
+ materializes batch DatasetArtifacts or serves one causal realtime inference row.
23
+ It records versions, lineage, quality, missingness, freshness, and execution
24
+ metadata so an algorithm project can consume data without owning source routing
25
+ or cache details.
26
+
27
+ It does not train, evaluate, or serve models; own complete business feature
28
+ engineering; provision arbitrary source tables; or administer Kubernetes and
29
+ platform services. Read
30
+ [references/platform-capability-guide.md](references/platform-capability-guide.md)
31
+ when the user needs the detailed platform boundary or evidence-level explanation.
32
+
33
+ Keep these evidence rules active in every workflow:
34
+
35
+ - Platform defaults and capabilities are not business evidence.
36
+ - Dry-run, publish, resolve, build, metadata inspection, downloaded-file
37
+ inspection, and realtime fetch are separate evidence checkpoints; success at
38
+ one checkpoint does not imply success at the next.
39
+ - `validation.ok=true` does not by itself prove source semantics, causal formula
40
+ correctness, Parquet contents, or numeric parity.
41
+ - Dry-run, publish, and resolve may not detect a missing source relation. Do not
42
+ report source-backed success until build/fetch returns the required evidence,
43
+ and never rename a Parameter or remove fields merely to bypass a failed read.
44
+
17
45
  ## Load The Right Context
18
46
 
19
47
  1. Resolve the Skill root as the directory containing this `SKILL.md`.
20
48
  2. Read [references/contracts.md](references/contracts.md) before creating or changing JSON assets.
21
49
  3. Read [references/operator-authoring.md](references/operator-authoring.md) whenever creating or changing Operator code or a wheel.
22
- 4. Read [references/commands.md](references/commands.md) before discovering assets, validating, publishing, building, fetching realtime data, or downloading.
50
+ 4. Read [references/commands.md](references/commands.md) immediately before a platform CLI action: discovery, validation, publication, build, realtime fetch, or artifact download. A local-only draft that is forbidden to call the CLI does not need this reference.
23
51
  5. Use [assets/catalog-template](assets/catalog-template) as a copyable starting point for a new end-to-end catalog. Rename every `example_*` identifier and update every referenced path before validation.
52
+ 6. Read [references/platform-capability-guide.md](references/platform-capability-guide.md) only when selecting an asset type or explaining a platform capability. Do not load it merely to write a confirmed local draft; it is never evidence for unresolved business semantics.
24
53
 
25
54
  ## Initialize The Client
26
55
 
@@ -46,6 +75,19 @@ ml-platform show-config
46
75
  ml-platform --profile server health
47
76
  ```
48
77
 
78
+ Use the global `--profile server` form shown above, before the business
79
+ command. Do not append `--profile` after a business command. Before discovery,
80
+ check the installed release with `ml-platform --help` and the relevant
81
+ subcommand's `--help`; CLI releases may differ in supported flags. In the
82
+ deployed 0.6.x CLI, registry `list-*` commands emit JSON by default and do not
83
+ accept a `--json` flag.
84
+
85
+ If the installed release rejects the global `--profile server` form shown above,
86
+ record the CLI syntax error and inspect `show-config`/subcommand help before
87
+ retrying the same read-only command without that flag when supported. Do not
88
+ switch clients or classify a syntax error as an API outage; keep each command
89
+ separate so CLI compatibility remains distinguishable from network health.
90
+
49
91
  The effective API target is resolved in this order: explicit `--api-url`,
50
92
  `ML_PLATFORM_API_URL`, then the saved value written by `ml-platform configure`.
51
93
  If no target is configured, ask the user for an approved API URL before making
@@ -61,7 +103,7 @@ Collect only missing information. Do not invent a source table, source field, fo
61
103
 
62
104
  Confirm:
63
105
 
64
- - the target project (namespace) for the assets; use the built-in `default` only when the user has no dedicated project. On creation the project comes from the optional `project` field in each asset spec (defaults to `default`); reads and filters use the `--project` command option;
106
+ - the target project (namespace) for the assets; use the built-in `default` only when the user has no dedicated project. After the target is confirmed, write its explicit `project` field in every Parameter, Operator, Feature, FeatureSet and DatasetManifest draft; never rely on the schema default. Reads and filters use the `--project` command option;
65
107
  - business meaning, stable asset names, owner, and intended consumers;
66
108
  - Parameter source adapter and credential-free source mapping;
67
109
  - feature formula, exact Parameter dependencies, windows, inclusion rules, rounding, null behavior, and output dtype;
@@ -75,9 +117,64 @@ Confirm:
75
117
 
76
118
  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.
77
119
 
120
+ A label such as "10-minute mean" confirms neither its input Parameter nor a
121
+ complete formula. Before drafting an Operator or Feature, ask for the exact
122
+ versioned input Parameter(s), or for an approved source contract from which
123
+ they will be created. Do not infer an input from the Feature name or a similar
124
+ existing asset.
125
+
126
+ ### Semantic Asset Review Gate
127
+
128
+ Treat resource correctness as a separate gate from workflow correctness. Before
129
+ creating Catalog JSON, write a local semantic asset review with one row per
130
+ Parameter, Operator, Feature and Dataset field:
131
+
132
+ ```text
133
+ asset_key | business_meaning | source/evidence | confirmed_by | unresolved | proposed_value
134
+ ```
135
+
136
+ The review must distinguish three sources: confirmed business facts, facts
137
+ observed from the selected Project Registry/CLI, and agent proposals. A Skill
138
+ reference, schema default, similarly named old asset, or platform capability is
139
+ not business evidence. Do not convert a proposal into `proposed_value` without
140
+ explicit user confirmation or an exact match in the selected Project.
141
+
142
+ Stop with `need_business_confirmation` when any required source mapping, field,
143
+ unit, formula, time conversion, missing/quality policy, FeatureSet order,
144
+ dataset read policy, rowset/endpoint policy, owner, or approval boundary is
145
+ unresolved. Do not create placeholder Catalog files to make the workflow look
146
+ complete.
147
+
148
+ For a requested Feature, `exact Parameter dependencies` is an independent
149
+ required contract item. If the user names a transform or window but not its
150
+ input Parameter(s), include `input_parameter` in the unresolved questions and
151
+ stop. Do this before treating an Operator formula as complete: a time window,
152
+ cutoff rule, or Feature name never identifies the input.
153
+
154
+ After confirmation, show the proposed dependency closure and obtain approval of
155
+ the semantic asset plan before generating Catalog files. A successful local
156
+ schema check or remote dry-run is not semantic approval.
157
+
158
+ Before emitting or writing a draft, mechanically check that every asset carries
159
+ the confirmed `project` field, that every Feature input carries that same
160
+ `project`, and that every Operator `input_schema.parameters` reference is
161
+ qualified as `project/parameter:version`. The schema's `default` values are not
162
+ valid substitutes for an explicit non-default target. When a confirmed Feature
163
+ config defines window closure, cutoff inclusion, duplicate handling, or empty
164
+ window behavior, copy all of those fields into the Feature config rather than
165
+ reducing it to the window string.
166
+
78
167
  ## Discover Existing Assets
79
168
 
80
- Check the selected profile and list existing Parameter, Operator, Feature, FeatureSet, and Dataset versions before choosing names. Scope discovery to the target project with `--project`, because the true registry key is `project/name:version` and the same `name:version` may exist independently under another project. Registry list commands are paginated; search by stable identifier or follow every page until `offset + len(items) >= total`. Reuse an exact immutable version only when its full content matches. Never reference an asset in another project; cross-project references are rejected.
169
+ Check the selected profile and list existing Parameter, Operator, Feature, FeatureSet, and Dataset versions before choosing names. Scope discovery to the target project with `--project`, because the true registry key is `project/name:version` and the same `name:version` may exist independently under another project. Run each `list-*` command as a separate command so one failed check cannot hide the status of the others. Do not add `--json` unless the installed subcommand help explicitly advertises it; supported releases emit machine-readable JSON by default. Registry list commands are paginated; search by stable identifier or follow every page until `offset + len(items) >= total`. Reuse an exact immutable version only when its full content matches. Never reference an asset in another project; cross-project references are rejected.
170
+
171
+ CLI readiness is a hard gate for every Registry conclusion. If `command -v`,
172
+ `version`, `show-config`, or server `health` fails, returns a nonzero status, or
173
+ does not identify the intended target, stop with `platform_not_ready`. Do not
174
+ list assets, select reuse versus authoring, dry-run, publish, build, fetch, or
175
+ report a Registry fact from prompt text, a remembered response, or a similar
176
+ local asset. Report the failed command and its configured target; resume only
177
+ after the user/environment restores the same target.
81
178
 
82
179
  Create a new version when source semantics, formula code, config meaning, inputs, output dtype, time behavior, quality rules, or column order change. Never overwrite an immutable version or use suffixes such as `new`, `final`, or `test2`.
83
180
 
@@ -140,8 +237,29 @@ Keep platform demos and V95 built-ins unchanged. Do not put unrelated business a
140
237
 
141
238
  ### Define Parameters
142
239
 
240
+ Use the platform-capability guide's asset table before choosing the resource
241
+ type. For each Parameter record the business signal, exact source evidence,
242
+ unit, time/availability semantics and missing policy in the semantic review.
243
+ Never infer a source table, column, measurement, tag, unit or timezone from a
244
+ Parameter name.
245
+
143
246
  Create one stable, versioned source contract for each independently readable value. Keep credentials out of JSON. Use only supported direct adapters and explicit source identity. Put source data validity in `quality_rules`; put dataset-specific transformations in preprocess Operators.
144
247
 
248
+ When the approved source contract supplies SQL, copy its query text and named
249
+ parameters exactly into `source.sql`/`source.params`. Do not retype, beautify,
250
+ rename an identifier, change a join key, or "simplify" an approved query while
251
+ drafting. SQL whitespace may be formatted only when a reviewed formatter proves
252
+ the token stream is unchanged; every source identifier and `<= %(end)s` boundary
253
+ remains part of the Parameter contract.
254
+
255
+ For SQL Parameters, the runtime contract is a single statement: use the exact
256
+ `source.sql` field and a mapping under `source.params` for every named
257
+ placeholder other than the reserved `start` and `end` window parameters. Do not
258
+ use `source.parameters` (it is not a supported alias and may be silently
259
+ dropped by older CLI/server releases). Do not leave a trailing semicolon; the
260
+ deployed Worker rejects SQL containing multiple statements. Verify the resolved
261
+ Parameter still contains the named params before authorizing a build.
262
+
145
263
  Use Parameter `rounding` when every consumer must receive the same fixed-point value. The platform applies `mode` (`half_up` or `half_even`) and `decimals` after source normalization and before quality checks, source caching, replay, and Operator execution. Prefer an unrounded source expression, do not repeat the same rounding in an Operator, and publish a new Parameter plus its affected reverse-dependency closure when the rule changes.
146
264
 
147
265
  Do not represent a rolling mean, lag, ratio, trend, or model input as a Parameter. Those are Features.
@@ -166,7 +284,11 @@ Use a meaningful immutable `function_hash`. Change the Operator version whenever
166
284
 
167
285
  ### Define Features
168
286
 
169
- Create one JSON file per single output column. Bind exactly one Operator version and physical `output_column`. List only the Parameter versions actually required by that column's formula.
287
+ Create one JSON file per single output column. Set the confirmed `project` on the
288
+ Feature and on every nested Feature input; a FeatureInput without `project`
289
+ silently targets `default`. Bind exactly one Operator version and physical
290
+ `output_column`. List only the Parameter versions actually required by that
291
+ column's formula.
170
292
 
171
293
  Features may share the same Operator and config. The planner will merge their exact inputs and call the Operator once per computation group. Do not duplicate every Operator input into every Feature merely to make schemas look uniform.
172
294
 
@@ -176,8 +298,41 @@ Reference immutable Feature versions in the exact consumer column order. Keep th
176
298
 
177
299
  ### Define The DatasetManifest
178
300
 
301
+ Do not use DatasetManifest as a catch-all for unresolved semantics. Confirm the
302
+ dataset purpose and consumer, then separately confirm the target grid, horizon,
303
+ read policy, explicit Parameter outputs, rowset or rowset splits, abnormal windows and
304
+ endpoint eligibility. Keep the semantic asset review beside the draft Catalog
305
+ so a reviewer can compare business decisions with generated fields.
306
+
179
307
  Reference exactly one FeatureSet. Use `parameters` only for Parameter columns that must also appear explicitly in `parameter_dataset.parquet`; Feature dependencies are resolved automatically.
180
308
 
309
+ Map the current Manifest schema exactly rather than carrying descriptive fields
310
+ from Parameter, Feature, or FeatureSet into it. A target Parameter reference is
311
+ **nested**; never flatten its `version` or `project` onto `target`:
312
+
313
+ ```json
314
+ "target": {
315
+ "parameter": {"parameter": "hot_metal_si", "version": "v1", "project": "<project>"},
316
+ "si_time_source": "dispatch_time",
317
+ "offset_minutes": 20,
318
+ "interpolation": {"method": "linear"}
319
+ }
320
+ ```
321
+
322
+ `target.interpolation` is `{"method": "linear"}`, `{"method": "none"}` or
323
+ `{"method": "forward_fill"}` -- neither it nor `target.parameter` accepts a
324
+ bare string. The current DatasetManifest has no top-level `owner`,
325
+ `description`, `si_null_policy`, or `preserve_all_candidate_rows`. Map a
326
+ business statement only to an existing schema field (for example `mark_only`,
327
+ `endpoint_policy`, a Feature config, or a Parameter missing policy); otherwise
328
+ keep it in the semantic review and ask for a platform/schema change. Validate
329
+ the generated object against the current schema before running `apply --dry-run`.
330
+
331
+ When a confirmed missing-data policy applies to a Feature-only Parameter, put it
332
+ in `parameter_missing_policies`, using `parameter`, `version`, `project`, and
333
+ `policy`. Do not copy the `ParameterRequest` names `name` and `missing_policy`
334
+ into that list. Omit the section when no such policy is confirmed.
335
+
181
336
  Declare one dataset-wide `prediction` contract when Features use a forecast cutoff. Operators consume `context.cutoff_times`; do not copy the same prediction horizon into every Feature config. Check the selected Operator's `input_schema.prediction` limits and satisfy an explicit-horizon requirement before publishing or building.
182
337
 
183
338
  Use `snapshot` for reproducible training data, `as_of` for historical visibility replay, and `latest` for current inference-style reads. Use a fresh `dataset_version` when validating a new release or intentionally requesting a new immutable dataset contract.
@@ -202,11 +357,16 @@ freshness, tail-edge, gap-fill, and rowset contracts.
202
357
 
203
358
  Run, in order:
204
359
 
205
- 1. Operator unit tests.
206
- 2. Wheel build and wheel filename verification.
207
- 3. Catalog path, schema, dependency, immutability, and package validation with `apply --dry-run` against the same target profile intended for publication.
208
- 4. Compare the catalog's exact dependency closure with the intended old-to-new version mapping. Reject any affected downstream reference that still points to an old version unless its retention is explicit and justified.
209
- 5. A human-readable summary of planned new, unchanged, retained, and conflicting assets.
360
+ 1. Semantic asset review: no required unresolved field and every proposed value has a source or user confirmation.
361
+ 2. Operator unit tests. Before building, turn every applicable item in
362
+ `operator-authoring.md` into a separately named test; do not let one broad
363
+ happy-path assertion substitute for cutoff-before/at/after, duplicate and
364
+ missing input, empty history, requested-output, event-time order, or
365
+ dtype/rounding coverage. The template is only a starting point.
366
+ 3. Wheel build and wheel filename verification.
367
+ 4. Catalog path, schema, dependency, immutability, and package validation with `apply --dry-run` against the same target profile intended for publication.
368
+ 5. Compare the catalog's exact dependency closure with the intended old-to-new version mapping. Reject any affected downstream reference that still points to an old version unless its retention is explicit and justified.
369
+ 6. A human-readable summary of planned new, unchanged, retained, and conflicting assets.
210
370
 
211
371
  Stop on any error. Do not weaken schema validation, fabricate a missing dependency, change an existing version in place, or switch profiles to make validation pass.
212
372
 
@@ -214,7 +374,7 @@ Stop on any error. Do not weaken schema validation, fabricate a missing dependen
214
374
 
215
375
  Publish only after the user explicitly approves Registry and wheel changes. Use catalog `apply` so publication follows Parameter -> Operator -> Feature -> FeatureSet -> Dataset order.
216
376
 
217
- 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. For server builds, wait for the terminal Job state and download the exact artifact by `dataset_id + manifest_hash`.
377
+ 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. 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`.
218
378
 
219
379
  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.
220
380
 
@@ -232,6 +392,14 @@ gap-filled inputs as degraded freshness, not as a normal read.
232
392
 
233
393
  ## Verify The Artifact
234
394
 
395
+ Treat Registry metadata inspection and downloaded-file inspection as separate
396
+ checkpoints. A successful `get-dataset-artifact` can establish the immutable
397
+ identity, object inventory, row count, validation summary, and lineage metadata;
398
+ it cannot establish Parquet schema, column order, or file-content hashes. If a
399
+ download times out, report whether no response/progress was observed or whether
400
+ some body bytes were received, preserve the metadata-only evidence, and leave
401
+ downloaded-file validation explicitly incomplete.
402
+
235
403
  Require all of the following before reporting success:
236
404
 
237
405
  - Job status is `succeeded`;
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "schema_version": "ml_data_platform.dataset_manifest/v1",
3
+ "project": "replace_with_project",
3
4
  "dataset_id": "example_temperature_training",
4
5
  "dataset_version": "v1",
5
6
  "mode": "training",
@@ -9,7 +10,22 @@
9
10
  "end": "2026-07-02T00:00:00+08:00",
10
11
  "grid": "10min"
11
12
  },
13
+ "prediction": {
14
+ "horizon": "0min"
15
+ },
16
+ "rowset": {
17
+ "strategy": "fixed_grid",
18
+ "grid": "10min"
19
+ },
12
20
  "parameters": [],
21
+ "parameter_missing_policies": [
22
+ {
23
+ "parameter": "example_temperature",
24
+ "version": "v1",
25
+ "project": "replace_with_project",
26
+ "policy": "report_only"
27
+ }
28
+ ],
13
29
  "feature_set": {
14
30
  "name": "example_temperature_core",
15
31
  "version": "v1"
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "schema_version": "ml_data_platform.feature_set/v1",
3
+ "project": "replace_with_project",
3
4
  "name": "example_temperature_core",
4
5
  "version": "v1",
5
6
  "features": [
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "schema_version": "ml_data_platform.feature/v1",
3
+ "project": "replace_with_project",
3
4
  "name": "example_temperature_mean_5m",
4
5
  "version": "v1",
5
6
  "inputs": [
6
7
  {
7
8
  "parameter": "example_temperature",
8
- "version": "v1"
9
+ "version": "v1",
10
+ "project": "replace_with_project"
9
11
  }
10
12
  ],
11
13
  "operator": "example_temperature_features",
12
14
  "operator_version": "v1",
13
15
  "config": {
14
- "window": "5min"
16
+ "window": "5min",
17
+ "window_closed": "right",
18
+ "cutoff_included": true,
19
+ "post_cutoff_allowed": false,
20
+ "duplicate_event_time": "last",
21
+ "empty_window": "null"
15
22
  },
16
23
  "output_column": "example_temperature_mean_5m",
17
24
  "output_dtype": "float64",
@@ -9,20 +9,21 @@ from business_feature_operator_template import compute_features
9
9
 
10
10
 
11
11
  class ComputeFeaturesTest(unittest.TestCase):
12
- def context(self, requested: list[str] | None = None) -> SimpleNamespace:
13
- source = pd.DataFrame(
14
- {
15
- "timestamp": pd.DatetimeIndex(
16
- [
17
- "2026-07-01 09:54:00",
18
- "2026-07-01 09:56:00",
19
- "2026-07-01 10:00:00",
20
- "2026-07-01 10:01:00",
21
- ]
22
- ),
23
- "value": [10.0, 20.0, 30.0, 40.0],
24
- }
25
- )
12
+ def context(
13
+ self,
14
+ timestamps: list[str] | None = None,
15
+ values: list[float | None] | None = None,
16
+ targets: list[str] | None = None,
17
+ requested: list[str] | None = None,
18
+ ) -> SimpleNamespace:
19
+ timestamps = timestamps or [
20
+ "2026-07-01 09:54:00",
21
+ "2026-07-01 09:56:00",
22
+ "2026-07-01 10:00:00",
23
+ "2026-07-01 10:01:00",
24
+ ]
25
+ values = values or [10.0, 20.0, 30.0, 40.0]
26
+ source = pd.DataFrame({"timestamp": pd.DatetimeIndex(timestamps), "value": values})
26
27
  return SimpleNamespace(
27
28
  requested_output_columns=requested or ["example_temperature_mean_5m"],
28
29
  inputs=[
@@ -32,24 +33,50 @@ class ComputeFeaturesTest(unittest.TestCase):
32
33
  )
33
34
  ],
34
35
  metric_frames={"example_temperature:v1": source},
35
- target_times=pd.DatetimeIndex(
36
- ["2026-07-01 10:00:00", "2026-07-01 10:02:00"]
37
- ),
36
+ target_times=pd.DatetimeIndex(targets or ["2026-07-01 10:00:00", "2026-07-01 10:02:00"]),
38
37
  config={"window": "5min"},
39
38
  )
40
39
 
41
- def test_uses_open_left_and_closed_right_window(self) -> None:
40
+ def test_formula_uses_the_confirmed_mean(self) -> None:
42
41
  result = compute_features(self.context())
43
-
44
- self.assertEqual(
45
- list(result.columns),
46
- ["event_time", "example_temperature_mean_5m"],
47
- )
48
42
  self.assertEqual(result["example_temperature_mean_5m"].tolist(), [25.0, 35.0])
49
43
 
44
+ def test_excludes_event_at_open_left_window_boundary(self) -> None:
45
+ result = compute_features(self.context(["2026-07-01 09:55:00", "2026-07-01 09:56:00"], [10.0, 20.0]))
46
+ self.assertEqual(result["example_temperature_mean_5m"].iloc[0], 20.0)
47
+
48
+ def test_includes_event_exactly_at_cutoff(self) -> None:
49
+ result = compute_features(self.context(["2026-07-01 10:00:00"], [30.0]))
50
+ self.assertEqual(result["example_temperature_mean_5m"].iloc[0], 30.0)
51
+
52
+ def test_excludes_event_after_cutoff(self) -> None:
53
+ result = compute_features(self.context(["2026-07-01 10:00:01"], [40.0]))
54
+ self.assertTrue(pd.isna(result["example_temperature_mean_5m"].iloc[0]))
55
+
56
+ def test_uses_last_value_for_duplicate_event_time(self) -> None:
57
+ result = compute_features(self.context(["2026-07-01 10:00:00", "2026-07-01 10:00:00"], [10.0, 30.0]))
58
+ self.assertEqual(result["example_temperature_mean_5m"].iloc[0], 30.0)
59
+
60
+ def test_empty_history_returns_null(self) -> None:
61
+ result = compute_features(self.context(["2026-07-01 09:00:00"], [10.0]))
62
+ self.assertTrue(pd.isna(result["example_temperature_mean_5m"].iloc[0]))
63
+
64
+ def test_requested_output_is_exactly_the_supported_subset(self) -> None:
65
+ result = compute_features(self.context(requested=["example_temperature_mean_5m"]))
66
+ self.assertEqual(list(result.columns), ["event_time", "example_temperature_mean_5m"])
67
+
68
+ def test_preserves_requested_event_time_order(self) -> None:
69
+ targets = ["2026-07-01 10:02:00", "2026-07-01 10:00:00"]
70
+ result = compute_features(self.context(targets=targets))
71
+ self.assertEqual(list(result["event_time"]), list(pd.DatetimeIndex(targets)))
72
+
73
+ def test_output_dtype_is_float64(self) -> None:
74
+ result = compute_features(self.context())
75
+ self.assertEqual(str(result["example_temperature_mean_5m"].dtype), "float64")
76
+
50
77
  def test_rejects_unknown_requested_output(self) -> None:
51
78
  with self.assertRaisesRegex(ValueError, "unsupported output columns"):
52
- compute_features(self.context(["unknown_feature"]))
79
+ compute_features(self.context(requested=["unknown_feature"]))
53
80
 
54
81
 
55
82
  if __name__ == "__main__":