runq-sdk 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runq/__init__.py +90 -0
- runq/_config.py +152 -0
- runq/_context.py +370 -0
- runq/_events.py +145 -0
- runq/_exceptions.py +66 -0
- runq/_loop.py +233 -0
- runq/_manifest.py +426 -0
- runq/_policies.py +281 -0
- runq/_prefix.py +59 -0
- runq/_range.py +149 -0
- runq/_record.py +147 -0
- runq/_report.py +307 -0
- runq/_safe_save.py +551 -0
- runq/_sizing.py +103 -0
- runq/_transport.py +112 -0
- runq/utils.py +78 -0
- runq_sdk-0.5.0.dist-info/METADATA +69 -0
- runq_sdk-0.5.0.dist-info/RECORD +20 -0
- runq_sdk-0.5.0.dist-info/WHEEL +5 -0
- runq_sdk-0.5.0.dist-info/top_level.txt +1 -0
runq/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""runq — Lab GPU scheduler SDK (in-task client).
|
|
2
|
+
|
|
3
|
+
Public API::
|
|
4
|
+
|
|
5
|
+
import runq
|
|
6
|
+
|
|
7
|
+
ctx = runq.context()
|
|
8
|
+
|
|
9
|
+
# Typed params with auto merge from sweep
|
|
10
|
+
@runq.dataclass(auto_overwrite=True)
|
|
11
|
+
class Params:
|
|
12
|
+
lr: float = 0.001
|
|
13
|
+
batch_size: int = 32
|
|
14
|
+
cfg = Params()
|
|
15
|
+
|
|
16
|
+
# Training loop with auto step + preemption
|
|
17
|
+
for step in runq.range(100):
|
|
18
|
+
loss = train(model)
|
|
19
|
+
runq.log_metric("loss", loss) # step auto-populated
|
|
20
|
+
runq.report({"val_loss": evaluate(model)}) # early-stop check
|
|
21
|
+
runq.safe_save("ckpt.pt", model.state_dict())
|
|
22
|
+
|
|
23
|
+
# Resume from latest checkpoint
|
|
24
|
+
ckpt = runq.latest_checkpoint()
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from ._config import dataclass
|
|
28
|
+
from ._context import Context, ParamDict, context, get_ctx
|
|
29
|
+
from ._events import log_metric
|
|
30
|
+
from ._exceptions import RunqDiskFullError, RunqEarlyStopSignal, RunqError
|
|
31
|
+
from ._loop import log_group, loop
|
|
32
|
+
from ._manifest import best_checkpoint, latest_checkpoint
|
|
33
|
+
from ._policies import convergence, patience, threshold
|
|
34
|
+
from ._range import is_preempted, range
|
|
35
|
+
from ._record import record
|
|
36
|
+
from ._report import Decision, early_stop, report
|
|
37
|
+
from ._safe_save import safe_save
|
|
38
|
+
from ._transport import TransportError
|
|
39
|
+
from . import utils
|
|
40
|
+
|
|
41
|
+
__all__ = [ # noqa: RUF022
|
|
42
|
+
# Init + context
|
|
43
|
+
"Context",
|
|
44
|
+
"ParamDict",
|
|
45
|
+
"context",
|
|
46
|
+
"get_ctx",
|
|
47
|
+
# Typed parameter dataclass
|
|
48
|
+
"dataclass",
|
|
49
|
+
# Training loop
|
|
50
|
+
"loop",
|
|
51
|
+
"range",
|
|
52
|
+
"is_preempted",
|
|
53
|
+
# Metrics
|
|
54
|
+
"log_metric",
|
|
55
|
+
"log_group",
|
|
56
|
+
# Results (bounded facts, stored in full — see _record.py)
|
|
57
|
+
"record",
|
|
58
|
+
# Early stop
|
|
59
|
+
"report",
|
|
60
|
+
"early_stop",
|
|
61
|
+
"Decision",
|
|
62
|
+
"patience",
|
|
63
|
+
"threshold",
|
|
64
|
+
"convergence",
|
|
65
|
+
# Checkpoint
|
|
66
|
+
"safe_save",
|
|
67
|
+
"best_checkpoint",
|
|
68
|
+
"latest_checkpoint",
|
|
69
|
+
# Exceptions
|
|
70
|
+
"RunqError",
|
|
71
|
+
"RunqDiskFullError",
|
|
72
|
+
"RunqEarlyStopSignal",
|
|
73
|
+
"TransportError",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
__version__ = "0.5.0"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def __getattr__(name: str):
|
|
80
|
+
"""Module-level attribute access for convenience properties."""
|
|
81
|
+
if name == "preempted":
|
|
82
|
+
from ._range import is_preempted
|
|
83
|
+
return is_preempted()
|
|
84
|
+
if name == "seed":
|
|
85
|
+
from ._context import get_ctx
|
|
86
|
+
return get_ctx().seed
|
|
87
|
+
if name == "params":
|
|
88
|
+
from ._context import get_ctx
|
|
89
|
+
return get_ctx().params
|
|
90
|
+
raise AttributeError(f"module 'runq' has no attribute {name!r}")
|
runq/_config.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Typed parameter dataclass with automatic runq.params merging.
|
|
2
|
+
|
|
3
|
+
``@runq.dataclass`` wraps a plain class into a ``dataclasses.dataclass``
|
|
4
|
+
with extras:
|
|
5
|
+
|
|
6
|
+
- **Pre-check**: all fields must have defaults — caught at class
|
|
7
|
+
definition time, not at runtime.
|
|
8
|
+
- **Auto-overwrite**: when ``auto_overwrite=True``, matching keys from
|
|
9
|
+
``runq.params`` override defaults at instantiation time.
|
|
10
|
+
- **Serialization**: ``to_dict`` / ``to_json`` / ``from_json`` /
|
|
11
|
+
``to_yaml`` / ``from_yaml`` out of the box.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import dataclasses
|
|
17
|
+
import json
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def dataclass(
|
|
23
|
+
cls=None,
|
|
24
|
+
*,
|
|
25
|
+
auto_overwrite: bool = False,
|
|
26
|
+
strict: bool = False,
|
|
27
|
+
):
|
|
28
|
+
"""Decorator that turns a class into a runq parameter dataclass.
|
|
29
|
+
|
|
30
|
+
All fields must have defaults. If ``auto_overwrite`` is True, matching
|
|
31
|
+
keys from ``runq.params`` override defaults at instantiation time.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
@runq.dataclass(auto_overwrite=True)
|
|
36
|
+
class MyConfig:
|
|
37
|
+
lr: float = 0.001
|
|
38
|
+
batch_size: int = 32
|
|
39
|
+
|
|
40
|
+
cfg = MyConfig() # lr overridden from runq.params if present
|
|
41
|
+
cfg.to_json("cfg.json")
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def wrap(cls):
|
|
45
|
+
# Make it a dataclass if not already
|
|
46
|
+
if not dataclasses.is_dataclass(cls):
|
|
47
|
+
cls = dataclasses.dataclass(cls)
|
|
48
|
+
|
|
49
|
+
# Pre-check: every field must have a default
|
|
50
|
+
for f in dataclasses.fields(cls):
|
|
51
|
+
has_default = (
|
|
52
|
+
f.default is not dataclasses.MISSING
|
|
53
|
+
or f.default_factory is not dataclasses.MISSING
|
|
54
|
+
)
|
|
55
|
+
if not has_default:
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"@runq.dataclass: field '{f.name}' has no default. "
|
|
58
|
+
"All fields must have defaults so missing params are "
|
|
59
|
+
"caught at definition time, not at runtime."
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# Store options on the class for introspection
|
|
63
|
+
cls._runq_auto_overwrite = auto_overwrite
|
|
64
|
+
cls._runq_strict = strict
|
|
65
|
+
|
|
66
|
+
# Wrap __init__ for auto_overwrite
|
|
67
|
+
if auto_overwrite:
|
|
68
|
+
original_init = cls.__init__
|
|
69
|
+
|
|
70
|
+
def new_init(self, *args, **kwargs):
|
|
71
|
+
original_init(self, *args, **kwargs)
|
|
72
|
+
_merge_params(self, strict)
|
|
73
|
+
|
|
74
|
+
cls.__init__ = new_init
|
|
75
|
+
|
|
76
|
+
# Add serialization methods
|
|
77
|
+
cls.to_dict = _to_dict
|
|
78
|
+
cls.to_json = _to_json
|
|
79
|
+
cls.from_json = classmethod(_from_json)
|
|
80
|
+
cls.to_yaml = _to_yaml
|
|
81
|
+
cls.from_yaml = classmethod(_from_yaml)
|
|
82
|
+
|
|
83
|
+
return cls
|
|
84
|
+
|
|
85
|
+
if cls is None:
|
|
86
|
+
return wrap
|
|
87
|
+
return wrap(cls)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _merge_params(instance: Any, strict: bool) -> None:
|
|
91
|
+
"""Merge runq.params into the dataclass instance."""
|
|
92
|
+
from runq._context import get_ctx
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
ctx = get_ctx()
|
|
96
|
+
except RuntimeError:
|
|
97
|
+
return # no context initialized yet
|
|
98
|
+
|
|
99
|
+
fields = {f.name for f in dataclasses.fields(instance)}
|
|
100
|
+
for key, val in ctx.params.items():
|
|
101
|
+
if key in fields:
|
|
102
|
+
setattr(instance, key, val)
|
|
103
|
+
elif strict:
|
|
104
|
+
raise AttributeError(
|
|
105
|
+
f"Param '{key}' from runq.params not found in "
|
|
106
|
+
f"{type(instance).__name__}. "
|
|
107
|
+
f"Available fields: {sorted(fields)}. "
|
|
108
|
+
f"Set strict=False to ignore."
|
|
109
|
+
)
|
|
110
|
+
else:
|
|
111
|
+
continue
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _to_dict(self) -> dict[str, Any]:
|
|
115
|
+
"""Convert to plain dict."""
|
|
116
|
+
return {f.name: getattr(self, f.name) for f in dataclasses.fields(self)}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _to_json(self, path: str | Path | None = None, **kwargs) -> str:
|
|
120
|
+
"""Serialize to JSON string. Optionally write to file."""
|
|
121
|
+
data = self.to_dict()
|
|
122
|
+
text = json.dumps(data, indent=2, ensure_ascii=False, **kwargs)
|
|
123
|
+
if path is not None:
|
|
124
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _from_json(cls, path: str | Path) -> Any:
|
|
129
|
+
"""Load from a JSON file."""
|
|
130
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
131
|
+
known = {f.name for f in dataclasses.fields(cls)}
|
|
132
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _to_yaml(self, path: str | Path | None = None) -> str:
|
|
136
|
+
"""Serialize to YAML string. Requires pyyaml."""
|
|
137
|
+
import yaml
|
|
138
|
+
|
|
139
|
+
data = self.to_dict()
|
|
140
|
+
text = yaml.dump(data, default_flow_style=False, allow_unicode=True)
|
|
141
|
+
if path is not None:
|
|
142
|
+
Path(path).write_text(text, encoding="utf-8")
|
|
143
|
+
return text
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _from_yaml(cls, path: str | Path) -> Any:
|
|
147
|
+
"""Load from a YAML file. Requires pyyaml."""
|
|
148
|
+
import yaml
|
|
149
|
+
|
|
150
|
+
data = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
151
|
+
known = {f.name for f in dataclasses.fields(cls)}
|
|
152
|
+
return cls(**{k: v for k, v in data.items() if k in known})
|
runq/_context.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""SDK Context object + initialization.
|
|
2
|
+
|
|
3
|
+
Three modes (see design doc §"Mode matrix"):
|
|
4
|
+
|
|
5
|
+
* daemon — RUNQ_TASK_ID set + RUNQ_SOCKET_PATH set + RUNQ_NO_DAEMON unset
|
|
6
|
+
* no_daemon — RUNQ_TASK_ID set + (RUNQ_SOCKET_PATH unset OR RUNQ_NO_DAEMON=1)
|
|
7
|
+
* manual — RUNQ_TASK_ID unset (running outside runq, local debug)
|
|
8
|
+
|
|
9
|
+
The module-level singleton `_ctx` is set by `context()` and consumed by
|
|
10
|
+
other SDK modules. Users get the Context back from `context()` and can
|
|
11
|
+
also call `get_ctx()` (mainly for internal use).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import uuid
|
|
18
|
+
import difflib
|
|
19
|
+
import hashlib
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# Module-level singleton. None until context() is called.
|
|
25
|
+
_ctx: Context | None = None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ParamDict(dict):
|
|
29
|
+
"""Dict subclass with helpful KeyError messages for parameter access.
|
|
30
|
+
|
|
31
|
+
When a key is missing, shows all available keys and suggests close
|
|
32
|
+
matches via fuzzy matching — catches the common "sweep YAML says
|
|
33
|
+
``learning_rate``, code says ``lr``" mismatch immediately.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __getitem__(self, key):
|
|
37
|
+
try:
|
|
38
|
+
return super().__getitem__(key)
|
|
39
|
+
except KeyError:
|
|
40
|
+
available = sorted(self.keys())
|
|
41
|
+
close = difflib.get_close_matches(str(key), available, n=3, cutoff=0.5)
|
|
42
|
+
msg = f"'{key}' not found in runq.params.\nAvailable: {available}"
|
|
43
|
+
if close:
|
|
44
|
+
msg += f"\nDid you mean: {close}?"
|
|
45
|
+
raise KeyError(msg) from None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class Context:
|
|
50
|
+
"""Per-task runtime context.
|
|
51
|
+
|
|
52
|
+
Populated from `RUNQ_*` env vars (daemon-injected) plus a local
|
|
53
|
+
`params.json`. Lazy users mostly interact via module-level helpers
|
|
54
|
+
(`runq.log_metric`, `runq.report`, ...) which pull state from the
|
|
55
|
+
singleton; this struct is the canonical home of all SDK state.
|
|
56
|
+
|
|
57
|
+
Fields are kept descriptive on purpose — debugging from `print(ctx)`
|
|
58
|
+
should be enough to see where the SDK thinks it lives.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
mode: str # "daemon" / "no_daemon" / "manual"
|
|
62
|
+
task_id: str
|
|
63
|
+
job_id: str = ""
|
|
64
|
+
project: str = ""
|
|
65
|
+
|
|
66
|
+
# Filesystem paths (None when the relevant env var wasn't set).
|
|
67
|
+
task_dir: Path | None = None
|
|
68
|
+
checkpoint_dir: Path | None = None
|
|
69
|
+
metrics_file: Path | None = None
|
|
70
|
+
# Three-file contract (RQ2-1): results.jsonl (runq.record data plane)
|
|
71
|
+
# and events.jsonl (lifecycle plane) live NEXT TO metrics.jsonl. Both
|
|
72
|
+
# are derived from metrics_file rather than env-injected, so an older
|
|
73
|
+
# daemon that only sets RUNQ_METRICS_FILE still yields correct paths.
|
|
74
|
+
results_file: Path | None = None
|
|
75
|
+
events_file: Path | None = None
|
|
76
|
+
|
|
77
|
+
# daemon-only fields
|
|
78
|
+
socket_path: str | None = None
|
|
79
|
+
|
|
80
|
+
# SDK contract: daemon always injects these, so we read them with a
|
|
81
|
+
# default for the manual mode (where there's no daemon).
|
|
82
|
+
safety_factor_percent: int = 110
|
|
83
|
+
safety_extra_gb: int = 0
|
|
84
|
+
|
|
85
|
+
# Loaded from RUNQ_PARAMS_FILE (or local params.json in manual mode).
|
|
86
|
+
params: dict = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
# ---- step bookkeeping (step 6) ----
|
|
89
|
+
#
|
|
90
|
+
# Single source of truth for "what step is this?". Maintained by:
|
|
91
|
+
# - ``report(metrics, step=N)`` — explicit step writes back here.
|
|
92
|
+
# - ``log_metric(..., step=N)`` — explicit step writes back here too,
|
|
93
|
+
# so the two helpers stay in sync.
|
|
94
|
+
# - ``loop()`` (step 8) — yields & sets per iteration.
|
|
95
|
+
#
|
|
96
|
+
# When None, log_metric / report write step=None to jsonl. There is
|
|
97
|
+
# NO auto-increment counter (unlike the early step 1 implementation);
|
|
98
|
+
# if you want incrementing steps without loop(), pass step explicitly.
|
|
99
|
+
current_step: int | None = None
|
|
100
|
+
|
|
101
|
+
# Deterministic seed derived from task_id. Different per task in the
|
|
102
|
+
# same sweep, but reproducible across reruns of the same task.
|
|
103
|
+
seed: int = 0
|
|
104
|
+
|
|
105
|
+
# ---- step-8 loop coordination ----
|
|
106
|
+
#
|
|
107
|
+
# Most-recent Decision returned by ``runq.report``. Read by
|
|
108
|
+
# ``runq.loop()`` after each iteration to decide whether to break.
|
|
109
|
+
# ``Any`` to dodge a forward ref to ``_report.Decision`` (would
|
|
110
|
+
# require a TYPE_CHECKING-only import dance).
|
|
111
|
+
_last_decision: Any | None = None
|
|
112
|
+
|
|
113
|
+
# ---- convenience accessors ----
|
|
114
|
+
|
|
115
|
+
def get(self, name: str, default: Any = None) -> Any:
|
|
116
|
+
"""Look up a param with a fallback default.
|
|
117
|
+
|
|
118
|
+
Equivalent to ``ctx.params.get(name, default)`` — common enough to
|
|
119
|
+
deserve a shorthand.
|
|
120
|
+
"""
|
|
121
|
+
return self.params.get(name, default)
|
|
122
|
+
|
|
123
|
+
# ---- wandb-like shape helpers (F9) ----
|
|
124
|
+
#
|
|
125
|
+
# SDK never imports wandb. These properties produce dicts shaped to
|
|
126
|
+
# be splatted into ``wandb.init(**...)`` / merged into
|
|
127
|
+
# ``wandb.log(...)``. User owns the wandb calls themselves.
|
|
128
|
+
#
|
|
129
|
+
# Kept inline (rather than delegated to a sibling module) so the
|
|
130
|
+
# only lazy import is the one ``wandb_like_metric`` needs for
|
|
131
|
+
# ``_report.latest_report_metrics``. No ``wandb`` import anywhere.
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def wandb_like_cfg(self) -> dict:
|
|
135
|
+
"""Kwargs dict for ``wandb.init(**ctx.wandb_like_cfg)``. See F9/P6.
|
|
136
|
+
|
|
137
|
+
When ``RUNQ_SWEEP_KEYS`` is set (P6 W&B integration), the run
|
|
138
|
+
name is built from sweep parameter key=value pairs so each task
|
|
139
|
+
in a sweep is immediately distinguishable in the W&B UI.
|
|
140
|
+
|
|
141
|
+
When ``RUNQ_JOB_NOTE`` is set, it becomes the W&B group — giving
|
|
142
|
+
the user's note semantics as the grouping label instead of a raw
|
|
143
|
+
job id.
|
|
144
|
+
|
|
145
|
+
Shape::
|
|
146
|
+
|
|
147
|
+
{
|
|
148
|
+
"project": self.project or "runq",
|
|
149
|
+
"name": "lr=0.01_bs=32" or f"{job_id}/{task_id}",
|
|
150
|
+
"group": job_note or job_id or None,
|
|
151
|
+
"config": dict(self.params),
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
``entity`` / ``mode`` are intentionally NOT included — those
|
|
155
|
+
come from the user's ``WANDB_*`` env / ``~/.netrc`` and runq
|
|
156
|
+
won't second-guess.
|
|
157
|
+
"""
|
|
158
|
+
sweep_keys_raw = os.environ.get("RUNQ_SWEEP_KEYS", "")
|
|
159
|
+
sweep_keys = [k.strip() for k in sweep_keys_raw.split(",") if k.strip()] if sweep_keys_raw else []
|
|
160
|
+
job_note = os.environ.get("RUNQ_JOB_NOTE", "")
|
|
161
|
+
|
|
162
|
+
if sweep_keys and self.params:
|
|
163
|
+
parts = [f"{k}={self.params[k]}" for k in sweep_keys if k in self.params]
|
|
164
|
+
name = "_".join(parts) if parts else (f"{self.job_id}/{self.task_id}" if self.job_id else self.task_id)
|
|
165
|
+
else:
|
|
166
|
+
name = f"{self.job_id}/{self.task_id}" if self.job_id else self.task_id
|
|
167
|
+
|
|
168
|
+
group = job_note if job_note else (self.job_id or None)
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
"project": self.project or "runq",
|
|
172
|
+
"name": name,
|
|
173
|
+
"group": group,
|
|
174
|
+
"config": dict(self.params or {}),
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def wandb_like_metric(self) -> dict:
|
|
179
|
+
"""Latest reported metrics for ``wandb.log({**ctx.wandb_like_metric, ...})``.
|
|
180
|
+
|
|
181
|
+
Empty dict if no report has happened yet. Caller merges their
|
|
182
|
+
own extras at the call site::
|
|
183
|
+
|
|
184
|
+
wandb.log({**ctx.wandb_like_metric, "lr": lr}, step=epoch)
|
|
185
|
+
|
|
186
|
+
The returned dict is a copy — runq's internal history is not
|
|
187
|
+
exposed to mutation.
|
|
188
|
+
"""
|
|
189
|
+
# Lazy import to avoid context ↔ report cycle (_report imports
|
|
190
|
+
# from _context at module load).
|
|
191
|
+
from . import _report
|
|
192
|
+
return _report.latest_report_metrics()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
# ---- mode detection ----
|
|
196
|
+
|
|
197
|
+
def _truthy(s: str | None) -> bool:
|
|
198
|
+
"""Lenient truthy parse for env vars. Empty / "0" / "false" → False."""
|
|
199
|
+
if not s:
|
|
200
|
+
return False
|
|
201
|
+
return s.strip().lower() not in ("0", "false", "no", "off")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _detect_mode() -> str:
|
|
205
|
+
"""Resolve which mode the SDK is operating in.
|
|
206
|
+
|
|
207
|
+
Order matters:
|
|
208
|
+
1. No RUNQ_TASK_ID → user is running outside runq → manual.
|
|
209
|
+
2. RUNQ_NO_DAEMON=1 (explicit HPC opt-out) → no_daemon.
|
|
210
|
+
3. No socket path → daemon isn't reachable → no_daemon.
|
|
211
|
+
4. Otherwise daemon.
|
|
212
|
+
"""
|
|
213
|
+
if not os.environ.get("RUNQ_TASK_ID"):
|
|
214
|
+
return "manual"
|
|
215
|
+
if _truthy(os.environ.get("RUNQ_NO_DAEMON")):
|
|
216
|
+
return "no_daemon"
|
|
217
|
+
if not os.environ.get("RUNQ_SOCKET_PATH"):
|
|
218
|
+
return "no_daemon"
|
|
219
|
+
return "daemon"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _load_params(path: Path | None) -> dict:
|
|
223
|
+
"""Strict params.json loader. Missing file → empty dict; broken file
|
|
224
|
+
→ RuntimeError. We surface load failures because silent {} would let
|
|
225
|
+
the user run with the wrong hyperparameters and only notice hours later.
|
|
226
|
+
"""
|
|
227
|
+
if path is None or not path.exists():
|
|
228
|
+
return {}
|
|
229
|
+
try:
|
|
230
|
+
with open(path, encoding="utf-8") as f:
|
|
231
|
+
data = json.load(f)
|
|
232
|
+
except (OSError, json.JSONDecodeError) as e:
|
|
233
|
+
raise RuntimeError(f"runq: failed to load params from {path}: {e}") from e
|
|
234
|
+
if not isinstance(data, dict):
|
|
235
|
+
# Out of the try block on purpose — this is a structural error,
|
|
236
|
+
# not a parse failure, and we want the message to be specific.
|
|
237
|
+
raise RuntimeError(
|
|
238
|
+
f"runq: params file {path} must contain a JSON object, "
|
|
239
|
+
f"got {type(data).__name__}"
|
|
240
|
+
)
|
|
241
|
+
return data
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _int_env(name: str, default: int) -> int:
|
|
245
|
+
"""Parse an int env var with a default. Returns default on parse error."""
|
|
246
|
+
raw = os.environ.get(name)
|
|
247
|
+
if raw is None or raw == "":
|
|
248
|
+
return default
|
|
249
|
+
try:
|
|
250
|
+
return int(raw)
|
|
251
|
+
except ValueError:
|
|
252
|
+
return default
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def context() -> Context:
|
|
256
|
+
"""Initialize the runq SDK context.
|
|
257
|
+
|
|
258
|
+
Idempotent: calling twice returns the same singleton. Reads
|
|
259
|
+
``RUNQ_*`` env vars, loads ``params.json``, sets up the
|
|
260
|
+
``metrics.jsonl`` sink, and stores the singleton for other SDK
|
|
261
|
+
modules to consume.
|
|
262
|
+
|
|
263
|
+
Returns the :class:`Context`. Lazy users usually do not need to keep
|
|
264
|
+
the return value because module-level helpers (``runq.log_metric``,
|
|
265
|
+
``runq.report``) read the singleton themselves.
|
|
266
|
+
|
|
267
|
+
Modes:
|
|
268
|
+
|
|
269
|
+
* ``daemon`` — talks to a runq daemon over a unix socket.
|
|
270
|
+
* ``no_daemon`` — HPC mode; daemon not reachable. Writes jsonl
|
|
271
|
+
locally, ``safe_save`` will raise ``RunqDiskFullError`` on disk
|
|
272
|
+
pressure instead of freezing.
|
|
273
|
+
* ``manual`` — user is running their script directly without runq.
|
|
274
|
+
jsonl goes to ``./runq_metrics.jsonl``, task IDs are local UUIDs,
|
|
275
|
+
no daemon comm. Useful for IDE debugging.
|
|
276
|
+
"""
|
|
277
|
+
global _ctx
|
|
278
|
+
if _ctx is not None:
|
|
279
|
+
return _ctx
|
|
280
|
+
|
|
281
|
+
mode = _detect_mode()
|
|
282
|
+
env = os.environ
|
|
283
|
+
|
|
284
|
+
if mode == "manual":
|
|
285
|
+
# Local mode: generate a short id, write jsonl into cwd, look for
|
|
286
|
+
# a sibling params.json (optional — most local debug runs have
|
|
287
|
+
# nothing).
|
|
288
|
+
task_id = "manual-" + uuid.uuid4().hex[:8]
|
|
289
|
+
task_dir: Path | None = None
|
|
290
|
+
checkpoint_dir: Path | None = None
|
|
291
|
+
metrics_file: Path | None = Path.cwd() / "runq_metrics.jsonl"
|
|
292
|
+
results_file: Path | None = Path.cwd() / "runq_results.jsonl"
|
|
293
|
+
events_file: Path | None = Path.cwd() / "runq_events.jsonl"
|
|
294
|
+
local_params = Path.cwd() / "params.json"
|
|
295
|
+
params = _load_params(local_params) if local_params.exists() else {}
|
|
296
|
+
else:
|
|
297
|
+
# daemon / no_daemon: daemon injects all the RUNQ_* vars.
|
|
298
|
+
task_id = env["RUNQ_TASK_ID"]
|
|
299
|
+
task_dir = Path(env["RUNQ_TASK_DIR"]) if env.get("RUNQ_TASK_DIR") else None
|
|
300
|
+
checkpoint_dir = (
|
|
301
|
+
Path(env["RUNQ_CHECKPOINT_DIR"]) if env.get("RUNQ_CHECKPOINT_DIR") else None
|
|
302
|
+
)
|
|
303
|
+
metrics_file = (
|
|
304
|
+
Path(env["RUNQ_METRICS_FILE"]) if env.get("RUNQ_METRICS_FILE") else None
|
|
305
|
+
)
|
|
306
|
+
# Siblings of metrics.jsonl (see Context field comment). The daemon's
|
|
307
|
+
# reap reads the same fixed names via workspace.ResultsPath/EventsPath.
|
|
308
|
+
results_file = metrics_file.with_name("results.jsonl") if metrics_file else None
|
|
309
|
+
events_file = metrics_file.with_name("events.jsonl") if metrics_file else None
|
|
310
|
+
params_file = env.get("RUNQ_PARAMS_FILE")
|
|
311
|
+
params = _load_params(Path(params_file)) if params_file else {}
|
|
312
|
+
|
|
313
|
+
_ctx = Context(
|
|
314
|
+
mode=mode,
|
|
315
|
+
task_id=task_id,
|
|
316
|
+
job_id=env.get("RUNQ_JOB_ID", ""),
|
|
317
|
+
project=env.get("RUNQ_PROJECT_NAME", ""),
|
|
318
|
+
task_dir=task_dir,
|
|
319
|
+
checkpoint_dir=checkpoint_dir,
|
|
320
|
+
metrics_file=metrics_file,
|
|
321
|
+
results_file=results_file,
|
|
322
|
+
events_file=events_file,
|
|
323
|
+
socket_path=env.get("RUNQ_SOCKET_PATH"),
|
|
324
|
+
safety_factor_percent=_int_env("RUNQ_SAFETY_FACTOR_PERCENT", 110),
|
|
325
|
+
safety_extra_gb=_int_env("RUNQ_SAFETY_EXTRA_GB", 0),
|
|
326
|
+
params=ParamDict(params),
|
|
327
|
+
seed=int(hashlib.sha256(task_id.encode()).hexdigest(), 16) % (2**32),
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
# Make sure dirs we will write to actually exist. Cheap, idempotent,
|
|
331
|
+
# and prevents a confusing first-write failure mid-training.
|
|
332
|
+
if _ctx.metrics_file is not None:
|
|
333
|
+
_ctx.metrics_file.parent.mkdir(parents=True, exist_ok=True)
|
|
334
|
+
if _ctx.checkpoint_dir is not None:
|
|
335
|
+
_ctx.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
|
336
|
+
|
|
337
|
+
# Step 7: if the user's job config carried an ``early_stopping:``
|
|
338
|
+
# block, build the matching built-in policy and register it.
|
|
339
|
+
# Done LAST so all ctx fields are populated by the time the policy
|
|
340
|
+
# factories run.
|
|
341
|
+
# Local import to keep _context.py free of a circular dep with
|
|
342
|
+
# _policies → _report → _context.
|
|
343
|
+
from . import _policies
|
|
344
|
+
_policies.maybe_register_from_params(_ctx)
|
|
345
|
+
|
|
346
|
+
return _ctx
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def get_ctx() -> Context:
|
|
350
|
+
"""Return the global context. Raises if ``context()`` was not called.
|
|
351
|
+
|
|
352
|
+
Mostly internal — user code usually keeps the return value of
|
|
353
|
+
``context()`` directly. Exposed for SDK helpers that don't have a
|
|
354
|
+
handle on it.
|
|
355
|
+
"""
|
|
356
|
+
if _ctx is None:
|
|
357
|
+
raise RuntimeError(
|
|
358
|
+
"runq: context() must be called before using other runq APIs. "
|
|
359
|
+
"Add `runq.context()` near the top of your script."
|
|
360
|
+
)
|
|
361
|
+
return _ctx
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---- testing helper ----
|
|
365
|
+
#
|
|
366
|
+
# Not part of the public API but useful in tests to reset the singleton
|
|
367
|
+
# between test cases. Marked with underscore to discourage user calls.
|
|
368
|
+
def _reset_for_tests() -> None:
|
|
369
|
+
global _ctx
|
|
370
|
+
_ctx = None
|