This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new 196e28f3a7 [python] Add paired ACT storage benchmark (#9466)
196e28f3a7 is described below
commit 196e28f3a7b257daa9aded93814f98c3cd88fc88
Author: Yann Byron <[email protected]>
AuthorDate: Wed Sep 9 19:52:16 2026 +0800
[python] Add paired ACT storage benchmark (#9466)
---
docs/docs/pypaimon/robomind-act-benchmark.md | 185 ++++++
docs/docs/pypaimon/robomind-agilex.md | 16 +-
paimon-python/pypaimon/benchmark/act/__init__.py | 17 +
paimon-python/pypaimon/benchmark/act/__main__.py | 200 ++++++
paimon-python/pypaimon/benchmark/act/compare.py | 231 +++++++
.../pypaimon/benchmark/act/default_experiment.json | 22 +
paimon-python/pypaimon/benchmark/act/experiment.py | 42 ++
paimon-python/pypaimon/benchmark/act/harness.py | 577 ++++++++++++++++
paimon-python/pypaimon/benchmark/act/hdf5.py | 201 ++++++
paimon-python/pypaimon/benchmark/act/paimon.py | 153 +++++
paimon-python/pypaimon/benchmark/act/runner.py | 740 +++++++++++++++++++++
paimon-python/pypaimon/sample/robomind_agilex.py | 28 +-
paimon-python/pypaimon/tests/act_benchmark_test.py | 208 ++++++
paimon-python/pypaimon/tests/act_runner_test.py | 709 ++++++++++++++++++++
.../tests/robomind_agilex_pipeline_test.py | 19 +-
paimon-python/setup.py | 28 +-
16 files changed, 3338 insertions(+), 38 deletions(-)
diff --git a/docs/docs/pypaimon/robomind-act-benchmark.md
b/docs/docs/pypaimon/robomind-act-benchmark.md
new file mode 100644
index 0000000000..b9c070b769
--- /dev/null
+++ b/docs/docs/pypaimon/robomind-act-benchmark.md
@@ -0,0 +1,185 @@
+---
+title: "RoboMIND ACT Storage Benchmark"
+sidebar_position: 8
+---
+
+<!--
+Licensed to the Apache Software Foundation (ASF) under one
+or more contributor license agreements. See the NOTICE file
+distributed with this work for additional information
+regarding copyright ownership. The ASF licenses this file
+to you under the Apache License, Version 2.0 (the
+"License"); you may not use this file except in compliance
+with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an
+"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+KIND, either express or implied. See the License for the
+specific language governing permissions and limitations
+under the License.
+-->
+
+# RoboMIND ACT Storage Benchmark
+
+This benchmark measures the same CPU LeRobot ACT training workload over an
+original RoboMIND AgileX HDF5 dataset or an already ingested and
+canonical-action-backfilled Paimon warehouse. Ingestion and backfill are
outside
+the timed scope; see [RoboMIND AgileX](./robomind-agilex) to build the
warehouse.
+
+The backends run independently. A resolved experiment document preserves the
+shared configuration, normalization, seed, episode selection, Paimon snapshot,
+and logical window sequence. Result comparison verifies that contract before it
+calculates performance ratios.
+
+## Install
+
+Python 3.10 or newer is required.
+
+```shell
+pip install 'pypaimon[act,hdf5]'
+```
+
+## 1. Prepare the experiment
+
+```shell
+python -m pypaimon.benchmark.act prepare \
+ --input /data/RoboMIND/h5_agilex_3rgb \
+ --warehouse /data/warehouse \
+ --output /data/results/experiment.json
+```
+
+Preparation is not timed. It verifies that HDF5 discovery matches the Paimon
+episodes table, checks versioned action statistics against train-only HDF5
+moments, selects eligible train and validation episodes, pins the frames
+snapshot, and materializes deterministic measurement, training, and validation
+window indices.
+
+Without `--experiment`, preparation starts from the packaged
+`default_experiment.json`. `--experiment` replaces that definition, so a
+custom JSON file must contain every required field. Command-line options then
+override individual values:
+
+```shell
+python -m pypaimon.benchmark.act prepare \
+ --experiment my-experiment.json \
+ --input /data/RoboMIND/h5_agilex_3rgb \
+ --warehouse /data/warehouse \
+ --action-horizon 32 \
+ --batch-size 2 \
+ --fetch-batches 8 \
+ --rounds 3 \
+ --output /data/results/experiment.json
+```
+
+The resolved experiment embeds the effective parameters as well as:
+
+- portable source episode metadata and its SHA-256;
+- normalization values, scope, version, frame count, and SHA-256;
+- selected train and validation episode IDs;
+- every logical window index, the window-plan SHA-256, and the
+ episode-qualified sample-sequence SHA-256;
+- the Paimon database, frames table, and pinned snapshot ID.
+
+## 2. Run each backend
+
+```shell
+python -m pypaimon.benchmark.act run \
+ --backend hdf5 \
+ --experiment /data/results/experiment.json \
+ --input /data/RoboMIND/h5_agilex_3rgb \
+ --results-dir /data/results
+
+python -m pypaimon.benchmark.act run \
+ --backend paimon \
+ --experiment /data/results/experiment.json \
+ --warehouse /data/warehouse \
+ --results-dir /data/results
+```
+
+Use `--output` to choose an exact result path. Otherwise the command writes an
+automatically named JSON file below `--results-dir` and prints its absolute
+path as a compact JSON object.
+
+Each result contains the complete resolved experiment and experiment SHA-256,
+backend identity, runtime environment, model metadata, planned-sample tensor
+fingerprint, three or more raw measurement rounds, and median/minimum/maximum
+summary metrics.
+
+Both adapters produce the same shared sample contract. State and camera images
+come from the anchor frame; action covers the complete horizon. HDF5 reads a
+window on demand from one episode file. Paimon uses a lazy, snapshot-pinned
+`ContiguousWindowDataset`; image columns are anchor-only, and plural
+`__getitems__` access coalesces multiple logical batches into a physical
+fetch before splitting them back into the unchanged model batch size.
+
+## 3. Compare results
+
+Compare explicit files:
+
+```shell
+python -m pypaimon.benchmark.act compare \
+ /data/results/robomind-act-hdf5-20260901T010000Z-a1b2c3d4.json \
+ /data/results/robomind-act-paimon-20260901T011000Z-e5f6a7b8.json \
+ --output /data/results/comparison.json
+```
+
+Or discover all ACT result documents in a directory:
+
+```shell
+python -m pypaimon.benchmark.act compare \
+ --results-dir /data/results \
+ --output /data/results/comparison.json
+```
+
+Directory discovery ignores experiment and prior comparison JSON files.
+Results are grouped by experiment SHA-256. Different experiments remain
+separate entries in one comparison artifact; only compatible repeated results
+for the same experiment and backend are aggregated.
+
+Within one experiment group, comparison requires identical runtime environment,
+model metadata, tensor fingerprint, train-loss trace, and validation-loss
trace.
+An environment mismatch marks the group `INCOMPATIBLE`; a model, tensor, or
+loss mismatch marks it `FAILED`. Neither case produces performance ratios.
+
+For compatible HDF5 and Paimon results, higher-is-better metrics report
+`paimon_over_hdf5`. Lower-is-better latency, time, and memory metrics report
+`hdf5_over_paimon`, which is the Paimon speedup or reduction factor.
+
+## Measurements
+
+Every backend repeat records:
+
+- dataset construction time;
+- first-batch latency after construction;
+- batch-fetch samples per second after warm-up;
+- end-to-end fixed ACT optimizer-step time, including dataset fetch;
+- per-step loss and compute time after each training batch has been fetched;
+- validation loss;
+- total measured wall time;
+- Python peak allocation from a separate dataset-first-batch replay.
+
+The shared harness resets Python, NumPy, and Torch random generators before
+model construction and enables deterministic Torch algorithms. The logical
+window plan is explicit rather than delegated to a streaming reader.
+
+Python peak allocation uses `tracemalloc` after wall-clock measurement so
+tracing overhead does not distort throughput. It does not include every native
+Arrow or Torch allocation. The benchmark does not drop the OS page cache.
+GPU, multi-worker loading, distributed training, recovery, and policy quality
+remain outside this benchmark.
+
+## Code organization
+
+- `benchmark.act.harness`: shared ACT tensors, model, trainer, window plan, and
+ measurement lifecycle;
+- `benchmark.act.hdf5`: HDF5 window dataset and train normalization moments;
+- `benchmark.act.paimon`: Paimon adapter, snapshot-pinned datasets, and
+ versioned statistics access;
+- `benchmark.act.runner`: experiment preparation and one-backend execution;
+- `benchmark.act.compare`: result discovery, compatibility checks, grouping by
+ experiment, and aggregation of compatible repeated runs;
+- `benchmark.act.__main__`: the `prepare`, `run`, and `compare`
+ command-line interface.
diff --git a/docs/docs/pypaimon/robomind-agilex.md
b/docs/docs/pypaimon/robomind-agilex.md
index ee8aa65a10..035438018f 100644
--- a/docs/docs/pypaimon/robomind-agilex.md
+++ b/docs/docs/pypaimon/robomind-agilex.md
@@ -45,11 +45,11 @@ normalization statistics.
## Run the local pipeline
-After downloading RoboMIND, use Python 3.11 or later, install the HDF5 and
-Vortex extras, and provide the source and warehouse directories to one command:
+After downloading RoboMIND, install the HDF5 extra and provide the source and
+warehouse directories to one command:
```bash
-pip install 'pypaimon[hdf5,vortex]'
+pip install 'pypaimon[hdf5]'
python -m pypaimon.sample.robomind_agilex \
--input /data/RoboMIND/h5_agilex_3rgb \
--warehouse /data/warehouse
@@ -133,10 +133,10 @@ time with that versioned row.
The tables are non-primary-key append tables. Repeating ingestion therefore
appends duplicate rows by design; it does not mean row-level update/delete is
-disabled. The sample keeps deletion vectors enabled, stores vectors with
-Vortex, and sets `blob-as-descriptor=false` because its transforms emit raw
-image/depth bytes rather than external BLOB descriptors. Parquet data format,
-dynamic bucket mode, and global-index search mode are inherited defaults and
-are not repeated in the sample options.
+disabled. The sample keeps deletion vectors enabled and sets
+`blob-as-descriptor=false` because its transforms emit raw image/depth bytes
+rather than external BLOB descriptors. Parquet data format, dynamic bucket
+mode, and global-index search mode are inherited defaults and are not repeated
+in the sample options.
Run local and Ray modes against separate new warehouses when comparing them.
diff --git a/paimon-python/pypaimon/benchmark/act/__init__.py
b/paimon-python/pypaimon/benchmark/act/__init__.py
new file mode 100644
index 0000000000..f6224db904
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/__init__.py
@@ -0,0 +1,17 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""ACT training benchmark backends and result comparison."""
diff --git a/paimon-python/pypaimon/benchmark/act/__main__.py
b/paimon-python/pypaimon/benchmark/act/__main__.py
new file mode 100644
index 0000000000..d1d8560ae2
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/__main__.py
@@ -0,0 +1,200 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Command-line entry point for ACT benchmark preparation, runs, and
reports."""
+
+# ruff: noqa: E402
+
+import sys
+
+
+def _require_supported_python(version_info):
+ """Reject runtimes older than the ACT dependencies support."""
+ if tuple(version_info[:2]) < (3, 10):
+ raise RuntimeError("ACT benchmark requires Python 3.10 or newer.")
+
+
+_require_supported_python(sys.version_info)
+
+import argparse
+import copy
+import json
+import uuid
+from datetime import datetime, timezone
+from pathlib import Path
+
+from pypaimon.benchmark.act.compare import (
+ compare_results,
+ load_result_documents,
+)
+from pypaimon.benchmark.act.experiment import load_experiment
+from pypaimon.benchmark.act.runner import prepare_experiment, run_experiment
+
+
+_CONFIG_ARGUMENTS = (
+ ("seed", int),
+ ("action_horizon", int),
+ ("batch_size", int),
+ ("optimizer_steps", int),
+ ("image_height", int),
+ ("image_width", int),
+ ("learning_rate", float),
+ ("weight_decay", float),
+ ("warmup_batches", int),
+ ("timed_batches", int),
+ ("fetch_batches", int),
+ ("rounds", int),
+)
+
+
+def main(argv=None):
+ """Parse an ACT benchmark subcommand and write its JSON artifact."""
+ parser = _parser()
+ args = parser.parse_args(argv)
+ if args.command == "prepare":
+ definition = copy.deepcopy(load_experiment(args.experiment))
+ for name, _ in _CONFIG_ARGUMENTS:
+ value = getattr(args, name)
+ if value is not None:
+ definition["config"][name] = value
+ for name in (
+ "statistics_version", "train_episode_id",
+ "validation_episode_id"):
+ value = getattr(args, name)
+ if value is not None:
+ definition[name] = value
+ output = Path(args.output)
+ experiment = prepare_experiment(
+ args.input,
+ args.warehouse,
+ output,
+ definition=definition,
+ database=args.database,
+ )
+ _print_artifact("experiment", output, experiment["schema_version"])
+ return 0
+ if args.command == "run":
+ experiment = load_experiment(args.experiment)
+ output = (
+ Path(args.output)
+ if args.output else _artifact_path(
+ args.results_dir,
+ "%s-%s" % (experiment["benchmark_id"], args.backend),
+ )
+ )
+ result = run_experiment(
+ args.backend,
+ args.experiment,
+ output,
+ input_root=args.input,
+ warehouse=args.warehouse,
+ )
+ _print_artifact("result", output, result["status"])
+ return 0
+ results_dir = args.results_dir
+ if not args.results and results_dir is None:
+ results_dir = "act-results"
+ results = load_result_documents(args.results, results_dir=results_dir)
+ comparison = compare_results(results)
+ output = (
+ Path(args.output)
+ if args.output else _artifact_path(
+ results_dir or "act-results", "comparison")
+ )
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(
+ json.dumps(comparison, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ _print_artifact("comparison", output, comparison["status"])
+ return 0 if comparison["status"] == "SUCCEEDED" else 1
+
+
+def _parser():
+ parser = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ commands = parser.add_subparsers(dest="command", required=True)
+
+ prepare = commands.add_parser(
+ "prepare",
+ help="Resolve a shared experiment against matching HDF5 and Paimon
data.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ prepare.add_argument("--input", required=True, help="RoboMIND HDF5 root.")
+ prepare.add_argument("--warehouse", required=True, help="Paimon
warehouse.")
+ prepare.add_argument(
+ "--experiment",
+ help="Input experiment JSON; packaged defaults are used when omitted.",
+ )
+ prepare.add_argument(
+ "--output", default="act-results/experiment.json",
+ help="Resolved experiment JSON path.")
+ prepare.add_argument("--database", default="robomind")
+ prepare.add_argument("--statistics-version")
+ prepare.add_argument("--train-episode-id")
+ prepare.add_argument("--validation-episode-id")
+ for name, argument_type in _CONFIG_ARGUMENTS:
+ prepare.add_argument(
+ "--" + name.replace("_", "-"), type=argument_type, default=None)
+
+ run = commands.add_parser(
+ "run",
+ help="Run one storage backend using a resolved experiment.",
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ run.add_argument("--backend", required=True, choices=("hdf5", "paimon"))
+ run.add_argument("--experiment", required=True)
+ run.add_argument("--input", help="HDF5 root; required for backend=hdf5.")
+ run.add_argument(
+ "--warehouse", help="Paimon warehouse; required for backend=paimon.")
+ run.add_argument("--output", help="Explicit result JSON path.")
+ run.add_argument(
+ "--results-dir", default="act-results",
+ help="Directory for an automatically named result.")
+
+ compare = commands.add_parser(
+ "compare",
+ help=(
+ "Group results by experiment and aggregate compatible repeats."
+ ),
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ )
+ compare.add_argument("results", nargs="*", help="Explicit result JSON
files.")
+ compare.add_argument(
+ "--results-dir",
+ help="Also discover ACT result JSON files in this directory.")
+ compare.add_argument("--output", help="Comparison JSON path.")
+ return parser
+
+
+def _artifact_path(directory, prefix):
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
+ return Path(directory).expanduser() / (
+ "%s-%s-%s.json" % (prefix, timestamp, uuid.uuid4().hex[:8]))
+
+
+def _print_artifact(kind, path, status):
+ print(json.dumps({
+ "artifact": str(Path(path).expanduser().resolve()),
+ "kind": kind,
+ "status": status,
+ }, sort_keys=True))
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/paimon-python/pypaimon/benchmark/act/compare.py
b/paimon-python/pypaimon/benchmark/act/compare.py
new file mode 100644
index 0000000000..ab53f4dd67
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/compare.py
@@ -0,0 +1,231 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Validate and aggregate independently produced ACT benchmark results."""
+
+import hashlib
+import json
+from pathlib import Path
+
+
+_METRICS = {
+ "batch_fetch_samples_per_s": "higher",
+ "dataset_build_s": "lower",
+ "first_batch_s": "lower",
+ "fixed_steps_s": "lower",
+ "python_peak_allocated_bytes": "lower",
+ "wall_time_s": "lower",
+}
+
+
+def canonical_sha256(value):
+ """Return the SHA-256 of a JSON value using canonical serialization."""
+ payload = json.dumps(value, sort_keys=True, separators=(",", ":"))
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def load_result_documents(paths, results_dir=None):
+ """Load explicit result files plus ACT results discovered in a directory.
+
+ Explicit paths must contain result documents. Directory discovery ignores
+ experiment and prior comparison JSON files. A path found both ways is read
+ once, preserving explicit-path order followed by sorted directory entries.
+ """
+ candidates = [Path(path).expanduser().resolve() for path in paths]
+ explicit = set(candidates)
+ if results_dir is not None:
+ directory = Path(results_dir).expanduser().resolve()
+ candidates.extend(sorted(directory.glob("*.json")))
+ seen = set()
+ results = []
+ for path in candidates:
+ path = path.resolve()
+ if path in seen:
+ continue
+ seen.add(path)
+ with path.open(encoding="utf-8") as result_file:
+ document = json.load(result_file)
+ if document.get("schema_version") != "act-benchmark-result@1":
+ if path in explicit:
+ raise ValueError("Not an ACT benchmark result: %s." % path)
+ continue
+ results.append(document)
+ if not results:
+ raise ValueError("No ACT benchmark result files were found.")
+ return results
+
+
+def compare_results(results):
+ """Group result documents by experiment and compare compatible backends.
+
+ Results from different experiment definitions remain separate. Results in
+ one experiment group must report the same runtime environment; otherwise
+ the group is marked incompatible and no performance ratios are produced.
+
+ Args:
+ results: Iterable of decoded ``act-benchmark-result@1`` documents.
+
+ Returns:
+ A JSON-compatible comparison document with one entry per experiment.
+ """
+ groups = {}
+ for result in results:
+ if result.get("schema_version") != "act-benchmark-result@1":
+ raise ValueError("Unsupported ACT benchmark result schema.")
+ experiment = result.get("experiment")
+ if not isinstance(experiment, dict):
+ raise ValueError("ACT benchmark result has no experiment object.")
+ experiment_sha256 = canonical_sha256(experiment)
+ if result.get("experiment_sha256") != experiment_sha256:
+ raise ValueError("ACT result experiment SHA-256 differs.")
+ if result.get("status") != "SUCCEEDED":
+ raise ValueError("ACT comparison requires successful results.")
+ if result.get("backend") not in ("hdf5", "paimon"):
+ raise ValueError("ACT result has an unsupported backend.")
+ groups.setdefault(experiment_sha256, []).append(result)
+
+ experiments = [
+ _compare_experiment(experiment_sha256, grouped)
+ for experiment_sha256, grouped in sorted(groups.items())
+ ]
+ statuses = {item["status"] for item in experiments}
+ if statuses == {"SUCCEEDED"}:
+ status = "SUCCEEDED"
+ elif "FAILED" in statuses:
+ status = "FAILED"
+ else:
+ status = "INCOMPATIBLE"
+ return {
+ "schema_version": "act-benchmark-comparison@1",
+ "status": status,
+ "experiments": experiments,
+ }
+
+
+def _compare_experiment(experiment_sha256, results):
+ environments = {
+ canonical_sha256(result.get("environment", {})) for result in results
+ }
+ by_backend = {}
+ for result in results:
+ by_backend.setdefault(result["backend"], []).append(result)
+ if set(by_backend) != {"hdf5", "paimon"}:
+ return {
+ "experiment_sha256": experiment_sha256,
+ "experiment": results[0]["experiment"],
+ "status": "INCOMPATIBLE",
+ "reason": "both hdf5 and paimon results are required",
+ "backends": sorted(by_backend),
+ "result_count": len(results),
+ "metrics": {},
+ }
+ if len(environments) != 1:
+ return {
+ "experiment_sha256": experiment_sha256,
+ "experiment": results[0]["experiment"],
+ "status": "INCOMPATIBLE",
+ "reason": "runtime environments differ",
+ "environment_sha256s": sorted(environments),
+ "backends": sorted(by_backend),
+ "metrics": {},
+ }
+ models = {canonical_sha256(result.get("model")) for result in results}
+ if len(models) != 1:
+ return _failed_group(
+ experiment_sha256, by_backend, results, "models differ")
+ fingerprints = {
+ result.get("tensor_fingerprint", {}).get("sha256")
+ for result in results
+ }
+ if len(fingerprints) != 1 or None in fingerprints:
+ return _failed_group(
+ experiment_sha256,
+ by_backend,
+ results,
+ "tensor fingerprints differ",
+ )
+ loss_traces = {canonical_sha256([{
+ "round": run["round"],
+ "train_loss": run["train_loss"],
+ "validation_loss": run["validation_loss"],
+ } for run in result.get("runs", [])]) for result in results}
+ if len(loss_traces) != 1:
+ return _failed_group(
+ experiment_sha256, by_backend, results, "loss traces differ")
+
+ medians = {
+ backend: _aggregate_backend(items)
+ for backend, items in by_backend.items()
+ }
+ metrics = {}
+ for name, preferred in _METRICS.items():
+ values = {
+ backend: summary[name]
+ for backend, summary in medians.items()
+ if name in summary
+ }
+ if values:
+ metric = dict(values)
+ metric["preferred"] = preferred
+ if set(values) == {"hdf5", "paimon"}:
+ if preferred == "higher" and values["hdf5"]:
+ metric["paimon_over_hdf5"] = (
+ values["paimon"] / values["hdf5"])
+ elif preferred == "lower" and values["paimon"]:
+ metric["hdf5_over_paimon"] = (
+ values["hdf5"] / values["paimon"])
+ metrics[name] = metric
+ return {
+ "experiment_sha256": experiment_sha256,
+ "experiment": results[0]["experiment"],
+ "status": "SUCCEEDED",
+ "environment": results[0]["environment"],
+ "environment_sha256": next(iter(environments)),
+ "backends": sorted(by_backend),
+ "result_count": len(results),
+ "metrics": metrics,
+ }
+
+
+def _failed_group(experiment_sha256, by_backend, results, reason):
+ return {
+ "experiment_sha256": experiment_sha256,
+ "experiment": results[0]["experiment"],
+ "status": "FAILED",
+ "reason": reason,
+ "backends": sorted(by_backend),
+ "result_count": len(results),
+ "metrics": {},
+ }
+
+
+def _aggregate_backend(results):
+ names = set.intersection(*(
+ set(result.get("summary", {})) for result in results
+ ))
+ aggregated = {}
+ for name in names:
+ if name not in _METRICS:
+ continue
+ values = [result["summary"][name]["median"] for result in results]
+ values.sort()
+ middle = len(values) // 2
+ aggregated[name] = (
+ values[middle]
+ if len(values) % 2
+ else (values[middle - 1] + values[middle]) / 2.0
+ )
+ return aggregated
diff --git a/paimon-python/pypaimon/benchmark/act/default_experiment.json
b/paimon-python/pypaimon/benchmark/act/default_experiment.json
new file mode 100644
index 0000000000..91c2ffe291
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/default_experiment.json
@@ -0,0 +1,22 @@
+{
+ "benchmark_id": "robomind-act",
+ "config": {
+ "action_horizon": 32,
+ "batch_size": 2,
+ "fetch_batches": 8,
+ "image_height": 64,
+ "image_width": 80,
+ "learning_rate": 0.0001,
+ "optimizer_steps": 2,
+ "rounds": 3,
+ "seed": 20260825,
+ "timed_batches": 32,
+ "warmup_batches": 1,
+ "weight_decay": 0.0001
+ },
+ "dataset": "RoboMIND AgileX",
+ "schema_version": "act-benchmark-experiment@1",
+ "statistics_version": "robomind-agilex-joint-position@1",
+ "train_episode_id": null,
+ "validation_episode_id": null
+}
diff --git a/paimon-python/pypaimon/benchmark/act/experiment.py
b/paimon-python/pypaimon/benchmark/act/experiment.py
new file mode 100644
index 0000000000..f1bb675ca2
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/experiment.py
@@ -0,0 +1,42 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Load the declarative parameters shared by ACT benchmark runs."""
+
+import json
+from pathlib import Path
+
+
+DEFAULT_EXPERIMENT = Path(__file__).with_name("default_experiment.json")
+
+
+def load_experiment(path=None):
+ """Load an ACT experiment definition from JSON or the packaged default.
+
+ Args:
+ path: Optional JSON path. When omitted, the packaged RoboMIND ACT
+ benchmark defaults are loaded.
+
+ Returns:
+ A dictionary containing the benchmark identity, normalization version,
+ episode selection, and shared ACT/measurement configuration.
+ """
+ source = DEFAULT_EXPERIMENT if path is None else Path(path)
+ with source.expanduser().open(encoding="utf-8") as experiment_file:
+ experiment = json.load(experiment_file)
+ if not isinstance(experiment, dict):
+ raise ValueError("ACT experiment must be a JSON object.")
+ return experiment
diff --git a/paimon-python/pypaimon/benchmark/act/harness.py
b/paimon-python/pypaimon/benchmark/act/harness.py
new file mode 100644
index 0000000000..17be12ea14
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/harness.py
@@ -0,0 +1,577 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Shared deterministic ACT model, trainer, and window plan for benchmarks."""
+
+import gc
+import hashlib
+import json
+import math
+import random
+import time
+import tracemalloc
+from dataclasses import asdict, dataclass
+from io import BytesIO
+
+import numpy as np
+import torch
+import torch.nn.functional as functional
+from PIL import Image
+from torch.utils.data import default_collate
+
+
+CAMERA_KEYS = (
+ "observation.images.front",
+ "observation.images.left_wrist",
+ "observation.images.right_wrist",
+)
+
+
+@dataclass(frozen=True)
+class BenchmarkConfig:
+ """Immutable model, sampling, training, and measurement parameters.
+
+ Every backend reconstructs this configuration from the resolved experiment
+ so tensor shapes, optimizer behavior, random seeds, and metric boundaries
+ remain comparable.
+ """
+
+ seed: int = 20260825
+ action_horizon: int = 32
+ batch_size: int = 2
+ optimizer_steps: int = 2
+ image_height: int = 64
+ image_width: int = 80
+ learning_rate: float = 1e-4
+ weight_decay: float = 1e-4
+ warmup_batches: int = 1
+ timed_batches: int = 32
+ fetch_batches: int = 8
+ rounds: int = 3
+
+ def __post_init__(self):
+ positive_ints = (
+ "action_horizon",
+ "batch_size",
+ "optimizer_steps",
+ "image_height",
+ "image_width",
+ "warmup_batches",
+ "timed_batches",
+ "fetch_batches",
+ )
+ for name in positive_ints:
+ value = getattr(self, name)
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, int)
+ or value <= 0):
+ raise ValueError("%s must be a positive int." % name)
+ if isinstance(self.seed, bool) or not isinstance(self.seed, int):
+ raise ValueError("seed must be an int.")
+ if isinstance(self.rounds, bool) or not isinstance(self.rounds, int):
+ raise ValueError("rounds must be an int.")
+ if self.rounds < 3:
+ raise ValueError("rounds must be at least 3.")
+ if self.learning_rate <= 0:
+ raise ValueError("learning_rate must be positive.")
+ if self.weight_decay < 0:
+ raise ValueError("weight_decay must not be negative.")
+
+ def to_dict(self):
+ return asdict(self)
+
+
+@dataclass(frozen=True)
+class WindowPlan:
+ """Logical dataset-window indices consumed by one experiment.
+
+ Measurement indices cover warm-up and timed reads, train indices cover
+ fixed optimizer steps, and validation indices cover the final loss. These
+ are map-style dataset indices, not Paimon row IDs. ``sha256`` identifies
+ the exact plan across independent backend processes.
+ """
+
+ seed: int
+ measurement_indices: tuple
+ train_indices: tuple
+ validation_indices: tuple
+
+ @property
+ def sha256(self):
+ payload = json.dumps(
+ self.to_dict(), sort_keys=True, separators=(",", ":"))
+ return hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+ def to_dict(self):
+ return {
+ "seed": self.seed,
+ "measurement_indices": list(self.measurement_indices),
+ "train_indices": list(self.train_indices),
+ "validation_indices": list(self.validation_indices),
+ }
+
+
+def build_window_plan(train_window_count, validation_window_count, config):
+ """Build deterministic measurement, training, and validation indices.
+
+ Args:
+ train_window_count: Number of complete windows in the train dataset.
+ validation_window_count: Number of complete validation windows.
+ config: Shared benchmark configuration supplying counts and the seed.
+
+ Returns:
+ A :class:`WindowPlan`. When more samples are needed than a dataset
+ contains, consecutive seeded permutations are concatenated; sampling
+ does not become independent sampling with replacement.
+ """
+ train_window_count = _positive_int(
+ train_window_count, "train_window_count")
+ validation_window_count = _positive_int(
+ validation_window_count, "validation_window_count")
+ batch_fetch_count = (
+ config.warmup_batches + config.timed_batches) * config.batch_size
+ train_count = config.optimizer_steps * config.batch_size
+ return WindowPlan(
+ seed=config.seed,
+ measurement_indices=tuple(_repeat_permutations(
+ train_window_count, batch_fetch_count, config.seed + 1)),
+ train_indices=tuple(_repeat_permutations(
+ train_window_count, train_count, config.seed + 2)),
+ validation_indices=tuple(_repeat_permutations(
+ validation_window_count, config.batch_size, config.seed + 3)),
+ )
+
+
+def decode_rgb_image(payload):
+ """Decode JPEG/PNG bytes into an ``H x W x 3`` RGB NumPy array.
+
+ Raises:
+ ValueError: If Pillow cannot decode the payload as an image.
+ """
+ try:
+ return np.asarray(Image.open(BytesIO(payload)).convert("RGB"))
+ except Exception as error:
+ raise ValueError("Cannot decode ACT RGB image bytes.") from error
+
+
+def decode_image_tensor(value):
+ """Decode bytes or an HDF5 uint8 value into normalized ``C x H x W``.
+
+ The returned NumPy array is float32 with values in ``[0, 1]``. Both
+ storage backends call this function so image conversion is not part of the
+ performance difference being measured.
+ """
+ payload = (
+ bytes(value)
+ if isinstance(value, (bytes, bytearray, memoryview))
+ else np.asarray(value, dtype=np.uint8).tobytes()
+ )
+ image = decode_rgb_image(payload)
+ return np.transpose(image, (2, 0, 1)).astype(np.float32) / 255.0
+
+
+def validate_act_batch(batch, config):
+ """Validate a collated batch against the shared ACT tensor contract.
+
+ Successful validation returns ``None``. It checks exact fields, tensor
+ shapes and dtypes, finite values, image range, complete unpadded windows,
+ and ``sample_id == episode_id#frame_index`` identity.
+ """
+ required = {
+ "sample_id", "episode_id", "frame_index", "qpos", "action",
+ "images", "is_pad",
+ }
+ if set(batch) != required:
+ raise ValueError(
+ "ACT batch fields differ: expected %s, got %s."
+ % (sorted(required), sorted(batch)))
+ batch_size = len(batch["sample_id"])
+ expected = {
+ "qpos": ((batch_size, 14), torch.float32),
+ "action": ((batch_size, config.action_horizon, 14), torch.float32),
+ "images": (
+ (batch_size, len(CAMERA_KEYS), 3)
+ + tuple(batch["images"].shape[-2:]),
+ torch.float32,
+ ),
+ "is_pad": ((batch_size, config.action_horizon), torch.bool),
+ "frame_index": ((batch_size,), torch.int64),
+ }
+ for name, (shape, dtype) in expected.items():
+ value = batch[name]
+ if not isinstance(value, torch.Tensor):
+ raise ValueError("%s must be a torch.Tensor." % name)
+ if tuple(value.shape) != shape:
+ raise ValueError(
+ "%s has shape %s; expected %s."
+ % (name, tuple(value.shape), shape))
+ if value.dtype != dtype:
+ raise ValueError(
+ "%s has dtype %s; expected %s." % (name, value.dtype, dtype))
+ for name in ("qpos", "action", "images"):
+ if not torch.isfinite(batch[name]).all():
+ raise ValueError("%s contains NaN or Inf." % name)
+ if torch.any(batch["images"] < 0) or torch.any(batch["images"] > 1):
+ raise ValueError("images must be normalized to [0, 1].")
+ if batch["is_pad"].any():
+ raise ValueError("ACT benchmark windows must be complete and
unpadded.")
+ for sample_id, episode_id, frame_index in zip(
+ batch["sample_id"], batch["episode_id"],
+ batch["frame_index"].tolist()):
+ if sample_id != "%s#%s" % (episode_id, frame_index):
+ raise ValueError(
+ "sample_id is not aligned with episode_id and frame_index.")
+
+
+def build_lerobot_batch(batch, config):
+ """Map a shared ACT batch to LeRobot ``ACTPolicy`` feature names.
+
+ Images are resized bilinearly to the configured height and width when
+ necessary. State, action, and padding retain their original semantics.
+ """
+ validate_act_batch(batch, config)
+ images = batch["images"]
+ target_size = (config.image_height, config.image_width)
+ if tuple(images.shape[-2:]) != target_size:
+ flat = images.flatten(0, 1)
+ flat = functional.interpolate(
+ flat, size=target_size, mode="bilinear", align_corners=False)
+ images = flat.reshape(images.shape[:3] + target_size)
+ result = {
+ "observation.state": batch["qpos"],
+ "action": batch["action"],
+ "action_is_pad": batch["is_pad"],
+ }
+ for index, name in enumerate(CAMERA_KEYS):
+ result[name] = images[:, index]
+ return result
+
+
+def build_act_policy(config):
+ """Build the reduced CPU ACT policy used only by this benchmark.
+
+ Returns:
+ ``(policy, metadata)`` containing the LeRobot policy and a
+ JSON-compatible description of its architecture and parameter counts.
+ Pretrained weights are disabled, so this function performs no model
+ download and does not represent a production training configuration.
+ """
+ try:
+ import importlib.metadata
+ from lerobot.configs.types import FeatureType, PolicyFeature
+ from lerobot.policies.act.configuration_act import ACTConfig
+ from lerobot.policies.act.modeling_act import ACTPolicy
+ except ImportError as error:
+ raise ImportError(
+ "ACT benchmark requires: "
+ "pip install -e '.[act]'.") from error
+
+ inputs = {
+ "observation.state": PolicyFeature(FeatureType.STATE, (14,)),
+ }
+ inputs.update({
+ name: PolicyFeature(
+ FeatureType.VISUAL,
+ (3, config.image_height, config.image_width),
+ )
+ for name in CAMERA_KEYS
+ })
+ act_config = ACTConfig(
+ input_features=inputs,
+ output_features={
+ "action": PolicyFeature(FeatureType.ACTION, (14,)),
+ },
+ device="cpu",
+ chunk_size=config.action_horizon,
+ n_action_steps=config.action_horizon,
+ vision_backbone="resnet18",
+ pretrained_backbone_weights=None,
+ dim_model=64,
+ n_heads=4,
+ dim_feedforward=256,
+ n_encoder_layers=1,
+ n_decoder_layers=1,
+ use_vae=True,
+ latent_dim=16,
+ n_vae_encoder_layers=1,
+ kl_weight=10.0,
+ )
+ policy = ACTPolicy(act_config)
+ return policy, {
+ "implementation": "lerobot.ACTPolicy",
+ "lerobot_version": importlib.metadata.version("lerobot"),
+ "vision_backbone": act_config.vision_backbone,
+ "pretrained_backbone_weights": act_config.pretrained_backbone_weights,
+ "chunk_size": act_config.chunk_size,
+ "dim_model": act_config.dim_model,
+ "n_heads": act_config.n_heads,
+ "n_encoder_layers": act_config.n_encoder_layers,
+ "n_decoder_layers": act_config.n_decoder_layers,
+ "n_vae_encoder_layers": act_config.n_vae_encoder_layers,
+ "latent_dim": act_config.latent_dim,
+ "kl_weight": act_config.kl_weight,
+ "parameter_count": sum(
+ parameter.numel() for parameter in policy.parameters()),
+ "trainable_parameter_count": sum(
+ parameter.numel()
+ for parameter in policy.parameters() if parameter.requires_grad),
+ }
+
+
+def run_backend(
+ backend,
+ round_number,
+ dataset_factory,
+ plan,
+ config,
+ sample_sequence_sha256,
+ policy_factory=None):
+ """Measure one backend with the shared plan, model, and trainer.
+
+ ``backend`` is a result label and ``round_number`` identifies the repeat.
+ ``dataset_factory`` must return ``(train_dataset, validation_dataset)`` and
+ must be reusable: it is called for the timed run and again by the separate
+ Python-memory replay. ``policy_factory`` is an optional test hook returning
+ ``(policy, model_metadata)``.
+
+ Returns:
+ A JSON-compatible metrics dictionary covering dataset construction,
+ first batch, timed batch fetch, fixed optimizer steps, validation loss,
+ and a separate ``tracemalloc`` peak replay. ``fixed_steps_s`` includes
+ dataset fetch, while each ``train_trace.step_time_s`` starts after its
+ batch is fetched and covers conversion, forward/backward, and optimizer
+ update. OS page cache is not controlled and native Arrow/Torch
+ allocations are outside tracemalloc.
+ """
+ _seed_everything(config.seed)
+ policy_factory = policy_factory or build_act_policy
+ started = time.monotonic()
+ dataset_started = time.monotonic()
+ train_dataset, validation_dataset = dataset_factory()
+ dataset_build_s = time.monotonic() - dataset_started
+
+ warmup_sample_count = config.warmup_batches * config.batch_size
+ warmup_iterator = _iter_logical_batches(
+ train_dataset,
+ plan.measurement_indices[:warmup_sample_count],
+ logical_batch_size=config.batch_size,
+ fetch_batches=1,
+ )
+ first_batch_started = time.monotonic()
+ first_batch = next(warmup_iterator)
+ first_batch_s = time.monotonic() - first_batch_started
+ validate_act_batch(first_batch, config)
+ for _ in range(config.warmup_batches - 1):
+ validate_act_batch(next(warmup_iterator), config)
+
+ batch_fetch_iterator = _iter_logical_batches(
+ train_dataset,
+ plan.measurement_indices[warmup_sample_count:],
+ logical_batch_size=config.batch_size,
+ fetch_batches=config.fetch_batches,
+ )
+ batch_fetch_seconds = 0.0
+ batch_fetch_sample_count = 0
+ for _ in range(config.timed_batches):
+ batch_fetch_started = time.monotonic()
+ batch = next(batch_fetch_iterator)
+ batch_fetch_seconds += time.monotonic() - batch_fetch_started
+ validate_act_batch(batch, config)
+ batch_fetch_sample_count += len(batch["sample_id"])
+
+ _seed_everything(config.seed)
+ policy, model = policy_factory(config)
+ parameters = (
+ policy.get_optim_params()
+ if hasattr(policy, "get_optim_params") else policy.parameters())
+ optimizer = torch.optim.AdamW(
+ parameters,
+ lr=config.learning_rate,
+ weight_decay=config.weight_decay,
+ )
+ policy.train()
+ train_started = time.monotonic()
+ losses = []
+ for step, batch in enumerate(_iter_logical_batches(
+ train_dataset,
+ plan.train_indices,
+ logical_batch_size=config.batch_size,
+ fetch_batches=config.fetch_batches,
+ ), 1):
+ step_started = time.monotonic()
+ model_batch = build_lerobot_batch(batch, config)
+ optimizer.zero_grad(set_to_none=True)
+ loss, components = policy(model_batch)
+ if loss.ndim != 0 or not torch.isfinite(loss):
+ raise FloatingPointError(
+ "ACT produced a non-finite scalar loss at step %d." % step)
+ loss.backward()
+ optimizer.step()
+ losses.append({
+ "step": step,
+ "total": float(loss.detach()),
+ "components": {
+ name: _finite_float(value, name)
+ for name, value in components.items()
+ },
+ "step_time_s": time.monotonic() - step_started,
+ })
+ fixed_steps_s = time.monotonic() - train_started
+ if len(losses) != config.optimizer_steps:
+ raise AssertionError(
+ "Expected %d optimizer steps, got %d."
+ % (config.optimizer_steps, len(losses)))
+
+ # ACTPolicy only constructs the VAE posterior needed by its supervised
+ # loss while the module is in training mode. Keep that mode for validation
+ # but disable gradients and parameter updates below.
+ policy.train()
+ _seed_everything(config.seed + 4)
+ validation_batch = next(_iter_logical_batches(
+ validation_dataset,
+ plan.validation_indices,
+ logical_batch_size=config.batch_size,
+ fetch_batches=config.fetch_batches,
+ ))
+ with torch.no_grad():
+ validation_loss, _ = policy(build_lerobot_batch(
+ validation_batch, config))
+ validation_value = _finite_float(validation_loss, "validation_loss")
+ wall_time_s = time.monotonic() - started
+ python_peak = _measure_python_peak(dataset_factory, plan, config)
+
+ return {
+ "round": round_number,
+ "backend": backend,
+ "sample_sequence_sha256": sample_sequence_sha256,
+ "model": model,
+ "optimizer": {
+ "name": "AdamW",
+ "learning_rate": config.learning_rate,
+ "weight_decay": config.weight_decay,
+ },
+ "warmup_batches": config.warmup_batches,
+ "first_batch_s": first_batch_s,
+ "dataset_build_s": dataset_build_s,
+ "batch_fetch_samples": batch_fetch_sample_count,
+ "batch_fetch_s": batch_fetch_seconds,
+ "batch_fetch_samples_per_s": (
+ batch_fetch_sample_count / batch_fetch_seconds),
+ "fixed_steps_s": fixed_steps_s,
+ "train_loss": [item["total"] for item in losses],
+ "train_trace": losses,
+ "validation_loss": validation_value,
+ "python_peak_allocated_bytes": python_peak,
+ "peak_memory_measurement": (
+ "python-tracemalloc-separate-dataset-first-batch"),
+ "wall_time_s": wall_time_s,
+ }
+
+
+def _measure_python_peak(dataset_factory, plan, config):
+ """Measure Python allocation peak in a separate dataset-first-batch replay.
+
+ The factory is called again so tracing overhead cannot distort the main
+ throughput timings. The returned integer is the tracemalloc peak in bytes.
+ """
+ gc.collect()
+ tracemalloc.start()
+ try:
+ train_dataset, _ = dataset_factory()
+ indices = plan.measurement_indices[
+ :config.batch_size * config.fetch_batches
+ ]
+ next(_iter_logical_batches(
+ train_dataset,
+ indices,
+ logical_batch_size=config.batch_size,
+ fetch_batches=config.fetch_batches,
+ ))
+ _, peak = tracemalloc.get_traced_memory()
+ return peak
+ finally:
+ tracemalloc.stop()
+
+
+def _repeat_permutations(size, count, seed):
+ """Return ``count`` indices by concatenating seeded permutations."""
+ values = []
+ generator = np.random.RandomState(seed)
+ while len(values) < count:
+ values.extend(generator.permutation(size).tolist())
+ return values[:count]
+
+
+def _seed_everything(seed):
+ """Reset Python, NumPy, and Torch RNGs and enable deterministic Torch
ops."""
+ random.seed(seed)
+ np.random.seed(seed)
+ torch.manual_seed(seed)
+ torch.use_deterministic_algorithms(True)
+
+
+def _finite_float(value, name):
+ if isinstance(value, torch.Tensor):
+ if value.numel() != 1:
+ raise ValueError("%s must be scalar." % name)
+ value = float(value.detach())
+ else:
+ value = float(value)
+ if not math.isfinite(value):
+ raise FloatingPointError("%s is NaN or Inf." % name)
+ return value
+
+
+def _positive_int(value, name):
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
+ raise ValueError("%s must be a positive int." % name)
+ return value
+
+
+def _iter_logical_batches(
+ dataset, indices, *, logical_batch_size, fetch_batches):
+ """Yield collated model batches while coalescing physical dataset reads.
+
+ Args:
+ dataset: Map-style dataset implementing ``__getitem__`` and optionally
+ plural ``__getitems__(indices)`` access.
+ indices: Explicit ordered logical-window indices. Their count must be
+ divisible by ``logical_batch_size``.
+ logical_batch_size: Number of samples consumed by one model step.
+ fetch_batches: Logical batches combined into one physical dataset read.
+
+ Yields:
+ Collated logical batches in the exact input-index order. A plural
+ dataset method is preferred when available; otherwise samples are read
+ individually and split back into the same logical batches.
+ """
+ logical_batch_size = _positive_int(
+ logical_batch_size, "logical_batch_size")
+ fetch_batches = _positive_int(fetch_batches, "fetch_batches")
+ if len(indices) % logical_batch_size:
+ raise ValueError("indices must contain complete logical batches.")
+ physical_size = logical_batch_size * fetch_batches
+ getitems = getattr(dataset, "__getitems__", None)
+ for offset in range(0, len(indices), physical_size):
+ physical_indices = list(indices[offset:offset + physical_size])
+ if getitems is None:
+ samples = [dataset[index] for index in physical_indices]
+ else:
+ samples = getitems(physical_indices)
+ for logical_offset in range(0, len(samples), logical_batch_size):
+ yield default_collate(
+ samples[logical_offset:logical_offset + logical_batch_size])
diff --git a/paimon-python/pypaimon/benchmark/act/hdf5.py
b/paimon-python/pypaimon/benchmark/act/hdf5.py
new file mode 100644
index 0000000000..4e37e461cd
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/hdf5.py
@@ -0,0 +1,201 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""HDF5 dataset adapter for the RoboMIND ACT benchmark."""
+
+import numpy as np
+import torch
+from torch.utils.data import Dataset
+
+from pypaimon.benchmark.act.harness import decode_image_tensor
+
+
+QPOS_FIELDS = (
+ "puppet/joint_position_left",
+ "puppet/joint_position_right",
+)
+ACTION_FIELDS = (
+ "master/joint_position_left",
+ "master/joint_position_right",
+)
+IMAGE_FIELDS = (
+ "observations/rgb_images/camera_front",
+ "observations/rgb_images/camera_left_wrist",
+ "observations/rgb_images/camera_right_wrist",
+)
+
+
+class Hdf5ACTWindowDataset(Dataset):
+ """Read complete ACT windows lazily from one HDF5 episode.
+
+ ``episode`` supplies the file path, logical episode ID, and frame count.
+ For a window anchor, state and three camera images come from the anchor
+ frame while actions cover ``[anchor, anchor + action_horizon)``. Each
+ access opens and closes the HDF5 file and returns the shared ACT sample
+ mapping consumed by :mod:`pypaimon.benchmark.act.harness`.
+ """
+
+ def __init__(self, episode, normalization, action_horizon):
+ self.episode = episode
+ self.normalization = normalization
+ self.action_horizon = action_horizon
+ self.window_count = episode.frame_count - action_horizon + 1
+ if self.window_count <= 0:
+ raise ValueError(
+ "Episode %s is shorter than action horizon %d."
+ % (episode.episode_id, action_horizon))
+
+ def __len__(self):
+ return self.window_count
+
+ def __getitem__(self, anchor):
+ """Return the ACT window whose first frame is ``anchor``.
+
+ Negative anchors follow Python sequence semantics. State and images
+ come from the anchor frame, while action contains the complete horizon.
+ """
+ if anchor < 0:
+ anchor += self.window_count
+ if anchor < 0 or anchor >= self.window_count:
+ raise IndexError(anchor)
+ import h5py
+
+ with h5py.File(str(self.episode.path), "r") as h5:
+ qpos = _read_vectors(h5, QPOS_FIELDS, anchor)
+ action = _read_vectors(
+ h5,
+ ACTION_FIELDS,
+ slice(anchor, anchor + self.action_horizon),
+ )
+ images = np.stack([
+ decode_image_tensor(h5[field][anchor]) for field in
IMAGE_FIELDS
+ ])
+ qpos = (
+ (qpos - self.normalization["qpos_mean"])
+ / self.normalization["qpos_std"])
+ action = (
+ (action - self.normalization["action_mean"])
+ / self.normalization["action_std"])
+ return {
+ "sample_id": "%s#%d" % (self.episode.episode_id, anchor),
+ "episode_id": self.episode.episode_id,
+ "frame_index": anchor,
+ "qpos": torch.from_numpy(np.ascontiguousarray(qpos)),
+ "action": torch.from_numpy(np.ascontiguousarray(action)),
+ "images": torch.from_numpy(np.ascontiguousarray(images)),
+ "is_pad": torch.zeros(self.action_horizon, dtype=torch.bool),
+ }
+
+
+def create_datasets(train_episode, validation_episode, normalization, config):
+ """Create HDF5 datasets for the experiment's selected episodes.
+
+ Args:
+ train_episode: Selected training episode with its HDF5 path and frame
+ count.
+ validation_episode: Selected validation episode with the same fields.
+ normalization: Shared state and action normalization arrays.
+ config: Benchmark configuration containing the action horizon.
+
+ Returns:
+ ``(train_dataset, validation_dataset)`` in that order.
+ """
+ return (
+ Hdf5ACTWindowDataset(
+ train_episode, normalization, config.action_horizon),
+ Hdf5ACTWindowDataset(
+ validation_episode, normalization, config.action_horizon),
+ )
+
+
+def compute_normalization(episodes):
+ """Compute train-only HDF5 state and action normalization.
+
+ Args:
+ episodes: Discovered episodes carrying ``path``, ``split``, and
+ ``success`` attributes.
+
+ Returns:
+ ``(normalization, metadata)`` where normalization contains float32
+ arrays used by training. Metadata retains float64-accumulated moments
+ over the canonical float32 actions and the frame count used to validate
+ Paimon statistics. Standard deviations use a ``1e-2`` floor.
+ """
+ train = [
+ episode for episode in episodes
+ if episode.split == "train" and episode.success
+ ]
+ if not train:
+ raise ValueError("No successful train episodes are available.")
+ qpos = _Moments(14)
+ action = _Moments(14)
+ import h5py
+
+ for episode in sorted(train, key=lambda item: item.episode_id):
+ with h5py.File(str(episode.path), "r") as h5:
+ qpos.update(_read_vectors(
+ h5, QPOS_FIELDS, slice(None), dtype=np.float64))
+ action.update(_read_vectors(
+ h5, ACTION_FIELDS, slice(None), dtype=np.float32))
+ qpos_mean, qpos_std = qpos.finish()
+ action_mean, action_std = action.finish()
+ return ({
+ "qpos_mean": qpos_mean.astype(np.float32),
+ "qpos_std": qpos_std.astype(np.float32),
+ "action_mean": action_mean.astype(np.float32),
+ "action_std": action_std.astype(np.float32),
+ }, {
+ "action_mean": action_mean,
+ "action_std": action_std,
+ "frame_count": action.count,
+ })
+
+
+def _read_vectors(h5, fields, selection, dtype=np.float32):
+ value = np.concatenate([
+ np.asarray(h5[field][selection], dtype=dtype) for field in fields
+ ], axis=-1)
+ if not np.isfinite(value).all():
+ raise ValueError("ACT vector contains NaN or Inf.")
+ return value
+
+
+class _Moments(object):
+ """Accumulate float64 population moments with a ``1e-2`` std floor."""
+
+ def __init__(self, width):
+ self.count = 0
+ self.total = np.zeros(width, dtype=np.float64)
+ self.total_square = np.zeros(width, dtype=np.float64)
+
+ def update(self, value):
+ value = np.asarray(value, dtype=np.float64)
+ if value.ndim != 2 or value.shape[1] != len(self.total):
+ raise ValueError(
+ "Unexpected normalization shape %s." % (value.shape,))
+ if not np.isfinite(value).all():
+ raise ValueError("Normalization input contains NaN or Inf.")
+ self.count += value.shape[0]
+ self.total += value.sum(axis=0)
+ self.total_square += np.square(value).sum(axis=0)
+
+ def finish(self):
+ if self.count == 0:
+ raise ValueError("Cannot compute normalization from no frames.")
+ mean = self.total / self.count
+ variance = np.maximum(
+ self.total_square / self.count - np.square(mean), 0.0)
+ return mean, np.maximum(np.sqrt(variance), 1e-2)
diff --git a/paimon-python/pypaimon/benchmark/act/paimon.py
b/paimon-python/pypaimon/benchmark/act/paimon.py
new file mode 100644
index 0000000000..18b50664b6
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/paimon.py
@@ -0,0 +1,153 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Paimon dataset adapter for the RoboMIND ACT benchmark."""
+
+import numpy as np
+import torch
+
+from pypaimon.benchmark.act.harness import decode_image_tensor
+from pypaimon.sample import robomind_agilex as agilex
+
+
+QPOS_COLUMNS = (
+ "state_joint_position_left",
+ "state_joint_position_right",
+)
+ACTION_COLUMNS = ("action",)
+IMAGE_COLUMNS = (
+ "observation_images_rgb_front",
+ "observation_images_rgb_wrist_left",
+ "observation_images_rgb_wrist_right",
+)
+
+
+class PaimonACTAdapter:
+ """Convert a contiguous Paimon row window to the shared ACT sample.
+
+ State and camera columns are taken from the anchor row. The action column
+ covers the full horizon. The returned mapping has the same IDs, tensors,
+ shapes, and normalization as :class:`Hdf5ACTWindowDataset`.
+ """
+
+ def __init__(self, normalization):
+ self.normalization = normalization
+
+ def __call__(self, sample):
+ """Convert the generic window mapping into ACT tensors and identity.
+
+ The persisted ``frame_index`` is forwarded as the shared ACT sample
+ position.
+ State and image columns are singleton lists; action retains the full
+ horizon and ``is_pad`` is forwarded unchanged.
+ """
+ qpos = np.concatenate([
+ np.asarray(sample[name][0], dtype=np.float32)
+ for name in QPOS_COLUMNS
+ ])
+ action = np.concatenate([
+ np.asarray(sample[name], dtype=np.float32)
+ for name in ACTION_COLUMNS
+ ], axis=-1)
+ images = np.stack([
+ decode_image_tensor(sample[name][0]) for name in IMAGE_COLUMNS
+ ])
+ qpos = (
+ (qpos - self.normalization["qpos_mean"])
+ / self.normalization["qpos_std"])
+ action = (
+ (action - self.normalization["action_mean"])
+ / self.normalization["action_std"])
+ episode_id = sample["episode_id"]
+ frame_index = sample["frame_index"]
+ return {
+ "sample_id": "%s#%d" % (episode_id, frame_index),
+ "episode_id": episode_id,
+ "frame_index": frame_index,
+ "qpos": torch.from_numpy(np.ascontiguousarray(qpos)),
+ "action": torch.from_numpy(np.ascontiguousarray(action)),
+ "images": torch.from_numpy(np.ascontiguousarray(images)),
+ "is_pad": sample["is_pad"],
+ }
+
+
+def create_datasets(
+ frames,
+ snapshot_id,
+ train_episode_id,
+ validation_episode_id,
+ normalization,
+ config):
+ """Create lazy train and validation windows pinned to one snapshot.
+
+ State and image columns are anchor-only, so one sample reads the initial
+ joint position and three observation images once rather than once per
+ action-horizon row.
+
+ Args:
+ frames: Paimon frames table used to create both scans.
+ snapshot_id: Snapshot pinned by experiment preparation. Both returned
+ datasets reject any different resolved snapshot.
+ train_episode_id: Episode selected for training windows.
+ validation_episode_id: Episode selected for validation windows.
+ normalization: Shared state and action normalization arrays.
+ config: Benchmark configuration containing the action horizon.
+
+ Returns:
+ ``(train_dataset, validation_dataset)`` in that order, as lazy
+ ``ContiguousWindowDataset`` instances pinned to ``snapshot_id``.
+ """
+ datasets = tuple(
+ frames.scan(snapshot_id=snapshot_id).where(
+ "episode_id = '%s'" % episode_id.replace("'", "''")
+ ).to_contiguous_window_dataset(
+ window_size=config.action_horizon,
+ columns=QPOS_COLUMNS + ACTION_COLUMNS + IMAGE_COLUMNS,
+ anchor_columns=QPOS_COLUMNS + IMAGE_COLUMNS,
+ group_key="episode_id",
+ order_key="frame_index",
+ stride=1,
+ tail="drop",
+ adapter=PaimonACTAdapter(normalization),
+ )
+ for episode_id in (train_episode_id, validation_episode_id)
+ )
+ actual_snapshot_ids = {dataset.snapshot_id for dataset in datasets}
+ if actual_snapshot_ids != {snapshot_id}:
+ raise RuntimeError(
+ "Paimon ACT windows must remain pinned to frames snapshot %s; "
+ "got %s." % (snapshot_id, sorted(actual_snapshot_ids)))
+ return datasets
+
+
+def statistics_row(connection, statistics_version):
+ """Return the unique versioned action-statistics row."""
+ escaped = statistics_version.replace("'", "''")
+ rows = (connection.get_table(agilex.FEATURE_STATS_TABLE).scan()
+ .where("statistics_version = '%s'" % escaped).to_list())
+ if len(rows) != 1:
+ raise ValueError(
+ "Expected one normalization row for %r, got %d."
+ % (statistics_version, len(rows)))
+ return rows[0]
+
+
+def latest_snapshot_id(table):
+ """Return the table's latest snapshot ID or fail for an empty table."""
+ snapshot = table.raw_table.snapshot_manager().get_latest_snapshot()
+ if snapshot is None:
+ raise ValueError("Paimon frames table has no snapshot.")
+ return snapshot.id
diff --git a/paimon-python/pypaimon/benchmark/act/runner.py
b/paimon-python/pypaimon/benchmark/act/runner.py
new file mode 100644
index 0000000000..657aba0b72
--- /dev/null
+++ b/paimon-python/pypaimon/benchmark/act/runner.py
@@ -0,0 +1,740 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Prepare and run RoboMIND ACT benchmarks over HDF5 or Paimon.
+
+Both adapters consume one immutable :class:`BenchmarkConfig`, one train-only
+normalization object, and one explicit window plan. The runner resets the same
+seed before constructing the same LeRobot ACT policy and AdamW trainer for each
+backend. Each backend runs independently without attempting OS cache control
+and writes its tensor fingerprint, loss trace, timing metrics, and Python
+allocation metrics to one result JSON document.
+Ingestion and canonical-action backfill are deliberately outside the benchmark.
+"""
+
+import gc
+import hashlib
+import json
+import os
+import platform
+import subprocess
+import time
+import uuid
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+
+import PIL
+import h5py
+import numpy as np
+import pyarrow as pa
+import torch
+import pypaimon.multimodal as pmm
+from pypaimon import build_info
+from pypaimon.benchmark.act.hdf5 import (
+ compute_normalization as compute_hdf5_normalization,
+ create_datasets as create_hdf5_datasets,
+)
+from pypaimon.benchmark.act.experiment import load_experiment
+from pypaimon.benchmark.act.harness import (
+ BenchmarkConfig,
+ WindowPlan,
+ build_window_plan,
+ run_backend,
+)
+from pypaimon.benchmark.act.compare import canonical_sha256
+from pypaimon.benchmark.act.paimon import (
+ create_datasets as create_paimon_datasets,
+ latest_snapshot_id,
+ statistics_row,
+)
+from pypaimon.sample import robomind_agilex as agilex
+
+
+@dataclass(frozen=True)
+class _BenchmarkEpisode:
+ path: Path
+ source_key: str
+ episode_id: str
+ split: str
+ success: bool
+ frame_count: int
+
+
+def prepare_experiment(
+ input_root,
+ warehouse,
+ output_path,
+ *,
+ definition=None,
+ database=agilex.DEFAULT_DATABASE):
+ """Resolve a benchmark definition against matching HDF5 and Paimon data.
+
+ Preparation is outside timed benchmark execution. It verifies source
+ identity and Paimon statistics, selects eligible train/validation episodes,
+ computes train-only normalization, and fixes every logical window index.
+
+ Args:
+ input_root: RoboMIND AgileX HDF5 root used as the source of episode
+ files and raw normalization moments.
+ warehouse: Existing Paimon warehouse containing the matching ingested
+ and canonical-action-backfilled dataset.
+ output_path: Destination for the resolved experiment JSON document.
+ definition: Optional decoded experiment definition. The packaged
+ defaults are used when omitted.
+ database: Paimon database containing the RoboMIND tables.
+
+ Returns:
+ The resolved, JSON-compatible experiment dictionary written to
+ ``output_path``.
+ """
+ definition = load_experiment() if definition is None else definition
+ if definition.get("schema_version") != "act-benchmark-experiment@1":
+ raise ValueError("Unsupported ACT benchmark experiment schema.")
+ config = BenchmarkConfig(**definition["config"])
+ statistics_version = definition["statistics_version"]
+ input_root = Path(input_root).expanduser().resolve()
+ warehouse = Path(warehouse).expanduser().resolve()
+ output_path = Path(output_path).expanduser().resolve()
+
+ discovered = agilex.discover_episodes(input_root)
+ connection = pmm.connect(
+ database=database, options={"warehouse": str(warehouse)})
+ source_episodes, source_sha256 = _validate_source_identity(
+ discovered, _episode_rows(connection))
+ source_by_id = {episode.episode_id: episode for episode in source_episodes}
+ frames = connection.get_table(agilex.FRAMES_TABLE)
+ frames_snapshot_id = latest_snapshot_id(frames)
+ normalization, normalization_metadata = _shared_normalization(
+ source_episodes,
+ connection,
+ frames_snapshot_id,
+ statistics_version,
+ )
+ del normalization
+ train_episode = _select_episode(
+ source_by_id,
+ split="train",
+ requested=definition.get("train_episode_id"),
+ action_horizon=config.action_horizon,
+ )
+ validation_episode = _select_episode(
+ source_by_id,
+ split="val",
+ requested=definition.get("validation_episode_id"),
+ action_horizon=config.action_horizon,
+ )
+ plan = build_window_plan(
+ train_episode.frame_count - config.action_horizon + 1,
+ validation_episode.frame_count - config.action_horizon + 1,
+ config,
+ )
+ sequence_sha256 = _sample_sequence_sha256(
+ train_episode.episode_id, validation_episode.episode_id, plan)
+ episodes = sorted(({
+ "episode_id": episode.episode_id,
+ "source_key": episode.source_key,
+ "split": episode.split,
+ "success": episode.success,
+ "frame_count": episode.frame_count,
+ } for episode in source_episodes), key=lambda item: item["episode_id"])
+ experiment = {
+ "schema_version": "act-benchmark-experiment@1",
+ "benchmark_id": definition.get("benchmark_id", "robomind-act"),
+ "dataset": definition.get("dataset", "RoboMIND AgileX"),
+ "config": config.to_dict(),
+ "statistics_version": statistics_version,
+ "train_episode_id": train_episode.episode_id,
+ "validation_episode_id": validation_episode.episode_id,
+ "source": {
+ "sha256": source_sha256,
+ "episodes": episodes,
+ },
+ "normalization": normalization_metadata,
+ "window_plan": {
+ **plan.to_dict(),
+ "sha256": plan.sha256,
+ "sample_sequence_sha256": sequence_sha256,
+ },
+ "paimon": {
+ "database": database,
+ "frames_table": agilex.FRAMES_TABLE,
+ "frames_snapshot_id": frames_snapshot_id,
+ },
+ }
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ json.dumps(experiment, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ return experiment
+
+
+def run_experiment(
+ backend,
+ experiment_path,
+ output_path,
+ *,
+ input_root=None,
+ warehouse=None,
+ policy_factory=None):
+ """Run one storage backend against a resolved ACT experiment.
+
+ Args:
+ backend: Result label and dataset implementation, either ``hdf5`` or
+ ``paimon``.
+ experiment_path: Resolved JSON produced by :func:`prepare_experiment`.
+ output_path: Destination JSON result path.
+ input_root: Required only for the HDF5 backend.
+ warehouse: Required only for the Paimon backend.
+ policy_factory: Optional test hook returning ``(policy, metadata)``.
+
+ Returns:
+ A JSON-compatible single-backend result containing the resolved
+ experiment, runtime environment, tensor fingerprint, per-round raw
+ metrics, and median/min/max summary.
+ """
+ if backend not in ("hdf5", "paimon"):
+ raise ValueError("backend must be 'hdf5' or 'paimon'.")
+ experiment = load_experiment(experiment_path)
+ _validate_resolved_experiment(experiment)
+ config = BenchmarkConfig(**experiment["config"])
+ plan = _window_plan_from_experiment(experiment)
+ normalization = {
+ name: np.asarray(value, dtype=np.float32)
+ for name, value in experiment["normalization"]["values"].items()
+ }
+ sequence_sha256 = experiment["window_plan"]["sample_sequence_sha256"]
+ if backend == "hdf5":
+ if input_root is None:
+ raise ValueError("input_root is required for the HDF5 backend.")
+ episodes = _hdf5_episodes_from_experiment(input_root, experiment)
+ by_id = {episode.episode_id: episode for episode in episodes}
+ train_episode = by_id[experiment["train_episode_id"]]
+ validation_episode = by_id[experiment["validation_episode_id"]]
+
+ def dataset_factory():
+ return create_hdf5_datasets(
+ train_episode, validation_episode, normalization, config)
+
+ source = {"input_root": str(Path(input_root).expanduser().resolve())}
+ else:
+ if warehouse is None:
+ raise ValueError("warehouse is required for the Paimon backend.")
+ dataset_factory, source = _paimon_factory_from_experiment(
+ warehouse, experiment, normalization, config)
+
+ started_at = _utc_now()
+ started = time.monotonic()
+ runs = []
+ for round_number in range(1, config.rounds + 1):
+ runs.append(run_backend(
+ backend,
+ round_number,
+ dataset_factory,
+ plan,
+ config,
+ sequence_sha256,
+ policy_factory=policy_factory,
+ ))
+ gc.collect()
+ # Fingerprinting scans samples, so keep it after the timed rounds.
+ fingerprint = _tensor_fingerprint(dataset_factory(), plan)
+ result = {
+ "schema_version": "act-benchmark-result@1",
+ "benchmark_id": experiment["benchmark_id"],
+ "run_id": "%s-%s" % (
+ started_at.replace(":", "").replace("-", ""),
+ uuid.uuid4().hex[:8],
+ ),
+ "status": "SUCCEEDED",
+ "backend": backend,
+ "experiment": experiment,
+ "experiment_sha256": canonical_sha256(experiment),
+ "source": source,
+ "tensor_fingerprint": fingerprint,
+ "model": runs[0]["model"],
+ "runs": runs,
+ "summary": _summarize(runs),
+ "environment": _runtime_environment(
+ Path(__file__).resolve().parents[4]),
+ "command": _command_argv(),
+ "timing": {"wall_time_s": time.monotonic() - started},
+ "unverified": [
+ "OS page cache is uncontrolled; no cache dropping was attempted.",
+ "CPU fixed-step loss parity proves engineering equivalence, "
+ "not policy quality.",
+ "GPU, multi-worker dataset loading, distributed training, and "
+ "recovery are unverified.",
+ "Python tracemalloc excludes native Arrow and Torch allocations.",
+ ],
+ "started_at": started_at,
+ "finished_at": _utc_now(),
+ }
+ output_path = Path(output_path).expanduser().resolve()
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(
+ json.dumps(result, indent=2, sort_keys=True) + "\n",
+ encoding="utf-8",
+ )
+ return result
+
+
+def _validate_resolved_experiment(experiment):
+ """Reject incomplete or internally inconsistent resolved experiments."""
+ required = {
+ "schema_version", "benchmark_id", "dataset", "config",
+ "statistics_version", "train_episode_id", "validation_episode_id",
+ "source", "normalization", "window_plan", "paimon",
+ }
+ if experiment.get("schema_version") != "act-benchmark-experiment@1":
+ raise ValueError("Unsupported ACT benchmark experiment schema.")
+ missing = required - set(experiment)
+ if missing:
+ raise ValueError(
+ "Resolved ACT experiment is missing: %s."
+ % ", ".join(sorted(missing)))
+ source = experiment["source"]
+ if canonical_sha256(source["episodes"]) != source["sha256"]:
+ raise ValueError("ACT experiment source-manifest hash differs.")
+ normalization = experiment["normalization"]
+ if canonical_sha256(normalization["values"]) != normalization["sha256"]:
+ raise ValueError("ACT experiment normalization hash differs.")
+ plan = _window_plan_from_experiment(experiment)
+ if plan.sha256 != experiment["window_plan"]["sha256"]:
+ raise ValueError("ACT experiment window-plan hash differs.")
+ episodes = {
+ item["episode_id"]: item for item in source["episodes"]
+ }
+ try:
+ train = episodes[experiment["train_episode_id"]]
+ validation = episodes[experiment["validation_episode_id"]]
+ except KeyError as error:
+ raise ValueError(
+ "ACT experiment selected episode is absent from the source."
+ ) from error
+ config = BenchmarkConfig(**experiment["config"])
+ expected_plan = build_window_plan(
+ train["frame_count"] - config.action_horizon + 1,
+ validation["frame_count"] - config.action_horizon + 1,
+ config,
+ )
+ if expected_plan.to_dict() != plan.to_dict():
+ raise ValueError(
+ "ACT experiment window plan was not built from its config and "
+ "selected episodes.")
+ expected_sequence = _sample_sequence_sha256(
+ train["episode_id"], validation["episode_id"], plan)
+ if expected_sequence != experiment["window_plan"][
+ "sample_sequence_sha256"]:
+ raise ValueError("ACT experiment sample-sequence hash differs.")
+
+
+def _window_plan_from_experiment(experiment):
+ """Reconstruct immutable logical-window indices from JSON values."""
+ value = experiment["window_plan"]
+ return WindowPlan(
+ seed=value["seed"],
+ measurement_indices=tuple(value["measurement_indices"]),
+ train_indices=tuple(value["train_indices"]),
+ validation_indices=tuple(value["validation_indices"]),
+ )
+
+
+def _hdf5_episodes_from_experiment(input_root, experiment):
+ """Validate HDF5 episode identity and attach manifest frame counts."""
+ discovered =
agilex.discover_episodes(Path(input_root).expanduser().resolve())
+ by_id = {episode.episode_id: episode for episode in discovered}
+ expected = experiment["source"]["episodes"]
+ actual_identity = sorted(({
+ "episode_id": episode.episode_id,
+ "source_key": episode.source_key,
+ "split": episode.split,
+ "success": episode.success,
+ } for episode in discovered), key=lambda item: item["episode_id"])
+ expected_identity = [{
+ "episode_id": item["episode_id"],
+ "source_key": item["source_key"],
+ "split": item["split"],
+ "success": item["success"],
+ } for item in expected]
+ if actual_identity != expected_identity:
+ raise ValueError("HDF5 source differs from the ACT experiment.")
+ return [
+ _BenchmarkEpisode(
+ path=by_id[item["episode_id"]].path,
+ source_key=item["source_key"],
+ episode_id=item["episode_id"],
+ split=item["split"],
+ success=item["success"],
+ frame_count=item["frame_count"],
+ )
+ for item in expected
+ ]
+
+
+def _paimon_factory_from_experiment(
+ warehouse, experiment, normalization, config):
+ """Validate Paimon source/statistics and return a pinned dataset
factory."""
+ warehouse = Path(warehouse).expanduser().resolve()
+ paimon = experiment["paimon"]
+ connection = pmm.connect(
+ database=paimon["database"],
+ options={"warehouse": str(warehouse)},
+ )
+ frames = connection.get_table(paimon["frames_table"])
+ snapshot_id = paimon["frames_snapshot_id"]
+ expected_episodes = experiment["source"]["episodes"]
+ actual_episodes = sorted(_episode_rows(connection),
+ key=lambda item: item["episode_id"])
+ if actual_episodes != expected_episodes:
+ raise ValueError("Paimon source differs from the ACT experiment.")
+ row = statistics_row(connection, experiment["statistics_version"])
+ expected_normalization = experiment["normalization"]
+ action_mean = np.asarray(row["action_mean"], dtype=np.float32)
+ action_std = np.asarray(row["action_std"], dtype=np.float32)
+ if (
+ row["source_snapshot_id"] != snapshot_id
+ or row["source_split"] != "train"
+ or row["frame_count"] != expected_normalization["frame_count"]
+ or row["feature_name"] != "action"
+ or row["standard_deviation_floor"] != 1e-2
+ or not np.array_equal(
+ action_mean, normalization["action_mean"])
+ or not np.array_equal(action_std, normalization["action_std"])):
+ raise ValueError(
+ "Paimon normalization differs from the ACT experiment.")
+
+ def factory():
+ return create_paimon_datasets(
+ frames,
+ snapshot_id,
+ experiment["train_episode_id"],
+ experiment["validation_episode_id"],
+ normalization,
+ config,
+ )
+
+ return factory, {
+ "warehouse": str(warehouse),
+ "database": paimon["database"],
+ "frames_table": paimon["frames_table"],
+ "frames_snapshot_id": snapshot_id,
+ }
+
+
+def _tensor_fingerprint(datasets, plan):
+ """Hash the exact planned sample IDs and tensors outside timed
execution."""
+ comparisons = (
+ ("train", datasets[0],
+ sorted(set(plan.measurement_indices + plan.train_indices))),
+ ("validation", datasets[1],
+ sorted(set(plan.validation_indices))),
+ )
+ digest = hashlib.sha256()
+ count = 0
+ for split, dataset, indices in comparisons:
+ for index in indices:
+ sample = dataset[index]
+ identity = {
+ "split": split,
+ "index": index,
+ "sample_id": sample["sample_id"],
+ "episode_id": sample["episode_id"],
+ "frame_index": sample["frame_index"],
+ }
+ digest.update(json.dumps(
+ identity, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8"))
+ for name in ("qpos", "action", "images", "is_pad"):
+ tensor = sample[name].detach().cpu().contiguous()
+ digest.update(name.encode("utf-8"))
+ digest.update(str(tensor.dtype).encode("ascii"))
+ digest.update(str(tuple(tensor.shape)).encode("ascii"))
+ digest.update(tensor.numpy().tobytes())
+ count += 1
+ return {
+ "sha256": digest.hexdigest(),
+ "checked_window_count": count,
+ "fields": [
+ "sample_id", "episode_id", "frame_index", "qpos", "action",
+ "images", "is_pad",
+ ],
+ }
+
+
+def _shared_normalization(
+ episodes,
+ connection,
+ frames_snapshot_id,
+ statistics_version):
+ """Build one train-only normalization contract for both backends.
+
+ HDF5 supplies state moments and float64-accumulated moments over canonical
+ float32 actions from successful train episodes. Versioned Paimon action
+ statistics must match those moments, train scope, frame count, source
+ snapshot, feature name, and ``1e-2`` standard-deviation floor.
+
+ Returns:
+ ``(arrays, metadata)`` where arrays are float32 training values and
+ metadata is JSON-compatible and includes their canonical SHA-256.
+ """
+ normalization, hdf5_metadata = compute_hdf5_normalization(episodes)
+ action_mean = hdf5_metadata["action_mean"]
+ action_std = hdf5_metadata["action_std"]
+ action_count = hdf5_metadata["frame_count"]
+ row = statistics_row(connection, statistics_version)
+ if row["source_snapshot_id"] != frames_snapshot_id:
+ raise ValueError(
+ "Normalization source snapshot %s differs from frames "
+ "snapshot %s."
+ % (row["source_snapshot_id"], frames_snapshot_id))
+ if row["source_split"] != "train" or row["frame_count"] != action_count:
+ raise ValueError(
+ "Versioned action normalization has the wrong train scope.")
+ if row["feature_name"] != "action":
+ raise ValueError("Versioned normalization feature must be action.")
+ if row["standard_deviation_floor"] != 1e-2:
+ raise ValueError(
+ "Versioned normalization must use the 1e-2 std floor.")
+ stored_mean = np.asarray(row["action_mean"], dtype=np.float64)
+ stored_std = np.asarray(row["action_std"], dtype=np.float64)
+ if not (
+ np.allclose(stored_mean, action_mean, rtol=1e-10, atol=1e-10)
+ and np.allclose(stored_std, action_std, rtol=1e-10, atol=1e-10)):
+ raise ValueError(
+ "Versioned Paimon action normalization differs from HDF5 source.")
+ normalization["action_mean"] = stored_mean.astype(np.float32)
+ normalization["action_std"] = stored_std.astype(np.float32)
+ serializable = {
+ name: value.tolist() for name, value in normalization.items()
+ }
+ digest = hashlib.sha256(json.dumps(
+ serializable, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8")).hexdigest()
+ return normalization, {
+ "statistics_version": statistics_version,
+ "source_split": "train",
+ "frame_count": action_count,
+ "standard_deviation_floor": 1e-2,
+ "values": serializable,
+ "sha256": digest,
+ }
+
+
+def _episode_rows(connection):
+ return connection.get_table(agilex.EPISODES_TABLE).scan().select([
+ "episode_id",
+ "source_key",
+ "split",
+ "success",
+ "frame_count",
+ ]).to_list()
+
+
+def _validate_source_identity(episodes, rows):
+ """Match HDF5 discovery to Paimon episodes and return a manifest hash.
+
+ Episode ID, source key, split, and success must match exactly. Paimon's
+ versioned episode rows contribute frame counts used to build complete
+ windows. The returned records retain the local HDF5 paths while the hash
+ covers only portable source metadata.
+ """
+ expected = {
+ item.episode_id: {
+ "episode_id": item.episode_id,
+ "source_key": item.source_key,
+ "split": item.split,
+ "success": item.success,
+ }
+ for item in episodes
+ }
+ actual = {
+ item["episode_id"]: {
+ "episode_id": item["episode_id"],
+ "source_key": item["source_key"],
+ "split": item["split"],
+ "success": item["success"],
+ }
+ for item in rows
+ }
+ if actual != expected or len(actual) != len(rows):
+ raise ValueError(
+ "HDF5 and Paimon source identity differ; rebuild or select "
+ "matching inputs.")
+ rows_by_id = {item["episode_id"]: item for item in rows}
+ enriched = [
+ _BenchmarkEpisode(
+ path=item.path,
+ source_key=item.source_key,
+ episode_id=item.episode_id,
+ split=item.split,
+ success=item.success,
+ frame_count=rows_by_id[item.episode_id]["frame_count"],
+ )
+ for item in episodes
+ ]
+ manifest = sorted([
+ {
+ "episode_id": item.episode_id,
+ "source_key": item.source_key,
+ "split": item.split,
+ "success": item.success,
+ "frame_count": rows_by_id[item.episode_id]["frame_count"],
+ }
+ for item in episodes
+ ], key=lambda item: item["episode_id"])
+ payload = json.dumps(manifest, sort_keys=True, separators=(",", ":"))
+ return enriched, hashlib.sha256(payload.encode("utf-8")).hexdigest()
+
+
+def _select_episode(source_by_id, split, requested, action_horizon):
+ """Select a successful, split-matching episode long enough for one window.
+
+ An explicit episode is honored when eligible; otherwise the
+ lexicographically first eligible episode ID is selected.
+ """
+ eligible = {
+ episode_id: episode
+ for episode_id, episode in source_by_id.items()
+ if episode.split == split
+ and episode.success
+ and episode.frame_count >= action_horizon
+ }
+ if not eligible:
+ raise ValueError(
+ "No successful %s episode is long enough for horizon %d."
+ % (split, action_horizon))
+ selected = requested or min(eligible)
+ if selected not in eligible:
+ raise ValueError(
+ "Requested %s episode is missing, unsuccessful, or too short: %s."
+ % (split, selected))
+ return eligible[selected]
+
+
+def _summarize(runs):
+ """Return median, minimum, and maximum metrics across backend repeats."""
+ metrics = (
+ "dataset_build_s",
+ "first_batch_s",
+ "batch_fetch_samples_per_s",
+ "fixed_steps_s",
+ "validation_loss",
+ "python_peak_allocated_bytes",
+ "wall_time_s",
+ )
+ result = {"round_count": len(runs)}
+ for name in metrics:
+ values = [item[name] for item in runs]
+ result[name] = {
+ "median": float(np.median(values)),
+ "min": float(np.min(values)),
+ "max": float(np.max(values)),
+ }
+ return result
+
+
+def _sample_sequence_sha256(train_episode_id, validation_episode_id, plan):
+ """Hash episode-qualified sample IDs in measurement/train/validation
order."""
+ value = {
+ "batch_fetch": [
+ "%s#%d" % (train_episode_id, index)
+ for index in plan.measurement_indices
+ ],
+ "train": [
+ "%s#%d" % (train_episode_id, index)
+ for index in plan.train_indices
+ ],
+ "validation": [
+ "%s#%d" % (validation_episode_id, index)
+ for index in plan.validation_indices
+ ],
+ }
+ return hashlib.sha256(json.dumps(
+ value, sort_keys=True, separators=(",", ":")
+ ).encode("utf-8")).hexdigest()
+
+
+def _git_head(repository):
+ try:
+ return subprocess.check_output(
+ ["git", "-C", str(repository), "rev-parse", "HEAD"],
+ stderr=subprocess.DEVNULL,
+ universal_newlines=True,
+ ).strip()
+ except (OSError, subprocess.CalledProcessError):
+ return "UNKNOWN"
+
+
+def _runtime_environment(repository):
+ """Return dependency, CPU, thread, and source identity for comparison."""
+ source_commit = _git_head(repository)
+ package_build = build_info.full_version()
+ if source_commit == "UNKNOWN" and package_build == "UNKNOWN":
+ raise RuntimeError(
+ "ACT benchmark cannot determine its source identity.")
+ return {
+ "python": platform.python_version(),
+ "os": platform.platform(),
+ "machine": platform.machine(),
+ "cpu_identity": _cpu_identity(),
+ "cpu_count": os.cpu_count() or 1,
+ "torch_threads": torch.get_num_threads(),
+ "torch_interop_threads": torch.get_num_interop_threads(),
+ "pypaimon_build": package_build,
+ "numpy": np.__version__,
+ "pyarrow": pa.__version__,
+ "h5py": h5py.__version__,
+ "pillow": PIL.__version__,
+ "torch": torch.__version__,
+ "source_commit": source_commit,
+ }
+
+
+def _cpu_identity():
+ """Return the most specific CPU model available from the local OS."""
+ if platform.system() == "Darwin":
+ try:
+ return subprocess.check_output(
+ ["sysctl", "-n", "machdep.cpu.brand_string"],
+ stderr=subprocess.DEVNULL,
+ universal_newlines=True,
+ ).strip()
+ except (OSError, subprocess.CalledProcessError):
+ pass
+ identity = platform.processor().strip()
+ if identity:
+ return identity
+ if platform.system() == "Linux":
+ try:
+ for line in Path("/proc/cpuinfo").read_text().splitlines():
+ if line.startswith(("model name", "Hardware")):
+ return line.partition(":")[2].strip()
+ except OSError:
+ pass
+ return platform.machine()
+
+
+def _command_argv():
+ """Return the invoked Python basename and command-line arguments."""
+ import sys
+ return [os.path.basename(sys.executable)] + list(sys.argv)
+
+
+def _utc_now():
+ return datetime.now(timezone.utc).isoformat(
+ timespec="seconds").replace("+00:00", "Z")
diff --git a/paimon-python/pypaimon/sample/robomind_agilex.py
b/paimon-python/pypaimon/sample/robomind_agilex.py
index 5a4b83a170..03805914a3 100644
--- a/paimon-python/pypaimon/sample/robomind_agilex.py
+++ b/paimon-python/pypaimon/sample/robomind_agilex.py
@@ -37,7 +37,6 @@ DEFAULT_STATISTICS_VERSION =
"robomind-agilex-joint-position@1"
TABLE_OPTIONS = {
"deletion-vectors.enabled": "true",
"blob-as-descriptor": "false",
- "vector.file.format": "vortex",
}
NUMERIC_FIELDS = (
@@ -59,12 +58,27 @@ NUMERIC_FIELDS = (
("action_joint_velocity_right", "master/joint_velocity_right"),
)
IMAGE_FIELDS = (
- ("rgb_front", "observations/rgb_images/camera_front"),
- ("rgb_left_wrist", "observations/rgb_images/camera_left_wrist"),
- ("rgb_right_wrist", "observations/rgb_images/camera_right_wrist"),
- ("depth_front", "observations/depth_images/camera_front"),
- ("depth_left_wrist", "observations/depth_images/camera_left_wrist"),
- ("depth_right_wrist", "observations/depth_images/camera_right_wrist"),
+ ("observation_images_rgb_front", "observations/rgb_images/camera_front"),
+ (
+ "observation_images_rgb_wrist_left",
+ "observations/rgb_images/camera_left_wrist",
+ ),
+ (
+ "observation_images_rgb_wrist_right",
+ "observations/rgb_images/camera_right_wrist",
+ ),
+ (
+ "observation_images_depth_front",
+ "observations/depth_images/camera_front",
+ ),
+ (
+ "observation_images_depth_wrist_left",
+ "observations/depth_images/camera_left_wrist",
+ ),
+ (
+ "observation_images_depth_wrist_right",
+ "observations/depth_images/camera_right_wrist",
+ ),
)
_ACTION_LEFT = "action_joint_position_left"
diff --git a/paimon-python/pypaimon/tests/act_benchmark_test.py
b/paimon-python/pypaimon/tests/act_benchmark_test.py
new file mode 100644
index 0000000000..9f9fb4357c
--- /dev/null
+++ b/paimon-python/pypaimon/tests/act_benchmark_test.py
@@ -0,0 +1,208 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+
+import pytest
+
+from pypaimon.benchmark.act.compare import (
+ canonical_sha256,
+ compare_results,
+ load_result_documents,
+)
+from pypaimon.benchmark.act.experiment import load_experiment
+
+
+def test_packaged_experiment_contains_a_valid_benchmark_config():
+ _require_act_runtime()
+ from pypaimon.benchmark.act.harness import BenchmarkConfig
+
+ experiment = load_experiment()
+
+ assert experiment["schema_version"] == "act-benchmark-experiment@1"
+ assert experiment["benchmark_id"] == "robomind-act"
+ assert BenchmarkConfig(**experiment["config"]).to_dict() == (
+ experiment["config"])
+ assert experiment["statistics_version"] == (
+ "robomind-agilex-joint-position@1")
+
+
+def test_compare_reports_ratio_for_matching_backend_results():
+ experiment = {"schema_version": "act-benchmark-experiment@1"}
+ environment = {"python": "3.10", "machine": "arm64"}
+ hdf5 = _result("hdf5", experiment, environment, throughput=10.0)
+ paimon = _result("paimon", experiment, environment, throughput=15.0)
+ hdf5["summary"]["first_batch_s"] = {
+ "median": 2.0, "min": 2.0, "max": 2.0}
+ paimon["summary"]["first_batch_s"] = {
+ "median": 1.0, "min": 1.0, "max": 1.0}
+
+ comparison = compare_results([hdf5, paimon])
+
+ assert comparison["status"] == "SUCCEEDED"
+ assert len(comparison["experiments"]) == 1
+ group = comparison["experiments"][0]
+ assert group["backends"] == ["hdf5", "paimon"]
+ assert group["metrics"]["batch_fetch_samples_per_s"] == {
+ "hdf5": 10.0,
+ "paimon": 15.0,
+ "paimon_over_hdf5": 1.5,
+ "preferred": "higher",
+ }
+ assert group["metrics"]["first_batch_s"] == {
+ "hdf5": 2.0,
+ "paimon": 1.0,
+ "hdf5_over_paimon": 2.0,
+ "preferred": "lower",
+ }
+
+
+def test_compare_rejects_different_tensor_fingerprints():
+ experiment = {"schema_version": "act-benchmark-experiment@1"}
+ environment = {"python": "3.10", "machine": "arm64"}
+ hdf5 = _result("hdf5", experiment, environment, throughput=10.0)
+ paimon = _result("paimon", experiment, environment, throughput=15.0)
+ paimon["tensor_fingerprint"]["sha256"] = "different"
+
+ comparison = compare_results([hdf5, paimon])
+
+ assert comparison["status"] == "FAILED"
+ group = comparison["experiments"][0]
+ assert group["status"] == "FAILED"
+ assert group["reason"] == "tensor fingerprints differ"
+ assert group["metrics"] == {}
+
+
+def test_compare_requires_results_from_both_backends():
+ experiment = {"schema_version": "act-benchmark-experiment@1"}
+ environment = {"python": "3.10", "machine": "arm64"}
+
+ comparison = compare_results([
+ _result("hdf5", experiment, environment, throughput=10.0),
+ ])
+
+ assert comparison["status"] == "INCOMPATIBLE"
+ group = comparison["experiments"][0]
+ assert group["status"] == "INCOMPATIBLE"
+ assert group["reason"] == "both hdf5 and paimon results are required"
+ assert group["metrics"] == {}
+
+
+def test_load_results_combines_explicit_files_and_directory(tmp_path):
+ experiment = {"schema_version": "act-benchmark-experiment@1"}
+ environment = {"python": "3.10", "machine": "arm64"}
+ hdf5_path = tmp_path / "hdf5.json"
+ paimon_path = tmp_path / "paimon.json"
+ ignored_path = tmp_path / "experiment.json"
+ hdf5_path.write_text(json.dumps(
+ _result("hdf5", experiment, environment, throughput=10.0)))
+ paimon_path.write_text(json.dumps(
+ _result("paimon", experiment, environment, throughput=15.0)))
+ ignored_path.write_text(json.dumps(experiment))
+
+ results = load_result_documents(
+ [hdf5_path], results_dir=tmp_path)
+
+ assert [result["backend"] for result in results] == ["hdf5", "paimon"]
+
+
+def test_compare_groups_multiple_experiments_without_cross_comparing():
+ environment = {"python": "3.10", "machine": "arm64"}
+ results = []
+ for seed in (1, 2):
+ experiment = {
+ "schema_version": "act-benchmark-experiment@1",
+ "config": {"seed": seed},
+ }
+ results.extend([
+ _result("hdf5", experiment, environment, throughput=10.0),
+ _result("paimon", experiment, environment, throughput=15.0),
+ ])
+
+ comparison = compare_results(results)
+
+ assert comparison["status"] == "SUCCEEDED"
+ assert len(comparison["experiments"]) == 2
+ assert all(
+ group["backends"] == ["hdf5", "paimon"]
+ for group in comparison["experiments"]
+ )
+
+
+def test_compare_reports_incompatible_runtime_environments():
+ experiment = {"schema_version": "act-benchmark-experiment@1"}
+ hdf5 = _result(
+ "hdf5", experiment,
+ {"python": "3.10", "machine": "arm64"}, throughput=10.0)
+ paimon = _result(
+ "paimon", experiment,
+ {"python": "3.11", "machine": "arm64"}, throughput=15.0)
+
+ comparison = compare_results([hdf5, paimon])
+
+ assert comparison["status"] == "INCOMPATIBLE"
+ group = comparison["experiments"][0]
+ assert group["reason"] == "runtime environments differ"
+ assert len(group["environment_sha256s"]) == 2
+ assert group["metrics"] == {}
+
+
+def test_compare_rejects_tampered_result_experiment_hash():
+ experiment = {"schema_version": "act-benchmark-experiment@1"}
+ result = _result(
+ "hdf5",
+ experiment,
+ {"python": "3.10", "machine": "arm64"},
+ throughput=10.0,
+ )
+ result["experiment_sha256"] = "tampered"
+
+ with pytest.raises(ValueError, match="experiment SHA-256 differs"):
+ compare_results([result])
+
+
+def _require_act_runtime():
+ pytest.importorskip("torch")
+ pytest.importorskip("PIL.Image")
+
+
+def _result(backend, experiment, environment, throughput):
+ return {
+ "schema_version": "act-benchmark-result@1",
+ "status": "SUCCEEDED",
+ "backend": backend,
+ "experiment": experiment,
+ "experiment_sha256": canonical_sha256(experiment),
+ "environment": environment,
+ "model": {"implementation": "test-policy", "parameter_count": 1},
+ "tensor_fingerprint": {
+ "sha256": "same-tensors",
+ "checked_window_count": 2,
+ },
+ "runs": [{
+ "round": round_number,
+ "train_loss": [1.0, 0.5],
+ "validation_loss": 0.25,
+ } for round_number in range(1, 4)],
+ "summary": {
+ "round_count": 3,
+ "batch_fetch_samples_per_s": {
+ "median": throughput,
+ "min": throughput,
+ "max": throughput,
+ },
+ },
+ }
diff --git a/paimon-python/pypaimon/tests/act_runner_test.py
b/paimon-python/pypaimon/tests/act_runner_test.py
new file mode 100644
index 0000000000..57d64bf8c1
--- /dev/null
+++ b/paimon-python/pypaimon/tests/act_runner_test.py
@@ -0,0 +1,709 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# ruff: noqa: E402
+
+import json
+import tracemalloc
+from io import BytesIO
+from types import SimpleNamespace
+from unittest.mock import patch
+
+import numpy as np
+import pytest
+
+
+torch = pytest.importorskip("torch")
+Image = pytest.importorskip("PIL.Image")
+h5py = pytest.importorskip("h5py")
+
+import pypaimon.multimodal as pmm
+import pypaimon.multimodal.window_dataset as window_dataset
+import pypaimon.benchmark.act.harness as act_harness
+import pypaimon.benchmark.act.__main__ as act_cli
+import pypaimon.benchmark.act.runner as act_runner
+from pypaimon.benchmark.act.runner import (
+ BenchmarkConfig,
+ prepare_experiment,
+ run_experiment,
+)
+from pypaimon.benchmark.act.experiment import load_experiment
+from pypaimon.benchmark.act.compare import canonical_sha256, compare_results
+from pypaimon.benchmark.act.harness import build_window_plan, run_backend
+from pypaimon.benchmark.act.hdf5 import Hdf5ACTWindowDataset
+from pypaimon.benchmark.act.paimon import (
+ ACTION_COLUMNS,
+ IMAGE_COLUMNS,
+ QPOS_COLUMNS,
+ create_datasets as create_paimon_datasets,
+ latest_snapshot_id,
+)
+from pypaimon.multimodal.window_dataset import ContiguousWindowDataset
+from pypaimon.sample import robomind_agilex as agilex
+
+
+def test_logical_batches_coalesce_one_physical_fetch():
+ class BatchDataset:
+ def __init__(self):
+ self.calls = []
+
+ def __getitems__(self, indices):
+ self.calls.append(list(indices))
+ return [{"value": torch.tensor(index)} for index in indices]
+
+ dataset = BatchDataset()
+
+ batches = list(act_harness._iter_logical_batches(
+ dataset,
+ tuple(range(8)),
+ logical_batch_size=2,
+ fetch_batches=4,
+ ))
+
+ assert dataset.calls == [list(range(8))]
+ assert [batch["value"].tolist() for batch in batches] == [
+ [0, 1], [2, 3], [4, 5], [6, 7],
+ ]
+
+
+def test_logical_batches_reject_incomplete_batch_tail():
+ class BatchDataset:
+ def __getitems__(self, indices):
+ return [{"value": torch.tensor(index)} for index in indices]
+
+ with pytest.raises(ValueError, match="complete logical batches"):
+ list(act_harness._iter_logical_batches(
+ BatchDataset(),
+ tuple(range(9)),
+ logical_batch_size=2,
+ fetch_batches=4,
+ ))
+
+
+def test_backend_times_without_tracemalloc_and_measures_memory_separately():
+ states = []
+ config = BenchmarkConfig(
+ seed=11,
+ action_horizon=1,
+ batch_size=1,
+ optimizer_steps=1,
+ image_height=2,
+ image_width=2,
+ warmup_batches=1,
+ timed_batches=1,
+ rounds=3,
+ )
+
+ class TracingDataset(torch.utils.data.Dataset):
+ def __len__(self):
+ return 2
+
+ def __getitem__(self, index):
+ states.append(tracemalloc.is_tracing())
+ return {
+ "sample_id": "episode-a#%d" % index,
+ "episode_id": "episode-a",
+ "frame_index": index,
+ "qpos": torch.zeros(14),
+ "action": torch.zeros((1, 14)),
+ "images": torch.zeros((3, 3, 2, 2)),
+ "is_pad": torch.zeros(1, dtype=torch.bool),
+ }
+
+ dataset = TracingDataset()
+ plan = build_window_plan(len(dataset), len(dataset), config)
+ result = run_backend(
+ "test",
+ 1,
+ lambda: (dataset, dataset),
+ plan,
+ config,
+ "sequence-sha256",
+ policy_factory=_policy_factory,
+ )
+
+ assert states[0] is False
+ assert states[-1] is True
+ assert result["peak_memory_measurement"] == (
+ "python-tracemalloc-separate-dataset-first-batch")
+
+
+def test_backend_coalesces_timed_batch_fetches():
+ config = BenchmarkConfig(
+ seed=11,
+ action_horizon=1,
+ batch_size=2,
+ optimizer_steps=1,
+ image_height=2,
+ image_width=2,
+ warmup_batches=1,
+ timed_batches=4,
+ fetch_batches=4,
+ rounds=3,
+ )
+
+ clock = SimpleNamespace(value=0.0)
+
+ class BatchDataset(torch.utils.data.Dataset):
+ def __init__(self):
+ self.calls = []
+
+ def __len__(self):
+ return 16
+
+ def __getitem__(self, index):
+ return {
+ "sample_id": "episode-a#%d" % index,
+ "episode_id": "episode-a",
+ "frame_index": index,
+ "qpos": torch.zeros(14),
+ "action": torch.zeros((1, 14)),
+ "images": torch.zeros((3, 3, 2, 2)),
+ "is_pad": torch.zeros(1, dtype=torch.bool),
+ }
+
+ def __getitems__(self, indices):
+ clock.value += 0.25
+ self.calls.append(list(indices))
+ return [self[index] for index in indices]
+
+ dataset = BatchDataset()
+ plan = build_window_plan(len(dataset), len(dataset), config)
+
+ def validate(_batch, _config):
+ clock.value += 1.0
+
+ with (
+ patch.object(act_harness, "_measure_python_peak", return_value=0),
+ patch.object(
+ act_harness.time, "monotonic", side_effect=lambda: clock.value),
+ patch.object(act_harness, "validate_act_batch", side_effect=validate),
+ ):
+ result = run_backend(
+ "test",
+ 1,
+ lambda: (dataset, dataset),
+ plan,
+ config,
+ "sequence-sha256",
+ policy_factory=_policy_factory,
+ )
+
+ assert dataset.calls == [
+ list(plan.measurement_indices[:2]),
+ list(plan.measurement_indices[2:10]),
+ list(plan.train_indices),
+ list(plan.validation_indices),
+ ]
+ assert result["batch_fetch_s"] == 0.25
+
+
+def _jpeg(value):
+ buffer = BytesIO()
+ Image.fromarray(np.full((8, 10, 3), value, dtype=np.uint8)).save(
+ buffer, format="JPEG")
+ return np.frombuffer(buffer.getvalue(), dtype=np.uint8)
+
+
+def _write_episode(root, split, name, offset, frames=6):
+ path = (root / "13_packbowl" / "success_episodes" / split / name
+ / "data" / "trajectory.hdf5")
+ path.parent.mkdir(parents=True)
+ with h5py.File(path, "w") as h5:
+ h5.create_dataset("language_raw", data=[b"pack the bowl"])
+ h5.create_dataset(
+ "language_distilbert",
+ data=np.zeros((1, 1, 768), dtype=np.float16),
+ )
+ for index, (_, hdf5_path) in enumerate(agilex.NUMERIC_FIELDS):
+ values = np.arange(frames * 7, dtype=np.float64).reshape(frames, 7)
+ h5.create_dataset(hdf5_path, data=values + offset + index * 100)
+ variable = h5py.vlen_dtype(np.dtype("uint8"))
+ for image_index, (_, hdf5_path) in enumerate(agilex.IMAGE_FIELDS):
+ dataset = h5.create_dataset(hdf5_path, (frames,), dtype=variable)
+ for frame_index in range(frames):
+ dataset[frame_index] = _jpeg(
+ offset + image_index + frame_index)
+ return path
+
+
[email protected]
+def benchmark_input(tmp_path, monkeypatch):
+ root = tmp_path / "input"
+ _write_episode(root, "train", "train-a", 1)
+ _write_episode(root, "train", "train-b", 11.1)
+ _write_episode(root, "val", "val-a", 21)
+ warehouse = tmp_path / "warehouse"
+ monkeypatch.setattr(agilex, "TABLE_OPTIONS", {
+ **agilex.TABLE_OPTIONS,
+ "vector.file.format": "parquet",
+ })
+ agilex.ingest_local(root, warehouse, batch_size=2)
+ agilex.backfill_canonical_action(
+ warehouse, statistics_version="act-test@1")
+ return root, warehouse
+
+
+class _Policy(torch.nn.Module):
+
+ def __init__(self):
+ super().__init__()
+ self.scale = torch.nn.Parameter(torch.tensor(0.0))
+
+ def forward(self, batch):
+ assert self.training
+ target = batch["action"].mean() + batch["observation.state"].mean()
+ loss = (self.scale - target).square()
+ return loss, {
+ "l1_loss": loss.detach(),
+ "kld_loss": torch.tensor(0.0),
+ }
+
+
+def _policy_factory(config):
+ return _Policy(), {
+ "implementation": "test-policy",
+ "chunk_size": config.action_horizon,
+ "parameter_count": 1,
+ }
+
+
+def test_prepare_writes_resolved_experiment(benchmark_input, tmp_path):
+ input_root, warehouse = benchmark_input
+ definition = load_experiment()
+ definition["statistics_version"] = "act-test@1"
+ definition["config"].update({
+ "seed": 17,
+ "action_horizon": 3,
+ "batch_size": 2,
+ "optimizer_steps": 2,
+ "image_height": 8,
+ "image_width": 10,
+ "timed_batches": 2,
+ })
+ output = tmp_path / "experiment.json"
+
+ experiment = prepare_experiment(
+ input_root, warehouse, output, definition=definition)
+
+ assert json.loads(output.read_text()) == experiment
+ assert experiment["schema_version"] == "act-benchmark-experiment@1"
+ assert experiment["train_episode_id"] == "train-a"
+ assert experiment["validation_episode_id"] == "val-a"
+ assert experiment["source"]["episodes"][0]["frame_count"] == 6
+ assert len(experiment["source"]["sha256"]) == 64
+ assert len(experiment["normalization"]["sha256"]) == 64
+ assert len(experiment["window_plan"]["sha256"]) == 64
+ assert experiment["paimon"]["frames_snapshot_id"] > 0
+
+
+def test_independent_backend_results_preserve_tensor_and_loss_parity(
+ benchmark_input, tmp_path):
+ input_root, warehouse = benchmark_input
+ definition = load_experiment()
+ definition["statistics_version"] = "act-test@1"
+ definition["config"].update({
+ "seed": 17,
+ "action_horizon": 3,
+ "batch_size": 2,
+ "optimizer_steps": 2,
+ "image_height": 8,
+ "image_width": 10,
+ "timed_batches": 2,
+ })
+ experiment_path = tmp_path / "experiment.json"
+ prepare_experiment(
+ input_root, warehouse, experiment_path, definition=definition)
+
+ hdf5_path = tmp_path / "hdf5-result.json"
+ events = []
+ real_run_backend = act_runner.run_backend
+ real_tensor_fingerprint = act_runner._tensor_fingerprint
+
+ def record_run(*args, **kwargs):
+ events.append("round")
+ return real_run_backend(*args, **kwargs)
+
+ def record_fingerprint(*args, **kwargs):
+ events.append("fingerprint")
+ return real_tensor_fingerprint(*args, **kwargs)
+
+ with (
+ patch.object(act_runner, "run_backend", side_effect=record_run),
+ patch.object(
+ act_runner,
+ "_tensor_fingerprint",
+ side_effect=record_fingerprint,
+ ),
+ ):
+ hdf5_result = run_experiment(
+ "hdf5",
+ experiment_path,
+ hdf5_path,
+ input_root=input_root,
+ policy_factory=_policy_factory,
+ )
+ assert events == ["round"] * definition["config"]["rounds"] + [
+ "fingerprint"]
+ paimon_path = tmp_path / "paimon-result.json"
+ paimon_result = run_experiment(
+ "paimon",
+ experiment_path,
+ paimon_path,
+ warehouse=warehouse,
+ policy_factory=_policy_factory,
+ )
+
+ assert json.loads(hdf5_path.read_text()) == hdf5_result
+ assert json.loads(paimon_path.read_text()) == paimon_result
+ assert hdf5_result["schema_version"] == "act-benchmark-result@1"
+ assert paimon_result["schema_version"] == "act-benchmark-result@1"
+ assert hdf5_result["experiment"] == paimon_result["experiment"]
+ assert hdf5_result["tensor_fingerprint"] == (
+ paimon_result["tensor_fingerprint"])
+ assert [run["train_loss"] for run in hdf5_result["runs"]] == [
+ run["train_loss"] for run in paimon_result["runs"]
+ ]
+ assert [run["validation_loss"] for run in hdf5_result["runs"]] == [
+ run["validation_loss"] for run in paimon_result["runs"]
+ ]
+ comparison = compare_results([hdf5_result, paimon_result])
+ assert comparison["status"] == "SUCCEEDED"
+ assert comparison["experiments"][0]["backends"] == ["hdf5", "paimon"]
+
+
+def test_backends_match_the_golden_act_window_contract(benchmark_input):
+ input_root, warehouse = benchmark_input
+ normalization = {
+ "qpos_mean": np.zeros(14, dtype=np.float32),
+ "qpos_std": np.ones(14, dtype=np.float32),
+ "action_mean": np.zeros(14, dtype=np.float32),
+ "action_std": np.ones(14, dtype=np.float32),
+ }
+ hdf5 = Hdf5ACTWindowDataset(
+ SimpleNamespace(
+ path=(input_root / "13_packbowl" / "success_episodes" / "train"
+ / "train-a" / "data" / "trajectory.hdf5"),
+ episode_id="train-a",
+ frame_count=6,
+ ),
+ normalization,
+ action_horizon=3,
+ )
+ connection = pmm.connect(
+ database=agilex.DEFAULT_DATABASE,
+ options={"warehouse": str(warehouse)},
+ )
+ frames = connection.get_table(agilex.FRAMES_TABLE)
+ paimon, _ = create_paimon_datasets(
+ frames,
+ latest_snapshot_id(frames),
+ "train-a",
+ "val-a",
+ normalization,
+ BenchmarkConfig(action_horizon=3),
+ )
+
+ expected = hdf5[1]
+ actual = paimon[1]
+
+ assert set(expected) == {
+ "sample_id", "episode_id", "frame_index", "qpos", "action",
+ "images", "is_pad",
+ }
+ assert expected["sample_id"] == "train-a#1"
+ assert expected["episode_id"] == "train-a"
+ assert expected["frame_index"] == 1
+ assert torch.equal(expected["qpos"], torch.tensor(
+ list(range(408, 415)) + list(range(508, 515)),
+ dtype=torch.float32,
+ ))
+ assert torch.equal(expected["action"], torch.tensor([
+ list(range(1208, 1215)) + list(range(1308, 1315)),
+ list(range(1215, 1222)) + list(range(1315, 1322)),
+ list(range(1222, 1229)) + list(range(1322, 1329)),
+ ], dtype=torch.float32))
+ assert torch.allclose(
+ expected["images"][:, :, 0, 0],
+ torch.tensor([[2 / 255] * 3, [3 / 255] * 3, [4 / 255] * 3]),
+ )
+ assert not expected["is_pad"].any()
+ for name in ("qpos", "action", "images", "is_pad"):
+ assert torch.equal(expected[name], actual[name])
+ for name in ("sample_id", "episode_id", "frame_index"):
+ assert expected[name] == actual[name]
+
+
+def test_hdf5_window_index_bounds(tmp_path):
+ path = _write_episode(tmp_path, "train", "train-a", 1)
+ dataset = Hdf5ACTWindowDataset(
+ SimpleNamespace(
+ path=path,
+ episode_id="train-a",
+ frame_count=6,
+ ),
+ {
+ "qpos_mean": np.zeros(14, dtype=np.float32),
+ "qpos_std": np.ones(14, dtype=np.float32),
+ "action_mean": np.zeros(14, dtype=np.float32),
+ "action_std": np.ones(14, dtype=np.float32),
+ },
+ action_horizon=3,
+ )
+
+ assert dataset[-1]["sample_id"] == "train-a#3"
+ with pytest.raises(IndexError):
+ dataset[-len(dataset) - 1]
+ with pytest.raises(IndexError):
+ dataset[len(dataset)]
+
+
+def test_paimon_run_rejects_normalization_not_recorded_in_statistics(
+ benchmark_input, tmp_path):
+ input_root, warehouse = benchmark_input
+ definition = load_experiment()
+ definition["statistics_version"] = "act-test@1"
+ definition["config"].update({
+ "action_horizon": 3,
+ "batch_size": 1,
+ "optimizer_steps": 1,
+ "image_height": 8,
+ "image_width": 10,
+ })
+ experiment_path = tmp_path / "experiment.json"
+ experiment = prepare_experiment(
+ input_root, warehouse, experiment_path, definition=definition)
+ experiment["normalization"]["values"]["action_mean"][0] += 1
+ experiment["normalization"]["sha256"] = canonical_sha256(
+ experiment["normalization"]["values"])
+ experiment_path.write_text(json.dumps(experiment))
+
+ with pytest.raises(ValueError, match="normalization differs"):
+ run_experiment(
+ "paimon",
+ experiment_path,
+ tmp_path / "must-not-exist.json",
+ warehouse=warehouse,
+ policy_factory=_policy_factory,
+ )
+
+
+def test_run_rejects_tampered_source_manifest(benchmark_input, tmp_path):
+ input_root, warehouse = benchmark_input
+ definition = load_experiment()
+ definition["statistics_version"] = "act-test@1"
+ definition["config"].update({
+ "action_horizon": 3,
+ "batch_size": 1,
+ "optimizer_steps": 1,
+ "image_height": 8,
+ "image_width": 10,
+ })
+ experiment_path = tmp_path / "experiment.json"
+ experiment = prepare_experiment(
+ input_root, warehouse, experiment_path, definition=definition)
+ experiment["source"]["episodes"][0]["frame_count"] += 1
+ experiment_path.write_text(json.dumps(experiment))
+
+ with pytest.raises(ValueError, match="source-manifest hash differs"):
+ run_experiment(
+ "hdf5",
+ experiment_path,
+ tmp_path / "must-not-exist.json",
+ input_root=input_root,
+ policy_factory=_policy_factory,
+ )
+
+
+def test_run_rejects_config_not_used_to_build_window_plan(
+ benchmark_input, tmp_path):
+ input_root, warehouse = benchmark_input
+ definition = load_experiment()
+ definition["statistics_version"] = "act-test@1"
+ definition["config"].update({
+ "action_horizon": 3,
+ "batch_size": 1,
+ "optimizer_steps": 1,
+ "image_height": 8,
+ "image_width": 10,
+ })
+ experiment_path = tmp_path / "experiment.json"
+ experiment = prepare_experiment(
+ input_root, warehouse, experiment_path, definition=definition)
+ experiment["config"]["seed"] += 1
+ experiment_path.write_text(json.dumps(experiment))
+
+ with pytest.raises(ValueError, match="window plan was not built"):
+ run_experiment(
+ "hdf5",
+ experiment_path,
+ tmp_path / "must-not-exist.json",
+ input_root=input_root,
+ policy_factory=_policy_factory,
+ )
+
+
+def test_paimon_windows_are_lazy_snapshot_pinned_and_vortex_independent(
+ benchmark_input, tmp_path):
+ input_root, warehouse = benchmark_input
+ definition = load_experiment()
+ definition["statistics_version"] = "act-test@1"
+ definition["config"]["action_horizon"] = 3
+ experiment = prepare_experiment(
+ input_root,
+ warehouse,
+ tmp_path / "experiment.json",
+ definition=definition,
+ )
+ normalization = {
+ name: np.asarray(value, dtype=np.float32)
+ for name, value in experiment["normalization"]["values"].items()
+ }
+ connection = pmm.connect(
+ database=agilex.DEFAULT_DATABASE,
+ options={"warehouse": str(warehouse)},
+ )
+ frames = connection.get_table(agilex.FRAMES_TABLE)
+ assert frames.raw_table.table_schema.options["vector.file.format"] == (
+ "parquet")
+ snapshot_id = latest_snapshot_id(frames)
+
+ with patch(
+ "pypaimon.multimodal.window_dataset.fetch_blob_bodies",
+ side_effect=window_dataset.fetch_blob_bodies) as fetch:
+ train, validation = create_paimon_datasets(
+ frames,
+ snapshot_id,
+ "train-a",
+ "val-a",
+ normalization,
+ BenchmarkConfig(
+ action_horizon=3,
+ batch_size=1,
+ optimizer_steps=1,
+ image_height=8,
+ image_width=10,
+ rounds=3,
+ ),
+ )
+ assert fetch.call_count == 0
+ assert isinstance(train, ContiguousWindowDataset)
+ assert isinstance(validation, ContiguousWindowDataset)
+ with patch.object(
+ train, "_read_rows", wraps=train._read_rows) as read_rows:
+ sample_before_append = train[0]
+ assert [call.args[1] for call in read_rows.call_args_list] == [
+ list(ACTION_COLUMNS),
+ list(QPOS_COLUMNS + IMAGE_COLUMNS),
+ ]
+ assert [len(call.args[0]) for call in read_rows.call_args_list] == [3,
1]
+ assert fetch.call_count == 1
+ assert {
+ name: len(fetch.call_args.args[1][name])
+ for name in IMAGE_COLUMNS
+ } == {name: 1 for name in IMAGE_COLUMNS}
+
+ scalar, blobs = frames.scan().where(
+ "episode_id = 'train-a' AND frame_index = 5"
+ ).read_blobs(IMAGE_COLUMNS)
+ appended = scalar.to_pylist()[0]
+ appended["frame_index"] = 6
+ for name in IMAGE_COLUMNS:
+ appended[name] = blobs[name][0]
+ frames.add([appended])
+
+ assert train.snapshot_id == snapshot_id
+ assert validation.snapshot_id == snapshot_id
+ assert latest_snapshot_id(frames) != snapshot_id
+ assert len(train) == 4
+ sample_after_append = train[0]
+ for name in ("qpos", "action", "images", "is_pad"):
+ assert torch.equal(
+ sample_before_append[name], sample_after_append[name])
+
+
+def test_requires_at_least_three_measurement_rounds():
+ with pytest.raises(ValueError, match="rounds must be at least 3"):
+ BenchmarkConfig(rounds=2)
+
+
+def test_fetch_batches_must_be_positive():
+ assert BenchmarkConfig(fetch_batches=4).fetch_batches == 4
+ with pytest.raises(ValueError, match="fetch_batches must be a positive
int"):
+ BenchmarkConfig(fetch_batches=0)
+
+
+def test_cli_exposes_prepare_run_and_compare_contracts(capsys):
+ with pytest.raises(SystemExit):
+ act_cli.main(["prepare", "--help"])
+
+ prepare_help = capsys.readouterr().out
+ assert "--experiment" in prepare_help
+ assert "--fetch-batches" in prepare_help
+
+ with pytest.raises(SystemExit):
+ act_cli.main(["run", "--help"])
+
+ run_help = capsys.readouterr().out
+ assert "--backend" in run_help
+ assert "--experiment" in run_help
+ assert "--results-dir" in run_help
+
+ with pytest.raises(SystemExit):
+ act_cli.main(["compare", "--help"])
+
+ assert "--results-dir" in capsys.readouterr().out
+
+
+def test_cli_requires_python_3_10_or_newer():
+ with pytest.raises(RuntimeError, match="Python 3.10 or newer"):
+ act_cli._require_supported_python((3, 9))
+
+ act_cli._require_supported_python((3, 10))
+
+
+def test_automatic_artifact_paths_do_not_overwrite_same_second(tmp_path):
+ with patch.object(act_cli, "datetime") as now:
+ now.now.return_value.strftime.return_value = "20260901T120000Z"
+
+ first = act_cli._artifact_path(tmp_path, "robomind-act-hdf5")
+ second = act_cli._artifact_path(tmp_path, "robomind-act-hdf5")
+
+ assert first != second
+ assert first.parent == tmp_path
+ assert second.parent == tmp_path
+
+
+def test_runtime_environment_uses_package_identity_outside_git_checkout(
+ tmp_path):
+ with patch.object(act_runner, "_git_head", return_value="UNKNOWN"):
+ environment = act_runner._runtime_environment(tmp_path)
+
+ assert environment["source_commit"] == "UNKNOWN"
+ assert environment["pypaimon_build"] != "UNKNOWN"
+ assert environment["cpu_identity"]
+ assert environment["cpu_count"] > 0
+ assert environment["torch_threads"] > 0
+ assert environment["torch_interop_threads"] > 0
+ assert all(environment[name] for name in (
+ "numpy", "pyarrow", "h5py", "pillow"))
+
+ with (
+ patch.object(act_runner, "_git_head", return_value="UNKNOWN"),
+ patch.object(act_runner.build_info, "full_version",
return_value="UNKNOWN"),
+ pytest.raises(RuntimeError, match="source identity"),
+ ):
+ act_runner._runtime_environment(tmp_path)
diff --git a/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
b/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
index f77e146515..4f677e42ac 100644
--- a/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
+++ b/paimon-python/pypaimon/tests/robomind_agilex_pipeline_test.py
@@ -14,7 +14,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
-import importlib.util
import inspect
import json
import subprocess
@@ -31,12 +30,6 @@ from pypaimon.sample import robomind_agilex as agilex
h5py = pytest.importorskip("h5py")
-requires_vortex = pytest.mark.skipif(
- importlib.util.find_spec("vortex") is None,
- reason="RoboMIND ingestion uses Vortex, which requires Python >= 3.11",
-)
-
-
_NUMERIC_PATHS = [path for _, path in agilex.NUMERIC_FIELDS]
_IMAGE_PATHS = [path for _, path in agilex.IMAGE_FIELDS]
@@ -87,7 +80,6 @@ def customer_agilex_input(request):
return value
-@requires_vortex
def test_explicit_customer_input_uses_downloaded_episodes(
customer_agilex_input, tmp_path):
episodes = agilex.discover_episodes(customer_agilex_input)
@@ -149,8 +141,10 @@ def
test_shared_transform_streams_complete_agilex_business_schema(
frames = pa.Table.from_batches(batches)
assert frames["episode_id"].to_pylist() == ["train-a"] * 3
assert frames["frame_index"].to_pylist() == [0, 1, 2]
- assert frames["rgb_front"][0].as_py() == b"train-a:0:0"
- assert frames["depth_right_wrist"][2].as_py() == b"train-a:5:2"
+ assert frames["observation_images_rgb_front"][0].as_py() == (
+ b"train-a:0:0")
+ assert frames["observation_images_depth_wrist_right"][2].as_py() == (
+ b"train-a:5:2")
assert frames["action_joint_position_left"][1].as_py() == [
float(value) for value in range(1207, 1214)
]
@@ -253,7 +247,6 @@ def
test_transform_rejects_empty_episode_but_accepts_one_frame(tmp_path):
assert [batch.num_rows for batch in batches] == [1]
-@requires_vortex
def test_local_ingest_and_backfill_materialize_only_canonical_action(
agilex_input, tmp_path):
root, paths = agilex_input
@@ -302,7 +295,7 @@ def
test_local_ingest_and_backfill_materialize_only_canonical_action(
for table in (episodes, frames, stats):
options = table.raw_table.table_schema.options
assert options["deletion-vectors.enabled"] == "true"
- assert options["vector.file.format"] == "vortex"
+ assert options["vector.file.format"] == "parquet"
assert options["blob-as-descriptor"] == "false"
assert "file.format" not in agilex.TABLE_OPTIONS
assert "action" in frames.raw_table.field_names
@@ -352,7 +345,6 @@ def
test_local_ingest_and_backfill_materialize_only_canonical_action(
assert refreshed_snapshot > backfill["statistics_snapshot_id"]
-@requires_vortex
def test_canonical_action_backfill_resumes_after_schema_change(
agilex_input, tmp_path, monkeypatch):
root, _ = agilex_input
@@ -387,7 +379,6 @@ def
test_canonical_action_backfill_resumes_after_schema_change(
np.testing.assert_array_equal(row["action"], expected)
-@requires_vortex
def test_ray_ingest_matches_local_schema_rows_and_backfill(
agilex_input, tmp_path):
ray = pytest.importorskip("ray")
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index 12e24632bc..e2cfcc33a8 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -216,6 +216,15 @@ def read_requirements():
install_requires = read_requirements()
+LEROBOT_DEPENDENCIES = [
+ # datasets 4.1+ may select PyArrow 21+, while PyPaimon currently
+ # supports PyArrow <20. Pandas 2.2.2+ supports NumPy 2.x selected
+ # by LeRobot's media dependencies.
+ 'datasets>=4,<4.1; python_version>="3.10"',
+ 'pandas>=2.2.2,<3; python_version>="3.10"',
+ 'lerobot>=0.4.4,<0.5; python_version>="3.10"',
+]
+
long_description = "See Apache Paimon Python API \
[Doc](https://paimon.apache.org/docs/master/pypaimon/python-api/) for usage."
@@ -224,7 +233,12 @@ setup(
version=VERSION,
packages=PACKAGES,
include_package_data=True,
- package_data={"pypaimon": ["_full_version"]},
+ package_data={
+ "pypaimon": [
+ "_full_version",
+ "benchmark/act/default_experiment.json",
+ ],
+ },
cmdclass={"build_py": PaimonBuildPy, "sdist": PaimonSdist},
install_requires=install_requires,
entry_points={
@@ -241,20 +255,16 @@ setup(
# rosbags is pure Python and does not require a ROS installation.
'rosbags>=0.11.5,<0.12; python_version>="3.10"',
],
- 'lerobot': [
- # datasets 4.1+ may select PyArrow 21+, while PyPaimon currently
- # supports PyArrow <20. Pandas 2.2.2+ supports NumPy 2.x selected
- # by LeRobot's media dependencies.
- 'datasets>=4,<4.1; python_version>="3.10"',
- 'pandas>=2.2.2,<3; python_version>="3.10"',
- 'lerobot>=0.4.4,<0.5; python_version>="3.10"',
- ],
+ 'lerobot': LEROBOT_DEPENDENCIES,
'ray': [
'ray>=2.10,<3; python_version>="3.8"',
],
'torch': [
'torch',
],
+ 'act': LEROBOT_DEPENDENCIES + [
+ 'Pillow; python_version>="3.10"',
+ ],
'daft': [
'daft>=0.7.6; python_version>="3.10"',
],