Skip to content

Commit b7fed2c

Browse files
author
Xiaofang Wu
committed
Add dataset ingestion and credit exchange flow
1 parent 14d577c commit b7fed2c

15 files changed

Lines changed: 1542 additions & 17 deletions

File tree

roboclaw/account/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Account credit ledger for Evo Studio billing."""
22

3-
from .ledger import AccountLedger, BillingRecord, PaymentOrder, Wallet
3+
from .ledger import AccountLedger, BillingRecord, DatasetAccessGrant, PaymentOrder, Wallet
44
from .training_billing import apply_service_fee_cents, estimate_training_hold_cents, hourly_cost_from_params
55

66
__all__ = [
77
"AccountLedger",
88
"BillingRecord",
9+
"DatasetAccessGrant",
910
"PaymentOrder",
1011
"Wallet",
1112
"apply_service_fee_cents",

roboclaw/account/ledger.py

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@
1010
from typing import Any, Literal
1111
from uuid import uuid4
1212

13-
LedgerKind = Literal["admin_recharge", "payment_recharge", "dataset_reward", "freeze", "settle", "release"]
13+
LedgerKind = Literal[
14+
"admin_recharge",
15+
"payment_recharge",
16+
"dataset_reward",
17+
"dataset_access_charge",
18+
"dataset_access_reward",
19+
"freeze",
20+
"settle",
21+
"release",
22+
]
1423
PaymentOrderStatus = Literal["pending", "paid", "cancelled"]
1524

1625

@@ -113,6 +122,28 @@ def to_dict(self) -> dict[str, Any]:
113122
}
114123

115124

125+
@dataclass(frozen=True)
126+
class DatasetAccessGrant:
127+
grant_id: str
128+
username: str
129+
dataset_id: str
130+
points_spent: int
131+
contributor_username: str = ""
132+
contributor_points: int = 0
133+
created_at: str = ""
134+
135+
def to_dict(self) -> dict[str, Any]:
136+
return {
137+
"grantId": self.grant_id,
138+
"username": self.username,
139+
"datasetId": self.dataset_id,
140+
"pointsSpent": self.points_spent,
141+
"contributorUsername": self.contributor_username,
142+
"contributorPoints": self.contributor_points,
143+
"createdAt": self.created_at,
144+
}
145+
146+
116147
class AccountLedger:
117148
"""File-backed wallet ledger.
118149
@@ -285,6 +316,87 @@ def grant_dataset_reward(
285316
self._save(state)
286317
return wallet, record, True
287318

319+
def redeem_dataset_access(
320+
self,
321+
username: str,
322+
dataset_id: str,
323+
price_points: int,
324+
*,
325+
contributor_username: str = "",
326+
contributor_share_bps: int = 5000,
327+
reason: str = "public dataset access",
328+
) -> tuple[Wallet, DatasetAccessGrant, BillingRecord | None, BillingRecord | None, bool]:
329+
if price_points < 0:
330+
raise ValueError("price_points must be non-negative")
331+
if contributor_share_bps < 0 or contributor_share_bps > 10_000:
332+
raise ValueError("contributor_share_bps must be between 0 and 10000")
333+
username = _clean_username(username)
334+
dataset_id = dataset_id.strip()
335+
if not dataset_id:
336+
raise ValueError("dataset_id is required")
337+
contributor_username = contributor_username.strip()
338+
with self._lock:
339+
state = self._load()
340+
for payload in state.get("datasetAccessGrants", []):
341+
grant = _dataset_access_grant_from_payload(payload)
342+
if grant.username == username and grant.dataset_id == dataset_id:
343+
return self._wallet_from_state(state, username), grant, None, None, False
344+
345+
wallet = self._wallet_from_state(state, username)
346+
if wallet.reward_points < price_points:
347+
raise ValueError("insufficient credit points")
348+
349+
wallet = Wallet(
350+
username=username,
351+
balance_cents=wallet.balance_cents,
352+
frozen_cents=wallet.frozen_cents,
353+
reward_points=wallet.reward_points - price_points,
354+
updated_at=_now(),
355+
)
356+
buyer_record = self._append_record(
357+
state,
358+
wallet,
359+
"dataset_access_charge",
360+
-price_points,
361+
reason=reason,
362+
job_id=dataset_id,
363+
)
364+
contributor_points = 0
365+
contributor_record: BillingRecord | None = None
366+
if contributor_username and contributor_username != username and price_points:
367+
contributor_points = price_points * contributor_share_bps // 10_000
368+
if contributor_points:
369+
contributor_wallet = self._wallet_from_state(state, contributor_username)
370+
contributor_wallet = Wallet(
371+
username=contributor_username,
372+
balance_cents=contributor_wallet.balance_cents,
373+
frozen_cents=contributor_wallet.frozen_cents,
374+
reward_points=contributor_wallet.reward_points + contributor_points,
375+
updated_at=_now(),
376+
)
377+
contributor_record = self._append_record(
378+
state,
379+
contributor_wallet,
380+
"dataset_access_reward",
381+
contributor_points,
382+
reason="public dataset reuse reward",
383+
job_id=dataset_id,
384+
)
385+
self._save_wallet(state, contributor_wallet)
386+
grant = DatasetAccessGrant(
387+
grant_id=uuid4().hex,
388+
username=username,
389+
dataset_id=dataset_id,
390+
points_spent=price_points,
391+
contributor_username=contributor_username,
392+
contributor_points=contributor_points,
393+
created_at=_now(),
394+
)
395+
state.setdefault("datasetAccessGrants", []).append(grant.to_dict())
396+
self._save_wallet(state, wallet)
397+
self._save(state)
398+
return wallet, grant, buyer_record, contributor_record, True
399+
288400
def admin_recharge(self, username: str, amount_cents: int, *, reason: str = "admin recharge") -> tuple[Wallet, BillingRecord]:
289401
if amount_cents <= 0:
290402
raise ValueError("amount_cents must be positive")
@@ -610,7 +722,7 @@ def _save_wallet(self, state: dict[str, Any], wallet: Wallet) -> None:
610722

611723
def _load(self) -> dict[str, Any]:
612724
if not self.path.is_file():
613-
return {"wallets": {}, "records": [], "paymentOrders": []}
725+
return {"wallets": {}, "records": [], "paymentOrders": [], "datasetAccessGrants": []}
614726
return json.loads(self.path.read_text(encoding="utf-8"))
615727

616728
def _save(self, state: dict[str, Any]) -> None:
@@ -659,5 +771,17 @@ def _order_from_payload(payload: dict[str, Any]) -> PaymentOrder:
659771
)
660772

661773

774+
def _dataset_access_grant_from_payload(payload: dict[str, Any]) -> DatasetAccessGrant:
775+
return DatasetAccessGrant(
776+
grant_id=str(payload.get("grantId") or ""),
777+
username=str(payload.get("username") or ""),
778+
dataset_id=str(payload.get("datasetId") or ""),
779+
points_spent=int(payload.get("pointsSpent", 0) or 0),
780+
contributor_username=str(payload.get("contributorUsername") or ""),
781+
contributor_points=int(payload.get("contributorPoints", 0) or 0),
782+
created_at=str(payload.get("createdAt") or ""),
783+
)
784+
785+
662786
def _now() -> str:
663787
return datetime.now(tz=timezone.utc).isoformat()

roboclaw/data/curation/service.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ def _quality_reward_multiplier(score: Any) -> float:
243243

244244

245245
def _source_reward_multiplier(info: dict[str, Any]) -> float:
246+
visibility = str(
247+
info.get("visibility")
248+
or info.get("accessLevel")
249+
or info.get("access_level")
250+
or "private",
251+
).strip().lower()
252+
if visibility not in {"public", "shared", "open"}:
253+
return 0.0
254+
246255
source_type = str(
247256
info.get("contributionSource")
248257
or info.get("contribution_source")

roboclaw/data/datasets.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ def datasets_root_from_manifest(manifest: Any) -> Path:
5757
class DatasetStats:
5858
total_episodes: int = 0
5959
total_frames: int = 0
60+
total_bytes: int = 0
6061
fps: int = 0
6162
robot_type: str = ""
6263
features: tuple[str, ...] = ()
@@ -66,6 +67,7 @@ def to_dict(self) -> dict[str, Any]:
6667
return {
6768
"total_episodes": self.total_episodes,
6869
"total_frames": self.total_frames,
70+
"total_bytes": self.total_bytes,
6971
"fps": self.fps,
7072
"robot_type": self.robot_type,
7173
"features": list(self.features),
@@ -456,6 +458,7 @@ def _read_stats(self, dataset_dir: Path, info: dict[str, Any]) -> DatasetStats:
456458
return DatasetStats(
457459
total_episodes=int(info.get("total_episodes", 0) or 0),
458460
total_frames=int(info.get("total_frames", 0) or 0),
461+
total_bytes=_directory_size_bytes(dataset_dir),
459462
fps=int(info.get("fps", 0) or 0),
460463
robot_type=str(info.get("robot_type", "")),
461464
features=tuple((info.get("features") or {}).keys()),
@@ -491,3 +494,15 @@ def _local_runtime_capabilities(self) -> DatasetCapabilities:
491494
can_push=True,
492495
can_curate=True,
493496
)
497+
498+
499+
def _directory_size_bytes(path: Path) -> int:
500+
total = 0
501+
for item in path.rglob("*"):
502+
if not item.is_file():
503+
continue
504+
try:
505+
total += item.stat().st_size
506+
except OSError:
507+
logger.debug("Failed to stat dataset file {}", item, exc_info=True)
508+
return total

roboclaw/data/ingestion.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Dataset source ingestion helpers.
2+
3+
This module materializes external dataset sources into the local
4+
``DatasetCatalog`` root so the existing curation and training paths can use
5+
them as normal local datasets.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import os
11+
import re
12+
import shutil
13+
from dataclasses import dataclass
14+
from pathlib import Path
15+
from urllib.parse import urlparse
16+
17+
from roboclaw.data.datasets import DatasetCatalog, DatasetRef
18+
19+
20+
@dataclass(frozen=True)
21+
class DatasetIngestSpec:
22+
dataset_id: str
23+
source_kind: str
24+
source_uri: str
25+
source_auth_ref: str = ""
26+
include_videos: bool = True
27+
force: bool = False
28+
29+
30+
def ingest_dataset_source(catalog: DatasetCatalog, spec: DatasetIngestSpec) -> DatasetRef:
31+
"""Materialize *spec* into *catalog* and return the local dataset ref."""
32+
source_kind = spec.source_kind.strip().lower()
33+
if source_kind in {"remote_dataset", "huggingface", "hf"}:
34+
repo_id = _normalize_remote_repo(spec.source_uri)
35+
return catalog.pull_dataset(
36+
repo_id,
37+
dataset_id=spec.dataset_id,
38+
token=_resolve_auth_token(spec.source_auth_ref),
39+
)
40+
41+
if source_kind in {"mounted_path", "local_path"}:
42+
return _ingest_directory(catalog, spec)
43+
44+
if source_kind in {"local_upload", "local_archive", "archive"}:
45+
return _ingest_archive(catalog, spec)
46+
47+
if source_kind in {"cloud_object", "oss_object", "s3_object", "cos_object", "gcs_object"}:
48+
raise NotImplementedError(
49+
"cloud object ingestion requires a configured storage provider "
50+
"(OSS/S3/COS/GCS) and is not enabled in this backend yet"
51+
)
52+
53+
raise ValueError(f"Unsupported dataset source_kind: {spec.source_kind!r}")
54+
55+
56+
def _normalize_remote_repo(source_uri: str) -> str:
57+
value = source_uri.strip()
58+
for prefix in ("hf://", "huggingface://"):
59+
if value.startswith(prefix):
60+
value = value[len(prefix):]
61+
break
62+
if value.startswith("https://huggingface.co/"):
63+
value = value[len("https://huggingface.co/"):]
64+
value = value.strip("/")
65+
if value.startswith("datasets/"):
66+
value = value[len("datasets/"):]
67+
if not value or "/" not in value:
68+
raise ValueError("remote dataset source_uri must be a HuggingFace repo id or hf://owner/name URI")
69+
return value
70+
71+
72+
def _resolve_auth_token(source_auth_ref: str) -> str:
73+
ref = source_auth_ref.strip()
74+
if not ref or ref == "public":
75+
return ""
76+
env_key = "ROBOCLAW_DATASET_AUTH_" + re.sub(r"[^A-Za-z0-9]+", "_", ref).upper() + "_TOKEN"
77+
token = os.environ.get(env_key, "").strip()
78+
if not token:
79+
raise ValueError(f"Dataset auth ref {ref!r} is not configured; expected env {env_key}")
80+
return token
81+
82+
83+
def _ingest_directory(catalog: DatasetCatalog, spec: DatasetIngestSpec) -> DatasetRef:
84+
source = _resolve_allowed_source_path(catalog, spec.source_uri)
85+
if not source.is_dir():
86+
raise ValueError(f"mounted_path source_uri must be a directory: {source}")
87+
target = catalog.resolve_local_path(spec.dataset_id)
88+
_prepare_target(target, spec.force)
89+
shutil.copytree(source, target, dirs_exist_ok=True)
90+
return catalog.require_local_dataset(spec.dataset_id)
91+
92+
93+
def _ingest_archive(catalog: DatasetCatalog, spec: DatasetIngestSpec) -> DatasetRef:
94+
source = _resolve_allowed_source_path(catalog, spec.source_uri)
95+
if not source.is_file():
96+
raise ValueError(f"archive source_uri must be a file: {source}")
97+
target = catalog.resolve_local_path(spec.dataset_id)
98+
_prepare_target(target, spec.force)
99+
target.mkdir(parents=True, exist_ok=True)
100+
shutil.unpack_archive(str(source), str(target))
101+
102+
nested = _find_single_nested_dataset(target)
103+
if nested is not None and nested != target:
104+
temp = target.with_name(target.name + ".__ingest_tmp__")
105+
if temp.exists():
106+
shutil.rmtree(temp)
107+
nested.rename(temp)
108+
shutil.rmtree(target)
109+
temp.rename(target)
110+
return catalog.require_local_dataset(spec.dataset_id)
111+
112+
113+
def _prepare_target(target: Path, force: bool) -> None:
114+
if target.exists():
115+
if not force:
116+
raise ValueError(f"Dataset target already exists: {target}")
117+
shutil.rmtree(target)
118+
target.parent.mkdir(parents=True, exist_ok=True)
119+
120+
121+
def _find_single_nested_dataset(target: Path) -> Path | None:
122+
if (target / "meta" / "info.json").is_file():
123+
return target
124+
children = [child for child in target.iterdir() if child.is_dir()]
125+
if len(children) != 1:
126+
return None
127+
child = children[0]
128+
if (child / "meta" / "info.json").is_file():
129+
return child
130+
return None
131+
132+
133+
def _resolve_allowed_source_path(catalog: DatasetCatalog, source_uri: str) -> Path:
134+
parsed = urlparse(source_uri.strip())
135+
raw_path = parsed.path if parsed.scheme == "file" else source_uri
136+
source = Path(raw_path).expanduser().resolve()
137+
allowed_roots = _allowed_ingest_roots(catalog)
138+
if not any(_is_relative_to(source, root) for root in allowed_roots):
139+
roots = ", ".join(str(root) for root in allowed_roots)
140+
raise ValueError(
141+
f"Dataset source path is outside allowed ingest roots: {source}. "
142+
f"Configure ROBOCLAW_DATASET_INGEST_ROOTS; current roots: {roots}"
143+
)
144+
return source
145+
146+
147+
def _allowed_ingest_roots(catalog: DatasetCatalog) -> tuple[Path, ...]:
148+
configured = os.environ.get("ROBOCLAW_DATASET_INGEST_ROOTS", "").strip()
149+
roots: list[Path] = []
150+
if configured:
151+
roots.extend(Path(value).expanduser().resolve() for value in configured.split(os.pathsep) if value.strip())
152+
roots.append(catalog.root.resolve())
153+
return tuple(dict.fromkeys(roots))
154+
155+
156+
def _is_relative_to(path: Path, root: Path) -> bool:
157+
try:
158+
path.relative_to(root)
159+
except ValueError:
160+
return False
161+
return True

0 commit comments

Comments
 (0)