Skip to content

Commit e696134

Browse files
GSOC(L2-2): experiment client API + /experiments/log endpoint
Add vis.experiment/log_metrics/finish_experiment client methods that POST to a new /experiments/log Tornado handler. The handler records metadata through ExperimentStore over the server's DataStore and mirrors the blob into in-memory env state so a later full-env save preserves it. Covers create/update, metric append+autocreate, and finish (finished/failed). Once an experiment is terminal, further log/metrics writes are rejected: the store raises ExperimentFinishedError and the handler maps it to 409 Conflict, so a finished run's recorded data cannot change after the fact. Validation returns 400 (bad action/params/status), 404 (finish without experiment), 409 (write to terminal). Adds end-to-end + client-shape tests.
1 parent 537810f commit e696134

11 files changed

Lines changed: 614 additions & 5 deletions

File tree

openapi.yaml

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ tags:
5252
description: Query, close, and retrieve window data
5353
- name: Environment
5454
description: Manage environments (create, delete, fork, list, save, compare)
55+
- name: Experiments
56+
description: Track experiment metadata (hyper-parameters, metrics, tags)
5557
- name: Authentication
5658
description: Login and session management
5759
- name: Socket Polling
@@ -382,6 +384,92 @@ paths:
382384
"500":
383385
description: Server error. Occurs if the source environment does not exist (unhandled assertion error).
384386

387+
/experiments/log:
388+
post:
389+
operationId: logExperiment
390+
tags: [Experiments]
391+
summary: Record experiment metadata for an environment
392+
description: >
393+
Attaches experiment metadata (hyper-parameters, metric observations,
394+
and tags) to an environment, stored under the environment's
395+
`experiment` key and persisted through the server's data store. The
396+
`action` field selects the operation:
397+
398+
399+
- `log` (default): create or update the experiment. Repeated calls
400+
merge new `params`/`tags` and overwrite `name`/`description`.
401+
402+
403+
- `metrics`: append one or more `{name: value}` observations at an
404+
optional `step`, creating the experiment if it does not exist yet.
405+
406+
407+
- `finish`: mark the experiment terminal (`finished` or `failed`).
408+
409+
410+
Once an experiment is terminal, `log` and `metrics` are rejected with
411+
`409` so a finished run's recorded data cannot change after the fact.
412+
requestBody:
413+
required: true
414+
content:
415+
application/json:
416+
schema:
417+
type: object
418+
properties:
419+
eid:
420+
type: string
421+
description: Target environment ID. Defaults to `main`.
422+
action:
423+
type: string
424+
enum: [log, metrics, finish]
425+
default: log
426+
description: Operation to perform.
427+
name:
428+
type: string
429+
description: Display name (action `log`). Defaults to the eid.
430+
description:
431+
type: string
432+
description: Free-form description (action `log`).
433+
params:
434+
type: object
435+
additionalProperties: true
436+
description: 'Hyper-parameters as `{name: value}` (action `log`).'
437+
tags:
438+
type: object
439+
additionalProperties: true
440+
description: 'Free-form tags as `{name: value}` (action `log`).'
441+
metrics:
442+
type: object
443+
additionalProperties:
444+
type: number
445+
description: >
446+
Metric observations as `{name: value}` (action `metrics`).
447+
Must be a non-empty object.
448+
step:
449+
type: integer
450+
description: Optional training step for the metrics (action `metrics`).
451+
status:
452+
type: string
453+
enum: [finished, failed]
454+
default: finished
455+
description: Terminal status (action `finish`).
456+
responses:
457+
"200":
458+
description: The stored experiment as JSON.
459+
content:
460+
application/json:
461+
schema:
462+
$ref: "#/components/schemas/Experiment"
463+
"400":
464+
description: >
465+
Invalid request — unknown `action`, non-object `params`/`tags`/`metrics`,
466+
empty `metrics`, or a non-terminal `finish` status. Also returned when
467+
authentication is required but not provided.
468+
"404":
469+
description: A `finish` was requested for an env that has no experiment.
470+
"409":
471+
description: A `log`/`metrics` write was attempted on a terminal (finished/failed) experiment.
472+
385473
/upload_env:
386474
post:
387475
operationId: uploadEnvironment
@@ -845,6 +933,66 @@ components:
845933
846934
schemas:
847935

936+
Experiment:
937+
type: object
938+
description: Experiment metadata attached to an environment.
939+
properties:
940+
env_id:
941+
type: string
942+
description: Environment the experiment belongs to.
943+
name:
944+
type: string
945+
description: Display name. Defaults to the env_id.
946+
description:
947+
type: string
948+
status:
949+
type: string
950+
enum: [running, finished, failed]
951+
description: Lifecycle state. New experiments start `running`.
952+
created_at:
953+
type: number
954+
description: Unix timestamp when the experiment was created.
955+
finished_at:
956+
type: [number, "null"]
957+
description: Unix timestamp when finished, or `null` while running.
958+
params:
959+
type: array
960+
description: Hyper-parameters, keyed by name.
961+
items:
962+
type: object
963+
properties:
964+
key:
965+
type: string
966+
value: {}
967+
dtype:
968+
type: string
969+
enum: [bool, int, float, str]
970+
description: Inferred type, so a stored value can be cast back.
971+
metrics:
972+
type: array
973+
description: Metric observations, appended over time.
974+
items:
975+
type: object
976+
properties:
977+
key:
978+
type: string
979+
value:
980+
type: number
981+
step:
982+
type: [integer, "null"]
983+
timestamp:
984+
type: number
985+
tags:
986+
type: array
987+
description: Free-form key/value labels.
988+
items:
989+
type: object
990+
properties:
991+
key:
992+
type: string
993+
value:
994+
type: string
995+
848996
UploadErrorResponse:
849997
type: object
850998
description: Error response returned by the /upload_env endpoint.
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
"""End-to-end tests for the ``/experiments/log`` endpoint (Layer 2, PR 2).
2+
3+
Drives a real :class:`~visdom.server.app.Application` over a temp env dir with
4+
Tornado's ``AsyncHTTPTestCase``, so the full route -> handler -> ``ExperimentStore``
5+
-> ``JSONStore`` path is exercised. Also unit-tests the client-side
6+
``Visdom.experiment``/``log_metrics``/``finish_experiment`` message shapes with
7+
``send=False`` (no server needed).
8+
"""
9+
10+
import json
11+
import tempfile
12+
import unittest
13+
14+
import tornado.testing
15+
16+
from visdom import Visdom
17+
from visdom.data_model import JSONStore
18+
from visdom.experiments import ExperimentStore
19+
from visdom.server.app import Application
20+
21+
22+
class TestExperimentLogEndpoint(tornado.testing.AsyncHTTPTestCase):
23+
def setUp(self):
24+
self._tmp_dir = tempfile.mkdtemp(prefix="visdom_exp_test_")
25+
super().setUp()
26+
27+
def get_app(self):
28+
return Application(port=self.get_http_port(), env_path=self._tmp_dir)
29+
30+
def post_json(self, path, body):
31+
return self.fetch(
32+
path,
33+
method="POST",
34+
body=json.dumps(body),
35+
headers={"Content-Type": "application/json"},
36+
)
37+
38+
def read_experiment(self, eid):
39+
"""Read the persisted experiment straight from disk via a fresh store."""
40+
return ExperimentStore(JSONStore(self._tmp_dir)).get_experiment(eid)
41+
42+
def test_log_creates_and_persists_experiment(self):
43+
resp = self.post_json(
44+
"/experiments/log",
45+
{
46+
"eid": "main",
47+
"action": "log",
48+
"name": "run-1",
49+
"params": {"lr": 0.01, "epochs": 10},
50+
"tags": {"dataset": "mnist"},
51+
"description": "first run",
52+
},
53+
)
54+
self.assertEqual(resp.code, 200)
55+
body = json.loads(resp.body)
56+
self.assertEqual(body["name"], "run-1")
57+
self.assertEqual(body["params"][0]["key"], "lr")
58+
59+
exp = self.read_experiment("main")
60+
self.assertIsNotNone(exp)
61+
self.assertEqual(exp.get_param("epochs").value, 10)
62+
self.assertEqual(exp.get_param("epochs").dtype, "int")
63+
self.assertEqual(exp.tags[0].value, "mnist")
64+
65+
def test_action_defaults_to_log(self):
66+
resp = self.post_json(
67+
"/experiments/log", {"eid": "main", "params": {"lr": 0.5}}
68+
)
69+
self.assertEqual(resp.code, 200)
70+
self.assertEqual(self.read_experiment("main").get_param("lr").value, 0.5)
71+
72+
def test_metrics_append_and_autocreate(self):
73+
resp = self.post_json(
74+
"/experiments/log",
75+
{
76+
"eid": "main",
77+
"action": "metrics",
78+
"metrics": {"acc": 0.9, "loss": 0.1},
79+
"step": 3,
80+
},
81+
)
82+
self.assertEqual(resp.code, 200)
83+
exp = self.read_experiment("main")
84+
self.assertEqual(len(exp.metrics), 2)
85+
self.assertEqual(exp.latest_metric("acc").value, 0.9)
86+
self.assertEqual(exp.latest_metric("acc").step, 3)
87+
88+
def test_finish_sets_terminal_status(self):
89+
self.post_json("/experiments/log", {"eid": "main", "params": {"lr": 0.01}})
90+
resp = self.post_json(
91+
"/experiments/log",
92+
{"eid": "main", "action": "finish", "status": "failed"},
93+
)
94+
self.assertEqual(resp.code, 200)
95+
self.assertEqual(self.read_experiment("main").status, "failed")
96+
97+
def test_finish_without_experiment_is_404(self):
98+
resp = self.post_json("/experiments/log", {"eid": "ghost", "action": "finish"})
99+
self.assertEqual(resp.code, 404)
100+
101+
def test_finish_with_running_status_is_400(self):
102+
self.post_json("/experiments/log", {"eid": "main", "params": {"lr": 0.01}})
103+
resp = self.post_json(
104+
"/experiments/log",
105+
{"eid": "main", "action": "finish", "status": "running"},
106+
)
107+
self.assertEqual(resp.code, 400)
108+
109+
def test_log_to_finished_is_409(self):
110+
self.post_json("/experiments/log", {"eid": "main", "params": {"lr": 0.01}})
111+
self.post_json("/experiments/log", {"eid": "main", "action": "finish"})
112+
resp = self.post_json(
113+
"/experiments/log", {"eid": "main", "params": {"lr": 0.02}}
114+
)
115+
self.assertEqual(resp.code, 409)
116+
self.assertEqual(self.read_experiment("main").get_param("lr").value, 0.01)
117+
118+
def test_metrics_to_finished_is_409(self):
119+
self.post_json(
120+
"/experiments/log",
121+
{"eid": "main", "action": "metrics", "metrics": {"acc": 0.9}},
122+
)
123+
self.post_json("/experiments/log", {"eid": "main", "action": "finish"})
124+
resp = self.post_json(
125+
"/experiments/log",
126+
{"eid": "main", "action": "metrics", "metrics": {"acc": 0.95}},
127+
)
128+
self.assertEqual(resp.code, 409)
129+
self.assertEqual(len(self.read_experiment("main").metrics), 1)
130+
131+
def test_unknown_action_is_400(self):
132+
resp = self.post_json("/experiments/log", {"eid": "main", "action": "bogus"})
133+
self.assertEqual(resp.code, 400)
134+
135+
def test_empty_metrics_is_400(self):
136+
resp = self.post_json(
137+
"/experiments/log", {"eid": "main", "action": "metrics", "metrics": {}}
138+
)
139+
self.assertEqual(resp.code, 400)
140+
141+
def test_non_mapping_params_is_400(self):
142+
resp = self.post_json("/experiments/log", {"eid": "main", "params": [1, 2, 3]})
143+
self.assertEqual(resp.code, 400)
144+
145+
def test_experiment_survives_full_env_save(self):
146+
"""A window save must not clobber a previously logged experiment.
147+
148+
This guards the in-memory/on-disk sync: logging writes the blob to disk
149+
and mirrors it into server state, so persisting that env (which writes
150+
the in-memory state) keeps the experiment instead of dropping it.
151+
"""
152+
self.post_json("/experiments/log", {"eid": "main", "params": {"lr": 0.01}})
153+
win_resp = self.post_json(
154+
"/events", {"eid": "main", "data": [{"type": "text", "content": "hi"}]}
155+
)
156+
self.assertEqual(win_resp.code, 200)
157+
save_resp = self.post_json("/save", {"data": ["main"]})
158+
self.assertEqual(save_resp.code, 200)
159+
160+
exp = self.read_experiment("main")
161+
self.assertIsNotNone(exp, "experiment was clobbered by the env save")
162+
self.assertEqual(exp.get_param("lr").value, 0.01)
163+
164+
165+
class TestClientMessageShapes(unittest.TestCase):
166+
"""Client methods build the right request without needing a server.
167+
168+
A ``send=False`` client short-circuits ``_send`` to return the
169+
``(msg, endpoint)`` it would have posted, so we can assert on it directly.
170+
"""
171+
172+
def _client(self):
173+
return Visdom(send=False, env="expenv")
174+
175+
def test_experiment_message(self):
176+
msg, endpoint = self._client().experiment(
177+
name="r1", params={"lr": 0.01}, tags={"ds": "mnist"}, description="d"
178+
)
179+
self.assertEqual(endpoint, "experiments/log")
180+
self.assertEqual(msg["action"], "log")
181+
self.assertEqual(msg["eid"], "expenv")
182+
self.assertEqual(msg["params"], {"lr": 0.01})
183+
self.assertEqual(msg["tags"], {"ds": "mnist"})
184+
185+
def test_experiment_env_override(self):
186+
msg, _ = self._client().experiment(params={"lr": 0.01}, env="other")
187+
self.assertEqual(msg["eid"], "other")
188+
189+
def test_log_metrics_message(self):
190+
msg, endpoint = self._client().log_metrics({"acc": 0.9}, step=5)
191+
self.assertEqual(endpoint, "experiments/log")
192+
self.assertEqual(msg["action"], "metrics")
193+
self.assertEqual(msg["metrics"], {"acc": 0.9})
194+
self.assertEqual(msg["step"], 5)
195+
196+
def test_finish_experiment_message(self):
197+
msg, _ = self._client().finish_experiment(status="failed")
198+
self.assertEqual(msg["action"], "finish")
199+
self.assertEqual(msg["status"], "failed")
200+
201+
def test_experiment_rejects_bad_params(self):
202+
with self.assertRaises(TypeError):
203+
self._client().experiment(params=[1, 2, 3])
204+
205+
def test_log_metrics_rejects_empty(self):
206+
with self.assertRaises(TypeError):
207+
self._client().log_metrics({})
208+
209+
210+
if __name__ == "__main__":
211+
unittest.main()

0 commit comments

Comments
 (0)