graphrefly 0.6.0__tar.gz → 0.8.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 (292) hide show
  1. {graphrefly-0.6.0 → graphrefly-0.8.0}/CHANGELOG.md +19 -0
  2. {graphrefly-0.6.0 → graphrefly-0.8.0}/PKG-INFO +1 -1
  3. {graphrefly-0.6.0 → graphrefly-0.8.0}/docs/optimizations.md +33 -18
  4. {graphrefly-0.6.0 → graphrefly-0.8.0}/docs/roadmap.md +6 -6
  5. {graphrefly-0.6.0 → graphrefly-0.8.0}/pyproject.toml +2 -1
  6. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/__init__.py +2 -0
  7. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/compat/trio_runner.py +1 -1
  8. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/__init__.py +4 -0
  9. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/dynamic_node.py +33 -12
  10. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/node.py +57 -16
  11. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/protocol.py +3 -3
  12. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/sugar.py +4 -1
  13. graphrefly-0.8.0/src/graphrefly/core/timer.py +43 -0
  14. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/adapters.py +116 -69
  15. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/cascading_cache.py +17 -10
  16. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/resilience.py +28 -49
  17. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/graph/graph.py +38 -10
  18. graphrefly-0.8.0/src/graphrefly/integrations/django.py +584 -0
  19. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/integrations/fastapi.py +45 -17
  20. graphrefly-0.8.0/src/graphrefly/patterns/__init__.py +13 -0
  21. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/ai.py +2 -2
  22. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/cqrs.py +29 -26
  23. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/reactive_layout/measurement_adapters.py +1 -1
  24. graphrefly-0.8.0/src/graphrefly/patterns/reduction.py +605 -0
  25. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_core.py +4 -1
  26. graphrefly-0.8.0/tests/test_django.py +436 -0
  27. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_resilience.py +12 -2
  28. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_sources.py +10 -6
  29. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_fastapi.py +5 -3
  30. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_graph.py +6 -6
  31. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_guard.py +4 -1
  32. graphrefly-0.8.0/tests/test_patterns_reduction.py +419 -0
  33. graphrefly-0.8.0/tests/test_reduction.py +421 -0
  34. graphrefly-0.6.0/src/graphrefly/patterns/__init__.py +0 -5
  35. {graphrefly-0.6.0 → graphrefly-0.8.0}/.claude/skills/dev-dispatch/SKILL.md +0 -0
  36. {graphrefly-0.6.0 → graphrefly-0.8.0}/.claude/skills/parity/SKILL.md +0 -0
  37. {graphrefly-0.6.0 → graphrefly-0.8.0}/.claude/skills/qa/SKILL.md +0 -0
  38. {graphrefly-0.6.0 → graphrefly-0.8.0}/.gemini/skills/dev-dispatch/SKILL.md +0 -0
  39. {graphrefly-0.6.0 → graphrefly-0.8.0}/.gemini/skills/parity/SKILL.md +0 -0
  40. {graphrefly-0.6.0 → graphrefly-0.8.0}/.github/workflows/pages.yml +0 -0
  41. {graphrefly-0.6.0 → graphrefly-0.8.0}/.github/workflows/release.yml +0 -0
  42. {graphrefly-0.6.0 → graphrefly-0.8.0}/.gitignore +0 -0
  43. {graphrefly-0.6.0 → graphrefly-0.8.0}/.mise.toml +0 -0
  44. {graphrefly-0.6.0 → graphrefly-0.8.0}/CLAUDE.md +0 -0
  45. {graphrefly-0.6.0 → graphrefly-0.8.0}/CONTRIBUTING.md +0 -0
  46. {graphrefly-0.6.0 → graphrefly-0.8.0}/GEMINI.md +0 -0
  47. {graphrefly-0.6.0 → graphrefly-0.8.0}/LICENSE +0 -0
  48. {graphrefly-0.6.0 → graphrefly-0.8.0}/README.md +0 -0
  49. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/DESIGN-ARCHIVE-INDEX.md +0 -0
  50. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-access-control-actor-guard.md +0 -0
  51. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-cross-repo-implementation-audit.md +0 -0
  52. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-demo-test-strategy.md +0 -0
  53. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-graphrefly-spec-design.md +0 -0
  54. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-serialization-memory-footprint.md +0 -0
  55. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-tier2-parity-nonlocal-forward-inner.md +0 -0
  56. {graphrefly-0.6.0 → graphrefly-0.8.0}/archive/docs/SESSION-universal-reduction-layer.md +0 -0
  57. {graphrefly-0.6.0 → graphrefly-0.8.0}/benchmarks/py-baseline.json +0 -0
  58. {graphrefly-0.6.0 → graphrefly-0.8.0}/docs/ADAPTER-CONTRACT.md +0 -0
  59. {graphrefly-0.6.0 → graphrefly-0.8.0}/docs/benchmark.md +0 -0
  60. {graphrefly-0.6.0 → graphrefly-0.8.0}/docs/docs-guidance.md +0 -0
  61. {graphrefly-0.6.0 → graphrefly-0.8.0}/docs/test-guidance.md +0 -0
  62. {graphrefly-0.6.0 → graphrefly-0.8.0}/examples/README.md +0 -0
  63. {graphrefly-0.6.0 → graphrefly-0.8.0}/examples/basic_counter.py +0 -0
  64. {graphrefly-0.6.0 → graphrefly-0.8.0}/llms.txt +0 -0
  65. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/compat/__init__.py +0 -0
  66. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/compat/async_utils.py +0 -0
  67. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/compat/asyncio_runner.py +0 -0
  68. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/cancellation.py +0 -0
  69. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/clock.py +0 -0
  70. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/guard.py +0 -0
  71. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/meta.py +0 -0
  72. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/runner.py +0 -0
  73. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/subgraph_locks.py +0 -0
  74. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/core/versioning.py +0 -0
  75. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/__init__.py +0 -0
  76. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/backoff.py +0 -0
  77. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/backpressure.py +0 -0
  78. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/checkpoint.py +0 -0
  79. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/composite.py +0 -0
  80. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/cron.py +0 -0
  81. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/data_structures.py +0 -0
  82. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/sources.py +0 -0
  83. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/tier1.py +0 -0
  84. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/extra/tier2.py +0 -0
  85. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/graph/__init__.py +0 -0
  86. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/integrations/__init__.py +0 -0
  87. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/memory.py +0 -0
  88. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/messaging.py +0 -0
  89. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/orchestration.py +0 -0
  90. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/reactive_layout/__init__.py +0 -0
  91. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/reactive_layout/reactive_block_layout.py +0 -0
  92. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/patterns/reactive_layout/reactive_layout.py +0 -0
  93. {graphrefly-0.6.0 → graphrefly-0.8.0}/src/graphrefly/py.typed +0 -0
  94. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/bench_core.py +0 -0
  95. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/conftest.py +0 -0
  96. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_adapter_contract.py +0 -0
  97. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_adapters_ingest.py +0 -0
  98. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_adapters_storage.py +0 -0
  99. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_backpressure.py +0 -0
  100. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_cascading_cache.py +0 -0
  101. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_concurrency.py +0 -0
  102. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_dynamic_node.py +0 -0
  103. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_edge_cases.py +0 -0
  104. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_composite.py +0 -0
  105. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_data_structures.py +0 -0
  106. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_sources_http.py +0 -0
  107. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_tier1.py +0 -0
  108. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_extra_tier2.py +0 -0
  109. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_measurement_adapters.py +0 -0
  110. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_patterns_ai.py +0 -0
  111. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_patterns_cqrs.py +0 -0
  112. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_patterns_memory.py +0 -0
  113. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_patterns_messaging.py +0 -0
  114. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_patterns_orchestration.py +0 -0
  115. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_perf_smoke.py +0 -0
  116. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_protocol.py +0 -0
  117. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_reactive_block_layout.py +0 -0
  118. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_reactive_layout.py +0 -0
  119. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_regressions.py +0 -0
  120. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_runner.py +0 -0
  121. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_smoke.py +0 -0
  122. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_sugar.py +0 -0
  123. {graphrefly-0.6.0 → graphrefly-0.8.0}/tests/test_versioning.py +0 -0
  124. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/.gitignore +0 -0
  125. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/README.md +0 -0
  126. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/astro.config.mjs +0 -0
  127. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/content.config.ts +0 -0
  128. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/package.json +0 -0
  129. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/pnpm-lock.yaml +0 -0
  130. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/public/llms.txt +0 -0
  131. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/py-api-sidebar.mjs +0 -0
  132. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/scripts/gen_api_docs.py +0 -0
  133. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/scripts/sync-docs.mjs +0 -0
  134. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/components/GraphreflyHero.astro +0 -0
  135. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/components/Header.astro +0 -0
  136. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/components/MobileMenuFooter.astro +0 -0
  137. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/components/PyodidePlayground.tsx +0 -0
  138. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/components/Sidebar.astro +0 -0
  139. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/components/SiteTitle.astro +0 -0
  140. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/BackoffPreset.md +0 -0
  141. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/BackoffStrategy.md +0 -0
  142. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/CheckpointAdapter.md +0 -0
  143. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/CircuitBreaker.md +0 -0
  144. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/CircuitOpenError.md +0 -0
  145. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/DeferWhen.md +0 -0
  146. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/DictCheckpointAdapter.md +0 -0
  147. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/DistillBundle.md +0 -0
  148. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/EmitStrategy.md +0 -0
  149. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/Extraction.md +0 -0
  150. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/FileCheckpointAdapter.md +0 -0
  151. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/HttpBundle.md +0 -0
  152. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/JitterMode.md +0 -0
  153. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/MemoryCheckpointAdapter.md +0 -0
  154. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/Message.md +0 -0
  155. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/MessageType.md +0 -0
  156. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/Messages.md +0 -0
  157. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/NodeActions.md +0 -0
  158. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/NodeFn.md +0 -0
  159. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/NodeImpl.md +0 -0
  160. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/NodeStatus.md +0 -0
  161. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/PipeOperator.md +0 -0
  162. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/PubSubHub.md +0 -0
  163. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/ReactiveIndexBundle.md +0 -0
  164. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/ReactiveListBundle.md +0 -0
  165. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/ReactiveLogBundle.md +0 -0
  166. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/ReactiveMapBundle.md +0 -0
  167. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/SqliteCheckpointAdapter.md +0 -0
  168. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/SubscribeHints.md +0 -0
  169. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/TokenBucket.md +0 -0
  170. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/VerifiableBundle.md +0 -0
  171. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/Versioned.md +0 -0
  172. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/WithBreakerBundle.md +0 -0
  173. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/WithStatusBundle.md +0 -0
  174. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/audit.md +0 -0
  175. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/batch.md +0 -0
  176. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/buffer.md +0 -0
  177. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/buffer_count.md +0 -0
  178. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/buffer_time.md +0 -0
  179. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/cached.md +0 -0
  180. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/checkpoint_node_value.md +0 -0
  181. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/circuit_breaker.md +0 -0
  182. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/combine.md +0 -0
  183. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/concat.md +0 -0
  184. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/concat_map.md +0 -0
  185. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/constant.md +0 -0
  186. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/debounce.md +0 -0
  187. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/decorrelated_jitter.md +0 -0
  188. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/delay.md +0 -0
  189. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/derived.md +0 -0
  190. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/dispatch_messages.md +0 -0
  191. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/distill.md +0 -0
  192. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/distinct_until_changed.md +0 -0
  193. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/effect.md +0 -0
  194. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/element_at.md +0 -0
  195. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/emit_with_batch.md +0 -0
  196. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/empty.md +0 -0
  197. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/exhaust_map.md +0 -0
  198. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/exponential.md +0 -0
  199. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/fibonacci.md +0 -0
  200. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/filter.md +0 -0
  201. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/find.md +0 -0
  202. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/first.md +0 -0
  203. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/first_value_from.md +0 -0
  204. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/flat_map.md +0 -0
  205. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/for_each.md +0 -0
  206. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_any.md +0 -0
  207. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_async_iter.md +0 -0
  208. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_awaitable.md +0 -0
  209. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_cron.md +0 -0
  210. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_event_emitter.md +0 -0
  211. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_fs_watch.md +0 -0
  212. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_git_hook.md +0 -0
  213. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_http.md +0 -0
  214. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_iter.md +0 -0
  215. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_mcp.md +0 -0
  216. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_timer.md +0 -0
  217. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_webhook.md +0 -0
  218. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/from_websocket.md +0 -0
  219. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/gate.md +0 -0
  220. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/index.md +0 -0
  221. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/interval.md +0 -0
  222. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/is_batching.md +0 -0
  223. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/is_phase2_message.md +0 -0
  224. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/is_terminal_message.md +0 -0
  225. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/last.md +0 -0
  226. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/linear.md +0 -0
  227. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/log_slice.md +0 -0
  228. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/map.md +0 -0
  229. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/merge.md +0 -0
  230. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/message_tier.md +0 -0
  231. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/never.md +0 -0
  232. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/node.md +0 -0
  233. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/of.md +0 -0
  234. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/operator.md +0 -0
  235. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/pairwise.md +0 -0
  236. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/partition_for_batch.md +0 -0
  237. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/pausable.md +0 -0
  238. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/pipe.md +0 -0
  239. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/producer.md +0 -0
  240. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/propagates_to_meta.md +0 -0
  241. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/pubsub.md +0 -0
  242. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/race.md +0 -0
  243. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/rate_limiter.md +0 -0
  244. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/reactive_index.md +0 -0
  245. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/reactive_list.md +0 -0
  246. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/reactive_log.md +0 -0
  247. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/reactive_map.md +0 -0
  248. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/reduce.md +0 -0
  249. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/repeat.md +0 -0
  250. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/replay.md +0 -0
  251. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/rescue.md +0 -0
  252. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/resolve_backoff_preset.md +0 -0
  253. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/restore_graph_checkpoint.md +0 -0
  254. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/retry.md +0 -0
  255. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/sample.md +0 -0
  256. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/save_graph_checkpoint.md +0 -0
  257. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/scan.md +0 -0
  258. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/share.md +0 -0
  259. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/skip.md +0 -0
  260. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/start_with.md +0 -0
  261. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/state.md +0 -0
  262. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/subscribe.md +0 -0
  263. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/switch_map.md +0 -0
  264. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/take.md +0 -0
  265. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/take_until.md +0 -0
  266. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/take_while.md +0 -0
  267. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/tap.md +0 -0
  268. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/throttle.md +0 -0
  269. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/throw_error.md +0 -0
  270. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/timeout.md +0 -0
  271. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/to_array.md +0 -0
  272. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/to_list.md +0 -0
  273. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/to_sse.md +0 -0
  274. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/to_websocket.md +0 -0
  275. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/token_bucket.md +0 -0
  276. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/token_tracker.md +0 -0
  277. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/verifiable.md +0 -0
  278. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/window.md +0 -0
  279. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/window_count.md +0 -0
  280. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/window_time.md +0 -0
  281. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/with_breaker.md +0 -0
  282. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/with_latest_from.md +0 -0
  283. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/with_max_attempts.md +0 -0
  284. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/with_status.md +0 -0
  285. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/api/zip.md +0 -0
  286. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/index.mdx +0 -0
  287. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content/docs/lab/python.mdx +0 -0
  288. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/content.config.ts +0 -0
  289. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/env.d.ts +0 -0
  290. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/src/styles/custom.css +0 -0
  291. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/theme-prototypes.html +0 -0
  292. {graphrefly-0.6.0 → graphrefly-0.8.0}/website/tsconfig.json +0 -0
@@ -2,6 +2,25 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.8.0 (2026-04-06)
6
+
7
+ ### Features
8
+
9
+ - 8.1 + django integration
10
+ ([`5dcfd3d`](https://github.com/graphrefly/graphrefly-py/commit/5dcfd3d78561e7ae8cdc905f95f9b4922926881e))
11
+
12
+
13
+ ## v0.7.0 (2026-04-06)
14
+
15
+ ### Features
16
+
17
+ - Central timer util
18
+ ([`d1285d5`](https://github.com/graphrefly/graphrefly-py/commit/d1285d5c4c95928b1c27e10ca77204ee0f8aaa5e))
19
+
20
+ - Redesign initial + cached
21
+ ([`86d8506`](https://github.com/graphrefly/graphrefly-py/commit/86d850623e4e09fda1e8547ab67ca6e4cefb2a48))
22
+
23
+
5
24
  ## v0.6.0 (2026-04-05)
6
25
 
7
26
  ### Chores
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphrefly
3
- Version: 0.6.0
3
+ Version: 0.8.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,39 @@ 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.
142
+ - **Reduction primitives cross-language parity (Phase 8.1, noted 2026-04-06, QA 2026-04-06):** Both TS and PY implement `stratify`, `funnel`, `feedback`, `budget_gate`/`budgetGate`, and `scorer` in `patterns/reduction`. All five follow the orchestration factory pattern (`_base_meta`, `_register_step`). Key alignment: (1) `stratify` buffers DIRTY until DATA arrives — on classifier miss, emits `[DIRTY, RESOLVED]` to preserve spec §1.3.1 (both). (2) `funnel` bridges stages via `subscribe` forwarding DIRTY/DATA/RESOLVED/COMPLETE/ERROR to preserve two-phase protocol. TODO(8.2): replace with graph-visible bridge nodes. (3) `feedback` counter node is source of truth (resettable via `graph.set()`); uses `continue` (not `return`) on max_iterations so remaining batch messages process. Counter name is `__feedback_<condition>` to support multiple loops per graph. (4) `budget_gate`/`budgetGate` force-flushes all buffered items on terminal regardless of budget; sends RESUME before terminal if paused; forwards constraint ERROR downstream, silences constraint COMPLETE, forwards unknown constraint types via default. (5) `scorer` coerces `None`/`undefined` to 0 before multiplication (no TypeError/NaN divergence). TS `ScoredItem` is a plain object; PY `ScoredItem` is a class with `__slots__` and `__eq__`. Meta keys: `reduction: True`, `reduction_type: "<name>"`. Both repos: 22 tests each.
137
143
 
138
144
  ---
139
145
 
146
+ ## Cross-language parity fixes (2026-04-05)
147
+
148
+ - **~~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 }`.
149
+ - **~~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.
150
+ - **~~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`.
151
+ - **~~PY `emit_with_batch` default strategy (resolved 2026-04-05):~~** PY defaulted to `"sequential"`, TS to `"partition"`. Fixed: PY default changed to `"partition"`.
152
+ - **~~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`.
153
+ - **~~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,)])`.
154
+
140
155
  ## Cross-language implementation notes
141
156
 
142
157
  **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
158
 
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.
159
+ - **~~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
160
 
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.
161
+ - **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
162
  - **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
163
  - **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
164
  - **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 +327,14 @@ Cross-language: `graphrefly-ts/docs/optimizations.md` §15. **Python (shipped):*
312
327
  | **`snapshot()`** | Both: `{ version: 1, ...describe() }` — flat `version` field, sorted `nodes` keys. |
313
328
  | **`restore(data)`** | Both: validate `data.name` matches graph name; skip `derived`/`operator`/`effect` types; silently ignore unknown/failing paths. |
314
329
  | **`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. |
330
+ | **`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. |
331
+ | **`toJSONString()`** | TS only — `JSON.stringify(toJSON()) + "\n"`. Python's `to_json_string()` serves the same role. |
317
332
 
318
333
  **Intentional divergence:**
319
334
 
320
335
  | Topic | Python | TypeScript | Rationale |
321
336
  |-------|--------|------------|-----------|
322
- | `to_json` return type | `to_json()` → `str` (no universal `__json__` hook in Python) | `toJSON()` → plain object (ECMAScript `JSON.stringify` protocol) | Language idiom |
337
+ | `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
338
  | `_parse_snapshot_envelope` | Validates `version`, `name`, `nodes`, `edges`, `subgraphs` types | Only validates `data.name` match | Python is stricter; both correct |
324
339
 
325
340
  ### Ingest adapters (roadmap 5.2c / 5.3b) — deferred items (QA)
@@ -330,7 +345,7 @@ Applies to `src/extra/adapters.ts` and `graphrefly.extra.adapters`. **Keep the t
330
345
  |------|--------|-------|
331
346
  | **`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
347
  | **`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. |
348
+ | **PY `from_csv` / `from_ndjson` thread cleanup** | Resolved (2026-04-05) | `t.join(timeout=1)` added after stop flag. |
334
349
 
335
350
  ### Ingest adapters — intentional cross-language divergences (parity review 2026-04-03)
336
351
 
@@ -377,10 +392,10 @@ Applies to `src/extra/adapters.ts` and `graphrefly.extra.adapters`. **Keep the t
377
392
  | Node internals | Class-based `NodeImpl`, all methods on class | Class-based `NodeImpl`, V8 hidden class optimization, prototype methods |
378
393
  | Dep-value identity check | Before cleanup (skip cleanup+fn on no-op) | Before cleanup (skip cleanup+fn on no-op) |
379
394
  | `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 |
395
+ | `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
396
  | `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
397
  | `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 |
398
+ | `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
399
  | `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
400
  | `policy()` semantics | Deny-overrides: any matching deny blocks; if no deny, any matching allow permits; no match → deny | Same (aligned from parity round) |
386
401
  | `DEFAULT_ACTOR` | `{"type": "system", "id": ""}` | `{ type: "system", id: "" }` (aligned) |
@@ -399,7 +414,7 @@ Applies to `src/extra/adapters.ts` and `graphrefly.extra.adapters`. **Keep the t
399
414
  | `from_event_emitter` / `fromEvent` | Generic emitter (`add_method=`, `remove_method=`) | DOM `addEventListener` API |
400
415
  | `to_array` / `toArray` | Reactive `Node[list]` | Reactive `Node<T[]>` |
401
416
  | `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 |
417
+ | 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
418
  | Extra Phase 3.2 (data structures) | `graphrefly.extra.data_structures` (`reactive_map`, …); see §17 | `reactiveMap` + `reactive-base` (`Versioned` snapshots); see §17 |
404
419
 
405
420
  ### 18. CQRS reactive log snapshot shape (Phase 4.5 — cross-language note)
@@ -535,7 +550,7 @@ Both repos now ship a Pulsar-inspired messaging domain layer under `patterns.mes
535
550
 
536
551
  | Topic | Python | TypeScript | Rationale |
537
552
  |-------|--------|------------|-----------|
538
- | Timer base | `monotonic_ns()` (nanoseconds via `time.monotonic_ns()`) | `monotonicNs()` (nanoseconds via `performance.now()`) | Both centralised in `core/clock`; nanosecond internal tracking |
553
+ | 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
554
  | Thread safety | `CircuitBreaker` + `TokenBucket` use `threading.Lock`; retry uses `threading.Timer` | Single-threaded (`setTimeout`) | Spec §6.1 |
540
555
  | `CircuitBreaker` params | `cooldown` (seconds, implicit) | `cooldownSeconds` (seconds, explicit) | Naming convention |
541
556
  | `CircuitOpenError` base | `RuntimeError` | `Error` | Language convention |
@@ -293,7 +293,7 @@ Composition layer over 3.2 (`reactive_log`), 4.1 (sagas), 4.2 (event bus), 4.3 (
293
293
  ### 5.1 — Framework compat
294
294
 
295
295
  - [x] FastAPI integration
296
- - [ ] Django integration
296
+ - [x] Django integration
297
297
  - [x] asyncio / trio Runner protocol
298
298
  - [x] Async utilities: `to_async_iter`, `first_value_from_async`, `settled`
299
299
 
@@ -487,11 +487,11 @@ Reusable patterns for taking heterogeneous massive inputs and producing prioriti
487
487
 
488
488
  Composable building blocks between sources and sinks.
489
489
 
490
- - [ ] `stratify(source, rules)` → Graph — route input to different reduction branches based on classifier fn. Each branch gets independent operator chains. Rules are reactive — an LLM can rewrite them at runtime.
491
- - [ ] `funnel(sources, stages)` → Graph — multi-source merge with sequential reduction stages. Each stage is a named subgraph. Stages are pluggable — swap a stage by graph composition.
492
- - [ ] `feedback(graph, condition, reentry)` → Graph — introduce a cycle: when condition node fires, route output back to reentry point. Bounded by max iterations + budget constraints.
493
- - [ ] `budget_gate(source, constraints)` → Node — pass-through respecting reactive constraint nodes (token budget, network IO, cost ceiling). Backpressure via PAUSE/RESUME.
494
- - [ ] `scorer(sources, weights)` → Node — reactive multi-signal scoring. Weights are nodes (LLM or human can adjust live). Output: sorted, prioritized items with full score breakdown in meta.
490
+ - [x] `stratify(source, rules)` → Graph — route input to different reduction branches based on classifier fn. Each branch gets independent operator chains. Rules are reactive — an LLM can rewrite them at runtime.
491
+ - [x] `funnel(sources, stages)` → Graph — multi-source merge with sequential reduction stages. Each stage is a named subgraph. Stages are pluggable — swap a stage by graph composition.
492
+ - [x] `feedback(graph, condition, reentry)` → Graph — introduce a cycle: when condition node fires, route output back to reentry point. Bounded by max iterations + budget constraints.
493
+ - [x] `budget_gate(source, constraints)` → Node — pass-through respecting reactive constraint nodes (token budget, network IO, cost ceiling). Backpressure via PAUSE/RESUME.
494
+ - [x] `scorer(sources, weights)` → Node — reactive multi-signal scoring. Weights are nodes (LLM or human can adjust live). Output: sorted, prioritized items with full score breakdown in meta.
495
495
 
496
496
  ### 8.2 — Domain templates (opinionated Graph factories)
497
497
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphrefly"
3
- version = "0.6.0"
3
+ version = "0.8.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"
@@ -60,6 +60,7 @@ dev = [
60
60
  "ruff>=0.9",
61
61
  "mypy>=1.14",
62
62
  "croniter>=2.0",
63
+ "django>=4.2",
63
64
  "fastapi>=0.100",
64
65
  "httpx>=0.24",
65
66
  "pillow>=12.2.0",
@@ -2,6 +2,7 @@
2
2
 
3
3
  from graphrefly import compat, integrations, patterns
4
4
  from graphrefly.core import (
5
+ NO_VALUE,
5
6
  V0,
6
7
  V1,
7
8
  Actor,
@@ -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",
@@ -24,7 +24,7 @@ from typing import TYPE_CHECKING, Any
24
24
  if TYPE_CHECKING:
25
25
  from collections.abc import Callable, Coroutine
26
26
 
27
- import trio # type: ignore[import-not-found]
27
+ import trio
28
28
 
29
29
 
30
30
  class TrioRunner:
@@ -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",
@@ -13,8 +13,12 @@ import threading
13
13
  from collections.abc import Callable, Mapping
14
14
  from contextlib import suppress
15
15
  from types import MappingProxyType
16
- from typing import Any
16
+ from typing import TYPE_CHECKING, Any
17
17
 
18
+ if TYPE_CHECKING:
19
+ from graphrefly.core.guard import MutationRecord
20
+
21
+ from graphrefly.core.node import _SENTINEL
18
22
  from graphrefly.core.protocol import Messages, MessageType, emit_with_batch, propagates_to_meta
19
23
 
20
24
  # ---------------------------------------------------------------------------
@@ -183,13 +187,13 @@ class DynamicNodeImpl[T]:
183
187
  self._on_resubscribe = on_resubscribe
184
188
  self._auto_complete = complete_when_deps_complete
185
189
  self._describe_kind = describe_kind
186
- self._last_mutation: dict[str, Any] | None = None
190
+ self._last_mutation: MutationRecord | None = None
187
191
  self._resubscribable = resubscribable
188
192
  self._reset_on_teardown = reset_on_teardown
189
193
  self._thread_safe = bool(thread_safe)
190
194
  self._inspector_hook: Callable[[dict[str, Any]], None] | None = None
191
195
 
192
- self._cached: T | None = None
196
+ self._cached: Any = _SENTINEL
193
197
  self._status: str = "disconnected"
194
198
  self._terminal = False
195
199
  self._connected = False
@@ -254,7 +258,7 @@ class DynamicNodeImpl[T]:
254
258
  return self._meta
255
259
 
256
260
  @property
257
- def last_mutation(self) -> dict[str, Any] | None:
261
+ def last_mutation(self) -> MutationRecord | None:
258
262
  return self._last_mutation
259
263
 
260
264
  @property
@@ -277,8 +281,10 @@ class DynamicNodeImpl[T]:
277
281
  lock = self._cache_lock
278
282
  if lock is not None:
279
283
  with lock:
280
- return self._cached
281
- return self._cached
284
+ v = self._cached
285
+ else:
286
+ v = self._cached
287
+ return None if v is _SENTINEL else v
282
288
 
283
289
  def down(
284
290
  self,
@@ -346,6 +352,12 @@ class DynamicNodeImpl[T]:
346
352
  ) -> Callable[[], None]:
347
353
  if self._terminal and self._resubscribable:
348
354
  self._terminal = False
355
+ lock = self._cache_lock
356
+ if lock is not None:
357
+ with lock:
358
+ self._cached = _SENTINEL
359
+ else:
360
+ self._cached = _SENTINEL
349
361
  self._status = "disconnected"
350
362
  if self._on_resubscribe is not None:
351
363
  self._on_resubscribe()
@@ -519,11 +531,14 @@ class DynamicNodeImpl[T]:
519
531
  if t is MessageType.INVALIDATE:
520
532
  if lock is not None:
521
533
  with lock:
522
- self._cached = None
534
+ self._cached = _SENTINEL
523
535
  else:
524
- self._cached = None
525
- if t is MessageType.DATA or t is MessageType.RESOLVED:
536
+ self._cached = _SENTINEL
537
+ self._status = "dirty"
538
+ if t is MessageType.DATA:
526
539
  self._status = "settled"
540
+ elif t is MessageType.RESOLVED:
541
+ self._status = "resolved"
527
542
  elif t is MessageType.DIRTY:
528
543
  self._status = "dirty"
529
544
  elif t is MessageType.COMPLETE:
@@ -536,9 +551,9 @@ class DynamicNodeImpl[T]:
536
551
  if self._reset_on_teardown:
537
552
  if lock is not None:
538
553
  with lock:
539
- self._cached = None
554
+ self._cached = _SENTINEL
540
555
  else:
541
- self._cached = None
556
+ self._cached = _SENTINEL
542
557
  try:
543
558
  self._propagate_to_meta(t)
544
559
  finally:
@@ -561,7 +576,13 @@ class DynamicNodeImpl[T]:
561
576
  cached_snapshot = self._cached
562
577
  else:
563
578
  cached_snapshot = self._cached
564
- unchanged = self._equals(cached_snapshot, value)
579
+ try:
580
+ unchanged = cached_snapshot is not _SENTINEL and self._equals(cached_snapshot, value)
581
+ except Exception as eq_err:
582
+ wrapped = RuntimeError(f'Node "{self._name}": equals threw: {eq_err}')
583
+ wrapped.__cause__ = eq_err
584
+ self._down_internal([(MessageType.ERROR, wrapped)])
585
+ return
565
586
  if unchanged:
566
587
  msgs: Messages = (
567
588
  [(MessageType.RESOLVED,)]
@@ -14,6 +14,7 @@ from graphrefly.core.guard import (
14
14
  Actor,
15
15
  GuardAction,
16
16
  GuardDenied,
17
+ MutationRecord,
17
18
  normalize_actor,
18
19
  record_mutation,
19
20
  )
@@ -32,6 +33,12 @@ from graphrefly.core.versioning import (
32
33
  default_hash,
33
34
  )
34
35
 
36
+ # Internal sentinel: "no cached value has been set or emitted."
37
+ # Distinct from None so that None can be a valid emitted value.
38
+ _SENTINEL = object()
39
+
40
+ NO_VALUE = _SENTINEL
41
+
35
42
  # --- Status & typing (graphrefly-ts node.ts) ---------------------------------
36
43
 
37
44
  type NodeStatus = str # structural: same strings as TS NodeStatus
@@ -203,6 +210,7 @@ class NodeImpl[T]:
203
210
  "_meta",
204
211
  "_name",
205
212
  "_on_message",
213
+ "_on_resubscribe",
206
214
  "_opts",
207
215
  "_producer_started",
208
216
  "_resubscribable",
@@ -236,6 +244,7 @@ class NodeImpl[T]:
236
244
  self._thread_safe: bool = bool(opts.get("thread_safe", True))
237
245
 
238
246
  self._on_message = opts.get("on_message")
247
+ self._on_resubscribe: Callable[[], None] | None = opts.get("on_resubscribe")
239
248
  self._fn = fn
240
249
  self._deps = deps
241
250
  self._has_deps = len(deps) > 0
@@ -245,10 +254,10 @@ class NodeImpl[T]:
245
254
  msg = "node option 'guard' must be callable or None"
246
255
  raise TypeError(msg)
247
256
  self._guard: Callable[[Actor, GuardAction], bool] | None = raw_guard
248
- self._last_mutation: dict[str, Any] | None = None
257
+ self._last_mutation: MutationRecord | None = None
249
258
 
250
259
  self._cache_lock = threading.Lock() if self._thread_safe else None
251
- self._cached: T | None = opts.get("initial")
260
+ self._cached: Any = opts.get("initial", _SENTINEL)
252
261
  self._status: NodeStatus = "disconnected" if self._has_deps else "settled"
253
262
 
254
263
  # Versioning (GRAPHREFLY-SPEC §7)
@@ -257,7 +266,7 @@ class NodeImpl[T]:
257
266
  self._versioning: NodeVersionInfo | None = (
258
267
  create_versioning(
259
268
  versioning_level,
260
- self._cached,
269
+ None if self._cached is _SENTINEL else self._cached,
261
270
  id=opts.get("versioning_id"),
262
271
  hash_fn=self._hash_fn,
263
272
  )
@@ -359,7 +368,7 @@ class NodeImpl[T]:
359
368
  else:
360
369
  self._cached = m[1] # type: ignore[misc]
361
370
  if self._versioning is not None:
362
- advance_version(self._versioning, m[1], self._hash_fn)
371
+ advance_version(self._versioning, m[1], self._hash_fn) # type: ignore[misc]
363
372
  if t is MessageType.INVALIDATE:
364
373
  # GRAPHREFLY-SPEC §1.2: clear cached state; do not auto-emit from here.
365
374
  if self._cleanup is not None:
@@ -368,9 +377,9 @@ class NodeImpl[T]:
368
377
  cb()
369
378
  if lock is not None:
370
379
  with lock:
371
- self._cached = None
380
+ self._cached = _SENTINEL
372
381
  else:
373
- self._cached = None
382
+ self._cached = _SENTINEL
374
383
  self._last_dep_values = None
375
384
  self._status = _status_after_message(self._status, m)
376
385
  if t is MessageType.COMPLETE or t is MessageType.ERROR:
@@ -379,9 +388,9 @@ class NodeImpl[T]:
379
388
  if self._reset_on_teardown:
380
389
  if lock is not None:
381
390
  with lock:
382
- self._cached = None
391
+ self._cached = _SENTINEL
383
392
  else:
384
- self._cached = None
393
+ self._cached = _SENTINEL
385
394
  # Invoke cleanup for compute nodes (deps+fn) — spec §2.4
386
395
  if self._cleanup is not None:
387
396
  cb = self._cleanup
@@ -416,7 +425,16 @@ class NodeImpl[T]:
416
425
  cached_snapshot = self._cached
417
426
  else:
418
427
  cached_snapshot = self._cached
419
- unchanged = self._equals(cached_snapshot, value)
428
+ # §2.5: equals() only compares two real DATA values. _SENTINEL
429
+ # disambiguates "never emitted" from "emitted None" and
430
+ # "reset via INVALIDATE/reset_on_teardown".
431
+ try:
432
+ unchanged = cached_snapshot is not _SENTINEL and self._equals(cached_snapshot, value)
433
+ except Exception as eq_err:
434
+ wrapped = RuntimeError(f'Node "{self._name}": equals threw: {eq_err}')
435
+ wrapped.__cause__ = eq_err
436
+ self.down([(MessageType.ERROR, wrapped)], internal=True)
437
+ return
420
438
  if unchanged:
421
439
  if was_dirty:
422
440
  self.down([(MessageType.RESOLVED,)], internal=True)
@@ -470,7 +488,9 @@ class NodeImpl[T]:
470
488
  return
471
489
  self._emit_auto_value(out)
472
490
  except Exception as err:
473
- self.down([(MessageType.ERROR, err)], internal=True)
491
+ wrapped = RuntimeError(f'Node "{self._name}": fn threw: {err}')
492
+ wrapped.__cause__ = err
493
+ self.down([(MessageType.ERROR, wrapped)], internal=True)
474
494
 
475
495
  def _run_fn(self) -> None:
476
496
  if self._fn is None:
@@ -519,7 +539,9 @@ class NodeImpl[T]:
519
539
  if self._on_message(msg, index, self._actions):
520
540
  continue
521
541
  except Exception as err:
522
- self.down([(MessageType.ERROR, err)], internal=True)
542
+ wrapped = RuntimeError(f'Node "{self._name}": on_message threw: {err}')
543
+ wrapped.__cause__ = err
544
+ self.down([(MessageType.ERROR, wrapped)], internal=True)
523
545
  return
524
546
  if self._fn is None:
525
547
  if t is MessageType.COMPLETE and len(self._deps) > 1:
@@ -623,7 +645,15 @@ class NodeImpl[T]:
623
645
  ) -> None:
624
646
  if self._terminal and self._resubscribable:
625
647
  self._terminal = False
648
+ lock = self._cache_lock
649
+ if lock is not None:
650
+ with lock:
651
+ self._cached = _SENTINEL
652
+ else:
653
+ self._cached = _SENTINEL
626
654
  self._status = "disconnected" if self._has_deps else "settled"
655
+ if self._on_resubscribe is not None:
656
+ self._on_resubscribe()
627
657
 
628
658
  h = hints or SubscribeHints()
629
659
  self._sink_count += 1
@@ -726,11 +756,13 @@ class NodeImpl[T]:
726
756
  lock = self._cache_lock
727
757
  if lock is not None:
728
758
  with lock:
729
- return self._cached
730
- return self._cached
759
+ v = self._cached
760
+ else:
761
+ v = self._cached
762
+ return None if v is _SENTINEL else v
731
763
 
732
764
  @property
733
- def last_mutation(self) -> dict[str, Any] | None:
765
+ def last_mutation(self) -> MutationRecord | None:
734
766
  """Last non-internal ``write`` attribution (``actor``, ``timestamp_ns``), if any."""
735
767
  return self._last_mutation
736
768
 
@@ -760,7 +792,7 @@ class NodeImpl[T]:
760
792
  self._hash_fn = hash_fn
761
793
  self._versioning = create_versioning(
762
794
  level,
763
- self._cached,
795
+ None if self._cached is _SENTINEL else self._cached,
764
796
  id=id,
765
797
  hash_fn=self._hash_fn,
766
798
  )
@@ -960,4 +992,13 @@ def node(
960
992
  # Public alias for type hints
961
993
  Node = NodeImpl
962
994
 
963
- __all__ = ["Node", "NodeActions", "NodeFn", "NodeImpl", "NodeStatus", "SubscribeHints", "node"]
995
+ __all__ = [
996
+ "NO_VALUE",
997
+ "Node",
998
+ "NodeActions",
999
+ "NodeFn",
1000
+ "NodeImpl",
1001
+ "NodeStatus",
1002
+ "SubscribeHints",
1003
+ "node",
1004
+ ]
@@ -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: