researchium 0.1.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.
- researchium/__init__.py +59 -0
- researchium/cli.py +109 -0
- researchium/environment.py +179 -0
- researchium/experiments.py +213 -0
- researchium/runtime.py +182 -0
- researchium-0.1.0.dist-info/METADATA +475 -0
- researchium-0.1.0.dist-info/RECORD +10 -0
- researchium-0.1.0.dist-info/WHEEL +4 -0
- researchium-0.1.0.dist-info/entry_points.txt +2 -0
- researchium-0.1.0.dist-info/licenses/LICENSE +21 -0
researchium/__init__.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Researchium - a research module for organizing and analyzing scientific work."""
|
|
2
|
+
|
|
3
|
+
from researchium.environment import (
|
|
4
|
+
Environment,
|
|
5
|
+
EnvironmentExistsError,
|
|
6
|
+
EnvironmentNotFoundError,
|
|
7
|
+
ResearchiumError,
|
|
8
|
+
discover_environment,
|
|
9
|
+
initialize_environment,
|
|
10
|
+
is_initialized,
|
|
11
|
+
)
|
|
12
|
+
from researchium.experiments import (
|
|
13
|
+
Experiment,
|
|
14
|
+
ExperimentExistsError,
|
|
15
|
+
ExperimentNotFoundError,
|
|
16
|
+
InvalidExperimentNameError,
|
|
17
|
+
create_experiment,
|
|
18
|
+
get_experiment,
|
|
19
|
+
get_experiment_results,
|
|
20
|
+
list_experiments,
|
|
21
|
+
search_experiments,
|
|
22
|
+
)
|
|
23
|
+
from researchium.runtime import (
|
|
24
|
+
Config,
|
|
25
|
+
Results,
|
|
26
|
+
RunFailedError,
|
|
27
|
+
RunScriptNotFoundError,
|
|
28
|
+
format_configuration,
|
|
29
|
+
format_run_results,
|
|
30
|
+
run_experiment,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
__all__ = [
|
|
34
|
+
"Config",
|
|
35
|
+
"Environment",
|
|
36
|
+
"EnvironmentExistsError",
|
|
37
|
+
"EnvironmentNotFoundError",
|
|
38
|
+
"Experiment",
|
|
39
|
+
"ExperimentExistsError",
|
|
40
|
+
"ExperimentNotFoundError",
|
|
41
|
+
"InvalidExperimentNameError",
|
|
42
|
+
"ResearchiumError",
|
|
43
|
+
"Results",
|
|
44
|
+
"RunFailedError",
|
|
45
|
+
"RunScriptNotFoundError",
|
|
46
|
+
"create_experiment",
|
|
47
|
+
"discover_environment",
|
|
48
|
+
"format_configuration",
|
|
49
|
+
"format_run_results",
|
|
50
|
+
"get_experiment",
|
|
51
|
+
"get_experiment_results",
|
|
52
|
+
"initialize_environment",
|
|
53
|
+
"is_initialized",
|
|
54
|
+
"list_experiments",
|
|
55
|
+
"run_experiment",
|
|
56
|
+
"search_experiments",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
__version__ = "0.1.0"
|
researchium/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Command-line interface for Researchium."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
|
|
9
|
+
from researchium import (
|
|
10
|
+
ResearchiumError,
|
|
11
|
+
create_experiment,
|
|
12
|
+
format_configuration,
|
|
13
|
+
format_run_results,
|
|
14
|
+
initialize_environment,
|
|
15
|
+
run_experiment,
|
|
16
|
+
search_experiments,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="researchium", description="Organize and track research experiments."
|
|
23
|
+
)
|
|
24
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
25
|
+
|
|
26
|
+
init = subparsers.add_parser("init", help="Initialize a research environment.")
|
|
27
|
+
init.add_argument("name", help="Name of the research project.")
|
|
28
|
+
|
|
29
|
+
create = subparsers.add_parser("create", help="Create a new experiment.")
|
|
30
|
+
create.add_argument("name", help="Experiment name (used as the folder name).")
|
|
31
|
+
create.add_argument("description", help="Brief description of the experiment.")
|
|
32
|
+
|
|
33
|
+
search = subparsers.add_parser(
|
|
34
|
+
"search", help="Search experiments by name or description."
|
|
35
|
+
)
|
|
36
|
+
search.add_argument("query", help="Text to search for.")
|
|
37
|
+
|
|
38
|
+
config = subparsers.add_parser(
|
|
39
|
+
"config", help="Show an experiment's configuration as key: value lines."
|
|
40
|
+
)
|
|
41
|
+
config.add_argument("identifier", help="ID or name of the experiment.")
|
|
42
|
+
|
|
43
|
+
results = subparsers.add_parser(
|
|
44
|
+
"results", help="Show an experiment's results as key: value lines."
|
|
45
|
+
)
|
|
46
|
+
results.add_argument("identifier", help="ID or name of the experiment.")
|
|
47
|
+
|
|
48
|
+
run = subparsers.add_parser(
|
|
49
|
+
"run", help="Run an experiment's script and store its config and results."
|
|
50
|
+
)
|
|
51
|
+
run.add_argument("identifier", help="ID or name of the experiment.")
|
|
52
|
+
|
|
53
|
+
return parser
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
57
|
+
args = build_parser().parse_args(argv)
|
|
58
|
+
try:
|
|
59
|
+
return _dispatch(args)
|
|
60
|
+
except ResearchiumError as exc:
|
|
61
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
62
|
+
return 1
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dispatch(args: argparse.Namespace) -> int:
|
|
66
|
+
if args.command == "init":
|
|
67
|
+
env = initialize_environment(name=args.name)
|
|
68
|
+
print(f"Initialized researchium environment at {env.root}")
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
if args.command == "create":
|
|
72
|
+
experiment = create_experiment(args.name, args.description)
|
|
73
|
+
print(
|
|
74
|
+
f"Created experiment {experiment.name} "
|
|
75
|
+
f"(ID {experiment.id}) at {experiment.path}"
|
|
76
|
+
)
|
|
77
|
+
return 0
|
|
78
|
+
|
|
79
|
+
if args.command == "search":
|
|
80
|
+
experiments = search_experiments(args.query)
|
|
81
|
+
if not experiments:
|
|
82
|
+
print(f"No experiments matching {args.query!r}.")
|
|
83
|
+
return 0
|
|
84
|
+
for experiment in experiments:
|
|
85
|
+
print(
|
|
86
|
+
f"- {experiment.name} (ID {experiment.id}): {experiment.description}"
|
|
87
|
+
)
|
|
88
|
+
return 0
|
|
89
|
+
|
|
90
|
+
if args.command == "config":
|
|
91
|
+
print(format_configuration(args.identifier))
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if args.command == "results":
|
|
95
|
+
print(format_run_results(args.identifier))
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
if args.command == "run":
|
|
99
|
+
experiment = run_experiment(args.identifier)
|
|
100
|
+
print(f"Ran experiment {experiment.name} (ID {experiment.id})")
|
|
101
|
+
print(f" config -> experiments/{experiment.name}/config.md, config.json")
|
|
102
|
+
print(f" results -> results/{experiment.name}/results.md, results.json")
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
raise SystemExit("unknown command") # pragma: no cover
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__": # pragma: no cover
|
|
109
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Environment awareness: initialize and discover Researchium environments.
|
|
2
|
+
|
|
3
|
+
An environment is a directory carrying a hidden ``.researchium/`` marker,
|
|
4
|
+
mirroring how ``git`` owns a directory with ``.git`` and ``uv`` owns one with
|
|
5
|
+
``uv.lock``. Any command that needs context reports the nearest environment
|
|
6
|
+
found by walking up from the current directory.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from datetime import date
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import researchium
|
|
17
|
+
|
|
18
|
+
MARKER_DIR_NAME = ".researchium"
|
|
19
|
+
METADATA_FILE_NAME = "environment.json"
|
|
20
|
+
EXPERIMENTS_NOTEBOOK = "experiments.md"
|
|
21
|
+
EXPERIMENTS_REGISTRY = "experiments.json"
|
|
22
|
+
REGISTRY_VERSION = 1
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ResearchiumError(Exception):
|
|
26
|
+
"""Base error raised by Researchium."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EnvironmentExistsError(ResearchiumError):
|
|
30
|
+
"""Raised when initializing an already-initialized directory."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class EnvironmentNotFoundError(ResearchiumError):
|
|
34
|
+
"""Raised when no environment can be discovered from a directory."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class Environment:
|
|
39
|
+
"""A discovered Researchium environment."""
|
|
40
|
+
|
|
41
|
+
root: Path
|
|
42
|
+
name: str
|
|
43
|
+
created: date
|
|
44
|
+
researchium_version: str
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def experiments_dir(self) -> Path:
|
|
48
|
+
return self.root / "experiments"
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def src_dir(self) -> Path:
|
|
52
|
+
return self.root / "src"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def results_dir(self) -> Path:
|
|
56
|
+
return self.root / "results"
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def experiments_notebook(self) -> Path:
|
|
60
|
+
return self.root / EXPERIMENTS_NOTEBOOK
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def experiments_registry(self) -> Path:
|
|
64
|
+
return self.root / EXPERIMENTS_REGISTRY
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def initialize_environment(
|
|
68
|
+
path: str | Path = ".", name: str | None = None
|
|
69
|
+
) -> Environment:
|
|
70
|
+
"""Create the standard Researchium structure and register the directory.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
path: Directory to initialize. Created if it does not exist.
|
|
74
|
+
name: Environment name. Defaults to the directory's basename.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The newly created ``Environment``.
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
EnvironmentExistsError: If ``path`` is already a Researchium environment.
|
|
81
|
+
"""
|
|
82
|
+
root = Path(path).resolve()
|
|
83
|
+
if (root / MARKER_DIR_NAME).exists():
|
|
84
|
+
raise EnvironmentExistsError(f"{root} is already a Researchium environment.")
|
|
85
|
+
|
|
86
|
+
experiments = root / "experiments"
|
|
87
|
+
src = root / "src"
|
|
88
|
+
results = root / "results"
|
|
89
|
+
|
|
90
|
+
experiments.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
src.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
results.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
|
|
94
|
+
_write_experiments_notebook(root, name)
|
|
95
|
+
_write_experiments_registry(root)
|
|
96
|
+
marker = _write_marker(root, name)
|
|
97
|
+
|
|
98
|
+
return _load_environment(marker)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_initialized(path: str | Path = ".") -> bool:
|
|
102
|
+
"""Return ``True`` if ``path`` is directly an initialized environment."""
|
|
103
|
+
return (Path(path).resolve() / MARKER_DIR_NAME).is_dir()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def discover_environment(path: str | Path = ".") -> Environment:
|
|
107
|
+
"""Return the nearest environment at ``path``, walking up parent directories.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
EnvironmentNotFoundError: If no ancestor directory is initialized.
|
|
111
|
+
"""
|
|
112
|
+
start = Path(path).resolve()
|
|
113
|
+
for current in (start, *start.parents):
|
|
114
|
+
marker = current / MARKER_DIR_NAME
|
|
115
|
+
if marker.is_dir():
|
|
116
|
+
return _load_environment(marker)
|
|
117
|
+
raise EnvironmentNotFoundError(
|
|
118
|
+
f"No Researchium environment found at or above {start}. "
|
|
119
|
+
f"Run `researchium init` to create one."
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _write_experiments_notebook(root: Path, name: str | None) -> None:
|
|
124
|
+
created = date.today()
|
|
125
|
+
header = name or root.name
|
|
126
|
+
content = (
|
|
127
|
+
f"# {header}\n"
|
|
128
|
+
"\n"
|
|
129
|
+
"Researchium environment initialized on "
|
|
130
|
+
f"{created.isoformat()}.\n"
|
|
131
|
+
"\n"
|
|
132
|
+
"## Experiments\n"
|
|
133
|
+
"\n"
|
|
134
|
+
"Each experiment has a folder in `experiments/` and a section below.\n"
|
|
135
|
+
)
|
|
136
|
+
(root / EXPERIMENTS_NOTEBOOK).write_text(content, encoding="utf-8")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def load_experiments_registry(path: str | Path) -> list[dict]:
|
|
140
|
+
"""Load experiment metadata records from ``path`` (empty list if absent)."""
|
|
141
|
+
registry = Path(path)
|
|
142
|
+
if not registry.exists():
|
|
143
|
+
return []
|
|
144
|
+
data = json.loads(registry.read_text(encoding="utf-8"))
|
|
145
|
+
return data["experiments"]
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def save_experiments_registry(path: str | Path, experiments: list[dict]) -> None:
|
|
149
|
+
"""Write experiment metadata records to ``path``."""
|
|
150
|
+
data = {"version": REGISTRY_VERSION, "experiments": experiments}
|
|
151
|
+
Path(path).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _write_experiments_registry(root: Path) -> None:
|
|
155
|
+
save_experiments_registry(root / EXPERIMENTS_REGISTRY, [])
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _write_marker(root: Path, name: str | None) -> Path:
|
|
159
|
+
marker = root / MARKER_DIR_NAME
|
|
160
|
+
marker.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
metadata = {
|
|
162
|
+
"name": name or root.name,
|
|
163
|
+
"created": date.today().isoformat(),
|
|
164
|
+
"researchium": researchium.__version__,
|
|
165
|
+
}
|
|
166
|
+
(marker / METADATA_FILE_NAME).write_text(
|
|
167
|
+
json.dumps(metadata, indent=2) + "\n", encoding="utf-8"
|
|
168
|
+
)
|
|
169
|
+
return marker
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _load_environment(marker: Path) -> Environment:
|
|
173
|
+
metadata = json.loads((marker / METADATA_FILE_NAME).read_text(encoding="utf-8"))
|
|
174
|
+
return Environment(
|
|
175
|
+
root=marker.parent,
|
|
176
|
+
name=metadata["name"],
|
|
177
|
+
created=date.fromisoformat(metadata["created"]),
|
|
178
|
+
researchium_version=metadata["researchium"],
|
|
179
|
+
)
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Experiment management inside a Researchium environment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from researchium.environment import (
|
|
12
|
+
Environment,
|
|
13
|
+
ResearchiumError,
|
|
14
|
+
discover_environment,
|
|
15
|
+
load_experiments_registry,
|
|
16
|
+
save_experiments_registry,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ExperimentExistsError(ResearchiumError):
|
|
21
|
+
"""Raised when an experiment name is already in use."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvalidExperimentNameError(ResearchiumError):
|
|
25
|
+
"""Raised when an experiment name is not a safe folder name."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExperimentNotFoundError(ResearchiumError):
|
|
29
|
+
"""Raised when no experiment matches a given ID or name."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Experiment:
|
|
34
|
+
"""A single experiment living inside an environment's experiments folder."""
|
|
35
|
+
|
|
36
|
+
id: str
|
|
37
|
+
name: str
|
|
38
|
+
description: str
|
|
39
|
+
created: date
|
|
40
|
+
environment: Environment
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def path(self) -> Path:
|
|
44
|
+
return self.environment.experiments_dir / self.name
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def results_dir(self) -> Path:
|
|
48
|
+
return self.environment.results_dir / self.name
|
|
49
|
+
|
|
50
|
+
def to_metadata(self) -> dict:
|
|
51
|
+
"""Return the record stored in ``experiments.json``."""
|
|
52
|
+
return {
|
|
53
|
+
"id": self.id,
|
|
54
|
+
"name": self.name,
|
|
55
|
+
"description": self.description,
|
|
56
|
+
"created": self.created.isoformat(),
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def create_experiment(
|
|
61
|
+
name: str, description: str, path: str | Path = "."
|
|
62
|
+
) -> Experiment:
|
|
63
|
+
"""Create an experiment folder and log it in the experiments notebook.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
name: Experiment name, also used as the folder name.
|
|
67
|
+
description: Brief description logged in ``experiments.md``.
|
|
68
|
+
path: Where to start looking for the environment. Defaults to the
|
|
69
|
+
current directory and walks up from there.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
The created ``Experiment``.
|
|
73
|
+
|
|
74
|
+
Raises:
|
|
75
|
+
EnvironmentNotFoundError: If no environment is found at ``path``.
|
|
76
|
+
InvalidExperimentNameError: If ``name`` is empty or a path separator.
|
|
77
|
+
ExperimentExistsError: If the experiment folder or notebook entry
|
|
78
|
+
already exists.
|
|
79
|
+
"""
|
|
80
|
+
env = discover_environment(path)
|
|
81
|
+
_validate_name(name)
|
|
82
|
+
|
|
83
|
+
folder = env.experiments_dir / name
|
|
84
|
+
if folder.exists():
|
|
85
|
+
raise ExperimentExistsError(f"Experiment {name!r} already exists at {folder}.")
|
|
86
|
+
if _has_notebook_entry(env.experiments_notebook, name):
|
|
87
|
+
raise ExperimentExistsError(
|
|
88
|
+
f"Experiment {name!r} is already logged in the experiments notebook."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
created = date.today()
|
|
92
|
+
experiment = Experiment(
|
|
93
|
+
id=_generate_id(),
|
|
94
|
+
name=name,
|
|
95
|
+
description=description,
|
|
96
|
+
created=created,
|
|
97
|
+
environment=env,
|
|
98
|
+
)
|
|
99
|
+
folder.mkdir()
|
|
100
|
+
experiment.results_dir.mkdir(parents=True)
|
|
101
|
+
_append_notebook_entry(
|
|
102
|
+
env.experiments_notebook, experiment.id, name, description, created
|
|
103
|
+
)
|
|
104
|
+
_add_to_registry(env, experiment)
|
|
105
|
+
|
|
106
|
+
return experiment
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_experiments(path: str | Path = ".") -> list[Experiment]:
|
|
110
|
+
"""Return all experiments in the environment at ``path``, sorted by name."""
|
|
111
|
+
env = discover_environment(path)
|
|
112
|
+
records = load_experiments_registry(env.experiments_registry)
|
|
113
|
+
experiments = [_experiment_from_record(env, record) for record in records]
|
|
114
|
+
return sorted(experiments, key=lambda experiment: experiment.name)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def search_experiments(query: str, path: str | Path = ".") -> list[Experiment]:
|
|
118
|
+
"""Return experiments whose name or description contains ``query``."""
|
|
119
|
+
needle = query.strip().lower()
|
|
120
|
+
if not needle:
|
|
121
|
+
return []
|
|
122
|
+
return [
|
|
123
|
+
experiment
|
|
124
|
+
for experiment in list_experiments(path)
|
|
125
|
+
if needle in experiment.name.lower()
|
|
126
|
+
or needle in experiment.description.lower()
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def get_experiment(identifier: str, path: str | Path = ".") -> Experiment:
|
|
131
|
+
"""Return the experiment registered under an ID or name.
|
|
132
|
+
|
|
133
|
+
IDs are checked first, then names (case-insensitive).
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
ExperimentNotFoundError: If no experiment matches ``identifier``.
|
|
137
|
+
"""
|
|
138
|
+
experiments = list_experiments(path)
|
|
139
|
+
for experiment in experiments:
|
|
140
|
+
if experiment.id == identifier:
|
|
141
|
+
return experiment
|
|
142
|
+
for experiment in experiments:
|
|
143
|
+
if experiment.name.lower() == identifier.lower():
|
|
144
|
+
return experiment
|
|
145
|
+
raise ExperimentNotFoundError(
|
|
146
|
+
f"No experiment with ID or name {identifier!r}."
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_experiment_results(
|
|
151
|
+
identifier: str, path: str | Path = "."
|
|
152
|
+
) -> list[Path]:
|
|
153
|
+
"""Return the result files of an experiment, sorted by name.
|
|
154
|
+
|
|
155
|
+
``identifier`` may be an experiment ID or name.
|
|
156
|
+
"""
|
|
157
|
+
experiment = get_experiment(identifier, path)
|
|
158
|
+
results = experiment.results_dir
|
|
159
|
+
if not results.is_dir():
|
|
160
|
+
return []
|
|
161
|
+
return sorted(path for path in results.iterdir() if path.is_file())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _experiment_from_record(env: Environment, record: dict) -> Experiment:
|
|
165
|
+
return Experiment(
|
|
166
|
+
id=record["id"],
|
|
167
|
+
name=record["name"],
|
|
168
|
+
description=record["description"],
|
|
169
|
+
created=date.fromisoformat(record["created"]),
|
|
170
|
+
environment=env,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _generate_id() -> str:
|
|
175
|
+
return uuid.uuid4().hex[:8]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _validate_name(name: str) -> None:
|
|
179
|
+
if not name or name.strip() in {".", ".."}:
|
|
180
|
+
raise InvalidExperimentNameError("Experiment name may not be empty.")
|
|
181
|
+
if "/" in name or "\\" in name:
|
|
182
|
+
raise InvalidExperimentNameError(
|
|
183
|
+
"Experiment name may not contain path separators."
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _has_notebook_entry(notebook: Path, name: str) -> bool:
|
|
188
|
+
if not notebook.exists():
|
|
189
|
+
return False
|
|
190
|
+
text = notebook.read_text(encoding="utf-8")
|
|
191
|
+
return re.search(rf"^### {re.escape(name)}\s*$", text, re.MULTILINE) is not None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _append_notebook_entry(
|
|
195
|
+
notebook: Path, experiment_id: str, name: str, description: str, created: date
|
|
196
|
+
) -> None:
|
|
197
|
+
entry = (
|
|
198
|
+
f"\n### {name}\n"
|
|
199
|
+
f"\n"
|
|
200
|
+
f"{description}\n"
|
|
201
|
+
f"\n"
|
|
202
|
+
f"- ID: `{experiment_id}`\n"
|
|
203
|
+
f"- Created: {created.isoformat()}\n"
|
|
204
|
+
f"- Folder: `experiments/{name}`\n"
|
|
205
|
+
)
|
|
206
|
+
with notebook.open("a", encoding="utf-8") as handle:
|
|
207
|
+
handle.write(entry)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _add_to_registry(env: Environment, experiment: Experiment) -> None:
|
|
211
|
+
experiments = load_experiments_registry(env.experiments_registry)
|
|
212
|
+
experiments.append(experiment.to_metadata())
|
|
213
|
+
save_experiments_registry(env.experiments_registry, experiments)
|
researchium/runtime.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Ephemeral run-time registries and the experiment runner."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import runpy
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from researchium.environment import ResearchiumError
|
|
11
|
+
from researchium.experiments import Experiment, get_experiment
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RunScriptNotFoundError(ResearchiumError):
|
|
15
|
+
"""Raised when an experiment has no run script."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RunFailedError(ResearchiumError):
|
|
19
|
+
"""Raised when an experiment's run script raises."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RunStorage(dict):
|
|
23
|
+
"""Mutable registry that a run script fills in at runtime."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
Config = RunStorage()
|
|
27
|
+
Results = RunStorage()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_experiment(identifier: str, path: str | Path = ".") -> Experiment:
|
|
31
|
+
"""Execute an experiment's run script and persist its config and results.
|
|
32
|
+
|
|
33
|
+
Runs ``experiments/<name>/run.py`` with a fresh ``Config``/``Results``,
|
|
34
|
+
then writes:
|
|
35
|
+
|
|
36
|
+
- ``experiments/<name>/config.md`` / ``config.json``
|
|
37
|
+
- ``results/<name>/results.md`` / ``results.json``
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
identifier: Experiment ID or name.
|
|
41
|
+
path: Where to start looking for the environment.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The executed ``Experiment``.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
RunScriptNotFoundError: If the experiment has no ``run.py``.
|
|
48
|
+
RunFailedError: If the run script raises.
|
|
49
|
+
"""
|
|
50
|
+
experiment = get_experiment(identifier, path)
|
|
51
|
+
script = experiment.path / "run.py"
|
|
52
|
+
if not script.is_file():
|
|
53
|
+
raise RunScriptNotFoundError(
|
|
54
|
+
f"Experiment {experiment.name!r} has no run script at {script}. "
|
|
55
|
+
f"Create experiments/{experiment.name}/run.py first."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
Config.clear()
|
|
59
|
+
Results.clear()
|
|
60
|
+
try:
|
|
61
|
+
runpy.run_path(str(script), run_name="__main__")
|
|
62
|
+
except Exception as exc:
|
|
63
|
+
raise RunFailedError(
|
|
64
|
+
f"Experiment {experiment.name!r} failed during run: {exc}"
|
|
65
|
+
) from exc
|
|
66
|
+
|
|
67
|
+
write_run_outputs(experiment)
|
|
68
|
+
return experiment
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def write_run_outputs(experiment: Experiment) -> list[Path]:
|
|
72
|
+
"""Write the current ``Config``/``Results`` for an experiment to disk."""
|
|
73
|
+
run_at = datetime.now().strftime("%Y-%m-%d %H:%M")
|
|
74
|
+
config_md = experiment.path / "config.md"
|
|
75
|
+
config_md.write_text(
|
|
76
|
+
_format_document(experiment, "Configuration", Config, run_at),
|
|
77
|
+
encoding="utf-8",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
experiment.results_dir.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
results_md = experiment.results_dir / "results.md"
|
|
82
|
+
results_md.write_text(
|
|
83
|
+
_format_document(experiment, "Results", Results, run_at),
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
_remove_stale_readme(experiment)
|
|
88
|
+
_write_json(experiment.path / "config.json", Config)
|
|
89
|
+
_write_json(experiment.results_dir / "results.json", Results)
|
|
90
|
+
|
|
91
|
+
return [
|
|
92
|
+
config_md,
|
|
93
|
+
experiment.path / "config.json",
|
|
94
|
+
results_md,
|
|
95
|
+
experiment.results_dir / "results.json",
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def format_configuration(identifier: str, path: str | Path = ".") -> str:
|
|
100
|
+
"""Return an experiment's configuration as ``key: value`` lines."""
|
|
101
|
+
experiment = get_experiment(identifier, path)
|
|
102
|
+
config_json = experiment.path / "config.json"
|
|
103
|
+
if not config_json.is_file():
|
|
104
|
+
return "(no configuration yet)"
|
|
105
|
+
records = json.loads(config_json.read_text(encoding="utf-8"))
|
|
106
|
+
return _format_key_values(records)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def format_run_results(identifier: str, path: str | Path = ".") -> str:
|
|
110
|
+
"""Return an experiment's results as ``key: value`` lines.
|
|
111
|
+
|
|
112
|
+
Falls back to a file listing when the experiment has no ``results.json``.
|
|
113
|
+
"""
|
|
114
|
+
experiment = get_experiment(identifier, path)
|
|
115
|
+
results_json = experiment.results_dir / "results.json"
|
|
116
|
+
if results_json.is_file():
|
|
117
|
+
records = json.loads(results_json.read_text(encoding="utf-8"))
|
|
118
|
+
return _format_key_values(records)
|
|
119
|
+
return _format_results_listing(experiment)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_key_values(records: dict) -> str:
|
|
123
|
+
if not records:
|
|
124
|
+
return "(none)"
|
|
125
|
+
return "\n".join(
|
|
126
|
+
f"{key}: {_inline(value)}" for key, value in records.items()
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _format_document(
|
|
131
|
+
experiment: Experiment, title: str, registry: RunStorage, run_at: str
|
|
132
|
+
) -> str:
|
|
133
|
+
lines = [f"# {experiment.name}", "", f"Last run: {run_at}", "", f"## {title}", ""]
|
|
134
|
+
if not registry:
|
|
135
|
+
lines.append("(none)")
|
|
136
|
+
for key, value in registry.items():
|
|
137
|
+
lines.append(f"- **{key}**: {_inline(value)}")
|
|
138
|
+
return "\n".join(lines) + "\n"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _format_results_listing(experiment: Experiment) -> str:
|
|
142
|
+
results_dir = experiment.results_dir
|
|
143
|
+
lines = [
|
|
144
|
+
f"Experiment: {experiment.name} (ID {experiment.id})",
|
|
145
|
+
f"Results ({results_dir}):",
|
|
146
|
+
]
|
|
147
|
+
if results_dir.is_dir():
|
|
148
|
+
results = sorted(path for path in results_dir.iterdir() if path.is_file())
|
|
149
|
+
else:
|
|
150
|
+
results = []
|
|
151
|
+
if not results:
|
|
152
|
+
lines.append(" (no results yet)")
|
|
153
|
+
return "\n".join(lines)
|
|
154
|
+
for result in results:
|
|
155
|
+
lines.append(f" - {result.name} ({_format_size(result.stat().st_size)})")
|
|
156
|
+
return "\n".join(lines)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _inline(value: object) -> str:
|
|
160
|
+
if isinstance(value, (dict, list, tuple)):
|
|
161
|
+
return json.dumps(value)
|
|
162
|
+
return str(value)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _format_size(num_bytes: int) -> str:
|
|
166
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
167
|
+
if num_bytes < 1024:
|
|
168
|
+
return f"{num_bytes:.0f} {unit}"
|
|
169
|
+
num_bytes /= 1024
|
|
170
|
+
return f"{num_bytes:.1f} TB"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _write_json(path: Path, storage: RunStorage) -> None:
|
|
174
|
+
path.write_text(
|
|
175
|
+
json.dumps(storage, indent=2, default=str) + "\n", encoding="utf-8"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _remove_stale_readme(experiment: Experiment) -> None:
|
|
180
|
+
readme = experiment.path / "readme.md"
|
|
181
|
+
if readme.exists():
|
|
182
|
+
readme.unlink()
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: researchium
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A research module for organizing and analyzing scientific work.
|
|
5
|
+
Project-URL: Homepage, https://github.com/samTheComputerArchitect/researchium
|
|
6
|
+
Project-URL: Repository, https://github.com/samTheComputerArchitect/researchium
|
|
7
|
+
Author: Samarth Javagal
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: analysis,experiments,research,science
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
22
|
+
Classifier: Topic :: Scientific/Engineering
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: build==1.6.1; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
29
|
+
Requires-Dist: twine==7.0.0; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Researchium
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/researchium) [](https://pypi.org/project/researchium) [](LICENSE) [](https://github.com/samTheComputerArchitect/researchium/actions/workflows/ci.yml) [](https://pypi.org/project/researchium)
|
|
35
|
+
|
|
36
|
+
**Version 0.1.0**
|
|
37
|
+
|
|
38
|
+
Researchium is a small, opinionated toolkit for organizing scientific work into
|
|
39
|
+
self-contained **environments** and tracking **experiments** within them. It
|
|
40
|
+
gives every experiment a stable ID, a home folder, a runnable script, and a
|
|
41
|
+
place for its configuration and results — all stored as plain, human-readable
|
|
42
|
+
files so nothing is locked in a database.
|
|
43
|
+
|
|
44
|
+
It is built around three ideas:
|
|
45
|
+
|
|
46
|
+
1. **An environment owns a directory.** `researchium init` lays down a standard
|
|
47
|
+
structure (mirroring what `git` does with `.git` and `uv` with `uv.lock`).
|
|
48
|
+
Every subsequent command discovers the environment by walking up from the
|
|
49
|
+
current directory, so you can work from any subfolder.
|
|
50
|
+
2. **Experiments are first-class.** Each experiment is a folder in
|
|
51
|
+
`experiments/`, has a unique short ID, and keeps its configuration and
|
|
52
|
+
results in its own location.
|
|
53
|
+
3. **Run scripts fill config and results.** Write a normal Python script that
|
|
54
|
+
imports `Config` and `Results` from `researchium`. `researchium run` executes
|
|
55
|
+
it and snapshots both registries into Markdown *and* JSON files — ready for
|
|
56
|
+
humans and machines.
|
|
57
|
+
|
|
58
|
+
---
|
|
59
|
+
|
|
60
|
+
## Table of contents
|
|
61
|
+
|
|
62
|
+
- [Quick start](#quick-start)
|
|
63
|
+
- [The environment layout](#the-environment-layout)
|
|
64
|
+
- [Command-line usage](#command-line-usage)
|
|
65
|
+
- [Writing run scripts](#writing-run-scripts)
|
|
66
|
+
- [Python API](#python-api)
|
|
67
|
+
- [File formats](#file-formats)
|
|
68
|
+
- [Error handling](#error-handling)
|
|
69
|
+
- [Development](#development)
|
|
70
|
+
- [Project layout](#project-layout)
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Quick start
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
researchium init "Climate Genomics" # lay down an environment
|
|
78
|
+
researchium create alpine-transcriptomics \
|
|
79
|
+
"RNA-seq of alpine vs lowland populations." # register an experiment
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Write a run script:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
# experiments/alpine-transcriptomics/run.py
|
|
86
|
+
from researchium import Config, Results
|
|
87
|
+
|
|
88
|
+
Config["epochs"] = 10
|
|
89
|
+
Config["learning_rate"] = 0.001
|
|
90
|
+
|
|
91
|
+
Results["mse"] = 0.032
|
|
92
|
+
Results["r2"] = 0.91
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Run it and inspect:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
researchium run alpine-transcriptomics # or by ID: researchium run 1e70f7c3
|
|
99
|
+
researchium config alpine-transcriptomics # epochs: 10
|
|
100
|
+
researchium results alpine-transcriptomics # mse: 0.032
|
|
101
|
+
researchium search alpine # find experiments and their IDs
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## The environment layout
|
|
107
|
+
|
|
108
|
+
`researchium init NAME` creates a directory with this structure:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
my-study/
|
|
112
|
+
├── .researchium/ # marker + environment metadata (like .git/)
|
|
113
|
+
│ └── environment.json
|
|
114
|
+
├── experiments/ # one folder per experiment
|
|
115
|
+
├── results/ # one folder per experiment, mirroring experiments/
|
|
116
|
+
├── src/ # reusable code for the project
|
|
117
|
+
├── experiments.md # human log of every experiment
|
|
118
|
+
└── experiments.json # machine-readable registry of experiments
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
After registering and running an experiment, an experiment looks like:
|
|
122
|
+
|
|
123
|
+
```
|
|
124
|
+
experiments/
|
|
125
|
+
└── alpine-transcriptomics/
|
|
126
|
+
├── run.py # you write this
|
|
127
|
+
├── config.md # written by `run`
|
|
128
|
+
└── config.json
|
|
129
|
+
results/
|
|
130
|
+
└── alpine-transcriptomics/
|
|
131
|
+
├── results.md
|
|
132
|
+
└── results.json
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Key details:
|
|
136
|
+
|
|
137
|
+
- **The `.researchium/` marker is what makes a directory an environment.**
|
|
138
|
+
`discover_environment()` walks up parent directories to find it, so commands
|
|
139
|
+
work from `results/alpine-transcriptomics/`, `src/`, or anywhere below the
|
|
140
|
+
root.
|
|
141
|
+
- **Result folders are keyed by experiment name** (`results/<name>/`), which
|
|
142
|
+
keeps them readable while `researchium results <name_or_id>` still resolves
|
|
143
|
+
either form.
|
|
144
|
+
- **Results and config are stored as both Markdown (for humans) and JSON (for
|
|
145
|
+
tooling).**
|
|
146
|
+
|
|
147
|
+
---
|
|
148
|
+
|
|
149
|
+
## Command-line usage
|
|
150
|
+
|
|
151
|
+
Every command accepts an experiment either by **exact name** or by **ID**.
|
|
152
|
+
|
|
153
|
+
| Command | Description |
|
|
154
|
+
| -------------------------------- | ------------------------------------------------------------------ |
|
|
155
|
+
| `researchium init NAME` | Create an environment named `NAME` in the current directory. |
|
|
156
|
+
| `researchium create NAME DESC` | Register an experiment: folder + log entry + registry record. |
|
|
157
|
+
| `researchium search QUERY` | Find experiments matching name or description (case-insensitive). |
|
|
158
|
+
| `researchium run NAME_OR_ID` | Execute `experiments/<name>/run.py`, snapshot config + results. |
|
|
159
|
+
| `researchium config NAME_OR_ID` | Print the experiment config as `key: value` lines. |
|
|
160
|
+
| `researchium results NAME_OR_ID` | Print the experiment results as `key: value` lines. |
|
|
161
|
+
|
|
162
|
+
### `init`
|
|
163
|
+
|
|
164
|
+
```bash
|
|
165
|
+
$ researchium init "Climate Genomics"
|
|
166
|
+
Initialized researchium environment at /path/to/project
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Creating an environment where one already exists fails with exit code `1`.
|
|
170
|
+
|
|
171
|
+
### `create`
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
$ researchium create alpine-transcriptomics "RNA-seq of alpine vs lowland populations."
|
|
175
|
+
Created experiment alpine-transcriptomics (ID 1e70f7c3) at .../experiments/alpine-transcriptomics
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`create` refuses empty or path-traversal names (e.g. `../x`) and duplicate
|
|
179
|
+
names. It also creates the experiment's `results/<name>/` folder and appends a
|
|
180
|
+
date/description/ID entry to `experiments.md`.
|
|
181
|
+
|
|
182
|
+
### `search`
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
$ researchium search alpine
|
|
186
|
+
- alpine-transcriptomics (ID 1e70f7c3): RNA-seq of alpine vs lowland populations.
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Matching is case-insensitive over both name and description. No matches prints
|
|
190
|
+
`No experiments matching 'query'.` and still exits `0`.
|
|
191
|
+
|
|
192
|
+
### `run`
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
$ researchium run alpine-transcriptomics
|
|
196
|
+
Ran experiment alpine-transcriptomics (ID 1e70f7c3)
|
|
197
|
+
config -> experiments/alpine-transcriptomics/config.md, config.json
|
|
198
|
+
results -> results/alpine-transcriptomics/results.md, results.json
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
`run` clears the previous `Config`/`Results`, executes
|
|
202
|
+
`experiments/<name>/run.py`, then writes four files. It fails with exit code
|
|
203
|
+
`1` if the script is missing or the script raises. See
|
|
204
|
+
[Writing run scripts](#writing-run-scripts).
|
|
205
|
+
|
|
206
|
+
### `config` and `results`
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
$ researchium config alpine
|
|
210
|
+
epochs: 10
|
|
211
|
+
learning_rate: 0.001
|
|
212
|
+
|
|
213
|
+
$ researchium results alpine
|
|
214
|
+
mse: 0.032
|
|
215
|
+
r2: 0.91
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
Structured values (lists, dicts) print as compact JSON, e.g.
|
|
219
|
+
`metrics: {"loss": 0.2}`. Before the first run, `config` prints
|
|
220
|
+
`(no configuration yet)`; `results` falls back to a file listing of the
|
|
221
|
+
results folder.
|
|
222
|
+
|
|
223
|
+
---
|
|
224
|
+
|
|
225
|
+
## Writing run scripts
|
|
226
|
+
|
|
227
|
+
A run script is any Python file named `run.py` inside an experiment folder. It
|
|
228
|
+
should import `Config`/`Results` and set values by key:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
from researchium import Config, Results
|
|
232
|
+
|
|
233
|
+
import numpy as np
|
|
234
|
+
from sklearn.model_selection import train_test_split
|
|
235
|
+
|
|
236
|
+
Config["epochs"] = 10
|
|
237
|
+
Config["optimizer"] = "adam"
|
|
238
|
+
Config["layers"] = [256, 128, 64]
|
|
239
|
+
|
|
240
|
+
# ... your training code ...
|
|
241
|
+
accuracy = 0.93
|
|
242
|
+
Results["accuracy"] = accuracy
|
|
243
|
+
Results["metrics"] = {"precision": 0.91, "recall": 0.89}
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
- `Config` and `Results` are plain mutable registries (dict-like). Use any key
|
|
247
|
+
you like; order of the printed/`.md` output follows insertion order.
|
|
248
|
+
- Values that are lists, tuples, or dicts are stored as JSON; any other object
|
|
249
|
+
is stored via `str()` — so non-JSON values (e.g. numpy types) never crash a
|
|
250
|
+
`run`.
|
|
251
|
+
- The registries are **cleared before every run**, so runs never leak state.
|
|
252
|
+
- `run` executes the script with `runpy`, as `__main__`. Anything the script
|
|
253
|
+
`print()`s appears on the CLI output.
|
|
254
|
+
|
|
255
|
+
---
|
|
256
|
+
|
|
257
|
+
## Python API
|
|
258
|
+
|
|
259
|
+
Everything below is importable from the top-level `researchium` package.
|
|
260
|
+
|
|
261
|
+
### Environments
|
|
262
|
+
|
|
263
|
+
| Function | Description |
|
|
264
|
+
| ----------------------------------------- | -------------------------------------------------- |
|
|
265
|
+
| `initialize_environment(path, name=None)` | Create the standard structure + `.researchium/` marker. Returns `Environment`. |
|
|
266
|
+
| `is_initialized(path)` | `True` if `path` is directly an environment root. |
|
|
267
|
+
| `discover_environment(path)` | Nearest environment at/above `path`. Raises `EnvironmentNotFoundError`. |
|
|
268
|
+
| `Environment` (dataclass) | `root`, `name`, `created`, `researchium_version`; properties `experiments_dir`, `src_dir`, `results_dir`, `experiments_notebook`, `experiments_registry`. |
|
|
269
|
+
| `load_experiments_registry(path)` | Read experiment records from `experiments.json`. |
|
|
270
|
+
| `save_experiments_registry(path, records)`| Write experiment records to `experiments.json`. |
|
|
271
|
+
|
|
272
|
+
### Experiments
|
|
273
|
+
|
|
274
|
+
| Function | Description |
|
|
275
|
+
| ---------------------------------------------- | -------------------------------------------------- |
|
|
276
|
+
| `create_experiment(name, description, path)` | Register an experiment and return it. Raises `ExperimentExistsError`. |
|
|
277
|
+
| `list_experiments(path)` | All experiments, sorted by name. |
|
|
278
|
+
| `search_experiments(query, path)` | Experiments matching name/description. |
|
|
279
|
+
| `get_experiment(identifier, path)` | Look up by ID or name. Raises `ExperimentNotFoundError`. |
|
|
280
|
+
| `get_experiment_results(identifier, path)` | Result files (paths) of an experiment. |
|
|
281
|
+
| `Experiment` (dataclass) | `id`, `name`, `description`, `created`, `environment`; properties `path`, `results_dir`. |
|
|
282
|
+
|
|
283
|
+
### Runtime
|
|
284
|
+
|
|
285
|
+
| Function | Description |
|
|
286
|
+
| ------------------------------------- | -------------------------------------------------- |
|
|
287
|
+
| `Config`, `Results` | Mutable registries your run script fills in. |
|
|
288
|
+
| `run_experiment(identifier, path)` | Execute the run script and persist outputs. Raises `RunScriptNotFoundError` / `RunFailedError`. |
|
|
289
|
+
| `format_configuration(identifier)` | Config as `key: value` lines. |
|
|
290
|
+
| `format_run_results(identifier)` | Results as `key: value` lines (listing fallback). |
|
|
291
|
+
|
|
292
|
+
### Example
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
from researchium import (
|
|
296
|
+
Config,
|
|
297
|
+
Results,
|
|
298
|
+
create_experiment,
|
|
299
|
+
discover_environment,
|
|
300
|
+
initialize_environment,
|
|
301
|
+
run_experiment,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
env = initialize_environment("~/study", name="Climate Genomics")
|
|
305
|
+
experiment = create_experiment(
|
|
306
|
+
"climate-model", "Temperature projections for 2050.", "~/study"
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
experiment.path.joinpath("run.py").write_text(
|
|
310
|
+
"from researchium import Config, Results\n"
|
|
311
|
+
"Config['seed'] = 42\n"
|
|
312
|
+
"Results['mae'] = 1.7\n"
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
run_experiment(experiment.name, "~/study") # runs + writes files
|
|
316
|
+
with open(experiment.path / "config.json") as fh:
|
|
317
|
+
print(fh.read())
|
|
318
|
+
|
|
319
|
+
# From anywhere inside the project:
|
|
320
|
+
env = discover_environment(".") # finds it by walking up
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
---
|
|
324
|
+
|
|
325
|
+
## File formats
|
|
326
|
+
|
|
327
|
+
### `experiments.md`
|
|
328
|
+
|
|
329
|
+
A human log. `create` appends a section per experiment:
|
|
330
|
+
|
|
331
|
+
```markdown
|
|
332
|
+
### alpine-transcriptomics
|
|
333
|
+
|
|
334
|
+
RNA-seq of alpine vs lowland populations.
|
|
335
|
+
|
|
336
|
+
- ID: `1e70f7c3`
|
|
337
|
+
- Created: 2026-09-22
|
|
338
|
+
- Folder: `experiments/alpine-transcriptomics`
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### `experiments.json`
|
|
342
|
+
|
|
343
|
+
The machine-readable registry (created empty by `init`, appended by `create`):
|
|
344
|
+
|
|
345
|
+
```json
|
|
346
|
+
{
|
|
347
|
+
"version": 1,
|
|
348
|
+
"experiments": [
|
|
349
|
+
{
|
|
350
|
+
"id": "1e70f7c3",
|
|
351
|
+
"name": "alpine-transcriptomics",
|
|
352
|
+
"description": "RNA-seq of alpine vs lowland populations.",
|
|
353
|
+
"created": "2026-09-22"
|
|
354
|
+
}
|
|
355
|
+
]
|
|
356
|
+
}
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
### `config.md` / `results.md`
|
|
360
|
+
|
|
361
|
+
Human-formatted snapshots written by `run`:
|
|
362
|
+
|
|
363
|
+
```markdown
|
|
364
|
+
# alpine-transcriptomics
|
|
365
|
+
|
|
366
|
+
Last run: 2026-09-22 23:08
|
|
367
|
+
|
|
368
|
+
## Configuration
|
|
369
|
+
|
|
370
|
+
- **epochs**: 10
|
|
371
|
+
- **learning_rate**: 0.001
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### `config.json` / `results.json`
|
|
375
|
+
|
|
376
|
+
The same data as JSON, suitable for programmatic consumption:
|
|
377
|
+
|
|
378
|
+
```json
|
|
379
|
+
{
|
|
380
|
+
"epochs": 10,
|
|
381
|
+
"learning_rate": 0.001
|
|
382
|
+
}
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
Note: a stale `readme.md` produced by a development build is removed
|
|
386
|
+
automatically on the next `run`.
|
|
387
|
+
|
|
388
|
+
### `.researchium/environment.json`
|
|
389
|
+
|
|
390
|
+
Environment metadata; this directory is the environment marker:
|
|
391
|
+
|
|
392
|
+
```json
|
|
393
|
+
{
|
|
394
|
+
"name": "Climate Genomics",
|
|
395
|
+
"created": "2026-09-22",
|
|
396
|
+
"researchium": "0.1.0"
|
|
397
|
+
}
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
---
|
|
401
|
+
|
|
402
|
+
## Error handling
|
|
403
|
+
|
|
404
|
+
All domain errors subclass `researchium.ResearchiumError`:
|
|
405
|
+
|
|
406
|
+
| Error | Raised when |
|
|
407
|
+
| ---------------------------- | ----------------------------------------- |
|
|
408
|
+
| `EnvironmentExistsError` | `init` on an already-initialized directory |
|
|
409
|
+
| `EnvironmentNotFoundError` | no environment at/above the given path |
|
|
410
|
+
| `ExperimentExistsError` | creating a duplicate experiment name |
|
|
411
|
+
| `ExperimentNotFoundError` | no experiment matches the ID/name |
|
|
412
|
+
| `InvalidExperimentNameError` | empty or path-traversal experiment name |
|
|
413
|
+
| `RunScriptNotFoundError` | experiment has no `run.py` |
|
|
414
|
+
| `RunFailedError` | the run script raised |
|
|
415
|
+
|
|
416
|
+
The CLI catches these, prints `error: <message>` to stderr, and exits `1`.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
420
|
+
## Development
|
|
421
|
+
|
|
422
|
+
```bash
|
|
423
|
+
uv sync --extra dev # install the package + dev tools
|
|
424
|
+
uv run pytest # 44 tests
|
|
425
|
+
uv run pytest -q # keep it quiet
|
|
426
|
+
uv run ruff check . # lint (E, F, W, I, UP on src + tests)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
Test coverage is configured in `pyproject.toml` (`--cov=researchium
|
|
430
|
+
--cov-report=term-missing`) and is kept at 100%. The GitHub Actions workflow in
|
|
431
|
+
`.github/workflows/ci.yml` runs tests and lint on Python 3.10 / 3.12 / 3.14.
|
|
432
|
+
|
|
433
|
+
### Adding a feature
|
|
434
|
+
|
|
435
|
+
1. Add the function to the relevant module under `src/researchium/`.
|
|
436
|
+
2. Export it from `researchium/__init__.py` and add it to `__all__`.
|
|
437
|
+
3. Add tests under `tests/`; keep coverage at 100% (`uv run pytest` will show
|
|
438
|
+
any missed lines).
|
|
439
|
+
4. Run `uv run ruff check . && uv run pytest` and reinstall the global tool if
|
|
440
|
+
you use it: `uv tool install --force /path/to/researchium`.
|
|
441
|
+
|
|
442
|
+
---
|
|
443
|
+
|
|
444
|
+
## Project layout
|
|
445
|
+
|
|
446
|
+
```
|
|
447
|
+
researchium/
|
|
448
|
+
├── pyproject.toml # PEP 621 metadata, hatchling build, ruff/pytest config
|
|
449
|
+
├── uv.lock # locked development dependencies
|
|
450
|
+
├── README.md
|
|
451
|
+
├── .python-version
|
|
452
|
+
├── .github/workflows/ci.yml # lint + tests matrix
|
|
453
|
+
├── src/researchium/
|
|
454
|
+
│ ├── __init__.py # public API surface
|
|
455
|
+
│ ├── environment.py # init, discovery, environment metadata + registry
|
|
456
|
+
│ ├── experiments.py # experiment lifecycle, search, lookup
|
|
457
|
+
│ ├── runtime.py # Config/Results registries + runner + formatters
|
|
458
|
+
│ └── cli.py # argparse-based CLI (entry point `researchium`)
|
|
459
|
+
└── tests/
|
|
460
|
+
├── test_environment.py
|
|
461
|
+
├── test_experiments.py
|
|
462
|
+
├── test_runtime.py
|
|
463
|
+
└── test_cli.py
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
## Roadmap
|
|
469
|
+
|
|
470
|
+
Planned directions, in rough order:
|
|
471
|
+
|
|
472
|
+
- `researchium scaffold <name>` to generate a starter `run.py`.
|
|
473
|
+
- Experiment status (pending / running / completed) tracked in the registry.
|
|
474
|
+
- Deleting and renaming experiments with safe migration of results.
|
|
475
|
+
- A smaller `researchium` output format flag (`--json`) for scripting.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
researchium/__init__.py,sha256=Ezle8dFpjtZwLU3J2ElpOCKJG4PZKOMyCLHqcunaEos,1355
|
|
2
|
+
researchium/cli.py,sha256=J86DS3nniDQ5l_TJXKW_D2W7jfIGa3TGan2t4_pr0bM,3541
|
|
3
|
+
researchium/environment.py,sha256=mTZaKpnLD3grVndebWKp8CmWlXNDfvkJveCgPvJsKrs,5452
|
|
4
|
+
researchium/experiments.py,sha256=HN6ubISfspMDY9rcKIWAXC4OH8i76_Wy63_GyMG2zdE,6533
|
|
5
|
+
researchium/runtime.py,sha256=8XidorLPSm8-ZMrynXczY61ER8XHSzwiMh04MKNayYM,5640
|
|
6
|
+
researchium-0.1.0.dist-info/METADATA,sha256=pPAIq6I0JmszhRL4XYAM-QVQd1SKxPAWMXclLkvQkHM,16888
|
|
7
|
+
researchium-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
researchium-0.1.0.dist-info/entry_points.txt,sha256=PT99_EpSMwl7LknsaCM9Pb8Y7gL18PKVT1vkdsL08qE,53
|
|
9
|
+
researchium-0.1.0.dist-info/licenses/LICENSE,sha256=yuK449AmmLkXdEuSVgJdmIxrqdTaygTEs7XrWGVKE_8,1071
|
|
10
|
+
researchium-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Samarth Javagal
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|