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