Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 114 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,14 +101,15 @@ model, stopping conditions, and why the loop is a first-class object).
| **`seed(initial) -> state`** *(optional)* | A one-time preprocess of the starting candidate. |
| **`Engine.run(initial) -> Result`** | Runs the loop to completion. |
| **`Engine.cycle(initial)`** | Generator form: yields each `Iteration` as it happens, for streaming/observability. |
| **`Result`** | `status` (`solved` / `plateau` / `exhausted` / `error`), final `state`, `history`, `best`, and `error`. |
| **`Result`** | `status` (`solved` / `plateau` / `exhausted` / `error` / `timeout` / `aborted`), final `state`, `history`, `best`, and `error`. `.metrics()` gives score aggregates; `.to_dict()` a JSON snapshot. |

### Stopping conditions

- **Solved** — a verdict comes back `ok=True`.
- **Solved** — a verdict comes back `ok=True` (or a `TargetScore` / `Predicate` stop condition fires).
- **Plateau** — with `patience=N` set, the score fails to improve by at least `min_delta` for `N` consecutive checks. (Higher-is-better; negate a cost to minimize it.)
- **Exhausted** — `max_iterations` reached without solving.
- **Error** — a `check`/`revise` raised; the run aborts cleanly with the trace so far.
- **Error** — a `check`/`revise` raised; the run aborts cleanly with the trace so far. A `RetryPolicy` can absorb transient raises first.
- **Timeout / Aborted** — a pluggable `StopCondition` (wall-clock deadline, divergence guard, custom rule) halted the run. See [New in 0.2](#new-in-02--the-loop-fully-equipped).

### Observability

Expand All @@ -118,14 +119,113 @@ Pass `on_iteration=callback` to receive every `Iteration` as it's recorded — w
Engine(check, revise, on_iteration=lambda it: print(it.index, it.score))
```

## New in 0.2 — the loop, fully equipped

The core loop is unchanged and every existing caller keeps working. 0.2 adds
opt-in, composable pieces around it — each is pure standard library and slots in
without touching the engine's contract.

### Pluggable stop conditions

Stopping is now composable and domain-driven. A `StopCondition` is any
`stop(iteration, history) -> Optional[Status]`; the ready-made ones cover the
common asks and combine with `|` (first to fire wins). They are consulted *after*
the built-in `ok` check, so `SOLVED` always takes precedence.

```python
from cyclework import Engine, MaxSeconds, TargetScore, NoImprovement

stop = MaxSeconds(30) | TargetScore(0.99) | NoImprovement(patience=5)
Engine(check, revise, stop=stop) # halts on whichever trips first
```

Also included: `ScoreThreshold` (divergence/bail-out guard) and `Predicate`
(stop when a rule over the candidate is true).

### Retry with backoff for flaky stages

Wrap a `check`/`revise` that calls something transient (a model endpoint, a
compiler, a network probe) so one hiccup doesn't abort the run.

```python
from cyclework import Engine, RetryPolicy

Engine(check, revise, retry=RetryPolicy(max_attempts=3, base_delay=0.1, jitter=0.05))
```

### Observers: metrics, JSONL audit trace, progress

`on_iteration` grows into a small ecosystem. `multi(...)` fans one run out to
several sinks; `JsonlWriter` appends one durable JSON line per iteration;
`MetricsCollector` exposes live running stats; `ProgressPrinter` prints a tidy
line per step.

```python
from cyclework import Engine, multi, JsonlWriter, MetricsCollector

metrics = MetricsCollector()
with JsonlWriter.to_path("run.jsonl") as trace:
Engine(check, revise, on_iteration=multi(trace, metrics)).run(seed)
print(metrics.snapshot())
```

### Keep-best-of-N and random restarts

Escape poor local optima by running the loop several times and keeping the best
outcome — serially or concurrently on a thread pool for I/O-bound stages.

```python
from cyclework import best_of, parallel_best_of

bundle = best_of(lambda: Engine(check, revise), seeds=[0.0, 2.0, 5.0])
bundle = parallel_best_of(lambda: Engine(check, revise), seeds=range(8))
print(bundle.best.summary(), bundle.best_index)
```

### Checkpoint / resume

Persist progress atomically as an observer so a killed process can pick up where
it left off instead of from the seed.

```python
from cyclework import Engine, Checkpoint

cp = Checkpoint("run.ckpt", every=5)
start = Checkpoint.resume_state("run.ckpt", default=seed) # resume or seed
Engine(check, revise, on_iteration=cp).run(start)
```

### Strategy library

Ready-made, domain-independent `revise` factories so you don't have to hand-roll
search: `hill_climb`, `simulated_anneal`, `grid_step`, and `generic_hill_climb`
(neighbor search over any state type).

```python
from cyclework import Engine, strategies

Engine(check, strategies.simulated_anneal(step=0.3, seed=0)).run(0.0)
```

### Command-line front-end

```bash
python -m cyclework sqrt 2 # Newton's-method sqrt as a loop
python -m cyclework optimize --strategy anneal --restarts 5
python -m cyclework demo --jsonl trace.jsonl # writes a JSONL audit trace
```

Global options (`--max-iter`, `--target`, `--max-seconds`, `--jsonl`, `--quiet`)
work before or after the subcommand.

## Demos

Five runnable, narrated scenarios in [`demos/`](demos/), each for a different
Eleven runnable, narrated scenarios in [`demos/`](demos/), each for a different
audience — all driving the real API, no network, exit 0. Full descriptions in
[`docs/DEMOS.md`](docs/DEMOS.md).

```bash
python demos/run_all.py # all five, end to end
python demos/run_all.py # all of them, end to end
python demos/02_numeric_solvers.py # or just one
```

Expand All @@ -136,18 +236,25 @@ python demos/02_numeric_solvers.py # or just one
| 3 | [`03_plateau_and_budget.py`](demos/03_plateau_and_budget.py) | Production loop owners | Honest stopping: SOLVED / PLATEAU / EXHAUSTED / ERROR, always keeping the best candidate. |
| 4 | [`04_feedback_refiner.py`](demos/04_feedback_refiner.py) | Pipeline engineers | Feedback-driven revision: the verdict names the fix, revise applies exactly it. |
| 5 | [`05_streaming_observability.py`](demos/05_streaming_observability.py) | Observability engineers | Watching a loop live via `on_iteration` and the `Engine.cycle()` generator. |
| 6 | [`06_wallclock_deadline.py`](demos/06_wallclock_deadline.py) | Production loop owners | Composable stop conditions: a wall-clock deadline OR'd with a target score. |
| 7 | [`07_retry_backoff.py`](demos/07_retry_backoff.py) | Reliability engineers | A `RetryPolicy` absorbing a flaky stage's transient failures with backoff. |
| 8 | [`08_jsonl_audit_trace.py`](demos/08_jsonl_audit_trace.py) | Observability / compliance | A durable JSONL audit trace + live `MetricsCollector`, fanned out via `multi`. |
| 9 | [`09_strategy_library.py`](demos/09_strategy_library.py) | Optimization users | Swapping in `hill_climb` / `simulated_anneal` / `grid_step` revise strategies. |
| 10 | [`10_best_of_restarts.py`](demos/10_best_of_restarts.py) | Optimization users | Random restarts with `best_of` escaping a local optimum. |
| 11 | [`11_checkpoint_resume.py`](demos/11_checkpoint_resume.py) | Long-run / fault-tolerance | Checkpointing progress and resuming after a simulated crash. |

On a cp1252 (Windows) console, prefix with `PYTHONUTF8=1`.

## Testing

```bash
pip install -e ".[dev]"
pytest -q # 24 tests (engine, examples, and demo smoke tests)
pytest -q # 107 tests (engine, stop conditions, retry, observers,
# search/restarts, checkpoint, strategies, CLI, and demos)
```

## License

COCL (Cognis Open Collaboration License). © Cognis Digital.

> Status: v0.1 — runnable and tested. Roadmap: async stages, parallel candidate fan-out (keep-best-of-N per cycle), and built-in budget accounting (token/time cost per iteration).
> Status: v0.2 — runnable and tested (107 tests, zero dependencies). 0.2 added pluggable/composable stop conditions, retry+backoff, an observer ecosystem (JSONL audit trace, live metrics), keep-best-of-N / parallel restarts, checkpoint-resume, a strategy library, and a CLI — all additive and backward-compatible. Roadmap: async stages and built-in budget accounting (token/time cost per iteration).
40 changes: 38 additions & 2 deletions cyclework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,42 @@
from .verdict import Verdict
from .trace import Iteration, Result, Status
from .engine import Engine
from .stopping import (
StopCondition,
MaxSeconds,
TargetScore,
NoImprovement,
ScoreThreshold,
Predicate,
as_stop_condition,
)
from .policy import RetryPolicy, NO_RETRY
from .observers import (
multi,
JsonlWriter,
MetricsCollector,
ProgressPrinter,
)
from .checkpoint import Checkpoint, CheckpointData
from .search import best_of, parallel_best_of, BestOf
from . import strategies

__version__ = "0.1.0"
__all__ = ["Engine", "Verdict", "Result", "Iteration", "Status", "__version__"]
__version__ = "0.2.0"
__all__ = [
# core
"Engine", "Verdict", "Result", "Iteration", "Status",
# stop conditions
"StopCondition", "MaxSeconds", "TargetScore", "NoImprovement",
"ScoreThreshold", "Predicate", "as_stop_condition",
# retry policy
"RetryPolicy", "NO_RETRY",
# observers
"multi", "JsonlWriter", "MetricsCollector", "ProgressPrinter",
# checkpointing
"Checkpoint", "CheckpointData",
# restart / parallel search
"best_of", "parallel_best_of", "BestOf",
# strategy library
"strategies",
"__version__",
]
5 changes: 5 additions & 0 deletions cyclework/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"""Enable ``python -m cyclework``."""
from .cli import main

if __name__ == "__main__":
raise SystemExit(main())
119 changes: 119 additions & 0 deletions cyclework/checkpoint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Checkpoint / resume.

A long or fragile refinement run should not have to start over if the process
dies. A :class:`Checkpoint` periodically persists the *current candidate state*
(and the best seen) to disk, so a later invocation can resume from where it left
off rather than from the original seed.

Design: checkpointing is an *observer* (``callable(Iteration)``) — it slots into
``Engine(on_iteration=...)`` and needs no engine changes. Because a candidate
can be anything, you supply ``dump``/``load`` codecs (default: JSON for
JSON-native states). To resume, load the checkpoint and pass its ``state`` as
the engine's ``initial``.
"""

from __future__ import annotations

import json
import os
import tempfile
from dataclasses import dataclass
from typing import Any, Callable, Optional

from .trace import Iteration


@dataclass
class CheckpointData:
index: int
state: Any
best_score: Optional[float]
best_state: Any


class Checkpoint:
"""Persist run progress every ``every`` iterations (and on the final/ok one).

Parameters
----------
path : where to write the checkpoint (atomically, via a temp file + rename).
every : write a checkpoint every N iterations (default 1).
dump/load : codecs mapping a payload dict <-> bytes. Defaults use JSON, which
works when the state is JSON-native; pass ``pickle`` codecs for arbitrary
objects.
"""

def __init__(
self,
path: str,
every: int = 1,
dump: Optional[Callable[[dict], bytes]] = None,
load: Optional[Callable[[bytes], dict]] = None,
):
if every < 1:
raise ValueError("every must be >= 1")
self.path = path
self.every = every
self._dump = dump or _json_dump
self._load = load or _json_load
self._best_score: Optional[float] = None
self._best_state: Any = None
self.writes = 0

def __call__(self, it: Iteration) -> None:
s = it.score
if s is not None and (self._best_score is None or s > self._best_score):
self._best_score = s
self._best_state = it.state
if it.verdict.ok or it.index % self.every == 0:
self._write(it)

def _write(self, it: Iteration) -> None:
payload = {
"index": it.index,
"state": it.state,
"best_score": self._best_score,
"best_state": self._best_state,
}
data = self._dump(payload)
# atomic replace so a crash mid-write never corrupts the checkpoint
d = os.path.dirname(os.path.abspath(self.path)) or "."
fd, tmp = tempfile.mkstemp(dir=d, suffix=".tmp")
try:
with os.fdopen(fd, "wb") as f:
f.write(data)
os.replace(tmp, self.path)
finally:
if os.path.exists(tmp):
os.remove(tmp)
self.writes += 1

def load(self) -> Optional[CheckpointData]:
"""Read the checkpoint back, or ``None`` if none exists yet."""
if not os.path.exists(self.path):
return None
with open(self.path, "rb") as f:
payload = self._load(f.read())
return CheckpointData(
index=payload["index"],
state=payload["state"],
best_score=payload.get("best_score"),
best_state=payload.get("best_state"),
)

@staticmethod
def resume_state(path: str, default: Any,
load: Optional[Callable[[bytes], dict]] = None) -> Any:
"""Convenience: return the checkpointed candidate state at ``path`` to
resume from, or ``default`` (the original seed) if there is none."""
cp = Checkpoint(path, load=load)
data = cp.load()
return data.state if data is not None else default


def _json_dump(payload: dict) -> bytes:
return json.dumps(payload, ensure_ascii=False).encode("utf-8")


def _json_load(data: bytes) -> dict:
return json.loads(data.decode("utf-8"))
Loading
Loading