graphrefly 0.6.0__tar.gz → 0.7.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 (286) hide show
  1. {graphrefly-0.6.0 → graphrefly-0.7.0}/CHANGELOG.md +11 -0
  2. {graphrefly-0.6.0 → graphrefly-0.7.0}/PKG-INFO +1 -1
  3. {graphrefly-0.6.0 → graphrefly-0.7.0}/docs/optimizations.md +32 -18
  4. {graphrefly-0.6.0 → graphrefly-0.7.0}/pyproject.toml +1 -1
  5. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/__init__.py +2 -0
  6. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/__init__.py +4 -0
  7. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/dynamic_node.py +27 -9
  8. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/node.py +44 -13
  9. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/protocol.py +3 -3
  10. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/sugar.py +4 -1
  11. graphrefly-0.7.0/src/graphrefly/core/timer.py +39 -0
  12. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/adapters.py +98 -48
  13. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/resilience.py +24 -46
  14. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/graph/graph.py +24 -3
  15. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_core.py +4 -1
  16. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_resilience.py +12 -2
  17. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_sources.py +10 -6
  18. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_graph.py +6 -6
  19. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_guard.py +4 -1
  20. {graphrefly-0.6.0 → graphrefly-0.7.0}/.claude/skills/dev-dispatch/SKILL.md +0 -0
  21. {graphrefly-0.6.0 → graphrefly-0.7.0}/.claude/skills/parity/SKILL.md +0 -0
  22. {graphrefly-0.6.0 → graphrefly-0.7.0}/.claude/skills/qa/SKILL.md +0 -0
  23. {graphrefly-0.6.0 → graphrefly-0.7.0}/.gemini/skills/dev-dispatch/SKILL.md +0 -0
  24. {graphrefly-0.6.0 → graphrefly-0.7.0}/.gemini/skills/parity/SKILL.md +0 -0
  25. {graphrefly-0.6.0 → graphrefly-0.7.0}/.github/workflows/pages.yml +0 -0
  26. {graphrefly-0.6.0 → graphrefly-0.7.0}/.github/workflows/release.yml +0 -0
  27. {graphrefly-0.6.0 → graphrefly-0.7.0}/.gitignore +0 -0
  28. {graphrefly-0.6.0 → graphrefly-0.7.0}/.mise.toml +0 -0
  29. {graphrefly-0.6.0 → graphrefly-0.7.0}/CLAUDE.md +0 -0
  30. {graphrefly-0.6.0 → graphrefly-0.7.0}/CONTRIBUTING.md +0 -0
  31. {graphrefly-0.6.0 → graphrefly-0.7.0}/GEMINI.md +0 -0
  32. {graphrefly-0.6.0 → graphrefly-0.7.0}/LICENSE +0 -0
  33. {graphrefly-0.6.0 → graphrefly-0.7.0}/README.md +0 -0
  34. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/DESIGN-ARCHIVE-INDEX.md +0 -0
  35. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-access-control-actor-guard.md +0 -0
  36. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-cross-repo-implementation-audit.md +0 -0
  37. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-demo-test-strategy.md +0 -0
  38. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-graphrefly-spec-design.md +0 -0
  39. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-serialization-memory-footprint.md +0 -0
  40. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-tier2-parity-nonlocal-forward-inner.md +0 -0
  41. {graphrefly-0.6.0 → graphrefly-0.7.0}/archive/docs/SESSION-universal-reduction-layer.md +0 -0
  42. {graphrefly-0.6.0 → graphrefly-0.7.0}/benchmarks/py-baseline.json +0 -0
  43. {graphrefly-0.6.0 → graphrefly-0.7.0}/docs/ADAPTER-CONTRACT.md +0 -0
  44. {graphrefly-0.6.0 → graphrefly-0.7.0}/docs/benchmark.md +0 -0
  45. {graphrefly-0.6.0 → graphrefly-0.7.0}/docs/docs-guidance.md +0 -0
  46. {graphrefly-0.6.0 → graphrefly-0.7.0}/docs/roadmap.md +0 -0
  47. {graphrefly-0.6.0 → graphrefly-0.7.0}/docs/test-guidance.md +0 -0
  48. {graphrefly-0.6.0 → graphrefly-0.7.0}/examples/README.md +0 -0
  49. {graphrefly-0.6.0 → graphrefly-0.7.0}/examples/basic_counter.py +0 -0
  50. {graphrefly-0.6.0 → graphrefly-0.7.0}/llms.txt +0 -0
  51. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/compat/__init__.py +0 -0
  52. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/compat/async_utils.py +0 -0
  53. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/compat/asyncio_runner.py +0 -0
  54. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/compat/trio_runner.py +0 -0
  55. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/cancellation.py +0 -0
  56. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/clock.py +0 -0
  57. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/guard.py +0 -0
  58. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/meta.py +0 -0
  59. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/runner.py +0 -0
  60. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/subgraph_locks.py +0 -0
  61. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/core/versioning.py +0 -0
  62. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/__init__.py +0 -0
  63. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/backoff.py +0 -0
  64. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/backpressure.py +0 -0
  65. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/cascading_cache.py +0 -0
  66. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/checkpoint.py +0 -0
  67. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/composite.py +0 -0
  68. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/cron.py +0 -0
  69. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/data_structures.py +0 -0
  70. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/sources.py +0 -0
  71. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/tier1.py +0 -0
  72. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/extra/tier2.py +0 -0
  73. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/graph/__init__.py +0 -0
  74. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/integrations/__init__.py +0 -0
  75. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/integrations/fastapi.py +0 -0
  76. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/__init__.py +0 -0
  77. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/ai.py +0 -0
  78. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/cqrs.py +0 -0
  79. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/memory.py +0 -0
  80. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/messaging.py +0 -0
  81. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/orchestration.py +0 -0
  82. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/reactive_layout/__init__.py +0 -0
  83. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/reactive_layout/measurement_adapters.py +0 -0
  84. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/reactive_layout/reactive_block_layout.py +0 -0
  85. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/patterns/reactive_layout/reactive_layout.py +0 -0
  86. {graphrefly-0.6.0 → graphrefly-0.7.0}/src/graphrefly/py.typed +0 -0
  87. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/bench_core.py +0 -0
  88. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/conftest.py +0 -0
  89. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_adapter_contract.py +0 -0
  90. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_adapters_ingest.py +0 -0
  91. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_adapters_storage.py +0 -0
  92. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_backpressure.py +0 -0
  93. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_cascading_cache.py +0 -0
  94. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_concurrency.py +0 -0
  95. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_dynamic_node.py +0 -0
  96. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_edge_cases.py +0 -0
  97. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_composite.py +0 -0
  98. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_data_structures.py +0 -0
  99. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_sources_http.py +0 -0
  100. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_tier1.py +0 -0
  101. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_extra_tier2.py +0 -0
  102. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_fastapi.py +0 -0
  103. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_measurement_adapters.py +0 -0
  104. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_patterns_ai.py +0 -0
  105. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_patterns_cqrs.py +0 -0
  106. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_patterns_memory.py +0 -0
  107. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_patterns_messaging.py +0 -0
  108. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_patterns_orchestration.py +0 -0
  109. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_perf_smoke.py +0 -0
  110. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_protocol.py +0 -0
  111. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_reactive_block_layout.py +0 -0
  112. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_reactive_layout.py +0 -0
  113. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_regressions.py +0 -0
  114. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_runner.py +0 -0
  115. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_smoke.py +0 -0
  116. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_sugar.py +0 -0
  117. {graphrefly-0.6.0 → graphrefly-0.7.0}/tests/test_versioning.py +0 -0
  118. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/.gitignore +0 -0
  119. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/README.md +0 -0
  120. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/astro.config.mjs +0 -0
  121. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/content.config.ts +0 -0
  122. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/package.json +0 -0
  123. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/pnpm-lock.yaml +0 -0
  124. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/public/llms.txt +0 -0
  125. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/py-api-sidebar.mjs +0 -0
  126. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/scripts/gen_api_docs.py +0 -0
  127. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/scripts/sync-docs.mjs +0 -0
  128. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/components/GraphreflyHero.astro +0 -0
  129. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/components/Header.astro +0 -0
  130. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/components/MobileMenuFooter.astro +0 -0
  131. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/components/PyodidePlayground.tsx +0 -0
  132. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/components/Sidebar.astro +0 -0
  133. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/components/SiteTitle.astro +0 -0
  134. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/BackoffPreset.md +0 -0
  135. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/BackoffStrategy.md +0 -0
  136. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/CheckpointAdapter.md +0 -0
  137. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/CircuitBreaker.md +0 -0
  138. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/CircuitOpenError.md +0 -0
  139. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/DeferWhen.md +0 -0
  140. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/DictCheckpointAdapter.md +0 -0
  141. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/DistillBundle.md +0 -0
  142. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/EmitStrategy.md +0 -0
  143. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/Extraction.md +0 -0
  144. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/FileCheckpointAdapter.md +0 -0
  145. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/HttpBundle.md +0 -0
  146. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/JitterMode.md +0 -0
  147. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/MemoryCheckpointAdapter.md +0 -0
  148. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/Message.md +0 -0
  149. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/MessageType.md +0 -0
  150. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/Messages.md +0 -0
  151. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/NodeActions.md +0 -0
  152. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/NodeFn.md +0 -0
  153. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/NodeImpl.md +0 -0
  154. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/NodeStatus.md +0 -0
  155. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/PipeOperator.md +0 -0
  156. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/PubSubHub.md +0 -0
  157. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/ReactiveIndexBundle.md +0 -0
  158. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/ReactiveListBundle.md +0 -0
  159. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/ReactiveLogBundle.md +0 -0
  160. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/ReactiveMapBundle.md +0 -0
  161. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/SqliteCheckpointAdapter.md +0 -0
  162. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/SubscribeHints.md +0 -0
  163. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/TokenBucket.md +0 -0
  164. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/VerifiableBundle.md +0 -0
  165. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/Versioned.md +0 -0
  166. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/WithBreakerBundle.md +0 -0
  167. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/WithStatusBundle.md +0 -0
  168. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/audit.md +0 -0
  169. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/batch.md +0 -0
  170. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/buffer.md +0 -0
  171. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/buffer_count.md +0 -0
  172. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/buffer_time.md +0 -0
  173. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/cached.md +0 -0
  174. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/checkpoint_node_value.md +0 -0
  175. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/circuit_breaker.md +0 -0
  176. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/combine.md +0 -0
  177. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/concat.md +0 -0
  178. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/concat_map.md +0 -0
  179. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/constant.md +0 -0
  180. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/debounce.md +0 -0
  181. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/decorrelated_jitter.md +0 -0
  182. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/delay.md +0 -0
  183. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/derived.md +0 -0
  184. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/dispatch_messages.md +0 -0
  185. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/distill.md +0 -0
  186. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/distinct_until_changed.md +0 -0
  187. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/effect.md +0 -0
  188. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/element_at.md +0 -0
  189. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/emit_with_batch.md +0 -0
  190. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/empty.md +0 -0
  191. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/exhaust_map.md +0 -0
  192. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/exponential.md +0 -0
  193. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/fibonacci.md +0 -0
  194. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/filter.md +0 -0
  195. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/find.md +0 -0
  196. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/first.md +0 -0
  197. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/first_value_from.md +0 -0
  198. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/flat_map.md +0 -0
  199. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/for_each.md +0 -0
  200. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_any.md +0 -0
  201. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_async_iter.md +0 -0
  202. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_awaitable.md +0 -0
  203. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_cron.md +0 -0
  204. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_event_emitter.md +0 -0
  205. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_fs_watch.md +0 -0
  206. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_git_hook.md +0 -0
  207. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_http.md +0 -0
  208. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_iter.md +0 -0
  209. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_mcp.md +0 -0
  210. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_timer.md +0 -0
  211. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_webhook.md +0 -0
  212. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/from_websocket.md +0 -0
  213. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/gate.md +0 -0
  214. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/index.md +0 -0
  215. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/interval.md +0 -0
  216. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/is_batching.md +0 -0
  217. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/is_phase2_message.md +0 -0
  218. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/is_terminal_message.md +0 -0
  219. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/last.md +0 -0
  220. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/linear.md +0 -0
  221. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/log_slice.md +0 -0
  222. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/map.md +0 -0
  223. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/merge.md +0 -0
  224. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/message_tier.md +0 -0
  225. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/never.md +0 -0
  226. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/node.md +0 -0
  227. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/of.md +0 -0
  228. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/operator.md +0 -0
  229. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/pairwise.md +0 -0
  230. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/partition_for_batch.md +0 -0
  231. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/pausable.md +0 -0
  232. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/pipe.md +0 -0
  233. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/producer.md +0 -0
  234. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/propagates_to_meta.md +0 -0
  235. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/pubsub.md +0 -0
  236. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/race.md +0 -0
  237. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/rate_limiter.md +0 -0
  238. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/reactive_index.md +0 -0
  239. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/reactive_list.md +0 -0
  240. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/reactive_log.md +0 -0
  241. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/reactive_map.md +0 -0
  242. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/reduce.md +0 -0
  243. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/repeat.md +0 -0
  244. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/replay.md +0 -0
  245. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/rescue.md +0 -0
  246. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/resolve_backoff_preset.md +0 -0
  247. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/restore_graph_checkpoint.md +0 -0
  248. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/retry.md +0 -0
  249. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/sample.md +0 -0
  250. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/save_graph_checkpoint.md +0 -0
  251. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/scan.md +0 -0
  252. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/share.md +0 -0
  253. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/skip.md +0 -0
  254. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/start_with.md +0 -0
  255. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/state.md +0 -0
  256. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/subscribe.md +0 -0
  257. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/switch_map.md +0 -0
  258. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/take.md +0 -0
  259. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/take_until.md +0 -0
  260. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/take_while.md +0 -0
  261. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/tap.md +0 -0
  262. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/throttle.md +0 -0
  263. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/throw_error.md +0 -0
  264. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/timeout.md +0 -0
  265. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/to_array.md +0 -0
  266. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/to_list.md +0 -0
  267. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/to_sse.md +0 -0
  268. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/to_websocket.md +0 -0
  269. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/token_bucket.md +0 -0
  270. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/token_tracker.md +0 -0
  271. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/verifiable.md +0 -0
  272. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/window.md +0 -0
  273. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/window_count.md +0 -0
  274. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/window_time.md +0 -0
  275. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/with_breaker.md +0 -0
  276. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/with_latest_from.md +0 -0
  277. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/with_max_attempts.md +0 -0
  278. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/with_status.md +0 -0
  279. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/api/zip.md +0 -0
  280. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/index.mdx +0 -0
  281. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content/docs/lab/python.mdx +0 -0
  282. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/content.config.ts +0 -0
  283. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/env.d.ts +0 -0
  284. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/src/styles/custom.css +0 -0
  285. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/theme-prototypes.html +0 -0
  286. {graphrefly-0.6.0 → graphrefly-0.7.0}/website/tsconfig.json +0 -0
@@ -2,6 +2,17 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.7.0 (2026-04-06)
6
+
7
+ ### Features
8
+
9
+ - Central timer util
10
+ ([`d1285d5`](https://github.com/graphrefly/graphrefly-py/commit/d1285d5c4c95928b1c27e10ca77204ee0f8aaa5e))
11
+
12
+ - Redesign initial + cached
13
+ ([`86d8506`](https://github.com/graphrefly/graphrefly-py/commit/86d850623e4e09fda1e8547ab67ca6e4cefb2a48))
14
+
15
+
5
16
  ## v0.6.0 (2026-04-05)
6
17
 
7
18
  ### Chores
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphrefly
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: Reactive graph protocol for human + LLM co-operation. Composable nodes, glitch-free diamond resolution, two-phase push, durable streaming. Zero dependencies.
5
5
  Project-URL: Homepage, https://py.graphrefly.dev
6
6
  Project-URL: Repository, https://github.com/graphrefly/graphrefly-py
@@ -126,24 +126,38 @@ Union-find over node identity merges components when nodes list dependencies at
126
126
  - **~~Synchronous SQLite blocking in `to_sqlite` sink (Phase 5.2b, noted 2026-04-04, resolved 2026-04-04):~~** Both TS and PY now accept `batch_insert` / `batchInsert` (default `False`) and `max_batch_size` / `maxBatchSize` (default `1000`). DATA values are buffered and flushed inside a `BEGIN`/`COMMIT` transaction on terminal messages (`message_tier >= 3`), at `max_batch_size` threshold, or on `dispose()`. On insert error, the first error triggers `break` + `ROLLBACK`. BEGIN failure preserves pending data for retry via manual `flush()`. Both return `BufferedSinkHandle` when batch mode is enabled.
127
127
  - **CheckpointAdapter refactored to key-value (Phase 3.1c, noted 2026-04-05, resolved 2026-04-05):** `CheckpointAdapter` changed from blob-based (`save(data)` / `load()`) to key-value (`save(key, data)` / `load(key)` / `clear(key)`). Unifies checkpoint persistence and cache storage under one interface. Both TS and PY updated. `save_graph_checkpoint` / `restore_graph_checkpoint` pass `graph.name` as key. `auto_checkpoint` passes `self.name` as key. `FileCheckpointAdapter` now takes a directory (one file per key, sanitized filenames). `DictCheckpointAdapter` no longer takes an internal key in constructor. `SqliteCheckpointAdapter` no longer takes a fixed key — caller provides it. Pre-1.0, all downstream consumers updated, no legacy shims.
128
128
  - **`tiered_storage` uses `CheckpointAdapter[]` directly (Phase 3.1c, noted 2026-04-05, resolved 2026-04-05):** With key-value `CheckpointAdapter`, `tiered_storage` wraps adapters as `CacheTier`s naturally. No separate `CacheTier` interface needed for end users — `CheckpointAdapter` serves both checkpoint and cache use cases.
129
- - **`equals` must never see undefined/None (all phases, noted 2026-04-05):** `_emit_auto_value` must skip `equals()` when `_cached` is still `None` (initial state before first DATA). TS fixed in `node.ts`; PY needs matching fix in `node.py`. Spec §2.5 updated: `equals` only compares two real DATA values, never `undefined`/`None`. **Audit all custom `equals` in PY** to verify none depend on seeing `None`. See also TS `docs/optimizations.md` and roadmap hotfix section.
130
- - **`_cached is None` sentinel is ambiguous with `[(DATA, None)]` and INVALIDATE (noted 2026-04-05):** The current guard uses `self._cached is not None` to detect "never emitted." This conflates: (a) never emitted, (b) emitted `[(DATA, None)]`, (c) reset via INVALIDATE/`reset_on_teardown`. **Proposed fix:** Add `_has_emitted_data: bool` flag (initially `False`, set `True` on first `_emit_auto_value`, reset on INVALIDATE/`reset_on_teardown`). Both TS and PY. Counter-argument: `_run_fn` skips `None` returns so auto-emit can never produce `None`; the `actions.emit(None)` path is exotic. Deferred: decide and implement in both languages.
131
- - **Derived node error observability gap (all phases, noted 2026-04-05, partially resolved 2026-04-05):** When a derived node's `fn` or `equals` throws, the node emits `[(ERROR, err)]` and status becomes `"errored"`. But `graph.get()` returns `None` — indistinguishable from "never computed." Resolution: spec §2.2 updated to emphasize `status` as source of truth and `describe()` as the primary diagnostic — `get()` is a value accessor only. Still needed: wrap `equals`/`fn` errors with node name context before emitting ERROR — deferred to next chat.
132
- - **PY `retry` lock scope around `connect()` (Phase 3.1, noted 2026-04-05):** In `resilience.py`, `schedule_retry_or_finish` calls `fire()` which calls `connect()` while holding the internal lock. `connect()` runs `source.subscribe(sink)` outside the lock, creating a window where concurrent `cleanup()` could miss the new subscription — a potential resource leak on rapid teardown + retry. Mitigation deferred: the window is narrow and only affects concurrent unsubscribe during active retry.
133
- - **PY `SqliteCheckpointAdapter` default `check_same_thread=True` (Phase 3.1, noted 2026-04-05):** Python's `sqlite3.connect()` defaults to `check_same_thread=True`, raising `ProgrammingError` if `save()` is called from a timer/debounce thread while `load()` runs on the main thread. Not an issue in single-threaded usage (the common case). Deferred: add `check_same_thread=False` + a `threading.Lock` if multi-threaded checkpoint usage becomes a pattern.
134
- - **Sink `value = msg[1] if len(msg) > 1 else None` silent None coercion (Phase 5.2–5.3, noted 2026-04-05):** All per-record sinks extract DATA payload via `msg[1] if len(msg) > 1 else None`, silently treating a malformed DATA (missing payload) as `None`. This masks upstream bugs. Deferred: pre-existing pattern across all sinks; fixing would require a coordinated change to all adapters + a spec decision on whether payload-less DATA is valid.
135
- - **Sink `suppress(Exception)` in `dispose()` hides teardown bugs (Phase 5.2–5.3, noted 2026-04-05):** All sinks use `with suppress(Exception)` around `errors_node.down([(MessageType.TEARDOWN,)])` in `dispose()`. This silently swallows teardown errors (double-dispose, graph corruption). Deferred: pre-existing pattern across all sinks; narrowing the suppression requires auditing what errors are actually possible during teardown.
136
- - **`from_django_orm` and `from_tortoise` have identical implementations (Phase 5.2, noted 2026-04-05):** Both sources iterate a sync iterable and emit DATA per row. Bodies are character-identical. Intentional: separate entry points with distinct docstrings and `from_tortoise` includes an async-guard that rejects unawaited coroutines/async iterables. A shared `_from_sync_iterable` helper could reduce duplication but would obscure the distinct adapter semantics. Deferred: revisit if more iterable-based sources are added.
129
+ - **~~`equals` must never see undefined/None (all phases, noted 2026-04-05, resolved 2026-04-05):~~** Superseded by `_SENTINEL` / `NO_VALUE` sentinel below. Original fix used `_has_emitted_data` flag; the sentinel approach replaced both.
130
+ - **~~`_cached is None` sentinel is ambiguous with `[(DATA, None)]` and INVALIDATE (noted 2026-04-05, resolved 2026-04-05):~~** Superseded by `_SENTINEL` / `NO_VALUE` sentinel below.
131
+ - **~~Derived node error observability gap (all phases, noted 2026-04-05, resolved 2026-04-05):~~** `fn`/`equals`/`on_message` errors now wrapped with `RuntimeError(f'Node "{name}": fn threw')` / `equals threw` / `on_message threw` with `__cause__`. Both TS and PY.
132
+ - **~~PY `retry` lock scope around `connect()` (Phase 3.1, noted 2026-04-05, resolved 2026-04-05):~~** Verified that `connect()` already runs outside the lock. Added clarifying comment.
133
+ - **PY `SqliteCheckpointAdapter` default `check_same_thread=True` (Phase 3.1, noted 2026-04-05):** Python's `sqlite3.connect()` defaults to `check_same_thread=True`, raising `ProgrammingError` if `save()` is called from a timer/debounce thread while `load()` runs on the main thread. `check_same_thread=False` + `threading.Lock` fix was prepared but reverted by linter. Deferred: re-apply when multi-threaded checkpoint usage becomes a pattern.
134
+ - **~~Payload-less DATA spec note (Phase 5.2–5.3, noted 2026-04-05, resolved 2026-04-05):~~** Spec clarified: payload-less DATA (`(DATA,)`) is not valid per protocol all DATA tuples must carry a payload. Sink `msg[1] if len(msg) > 1 else None` coercion retained as defensive guard; upstream producers are the source of truth.
135
+ - **~~Sink `dispose()` error routing (Phase 5.2–5.3, noted 2026-04-05, resolved 2026-04-05):~~** All 20 PY sink `dispose()` functions now route errors to the `errors` companion node before suppressing.
136
+ - **~~`from_django_orm` and `from_tortoise` have identical implementations (Phase 5.2, noted 2026-04-05, resolved 2026-04-05):~~** Extracted `_from_sync_rows` helper. Both entry points kept with distinct validation.
137
+ - **~~Auto-edge registration from constructor deps (all phases, noted 2026-04-05, resolved 2026-04-05):~~** `Graph.add()` now auto-registers edges from constructor deps. Eliminates dual-bookkeeping bug surface.
138
+ - **~~ResettableTimer primitive (Phase 3.1, noted 2026-04-05, resolved 2026-04-05):~~** `ResettableTimer` in `core/timer.py`. `retry`, `rate_limiter`, `timeout` refactored to use it.
139
+ - **~~`to_json()` → `to_dict()` + `to_json_string()` (Phase 1.4, noted 2026-04-05, resolved 2026-04-05):~~** PY: `to_json()` renamed to `to_json_string()`; `to_dict()` added as alias of `snapshot()`; `to_json` alias removed (pre-1.0, no backward compat needed). TS: `toJSON()` renamed to `toObject()`; `toJSON()` kept as ECMAScript hook. Spec §3.8 updated.
140
+ - **~~Initial value, cached state, and equals interaction (all phases, noted 2026-04-05, resolved 2026-04-05):~~** Resolved with `_SENTINEL` / `NO_VALUE` sentinel (TS: `Symbol.for("graphrefly/NO_VALUE")`, PY: `_SENTINEL = object()`). Replaces the `_has_emitted_data` boolean flag entirely. One field (`_cached`) instead of two — impossible to desync. Key semantics: (1) When `initial` option is present (even as `None`), `_cached = initial` — `equals` IS called on first emission. (2) When `initial` is absent, `_cached = _SENTINEL` — first emission always DATA. (3) INVALIDATE / `reset_on_teardown` set `_cached = _SENTINEL`. (4) Resubscribable: terminal reset now also sets `_cached = _SENTINEL` — new subscriber always gets DATA. (5) Reconnect: cache retained → same-value emits RESOLVED — correct. (6) `get()` returns `None` when `_cached is _SENTINEL`. Spec §2.5 updated.
141
+ - **Auto-edge registration is local-only (Phase 1.1, noted 2026-04-05):** `Graph.add()` auto-registers edges for deps within the same `Graph` instance only. Cross-subgraph deps still require explicit `connect()`. Consistent with spec: cross-subgraph edges are explicit wiring.
137
142
 
138
143
  ---
139
144
 
145
+ ## Cross-language parity fixes (2026-04-05)
146
+
147
+ - **~~PY error wrapping missing original message (resolved 2026-04-05):~~** `RuntimeError(f'Node "{name}": fn threw')` omitted the original error message. TS included it: `fn threw: ${errMsg}`. Fixed all 3 PY sites (`fn threw`, `equals threw`, `on_message threw`) to include `{err}`. TS `onMessage` error was also not wrapped — now wraps as `Node "${name}": onMessage threw: ${errMsg}` with `{ cause }`.
148
+ - **~~PY `Graph.add()` missing reverse edge scan (resolved 2026-04-05):~~** PY only did forward auto-edge registration. TS did both forward + reverse (existing nodes whose deps include the new node). Fixed: PY now does the reverse scan, matching TS.
149
+ - **~~PY `_emit_sequential` terminal routing (resolved 2026-04-05):~~** PY appended terminals to the same queue as DATA/RESOLVED. TS routed them to `pendingPhase3`. Fixed: PY terminals now go to `bs.pending_phase3`.
150
+ - **~~PY `emit_with_batch` default strategy (resolved 2026-04-05):~~** PY defaulted to `"sequential"`, TS to `"partition"`. Fixed: PY default changed to `"partition"`.
151
+ - **~~PY `retry` sink missing `stopped` guard (resolved 2026-04-05):~~** TS checked `if (stopped) return;` at top of retry subscribe callback. PY had no such guard. Fixed: added `if stopped[0]: return`.
152
+ - **~~PY `fallback` plain-value path used `down()` (resolved 2026-04-05):~~** TS used `a.emit(fb)` (goes through equals + DIRTY/DATA wrapping). PY used `actions.down([(DATA, fb), (COMPLETE,)])` (bypassed equals). Fixed: PY now uses `actions.emit(fb)` then `actions.down([(COMPLETE,)])`.
153
+
140
154
  ## Cross-language implementation notes
141
155
 
142
156
  **Keep this section in sync with `graphrefly-ts/docs/optimizations.md` § Cross-language implementation notes** so you can open both files side by side.
143
157
 
144
- - **Dual-bookkeeping of derived deps + connect edges (all phases, noted 2026-04-05):** In both TS and PY, `derived([depA, depB], fn)` declares reactive deps at the node level (DIRTY/DATA propagation), but `Graph` only records named edges via explicit `graph.connect(from, to)`. Both declarations are required: the node deps drive the reactive engine, the connect edges drive `describe()` / `to_mermaid()` / `to_d2()` output. If a dep is added/removed in the derived call but the corresponding `connect` is not updated (or vice versa), the introspection output diverges from actual dataflow. All graph factories (orchestration, cqrs, memory, messaging) follow this dual pattern. A future improvement could auto-register edges from constructor deps when a node is `add()`-ed but this requires the Graph to inspect the node's internal dep list, which currently crosses the node/graph encapsulation boundary. Deferred: revisit if the divergence-risk causes real bugs.
158
+ - **~~Dual-bookkeeping of derived deps + connect edges (all phases, noted 2026-04-05, resolved 2026-04-05):~~** `Graph.add()` now auto-registers edges from constructor deps in both TS and PY. Eliminates the divergence risk between node-level deps and graph-level edge registry. All graph factories no longer need explicit `connect()` calls for constructor deps.
145
159
 
146
- - **Resilience composition parity (Phase 3.1c, 2026-04-05):** Both TS and PY implement `fallback(source, fb)`, `timeout(source, timeoutNs/timeout_ns)`, `cache(source, ttlNs/ttl_ns)`, `cascadingCache`/`cascading_cache`, and `tieredStorage`/`tiered_storage`. `CacheEvictionPolicy`/`EvictionPolicy` use aligned method names: `insert`/`touch`/`delete`/`evict(count)`/`size()`. `CacheTier`: `load` is required; `save`/`clear` are optional (TS `?` modifier; PY uses `hasattr` checks). `tieredStorage`/`tiered_storage` returns a wrapper with `.cache` property exposing the inner `CascadingCache` in both languages. Eviction demotes to deepest tier with `save` before removing — value is preserved in cold storage. Cache miss sentinel: TS `undefined`, PY `None`. `TimeoutError`: TS extends `Error`; PY subclasses `builtins.TimeoutError` so `except TimeoutError` catches both. Validation: TS `RangeError`, PY `ValueError` — language convention. `cache()` replay emits raw `DATA` (no DIRTY/RESOLVED) on both sides. §5.10 timer exception: `timeout`, `retry`, `rateLimiter`/`rate_limiter` all use `setTimeout`/`threading.Timer` for resettable deadlines documented with inline `§5.10` comments; a reusable resettable timer primitive is deferred.
160
+ - **Resilience composition parity (Phase 3.1c, 2026-04-05):** Both TS and PY implement `fallback(source, fb)`, `timeout(source, timeoutNs/timeout_ns)`, `cache(source, ttlNs/ttl_ns)`, `cascadingCache`/`cascading_cache`, and `tieredStorage`/`tiered_storage`. `CacheEvictionPolicy`/`EvictionPolicy` use aligned method names: `insert`/`touch`/`delete`/`evict(count)`/`size()`. `CacheTier`: `load` is required; `save`/`clear` are optional (TS `?` modifier; PY uses `hasattr` checks). `tieredStorage`/`tiered_storage` returns a wrapper with `.cache` property exposing the inner `CascadingCache` in both languages. Eviction demotes to deepest tier with `save` before removing — value is preserved in cold storage. Cache miss sentinel: TS `undefined`, PY `None`. `TimeoutError`: TS extends `Error`; PY subclasses `builtins.TimeoutError` so `except TimeoutError` catches both. Validation: TS `RangeError`, PY `ValueError` — language convention. `cache()` replay emits raw `DATA` (no DIRTY/RESOLVED) on both sides. §5.10 timer exception: `timeout`, `retry`, `rateLimiter`/`rate_limiter` now use `ResettableTimer` (`core/timer.py` in PY, `core/timer.ts` in TS) a reusable primitive extracted from the repeated `setTimeout`/`threading.Timer` pattern.
147
161
  - **Progressive disclosure parity (Phase 3.3b, 2026-04-04):** Both TS and PY `describe()` support `detail` (`"minimal"` / `"standard"` / `"full"`) and `fields` (GraphQL-style field selection with dotted meta paths like `"meta.label"`). Default changed from returning all fields to `"minimal"` (type + deps only) in both languages — pre-1.0, no backward compat concern. `format: "spec"` / `format="spec"` forces minimal fields. Both return an `expand()` method on the result for re-reading with higher detail. TS `expand` is a property on the result object; PY `describe()` returns a `DescribeResult` (dict subclass) with `expand()` as a method — `json.dumps(graph.describe())` works safely in both languages (TS `JSON.stringify` drops functions; PY `expand` is not a dict key). `observe()` supports `detail` with the same three levels: `"minimal"` (DATA events only in events list, counts still tracked), `"standard"` (current behavior), `"full"` (implies structured + timeline + causal + derived). `ObserveResult` has `expand()` in both languages. TS `expand` is a method on the result object; PY `expand` is a method on the `ObserveResult` dataclass. Internal callers (`snapshot`, `auto_checkpoint`, `dump_graph`, AI pattern functions) explicitly pass the detail level they need. Diagram methods (`to_mermaid`/`toMermaid`, `to_d2`/`toD2`) only use paths/edges so minimal default is fine — intentional, no detail option needed. `"standard"` includes versioning (`v`); `"full"` adds `guard` and `last_mutation`/`lastMutation` (runtime attribution, not restored by `restore()`). `snapshot()` / `auto_checkpoint` strip `last_mutation`/`guard` from persisted nodes so snapshot → restore → snapshot is idempotent — use `describe(detail="full")` for audit snapshots that include attribution. Unrecognized `detail` strings silently fall back to `"minimal"` — no runtime validation; we'll revisit if real-world usage shows this causes confusion. Dict/object filters (`meta_has`, `status`, etc.) operate on whatever fields the chosen detail level provides; at `"minimal"`, fields like `meta` and `status` are absent, so filters that depend on them silently exclude all nodes. Users should pass `detail="standard"` or higher when using these filters. Spec Appendix B `status` field changed from `required` to optional (schema applies at `detail >= "standard"`).
148
162
  - **SQLite adapter parity (Phase 5.2b, 2026-04-04):** Both TS and PY use duck-typed `SqliteDbLike` with a `query(sql, params)` method — matching the `PostgresClientLike`/`ClickHouseClientLike` convention. TS `SqliteDbLike.query()` returns `unknown[]`; PY `SqliteDbLike.query()` returns `list[Any]`. Both are fully synchronous (no Promises/async). `from_sqlite`/`fromSqlite` is one-shot (DATA per row, then COMPLETE); compose with `switch_map` + `from_timer` for periodic re-query. `to_sqlite`/`toSqlite` follows per-record sink pattern (same as `to_postgres`/`toPostgres`). Default insert SQL uses JSON column; custom `to_sql` override available. TS uses `node:sqlite` `DatabaseSync` or `better-sqlite3`; PY uses stdlib `sqlite3` — both zero-dep from GraphReFly's perspective (user provides instance).
149
163
  - **Storage & sink adapter pattern parity (Phase 5.2d, 2026-04-04):** All 5.2d sinks follow the same pattern in both TS and PY: duck-typed client protocols, `on_message` intercepting `DATA`, `SinkTransportError` for serialize/send failures. All sinks return a `SinkHandle` with `dispose()` + `errors: Node[SinkTransportError | None]`. `dispose()` sends `TEARDOWN` to the errors node. Buffered sinks (`to_clickhouse`, `to_s3`, `to_file`, `to_csv`) return a `BufferedSinkHandle` adding `flush()`. TS `SinkHandle` has optional `flush?: ...`; PY uses separate `BufferedSinkHandle` dataclass (intentional — more Pythonic). Checkpoint adapters (`checkpoint_to_s3`, `checkpoint_to_redis`) wire `graph.auto_checkpoint()`. PY uses `threading.Timer` for flush timers; TS uses `setTimeout`. PY `to_postgres` calls `client.execute(sql, params)` (psycopg2/3 style); TS calls `client.query(sql, params)` (pg style). PY `json.dumps` includes spaces after separators; TS `JSON.stringify` does not — NDJSON output is semantically equivalent but not byte-identical across languages.
@@ -312,14 +326,14 @@ Cross-language: `graphrefly-ts/docs/optimizations.md` §15. **Python (shipped):*
312
326
  | **`snapshot()`** | Both: `{ version: 1, ...describe() }` — flat `version` field, sorted `nodes` keys. |
313
327
  | **`restore(data)`** | Both: validate `data.name` matches graph name; skip `derived`/`operator`/`effect` types; silently ignore unknown/failing paths. |
314
328
  | **`from_snapshot(data, build?)`** | Both: optional `build` callback registers topology before `restore()` applies values. Without `build`, both use registry-based reconstruction (mounts → topo node creation via factories → edges → restore). |
315
- | **`to_json()` / `toJSON()`** | Python returns compact JSON **string** with trailing newline. TS returns a plain sorted-key **object** (for `JSON.stringify(graph)`). Language-appropriate. |
316
- | **`toJSONString()`** | TS only — `JSON.stringify(toJSON()) + "\n"`. Python's `to_json()` serves the same role. |
329
+ | **`to_json_string()` / `toJSON()`** | Python `to_json_string()` returns compact JSON **string** with trailing newline. TS `toJSON()` returns a plain sorted-key **object** (for `JSON.stringify(graph)`). Language-appropriate. |
330
+ | **`toJSONString()`** | TS only — `JSON.stringify(toJSON()) + "\n"`. Python's `to_json_string()` serves the same role. |
317
331
 
318
332
  **Intentional divergence:**
319
333
 
320
334
  | Topic | Python | TypeScript | Rationale |
321
335
  |-------|--------|------------|-----------|
322
- | `to_json` return type | `to_json()` → `str` (no universal `__json__` hook in Python) | `toJSON()` → plain object (ECMAScript `JSON.stringify` protocol) | Language idiom |
336
+ | `to_json_string` return type | `to_json_string()` → `str` (no universal `__json__` hook in Python) | `toJSON()` → plain object (ECMAScript `JSON.stringify` protocol) | Language idiom |
323
337
  | `_parse_snapshot_envelope` | Validates `version`, `name`, `nodes`, `edges`, `subgraphs` types | Only validates `data.name` match | Python is stricter; both correct |
324
338
 
325
339
  ### Ingest adapters (roadmap 5.2c / 5.3b) — deferred items (QA)
@@ -330,7 +344,7 @@ Applies to `src/extra/adapters.ts` and `graphrefly.extra.adapters`. **Keep the t
330
344
  |------|--------|-------|
331
345
  | **`fromRedisStream` / `from_redis_stream` never emits COMPLETE** | Documented limitation (2026-04-03) | Long-lived stream consumers intentionally never complete. The consumer loop runs until teardown. This is expected behavior for persistent stream sources (same as Kafka). Document in JSDoc/docstrings. |
332
346
  | **`fromRedisStream` / `from_redis_stream` does not disconnect client** | Documented limitation (2026-04-03) | The caller owns the Redis client lifecycle. The adapter does not call `disconnect()` on teardown — the caller is responsible for closing the connection. Same contract as `fromKafka` (caller owns `consumer.connect()`/`disconnect()`). |
333
- | **PY `from_csv` / `from_ndjson` thread not joined on cleanup** | Documented limitation (2026-04-03) | Python file-ingest adapters run in a daemon thread. On teardown, `active[0] = False` signals the thread to exit but does not `join()` it. The daemon flag ensures the thread does not block process exit. A future optimization could add optional `join(timeout)` on cleanup for stricter resource control. |
347
+ | **PY `from_csv` / `from_ndjson` thread cleanup** | Resolved (2026-04-05) | `t.join(timeout=1)` added after stop flag. |
334
348
 
335
349
  ### Ingest adapters — intentional cross-language divergences (parity review 2026-04-03)
336
350
 
@@ -377,10 +391,10 @@ Applies to `src/extra/adapters.ts` and `graphrefly.extra.adapters`. **Keep the t
377
391
  | Node internals | Class-based `NodeImpl`, all methods on class | Class-based `NodeImpl`, V8 hidden class optimization, prototype methods |
378
392
  | Dep-value identity check | Before cleanup (skip cleanup+fn on no-op) | Before cleanup (skip cleanup+fn on no-op) |
379
393
  | `INVALIDATE` (§1.2) | Cleanup + clear `_cached` + `_last_dep_values`; terminal passthrough (§9); no auto recompute | Same |
380
- | `Graph` Phase 1.1 | `thread_safe` + `RLock`; TEARDOWN after unlock on `remove`; `disconnect` registry-only (§C resolved) | Registry only; `connect` / `disconnect` errors aligned; §C resolved |
394
+ | `Graph` Phase 1.1 | `thread_safe` + `RLock`; TEARDOWN after unlock on `remove`; `disconnect` registry-only (§C resolved); `add()` auto-registers edges from constructor deps | Registry only; `connect` / `disconnect` errors aligned; §C resolved; `add()` auto-registers edges from constructor deps |
381
395
  | `Graph` Phase 1.2 | Aligned: `::` path separator, mount `remove` + subtree TEARDOWN, qualified paths, `edges()`, signal mounts-first, `resolve` strips leading name, `:` in names OK; see §14 | Same; see §14 |
382
396
  | `Graph` Phase 1.3 | `describe`, `observe`, `GRAPH_META_SEGMENT`, `signal`→meta, `describe_kind` on sugar; see §15 | TS: `describe()`, `observe()`, `GRAPH_META_SEGMENT`, `describeKind` on sugar; see graphrefly-ts §15 | `observe()` order: both use full-path code-point sort (resolved 2026-03-31; see §15) |
383
- | `Graph` Phase 1.4 | `destroy`, `snapshot` (flat `version: 1`), `restore` (name check + type filter + silent catch), `from_snapshot(data, build=)`, `to_json()` → str + `\n`; see §16 | `destroy`, `snapshot`, `restore`, `fromSnapshot(data, build?)`, `toJSON()` → object, `toJSONString()` → str + `\n`; see §16 |
397
+ | `Graph` Phase 1.4 | `destroy`, `snapshot` (flat `version: 1`), `restore` (name check + type filter + silent catch), `from_snapshot(data, build=)`, `to_json_string()` → str + `\n`; see §16 | `destroy`, `snapshot`, `restore`, `fromSnapshot(data, build?)`, `toJSON()` → object, `toJSONString()` → str + `\n`; see §16 |
384
398
  | `Graph` Phase 1.5 | **Python:** `Actor`, `GuardDenied`, `policy()`, `compose_guards`, node `guard` opt, `down`/`set`/`signal`/`subscribe`/`describe` actor params, `internal` propagation bypass, `remove`/unmount subtree TEARDOWN `internal=True`; see built-in §8 | **TypeScript:** aligned — `GraphActorOptions`, `NodeTransportOptions`, scoped `describe`/`observe`, `GuardDenied.node` getter mirrors `nodeName` |
385
399
  | `policy()` semantics | Deny-overrides: any matching deny blocks; if no deny, any matching allow permits; no match → deny | Same (aligned from parity round) |
386
400
  | `DEFAULT_ACTOR` | `{"type": "system", "id": ""}` | `{ type: "system", id: "" }` (aligned) |
@@ -399,7 +413,7 @@ Applies to `src/extra/adapters.ts` and `graphrefly.extra.adapters`. **Keep the t
399
413
  | `from_event_emitter` / `fromEvent` | Generic emitter (`add_method=`, `remove_method=`) | DOM `addEventListener` API |
400
414
  | `to_array` / `toArray` | Reactive `Node[list]` | Reactive `Node<T[]>` |
401
415
  | `to_list` (blocking) | Py-only sync bridge | N/A |
402
- | Extra Phase 3.1 (resilience) | `graphrefly.extra.{backoff,resilience,checkpoint}`; see §6 below | `src/extra/{backoff,resilience,checkpoint}.ts`; see §6 below |
416
+ | Extra Phase 3.1 (resilience) | `graphrefly.extra.{backoff,resilience,checkpoint}` + `core/timer.py` (`ResettableTimer`); see §6 below | `src/extra/{backoff,resilience,checkpoint}.ts` + `core/timer.ts` (`ResettableTimer`); see §6 below |
403
417
  | Extra Phase 3.2 (data structures) | `graphrefly.extra.data_structures` (`reactive_map`, …); see §17 | `reactiveMap` + `reactive-base` (`Versioned` snapshots); see §17 |
404
418
 
405
419
  ### 18. CQRS reactive log snapshot shape (Phase 4.5 — cross-language note)
@@ -535,7 +549,7 @@ Both repos now ship a Pulsar-inspired messaging domain layer under `patterns.mes
535
549
 
536
550
  | Topic | Python | TypeScript | Rationale |
537
551
  |-------|--------|------------|-----------|
538
- | Timer base | `monotonic_ns()` (nanoseconds via `time.monotonic_ns()`) | `monotonicNs()` (nanoseconds via `performance.now()`) | Both centralised in `core/clock`; nanosecond internal tracking |
552
+ | Timer base | `monotonic_ns()` (nanoseconds via `time.monotonic_ns()`); `ResettableTimer` in `core/timer.py` | `monotonicNs()` (nanoseconds via `performance.now()`); `ResettableTimer` in `core/timer.ts` | Both centralised in `core/clock`; nanosecond internal tracking; `ResettableTimer` used by `retry`, `rate_limiter`, `timeout` |
539
553
  | Thread safety | `CircuitBreaker` + `TokenBucket` use `threading.Lock`; retry uses `threading.Timer` | Single-threaded (`setTimeout`) | Spec §6.1 |
540
554
  | `CircuitBreaker` params | `cooldown` (seconds, implicit) | `cooldownSeconds` (seconds, explicit) | Naming convention |
541
555
  | `CircuitOpenError` base | `RuntimeError` | `Error` | Language convention |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphrefly"
3
- version = "0.6.0"
3
+ version = "0.7.0"
4
4
  description = "Reactive graph protocol for human + LLM co-operation. Composable nodes, glitch-free diamond resolution, two-phase push, durable streaming. Zero dependencies."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,6 +15,7 @@ from graphrefly.core import (
15
15
  Message,
16
16
  Messages,
17
17
  MessageType,
18
+ NO_VALUE,
18
19
  Node,
19
20
  NodeActions,
20
21
  NodeFn,
@@ -115,6 +116,7 @@ __all__ = [
115
116
  "Message",
116
117
  "MessageType",
117
118
  "Messages",
119
+ "NO_VALUE",
118
120
  "Node",
119
121
  "PipeOperator",
120
122
  "NodeActions",
@@ -18,6 +18,7 @@ from graphrefly.core.guard import (
18
18
  )
19
19
  from graphrefly.core.meta import describe_node, meta_snapshot
20
20
  from graphrefly.core.node import (
21
+ NO_VALUE,
21
22
  Node,
22
23
  NodeActions,
23
24
  NodeFn,
@@ -64,6 +65,7 @@ from graphrefly.core.sugar import (
64
65
  producer,
65
66
  state,
66
67
  )
68
+ from graphrefly.core.timer import ResettableTimer
67
69
  from graphrefly.core.versioning import (
68
70
  V0,
69
71
  V1,
@@ -87,6 +89,7 @@ __all__ = [
87
89
  "GuardDenied",
88
90
  "GuardFn",
89
91
  "Message",
92
+ "NO_VALUE",
90
93
  "MessageType",
91
94
  "Messages",
92
95
  "Node",
@@ -95,6 +98,7 @@ __all__ = [
95
98
  "NodeFn",
96
99
  "NodeImpl",
97
100
  "NodeStatus",
101
+ "ResettableTimer",
98
102
  "SubscribeHints",
99
103
  "monotonic_ns",
100
104
  "wall_clock_ns",
@@ -15,6 +15,7 @@ from contextlib import suppress
15
15
  from types import MappingProxyType
16
16
  from typing import Any
17
17
 
18
+ from graphrefly.core.node import _SENTINEL
18
19
  from graphrefly.core.protocol import Messages, MessageType, emit_with_batch, propagates_to_meta
19
20
 
20
21
  # ---------------------------------------------------------------------------
@@ -189,7 +190,7 @@ class DynamicNodeImpl[T]:
189
190
  self._thread_safe = bool(thread_safe)
190
191
  self._inspector_hook: Callable[[dict[str, Any]], None] | None = None
191
192
 
192
- self._cached: T | None = None
193
+ self._cached: Any = _SENTINEL
193
194
  self._status: str = "disconnected"
194
195
  self._terminal = False
195
196
  self._connected = False
@@ -277,8 +278,10 @@ class DynamicNodeImpl[T]:
277
278
  lock = self._cache_lock
278
279
  if lock is not None:
279
280
  with lock:
280
- return self._cached
281
- return self._cached
281
+ v = self._cached
282
+ else:
283
+ v = self._cached
284
+ return None if v is _SENTINEL else v
282
285
 
283
286
  def down(
284
287
  self,
@@ -346,6 +349,12 @@ class DynamicNodeImpl[T]:
346
349
  ) -> Callable[[], None]:
347
350
  if self._terminal and self._resubscribable:
348
351
  self._terminal = False
352
+ lock = self._cache_lock
353
+ if lock is not None:
354
+ with lock:
355
+ self._cached = _SENTINEL
356
+ else:
357
+ self._cached = _SENTINEL
349
358
  self._status = "disconnected"
350
359
  if self._on_resubscribe is not None:
351
360
  self._on_resubscribe()
@@ -519,11 +528,14 @@ class DynamicNodeImpl[T]:
519
528
  if t is MessageType.INVALIDATE:
520
529
  if lock is not None:
521
530
  with lock:
522
- self._cached = None
531
+ self._cached = _SENTINEL
523
532
  else:
524
- self._cached = None
525
- if t is MessageType.DATA or t is MessageType.RESOLVED:
533
+ self._cached = _SENTINEL
534
+ self._status = "dirty"
535
+ if t is MessageType.DATA:
526
536
  self._status = "settled"
537
+ elif t is MessageType.RESOLVED:
538
+ self._status = "resolved"
527
539
  elif t is MessageType.DIRTY:
528
540
  self._status = "dirty"
529
541
  elif t is MessageType.COMPLETE:
@@ -536,9 +548,9 @@ class DynamicNodeImpl[T]:
536
548
  if self._reset_on_teardown:
537
549
  if lock is not None:
538
550
  with lock:
539
- self._cached = None
551
+ self._cached = _SENTINEL
540
552
  else:
541
- self._cached = None
553
+ self._cached = _SENTINEL
542
554
  try:
543
555
  self._propagate_to_meta(t)
544
556
  finally:
@@ -561,7 +573,13 @@ class DynamicNodeImpl[T]:
561
573
  cached_snapshot = self._cached
562
574
  else:
563
575
  cached_snapshot = self._cached
564
- unchanged = self._equals(cached_snapshot, value)
576
+ try:
577
+ unchanged = cached_snapshot is not _SENTINEL and self._equals(cached_snapshot, value)
578
+ except Exception as eq_err:
579
+ wrapped = RuntimeError(f'Node "{self._name}": equals threw: {eq_err}')
580
+ wrapped.__cause__ = eq_err
581
+ self._down_internal([(MessageType.ERROR, wrapped)])
582
+ return
565
583
  if unchanged:
566
584
  msgs: Messages = (
567
585
  [(MessageType.RESOLVED,)]
@@ -32,6 +32,12 @@ from graphrefly.core.versioning import (
32
32
  default_hash,
33
33
  )
34
34
 
35
+ # Internal sentinel: "no cached value has been set or emitted."
36
+ # Distinct from None so that None can be a valid emitted value.
37
+ _SENTINEL = object()
38
+
39
+ NO_VALUE = _SENTINEL
40
+
35
41
  # --- Status & typing (graphrefly-ts node.ts) ---------------------------------
36
42
 
37
43
  type NodeStatus = str # structural: same strings as TS NodeStatus
@@ -203,6 +209,7 @@ class NodeImpl[T]:
203
209
  "_meta",
204
210
  "_name",
205
211
  "_on_message",
212
+ "_on_resubscribe",
206
213
  "_opts",
207
214
  "_producer_started",
208
215
  "_resubscribable",
@@ -236,6 +243,7 @@ class NodeImpl[T]:
236
243
  self._thread_safe: bool = bool(opts.get("thread_safe", True))
237
244
 
238
245
  self._on_message = opts.get("on_message")
246
+ self._on_resubscribe: Callable[[], None] | None = opts.get("on_resubscribe")
239
247
  self._fn = fn
240
248
  self._deps = deps
241
249
  self._has_deps = len(deps) > 0
@@ -248,7 +256,7 @@ class NodeImpl[T]:
248
256
  self._last_mutation: dict[str, Any] | None = None
249
257
 
250
258
  self._cache_lock = threading.Lock() if self._thread_safe else None
251
- self._cached: T | None = opts.get("initial")
259
+ self._cached: Any = opts["initial"] if "initial" in opts else _SENTINEL
252
260
  self._status: NodeStatus = "disconnected" if self._has_deps else "settled"
253
261
 
254
262
  # Versioning (GRAPHREFLY-SPEC §7)
@@ -257,7 +265,7 @@ class NodeImpl[T]:
257
265
  self._versioning: NodeVersionInfo | None = (
258
266
  create_versioning(
259
267
  versioning_level,
260
- self._cached,
268
+ None if self._cached is _SENTINEL else self._cached,
261
269
  id=opts.get("versioning_id"),
262
270
  hash_fn=self._hash_fn,
263
271
  )
@@ -368,9 +376,9 @@ class NodeImpl[T]:
368
376
  cb()
369
377
  if lock is not None:
370
378
  with lock:
371
- self._cached = None
379
+ self._cached = _SENTINEL
372
380
  else:
373
- self._cached = None
381
+ self._cached = _SENTINEL
374
382
  self._last_dep_values = None
375
383
  self._status = _status_after_message(self._status, m)
376
384
  if t is MessageType.COMPLETE or t is MessageType.ERROR:
@@ -379,9 +387,9 @@ class NodeImpl[T]:
379
387
  if self._reset_on_teardown:
380
388
  if lock is not None:
381
389
  with lock:
382
- self._cached = None
390
+ self._cached = _SENTINEL
383
391
  else:
384
- self._cached = None
392
+ self._cached = _SENTINEL
385
393
  # Invoke cleanup for compute nodes (deps+fn) — spec §2.4
386
394
  if self._cleanup is not None:
387
395
  cb = self._cleanup
@@ -416,7 +424,16 @@ class NodeImpl[T]:
416
424
  cached_snapshot = self._cached
417
425
  else:
418
426
  cached_snapshot = self._cached
419
- unchanged = self._equals(cached_snapshot, value)
427
+ # §2.5: equals() only compares two real DATA values. _SENTINEL
428
+ # disambiguates "never emitted" from "emitted None" and
429
+ # "reset via INVALIDATE/reset_on_teardown".
430
+ try:
431
+ unchanged = cached_snapshot is not _SENTINEL and self._equals(cached_snapshot, value)
432
+ except Exception as eq_err:
433
+ wrapped = RuntimeError(f'Node "{self._name}": equals threw: {eq_err}')
434
+ wrapped.__cause__ = eq_err
435
+ self.down([(MessageType.ERROR, wrapped)], internal=True)
436
+ return
420
437
  if unchanged:
421
438
  if was_dirty:
422
439
  self.down([(MessageType.RESOLVED,)], internal=True)
@@ -470,7 +487,9 @@ class NodeImpl[T]:
470
487
  return
471
488
  self._emit_auto_value(out)
472
489
  except Exception as err:
473
- self.down([(MessageType.ERROR, err)], internal=True)
490
+ wrapped = RuntimeError(f'Node "{self._name}": fn threw: {err}')
491
+ wrapped.__cause__ = err
492
+ self.down([(MessageType.ERROR, wrapped)], internal=True)
474
493
 
475
494
  def _run_fn(self) -> None:
476
495
  if self._fn is None:
@@ -519,7 +538,9 @@ class NodeImpl[T]:
519
538
  if self._on_message(msg, index, self._actions):
520
539
  continue
521
540
  except Exception as err:
522
- self.down([(MessageType.ERROR, err)], internal=True)
541
+ wrapped = RuntimeError(f'Node "{self._name}": on_message threw: {err}')
542
+ wrapped.__cause__ = err
543
+ self.down([(MessageType.ERROR, wrapped)], internal=True)
523
544
  return
524
545
  if self._fn is None:
525
546
  if t is MessageType.COMPLETE and len(self._deps) > 1:
@@ -623,7 +644,15 @@ class NodeImpl[T]:
623
644
  ) -> None:
624
645
  if self._terminal and self._resubscribable:
625
646
  self._terminal = False
647
+ lock = self._cache_lock
648
+ if lock is not None:
649
+ with lock:
650
+ self._cached = _SENTINEL
651
+ else:
652
+ self._cached = _SENTINEL
626
653
  self._status = "disconnected" if self._has_deps else "settled"
654
+ if self._on_resubscribe is not None:
655
+ self._on_resubscribe()
627
656
 
628
657
  h = hints or SubscribeHints()
629
658
  self._sink_count += 1
@@ -726,8 +755,10 @@ class NodeImpl[T]:
726
755
  lock = self._cache_lock
727
756
  if lock is not None:
728
757
  with lock:
729
- return self._cached
730
- return self._cached
758
+ v = self._cached
759
+ else:
760
+ v = self._cached
761
+ return None if v is _SENTINEL else v
731
762
 
732
763
  @property
733
764
  def last_mutation(self) -> dict[str, Any] | None:
@@ -760,7 +791,7 @@ class NodeImpl[T]:
760
791
  self._hash_fn = hash_fn
761
792
  self._versioning = create_versioning(
762
793
  level,
763
- self._cached,
794
+ None if self._cached is _SENTINEL else self._cached,
764
795
  id=id,
765
796
  hash_fn=self._hash_fn,
766
797
  )
@@ -960,4 +991,4 @@ def node(
960
991
  # Public alias for type hints
961
992
  Node = NodeImpl
962
993
 
963
- __all__ = ["Node", "NodeActions", "NodeFn", "NodeImpl", "NodeStatus", "SubscribeHints", "node"]
994
+ __all__ = ["NO_VALUE", "Node", "NodeActions", "NodeFn", "NodeImpl", "NodeStatus", "SubscribeHints", "node"]
@@ -306,7 +306,7 @@ def emit_with_batch(
306
306
  messages: Messages,
307
307
  *,
308
308
  phase: int = 2,
309
- strategy: EmitStrategy = "sequential",
309
+ strategy: EmitStrategy = "partition",
310
310
  defer_when: DeferWhen = "batching",
311
311
  subgraph_lock: object | None = None,
312
312
  ) -> None:
@@ -428,11 +428,11 @@ def _emit_sequential(
428
428
 
429
429
  queue.append(_wrap_deferred_subgraph(_emit, subgraph_lock))
430
430
  elif kind in _TERMINAL_TYPES and _should_defer_phase2(bs, defer_when):
431
- # Terminal: defer so preceding deferred flushes first.
431
+ # Terminal: always route to phase-3 queue so all phase-2 drains first.
432
432
  def _emit_term(m: Message = msg, s: Callable[[Messages], None] = sink) -> None:
433
433
  s([m])
434
434
 
435
- queue.append(_wrap_deferred_subgraph(_emit_term, subgraph_lock))
435
+ bs.pending_phase3.append(_wrap_deferred_subgraph(_emit_term, subgraph_lock))
436
436
  else:
437
437
  sink([msg])
438
438
 
@@ -14,7 +14,10 @@ def state(initial: Any, **opts: Any) -> Node[Any]:
14
14
  """Create a manually-settable source node with a fixed initial value.
15
15
 
16
16
  Args:
17
- initial: The initial cached value for the node.
17
+ initial: The initial cached value for the node. Because ``initial`` is
18
+ provided, ``equals`` is called on the first ``down()`` emission — if
19
+ the value matches ``initial``, the node emits ``RESOLVED`` instead
20
+ of ``DATA`` (spec §2.5).
18
21
  **opts: Additional node options passed through to :func:`~graphrefly.core.node.node`.
19
22
 
20
23
  Returns:
@@ -0,0 +1,39 @@
1
+ """Resettable deadline timer — centralised primitive for timeout, retry,
2
+ and rate_limiter (§5.10 exception: these need cancel/restart;
3
+ from_timer creates a new Node per reset)."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import threading
8
+
9
+
10
+ class ResettableTimer:
11
+ """Thread-safe resettable deadline timer."""
12
+
13
+ __slots__ = ("_timer", "_lock")
14
+
15
+ def __init__(self) -> None:
16
+ self._timer: threading.Timer | None = None
17
+ self._lock = threading.Lock()
18
+
19
+ def start(self, delay_seconds: float, callback: callable) -> None:
20
+ """Schedule callback after delay_seconds. Cancels any pending timer."""
21
+ with self._lock:
22
+ if self._timer is not None:
23
+ self._timer.cancel()
24
+ self._timer = threading.Timer(delay_seconds, callback)
25
+ self._timer.daemon = True
26
+ self._timer.start()
27
+
28
+ def cancel(self) -> None:
29
+ """Cancel the pending timer (if any)."""
30
+ with self._lock:
31
+ if self._timer is not None:
32
+ self._timer.cancel()
33
+ self._timer = None
34
+
35
+ @property
36
+ def pending(self) -> bool:
37
+ """Whether a timer is currently pending."""
38
+ with self._lock:
39
+ return self._timer is not None