value-stream 0.1.1__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.
- value_stream/__init__.py +1 -0
- value_stream/app/SPEC.md +79 -0
- value_stream/app/__init__.py +1 -0
- value_stream/app/__main__.py +28 -0
- value_stream/app/contracts.py +18 -0
- value_stream/app/coordinator.py +359 -0
- value_stream/app/errors.py +17 -0
- value_stream/app/exports.py +142 -0
- value_stream/app/gateway.py +130 -0
- value_stream/app/generation.py +269 -0
- value_stream/app/insights.py +75 -0
- value_stream/app/metrics.py +132 -0
- value_stream/app/openapi.json +3699 -0
- value_stream/app/schemas.py +288 -0
- value_stream/app/server.py +298 -0
- value_stream/app/settings.py +46 -0
- value_stream/app/storage.py +651 -0
- value_stream/client/__init__.py +3 -0
- value_stream/client/simulation_runner.py +83 -0
- value_stream/client/views/__init__.py +4 -0
- value_stream/client/views/metadata_viewer.py +235 -0
- value_stream/client/views/result_viewer.py +76 -0
- value_stream/client/views/viewer.py +8 -0
- value_stream/client/web/__init__.py +10 -0
- value_stream/client/web/client.py +115 -0
- value_stream/client/web/errors.py +29 -0
- value_stream/client/web/local_service.py +92 -0
- value_stream/factory/__init__.py +5 -0
- value_stream/factory/developer_factory.py +33 -0
- value_stream/factory/factory.py +43 -0
- value_stream/factory/task_factory.py +64 -0
- value_stream/factory/task_generator.py +102 -0
- value_stream/policy/__init__.py +3 -0
- value_stream/policy/simulation_policy.py +20 -0
- value_stream/resources/__init__.py +23 -0
- value_stream/resources/developer.py +53 -0
- value_stream/resources/qa_tester.py +51 -0
- value_stream/resources/resource.py +162 -0
- value_stream/resources/resource_metadata.py +24 -0
- value_stream/resources/resource_policy.py +13 -0
- value_stream/resources/resource_pool.py +39 -0
- value_stream/resources/resource_tracker.py +160 -0
- value_stream/resources/toolchain.py +43 -0
- value_stream/service/SPEC.md +46 -0
- value_stream/service/__init__.py +1 -0
- value_stream/service/app.py +218 -0
- value_stream/service/codec.py +237 -0
- value_stream/service/job_store.py +322 -0
- value_stream/service/openapi.json +1283 -0
- value_stream/service/scheduler.py +234 -0
- value_stream/service/schemas.py +248 -0
- value_stream/service/settings.py +37 -0
- value_stream/service/worker.py +82 -0
- value_stream/simulation/__init__.py +16 -0
- value_stream/simulation/default_simulation_policy.py +68 -0
- value_stream/simulation/model.py +30 -0
- value_stream/simulation/model_factory.py +54 -0
- value_stream/simulation/simulation.py +144 -0
- value_stream/simulation/simulation_metadata.py +12 -0
- value_stream/simulation/simulation_result.py +20 -0
- value_stream/task/__init__.py +23 -0
- value_stream/task/epoch.py +21 -0
- value_stream/task/event_status.py +8 -0
- value_stream/task/task.py +260 -0
- value_stream/task/task_event.py +97 -0
- value_stream/task/task_history.py +208 -0
- value_stream/task/task_router.py +70 -0
- value_stream/task/task_state.py +7 -0
- value_stream/task/task_type.py +8 -0
- value_stream/workflow/__init__.py +18 -0
- value_stream/workflow/assignment_strategy.py +10 -0
- value_stream/workflow/pool_manager.py +136 -0
- value_stream/workflow/resource_operator.py +204 -0
- value_stream/workflow/sdlc_workflow.py +99 -0
- value_stream/workflow/support_workflow.py +23 -0
- value_stream/workflow/task_store.py +125 -0
- value_stream/workflow/workflow_policy.py +12 -0
- value_stream-0.1.1.dist-info/METADATA +288 -0
- value_stream-0.1.1.dist-info/RECORD +81 -0
- value_stream-0.1.1.dist-info/WHEEL +4 -0
- value_stream-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import weakref
|
|
3
|
+
from collections.abc import Iterable
|
|
4
|
+
from typing import Optional, Type
|
|
5
|
+
|
|
6
|
+
from tqdm import tqdm
|
|
7
|
+
|
|
8
|
+
from value_stream.policy import SimulationPolicy
|
|
9
|
+
from value_stream.simulation import Model, Simulation, DefaultSimulationPolicy, SimulationResult
|
|
10
|
+
from value_stream.task import Task, TaskState
|
|
11
|
+
|
|
12
|
+
from .web import WebSimulationClient
|
|
13
|
+
from .web.local_service import acquire_local_service, release_local_service
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SimulationRunner:
|
|
17
|
+
"""Submits model batches and retrieves their simulation results."""
|
|
18
|
+
|
|
19
|
+
@classmethod
|
|
20
|
+
def task_states(cls) -> Type[TaskState]:
|
|
21
|
+
return Simulation.task_states
|
|
22
|
+
|
|
23
|
+
def __init__(self, service_url: str | None = None) -> None:
|
|
24
|
+
"""Connect the runner to the configured simulation service.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
service_url (str | None): Service url.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
configured_url = service_url or os.environ.get("VALUE_STREAM_SERVICE_URL") or None
|
|
31
|
+
self._local_finalizer = None
|
|
32
|
+
if configured_url is None:
|
|
33
|
+
configured_url = acquire_local_service()
|
|
34
|
+
self._local_finalizer = weakref.finalize(self, release_local_service)
|
|
35
|
+
self.client = WebSimulationClient(configured_url)
|
|
36
|
+
self._closed = False
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
"""Close the client and release its resources."""
|
|
40
|
+
|
|
41
|
+
if self._closed:
|
|
42
|
+
return
|
|
43
|
+
self._closed = True
|
|
44
|
+
self.client.close()
|
|
45
|
+
if self._local_finalizer is not None:
|
|
46
|
+
self._local_finalizer()
|
|
47
|
+
|
|
48
|
+
def __enter__(self):
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
def __exit__(self, _type, _value, _traceback):
|
|
52
|
+
self.close()
|
|
53
|
+
|
|
54
|
+
def execute(
|
|
55
|
+
self,
|
|
56
|
+
tasks: list[Task],
|
|
57
|
+
models: Iterable[Model],
|
|
58
|
+
pbar: Optional[tqdm] = None,
|
|
59
|
+
policy: SimulationPolicy = DefaultSimulationPolicy(),
|
|
60
|
+
seed: int | None = None,
|
|
61
|
+
) -> list[SimulationResult]:
|
|
62
|
+
"""Run the model batch and return results in submission order.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
tasks (list[Task]): Tasks to process.
|
|
66
|
+
models (Iterable[Model]): Models to simulate.
|
|
67
|
+
pbar (Optional[tqdm]): Optional progress bar to update.
|
|
68
|
+
policy (SimulationPolicy): Policy used to make simulation decisions.
|
|
69
|
+
seed (int | None): Random seed for repeatable simulation.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
RuntimeError: If the runner is closed or the requested policy is unsupported.
|
|
73
|
+
ValueError: If the runner is closed or the requested policy is unsupported.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
list[SimulationResult]: Results in the same order as the submitted models.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
if self._closed:
|
|
80
|
+
raise RuntimeError("SimulationRunner is closed")
|
|
81
|
+
if type(policy) is not DefaultSimulationPolicy:
|
|
82
|
+
raise ValueError("version 1 supports only DefaultSimulationPolicy")
|
|
83
|
+
return self.client.execute(tasks=tasks, models=models, pbar=pbar, seed=seed)
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
from typing import Any, Type
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
from matplotlib import ticker
|
|
4
|
+
import numpy as np
|
|
5
|
+
from pandas import json_normalize, Categorical
|
|
6
|
+
|
|
7
|
+
from value_stream.simulation import SimulationResult
|
|
8
|
+
from value_stream.task import TaskState
|
|
9
|
+
|
|
10
|
+
from .viewer import Viewer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class MetadataViewer(Viewer):
|
|
14
|
+
"""Displays simulation metadata."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
results: list[SimulationResult],
|
|
19
|
+
task_states: Type[TaskState],
|
|
20
|
+
colormap: str = "plasma",
|
|
21
|
+
):
|
|
22
|
+
super().__init__(colormap)
|
|
23
|
+
self._results_dict: list[Any] = []
|
|
24
|
+
|
|
25
|
+
self._task_states = task_states
|
|
26
|
+
|
|
27
|
+
for result in results:
|
|
28
|
+
self._results_dict.append(result.model_dump())
|
|
29
|
+
|
|
30
|
+
def mean_stage_loss(self):
|
|
31
|
+
df = json_normalize(
|
|
32
|
+
self._results_dict,
|
|
33
|
+
record_path=["metadata", "event_metadata"],
|
|
34
|
+
meta=[["metadata", "model", "deployment_cadence"], ["metadata", "model", "team_size"]],
|
|
35
|
+
errors="ignore",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
df.set_index(
|
|
39
|
+
["metadata.model.deployment_cadence", "metadata.model.team_size"], inplace=True
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
df.sort_index(inplace=True)
|
|
43
|
+
|
|
44
|
+
df = df[(df["event_type"] == "end")][["event", "loss", "status"]]
|
|
45
|
+
|
|
46
|
+
df["event"] = Categorical(
|
|
47
|
+
df["event"], categories=[e.value for e in self._task_states], ordered=True
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
team_samples = df.groupby(["metadata.model.team_size"])
|
|
51
|
+
|
|
52
|
+
fig, axs = plt.subplots(
|
|
53
|
+
ncols=len(team_samples), nrows=1, sharex=True, sharey=True, squeeze=True
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
axs_i = 0
|
|
57
|
+
|
|
58
|
+
for name, team_sample in team_samples:
|
|
59
|
+
ax = axs[axs_i]
|
|
60
|
+
axs_i += 1
|
|
61
|
+
|
|
62
|
+
df = (
|
|
63
|
+
team_sample.groupby(["event", "metadata.model.deployment_cadence"])
|
|
64
|
+
.mean(numeric_only=True)["loss"]
|
|
65
|
+
.unstack(level=["metadata.model.deployment_cadence"])
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
df.plot.bar(ax=ax, xlabel="", ylabel="", legend=None, colormap=self.colormap)
|
|
69
|
+
|
|
70
|
+
ax.set_title(label=f"Team Size={name[0]}", fontsize=8)
|
|
71
|
+
|
|
72
|
+
ax.yaxis.set_inverted(True)
|
|
73
|
+
ax.yaxis.set_major_formatter(ticker.PercentFormatter(xmax=1.0, decimals=1))
|
|
74
|
+
|
|
75
|
+
if axs_i == 1:
|
|
76
|
+
ax.legend(title="Deployment Cadence")
|
|
77
|
+
|
|
78
|
+
fig.supxlabel("SDLC Workflow Stage")
|
|
79
|
+
fig.supylabel("Mean Loss")
|
|
80
|
+
fig.suptitle("Mean Stage Loss")
|
|
81
|
+
|
|
82
|
+
plt.tight_layout()
|
|
83
|
+
plt.show()
|
|
84
|
+
|
|
85
|
+
def resource_utilization(self):
|
|
86
|
+
|
|
87
|
+
df_all = json_normalize(
|
|
88
|
+
self._results_dict,
|
|
89
|
+
record_path=["metadata", "resource_metadata"],
|
|
90
|
+
meta=[["metadata", "model", "deployment_cadence"], ["metadata", "model", "team_size"]],
|
|
91
|
+
errors="ignore",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
df_all["state"] = Categorical(
|
|
95
|
+
df_all["state"], categories=[e.value for e in self._task_states], ordered=True
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
df_all.set_index(
|
|
99
|
+
["metadata.model.deployment_cadence", "metadata.model.team_size", "state", "time"],
|
|
100
|
+
inplace=True,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
df_all.sort_index(inplace=True)
|
|
104
|
+
|
|
105
|
+
df_all = df_all.groupby(
|
|
106
|
+
["metadata.model.deployment_cadence", "metadata.model.team_size", "state", "time"]
|
|
107
|
+
).sum()
|
|
108
|
+
|
|
109
|
+
cadence_x_team_size_samples = df_all.groupby(
|
|
110
|
+
["metadata.model.deployment_cadence", "metadata.model.team_size"]
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
dataframes = {key: group for key, group in cadence_x_team_size_samples}
|
|
114
|
+
|
|
115
|
+
# detemrine the size of the subplot grid based on number of unique keys in each dimension
|
|
116
|
+
|
|
117
|
+
all_keys = np.array(list(dataframes.keys()))
|
|
118
|
+
cadences, team_sizes = list(all_keys[:, 0]), list(all_keys[:, 1])
|
|
119
|
+
|
|
120
|
+
# deduplicate
|
|
121
|
+
cadences = list(dict.fromkeys(cadences))
|
|
122
|
+
team_sizes = list(dict.fromkeys(team_sizes))
|
|
123
|
+
|
|
124
|
+
fig, axs = plt.subplots(
|
|
125
|
+
nrows=len(cadences),
|
|
126
|
+
ncols=len(team_sizes),
|
|
127
|
+
sharex=True,
|
|
128
|
+
sharey=True,
|
|
129
|
+
layout="constrained",
|
|
130
|
+
squeeze=False,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
labels = ["Idle", "Productive", "Failure", "Unplanned Work"]
|
|
134
|
+
|
|
135
|
+
for team_i, team_size in enumerate(team_sizes):
|
|
136
|
+
for cadence_i, cadence in enumerate(cadences):
|
|
137
|
+
group = dataframes[(cadence, team_size)]
|
|
138
|
+
|
|
139
|
+
group = group.groupby(["state"]).sum()[
|
|
140
|
+
["idle_t", "success_t", "failure_t", "interruption_t"]
|
|
141
|
+
]
|
|
142
|
+
|
|
143
|
+
df = group.divide(group.sum(axis=1), axis=0)
|
|
144
|
+
|
|
145
|
+
ax = axs[cadence_i, team_i]
|
|
146
|
+
df.plot(
|
|
147
|
+
ax=ax,
|
|
148
|
+
kind="bar",
|
|
149
|
+
stacked=True,
|
|
150
|
+
legend=False,
|
|
151
|
+
xlabel="",
|
|
152
|
+
ylabel="",
|
|
153
|
+
colormap=self.colormap,
|
|
154
|
+
)
|
|
155
|
+
ax.set_title(label=f"Cadence={cadence},Team Size={team_size}", fontsize=8)
|
|
156
|
+
|
|
157
|
+
ax.yaxis.set_major_formatter(ticker.PercentFormatter(xmax=1.0, decimals=1))
|
|
158
|
+
|
|
159
|
+
plt.sca(ax)
|
|
160
|
+
plt.xticks(rotation=45)
|
|
161
|
+
|
|
162
|
+
fig.legend(title="Utilization Category", labels=labels)
|
|
163
|
+
fig.supylabel("Utilization")
|
|
164
|
+
fig.supxlabel("SDLC Workflow Stage")
|
|
165
|
+
fig.suptitle("Resource Utilization")
|
|
166
|
+
plt.show()
|
|
167
|
+
|
|
168
|
+
def resource_capacity(self):
|
|
169
|
+
df_all = json_normalize(
|
|
170
|
+
self._results_dict,
|
|
171
|
+
record_path=["metadata", "resource_metadata"],
|
|
172
|
+
meta=[["metadata", "model", "deployment_cadence"], ["metadata", "model", "team_size"]],
|
|
173
|
+
errors="ignore",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
df_all["state"] = Categorical(
|
|
177
|
+
df_all["state"], categories=[e.value for e in self._task_states], ordered=True
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
df_all.sort_index(inplace=True)
|
|
181
|
+
|
|
182
|
+
df_all = df_all.groupby(
|
|
183
|
+
["metadata.model.deployment_cadence", "metadata.model.team_size", "state", "time"]
|
|
184
|
+
).sum()
|
|
185
|
+
|
|
186
|
+
cadence_x_team_size_samples = df_all.groupby(
|
|
187
|
+
["metadata.model.deployment_cadence", "metadata.model.team_size"]
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
dataframes = {key: group for key, group in cadence_x_team_size_samples}
|
|
191
|
+
|
|
192
|
+
# detemrine the size of the subplot grid based on number of unique keys in each dimension
|
|
193
|
+
|
|
194
|
+
all_keys = np.array(list(dataframes.keys()))
|
|
195
|
+
cadences, team_sizes = list(all_keys[:, 0]), list(all_keys[:, 1])
|
|
196
|
+
|
|
197
|
+
# deduplicate
|
|
198
|
+
cadences = list(dict.fromkeys(cadences))
|
|
199
|
+
team_sizes = list(dict.fromkeys(team_sizes))
|
|
200
|
+
|
|
201
|
+
fig, axs = plt.subplots(
|
|
202
|
+
nrows=len(cadences),
|
|
203
|
+
ncols=len(team_sizes),
|
|
204
|
+
sharey=True,
|
|
205
|
+
layout="constrained",
|
|
206
|
+
squeeze=False,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
for team_i, team_size in enumerate(team_sizes):
|
|
210
|
+
for cadence_i, cadence in enumerate(cadences):
|
|
211
|
+
group = dataframes[(cadence, team_size)]
|
|
212
|
+
|
|
213
|
+
group = group.droplevel([0, 1])["waiting"].unstack(["state"])
|
|
214
|
+
# group = group.droplevel([0, 1]) # .unstack(['state'])
|
|
215
|
+
group.sort_index(inplace=True)
|
|
216
|
+
|
|
217
|
+
group = group.cumsum()
|
|
218
|
+
|
|
219
|
+
group.ffill(inplace=True)
|
|
220
|
+
|
|
221
|
+
ax = axs[cadence_i, team_i]
|
|
222
|
+
group.plot(ax=ax, legend=False, xlabel="", ylabel="", colormap=self.colormap)
|
|
223
|
+
|
|
224
|
+
ax.set_yscale("log")
|
|
225
|
+
|
|
226
|
+
ax.set_title(label=f"Cadence={cadence},Team Size={team_size}", fontsize=8)
|
|
227
|
+
|
|
228
|
+
plt.sca(ax)
|
|
229
|
+
plt.xticks(rotation=45)
|
|
230
|
+
|
|
231
|
+
fig.legend(title="Backlog", labels=["Development", "QA", "Deployment"])
|
|
232
|
+
fig.supylabel("# Waiting for Resource")
|
|
233
|
+
fig.supxlabel("Time")
|
|
234
|
+
fig.suptitle("Resource Capacity")
|
|
235
|
+
plt.show()
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from matplotlib import ticker
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
from pandas import json_normalize
|
|
5
|
+
from value_stream.simulation import SimulationResult
|
|
6
|
+
from .viewer import Viewer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ResultViewer(Viewer):
|
|
10
|
+
"""Displays simulation results."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, results: list[SimulationResult], colormap="plasma"):
|
|
13
|
+
super().__init__(colormap)
|
|
14
|
+
self._results_dict: list[Any] = []
|
|
15
|
+
|
|
16
|
+
for result in results:
|
|
17
|
+
self._results_dict.append(result.model_dump())
|
|
18
|
+
|
|
19
|
+
self.data = json_normalize(
|
|
20
|
+
self._results_dict,
|
|
21
|
+
meta=[
|
|
22
|
+
["summary_result", "model", "deployment_cadence"],
|
|
23
|
+
["summary_result", "model", "team_size"],
|
|
24
|
+
],
|
|
25
|
+
errors="ignore",
|
|
26
|
+
record_prefix="",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self.data.set_index(
|
|
30
|
+
["summary_result.model.deployment_cadence", "summary_result.model.team_size"],
|
|
31
|
+
inplace=True,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
def loss_vs_cadence(self):
|
|
35
|
+
df = self.data
|
|
36
|
+
|
|
37
|
+
df = df[["summary_result.loss", "summary_result.total_delivered_value"]].unstack(-1)[
|
|
38
|
+
["summary_result.loss"]
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
df.columns = df.columns.get_level_values(1) # pyright: ignore[reportAttributeAccessIssue]
|
|
42
|
+
|
|
43
|
+
ax = df.plot(
|
|
44
|
+
title="Loss vs Cadence",
|
|
45
|
+
xlabel="Cadence",
|
|
46
|
+
ylabel="Loss",
|
|
47
|
+
grid=True,
|
|
48
|
+
colormap=self.colormap,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
ax.yaxis.set_minor_locator(ticker.AutoMinorLocator())
|
|
52
|
+
|
|
53
|
+
plt.legend(title="Team Size")
|
|
54
|
+
plt.gca().invert_xaxis()
|
|
55
|
+
ax.yaxis.set_major_formatter(ticker.PercentFormatter(xmax=1.0))
|
|
56
|
+
plt.show()
|
|
57
|
+
|
|
58
|
+
def loss_vs_team_size(self):
|
|
59
|
+
|
|
60
|
+
df = self.data
|
|
61
|
+
|
|
62
|
+
df = df[["summary_result.loss"]].unstack(0)
|
|
63
|
+
|
|
64
|
+
df.columns = df.columns.get_level_values(1) # pyright: ignore[reportAttributeAccessIssue]
|
|
65
|
+
|
|
66
|
+
ax = df.plot(
|
|
67
|
+
title="Loss vs Team Size",
|
|
68
|
+
xlabel="Team Size",
|
|
69
|
+
ylabel="Loss",
|
|
70
|
+
grid=True,
|
|
71
|
+
colormap=self.colormap,
|
|
72
|
+
)
|
|
73
|
+
ax.yaxis.set_minor_locator(ticker.AutoMinorLocator())
|
|
74
|
+
plt.legend(title="Deployment Cadence")
|
|
75
|
+
ax.yaxis.set_major_formatter(ticker.PercentFormatter(xmax=1.0))
|
|
76
|
+
plt.show()
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Python HTTP client for simulation jobs."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Iterable
|
|
5
|
+
|
|
6
|
+
import httpx2 as httpx
|
|
7
|
+
from pydantic import ValidationError
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
from value_stream.service.codec import decode_result, encode_request
|
|
11
|
+
from value_stream.service.schemas import JobAccepted, JobStatus, ModelError, OutcomePage
|
|
12
|
+
from value_stream.simulation import Model, SimulationResult
|
|
13
|
+
from value_stream.task import Task
|
|
14
|
+
|
|
15
|
+
from .errors import BatchSimulationError, JobCancelledError, ServiceClientError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class WebSimulationClient:
|
|
19
|
+
"""Sends simulation requests to the web service."""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
service_url: str,
|
|
24
|
+
poll_interval: float = 0.1,
|
|
25
|
+
max_wait_seconds: float = 3600,
|
|
26
|
+
):
|
|
27
|
+
self.service_url = service_url.rstrip("/")
|
|
28
|
+
self.poll_interval = poll_interval
|
|
29
|
+
self.max_wait_seconds = max_wait_seconds
|
|
30
|
+
self._http = httpx.Client(base_url=self.service_url, timeout=5, trust_env=False)
|
|
31
|
+
|
|
32
|
+
def close(self) -> None:
|
|
33
|
+
self._http.close()
|
|
34
|
+
|
|
35
|
+
def _request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
|
36
|
+
try:
|
|
37
|
+
response = self._http.request(method, path, **kwargs)
|
|
38
|
+
response.raise_for_status()
|
|
39
|
+
return response
|
|
40
|
+
except httpx.HTTPStatusError as exc:
|
|
41
|
+
try:
|
|
42
|
+
data = exc.response.json()
|
|
43
|
+
message = f"{data.get('code', 'HTTP_ERROR')}: {data.get('message', exc)}"
|
|
44
|
+
except ValueError:
|
|
45
|
+
message = str(exc)
|
|
46
|
+
raise ServiceClientError(message) from exc
|
|
47
|
+
except httpx.RequestError as exc:
|
|
48
|
+
raise ServiceClientError(f"service connection failed: {exc}") from exc
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def _validate(model_type, value: dict, description: str):
|
|
52
|
+
try:
|
|
53
|
+
return model_type.model_validate(value)
|
|
54
|
+
except ValidationError as exc:
|
|
55
|
+
raise ServiceClientError(f"service returned malformed {description}") from exc
|
|
56
|
+
|
|
57
|
+
def execute(
|
|
58
|
+
self,
|
|
59
|
+
tasks: list[Task],
|
|
60
|
+
models: Iterable[Model],
|
|
61
|
+
pbar: tqdm | None = None,
|
|
62
|
+
seed: int | None = None,
|
|
63
|
+
) -> list[SimulationResult]:
|
|
64
|
+
model_list = list(models)
|
|
65
|
+
request = encode_request(tasks, model_list, seed)
|
|
66
|
+
submitted = self._validate(
|
|
67
|
+
JobAccepted,
|
|
68
|
+
self._request(
|
|
69
|
+
"POST", "/v1/simulation-jobs", json=request.model_dump(mode="json")
|
|
70
|
+
).json(),
|
|
71
|
+
"job acceptance",
|
|
72
|
+
)
|
|
73
|
+
job_path = f"/v1/simulation-jobs/{submitted.job_id}"
|
|
74
|
+
results: dict[int, SimulationResult] = {}
|
|
75
|
+
errors: dict[int, ModelError] = {}
|
|
76
|
+
cursor = 0
|
|
77
|
+
deadline = time.monotonic() + self.max_wait_seconds
|
|
78
|
+
while True:
|
|
79
|
+
if time.monotonic() >= deadline:
|
|
80
|
+
raise ServiceClientError(f"job {submitted.job_id} exceeded client wait limit")
|
|
81
|
+
page = self._validate(
|
|
82
|
+
OutcomePage,
|
|
83
|
+
self._request("GET", f"{job_path}/outcomes", params={"after": cursor}).json(),
|
|
84
|
+
"outcomes",
|
|
85
|
+
)
|
|
86
|
+
for outcome in page.outcomes:
|
|
87
|
+
if not 0 <= outcome.model_index < len(model_list):
|
|
88
|
+
raise ServiceClientError("service returned an invalid model index")
|
|
89
|
+
if outcome.status == "succeeded" and outcome.result is not None:
|
|
90
|
+
results[outcome.model_index] = decode_result(
|
|
91
|
+
outcome.result, model_list[outcome.model_index]
|
|
92
|
+
)
|
|
93
|
+
elif outcome.status == "failed" and outcome.error is not None:
|
|
94
|
+
errors[outcome.model_index] = outcome.error
|
|
95
|
+
if pbar:
|
|
96
|
+
pbar.update()
|
|
97
|
+
cursor = page.next_cursor
|
|
98
|
+
status = self._validate(JobStatus, self._request("GET", job_path).json(), "job status")
|
|
99
|
+
if status.total_models != len(model_list):
|
|
100
|
+
raise ServiceClientError("service returned an unexpected model count")
|
|
101
|
+
if status.status in ("completed", "completed_with_errors", "cancelled"):
|
|
102
|
+
if cursor < status.total_models:
|
|
103
|
+
continue
|
|
104
|
+
if status.status == "cancelled":
|
|
105
|
+
raise JobCancelledError(f"job {submitted.job_id} was cancelled")
|
|
106
|
+
break
|
|
107
|
+
time.sleep(self.poll_interval)
|
|
108
|
+
successful = sorted(results.items())
|
|
109
|
+
if errors:
|
|
110
|
+
if len(successful) + len(errors) != len(model_list):
|
|
111
|
+
raise ServiceClientError("service omitted a model outcome")
|
|
112
|
+
raise BatchSimulationError(successful_results=successful, errors=errors)
|
|
113
|
+
if len(successful) != len(model_list):
|
|
114
|
+
raise ServiceClientError("service omitted a model result")
|
|
115
|
+
return [result for _, result in successful]
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Client errors that preserve partial batch outcomes."""
|
|
2
|
+
|
|
3
|
+
from value_stream.simulation import SimulationResult
|
|
4
|
+
from value_stream.service.schemas import ModelError
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class ServiceClientError(RuntimeError):
|
|
8
|
+
"""Base error raised by the web service client."""
|
|
9
|
+
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BatchSimulationError(ServiceClientError):
|
|
14
|
+
"""Reports a failure while running a batch of simulations."""
|
|
15
|
+
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
successful_results: list[tuple[int, SimulationResult]],
|
|
19
|
+
errors: dict[int, ModelError],
|
|
20
|
+
):
|
|
21
|
+
self.successful_results = successful_results
|
|
22
|
+
self.errors = errors
|
|
23
|
+
super().__init__(f"{len(errors)} model(s) failed: {sorted(errors)}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class JobCancelledError(ServiceClientError):
|
|
27
|
+
"""Reports that a simulation job was cancelled."""
|
|
28
|
+
|
|
29
|
+
pass
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""One reference-counted loopback HTTP server per Python process."""
|
|
2
|
+
|
|
3
|
+
import atexit
|
|
4
|
+
import socket
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import httpx2 as httpx
|
|
9
|
+
import uvicorn
|
|
10
|
+
|
|
11
|
+
from value_stream.service.app import create_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class _LocalService:
|
|
15
|
+
"""Manages the local simulation service process."""
|
|
16
|
+
|
|
17
|
+
def __init__(self):
|
|
18
|
+
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
19
|
+
self._socket.bind(("127.0.0.1", 0))
|
|
20
|
+
self._socket.listen(128)
|
|
21
|
+
self.url = f"http://127.0.0.1:{self._socket.getsockname()[1]}"
|
|
22
|
+
# Uvicorn loggers are shared with the hosting API. Reconfiguring them
|
|
23
|
+
# here can suppress its startup message (and VS Code's browser trigger).
|
|
24
|
+
config = uvicorn.Config(create_app(), host="127.0.0.1", log_config=None)
|
|
25
|
+
self._server = uvicorn.Server(config)
|
|
26
|
+
self._thread = threading.Thread(
|
|
27
|
+
target=self._server.run, kwargs={"sockets": [self._socket]}, daemon=True
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def start(self):
|
|
31
|
+
self._thread.start()
|
|
32
|
+
deadline = time.monotonic() + 5
|
|
33
|
+
while time.monotonic() < deadline:
|
|
34
|
+
if not self._thread.is_alive():
|
|
35
|
+
break
|
|
36
|
+
try:
|
|
37
|
+
if httpx.get(f"{self.url}/health", timeout=0.2, trust_env=False).status_code == 200:
|
|
38
|
+
return
|
|
39
|
+
except httpx.RequestError:
|
|
40
|
+
pass
|
|
41
|
+
time.sleep(0.05)
|
|
42
|
+
self.stop()
|
|
43
|
+
raise RuntimeError("local simulation service did not become ready")
|
|
44
|
+
|
|
45
|
+
def stop(self):
|
|
46
|
+
self._server.should_exit = True
|
|
47
|
+
if self._thread.is_alive():
|
|
48
|
+
self._thread.join(timeout=5)
|
|
49
|
+
self._socket.close()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_lock = threading.RLock()
|
|
53
|
+
_service: _LocalService | None = None
|
|
54
|
+
_owners = 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def acquire_local_service() -> str:
|
|
58
|
+
global _service, _owners
|
|
59
|
+
with _lock:
|
|
60
|
+
if _service is None:
|
|
61
|
+
new_service = _LocalService()
|
|
62
|
+
new_service.start()
|
|
63
|
+
_service = new_service
|
|
64
|
+
_owners += 1
|
|
65
|
+
return _service.url
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def release_local_service() -> None:
|
|
69
|
+
global _service, _owners
|
|
70
|
+
with _lock:
|
|
71
|
+
if _owners <= 0:
|
|
72
|
+
return
|
|
73
|
+
_owners -= 1
|
|
74
|
+
if _owners:
|
|
75
|
+
return
|
|
76
|
+
service = _service
|
|
77
|
+
_service = None
|
|
78
|
+
if service is not None:
|
|
79
|
+
service.stop()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _cleanup() -> None:
|
|
83
|
+
global _service, _owners
|
|
84
|
+
with _lock:
|
|
85
|
+
service = _service
|
|
86
|
+
_service = None
|
|
87
|
+
_owners = 0
|
|
88
|
+
if service is not None:
|
|
89
|
+
service.stop()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
atexit.register(_cleanup)
|