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,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
|
+
)
|