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.
Files changed (81) hide show
  1. value_stream/__init__.py +1 -0
  2. value_stream/app/SPEC.md +79 -0
  3. value_stream/app/__init__.py +1 -0
  4. value_stream/app/__main__.py +28 -0
  5. value_stream/app/contracts.py +18 -0
  6. value_stream/app/coordinator.py +359 -0
  7. value_stream/app/errors.py +17 -0
  8. value_stream/app/exports.py +142 -0
  9. value_stream/app/gateway.py +130 -0
  10. value_stream/app/generation.py +269 -0
  11. value_stream/app/insights.py +75 -0
  12. value_stream/app/metrics.py +132 -0
  13. value_stream/app/openapi.json +3699 -0
  14. value_stream/app/schemas.py +288 -0
  15. value_stream/app/server.py +298 -0
  16. value_stream/app/settings.py +46 -0
  17. value_stream/app/storage.py +651 -0
  18. value_stream/client/__init__.py +3 -0
  19. value_stream/client/simulation_runner.py +83 -0
  20. value_stream/client/views/__init__.py +4 -0
  21. value_stream/client/views/metadata_viewer.py +235 -0
  22. value_stream/client/views/result_viewer.py +76 -0
  23. value_stream/client/views/viewer.py +8 -0
  24. value_stream/client/web/__init__.py +10 -0
  25. value_stream/client/web/client.py +115 -0
  26. value_stream/client/web/errors.py +29 -0
  27. value_stream/client/web/local_service.py +92 -0
  28. value_stream/factory/__init__.py +5 -0
  29. value_stream/factory/developer_factory.py +33 -0
  30. value_stream/factory/factory.py +43 -0
  31. value_stream/factory/task_factory.py +64 -0
  32. value_stream/factory/task_generator.py +102 -0
  33. value_stream/policy/__init__.py +3 -0
  34. value_stream/policy/simulation_policy.py +20 -0
  35. value_stream/resources/__init__.py +23 -0
  36. value_stream/resources/developer.py +53 -0
  37. value_stream/resources/qa_tester.py +51 -0
  38. value_stream/resources/resource.py +162 -0
  39. value_stream/resources/resource_metadata.py +24 -0
  40. value_stream/resources/resource_policy.py +13 -0
  41. value_stream/resources/resource_pool.py +39 -0
  42. value_stream/resources/resource_tracker.py +160 -0
  43. value_stream/resources/toolchain.py +43 -0
  44. value_stream/service/SPEC.md +46 -0
  45. value_stream/service/__init__.py +1 -0
  46. value_stream/service/app.py +218 -0
  47. value_stream/service/codec.py +237 -0
  48. value_stream/service/job_store.py +322 -0
  49. value_stream/service/openapi.json +1283 -0
  50. value_stream/service/scheduler.py +234 -0
  51. value_stream/service/schemas.py +248 -0
  52. value_stream/service/settings.py +37 -0
  53. value_stream/service/worker.py +82 -0
  54. value_stream/simulation/__init__.py +16 -0
  55. value_stream/simulation/default_simulation_policy.py +68 -0
  56. value_stream/simulation/model.py +30 -0
  57. value_stream/simulation/model_factory.py +54 -0
  58. value_stream/simulation/simulation.py +144 -0
  59. value_stream/simulation/simulation_metadata.py +12 -0
  60. value_stream/simulation/simulation_result.py +20 -0
  61. value_stream/task/__init__.py +23 -0
  62. value_stream/task/epoch.py +21 -0
  63. value_stream/task/event_status.py +8 -0
  64. value_stream/task/task.py +260 -0
  65. value_stream/task/task_event.py +97 -0
  66. value_stream/task/task_history.py +208 -0
  67. value_stream/task/task_router.py +70 -0
  68. value_stream/task/task_state.py +7 -0
  69. value_stream/task/task_type.py +8 -0
  70. value_stream/workflow/__init__.py +18 -0
  71. value_stream/workflow/assignment_strategy.py +10 -0
  72. value_stream/workflow/pool_manager.py +136 -0
  73. value_stream/workflow/resource_operator.py +204 -0
  74. value_stream/workflow/sdlc_workflow.py +99 -0
  75. value_stream/workflow/support_workflow.py +23 -0
  76. value_stream/workflow/task_store.py +125 -0
  77. value_stream/workflow/workflow_policy.py +12 -0
  78. value_stream-0.1.1.dist-info/METADATA +288 -0
  79. value_stream-0.1.1.dist-info/RECORD +81 -0
  80. value_stream-0.1.1.dist-info/WHEEL +4 -0
  81. 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,4 @@
1
+ from .metadata_viewer import MetadataViewer
2
+ from .result_viewer import ResultViewer
3
+
4
+ __all__ = ["MetadataViewer", "ResultViewer"]
@@ -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,8 @@
1
+ from matplotlib import colormaps
2
+
3
+
4
+ class Viewer:
5
+ """Defines the interface for simulation result viewers."""
6
+
7
+ def __init__(self, colormap: str = "plasma"):
8
+ self.colormap = colormaps[colormap]
@@ -0,0 +1,10 @@
1
+ __all__ = []
2
+ from .client import WebSimulationClient
3
+ from .errors import BatchSimulationError, JobCancelledError, ServiceClientError
4
+
5
+ __all__ = [
6
+ "WebSimulationClient",
7
+ "BatchSimulationError",
8
+ "JobCancelledError",
9
+ "ServiceClientError",
10
+ ]
@@ -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)
@@ -0,0 +1,5 @@
1
+ from .developer_factory import DeveloperFactory
2
+ from .task_factory import TaskFactory
3
+ from .task_generator import TaskGenerator
4
+
5
+ __all__ = ["DeveloperFactory", "TaskFactory", "TaskGenerator"]