dreamcycle 0.2.0__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.
- dreamcycle/__init__.py +48 -0
- dreamcycle/adapters.py +159 -0
- dreamcycle/cycle.py +377 -0
- dreamcycle/dataset.py +125 -0
- dreamcycle/errors.py +37 -0
- dreamcycle/evaluation.py +36 -0
- dreamcycle/events.py +55 -0
- dreamcycle/memory/__init__.py +27 -0
- dreamcycle/memory/base.py +33 -0
- dreamcycle/memory/embeddings.py +91 -0
- dreamcycle/memory/postgres.py +855 -0
- dreamcycle/memory/schema.py +165 -0
- dreamcycle/py.typed +1 -0
- dreamcycle/sdk/__init__.py +12 -0
- dreamcycle/sdk/client.py +192 -0
- dreamcycle/sdk/models.py +72 -0
- dreamcycle/server/__init__.py +42 -0
- dreamcycle/server/app.py +261 -0
- dreamcycle/server/auth.py +57 -0
- dreamcycle/server/cli.py +44 -0
- dreamcycle/server/jobs.py +131 -0
- dreamcycle/server/memory.py +74 -0
- dreamcycle/server/models.py +124 -0
- dreamcycle/server/proxy.py +392 -0
- dreamcycle/server/runtime.py +292 -0
- dreamcycle/server/service.py +102 -0
- dreamcycle/training/__init__.py +18 -0
- dreamcycle/training/base.py +5 -0
- dreamcycle/training/transformers.py +360 -0
- dreamcycle/types.py +223 -0
- dreamcycle-0.2.0.dist-info/METADATA +498 -0
- dreamcycle-0.2.0.dist-info/RECORD +36 -0
- dreamcycle-0.2.0.dist-info/WHEEL +4 -0
- dreamcycle-0.2.0.dist-info/entry_points.txt +2 -0
- dreamcycle-0.2.0.dist-info/licenses/LICENSE +201 -0
- dreamcycle-0.2.0.dist-info/licenses/NOTICE +4 -0
dreamcycle/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""DreamCycle public API."""
|
|
2
|
+
|
|
3
|
+
from dreamcycle.adapters import AdapterManager
|
|
4
|
+
from dreamcycle.cycle import DreamCycle, DreamCycleConfig, Evaluator, ShadowEvaluator, Trainer
|
|
5
|
+
from dreamcycle.dataset import DatasetBuilder, DatasetBuilderConfig, TrainingCandidateSource
|
|
6
|
+
from dreamcycle.evaluation import QualityGate, ShadowGate
|
|
7
|
+
from dreamcycle.events import DreamEvent, EventSink, EventType
|
|
8
|
+
from dreamcycle.types import (
|
|
9
|
+
CycleStatus,
|
|
10
|
+
DatasetArtifact,
|
|
11
|
+
DreamCycleReport,
|
|
12
|
+
EvaluationResult,
|
|
13
|
+
PhaseResult,
|
|
14
|
+
PhaseStatus,
|
|
15
|
+
PromotionResult,
|
|
16
|
+
ShadowResult,
|
|
17
|
+
TrainingCandidate,
|
|
18
|
+
TrainingResult,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__version__ = "0.2.0"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"AdapterManager",
|
|
25
|
+
"CycleStatus",
|
|
26
|
+
"DatasetArtifact",
|
|
27
|
+
"DatasetBuilder",
|
|
28
|
+
"DatasetBuilderConfig",
|
|
29
|
+
"DreamCycle",
|
|
30
|
+
"DreamCycleConfig",
|
|
31
|
+
"DreamCycleReport",
|
|
32
|
+
"DreamEvent",
|
|
33
|
+
"EvaluationResult",
|
|
34
|
+
"Evaluator",
|
|
35
|
+
"EventSink",
|
|
36
|
+
"EventType",
|
|
37
|
+
"PhaseResult",
|
|
38
|
+
"PhaseStatus",
|
|
39
|
+
"PromotionResult",
|
|
40
|
+
"QualityGate",
|
|
41
|
+
"ShadowEvaluator",
|
|
42
|
+
"ShadowGate",
|
|
43
|
+
"ShadowResult",
|
|
44
|
+
"Trainer",
|
|
45
|
+
"TrainingCandidate",
|
|
46
|
+
"TrainingCandidateSource",
|
|
47
|
+
"TrainingResult",
|
|
48
|
+
]
|
dreamcycle/adapters.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Filesystem-backed adapter activation and rollback.
|
|
2
|
+
|
|
3
|
+
Adapted from the JintellarCore Dream Engine adapter promotion module.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import time
|
|
12
|
+
from collections.abc import Iterator, Mapping
|
|
13
|
+
from contextlib import contextmanager
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from dreamcycle.errors import ConfigurationError, PromotionError
|
|
17
|
+
from dreamcycle.types import PromotionResult
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AdapterManager:
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
*,
|
|
24
|
+
candidate_root: Path,
|
|
25
|
+
active_root: Path,
|
|
26
|
+
stale_lock_seconds: float = 1800,
|
|
27
|
+
) -> None:
|
|
28
|
+
self._candidate_root = candidate_root.resolve()
|
|
29
|
+
self._active_root = active_root.resolve()
|
|
30
|
+
self._versions_root = self._active_root / "versions"
|
|
31
|
+
self._stale_lock_seconds = stale_lock_seconds
|
|
32
|
+
if stale_lock_seconds <= 0:
|
|
33
|
+
raise ConfigurationError("stale_lock_seconds must be positive")
|
|
34
|
+
|
|
35
|
+
def promote(
|
|
36
|
+
self,
|
|
37
|
+
adapter_path: Path,
|
|
38
|
+
*,
|
|
39
|
+
session_id: str,
|
|
40
|
+
metrics: Mapping[str, object] | None = None,
|
|
41
|
+
) -> PromotionResult:
|
|
42
|
+
candidate = self._contained(adapter_path, self._candidate_root, "adapter_path")
|
|
43
|
+
if not candidate.is_dir():
|
|
44
|
+
raise PromotionError(f"adapter directory does not exist: {candidate}")
|
|
45
|
+
self._versions_root.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
with self._promotion_lock():
|
|
48
|
+
previous = self.active_adapter()
|
|
49
|
+
safe_session = "".join(ch for ch in session_id if ch.isalnum() or ch in "-_")
|
|
50
|
+
if not safe_session:
|
|
51
|
+
raise PromotionError("session_id cannot form an adapter version name")
|
|
52
|
+
version_name = f"{safe_session}-{candidate.name}"
|
|
53
|
+
destination = self._versions_root / version_name
|
|
54
|
+
temporary = self._versions_root / f".{version_name}.tmp-{os.getpid()}"
|
|
55
|
+
if destination.exists():
|
|
56
|
+
raise PromotionError(f"adapter version already exists: {destination}")
|
|
57
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
58
|
+
try:
|
|
59
|
+
shutil.copytree(candidate, temporary)
|
|
60
|
+
manifest = {
|
|
61
|
+
"session_id": session_id,
|
|
62
|
+
"source_path": str(candidate),
|
|
63
|
+
"metrics": dict(metrics or {}),
|
|
64
|
+
"promoted_at": time.time(),
|
|
65
|
+
}
|
|
66
|
+
(temporary / "dreamcycle-promotion.json").write_text(
|
|
67
|
+
json.dumps(manifest, indent=2, sort_keys=True) + "\n",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
os.replace(temporary, destination)
|
|
71
|
+
if previous is not None:
|
|
72
|
+
self._write_pointer("PREVIOUS", previous)
|
|
73
|
+
self._write_pointer("ACTIVE", destination)
|
|
74
|
+
except OSError as exc:
|
|
75
|
+
shutil.rmtree(temporary, ignore_errors=True)
|
|
76
|
+
raise PromotionError(f"failed to promote adapter: {exc}") from exc
|
|
77
|
+
|
|
78
|
+
return PromotionResult(
|
|
79
|
+
accepted=True,
|
|
80
|
+
reason="all quality gates passed",
|
|
81
|
+
promoted_path=destination,
|
|
82
|
+
previous_path=previous,
|
|
83
|
+
metrics=dict(metrics or {}),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def rollback(self) -> PromotionResult:
|
|
87
|
+
self._versions_root.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
with self._promotion_lock():
|
|
89
|
+
current = self.active_adapter()
|
|
90
|
+
previous = self._read_pointer("PREVIOUS")
|
|
91
|
+
if previous is None:
|
|
92
|
+
return PromotionResult(
|
|
93
|
+
accepted=False,
|
|
94
|
+
reason="no previous adapter is available",
|
|
95
|
+
promoted_path=current,
|
|
96
|
+
)
|
|
97
|
+
self._write_pointer("ACTIVE", previous)
|
|
98
|
+
(self._active_root / "PREVIOUS").unlink(missing_ok=True)
|
|
99
|
+
return PromotionResult(
|
|
100
|
+
accepted=True,
|
|
101
|
+
reason="previous adapter restored",
|
|
102
|
+
promoted_path=previous,
|
|
103
|
+
previous_path=current,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def active_adapter(self) -> Path | None:
|
|
107
|
+
return self._read_pointer("ACTIVE")
|
|
108
|
+
|
|
109
|
+
def _read_pointer(self, name: str) -> Path | None:
|
|
110
|
+
pointer = self._active_root / name
|
|
111
|
+
if not pointer.exists():
|
|
112
|
+
return None
|
|
113
|
+
value = pointer.read_text(encoding="utf-8").strip()
|
|
114
|
+
if not value:
|
|
115
|
+
raise PromotionError(f"{name} adapter pointer is empty")
|
|
116
|
+
candidate = Path(value)
|
|
117
|
+
resolved = self._contained(candidate, self._versions_root, f"{name} pointer")
|
|
118
|
+
if not resolved.is_dir():
|
|
119
|
+
raise PromotionError(f"{name} adapter directory does not exist: {resolved}")
|
|
120
|
+
return resolved
|
|
121
|
+
|
|
122
|
+
def _write_pointer(self, name: str, value: Path) -> None:
|
|
123
|
+
resolved = self._contained(value, self._versions_root, f"{name} pointer")
|
|
124
|
+
self._active_root.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
temporary = self._active_root / f".{name}.tmp-{os.getpid()}"
|
|
126
|
+
temporary.write_text(str(resolved) + "\n", encoding="utf-8")
|
|
127
|
+
os.replace(temporary, self._active_root / name)
|
|
128
|
+
|
|
129
|
+
@contextmanager
|
|
130
|
+
def _promotion_lock(self) -> Iterator[None]:
|
|
131
|
+
self._active_root.mkdir(parents=True, exist_ok=True)
|
|
132
|
+
lock_path = self._active_root / ".promotion.lock"
|
|
133
|
+
while True:
|
|
134
|
+
try:
|
|
135
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
136
|
+
os.write(descriptor, f"{os.getpid()} {time.time()}\n".encode())
|
|
137
|
+
os.close(descriptor)
|
|
138
|
+
break
|
|
139
|
+
except FileExistsError as exc:
|
|
140
|
+
try:
|
|
141
|
+
age = time.time() - lock_path.stat().st_mtime
|
|
142
|
+
except FileNotFoundError:
|
|
143
|
+
continue
|
|
144
|
+
if age <= self._stale_lock_seconds:
|
|
145
|
+
raise PromotionError("another adapter promotion is in progress") from exc
|
|
146
|
+
lock_path.unlink(missing_ok=True)
|
|
147
|
+
try:
|
|
148
|
+
yield
|
|
149
|
+
finally:
|
|
150
|
+
lock_path.unlink(missing_ok=True)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _contained(path: Path, root: Path, label: str) -> Path:
|
|
154
|
+
resolved = path.resolve()
|
|
155
|
+
try:
|
|
156
|
+
resolved.relative_to(root)
|
|
157
|
+
except ValueError as exc:
|
|
158
|
+
raise ConfigurationError(f"{label} must be inside {root}") from exc
|
|
159
|
+
return resolved
|
dreamcycle/cycle.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Standalone Dream Cycle orchestration.
|
|
2
|
+
|
|
3
|
+
Adapted from the dependency-injected JintellarCore Dream Engine cycle runner.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from dataclasses import dataclass, replace
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Protocol
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from dreamcycle.adapters import AdapterManager
|
|
18
|
+
from dreamcycle.dataset import DatasetBuilder
|
|
19
|
+
from dreamcycle.errors import InsufficientTrainingData
|
|
20
|
+
from dreamcycle.evaluation import QualityGate, ShadowGate
|
|
21
|
+
from dreamcycle.events import EventDispatcher, EventSink, EventType
|
|
22
|
+
from dreamcycle.types import (
|
|
23
|
+
CycleStatus,
|
|
24
|
+
DreamCycleReport,
|
|
25
|
+
EvaluationResult,
|
|
26
|
+
PhaseResult,
|
|
27
|
+
PhaseStatus,
|
|
28
|
+
ShadowResult,
|
|
29
|
+
TrainingResult,
|
|
30
|
+
utc_now,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Trainer(Protocol):
|
|
35
|
+
async def train(
|
|
36
|
+
self, dataset_path: Path, eval_path: Path, output_path: Path
|
|
37
|
+
) -> TrainingResult: ...
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Evaluator(Protocol):
|
|
41
|
+
async def evaluate(self, adapter_path: Path, eval_path: Path) -> EvaluationResult: ...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ShadowEvaluator(Protocol):
|
|
45
|
+
async def evaluate(self, adapter_path: Path) -> ShadowResult: ...
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class CycleRecorder(Protocol):
|
|
49
|
+
def record_cycle(self, report: DreamCycleReport) -> Any: ...
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class DreamCycleConfig:
|
|
54
|
+
candidate_adapter_dir: Path
|
|
55
|
+
quality_gate: QualityGate = QualityGate()
|
|
56
|
+
shadow_gate: ShadowGate | None = None
|
|
57
|
+
require_shadow: bool = False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DreamCycle:
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
config: DreamCycleConfig,
|
|
65
|
+
dataset_builder: DatasetBuilder,
|
|
66
|
+
trainer: Trainer,
|
|
67
|
+
evaluator: Evaluator,
|
|
68
|
+
adapter_manager: AdapterManager,
|
|
69
|
+
shadow_evaluator: ShadowEvaluator | None = None,
|
|
70
|
+
recorder: CycleRecorder | None = None,
|
|
71
|
+
event_sinks: tuple[EventSink, ...] = (),
|
|
72
|
+
) -> None:
|
|
73
|
+
if (config.require_shadow or config.shadow_gate is not None) and shadow_evaluator is None:
|
|
74
|
+
raise ValueError("require_shadow=True requires a shadow_evaluator")
|
|
75
|
+
self._config = config
|
|
76
|
+
self._dataset_builder = dataset_builder
|
|
77
|
+
self._trainer = trainer
|
|
78
|
+
self._evaluator = evaluator
|
|
79
|
+
self._adapter_manager = adapter_manager
|
|
80
|
+
self._shadow_evaluator = shadow_evaluator
|
|
81
|
+
self._recorder = recorder
|
|
82
|
+
self._events = EventDispatcher(event_sinks)
|
|
83
|
+
|
|
84
|
+
async def run(self) -> DreamCycleReport:
|
|
85
|
+
session_id = f"dc-{utc_now().strftime('%Y%m%dT%H%M%S')}-{uuid4().hex[:8]}"
|
|
86
|
+
started_at = utc_now()
|
|
87
|
+
phases: list[PhaseResult] = []
|
|
88
|
+
event_errors = await self._events.emit(EventType.CYCLE_STARTED, session_id)
|
|
89
|
+
dataset = None
|
|
90
|
+
training = None
|
|
91
|
+
evaluation = None
|
|
92
|
+
shadow = None
|
|
93
|
+
promotion = None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
dataset = await self._phase(
|
|
97
|
+
phases,
|
|
98
|
+
session_id,
|
|
99
|
+
"dataset_build",
|
|
100
|
+
lambda: asyncio.to_thread(self._dataset_builder.build, session_id),
|
|
101
|
+
event_errors,
|
|
102
|
+
)
|
|
103
|
+
candidate_path = self._config.candidate_adapter_dir.resolve() / session_id
|
|
104
|
+
candidate_path.mkdir(parents=True, exist_ok=False)
|
|
105
|
+
training = await self._phase(
|
|
106
|
+
phases,
|
|
107
|
+
session_id,
|
|
108
|
+
"adapter_training",
|
|
109
|
+
lambda: self._trainer.train(dataset.train_path, dataset.eval_path, candidate_path),
|
|
110
|
+
event_errors,
|
|
111
|
+
)
|
|
112
|
+
evaluation = await self._phase(
|
|
113
|
+
phases,
|
|
114
|
+
session_id,
|
|
115
|
+
"benchmark",
|
|
116
|
+
lambda: self._evaluator.evaluate(training.adapter_path, dataset.eval_path),
|
|
117
|
+
event_errors,
|
|
118
|
+
)
|
|
119
|
+
evaluation = self._config.quality_gate.apply(evaluation)
|
|
120
|
+
event_errors.extend(
|
|
121
|
+
await self._events.emit(
|
|
122
|
+
EventType.QUALITY_GATE,
|
|
123
|
+
session_id,
|
|
124
|
+
{
|
|
125
|
+
"passed": evaluation.passed,
|
|
126
|
+
"score": evaluation.score,
|
|
127
|
+
"baseline_score": evaluation.baseline_score,
|
|
128
|
+
"relative_performance": evaluation.relative_performance,
|
|
129
|
+
},
|
|
130
|
+
)
|
|
131
|
+
)
|
|
132
|
+
if not evaluation.passed:
|
|
133
|
+
phases.append(self._rejected_phase("quality_gate", "benchmark gate failed"))
|
|
134
|
+
report = self._report(
|
|
135
|
+
session_id=session_id,
|
|
136
|
+
status=CycleStatus.REJECTED,
|
|
137
|
+
started_at=started_at,
|
|
138
|
+
phases=phases,
|
|
139
|
+
dataset=dataset,
|
|
140
|
+
training=training,
|
|
141
|
+
evaluation=evaluation,
|
|
142
|
+
reason="benchmark gate failed",
|
|
143
|
+
event_errors=event_errors,
|
|
144
|
+
)
|
|
145
|
+
return await self._finish(report)
|
|
146
|
+
|
|
147
|
+
if self._shadow_evaluator is not None:
|
|
148
|
+
shadow = await self._phase(
|
|
149
|
+
phases,
|
|
150
|
+
session_id,
|
|
151
|
+
"shadow_mode",
|
|
152
|
+
lambda: self._shadow_evaluator.evaluate(training.adapter_path),
|
|
153
|
+
event_errors,
|
|
154
|
+
)
|
|
155
|
+
if self._config.shadow_gate is not None:
|
|
156
|
+
shadow = self._config.shadow_gate.apply(shadow)
|
|
157
|
+
event_errors.extend(
|
|
158
|
+
await self._events.emit(
|
|
159
|
+
EventType.SHADOW_GATE,
|
|
160
|
+
session_id,
|
|
161
|
+
{"acceptable": shadow.acceptable, "error_rate": shadow.error_rate},
|
|
162
|
+
)
|
|
163
|
+
)
|
|
164
|
+
if not shadow.acceptable:
|
|
165
|
+
phases.append(self._rejected_phase("shadow_gate", "shadow gate failed"))
|
|
166
|
+
report = self._report(
|
|
167
|
+
session_id=session_id,
|
|
168
|
+
status=CycleStatus.REJECTED,
|
|
169
|
+
started_at=started_at,
|
|
170
|
+
phases=phases,
|
|
171
|
+
dataset=dataset,
|
|
172
|
+
training=training,
|
|
173
|
+
evaluation=evaluation,
|
|
174
|
+
shadow=shadow,
|
|
175
|
+
reason="shadow gate failed",
|
|
176
|
+
event_errors=event_errors,
|
|
177
|
+
)
|
|
178
|
+
return await self._finish(report)
|
|
179
|
+
|
|
180
|
+
promotion = await self._phase(
|
|
181
|
+
phases,
|
|
182
|
+
session_id,
|
|
183
|
+
"promotion",
|
|
184
|
+
lambda: asyncio.to_thread(
|
|
185
|
+
self._adapter_manager.promote,
|
|
186
|
+
training.adapter_path,
|
|
187
|
+
session_id=session_id,
|
|
188
|
+
metrics={
|
|
189
|
+
"score": evaluation.score,
|
|
190
|
+
"baseline_score": evaluation.baseline_score,
|
|
191
|
+
"relative_performance": evaluation.relative_performance,
|
|
192
|
+
},
|
|
193
|
+
),
|
|
194
|
+
event_errors,
|
|
195
|
+
)
|
|
196
|
+
event_errors.extend(
|
|
197
|
+
await self._events.emit(
|
|
198
|
+
EventType.ADAPTER_PROMOTED,
|
|
199
|
+
session_id,
|
|
200
|
+
{"adapter_path": str(promotion.promoted_path)},
|
|
201
|
+
)
|
|
202
|
+
)
|
|
203
|
+
report = self._report(
|
|
204
|
+
session_id=session_id,
|
|
205
|
+
status=CycleStatus.SUCCESS,
|
|
206
|
+
started_at=started_at,
|
|
207
|
+
phases=phases,
|
|
208
|
+
dataset=dataset,
|
|
209
|
+
training=training,
|
|
210
|
+
evaluation=evaluation,
|
|
211
|
+
shadow=shadow,
|
|
212
|
+
promotion=promotion,
|
|
213
|
+
event_errors=event_errors,
|
|
214
|
+
)
|
|
215
|
+
return await self._finish(report)
|
|
216
|
+
except InsufficientTrainingData as exc:
|
|
217
|
+
if phases and phases[-1].name == "dataset_build":
|
|
218
|
+
phases[-1] = replace(phases[-1], status=PhaseStatus.SKIPPED)
|
|
219
|
+
else:
|
|
220
|
+
phases.append(self._error_phase("dataset_build", PhaseStatus.SKIPPED, exc))
|
|
221
|
+
return await self._finish(
|
|
222
|
+
self._report(
|
|
223
|
+
session_id=session_id,
|
|
224
|
+
status=CycleStatus.SKIPPED,
|
|
225
|
+
started_at=started_at,
|
|
226
|
+
phases=phases,
|
|
227
|
+
reason=str(exc),
|
|
228
|
+
event_errors=event_errors,
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
if not phases or phases[-1].status is not PhaseStatus.ERROR:
|
|
233
|
+
phases.append(self._error_phase("cycle", PhaseStatus.ERROR, exc))
|
|
234
|
+
return await self._finish(
|
|
235
|
+
self._report(
|
|
236
|
+
session_id=session_id,
|
|
237
|
+
status=CycleStatus.ERROR,
|
|
238
|
+
started_at=started_at,
|
|
239
|
+
phases=phases,
|
|
240
|
+
dataset=dataset,
|
|
241
|
+
training=training,
|
|
242
|
+
evaluation=evaluation,
|
|
243
|
+
shadow=shadow,
|
|
244
|
+
promotion=promotion,
|
|
245
|
+
reason=f"{type(exc).__name__}: {exc}",
|
|
246
|
+
event_errors=event_errors,
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
async def _phase(
|
|
251
|
+
self,
|
|
252
|
+
phases: list[PhaseResult],
|
|
253
|
+
session_id: str,
|
|
254
|
+
name: str,
|
|
255
|
+
operation: Callable[[], Awaitable[Any]],
|
|
256
|
+
event_errors: list[str],
|
|
257
|
+
) -> Any:
|
|
258
|
+
started = utc_now()
|
|
259
|
+
event_errors.extend(
|
|
260
|
+
await self._events.emit(EventType.PHASE_STARTED, session_id, {"phase": name})
|
|
261
|
+
)
|
|
262
|
+
try:
|
|
263
|
+
result = await operation()
|
|
264
|
+
except Exception as exc:
|
|
265
|
+
phases.append(
|
|
266
|
+
PhaseResult(
|
|
267
|
+
name=name,
|
|
268
|
+
status=PhaseStatus.ERROR,
|
|
269
|
+
started_at=started,
|
|
270
|
+
completed_at=utc_now(),
|
|
271
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
raise
|
|
275
|
+
phases.append(
|
|
276
|
+
PhaseResult(
|
|
277
|
+
name=name,
|
|
278
|
+
status=PhaseStatus.SUCCESS,
|
|
279
|
+
started_at=started,
|
|
280
|
+
completed_at=utc_now(),
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
event_errors.extend(
|
|
284
|
+
await self._events.emit(EventType.PHASE_COMPLETED, session_id, {"phase": name})
|
|
285
|
+
)
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
async def _finish(self, report: DreamCycleReport) -> DreamCycleReport:
|
|
289
|
+
event_errors = list(report.event_errors)
|
|
290
|
+
event_errors.extend(
|
|
291
|
+
await self._events.emit(
|
|
292
|
+
EventType.CYCLE_COMPLETED,
|
|
293
|
+
report.session_id,
|
|
294
|
+
{"status": report.status.value, "reason": report.reason},
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
report = self._replace_event_errors(report, tuple(event_errors))
|
|
298
|
+
if self._recorder is not None:
|
|
299
|
+
try:
|
|
300
|
+
result = self._recorder.record_cycle(report)
|
|
301
|
+
if inspect.isawaitable(result):
|
|
302
|
+
await result
|
|
303
|
+
except Exception as exc:
|
|
304
|
+
event_errors.append(f"cycle recorder: {type(exc).__name__}: {exc}")
|
|
305
|
+
report = self._replace_event_errors(report, tuple(event_errors))
|
|
306
|
+
return report
|
|
307
|
+
|
|
308
|
+
@staticmethod
|
|
309
|
+
def _report(
|
|
310
|
+
*,
|
|
311
|
+
session_id: str,
|
|
312
|
+
status: CycleStatus,
|
|
313
|
+
started_at: datetime,
|
|
314
|
+
phases: list[PhaseResult],
|
|
315
|
+
dataset: Any = None,
|
|
316
|
+
training: Any = None,
|
|
317
|
+
evaluation: Any = None,
|
|
318
|
+
shadow: Any = None,
|
|
319
|
+
promotion: Any = None,
|
|
320
|
+
reason: str = "",
|
|
321
|
+
event_errors: list[str],
|
|
322
|
+
) -> DreamCycleReport:
|
|
323
|
+
return DreamCycleReport(
|
|
324
|
+
session_id=session_id,
|
|
325
|
+
status=status,
|
|
326
|
+
started_at=started_at,
|
|
327
|
+
completed_at=utc_now(),
|
|
328
|
+
phases=tuple(phases),
|
|
329
|
+
dataset=dataset,
|
|
330
|
+
training=training,
|
|
331
|
+
evaluation=evaluation,
|
|
332
|
+
shadow=shadow,
|
|
333
|
+
promotion=promotion,
|
|
334
|
+
reason=reason,
|
|
335
|
+
event_errors=tuple(event_errors),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
@staticmethod
|
|
339
|
+
def _replace_event_errors(
|
|
340
|
+
report: DreamCycleReport, event_errors: tuple[str, ...]
|
|
341
|
+
) -> DreamCycleReport:
|
|
342
|
+
return DreamCycleReport(
|
|
343
|
+
session_id=report.session_id,
|
|
344
|
+
status=report.status,
|
|
345
|
+
started_at=report.started_at,
|
|
346
|
+
completed_at=report.completed_at,
|
|
347
|
+
phases=report.phases,
|
|
348
|
+
dataset=report.dataset,
|
|
349
|
+
training=report.training,
|
|
350
|
+
evaluation=report.evaluation,
|
|
351
|
+
shadow=report.shadow,
|
|
352
|
+
promotion=report.promotion,
|
|
353
|
+
reason=report.reason,
|
|
354
|
+
event_errors=event_errors,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
@staticmethod
|
|
358
|
+
def _rejected_phase(name: str, reason: str) -> PhaseResult:
|
|
359
|
+
now = utc_now()
|
|
360
|
+
return PhaseResult(
|
|
361
|
+
name=name,
|
|
362
|
+
status=PhaseStatus.REJECTED,
|
|
363
|
+
started_at=now,
|
|
364
|
+
completed_at=now,
|
|
365
|
+
error=reason,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
@staticmethod
|
|
369
|
+
def _error_phase(name: str, status: PhaseStatus, exc: Exception) -> PhaseResult:
|
|
370
|
+
now = utc_now()
|
|
371
|
+
return PhaseResult(
|
|
372
|
+
name=name,
|
|
373
|
+
status=status,
|
|
374
|
+
started_at=now,
|
|
375
|
+
completed_at=now,
|
|
376
|
+
error=f"{type(exc).__name__}: {exc}",
|
|
377
|
+
)
|