chrono-daemon 0.1.0__tar.gz

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 (91) hide show
  1. chrono_daemon-0.1.0/.github/dependabot.yml +7 -0
  2. chrono_daemon-0.1.0/.github/workflows/ci.yml +30 -0
  3. chrono_daemon-0.1.0/.github/workflows/publish.yml +39 -0
  4. chrono_daemon-0.1.0/.gitignore +9 -0
  5. chrono_daemon-0.1.0/.python-version +1 -0
  6. chrono_daemon-0.1.0/CLAUDE.md +63 -0
  7. chrono_daemon-0.1.0/LICENSE +21 -0
  8. chrono_daemon-0.1.0/Makefile +23 -0
  9. chrono_daemon-0.1.0/PKG-INFO +126 -0
  10. chrono_daemon-0.1.0/README.md +111 -0
  11. chrono_daemon-0.1.0/docs/README.md +30 -0
  12. chrono_daemon-0.1.0/docs/adr/0001-channel-is-the-sole-comm-primitive.md +74 -0
  13. chrono_daemon-0.1.0/docs/adr/0002-wall-and-sim-clocks-as-pluggable-protocol.md +74 -0
  14. chrono_daemon-0.1.0/docs/adr/0003-daemon-dual-api-class-and-decorator.md +61 -0
  15. chrono_daemon-0.1.0/docs/adr/0004-on-error-shutdown-by-default.md +70 -0
  16. chrono_daemon-0.1.0/docs/adr/0005-no-lifecycle-states-beyond-start-run-stop.md +58 -0
  17. chrono_daemon-0.1.0/docs/adr/0006-in-process-v0-transport-adapter-slot.md +70 -0
  18. chrono_daemon-0.1.0/docs/adr/0007-anyio-only-runtime-dependency.md +58 -0
  19. chrono_daemon-0.1.0/docs/adr/0008-sim-aware-logging-and-supervisor-diagnostics.md +81 -0
  20. chrono_daemon-0.1.0/docs/adr/0009-cooperative-stop-signaling.md +89 -0
  21. chrono_daemon-0.1.0/docs/adr/0010-channel-endpoints-are-single-owner.md +63 -0
  22. chrono_daemon-0.1.0/docs/adr/0011-zmq-transport-extra.md +64 -0
  23. chrono_daemon-0.1.0/docs/adr/README.md +32 -0
  24. chrono_daemon-0.1.0/docs/archive/proposal/01_PROPOSAL.md +131 -0
  25. chrono_daemon-0.1.0/docs/archive/proposal/02_COMPETITIVE_LANDSCAPE.md +159 -0
  26. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/apollo_cyberrt.md +75 -0
  27. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/dora_rs.md +87 -0
  28. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/drake.md +80 -0
  29. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/erdos.md +80 -0
  30. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/holoscan.md +90 -0
  31. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/horus.md +86 -0
  32. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/orocos_rtt.md +77 -0
  33. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/redisros.md +74 -0
  34. chrono_daemon-0.1.0/docs/archive/proposal/03_REFERENCE_SURVEY/simple_env.md +97 -0
  35. chrono_daemon-0.1.0/docs/concepts.md +151 -0
  36. chrono_daemon-0.1.0/docs/recipes.md +75 -0
  37. chrono_daemon-0.1.0/examples/README.md +66 -0
  38. chrono_daemon-0.1.0/examples/system_stack_mock.py +208 -0
  39. chrono_daemon-0.1.0/examples/system_stack_multi_session.py +134 -0
  40. chrono_daemon-0.1.0/pyproject.toml +57 -0
  41. chrono_daemon-0.1.0/src/chrono_daemon/__init__.py +53 -0
  42. chrono_daemon-0.1.0/src/chrono_daemon/_logging.py +32 -0
  43. chrono_daemon-0.1.0/src/chrono_daemon/_types.py +38 -0
  44. chrono_daemon-0.1.0/src/chrono_daemon/channel.py +212 -0
  45. chrono_daemon-0.1.0/src/chrono_daemon/clock.py +190 -0
  46. chrono_daemon-0.1.0/src/chrono_daemon/context.py +38 -0
  47. chrono_daemon-0.1.0/src/chrono_daemon/daemon.py +80 -0
  48. chrono_daemon-0.1.0/src/chrono_daemon/recipes/__init__.py +35 -0
  49. chrono_daemon-0.1.0/src/chrono_daemon/recipes/_flow.py +44 -0
  50. chrono_daemon-0.1.0/src/chrono_daemon/recipes/batcher.py +5 -0
  51. chrono_daemon-0.1.0/src/chrono_daemon/recipes/cooperative_every.py +5 -0
  52. chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/__init__.py +7 -0
  53. chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/batcher.py +166 -0
  54. chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/cooperative_every.py +17 -0
  55. chrono_daemon-0.1.0/src/chrono_daemon/recipes/coordination/select.py +48 -0
  56. chrono_daemon-0.1.0/src/chrono_daemon/recipes/fanout.py +5 -0
  57. chrono_daemon-0.1.0/src/chrono_daemon/recipes/hosting/__init__.py +6 -0
  58. chrono_daemon-0.1.0/src/chrono_daemon/recipes/hosting/supervisor_host.py +107 -0
  59. chrono_daemon-0.1.0/src/chrono_daemon/recipes/hosting/sync_bridge.py +92 -0
  60. chrono_daemon-0.1.0/src/chrono_daemon/recipes/latest.py +5 -0
  61. chrono_daemon-0.1.0/src/chrono_daemon/recipes/load_balance.py +5 -0
  62. chrono_daemon-0.1.0/src/chrono_daemon/recipes/lossy.py +5 -0
  63. chrono_daemon-0.1.0/src/chrono_daemon/recipes/merge.py +5 -0
  64. chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/__init__.py +8 -0
  65. chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/fanout.py +29 -0
  66. chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/load_balance.py +49 -0
  67. chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/merge.py +53 -0
  68. chrono_daemon-0.1.0/src/chrono_daemon/recipes/routing/worker_pool.py +89 -0
  69. chrono_daemon-0.1.0/src/chrono_daemon/recipes/select.py +5 -0
  70. chrono_daemon-0.1.0/src/chrono_daemon/recipes/state/__init__.py +6 -0
  71. chrono_daemon-0.1.0/src/chrono_daemon/recipes/state/latest.py +24 -0
  72. chrono_daemon-0.1.0/src/chrono_daemon/recipes/state/lossy.py +102 -0
  73. chrono_daemon-0.1.0/src/chrono_daemon/recipes/supervisor_host.py +5 -0
  74. chrono_daemon-0.1.0/src/chrono_daemon/recipes/sync_bridge.py +5 -0
  75. chrono_daemon-0.1.0/src/chrono_daemon/recipes/worker_pool.py +5 -0
  76. chrono_daemon-0.1.0/src/chrono_daemon/supervisor.py +376 -0
  77. chrono_daemon-0.1.0/src/chrono_daemon/transports/__init__.py +5 -0
  78. chrono_daemon-0.1.0/src/chrono_daemon/transports/zmq.py +345 -0
  79. chrono_daemon-0.1.0/tests/__init__.py +0 -0
  80. chrono_daemon-0.1.0/tests/conftest.py +11 -0
  81. chrono_daemon-0.1.0/tests/test_channel.py +184 -0
  82. chrono_daemon-0.1.0/tests/test_clock.py +272 -0
  83. chrono_daemon-0.1.0/tests/test_daemon.py +69 -0
  84. chrono_daemon-0.1.0/tests/test_examples.py +64 -0
  85. chrono_daemon-0.1.0/tests/test_integration.py +78 -0
  86. chrono_daemon-0.1.0/tests/test_logging.py +49 -0
  87. chrono_daemon-0.1.0/tests/test_recipes.py +611 -0
  88. chrono_daemon-0.1.0/tests/test_recipes_lossy.py +128 -0
  89. chrono_daemon-0.1.0/tests/test_supervisor.py +614 -0
  90. chrono_daemon-0.1.0/tests/test_zmq_transport.py +243 -0
  91. chrono_daemon-0.1.0/uv.lock +435 -0
@@ -0,0 +1,7 @@
1
+ version: 2
2
+
3
+ updates:
4
+ - package-ecosystem: "github-actions"
5
+ directory: "/"
6
+ schedule:
7
+ interval: "weekly"
@@ -0,0 +1,30 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - main
8
+
9
+ jobs:
10
+ check:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v7
14
+
15
+ - name: Set up uv
16
+ uses: astral-sh/setup-uv@v7
17
+ with:
18
+ enable-cache: true
19
+
20
+ - name: Install Python
21
+ run: uv python install 3.11
22
+
23
+ - name: Install dependencies
24
+ run: uv sync --dev --extra zmq --locked
25
+
26
+ - name: Check
27
+ run: make check
28
+
29
+ - name: Test
30
+ run: make test
@@ -0,0 +1,39 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment:
12
+ name: pypi
13
+ permissions:
14
+ contents: write
15
+ id-token: write
16
+ steps:
17
+ - name: Checkout
18
+ uses: actions/checkout@v7
19
+ with:
20
+ fetch-depth: 0
21
+
22
+ - name: Set up uv
23
+ uses: astral-sh/setup-uv@v7
24
+ with:
25
+ enable-cache: true
26
+
27
+ - name: Install Python
28
+ run: uv python install 3.11
29
+
30
+ - name: Build
31
+ run: uv build
32
+
33
+ - name: Publish to PyPI
34
+ run: uv publish
35
+
36
+ - name: Create GitHub release
37
+ env:
38
+ GH_TOKEN: ${{ github.token }}
39
+ run: gh release create "$GITHUB_REF_NAME" dist/* --title "$GITHUB_REF_NAME" --generate-notes
@@ -0,0 +1,9 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ .pyrefly_cache/
7
+ dist/
8
+ build/
9
+ *.pyc
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,63 @@
1
+ # CLAUDE.md — chrono-daemon
2
+
3
+ Tiny anyio-based concurrency library. Four primitives: `Channel`, `Clock`
4
+ (Wall/Sim), `Daemon`, `Supervisor`. See `docs/concepts.md` for the user-facing
5
+ explanation and `docs/adr/` for why each piece looks the way it does.
6
+
7
+ This file is the editing-rule sheet for AI agents working on this project.
8
+ The *why* of any decision lives in an ADR, not here.
9
+
10
+ ## Commands
11
+
12
+ ```bash
13
+ uv sync --dev
14
+ make check # ruff lint + format check + pyrefly
15
+ make test # pytest on asyncio + trio
16
+ make all # format + check + test
17
+ ```
18
+
19
+ ## Editing rules
20
+
21
+ Each of these has an ADR. Don't violate them without writing a superseding
22
+ ADR first.
23
+
24
+ - **`anyio` is the only direct runtime dependency.** No `msgspec`, no
25
+ `structlog`, no `pydantic`. (ADR 0007.)
26
+ - **`Channel` is the sole communication primitive on the core surface.**
27
+ Don't add `Topic`, pub/sub broadcast, services, RPC, or a parameter
28
+ system to `chrono_daemon.*`. Fanout lives at `chrono_daemon.recipes.fanout.tee` —
29
+ importable, but under the weaker-stability recipes namespace. (ADR 0001.)
30
+ - **Daemons must reach for `ctx.clock.sleep(...)` or
31
+ `ctx.clock.wait_until(...)`** — never `anyio.sleep` directly.
32
+ Library-internal code must obey this so `SimClock` can intercept.
33
+ (ADR 0002.)
34
+ - **`Daemon` has exactly three hooks**: `on_start`, `run`, `on_stop`. No
35
+ pause/resume, no lifecycle state machine. (ADR 0005.)
36
+ - **`Channel` protocol signatures stay transport-agnostic.** Don't bake
37
+ in-process Python-identity or sync assumptions; future transport adapters
38
+ share the same surface. (ADR 0006.)
39
+
40
+ ## Test discipline
41
+
42
+ - Every async test is parametrized over `["asyncio", "trio"]` via
43
+ `tests/conftest.py`. New tests must pass on both.
44
+ - Canary: `tests/test_clock.py::test_simclock_burst_step_deterministic_order`
45
+ — if this fails on either backend, the rest of the system is unreliable.
46
+ - `tests/test_integration.py` is the "would chrono-daemon replace simple_env"
47
+ check. Don't weaken it.
48
+ - `tests/test_examples.py` pins the demos in `examples/`. Don't bypass it
49
+ by skipping; fix the demo instead.
50
+
51
+ ## What lives where
52
+
53
+ - `src/chrono_daemon/` — the seven core modules. Each one is short and has one job.
54
+ - `docs/concepts.md` — user-facing explanation of the four primitives.
55
+ - `docs/adr/` — frozen-in-time decision records. Immutable once accepted;
56
+ add a new ADR to revise.
57
+ - `src/chrono_daemon/recipes/` — patterns kept off the core surface but
58
+ importable under `chrono_daemon.recipes.*`. Weaker stability guarantees than
59
+ the core (see `src/chrono_daemon/recipes/__init__.py`).
60
+ - `docs/recipes.md` — user-facing index for the above.
61
+ - `examples/` — end-to-end demos exercised by CI (`tests/test_examples.py`).
62
+ - `CLAUDE.md` (this file) — editing rules + commands only.
63
+ - Top-level `README.md` — the 5-minute pitch.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Suhwan Choi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,23 @@
1
+ .PHONY: check test lint format install all
2
+
3
+ # Source paths that participate in lint/format/type-check.
4
+ SRC_PATHS = src/ tests/ examples/
5
+
6
+ check: lint
7
+ uv run pyrefly check
8
+
9
+ lint:
10
+ uv run ruff check $(SRC_PATHS)
11
+ uv run ruff format --check $(SRC_PATHS)
12
+
13
+ format:
14
+ uv run ruff format $(SRC_PATHS)
15
+ uv run ruff check --fix $(SRC_PATHS)
16
+
17
+ test:
18
+ uv run pytest tests/ -v
19
+
20
+ install:
21
+ uv sync --dev --extra zmq
22
+
23
+ all: format check test
@@ -0,0 +1,126 @@
1
+ Metadata-Version: 2.4
2
+ Name: chrono-daemon
3
+ Version: 0.1.0
4
+ Summary: Tiny anyio-based concurrency primitives: Channel, Clock (wall + sim), Daemon, Supervisor.
5
+ Project-URL: Repository, https://github.com/MilkClouds/chrono-daemon
6
+ Author: Suhwan Choi
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Requires-Python: >=3.11
10
+ Requires-Dist: anyio>=4
11
+ Provides-Extra: zmq
12
+ Requires-Dist: msgspec>=0.18; extra == 'zmq'
13
+ Requires-Dist: pyzmq>=26; extra == 'zmq'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # chrono-daemon
17
+
18
+ [![CI](https://github.com/MilkClouds/chrono-daemon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MilkClouds/chrono-daemon/actions/workflows/ci.yml)
19
+ [![PyPI](https://img.shields.io/pypi/v/chrono-daemon.svg)](https://pypi.org/project/chrono-daemon/)
20
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)
21
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
22
+
23
+ chrono-daemon is a small Python library for long-running async components whose time
24
+ can be replayed deterministically. It wraps
25
+ [anyio](https://anyio.readthedocs.io/) with four primitives:
26
+
27
+ - `Channel[T]`: typed single-producer / single-consumer queues.
28
+ - `Clock`: real time with `WallClock`, virtual time with `SimClock`.
29
+ - `Daemon`: a lifecycle unit with `on_start`, `run`, and `on_stop`.
30
+ - `Supervisor`: a structured-concurrency root for hosting daemons.
31
+
32
+ The practical payoff is simple: production daemons sleep on `ctx.clock`; tests
33
+ swap in `SimClock` and advance seconds of work without waiting for wall time.
34
+
35
+ ## Quick Example
36
+
37
+ ```python
38
+ from chrono_daemon import Channel, Context, SimClock, Supervisor, daemon, open_channel
39
+
40
+ @daemon
41
+ async def producer(ctx: Context, out: Channel[int]) -> None:
42
+ for i in range(10):
43
+ await ctx.clock.sleep(0.1)
44
+ await out.send.send(i)
45
+ await out.send.aclose()
46
+
47
+ @daemon
48
+ async def consumer(ctx: Context, src: Channel[int]) -> None:
49
+ async for item in src.recv:
50
+ ctx.logger.info("got %d", item)
51
+
52
+ async def main() -> None:
53
+ ch: Channel[int] = open_channel(maxsize=4)
54
+ clock = SimClock()
55
+ async with Supervisor(clock=clock) as sup:
56
+ sup.add(producer(ch))
57
+ sup.add(consumer(ch))
58
+ await clock.advance(1.5) # replay 1.5 s of work immediately
59
+ ```
60
+
61
+ ## Why Use It
62
+
63
+ - Time-dependent async code is testable without sleeps, polling, or fake
64
+ task schedulers.
65
+ - Wiring is explicit. Every edge is a named SPSC channel, so ownership and
66
+ backpressure stay visible.
67
+ - Lifecycle behavior is structured. Daemons get startup, shutdown, logging,
68
+ cancellation, and error policy in one place.
69
+ - The core runtime is small: pure Python, `anyio` underneath, and no
70
+ runtime dependency beyond `anyio`.
71
+
72
+ ## Core API
73
+
74
+ | | What it is |
75
+ |---|---|
76
+ | **`Channel[T]`** | typed bounded SPSC queue, the only inter-daemon communication primitive |
77
+ | **`Clock`** | `WallClock` (real time) or `SimClock` (deterministic, burst `advance(dt)` / `advance_to(t)`) |
78
+ | **`Daemon`** | long-running async unit; `on_start` / `run` / `on_stop` hooks. Use a subclass or the `@daemon` decorator |
79
+ | **`Supervisor`** | `async with Supervisor(...)` structured-concurrency root; hosts daemons, dispatches errors (`shutdown` / `restart` / `ignore`) |
80
+
81
+ See [`docs/concepts.md`](docs/concepts.md) for details.
82
+
83
+ ## Scope
84
+
85
+ chrono-daemon is for in-process async systems where explicit wiring and deterministic
86
+ time matter: evaluation loops, agent pipelines, robotics-style control mocks,
87
+ and testable service internals.
88
+
89
+ It is intentionally not a topic broker, service registry, RPC framework,
90
+ parameter server, CLI launcher, network runtime, or continuous-time numerical
91
+ simulator.
92
+
93
+ ## Install / dev
94
+
95
+ ```bash
96
+ uv sync --dev --extra zmq
97
+ make check # ruff + pyrefly
98
+ make test # pytest on asyncio + trio
99
+ make all # format + check + test
100
+ ```
101
+
102
+ Python 3.11+. Only runtime dependency is `anyio>=4`; `--extra zmq` installs
103
+ the optional transport dependencies needed for the full test suite.
104
+ Remote ZMQ channels are optional:
105
+
106
+ ```bash
107
+ pip install "chrono-daemon[zmq]"
108
+ ```
109
+
110
+ ## Where to look next
111
+
112
+ - [`docs/concepts.md`](docs/concepts.md): what each primitive is and the
113
+ invariants the test suite pins.
114
+ - [`docs/adr/`](docs/adr/): why each decision looks the way it does
115
+ (Topic-less, on-error-shutdown by default, anyio-only).
116
+ - [`docs/recipes.md`](docs/recipes.md): patterns kept off the core
117
+ surface but importable under `chrono_daemon.recipes.*`, grouped as routing,
118
+ coordination, state, buffering, and hosting helpers under
119
+ `src/chrono_daemon/recipes/`.
120
+ - [`examples/`](examples/): runnable System 2/1/0 demos and notes.
121
+
122
+ ## Status
123
+
124
+ Early. In-process channels are the default and keep zero runtime dependencies
125
+ beyond `anyio`. ZMQ remote channels are available as an optional extra
126
+ (`chrono-daemon[zmq]`) for asyncio-backed deployments.
@@ -0,0 +1,111 @@
1
+ # chrono-daemon
2
+
3
+ [![CI](https://github.com/MilkClouds/chrono-daemon/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/MilkClouds/chrono-daemon/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/chrono-daemon.svg)](https://pypi.org/project/chrono-daemon/)
5
+ ![Python 3.11+](https://img.shields.io/badge/python-3.11%2B-blue)
6
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
7
+
8
+ chrono-daemon is a small Python library for long-running async components whose time
9
+ can be replayed deterministically. It wraps
10
+ [anyio](https://anyio.readthedocs.io/) with four primitives:
11
+
12
+ - `Channel[T]`: typed single-producer / single-consumer queues.
13
+ - `Clock`: real time with `WallClock`, virtual time with `SimClock`.
14
+ - `Daemon`: a lifecycle unit with `on_start`, `run`, and `on_stop`.
15
+ - `Supervisor`: a structured-concurrency root for hosting daemons.
16
+
17
+ The practical payoff is simple: production daemons sleep on `ctx.clock`; tests
18
+ swap in `SimClock` and advance seconds of work without waiting for wall time.
19
+
20
+ ## Quick Example
21
+
22
+ ```python
23
+ from chrono_daemon import Channel, Context, SimClock, Supervisor, daemon, open_channel
24
+
25
+ @daemon
26
+ async def producer(ctx: Context, out: Channel[int]) -> None:
27
+ for i in range(10):
28
+ await ctx.clock.sleep(0.1)
29
+ await out.send.send(i)
30
+ await out.send.aclose()
31
+
32
+ @daemon
33
+ async def consumer(ctx: Context, src: Channel[int]) -> None:
34
+ async for item in src.recv:
35
+ ctx.logger.info("got %d", item)
36
+
37
+ async def main() -> None:
38
+ ch: Channel[int] = open_channel(maxsize=4)
39
+ clock = SimClock()
40
+ async with Supervisor(clock=clock) as sup:
41
+ sup.add(producer(ch))
42
+ sup.add(consumer(ch))
43
+ await clock.advance(1.5) # replay 1.5 s of work immediately
44
+ ```
45
+
46
+ ## Why Use It
47
+
48
+ - Time-dependent async code is testable without sleeps, polling, or fake
49
+ task schedulers.
50
+ - Wiring is explicit. Every edge is a named SPSC channel, so ownership and
51
+ backpressure stay visible.
52
+ - Lifecycle behavior is structured. Daemons get startup, shutdown, logging,
53
+ cancellation, and error policy in one place.
54
+ - The core runtime is small: pure Python, `anyio` underneath, and no
55
+ runtime dependency beyond `anyio`.
56
+
57
+ ## Core API
58
+
59
+ | | What it is |
60
+ |---|---|
61
+ | **`Channel[T]`** | typed bounded SPSC queue, the only inter-daemon communication primitive |
62
+ | **`Clock`** | `WallClock` (real time) or `SimClock` (deterministic, burst `advance(dt)` / `advance_to(t)`) |
63
+ | **`Daemon`** | long-running async unit; `on_start` / `run` / `on_stop` hooks. Use a subclass or the `@daemon` decorator |
64
+ | **`Supervisor`** | `async with Supervisor(...)` structured-concurrency root; hosts daemons, dispatches errors (`shutdown` / `restart` / `ignore`) |
65
+
66
+ See [`docs/concepts.md`](docs/concepts.md) for details.
67
+
68
+ ## Scope
69
+
70
+ chrono-daemon is for in-process async systems where explicit wiring and deterministic
71
+ time matter: evaluation loops, agent pipelines, robotics-style control mocks,
72
+ and testable service internals.
73
+
74
+ It is intentionally not a topic broker, service registry, RPC framework,
75
+ parameter server, CLI launcher, network runtime, or continuous-time numerical
76
+ simulator.
77
+
78
+ ## Install / dev
79
+
80
+ ```bash
81
+ uv sync --dev --extra zmq
82
+ make check # ruff + pyrefly
83
+ make test # pytest on asyncio + trio
84
+ make all # format + check + test
85
+ ```
86
+
87
+ Python 3.11+. Only runtime dependency is `anyio>=4`; `--extra zmq` installs
88
+ the optional transport dependencies needed for the full test suite.
89
+ Remote ZMQ channels are optional:
90
+
91
+ ```bash
92
+ pip install "chrono-daemon[zmq]"
93
+ ```
94
+
95
+ ## Where to look next
96
+
97
+ - [`docs/concepts.md`](docs/concepts.md): what each primitive is and the
98
+ invariants the test suite pins.
99
+ - [`docs/adr/`](docs/adr/): why each decision looks the way it does
100
+ (Topic-less, on-error-shutdown by default, anyio-only).
101
+ - [`docs/recipes.md`](docs/recipes.md): patterns kept off the core
102
+ surface but importable under `chrono_daemon.recipes.*`, grouped as routing,
103
+ coordination, state, buffering, and hosting helpers under
104
+ `src/chrono_daemon/recipes/`.
105
+ - [`examples/`](examples/): runnable System 2/1/0 demos and notes.
106
+
107
+ ## Status
108
+
109
+ Early. In-process channels are the default and keep zero runtime dependencies
110
+ beyond `anyio`. ZMQ remote channels are available as an optional extra
111
+ (`chrono-daemon[zmq]`) for asyncio-backed deployments.
@@ -0,0 +1,30 @@
1
+ # chrono-daemon docs
2
+
3
+ This directory holds chrono-daemon's design and usage docs.
4
+
5
+ ## Layout
6
+
7
+ - `concepts.md`: the four primitives (`Channel`, `Clock`, `Daemon`,
8
+ `Supervisor`), how they compose, and which invariants hold.
9
+ - `adr/`: Architecture Decision Records. Each ADR is a frozen-in-time
10
+ statement of a load-bearing decision. New ADRs are added rather than
11
+ editing old ones; superseded ADRs link forward.
12
+ - `recipes.md`: the user-facing index for `chrono_daemon.recipes`, grouped as
13
+ routing, coordination, state, buffering, and hosting helpers. Recipes are
14
+ importable but carry weaker stability guarantees than the core surface.
15
+ - Optional transport adapters live in `src/chrono_daemon/transports/`. The first
16
+ one is `chrono_daemon.transports.zmq`, covered by `concepts.md` and ADR 0011.
17
+ - `archive/`: long-form proposal notes preserved for design history, not as
18
+ the primary user documentation.
19
+
20
+ ## When to add what
21
+
22
+ - A user-facing API change → update `concepts.md` and (if a tradeoff was
23
+ involved) a new ADR.
24
+ - A "let's not do X" decision → ADR.
25
+ - "How do I X" question that recurs → recipe.
26
+
27
+ ## When not to write docs here
28
+
29
+ - Editing rules for agents belong outside the user-facing docs.
30
+ - Per-PR justifications belong in the PR description.
@@ -0,0 +1,74 @@
1
+ # ADR 0001: Channel is the sole communication primitive
2
+
3
+ Status: Accepted (2026-05-18); superseded in part by ADR 0010
4
+
5
+ Note: this ADR chose `Channel` as the only core communication primitive.
6
+ ADR 0010 later changed the endpoint ownership rule from MPMC sharing to
7
+ single-owner endpoints.
8
+
9
+ ## Context
10
+
11
+ ROS-style frameworks expose pub/sub `Topic`, point-to-point queues, services,
12
+ parameters, and broadcast events as distinct primitives. Each adds API
13
+ surface, QoS knobs, and edge cases, most notably the *slow-consumer policy*
14
+ problem: when a topic has N subscribers and one is slow, the publisher has to
15
+ choose between blocking everyone, dropping for everyone, or dropping
16
+ per-subscriber, and that choice has to live somewhere in the configuration
17
+ matrix. ROS2's QoS profile sprawl is the load-bearing evidence that this is
18
+ not a small cost.
19
+
20
+ The target workloads for chrono-daemon, including robotics control loops, ML eval
21
+ harnesses, and agent orchestration, are dominated by **statically wired** dataflow: the
22
+ producer and the consumer of any given message are known at supervisor
23
+ construction. Where dynamic subscription happens at all (log taps,
24
+ visualizers), it is rare enough that handling it as an explicit, in-code
25
+ fanout rather than a runtime-discovered subscription is acceptable.
26
+
27
+ A 1:N broadcast can always be expressed as N 1:1 channels plus a fanout
28
+ daemon. The reverse direction, taking a Topic and recovering "exactly one
29
+ consumer gets each message", requires fighting the primitive.
30
+
31
+ ## Decision
32
+
33
+ `Channel[T]` is the sole communication primitive in chrono_daemon. It is a typed,
34
+ bounded queue with two endpoints, `send` and `recv`. As originally accepted,
35
+ this ADR allowed multiple producers and consumers to share endpoints. ADR 0010
36
+ supersedes that part: core channels now use single-owner endpoints and raise
37
+ `ChannelInUse` on concurrent blocking endpoint sharing. Closing the send side
38
+ propagates `EndOfStream` to receivers after the buffer drains.
39
+
40
+ There is no `Topic`, no broadcast primitive, no service/RPC primitive, no
41
+ parameter system, no discovery on the core surface. Users that need 1:N
42
+ broadcast import `chrono_daemon.recipes.fanout.tee`. Recipes live under a
43
+ sibling namespace (`chrono_daemon.recipes`) with weaker stability guarantees
44
+ than the core (see `docs/recipes.md`), so they're available without
45
+ copy-paste but are signaled as best-effort rather than part of the
46
+ load-bearing API.
47
+
48
+ ## Consequences
49
+
50
+ + One API to learn; static type checking on a single `Channel[T]`.
51
+ + Backpressure has one meaning: `send` blocks while the receiver is slow.
52
+ No slow-consumer policy matrix, no QoS profile to negotiate.
53
+ + Replay determinism is straightforward. There is no multi-subscriber
54
+ ordering ambiguity to specify.
55
+ + Wiring is visible in code (every channel is a named local variable), so a
56
+ reader can statically trace dataflow.
57
+ - 1:N broadcast costs the user a one-line `from chrono_daemon.recipes.fanout
58
+ import tee` plus the wiring. For workloads with pervasive broadcast
59
+ (e.g. a single `on_tick` driving multiple inference loops), the wiring
60
+ is still explicit, just import-able.
61
+ - Migration from ROS code that relies on runtime subscriber discovery is
62
+ not a mechanical port. Every dynamic subscription has to become an
63
+ explicit channel handed in at construction.
64
+ - Lifecycle-event streams that ROS users would express as a topic
65
+ ("session created/destroyed broadcasts") have to be modeled as either
66
+ N pre-allocated channels or a single channel with the `tee` recipe.
67
+
68
+ ## Related
69
+
70
+ - ADR 0006 (transport adapter slot): `Channel` is a Protocol so multi-process
71
+ and network backends can be added later without reopening this decision.
72
+ - `chrono_daemon.recipes.fanout.tee`: the canonical 1:N broadcast helper.
73
+ - `chrono_daemon.recipes.batcher`: shows how request/response is built from
74
+ channels alone (no service primitive needed).
@@ -0,0 +1,74 @@
1
+ # ADR 0002: Wall and Sim clocks as a pluggable Clock protocol
2
+
3
+ Status: Accepted (2026-05-18)
4
+
5
+ ## Context
6
+
7
+ The library's distinguishing capability vs. dora-rs, HORUS, Apollo CyberRT,
8
+ and any "ROS for Python" attempt is **deterministic burst-step replay**: a
9
+ driver task should be able to advance an entire scenario's worth of time in
10
+ a single call (`await clock.advance(10.0)`) and observe daemons fire their
11
+ sleeps and timers in exactly the order and at exactly the virtual instants
12
+ they would have at wall-clock speed.
13
+
14
+ `anyio` provides `current_time()` and `sleep()` as monotonic, backend-agnostic
15
+ primitives, but does not provide a controllable virtual clock. trio's
16
+ `MockClock` exists but is trio-specific. asyncio has nothing comparable.
17
+
18
+ If daemon code is free to call `anyio.sleep` directly, no virtual clock can
19
+ intercept it. Determinism would then depend on every daemon author
20
+ remembering to use a `Clock` object. a passive convention is not strong
21
+ enough.
22
+
23
+ ## Decision
24
+
25
+ Time is reached through a `Clock` protocol with four methods: `now() -> float`,
26
+ `async sleep(seconds)`, `async wait_until(deadline)`, and
27
+ `every(period) -> AsyncIterator[float]`. Two
28
+ implementations are provided:
29
+
30
+ - `WallClock` uses `time.monotonic()` for `now()` and delegates sleeps to
31
+ `anyio.sleep`.
32
+ - `SimClock` keeps an internal heap of `(deadline, sequence, anyio.Event)`
33
+ tuples. `sleep` enqueues; `advance(dt)` walks the heap in deadline order,
34
+ setting each event and yielding to let the woken task register a
35
+ follow-up sleep before the next iteration looks at the heap.
36
+
37
+ `Context` exposes the active clock as `ctx.clock`. The CLAUDE.md editing rule
38
+ "daemons must use `ctx.clock.sleep`, never `anyio.sleep`" is the convention
39
+ that makes the interception complete; it is enforced socially, not by code.
40
+
41
+ `SimClock.advance_to` performs a configurable `settle_rounds` checkpoint
42
+ budget between successive wakes. The default is 8. This is empirically
43
+ required on the trio backend, which does not necessarily resume a woken
44
+ task on a single fairness round. anyio does not expose a backend-agnostic
45
+ "run until all tasks are idle" primitive, so the budget is explicit rather
46
+ than inferred.
47
+
48
+ ## Consequences
49
+
50
+ + Deterministic burst-step replay is a first-class capability, not a
51
+ research-mode toggle.
52
+ + Production daemons swap `SimClock` for `WallClock` with no code change.
53
+ + Restart backoff, periodic timers, and any "wait until X seconds elapsed"
54
+ pattern all become deterministic under simulation, because they all go
55
+ through `Clock.sleep`.
56
+ - The "always use `ctx.clock`" rule is a discipline, not a constraint
57
+ enforced by the type system. A daemon that imports `anyio.sleep` directly
58
+ silently breaks `SimClock`. The test suite has integration tests that would
59
+ detect it for in-tree code; user code is on the honor system.
60
+ - `settle_rounds` is still a scheduler-settlement budget, not a proof of
61
+ global quiescence. Raising or lowering it changes how much downstream task
62
+ chaining one `advance_to()` call will absorb before finalizing.
63
+ - Backend-agnostic deterministic primitives are an ongoing area of churn
64
+ in the anyio ecosystem; if a future anyio version ships an equivalent,
65
+ `SimClock` will likely be re-implemented on top of it rather than
66
+ remaining a hand-rolled heap.
67
+
68
+ ## Related
69
+
70
+ - ADR 0008 (sim-aware logging) extends this by making `Context.logger`
71
+ carry sim-time on records, so burst-replay log output is interpretable.
72
+ - `tests/test_clock.py::test_simclock_burst_step_deterministic_order` is
73
+ the canary: if this test fails on either backend, the rest of the system
74
+ is unreliable.