activegraph 1.2.0__tar.gz → 1.4.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 (187) hide show
  1. {activegraph-1.2.0/activegraph.egg-info → activegraph-1.4.0}/PKG-INFO +1 -1
  2. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/__init__.py +13 -1
  3. activegraph-1.4.0/activegraph/_signature.py +222 -0
  4. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/behaviors/base.py +35 -6
  5. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/behaviors/decorators.py +43 -8
  6. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/cli/main.py +121 -2
  7. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/clock.py +21 -4
  8. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/event.py +10 -0
  9. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/graph.py +52 -13
  10. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/ids.py +9 -1
  11. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/patch.py +11 -0
  12. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/errors.py +22 -3
  13. activegraph-1.4.0/activegraph/frame.py +24 -0
  14. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/__init__.py +8 -0
  15. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/anthropic.py +86 -13
  16. activegraph-1.4.0/activegraph/llm/embedding.py +94 -0
  17. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/errors.py +38 -0
  18. activegraph-1.4.0/activegraph/llm/native.py +173 -0
  19. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/openai.py +114 -20
  20. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/prompt.py +24 -2
  21. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/provider.py +16 -0
  22. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/recorded.py +46 -2
  23. activegraph-1.4.0/activegraph/llm/wire.py +140 -0
  24. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/logging.py +4 -4
  25. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/migration.py +26 -7
  26. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/otel.py +2 -2
  27. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/prometheus.py +4 -4
  28. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/status.py +11 -1
  29. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/__init__.py +148 -26
  30. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/settings.py +1 -1
  31. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/loader.py +21 -0
  32. activegraph-1.4.0/activegraph/packs/manifest.py +593 -0
  33. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/policy.py +11 -0
  34. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/diff.py +32 -4
  35. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/exec_errors.py +117 -0
  36. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/patterns.py +20 -10
  37. activegraph-1.4.0/activegraph/runtime/promote.py +411 -0
  38. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/registration_errors.py +4 -1
  39. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/runtime.py +841 -60
  40. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/base.py +23 -3
  41. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/falkordb.py +2 -2
  42. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/postgres.py +3 -3
  43. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/serde.py +2 -1
  44. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/sqlite.py +10 -3
  45. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/url.py +8 -1
  46. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/base.py +14 -2
  47. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/context.py +11 -0
  48. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/decorators.py +24 -3
  49. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/trace/printer.py +85 -0
  50. {activegraph-1.2.0 → activegraph-1.4.0/activegraph.egg-info}/PKG-INFO +1 -1
  51. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph.egg-info/SOURCES.txt +14 -0
  52. {activegraph-1.2.0 → activegraph-1.4.0}/pyproject.toml +45 -6
  53. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_cli.py +118 -0
  54. activegraph-1.4.0/tests/test_disable_pack.py +155 -0
  55. activegraph-1.4.0/tests/test_embedding_provider.py +97 -0
  56. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_anthropic.py +100 -0
  57. activegraph-1.4.0/tests/test_llm_native_structured_output.py +466 -0
  58. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_openai.py +183 -4
  59. activegraph-1.4.0/tests/test_llm_wire.py +186 -0
  60. activegraph-1.4.0/tests/test_pack_manifest.py +408 -0
  61. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_persistence.py +75 -0
  62. activegraph-1.4.0/tests/test_promote.py +821 -0
  63. activegraph-1.4.0/tests/test_registration_validation.py +301 -0
  64. activegraph-1.4.0/tests/test_trace_accessors.py +75 -0
  65. activegraph-1.2.0/activegraph/frame.py +0 -15
  66. {activegraph-1.2.0 → activegraph-1.4.0}/LICENSE +0 -0
  67. {activegraph-1.2.0 → activegraph-1.4.0}/NOTICE +0 -0
  68. {activegraph-1.2.0 → activegraph-1.4.0}/README.md +0 -0
  69. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/__main__.py +0 -0
  70. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/behaviors/__init__.py +0 -0
  71. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/cli/__init__.py +0 -0
  72. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/cli/quickstart.py +0 -0
  73. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/cli/renderers.py +0 -0
  74. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/__init__.py +0 -0
  75. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/graph_store.py +0 -0
  76. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/core/view.py +0 -0
  77. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/cache.py +0 -0
  78. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/parsing.py +0 -0
  79. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/llm/types.py +0 -0
  80. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/__init__.py +0 -0
  81. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/observability/metrics.py +0 -0
  82. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/__init__.py +0 -0
  83. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/behaviors.py +0 -0
  84. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
  85. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/fixtures/companies.py +0 -0
  86. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/object_types.py +0 -0
  87. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
  88. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
  89. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
  90. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
  91. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/diligence/tools.py +0 -0
  92. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/packs/scaffold.py +0 -0
  93. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/__init__.py +0 -0
  94. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/_live.py +0 -0
  95. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/behavior_graph.py +0 -0
  96. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/budget.py +0 -0
  97. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/config_errors.py +0 -0
  98. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/errors.py +0 -0
  99. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/queue.py +0 -0
  100. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/registry.py +0 -0
  101. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/scheduler.py +0 -0
  102. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/runtime/view_builder.py +0 -0
  103. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/__init__.py +0 -0
  104. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/conformance.py +0 -0
  105. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/errors.py +0 -0
  106. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/graph_conformance.py +0 -0
  107. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/store/memory.py +0 -0
  108. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/__init__.py +0 -0
  109. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/cache.py +0 -0
  110. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/errors.py +0 -0
  111. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/graph_query.py +0 -0
  112. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/recorded.py +0 -0
  113. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/tools/web_fetch.py +0 -0
  114. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/trace/__init__.py +0 -0
  115. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph/trace/causal.py +0 -0
  116. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph.egg-info/dependency_links.txt +0 -0
  117. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph.egg-info/entry_points.txt +0 -0
  118. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph.egg-info/requires.txt +0 -0
  119. {activegraph-1.2.0 → activegraph-1.4.0}/activegraph.egg-info/top_level.txt +0 -0
  120. {activegraph-1.2.0 → activegraph-1.4.0}/setup.cfg +0 -0
  121. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_activate_after.py +0 -0
  122. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_causal_cross_tool.py +0 -0
  123. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_cli_docs_flags.py +0 -0
  124. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_clock.py +0 -0
  125. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_diff.py +0 -0
  126. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_diligence_pack.py +0 -0
  127. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_diligence_with_tools.py +0 -0
  128. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_doc_links.py +0 -0
  129. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_doc_python_snippets.py +0 -0
  130. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_doc_site_reachable.py +0 -0
  131. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_errors_format.py +0 -0
  132. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_event.py +0 -0
  133. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_falkordb_store.py +0 -0
  134. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_fork.py +0 -0
  135. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_graph.py +0 -0
  136. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_graph_store.py +0 -0
  137. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_ids.py +0 -0
  138. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_license.py +0 -0
  139. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_behavior.py +0 -0
  140. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_budget.py +0 -0
  141. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_causal.py +0 -0
  142. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_claim_extraction.py +0 -0
  143. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_default_model.py +0 -0
  144. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_determinism.py +0 -0
  145. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_failure.py +0 -0
  146. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_prompt.py +0 -0
  147. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_provider_fixtures.py +0 -0
  148. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_replay.py +0 -0
  149. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_tool_loop.py +0 -0
  150. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_trace.py +0 -0
  151. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_trace_snapshot.py +0 -0
  152. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llm_types.py +0 -0
  153. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_llms_txt.py +0 -0
  154. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_migration.py +0 -0
  155. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_observability_logging.py +0 -0
  156. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_observability_metrics.py +0 -0
  157. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_operate_example.py +0 -0
  158. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_pack_scaffold.py +0 -0
  159. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_packs.py +0 -0
  160. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_patch.py +0 -0
  161. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_pattern_matcher.py +0 -0
  162. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_pattern_parser.py +0 -0
  163. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_pattern_subscriptions.py +0 -0
  164. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_postgres_store.py +0 -0
  165. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_quickstart.py +0 -0
  166. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_quickstart_snapshot.py +0 -0
  167. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_reason_codes_docs.py +0 -0
  168. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_replay.py +0 -0
  169. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_replay_trace_snapshot.py +0 -0
  170. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_requeue_unfired.py +0 -0
  171. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_resume_example.py +0 -0
  172. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_runtime.py +0 -0
  173. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_runtime_status.py +0 -0
  174. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_serde.py +0 -0
  175. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_store_conformance.py +0 -0
  176. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_store_url.py +0 -0
  177. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_tool_replay.py +0 -0
  178. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_tool_trace_snapshot.py +0 -0
  179. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_tools.py +0 -0
  180. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_trace.py +0 -0
  181. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_tutorial_snippets.py +0 -0
  182. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_v1_0_1_patches.py +0 -0
  183. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
  184. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_v1_0_3_tool_multiturn.py +0 -0
  185. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_version_sync.py +0 -0
  186. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_view.py +0 -0
  187. {activegraph-1.2.0 → activegraph-1.4.0}/tests/test_wheel_completeness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.2.0
3
+ Version: 1.4.0
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License-Expression: Apache-2.0
@@ -42,6 +42,11 @@ from activegraph.llm.errors import LLMBehaviorError, MissingProviderError
42
42
  from activegraph.policy import Policy
43
43
  from activegraph.runtime.budget import Budget
44
44
  from activegraph.runtime.diff import Diff, DivergentObject, DivergentRelation
45
+ from activegraph.runtime.promote import (
46
+ PromoteConflict,
47
+ PromotePlan,
48
+ PromoteResult,
49
+ )
45
50
  from activegraph.runtime.config_errors import (
46
51
  IncompatibleRuntimeState,
47
52
  InvalidArgumentType,
@@ -52,6 +57,8 @@ from activegraph.runtime.exec_errors import (
52
57
  ApprovalNotFoundError,
53
58
  InternalEvaluatorError,
54
59
  InvalidPatchLifecycleState,
60
+ PromoteConflictError,
61
+ PromoteLineageError,
55
62
  RuntimeContextRequiredError,
56
63
  )
57
64
  from activegraph.runtime.patterns import UnsupportedPatternError
@@ -190,6 +197,11 @@ __all__ = [
190
197
  "PendingApproval",
191
198
  "Policy",
192
199
  "PrometheusMetrics",
200
+ "PromoteConflict",
201
+ "PromoteConflictError",
202
+ "PromoteLineageError",
203
+ "PromotePlan",
204
+ "PromoteResult",
193
205
  "Relation",
194
206
  "RelationBehavior",
195
207
  "RegistrationError",
@@ -230,4 +242,4 @@ __all__ = [
230
242
  "tool",
231
243
  ]
232
244
 
233
- __version__ = "1.2.0"
245
+ __version__ = "1.4.0"
@@ -0,0 +1,222 @@
1
+ """Registration-time handler-signature validation.
2
+
3
+ Shared by the ``@behavior`` / ``@relation_behavior`` / ``@llm_behavior``
4
+ decorators (both the global registry in
5
+ :mod:`activegraph.behaviors.decorators` and the pack-scoped variants in
6
+ :mod:`activegraph.packs`) and the ``@tool`` decorators.
7
+
8
+ Rationale (July 2026 agent-readiness review): a handler with the wrong
9
+ arity — ``def h(ctx)``, ``def h(event, ctx)`` — previously registered
10
+ fine and failed at first invocation with a ``TypeError`` swallowed into
11
+ a ``behavior.failed`` event, painful to notice during pack development.
12
+ Validating at decoration time names the mistake at the line that made
13
+ it, matching the precedent set by ``@llm_behavior``'s
14
+ ``output_schema=`` strict validation (CONTRACT v1.0.3 #2).
15
+
16
+ The check is deliberately permissive:
17
+
18
+ * Objects without an inspectable signature (builtins, some callables,
19
+ mocks with ``(*args, **kwargs)``) pass through unvalidated — the
20
+ goal is catching the common authoring mistake, not gatekeeping
21
+ every callable Python can construct.
22
+ * ``*args`` satisfies any positional arity.
23
+ * Extra parameters beyond the contract are allowed when the call can
24
+ still succeed: they need a default, or — for behaviors, where the
25
+ pack loader injects typed settings by keyword
26
+ (``packs/loader.py:_wrap_with_injection``) — a type annotation
27
+ that marks them as injection candidates.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import inspect
33
+ from typing import Any
34
+
35
+
36
+ def validate_handler_signature(
37
+ fn: Any,
38
+ *,
39
+ expected_params: tuple[str, ...],
40
+ decorator: str,
41
+ allow_annotated_extras: bool,
42
+ ) -> None:
43
+ """Raise ``TypeError`` when ``fn`` cannot be invoked with the
44
+ decorator's positional calling convention.
45
+
46
+ ``expected_params`` names the contract's positional parameters
47
+ (documentation for the error message; only arity is checked, not
48
+ names). ``decorator`` is the user-facing decorator name for the
49
+ message (e.g. ``"@tool"``). ``allow_annotated_extras=True`` treats
50
+ a type annotation on an extra parameter as evidence it will be
51
+ keyword-injected at call time (the pack settings pattern
52
+ ``*, settings: MyPackSettings``); ``False`` (tools) requires extras
53
+ to carry defaults, because tools are always invoked as exactly
54
+ ``fn(args, ctx)``.
55
+
56
+ Callables whose signature cannot be inspected are skipped, and
57
+ ``*args`` / ``**kwargs`` satisfy the positional / extra checks
58
+ respectively — the validation is a loud guard for the common
59
+ mistake, not an exhaustive gate.
60
+ """
61
+ if not callable(fn):
62
+ raise TypeError(
63
+ f"{decorator} must decorate a callable, got "
64
+ f"{type(fn).__name__}."
65
+ )
66
+ try:
67
+ sig = inspect.signature(fn)
68
+ except (TypeError, ValueError):
69
+ return
70
+
71
+ params = list(sig.parameters.values())
72
+ has_var_positional = any(
73
+ p.kind is inspect.Parameter.VAR_POSITIONAL for p in params
74
+ )
75
+ positional = [
76
+ p
77
+ for p in params
78
+ if p.kind
79
+ in (
80
+ inspect.Parameter.POSITIONAL_ONLY,
81
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
82
+ )
83
+ ]
84
+ n_expected = len(expected_params)
85
+ expected_sig = f"({', '.join(expected_params)})"
86
+
87
+ if len(positional) < n_expected and not has_var_positional:
88
+ found = (
89
+ f"({', '.join(p.name for p in positional)})"
90
+ if positional
91
+ else "()"
92
+ )
93
+ raise TypeError(
94
+ f"{decorator} function {_name_of(fn)!r} must accept "
95
+ f"{n_expected} positional parameters {expected_sig}, but its "
96
+ f"signature is {found}.\n"
97
+ f"\n"
98
+ f"The runtime invokes it as fn{expected_sig}. "
99
+ f"{_extra_hint(decorator)}\n"
100
+ f"\n"
101
+ f"Example:\n"
102
+ f" {decorator}(...)\n"
103
+ f" def {_example_name(fn)}{expected_sig}: ..."
104
+ )
105
+
106
+ problems: list[str] = []
107
+ for p in positional[n_expected:]:
108
+ if _extra_is_satisfiable(p, allow_annotated_extras):
109
+ continue
110
+ problems.append(p.name)
111
+ for p in params:
112
+ if p.kind is not inspect.Parameter.KEYWORD_ONLY:
113
+ continue
114
+ if _extra_is_satisfiable(p, allow_annotated_extras):
115
+ continue
116
+ problems.append(p.name)
117
+
118
+ if problems:
119
+ if allow_annotated_extras:
120
+ fix = (
121
+ "give it a default value, or annotate it with your "
122
+ "pack's settings class so the pack loader injects it "
123
+ "(e.g. `*, settings: MyPackSettings`)"
124
+ )
125
+ else:
126
+ fix = "give it a default value or remove it"
127
+ raise TypeError(
128
+ f"{decorator} function {_name_of(fn)!r} has required "
129
+ f"parameter(s) beyond the {expected_sig} contract that the "
130
+ f"runtime will never pass: {', '.join(problems)}.\n"
131
+ f"\n"
132
+ f"The runtime invokes it as fn{expected_sig}, so every "
133
+ f"extra parameter must be optional at call time — {fix}."
134
+ )
135
+
136
+
137
+ def infer_tool_input_schema(fn: Any) -> Any:
138
+ """Infer a tool's ``input_schema`` from its first parameter's
139
+ type annotation, or return ``None``.
140
+
141
+ v1.3: with ``input_schema=None``, ``@tool`` previously sent the
142
+ model an empty parameters schema (``{"type": "object",
143
+ "properties": {}}``) even when the function body plainly declared
144
+ its shape via ``def fetch(args: FetchArgs, ctx)``. Inference
145
+ closes that gap: when the first positional parameter is annotated
146
+ with a Pydantic ``BaseModel`` subclass, that class becomes the
147
+ tool's ``input_schema`` — the model sees real parameters and the
148
+ runtime validates arguments before invocation, exactly as if
149
+ ``input_schema=FetchArgs`` had been passed explicitly.
150
+
151
+ Anything else — no annotation, a non-model annotation
152
+ (``dict``, ``Any``), an uninspectable callable — returns ``None``
153
+ and the tool behaves exactly as before. Explicit ``input_schema=``
154
+ always wins; the decorators only call this when it was omitted.
155
+ """
156
+ try:
157
+ sig = inspect.signature(fn)
158
+ except (TypeError, ValueError):
159
+ return None
160
+ positional = [
161
+ p
162
+ for p in sig.parameters.values()
163
+ if p.kind
164
+ in (
165
+ inspect.Parameter.POSITIONAL_ONLY,
166
+ inspect.Parameter.POSITIONAL_OR_KEYWORD,
167
+ )
168
+ ]
169
+ if not positional:
170
+ return None
171
+ annotation = positional[0].annotation
172
+ if annotation is inspect.Parameter.empty:
173
+ return None
174
+ # PEP 563 / `from __future__ import annotations` leaves string
175
+ # annotations; resolve through get_type_hints (module-global
176
+ # visibility), the same tolerance the pack loader's settings
177
+ # injection applies.
178
+ if isinstance(annotation, str):
179
+ import typing
180
+
181
+ try:
182
+ hints = typing.get_type_hints(fn)
183
+ except Exception:
184
+ return None
185
+ annotation = hints.get(positional[0].name)
186
+ if annotation is None:
187
+ return None
188
+ try:
189
+ from pydantic import BaseModel
190
+ except ImportError: # pragma: no cover — Pydantic is a hard dep
191
+ return None
192
+ if isinstance(annotation, type) and issubclass(annotation, BaseModel):
193
+ return annotation
194
+ return None
195
+
196
+
197
+ def _extra_is_satisfiable(
198
+ p: inspect.Parameter, allow_annotated_extras: bool
199
+ ) -> bool:
200
+ if p.default is not inspect.Parameter.empty:
201
+ return True
202
+ if allow_annotated_extras and p.annotation is not inspect.Parameter.empty:
203
+ return True
204
+ return False
205
+
206
+
207
+ def _name_of(fn: Any) -> str:
208
+ return getattr(fn, "__name__", repr(fn))
209
+
210
+
211
+ def _example_name(fn: Any) -> str:
212
+ name = getattr(fn, "__name__", "my_handler")
213
+ return name if name.isidentifier() else "my_handler"
214
+
215
+
216
+ def _extra_hint(decorator: str) -> str:
217
+ if decorator == "@tool":
218
+ return (
219
+ "`args` is the validated input model (or a plain dict when "
220
+ "no input_schema is set) and `ctx` is the ToolContext."
221
+ )
222
+ return "See the behavior-handler contract in docs/concepts/behaviors."
@@ -20,12 +20,15 @@ from typing import TYPE_CHECKING, Any, Callable, Optional
20
20
 
21
21
  if TYPE_CHECKING:
22
22
  from activegraph.core.event import Event
23
- from activegraph.core.graph import Graph
23
+ from activegraph.core.graph import Graph, Relation
24
24
  from activegraph.frame import Frame
25
25
  from activegraph.llm.prompt import AssembledPrompt
26
+ from activegraph.runtime.runtime import Context
26
27
 
27
28
 
28
- def _llm_behavior_fn_placeholder(event, graph, ctx) -> None: # pragma: no cover
29
+ def _llm_behavior_fn_placeholder(
30
+ event: "Event", graph: "Graph", ctx: "Context"
31
+ ) -> None: # pragma: no cover
29
32
  raise RuntimeError(
30
33
  "LLMBehavior.fn invoked directly. The runtime owns LLM behavior "
31
34
  "invocation via _invoke_llm; calling .run() bypasses prompt "
@@ -35,6 +38,18 @@ def _llm_behavior_fn_placeholder(event, graph, ctx) -> None: # pragma: no cover
35
38
 
36
39
  @dataclass
37
40
  class Behavior:
41
+ """Metadata plus the callable for an event-driven behavior.
42
+
43
+ A Behavior is data, not magic: ``on`` (event types), ``where``
44
+ (payload filter), ``pattern`` (Cypher-subset subscription),
45
+ ``activate_after`` (event-count delay), and ``view_spec`` (what
46
+ the runtime builds into ``ctx.view``) describe *when* it fires;
47
+ the 3-arg ``fn`` — invoked as ``fn(event, graph, ctx)`` — is
48
+ *what* runs. Instances come from ``@behavior`` or a pack; the
49
+ runtime introspects the metadata to match events, and nothing
50
+ here executes on its own.
51
+ """
52
+
38
53
  name: str
39
54
  fn: Callable[..., None]
40
55
  on: list[str] = field(default_factory=list)
@@ -51,12 +66,22 @@ class Behavior:
51
66
  # CONTRACT v0.7 #13. Event-count delay. None = fire immediately.
52
67
  activate_after: Optional[int] = None
53
68
 
54
- def run(self, event, graph, ctx) -> None:
69
+ def run(self, event: "Event", graph: "Graph", ctx: "Context") -> None:
55
70
  self.fn(event, graph, ctx)
56
71
 
57
72
 
58
73
  @dataclass
59
74
  class RelationBehavior:
75
+ """A behavior that fires once per matching relation edge.
76
+
77
+ Same metadata surface as :class:`Behavior` plus
78
+ ``relation_type``: when a triggering event touches a relation of
79
+ that type, the 4-arg ``fn`` runs as
80
+ ``fn(relation, event, graph, ctx)`` — once per (event, relation)
81
+ pair. Deliberately not a subclass of ``Behavior``; the runtime
82
+ dispatches the two kinds separately.
83
+ """
84
+
60
85
  name: str
61
86
  fn: Callable[..., None]
62
87
  relation_type: str
@@ -72,7 +97,9 @@ class RelationBehavior:
72
97
  pattern_matcher: Any = None
73
98
  activate_after: Optional[int] = None
74
99
 
75
- def run(self, relation, event, graph, ctx) -> None:
100
+ def run(
101
+ self, relation: "Relation", event: "Event", graph: "Graph", ctx: "Context"
102
+ ) -> None:
76
103
  self.fn(relation, event, graph, ctx)
77
104
 
78
105
 
@@ -98,7 +125,7 @@ class LLMBehavior(Behavior):
98
125
  # at registration time. Existing call sites that pass an explicit
99
126
  # string keep working unchanged.
100
127
  model: Optional[str] = None
101
- output_schema: Optional[type] = None
128
+ output_schema: Optional[type[Any]] = None
102
129
  deterministic: bool = False
103
130
  max_tokens: int = 4096
104
131
  temperature: float = 0.7
@@ -106,7 +133,7 @@ class LLMBehavior(Behavior):
106
133
  timeout_seconds: float = 60.0
107
134
  prompt_template: Optional[str] = None
108
135
  # v0.7
109
- tools: list = field(default_factory=list)
136
+ tools: list[Any] = field(default_factory=list) # Tool | str names
110
137
  max_tool_turns: int = 6
111
138
 
112
139
  def build_prompt(
@@ -115,6 +142,7 @@ class LLMBehavior(Behavior):
115
142
  graph: "Graph",
116
143
  *,
117
144
  frame: Optional["Frame"] = None,
145
+ structured_output_mode: str = "prompt",
118
146
  ) -> "AssembledPrompt":
119
147
  """Assemble the prompt that would be sent for this event.
120
148
 
@@ -161,4 +189,5 @@ class LLMBehavior(Behavior):
161
189
  top_p=self.top_p,
162
190
  deterministic=self.deterministic,
163
191
  prompt_template=self.prompt_template,
192
+ structured_output_mode=structured_output_mode,
164
193
  )
@@ -94,7 +94,15 @@ def clear_registry() -> list[Union[Behavior, RelationBehavior]]:
94
94
 
95
95
 
96
96
  def get_registry() -> list[Union[Behavior, RelationBehavior]]:
97
- """Snapshot of the global behavior registry (a shallow copy)."""
97
+ """Snapshot of the global behavior registry (a shallow copy).
98
+
99
+ Decoration with ``@behavior`` / ``@relation_behavior`` /
100
+ ``@llm_behavior`` appends to a module-level list; this returns a
101
+ copy, so callers can filter or iterate without mutating
102
+ registration state. ``Runtime`` snapshots it at construction when
103
+ ``behaviors=`` is omitted — later registrations don't leak into
104
+ already-built runtimes.
105
+ """
98
106
  return list(_REGISTRY)
99
107
 
100
108
 
@@ -152,7 +160,7 @@ def behavior(
152
160
  *,
153
161
  pattern: Optional[str] = None,
154
162
  activate_after: Any = None,
155
- ) -> Callable[[Callable], Behavior]:
163
+ ) -> Callable[[Callable[..., None]], Behavior]:
156
164
  """Decorate a function as an event-driven behavior.
157
165
 
158
166
  v0.7 additions (both keyword-only):
@@ -175,7 +183,16 @@ def behavior(
175
183
  if activate_after is not None:
176
184
  delay_n = _parse_aa(activate_after)
177
185
 
178
- def wrap(fn: Callable) -> Behavior:
186
+ def wrap(fn: Callable[..., None]) -> Behavior:
187
+ # v1.3: arity check at decoration time (see activegraph/_signature.py).
188
+ from activegraph._signature import validate_handler_signature
189
+
190
+ validate_handler_signature(
191
+ fn,
192
+ expected_params=("event", "graph", "ctx"),
193
+ decorator="@behavior",
194
+ allow_annotated_extras=True,
195
+ )
179
196
  b = Behavior(
180
197
  name=name or fn.__name__,
181
198
  fn=fn,
@@ -215,9 +232,9 @@ def llm_behavior(
215
232
  priority: int = 0,
216
233
  pattern: Optional[str] = None,
217
234
  activate_after: Any = None,
218
- tools: Optional[list] = None,
235
+ tools: Optional[list[Any]] = None,
219
236
  max_tool_turns: int = 6,
220
- ) -> Callable[[Callable], LLMBehavior]:
237
+ ) -> Callable[[Callable[..., None]], LLMBehavior]:
221
238
  """Decorate a function as an LLM-driven behavior.
222
239
 
223
240
  The decorated function's signature is
@@ -281,7 +298,16 @@ def llm_behavior(
281
298
  # call; failing here names the cause at the @llm_behavior line.
282
299
  _validate_output_schema(output_schema)
283
300
 
284
- def wrap(fn: Callable) -> LLMBehavior:
301
+ def wrap(fn: Callable[..., None]) -> LLMBehavior:
302
+ # v1.3: arity check at decoration time (see activegraph/_signature.py).
303
+ from activegraph._signature import validate_handler_signature
304
+
305
+ validate_handler_signature(
306
+ fn,
307
+ expected_params=("event", "graph", "ctx", "llm_output"),
308
+ decorator="@llm_behavior",
309
+ allow_annotated_extras=True,
310
+ )
285
311
  b = LLMBehavior(
286
312
  name=name or fn.__name__,
287
313
  fn=_llm_behavior_fn_placeholder,
@@ -331,7 +357,7 @@ def relation_behavior(
331
357
  *,
332
358
  pattern: Optional[str] = None,
333
359
  activate_after: Any = None,
334
- ) -> Callable[[Callable], RelationBehavior]:
360
+ ) -> Callable[[Callable[..., None]], RelationBehavior]:
335
361
  """Decorate a function as a relation behavior — fires once per matching edge.
336
362
 
337
363
  v0.7: also accepts `pattern=` and `activate_after=` per CONTRACT
@@ -348,7 +374,16 @@ def relation_behavior(
348
374
  if activate_after is not None:
349
375
  delay_n = _parse_aa(activate_after)
350
376
 
351
- def wrap(fn: Callable) -> RelationBehavior:
377
+ def wrap(fn: Callable[..., None]) -> RelationBehavior:
378
+ # v1.3: arity check at decoration time (see activegraph/_signature.py).
379
+ from activegraph._signature import validate_handler_signature
380
+
381
+ validate_handler_signature(
382
+ fn,
383
+ expected_params=("relation", "event", "graph", "ctx"),
384
+ decorator="@relation_behavior",
385
+ allow_annotated_extras=True,
386
+ )
352
387
  rb = RelationBehavior(
353
388
  name=name or fn.__name__,
354
389
  fn=fn,
@@ -1,6 +1,6 @@
1
1
  """activegraph CLI entry point. CONTRACT v0.8 #12–#13.
2
2
 
3
- Subcommands: inspect, replay, fork, diff, export-trace, migrate.
3
+ Subcommands: inspect, replay, fork, diff, promote, export-trace, migrate.
4
4
 
5
5
  Each one is a thin wrapper around a library API. The CLI does no
6
6
  business logic — it parses arguments, calls into Python, and formats
@@ -129,7 +129,7 @@ def _list_runs_or_die(url: str):
129
129
  @click.group(context_settings={"help_option_names": ["-h", "--help"]})
130
130
  @click.version_option(message="activegraph %(version)s")
131
131
  def cli() -> None:
132
- """Inspect, replay, fork, diff, export, and migrate activegraph runs."""
132
+ """Inspect, replay, fork, diff, promote, export, and migrate activegraph runs."""
133
133
 
134
134
 
135
135
  # ---- quickstart ---------------------------------------------------------
@@ -866,6 +866,125 @@ def cmd_diff(url: str, run_a: str, run_b: str, as_json: bool) -> None:
866
866
  click.echo(f" - {r.summary()}")
867
867
 
868
868
 
869
+ # ---- promote ------------------------------------------------------------
870
+
871
+
872
+ @cli.command("promote")
873
+ @click.argument("url")
874
+ @click.option("--run-id", required=True, help="Destination (parent) run.")
875
+ @click.option("--from-run", required=True, help="Source (fork) run.")
876
+ @click.option(
877
+ "--dry-run",
878
+ is_flag=True,
879
+ help="Compute and print the plan without applying anything.",
880
+ )
881
+ @click.option("--json", "as_json", is_flag=True, help="Machine-readable output.")
882
+ def cmd_promote(
883
+ url: str, run_id: str, from_run: str, dry_run: bool, as_json: bool
884
+ ) -> None:
885
+ """Apply a fork's net structural delta to its parent (CONTRACT v1.3 #4).
886
+
887
+ Fail-closed and atomic: any conflict between the fork's delta and
888
+ the parent's own post-fork changes aborts with nothing applied
889
+ (exit 5, the divergence code) — a conflicted --dry-run also exits
890
+ 5 so scripts can gate on it. Fork lineage is verified from the
891
+ store's runs table.
892
+ """
893
+ from activegraph.runtime.exec_errors import (
894
+ PromoteConflictError,
895
+ PromoteLineageError,
896
+ )
897
+ from activegraph.runtime.runtime import Runtime
898
+ from activegraph.store.url import InvalidStoreURL, parse_store_url
899
+
900
+ try:
901
+ parse_store_url(url)
902
+ except InvalidStoreURL as e:
903
+ click.echo(str(e), err=True)
904
+ raise SystemExit(EXIT_USAGE_ERROR)
905
+
906
+ # Validate both run ids against the runs table BEFORE Runtime.load:
907
+ # load upserts a run row for whatever id it's given, so loading a
908
+ # mistyped id would insert a phantom empty run and then fail with
909
+ # a misleading lineage error.
910
+ known_runs = {r.run_id for r in _list_runs_or_die(url)}
911
+ for label_, rid in (("--run-id", run_id), ("--from-run", from_run)):
912
+ if rid not in known_runs:
913
+ click.echo(
914
+ f"{label_} {rid!r}: no such run in {url} "
915
+ f"(activegraph inspect {url} --runs lists them)",
916
+ err=True,
917
+ )
918
+ raise SystemExit(EXIT_NOT_FOUND)
919
+
920
+ try:
921
+ parent = Runtime.load(url, run_id=run_id)
922
+ fork = Runtime.load(url, run_id=from_run)
923
+ except FileNotFoundError as e:
924
+ click.echo(str(e), err=True)
925
+ raise SystemExit(EXIT_NOT_FOUND)
926
+ except __import__("sqlite3").OperationalError as e:
927
+ click.echo(f"{url}: {e}", err=True)
928
+ raise SystemExit(EXIT_NOT_FOUND)
929
+
930
+ try:
931
+ outcome = parent.promote(fork, dry_run=dry_run)
932
+ except PromoteLineageError as e:
933
+ click.echo(str(e), err=True)
934
+ raise SystemExit(EXIT_NOT_FOUND)
935
+ except PromoteConflictError as e:
936
+ click.echo(str(e), err=True)
937
+ raise SystemExit(EXIT_DIVERGENCE)
938
+
939
+ plan = outcome if dry_run else outcome.plan
940
+ summary = {
941
+ "from_run": plan.from_run,
942
+ "into_run": plan.into_run,
943
+ "forked_at_event": plan.forked_at_event,
944
+ "computed_against": plan.computed_against,
945
+ "dry_run": dry_run,
946
+ "objects_created": [o["id"] for o in plan.object_creates],
947
+ "objects_patched": [o["id"] for o in plan.object_patches],
948
+ "objects_removed": list(plan.object_removes),
949
+ "relations_created": [r["id"] for r in plan.relation_creates],
950
+ "relations_removed": list(plan.relation_removes),
951
+ "conflicts": [
952
+ {"kind": c.kind, "entity": c.entity, "id": c.id, "detail": c.detail}
953
+ for c in plan.conflicts
954
+ ],
955
+ "warnings": list(plan.warnings),
956
+ }
957
+ if not dry_run:
958
+ summary["marker_event_id"] = outcome.marker_event_id
959
+ summary["applied_event_ids"] = list(outcome.applied_event_ids)
960
+ if as_json:
961
+ click.echo(_json.dumps(summary))
962
+ if dry_run and plan.conflicts:
963
+ raise SystemExit(EXIT_DIVERGENCE)
964
+ return
965
+ verb = "would promote" if dry_run else "promoted"
966
+ click.echo(f"{verb} {plan.from_run} -> {plan.into_run}:")
967
+ for key in (
968
+ "objects_created",
969
+ "objects_patched",
970
+ "objects_removed",
971
+ "relations_created",
972
+ "relations_removed",
973
+ ):
974
+ ids = summary[key]
975
+ if ids:
976
+ click.echo(f" {key:20s} {', '.join(ids)}")
977
+ for w in plan.warnings:
978
+ click.echo(f" warning: {w}")
979
+ if dry_run and plan.conflicts:
980
+ click.echo("conflicts (promote would fail):")
981
+ for c in plan.conflicts:
982
+ click.echo(f" - [{c.kind}] {c.entity} {c.id}: {c.detail}")
983
+ # Scripts gate on the exit code: a conflicted dry run is the
984
+ # same signal as a conflicted apply, minus the mutation.
985
+ raise SystemExit(EXIT_DIVERGENCE)
986
+
987
+
869
988
  # ---- export-trace -------------------------------------------------------
870
989
 
871
990
 
@@ -6,7 +6,13 @@ from datetime import datetime, timezone
6
6
 
7
7
 
8
8
  class Clock:
9
- """Real wall-clock UTC. ISO 8601 second precision, Z suffix."""
9
+ """Real wall-clock UTC. ISO 8601 second precision, Z suffix.
10
+
11
+ The default time source for event timestamps. The runtime reads
12
+ time only through this interface, so deterministic runs swap in
13
+ :class:`FrozenClock` or :class:`TickingClock` and replay never
14
+ depends on the machine clock.
15
+ """
10
16
 
11
17
  def now(self) -> str:
12
18
  return (
@@ -17,7 +23,13 @@ class Clock:
17
23
 
18
24
 
19
25
  class FrozenClock(Clock):
20
- """Always returns the same timestamp. For tests and snapshots."""
26
+ """Always returns the same timestamp. For tests and snapshots.
27
+
28
+ Every ``now()`` call yields the constructor's ``t`` unchanged, so
29
+ an event log written under a FrozenClock is byte-for-byte
30
+ reproducible. Use :class:`TickingClock` when a test needs ordering
31
+ across timestamps rather than strict equality.
32
+ """
21
33
 
22
34
  def __init__(self, t: str = "2026-05-15T10:32:01Z") -> None:
23
35
  self._t = t
@@ -27,8 +39,13 @@ class FrozenClock(Clock):
27
39
 
28
40
 
29
41
  class TickingClock(Clock):
30
- """Monotonically advances by `step` seconds on every call. For tests that
31
- care about ordering but don't want wall-clock noise."""
42
+ """Monotonically advances by ``step`` seconds on every call.
43
+
44
+ For tests that care about ordering but don't want wall-clock
45
+ noise: timestamps increase deterministically from the start value,
46
+ so before/after assertions hold without sleeping or freezing time
47
+ entirely.
48
+ """
32
49
 
33
50
  def __init__(
34
51
  self,
@@ -13,6 +13,16 @@ from typing import Any, Optional
13
13
 
14
14
  @dataclass(frozen=True)
15
15
  class Event:
16
+ """One immutable record in the append-only log. CONTRACT #3.
17
+
18
+ An event is a fact: ``type`` names what happened, ``payload``
19
+ carries the data, ``actor`` says who caused it, ``caused_by``
20
+ links the causal parent event, and ``frame_id`` scopes it to a
21
+ mission frame. Events are never modified after ``emit`` —
22
+ objects, relations, patches, and views are all projections
23
+ derived from the sequence of these records (CONTRACT #2).
24
+ """
25
+
16
26
  id: str
17
27
  type: str
18
28
  payload: dict[str, Any] = field(default_factory=dict)