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,130 @@
1
+ """HTTP-only simulation boundary with bounded reads and owned startup."""
2
+
3
+ import asyncio
4
+ from typing import Protocol
5
+ from uuid import UUID
6
+ import httpx
7
+ from value_stream.service.schemas import JobRequest, JobAccepted, JobStatus, OutcomePage
8
+ from .errors import AppError
9
+
10
+
11
+ class SimulationGateway(Protocol):
12
+ """Defines the interface for submitting simulation jobs."""
13
+
14
+ async def start(self): ...
15
+ async def ready(self): ...
16
+ async def submit(self, request: JobRequest) -> JobAccepted: ...
17
+ async def status(self, job_id: UUID) -> JobStatus: ...
18
+ async def outcomes(self, job_id: UUID, after: int) -> OutcomePage: ...
19
+ async def cancel(self, job_id: UUID) -> JobStatus: ...
20
+ async def close(self): ...
21
+
22
+
23
+ class HttpSimulationGateway:
24
+ """Submits simulation jobs through the HTTP service."""
25
+
26
+ def __init__(self, url=None, max_response_bytes=65 * 1024 * 1024):
27
+ self.url = url
28
+ self.owned = False
29
+ self.client = None
30
+ self.max_response_bytes = max_response_bytes
31
+ self.download = asyncio.Semaphore(1)
32
+
33
+ async def start(self):
34
+ if not self.url:
35
+ from value_stream.client.web.local_service import acquire_local_service
36
+
37
+ self.url = await asyncio.to_thread(acquire_local_service)
38
+ self.owned = True
39
+ self.client = httpx.AsyncClient(
40
+ base_url=self.url, timeout=httpx.Timeout(10, connect=3), trust_env=False
41
+ )
42
+
43
+ async def request(self, method, path, body=None):
44
+ if self.client is None or self.client.is_closed:
45
+ raise AppError("SERVICE_UNAVAILABLE", "Simulation service is not connected", 503)
46
+ try:
47
+ async with self.download:
48
+ async with self.client.stream(method, path, json=body) as response:
49
+ data = bytearray()
50
+ async for chunk in response.aiter_bytes():
51
+ data.extend(chunk)
52
+ if len(data) > self.max_response_bytes:
53
+ raise AppError(
54
+ "RESULT_TOO_LARGE",
55
+ "Service response exceeds the configured limit",
56
+ 502,
57
+ )
58
+ import json
59
+
60
+ try:
61
+ value = json.loads(data)
62
+ except ValueError as exc:
63
+ raise AppError(
64
+ "SERVICE_INVALID_RESPONSE",
65
+ "Service returned invalid JSON",
66
+ 502,
67
+ ) from exc
68
+ if not isinstance(value, dict):
69
+ raise AppError(
70
+ "SERVICE_INVALID_RESPONSE",
71
+ "Service returned a non-object JSON response",
72
+ 502,
73
+ )
74
+ if response.is_error:
75
+ raise AppError(
76
+ value.get("code", "SERVICE_ERROR"),
77
+ value.get("message", "Simulation service request failed"),
78
+ response.status_code,
79
+ value.get("details"),
80
+ )
81
+ return value
82
+ except httpx.RequestError as exc:
83
+ raise AppError(
84
+ "SERVICE_UNAVAILABLE",
85
+ "Cannot reach the simulation service. Retained results are still available.",
86
+ 503,
87
+ ) from exc
88
+
89
+ async def ready(self):
90
+ await self.request("GET", "/health")
91
+ schema = await self.request("GET", "/openapi.json")
92
+ properties = (
93
+ schema.get("components", {})
94
+ .get("schemas", {})
95
+ .get("JobRequest", {})
96
+ .get("properties", {})
97
+ )
98
+ if not {"model_seeds", "submission_id"} <= properties.keys():
99
+ raise AppError(
100
+ "SERVICE_INCOMPATIBLE",
101
+ "Update the simulation service: model seeds and submission identifiers are required.",
102
+ 503,
103
+ )
104
+
105
+ async def submit(self, request):
106
+ return JobAccepted.model_validate(
107
+ await self.request("POST", "/v1/simulation-jobs", request.model_dump(mode="json"))
108
+ )
109
+
110
+ async def status(self, job_id):
111
+ return JobStatus.model_validate(await self.request("GET", f"/v1/simulation-jobs/{job_id}"))
112
+
113
+ async def outcomes(self, job_id, after):
114
+ return OutcomePage.model_validate(
115
+ await self.request("GET", f"/v1/simulation-jobs/{job_id}/outcomes?after={after}")
116
+ )
117
+
118
+ async def cancel(self, job_id):
119
+ return JobStatus.model_validate(
120
+ await self.request("DELETE", f"/v1/simulation-jobs/{job_id}")
121
+ )
122
+
123
+ async def close(self):
124
+ if self.client:
125
+ await self.client.aclose()
126
+ if self.owned:
127
+ from value_stream.client.web.local_service import release_local_service
128
+
129
+ await asyncio.to_thread(release_local_service)
130
+ self.owned = False
@@ -0,0 +1,269 @@
1
+ """Pure, bounded materialization independent of batch ordering."""
2
+
3
+ from typing import Any
4
+
5
+ import hashlib
6
+ import itertools
7
+ import json
8
+ import math
9
+ import random
10
+ from decimal import Decimal
11
+ from uuid import UUID, uuid5
12
+ from pydantic import ValidationError
13
+ from value_stream.service.schemas import (
14
+ DeveloperInput,
15
+ ModelInput,
16
+ QAPoolInput,
17
+ ToolchainPoolInput,
18
+ TaskInput,
19
+ )
20
+ from .errors import AppError
21
+ from .schemas import (
22
+ ConstantValue,
23
+ TaskSetSpec,
24
+ ModelSettings,
25
+ ScenarioDefinition,
26
+ SweepRange,
27
+ ConcreteScenario,
28
+ GENERATOR_VERSION,
29
+ )
30
+ from .settings import AppSettings
31
+
32
+
33
+ def canonical(value: Any) -> str:
34
+ """Serialize a value with stable key ordering and separators.
35
+
36
+ Args:
37
+ value (Any): Value to normalize or inspect.
38
+
39
+ Returns:
40
+ str: Canonical JSON representation of the value.
41
+ """
42
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False, default=str)
43
+
44
+
45
+ def fingerprint(value: Any) -> str:
46
+ """Return the SHA-256 fingerprint of a canonical value.
47
+
48
+ Args:
49
+ value (Any): Value to normalize or inspect.
50
+
51
+ Returns:
52
+ str: Hexadecimal SHA-256 digest.
53
+ """
54
+ return hashlib.sha256(canonical(value).encode()).hexdigest()
55
+
56
+
57
+ def derive_seed(domain, *values):
58
+ # JSON numbers remain exactly representable in the browser.
59
+ return int(fingerprint([GENERATOR_VERSION, domain, *values])[:13], 16)
60
+
61
+
62
+ def materialize_tasks(spec: TaskSetSpec) -> list[TaskInput]:
63
+ """Build task inputs from the task set settings.
64
+
65
+ Args:
66
+ spec (TaskSetSpec): Task set configuration used to generate tasks.
67
+
68
+ Returns:
69
+ list[TaskInput]: Generated task inputs.
70
+ """
71
+
72
+ rng = random.Random(derive_seed("tasks", spec.seed))
73
+
74
+ def sample(value):
75
+ return (
76
+ value.value
77
+ if isinstance(value, ConstantValue)
78
+ else rng.uniform(value.minimum, value.maximum)
79
+ )
80
+
81
+ return [
82
+ TaskInput(
83
+ task_name=str(i + 1),
84
+ initial_value=sample(spec.initial_value),
85
+ story_points=sample(spec.story_points),
86
+ depreciation_rate=spec.depreciation_rate,
87
+ )
88
+ for i in range(spec.count)
89
+ ]
90
+
91
+
92
+ def materialize_team(settings: ModelSettings, seed: int) -> list[DeveloperInput]:
93
+ """Build a developer team using the configured efficiency distribution.
94
+
95
+ Args:
96
+ settings (ModelSettings): Settings to validate or use.
97
+ seed (int): Random seed for repeatable simulation.
98
+
99
+ Returns:
100
+ list[DeveloperInput]: Generated developer inputs.
101
+ """
102
+ low, high, n = settings.efficiency_min, settings.efficiency_max, settings.team_size
103
+ rng = random.Random(derive_seed("team", seed, low, high, settings.distribution))
104
+ values = []
105
+ for i in range(n):
106
+ if low == high:
107
+ value = low
108
+ elif settings.distribution == "linear":
109
+ value = low + (high - low) / 2 if n == 1 else low + (high - low) * (i / (n - 1))
110
+ else:
111
+ while True:
112
+ value = rng.gauss(low + (high - low) / 2, (high - low) / 6)
113
+ if low <= value <= high:
114
+ break
115
+ values.append(DeveloperInput(name=f"Developer {i + 1}", efficiency=value))
116
+ return values
117
+
118
+
119
+ def concrete_model(settings: ModelSettings, seed: int) -> ModelInput:
120
+ """Build a service model input from scenario settings.
121
+
122
+ Args:
123
+ settings (ModelSettings): Settings to validate or use.
124
+ seed (int): Random seed for repeatable simulation.
125
+
126
+ Returns:
127
+ ModelInput: Model configuration ready for submission.
128
+ """
129
+ return ModelInput(
130
+ developer_team=materialize_team(settings, seed),
131
+ deployment_cadence=settings.deployment_cadence,
132
+ qa_testers=QAPoolInput(
133
+ limit=settings.qa_size,
134
+ time_cost=settings.qa_time_cost,
135
+ failure_rate=settings.qa_failure_rate,
136
+ failure_cost=settings.qa_failure_cost,
137
+ ),
138
+ toolchain_pool=ToolchainPoolInput(
139
+ limit=settings.toolchain_size,
140
+ deployment_duration=settings.deployment_duration,
141
+ failure_rate=settings.deployment_failure_rate,
142
+ ),
143
+ support_interval=settings.support_interval,
144
+ support_task_story_points=settings.support_task_story_points,
145
+ )
146
+
147
+
148
+ def axis_values(axis: SweepRange | list[Any], limit: int) -> list[Any]:
149
+ """Expand a sweep axis into its distinct values.
150
+
151
+ Args:
152
+ axis (SweepRange | list[Any]): Range or explicit values to expand.
153
+ limit (int): Maximum number of values allowed.
154
+
155
+ Raises:
156
+ AppError: If the axis is empty, invalid, or exceeds the configured value limit.
157
+
158
+ Returns:
159
+ list[Any]: Expanded sweep values.
160
+ """
161
+ if isinstance(axis, SweepRange):
162
+ start, end, step = (Decimal(str(v)) for v in (axis.start, axis.end, axis.step))
163
+ if start > end:
164
+ raise AppError("INVALID_INPUT", "Sweep start must not exceed end")
165
+ span = (end - start) / step
166
+ if span >= limit:
167
+ raise AppError("LIMIT_EXCEEDED", f"Each sweep can contain at most {limit} values")
168
+ count = int(span) + 1
169
+ values = [float(start + step * i) for i in range(count)]
170
+ else:
171
+ if len(axis) > limit:
172
+ raise AppError("LIMIT_EXCEEDED", f"Each sweep can contain at most {limit} values")
173
+ values = list(dict.fromkeys(axis))
174
+ if not values:
175
+ raise AppError("INVALID_INPUT", "A sweep needs at least one value")
176
+ return values
177
+
178
+
179
+ def validate_model_limits(settings: ModelSettings, limits: AppSettings) -> None:
180
+ """Validate model settings against configured limits.
181
+
182
+ Args:
183
+ settings (ModelSettings): Model settings to validate.
184
+ limits (AppSettings): Application limits to enforce.
185
+
186
+ Raises:
187
+ AppError: If a model setting falls outside the configured limits.
188
+ """
189
+ if max(settings.team_size, settings.qa_size, settings.toolchain_size) > limits.max_resources:
190
+ raise AppError("LIMIT_EXCEEDED", f"Resource counts must not exceed {limits.max_resources}")
191
+
192
+
193
+ def expand_scenarios(
194
+ definitions: list[ScenarioDefinition], limits: AppSettings
195
+ ) -> list[ConcreteScenario]:
196
+ """Expand scenario definitions into concrete scenarios.
197
+
198
+ Args:
199
+ definitions (list[ScenarioDefinition]): Definitions.
200
+ limits (AppSettings): Configured limits for validation.
201
+
202
+ Raises:
203
+ AppError: If scenario definitions are invalid or exceed the expansion limit.
204
+
205
+ Returns:
206
+ list[ConcreteScenario]: The resulting value.
207
+ """
208
+ axes = []
209
+ count = 0
210
+ if len(definitions) > limits.max_definitions:
211
+ raise AppError("LIMIT_EXCEEDED", "Too many definitions")
212
+ for definition in definitions:
213
+ unknown = set(definition.sweeps) - ModelSettings.model_fields.keys()
214
+ if unknown:
215
+ raise AppError(
216
+ "INVALID_INPUT",
217
+ f"Unknown sweep properties: {', '.join(sorted(unknown))}",
218
+ )
219
+ values = {
220
+ k: axis_values(v, limits.max_axis_values) for k, v in sorted(definition.sweeps.items())
221
+ }
222
+ count += math.prod(len(v) for v in values.values())
223
+ if count > limits.max_models:
224
+ raise AppError(
225
+ "LIMIT_EXCEEDED",
226
+ f"Sweep produces more than {limits.max_models} models. Reduce the sweep or configure a higher limit.",
227
+ )
228
+ axes.append(values)
229
+ scenarios: list[ConcreteScenario] = []
230
+ seen = set()
231
+ for definition, values in zip(definitions, axes):
232
+ for combination in itertools.product(*values.values()):
233
+ changed = dict(zip(values, combination))
234
+ try:
235
+ settings = ModelSettings.model_validate(
236
+ {**definition.settings.model_dump(), **changed}
237
+ )
238
+ except ValidationError as exc:
239
+ raise AppError(
240
+ "INVALID_INPUT",
241
+ "Invalid sweep combination",
242
+ details={
243
+ "definition": definition.name,
244
+ "values": changed,
245
+ "issues": str(exc),
246
+ },
247
+ ) from exc
248
+ validate_model_limits(settings, limits)
249
+ identity = canonical(settings.model_dump())
250
+ scenario_id = uuid5(definition.id, identity)
251
+ if scenario_id in seen:
252
+ continue
253
+ seen.add(scenario_id)
254
+ label = ", ".join(f"{k.replace('_', ' ')}={getattr(settings, k)}" for k in changed)
255
+ scenarios.append(
256
+ ConcreteScenario(
257
+ id=scenario_id,
258
+ definition_id=definition.id,
259
+ revision=definition.revision,
260
+ name=f"{definition.name} · {label}" if label else definition.name,
261
+ settings=settings,
262
+ team_seed=definition.team_seed,
263
+ execution_seed=derive_seed(
264
+ "simulation", definition.execution_seed, str(scenario_id)
265
+ ),
266
+ model=concrete_model(settings, definition.team_seed),
267
+ )
268
+ )
269
+ return scenarios
@@ -0,0 +1,75 @@
1
+ """Small evidence-backed observations; proposed interventions are untested."""
2
+
3
+ from .schemas import ModelSettings, Observation, PlotData
4
+
5
+
6
+ def observations(metrics: PlotData, settings: ModelSettings) -> list[Observation]:
7
+ """Build evidence-backed observations from simulation metrics.
8
+
9
+ Args:
10
+ metrics (PlotData): Simulation metrics to summarize.
11
+ settings (ModelSettings): Model settings used to suggest changes.
12
+
13
+ Returns:
14
+ list[Observation]: Findings derived from the supplied metrics.
15
+ """
16
+ if metrics.loss_percent is None:
17
+ return [
18
+ Observation(
19
+ rule="zero_value",
20
+ message="Value-loss comparisons are unavailable because initial value is zero.",
21
+ evidence="Total initial value is zero.",
22
+ )
23
+ ]
24
+ result: list[Observation] = []
25
+ if metrics.stages:
26
+ stage = max(metrics.stages, key=lambda item: item.loss_percent)
27
+ if stage.loss_percent > 0:
28
+ result.append(
29
+ Observation(
30
+ rule="stage_loss",
31
+ message=f"{stage.label} has the largest observed mean stage-visit loss.",
32
+ evidence=f"{stage.loss_percent:.2f}% across {stage.visits} visits. These means are not an additive breakdown of total loss.",
33
+ property=(
34
+ "deployment_cadence"
35
+ if stage.stage == "qa_complete" and settings.deployment_cadence
36
+ else None
37
+ ),
38
+ value=max(0, settings.deployment_cadence // 2),
39
+ )
40
+ )
41
+ if metrics.backlog:
42
+ queue = max(metrics.backlog, key=lambda item: max(item.waiting, default=0))
43
+ peak = max(queue.waiting, default=0)
44
+ if peak > 0:
45
+ field = {
46
+ "qa_testing": "qa_size",
47
+ "development": "team_size",
48
+ "deployment": "toolchain_size",
49
+ }.get(queue.stage)
50
+ result.append(
51
+ Observation(
52
+ rule="backlog",
53
+ message=f"{queue.label} has the largest observed resource-request backlog. Try additional capacity.",
54
+ evidence=f"Peak: {peak} waiting requests; a request may contain several tasks. Improvement is untested.",
55
+ property=field,
56
+ value=getattr(settings, field) + 1 if field else None,
57
+ )
58
+ )
59
+ for item in metrics.activity:
60
+ if item.durations["failure_t"] > 0:
61
+ field = (
62
+ "qa_failure_rate"
63
+ if item.stage == "qa_testing"
64
+ else "deployment_failure_rate" if item.stage == "deployment" else None
65
+ )
66
+ result.append(
67
+ Observation(
68
+ rule="failed_work",
69
+ message=f"{item.label} records time spent on failed work. Test a lower failure rate.",
70
+ evidence=f"Recorded failed-work duration: {item.durations['failure_t']:.2f} time units. Improvement is untested.",
71
+ property=field,
72
+ value=getattr(settings, field) / 2 if field else None,
73
+ )
74
+ )
75
+ return result
@@ -0,0 +1,132 @@
1
+ """Explicit display measures for the existing simulation telemetry."""
2
+
3
+ from collections import defaultdict
4
+
5
+ from value_stream.service.schemas import ResultData
6
+
7
+ from .errors import AppError
8
+ from .schemas import PlotData, StageLoss, ResourceActivity, Backlog
9
+
10
+ STAGES = {
11
+ "pending": "Waiting for development",
12
+ "development": "Development",
13
+ "dev_complete": "Waiting for QA",
14
+ "qa_testing": "QA testing",
15
+ "qa_complete": "Waiting for deployment",
16
+ "deployment": "Deployment",
17
+ "delivery": "Delivery",
18
+ "support_pending": "Support waiting",
19
+ "support_complete": "Support complete",
20
+ }
21
+ CATEGORIES = ("idle_t", "success_t", "failure_t", "interruption_t")
22
+
23
+
24
+ def loss_percent(initial, delivered):
25
+ return 100 * (initial - delivered) / initial if initial else None
26
+
27
+
28
+ # thin out by chunking and returning start, end, min and max from each chunk
29
+ def reduce_points(points: list[tuple[float, int]], limit: int = 5000) -> list[tuple[float, int]]:
30
+ """Keep representative extrema while reducing a series to the point limit.
31
+
32
+ Args:
33
+ points (list[tuple[float, int]]): Ordered (time, value) points to reduce.
34
+ limit (int): Maximum number of points to keep.
35
+
36
+ Returns:
37
+ list[tuple[float, int]]: Reduced points, preserving each chunk boundary and extreme value.
38
+ """
39
+ if len(points) <= limit:
40
+ return points
41
+ width = max(1, (len(points) + limit // 4 - 1) // (limit // 4))
42
+ result = []
43
+ for start in range(0, len(points), width):
44
+ chunk = points[start : start + width]
45
+ indices = {
46
+ 0,
47
+ len(chunk) - 1,
48
+ min(range(len(chunk)), key=lambda i: chunk[i][1]),
49
+ max(range(len(chunk)), key=lambda i: chunk[i][1]),
50
+ }
51
+ result.extend(chunk[i] for i in sorted(indices))
52
+ return result
53
+
54
+
55
+ def plot_data(result: ResultData, initial: float) -> PlotData:
56
+ """Build plot data from a simulation result.
57
+
58
+ Args:
59
+ result (ResultData): Simulation result to retain.
60
+ initial (float): Total initial task value used to calculate loss.
61
+
62
+ Raises:
63
+ AppError: If the requested operation is invalid.
64
+
65
+ Returns:
66
+ PlotData: PlotData containing stage loss, resource activity, and backlog series.
67
+ """
68
+ events: dict[str, list[float]] = defaultdict(list)
69
+ durations: dict[str, dict[str, float]] = defaultdict(lambda: dict.fromkeys(CATEGORIES, 0.0))
70
+ waiting: dict[str, dict[float, int]] = defaultdict(lambda: defaultdict(int))
71
+
72
+ # summarize loss by event (workflow state)
73
+ for event in result.metadata.event_metadata:
74
+ if event.event_type == "end" and event.task_type == "development":
75
+ events[event.event].append(-100 * event.loss)
76
+
77
+ for record in result.metadata.resource_metadata:
78
+ # track incremental increase/decrease in waiting resources by workflow state
79
+ waiting[record.state][record.time] += record.waiting
80
+
81
+ # summarize time spent by resource in each time category
82
+ for key in CATEGORIES:
83
+ durations[record.state][key] += getattr(record, key) or 0
84
+
85
+ stages, activity, backlog = [], [], []
86
+ for stage in dict.fromkeys([*STAGES, *events, *durations]):
87
+ label = STAGES.get(stage, stage)
88
+ if events[stage]:
89
+ stages.append(
90
+ StageLoss(
91
+ stage=stage,
92
+ label=label,
93
+ visits=len(events[stage]),
94
+ loss_percent=sum(events[stage]) / len(events[stage]),
95
+ )
96
+ )
97
+ if stage in durations:
98
+ total = sum(durations[stage].values())
99
+ activity.append(
100
+ ResourceActivity(
101
+ stage=stage,
102
+ label=label,
103
+ durations=durations[stage],
104
+ shares={
105
+ k: 100 * v / total if total else None for k, v in durations[stage].items()
106
+ },
107
+ )
108
+ )
109
+ if stage in waiting:
110
+ points, current = [(0.0, 0)], 0
111
+ for time, delta in sorted(waiting[stage].items()):
112
+ current += delta
113
+ if current < 0:
114
+ raise AppError("INVALID_RESULT", "Resource backlog became negative", 502)
115
+ points.append((time, current))
116
+ points.append((result.summary_result.completion_time, current))
117
+ reduced = reduce_points(points)
118
+ backlog.append(
119
+ Backlog(
120
+ stage=stage,
121
+ label=label,
122
+ time=[p[0] for p in reduced],
123
+ waiting=[p[1] for p in reduced],
124
+ reduced=len(points) != len(reduced),
125
+ )
126
+ )
127
+ return PlotData(
128
+ loss_percent=loss_percent(initial, result.summary_result.total_delivered_value),
129
+ stages=stages,
130
+ activity=activity,
131
+ backlog=backlog,
132
+ )