Spaces:
Running
Running
Deploy GitHub main 0d2efcf4a46b
Browse files- cloud/acceptance.py +101 -0
- cloud/entrypoint.py +9 -1
- deployment.json +1 -1
- scripts/configure_cloud_space.py +5 -1
- scripts/live_acceptance.py +1 -1
cloud/acceptance.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Run an optional real model acceptance, isolated from production scores/state."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
from datetime import datetime, timezone
|
| 5 |
+
import json
|
| 6 |
+
import os
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
import re
|
| 9 |
+
import signal
|
| 10 |
+
import sys
|
| 11 |
+
import tempfile
|
| 12 |
+
import time
|
| 13 |
+
|
| 14 |
+
ROOT = Path(__file__).resolve().parents[1]
|
| 15 |
+
sys.path[:0] = [str(ROOT), str(ROOT / "src")]
|
| 16 |
+
BRANCH = "acceptance"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def publish(api, repo, output, report):
|
| 20 |
+
from huggingface_hub import CommitOperationAdd
|
| 21 |
+
from tsfm_bench.remote_state import pack_files
|
| 22 |
+
if not api.dataset_info(repo).private:
|
| 23 |
+
raise ValueError("Acceptance artifacts require a private Dataset")
|
| 24 |
+
api.create_branch(repo, branch=BRANCH, repo_type="dataset", exist_ok=True)
|
| 25 |
+
parent = api.dataset_info(repo, revision=BRANCH).sha
|
| 26 |
+
prefix = "acceptance/" + output.name
|
| 27 |
+
with tempfile.TemporaryDirectory(prefix="livehouse-acceptance-") as temp:
|
| 28 |
+
archive = Path(temp) / "artifacts.tar.gz"
|
| 29 |
+
manifest = pack_files(ROOT, sorted(p for p in output.rglob("*") if p.is_file()), archive)
|
| 30 |
+
return api.create_commit(repo, repo_type="dataset", revision=BRANCH, parent_commit=parent,
|
| 31 |
+
commit_message=f"Cloud model acceptance {output.name}: {report['status']}", operations=[
|
| 32 |
+
CommitOperationAdd(path_in_repo=prefix + "/artifacts.tar.gz", path_or_fileobj=archive),
|
| 33 |
+
CommitOperationAdd(path_in_repo=prefix + "/manifest.json", path_or_fileobj=json.dumps(manifest).encode()),
|
| 34 |
+
CommitOperationAdd(path_in_repo=prefix + "/report.json", path_or_fileobj=json.dumps(report, indent=2).encode()),
|
| 35 |
+
])
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def main():
|
| 39 |
+
from huggingface_hub import HfApi, hf_hub_download
|
| 40 |
+
from scripts.run_online_daemon import run_managed_subprocess
|
| 41 |
+
from tsfm_bench.remote_state import restore_archive
|
| 42 |
+
identifier = os.environ["LIVEHOUSE_ACCEPTANCE_ID"]
|
| 43 |
+
if not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,60}", identifier):
|
| 44 |
+
raise ValueError("Acceptance ID must contain only lowercase letters, digits and hyphens")
|
| 45 |
+
signal.signal(signal.SIGTERM, lambda *_: sys.exit(0))
|
| 46 |
+
repo = os.environ["HF_STATE_REPO"]
|
| 47 |
+
token = os.environ["HF_TOKEN"]
|
| 48 |
+
api = HfApi(token=token)
|
| 49 |
+
if not api.dataset_info(repo).private:
|
| 50 |
+
raise ValueError("Acceptance artifacts require a private Dataset")
|
| 51 |
+
output = ROOT / "outputs/cloud-acceptance" / identifier
|
| 52 |
+
output.mkdir(parents=True, exist_ok=True)
|
| 53 |
+
prefix = "acceptance/" + identifier
|
| 54 |
+
api.create_branch(repo, branch=BRANCH, repo_type="dataset", exist_ok=True)
|
| 55 |
+
revision = api.dataset_info(repo, revision=BRANCH).sha
|
| 56 |
+
def download(name):
|
| 57 |
+
return Path(hf_hub_download(repo, prefix + "/" + name, repo_type="dataset",
|
| 58 |
+
revision=revision, token=token))
|
| 59 |
+
report = {"id":identifier, "isolated":True, "space":os.getenv("SPACE_ID"),
|
| 60 |
+
"source_revision":os.environ["LIVEHOUSE_SOURCE_REVISION"],
|
| 61 |
+
"started_at":datetime.now(timezone.utc).isoformat()}
|
| 62 |
+
if api.file_exists(repo, prefix + "/report.json", repo_type="dataset", revision=revision):
|
| 63 |
+
report = json.loads(download("report.json").read_text())
|
| 64 |
+
if report["status"] in {"complete", "failed"}:
|
| 65 |
+
print(f"Acceptance {identifier} already {report['status']}; no inference repeated", flush=True)
|
| 66 |
+
if report["status"] == "failed":
|
| 67 |
+
raise RuntimeError("This acceptance already failed; inspect its archived report")
|
| 68 |
+
return
|
| 69 |
+
manifest = json.loads(download("manifest.json").read_text())
|
| 70 |
+
restore_archive(ROOT, download("artifacts.tar.gz"), manifest, allowed_roots=(output.relative_to(ROOT),))
|
| 71 |
+
def run(phase):
|
| 72 |
+
return run_managed_subprocess([sys.executable, "scripts/live_acceptance.py", phase,
|
| 73 |
+
"--output-root", str(output)], timeout=1200, timeout_message="Cloud acceptance timed out")
|
| 74 |
+
try:
|
| 75 |
+
if report.get("status") != "issued":
|
| 76 |
+
if run("issue") != 0:
|
| 77 |
+
raise RuntimeError("At least one real model forecast failed; inspect archived acceptance-issue.json")
|
| 78 |
+
report.update(status="issued", issue=json.loads((output / "acceptance-issue.json").read_text()))
|
| 79 |
+
publish(api, repo, output, report)
|
| 80 |
+
# The target bar starts at the forecast deadline and closes five minutes later.
|
| 81 |
+
deadline = datetime.fromisoformat(report["issue"]["forecast_deadline"]).timestamp() + 310
|
| 82 |
+
while time.time() < deadline:
|
| 83 |
+
time.sleep(min(15, deadline - time.time()))
|
| 84 |
+
for attempt in range(4):
|
| 85 |
+
if run("resolve") == 0:
|
| 86 |
+
break
|
| 87 |
+
if attempt == 3:
|
| 88 |
+
raise RuntimeError("Real future observations did not resolve every model")
|
| 89 |
+
time.sleep(30)
|
| 90 |
+
report.update(status="complete", resolve=json.loads((output / "acceptance-resolve.json").read_text()))
|
| 91 |
+
except Exception as exc:
|
| 92 |
+
report.update(status="failed", error_type=type(exc).__name__)
|
| 93 |
+
report["finished_at"] = datetime.now(timezone.utc).isoformat()
|
| 94 |
+
publish(api, repo, output, report)
|
| 95 |
+
print(f"Cloud acceptance {identifier}: {report['status']}; artifacts on private Dataset branch {BRANCH}", flush=True)
|
| 96 |
+
if report["status"] != "complete":
|
| 97 |
+
raise RuntimeError("Cloud model acceptance failed; inspect its private artifacts")
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
main()
|
cloud/entrypoint.py
CHANGED
|
@@ -44,6 +44,9 @@ def main():
|
|
| 44 |
commands = {"web": [sys.executable, "cloud/web.py"]}
|
| 45 |
if enabled:
|
| 46 |
commands["worker"] = [sys.executable, "cloud/worker.py"]
|
|
|
|
|
|
|
|
|
|
| 47 |
stopping = False
|
| 48 |
|
| 49 |
def shutdown(*_):
|
|
@@ -61,10 +64,15 @@ def main():
|
|
| 61 |
for name, command in commands.items():
|
| 62 |
proc = processes.get(name)
|
| 63 |
if proc is not None and proc.poll() is not None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
print(f"{name} exited with {proc.returncode}; restart after 60 seconds", flush=True)
|
| 65 |
restart_at[name] = time.monotonic() + 60
|
| 66 |
del processes[name]
|
| 67 |
-
if name not in processes and time.monotonic() >= restart_at.get(name, 0):
|
| 68 |
processes[name] = subprocess.Popen(command, cwd=ROOT)
|
| 69 |
status = {"updated_at": time.time(), "source_revision": revision,
|
| 70 |
"evaluator_enabled": enabled,
|
|
|
|
| 44 |
commands = {"web": [sys.executable, "cloud/web.py"]}
|
| 45 |
if enabled:
|
| 46 |
commands["worker"] = [sys.executable, "cloud/worker.py"]
|
| 47 |
+
if os.getenv("LIVEHOUSE_ACCEPTANCE_ID"):
|
| 48 |
+
commands["acceptance"] = [sys.executable, "cloud/acceptance.py"]
|
| 49 |
+
completed = set()
|
| 50 |
stopping = False
|
| 51 |
|
| 52 |
def shutdown(*_):
|
|
|
|
| 64 |
for name, command in commands.items():
|
| 65 |
proc = processes.get(name)
|
| 66 |
if proc is not None and proc.poll() is not None:
|
| 67 |
+
if name == "acceptance":
|
| 68 |
+
print(f"Acceptance process finished with {proc.returncode}; no automatic rerun", flush=True)
|
| 69 |
+
completed.add(name)
|
| 70 |
+
del processes[name]
|
| 71 |
+
continue
|
| 72 |
print(f"{name} exited with {proc.returncode}; restart after 60 seconds", flush=True)
|
| 73 |
restart_at[name] = time.monotonic() + 60
|
| 74 |
del processes[name]
|
| 75 |
+
if name not in processes and name not in completed and time.monotonic() >= restart_at.get(name, 0):
|
| 76 |
processes[name] = subprocess.Popen(command, cwd=ROOT)
|
| 77 |
status = {"updated_at": time.time(), "source_revision": revision,
|
| 78 |
"evaluator_enabled": enabled,
|
deployment.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
"source_repository": "https://github.com/Thinkcat-Lab/LiveHouse-TS",
|
| 3 |
"source_branch": "main",
|
| 4 |
-
"source_revision": "
|
| 5 |
}
|
|
|
|
| 1 |
{
|
| 2 |
"source_repository": "https://github.com/Thinkcat-Lab/LiveHouse-TS",
|
| 3 |
"source_branch": "main",
|
| 4 |
+
"source_revision": "0d2efcf4a46bffe86cd0bf965a279c46cb2ecc27"
|
| 5 |
}
|
scripts/configure_cloud_space.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
|
| 8 |
|
|
@@ -17,6 +18,9 @@ def configure(api, env):
|
|
| 17 |
enabled = env.get("TSFM_EVALUATOR_ENABLED", "0")
|
| 18 |
if enabled not in {"0", "1"}:
|
| 19 |
raise ValueError("TSFM_EVALUATOR_ENABLED must be 0 or 1")
|
|
|
|
|
|
|
|
|
|
| 20 |
# Validate all destinations before the first secret/variable write.
|
| 21 |
api.space_info(repo)
|
| 22 |
if not api.dataset_info(state).private:
|
|
@@ -28,7 +32,7 @@ def configure(api, env):
|
|
| 28 |
for name in names:
|
| 29 |
api.add_space_secret(repo, name, env[name])
|
| 30 |
variables = {"HF_SPACE_REPO":repo, "HF_RESULTS_REPO":results, "HF_STATE_REPO":state,
|
| 31 |
-
"TSFM_EVALUATOR_ENABLED":enabled}
|
| 32 |
for name, value in variables.items():
|
| 33 |
api.add_space_variable(repo, name, value)
|
| 34 |
return {"space":repo, "secret_names":list(names), "variables":variables}
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import os
|
| 6 |
+
import re
|
| 7 |
from pathlib import Path
|
| 8 |
|
| 9 |
|
|
|
|
| 18 |
enabled = env.get("TSFM_EVALUATOR_ENABLED", "0")
|
| 19 |
if enabled not in {"0", "1"}:
|
| 20 |
raise ValueError("TSFM_EVALUATOR_ENABLED must be 0 or 1")
|
| 21 |
+
acceptance = env.get("LIVEHOUSE_ACCEPTANCE_ID", "")
|
| 22 |
+
if acceptance and not re.fullmatch(r"[a-z0-9][a-z0-9-]{1,60}", acceptance):
|
| 23 |
+
raise ValueError("Invalid cloud acceptance ID")
|
| 24 |
# Validate all destinations before the first secret/variable write.
|
| 25 |
api.space_info(repo)
|
| 26 |
if not api.dataset_info(state).private:
|
|
|
|
| 32 |
for name in names:
|
| 33 |
api.add_space_secret(repo, name, env[name])
|
| 34 |
variables = {"HF_SPACE_REPO":repo, "HF_RESULTS_REPO":results, "HF_STATE_REPO":state,
|
| 35 |
+
"TSFM_EVALUATOR_ENABLED":enabled, "LIVEHOUSE_ACCEPTANCE_ID":acceptance}
|
| 36 |
for name, value in variables.items():
|
| 37 |
api.add_space_variable(repo, name, value)
|
| 38 |
return {"space":repo, "secret_names":list(names), "variables":variables}
|
scripts/live_acceptance.py
CHANGED
|
@@ -56,7 +56,7 @@ def main():
|
|
| 56 |
if remaining < 180:
|
| 57 |
print(f"Waiting {remaining + 3:.0f}s for a full forecast issue window", flush=True)
|
| 58 |
time.sleep(remaining + 3)
|
| 59 |
-
response = requests.get("https://api.binance.
|
| 60 |
params={"symbol": "BTCUSDT", "interval": "5m", "limit": 130})
|
| 61 |
response.raise_for_status()
|
| 62 |
rows = response.json()
|
|
|
|
| 56 |
if remaining < 180:
|
| 57 |
print(f"Waiting {remaining + 3:.0f}s for a full forecast issue window", flush=True)
|
| 58 |
time.sleep(remaining + 3)
|
| 59 |
+
response = requests.get("https://data-api.binance.vision/api/v3/klines", timeout=45,
|
| 60 |
params={"symbol": "BTCUSDT", "interval": "5m", "limit": 130})
|
| 61 |
response.raise_for_status()
|
| 62 |
rows = response.json()
|