yanantin 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (185) hide show
  1. yanantin-0.1.0/.gitignore +15 -0
  2. yanantin-0.1.0/PKG-INFO +18 -0
  3. yanantin-0.1.0/README.md +0 -0
  4. yanantin-0.1.0/conftest.py +27 -0
  5. yanantin-0.1.0/pyproject.toml +47 -0
  6. yanantin-0.1.0/src/yanantin/__init__.py +3 -0
  7. yanantin-0.1.0/src/yanantin/activity/__init__.py +30 -0
  8. yanantin-0.1.0/src/yanantin/activity/anchor.py +190 -0
  9. yanantin-0.1.0/src/yanantin/activity/backends/__init__.py +18 -0
  10. yanantin-0.1.0/src/yanantin/activity/backends/arango.py +308 -0
  11. yanantin-0.1.0/src/yanantin/activity/backends/duckdb.py +255 -0
  12. yanantin-0.1.0/src/yanantin/activity/backends/memory.py +154 -0
  13. yanantin-0.1.0/src/yanantin/activity/models.py +122 -0
  14. yanantin-0.1.0/src/yanantin/activity/store.py +90 -0
  15. yanantin-0.1.0/src/yanantin/apacheta/__init__.py +42 -0
  16. yanantin-0.1.0/src/yanantin/apacheta/backends/__init__.py +1 -0
  17. yanantin-0.1.0/src/yanantin/apacheta/backends/arango.py +739 -0
  18. yanantin-0.1.0/src/yanantin/apacheta/backends/duckdb.py +570 -0
  19. yanantin-0.1.0/src/yanantin/apacheta/backends/memory.py +545 -0
  20. yanantin-0.1.0/src/yanantin/apacheta/clients/__init__.py +5 -0
  21. yanantin-0.1.0/src/yanantin/apacheta/clients/gateway.py +399 -0
  22. yanantin-0.1.0/src/yanantin/apacheta/clients/openrouter.py +177 -0
  23. yanantin-0.1.0/src/yanantin/apacheta/config.py +241 -0
  24. yanantin-0.1.0/src/yanantin/apacheta/content_address.py +248 -0
  25. yanantin-0.1.0/src/yanantin/apacheta/ingest/__init__.py +1 -0
  26. yanantin-0.1.0/src/yanantin/apacheta/ingest/markdown_parser.py +408 -0
  27. yanantin-0.1.0/src/yanantin/apacheta/ingest/tensor_ballot.py +86 -0
  28. yanantin-0.1.0/src/yanantin/apacheta/interface/__init__.py +19 -0
  29. yanantin-0.1.0/src/yanantin/apacheta/interface/abstract.py +293 -0
  30. yanantin-0.1.0/src/yanantin/apacheta/interface/errors.py +21 -0
  31. yanantin-0.1.0/src/yanantin/apacheta/models/__init__.py +51 -0
  32. yanantin-0.1.0/src/yanantin/apacheta/models/base.py +20 -0
  33. yanantin-0.1.0/src/yanantin/apacheta/models/composition.py +97 -0
  34. yanantin-0.1.0/src/yanantin/apacheta/models/entities.py +26 -0
  35. yanantin-0.1.0/src/yanantin/apacheta/models/epistemics.py +65 -0
  36. yanantin-0.1.0/src/yanantin/apacheta/models/provenance.py +33 -0
  37. yanantin-0.1.0/src/yanantin/apacheta/models/tensor.py +66 -0
  38. yanantin-0.1.0/src/yanantin/apacheta/operators/__init__.py +1 -0
  39. yanantin-0.1.0/src/yanantin/apacheta/operators/bootstrap.py +48 -0
  40. yanantin-0.1.0/src/yanantin/apacheta/operators/compose.py +41 -0
  41. yanantin-0.1.0/src/yanantin/apacheta/operators/correct.py +53 -0
  42. yanantin-0.1.0/src/yanantin/apacheta/operators/dissent.py +48 -0
  43. yanantin-0.1.0/src/yanantin/apacheta/operators/evolve.py +30 -0
  44. yanantin-0.1.0/src/yanantin/apacheta/operators/negate.py +45 -0
  45. yanantin-0.1.0/src/yanantin/apacheta/operators/project.py +34 -0
  46. yanantin-0.1.0/src/yanantin/apacheta/renderer/__init__.py +1 -0
  47. yanantin-0.1.0/src/yanantin/apacheta/renderer/markdown.py +155 -0
  48. yanantin-0.1.0/src/yanantin/apacheta/rummage.py +352 -0
  49. yanantin-0.1.0/src/yanantin/apacheta/storage_obfuscator.py +70 -0
  50. yanantin-0.1.0/src/yanantin/awaq/__init__.py +10 -0
  51. yanantin-0.1.0/src/yanantin/awaq/__main__.py +140 -0
  52. yanantin-0.1.0/src/yanantin/awaq/materialize.py +315 -0
  53. yanantin-0.1.0/src/yanantin/awaq/weaver.py +855 -0
  54. yanantin-0.1.0/src/yanantin/chasqui/__init__.py +1 -0
  55. yanantin-0.1.0/src/yanantin/chasqui/__main__.py +270 -0
  56. yanantin-0.1.0/src/yanantin/chasqui/analyst.py +628 -0
  57. yanantin-0.1.0/src/yanantin/chasqui/attestation.py +244 -0
  58. yanantin-0.1.0/src/yanantin/chasqui/coordinator.py +1008 -0
  59. yanantin-0.1.0/src/yanantin/chasqui/coverage.py +449 -0
  60. yanantin-0.1.0/src/yanantin/chasqui/gleaner.py +801 -0
  61. yanantin-0.1.0/src/yanantin/chasqui/model_selector.py +124 -0
  62. yanantin-0.1.0/src/yanantin/chasqui/scorer.py +585 -0
  63. yanantin-0.1.0/src/yanantin/chasqui/scourer.py +537 -0
  64. yanantin-0.1.0/src/yanantin/chasqui/scout.py +610 -0
  65. yanantin-0.1.0/src/yanantin/collector/__init__.py +107 -0
  66. yanantin-0.1.0/src/yanantin/collector/__main__.py +558 -0
  67. yanantin-0.1.0/src/yanantin/collector/base.py +215 -0
  68. yanantin-0.1.0/src/yanantin/collector/checksum.py +347 -0
  69. yanantin-0.1.0/src/yanantin/collector/dropbox/__init__.py +24 -0
  70. yanantin-0.1.0/src/yanantin/collector/dropbox/collector.py +262 -0
  71. yanantin-0.1.0/src/yanantin/collector/dropbox/fact_recorder.py +70 -0
  72. yanantin-0.1.0/src/yanantin/collector/dropbox/models.py +103 -0
  73. yanantin-0.1.0/src/yanantin/collector/dropbox/recorder.py +118 -0
  74. yanantin-0.1.0/src/yanantin/collector/dropbox/synthetic.py +160 -0
  75. yanantin-0.1.0/src/yanantin/collector/filesystem/__init__.py +29 -0
  76. yanantin-0.1.0/src/yanantin/collector/filesystem/collector.py +182 -0
  77. yanantin-0.1.0/src/yanantin/collector/filesystem/fact_recorder.py +66 -0
  78. yanantin-0.1.0/src/yanantin/collector/filesystem/models.py +134 -0
  79. yanantin-0.1.0/src/yanantin/collector/filesystem/recorder.py +116 -0
  80. yanantin-0.1.0/src/yanantin/collector/filesystem/synthetic.py +212 -0
  81. yanantin-0.1.0/src/yanantin/collector/fs_events/__init__.py +23 -0
  82. yanantin-0.1.0/src/yanantin/collector/fs_events/collector.py +190 -0
  83. yanantin-0.1.0/src/yanantin/collector/fs_events/fact_recorder.py +66 -0
  84. yanantin-0.1.0/src/yanantin/collector/fs_events/models.py +69 -0
  85. yanantin-0.1.0/src/yanantin/collector/fs_events/recorder.py +116 -0
  86. yanantin-0.1.0/src/yanantin/collector/fs_events/synthetic.py +116 -0
  87. yanantin-0.1.0/src/yanantin/collector/machine_config.py +259 -0
  88. yanantin-0.1.0/src/yanantin/collector/models.py +66 -0
  89. yanantin-0.1.0/src/yanantin/collector/openrouter/__init__.py +15 -0
  90. yanantin-0.1.0/src/yanantin/collector/openrouter/collector.py +139 -0
  91. yanantin-0.1.0/src/yanantin/collector/openrouter/fact_recorder.py +74 -0
  92. yanantin-0.1.0/src/yanantin/collector/openrouter/models.py +75 -0
  93. yanantin-0.1.0/src/yanantin/collector/pipeline.py +114 -0
  94. yanantin-0.1.0/src/yanantin/collector/synthetic.py +57 -0
  95. yanantin-0.1.0/src/yanantin/collector/wranglers.py +144 -0
  96. yanantin-0.1.0/src/yanantin/infra/__init__.py +15 -0
  97. yanantin-0.1.0/src/yanantin/infra/__main__.py +147 -0
  98. yanantin-0.1.0/src/yanantin/infra/config.py +211 -0
  99. yanantin-0.1.0/src/yanantin/infra/docker.py +191 -0
  100. yanantin-0.1.0/src/yanantin/infra/orchestrator.py +181 -0
  101. yanantin-0.1.0/src/yanantin/jabberwock/__init__.py +49 -0
  102. yanantin-0.1.0/src/yanantin/jabberwock/__main__.py +309 -0
  103. yanantin-0.1.0/src/yanantin/jabberwock/brillig.py +453 -0
  104. yanantin-0.1.0/src/yanantin/jabberwock/models.py +247 -0
  105. yanantin-0.1.0/src/yanantin/jabberwock/normalize.py +66 -0
  106. yanantin-0.1.0/src/yanantin/provenance/__init__.py +48 -0
  107. yanantin-0.1.0/src/yanantin/provenance/timestamp.py +486 -0
  108. yanantin-0.1.0/src/yanantin/query/__init__.py +21 -0
  109. yanantin-0.1.0/src/yanantin/query/__main__.py +244 -0
  110. yanantin-0.1.0/src/yanantin/query/engine.py +196 -0
  111. yanantin-0.1.0/src/yanantin/query/models.py +91 -0
  112. yanantin-0.1.0/src/yanantin/query/recorder.py +54 -0
  113. yanantin-0.1.0/src/yanantin/tinkuy/__init__.py +5 -0
  114. yanantin-0.1.0/src/yanantin/tinkuy/__main__.py +67 -0
  115. yanantin-0.1.0/src/yanantin/tinkuy/audit.py +275 -0
  116. yanantin-0.1.0/src/yanantin/tinkuy/succession.py +204 -0
  117. yanantin-0.1.0/tests/__init__.py +0 -0
  118. yanantin-0.1.0/tests/integration/__init__.py +10 -0
  119. yanantin-0.1.0/tests/integration/test_arango_activity.py +561 -0
  120. yanantin-0.1.0/tests/integration/test_arango_real.py +1291 -0
  121. yanantin-0.1.0/tests/integration/test_open_record_queries_arango_real.py +183 -0
  122. yanantin-0.1.0/tests/red_bar/__init__.py +0 -0
  123. yanantin-0.1.0/tests/red_bar/test_activity_stream.py +488 -0
  124. yanantin-0.1.0/tests/red_bar/test_attestation_invariants.py +246 -0
  125. yanantin-0.1.0/tests/red_bar/test_governance.py +369 -0
  126. yanantin-0.1.0/tests/red_bar/test_immutability.py +58 -0
  127. yanantin-0.1.0/tests/red_bar/test_jabberwock_cli_invariants.py +126 -0
  128. yanantin-0.1.0/tests/red_bar/test_jabberwock_invariants.py +250 -0
  129. yanantin-0.1.0/tests/red_bar/test_least_privilege.py +206 -0
  130. yanantin-0.1.0/tests/red_bar/test_monotonicity.py +73 -0
  131. yanantin-0.1.0/tests/red_bar/test_portability.py +80 -0
  132. yanantin-0.1.0/tests/red_bar/test_provenance.py +100 -0
  133. yanantin-0.1.0/tests/red_bar/test_query_pipeline.py +107 -0
  134. yanantin-0.1.0/tests/unit/__init__.py +0 -0
  135. yanantin-0.1.0/tests/unit/test_activity_store.py +341 -0
  136. yanantin-0.1.0/tests/unit/test_analyst.py +555 -0
  137. yanantin-0.1.0/tests/unit/test_arango_independent.py +1280 -0
  138. yanantin-0.1.0/tests/unit/test_attestation.py +311 -0
  139. yanantin-0.1.0/tests/unit/test_awaq_weaver.py +627 -0
  140. yanantin-0.1.0/tests/unit/test_chasqui.py +110 -0
  141. yanantin-0.1.0/tests/unit/test_collector.py +200 -0
  142. yanantin-0.1.0/tests/unit/test_collector_checksum.py +163 -0
  143. yanantin-0.1.0/tests/unit/test_collector_cli.py +83 -0
  144. yanantin-0.1.0/tests/unit/test_collector_dropbox.py +124 -0
  145. yanantin-0.1.0/tests/unit/test_collector_filesystem.py +227 -0
  146. yanantin-0.1.0/tests/unit/test_collector_fs_events.py +208 -0
  147. yanantin-0.1.0/tests/unit/test_collector_isomorphism.py +422 -0
  148. yanantin-0.1.0/tests/unit/test_collector_synthetic.py +86 -0
  149. yanantin-0.1.0/tests/unit/test_config_tensors.py +242 -0
  150. yanantin-0.1.0/tests/unit/test_content_address.py +336 -0
  151. yanantin-0.1.0/tests/unit/test_coverage.py +427 -0
  152. yanantin-0.1.0/tests/unit/test_duckdb_backend.py +602 -0
  153. yanantin-0.1.0/tests/unit/test_duckdb_independent.py +1874 -0
  154. yanantin-0.1.0/tests/unit/test_fact_recorders.py +240 -0
  155. yanantin-0.1.0/tests/unit/test_gateway_client_independent.py +1158 -0
  156. yanantin-0.1.0/tests/unit/test_gleaner.py +1163 -0
  157. yanantin-0.1.0/tests/unit/test_infra_config.py +142 -0
  158. yanantin-0.1.0/tests/unit/test_infra_docker.py +121 -0
  159. yanantin-0.1.0/tests/unit/test_infra_orchestrator.py +90 -0
  160. yanantin-0.1.0/tests/unit/test_ingest.py +90 -0
  161. yanantin-0.1.0/tests/unit/test_interface.py +44 -0
  162. yanantin-0.1.0/tests/unit/test_jabberwock_brillig.py +768 -0
  163. yanantin-0.1.0/tests/unit/test_jabberwock_cli.py +462 -0
  164. yanantin-0.1.0/tests/unit/test_jabberwock_models.py +596 -0
  165. yanantin-0.1.0/tests/unit/test_jabberwock_normalize.py +110 -0
  166. yanantin-0.1.0/tests/unit/test_machine_config.py +136 -0
  167. yanantin-0.1.0/tests/unit/test_materialize.py +327 -0
  168. yanantin-0.1.0/tests/unit/test_memory_anchor.py +248 -0
  169. yanantin-0.1.0/tests/unit/test_memory_backend.py +587 -0
  170. yanantin-0.1.0/tests/unit/test_models.py +509 -0
  171. yanantin-0.1.0/tests/unit/test_open_record_queries_memory.py +80 -0
  172. yanantin-0.1.0/tests/unit/test_open_record_queries_not_implemented.py +44 -0
  173. yanantin-0.1.0/tests/unit/test_openrouter.py +44 -0
  174. yanantin-0.1.0/tests/unit/test_operators.py +208 -0
  175. yanantin-0.1.0/tests/unit/test_precompact_hook.py +1607 -0
  176. yanantin-0.1.0/tests/unit/test_provenance_timestamp.py +1052 -0
  177. yanantin-0.1.0/tests/unit/test_query_engine.py +445 -0
  178. yanantin-0.1.0/tests/unit/test_query_models.py +137 -0
  179. yanantin-0.1.0/tests/unit/test_recorders.py +509 -0
  180. yanantin-0.1.0/tests/unit/test_renderer.py +182 -0
  181. yanantin-0.1.0/tests/unit/test_scorer.py +153 -0
  182. yanantin-0.1.0/tests/unit/test_scourer.py +545 -0
  183. yanantin-0.1.0/tests/unit/test_scout_features.py +269 -0
  184. yanantin-0.1.0/tests/unit/test_tinkuy_audit.py +191 -0
  185. yanantin-0.1.0/tests/unit/test_tinkuy_succession.py +199 -0
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ .serena/
5
+ .uv-cache/
6
+ .env
7
+ .claude/pipeline_health.json
8
+ .claude/heartbeat_state.json
9
+ .claude/work_queue.json
10
+ .claude/.pulse.lock
11
+ logs/
12
+ tmp/
13
+ data/
14
+ .vscode/
15
+ .codex
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: yanantin
3
+ Version: 0.1.0
4
+ Summary: Complementary duality between human and AI — composable tensor infrastructure
5
+ Requires-Python: >=3.14
6
+ Requires-Dist: anthropic>=0.84.0
7
+ Requires-Dist: docker>=7.1.0
8
+ Requires-Dist: duckdb>=1.4.4
9
+ Requires-Dist: flask>=3.1.3
10
+ Requires-Dist: httpx>=0.27
11
+ Requires-Dist: numpy>=2.4.2
12
+ Requires-Dist: opentimestamps>=0.4.5
13
+ Requires-Dist: pydantic>=2.0
14
+ Requires-Dist: python-arango>=8.2.6
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest>=8.0; extra == 'dev'
17
+ Provides-Extra: dropbox
18
+ Requires-Dist: dropbox>=12.0; extra == 'dropbox'
File without changes
@@ -0,0 +1,27 @@
1
+ """Root conftest: loads .env into os.environ at test startup.
2
+
3
+ Uses stdlib only. Environment variables already set take precedence
4
+ (setdefault, not overwrite). This replaces hardcoded credentials
5
+ in integration test files.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from pathlib import Path
12
+
13
+
14
+ def pytest_configure(config) -> None:
15
+ """Load .env file if it exists."""
16
+ env_file = Path(__file__).parent / ".env"
17
+ if not env_file.exists():
18
+ return
19
+
20
+ for line in env_file.read_text().splitlines():
21
+ line = line.strip()
22
+ if not line or line.startswith("#"):
23
+ continue
24
+ if "=" not in line:
25
+ continue
26
+ key, _, value = line.partition("=")
27
+ os.environ.setdefault(key.strip(), value.strip())
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "yanantin"
3
+ version = "0.1.0"
4
+ description = "Complementary duality between human and AI — composable tensor infrastructure"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "opentimestamps>=0.4.5",
9
+ "pydantic>=2.0",
10
+ "httpx>=0.27",
11
+ "duckdb>=1.4.4",
12
+ "python-arango>=8.2.6",
13
+ "flask>=3.1.3",
14
+ "numpy>=2.4.2",
15
+ "anthropic>=0.84.0",
16
+ "docker>=7.1.0",
17
+ ]
18
+
19
+ [project.optional-dependencies]
20
+ dev = [
21
+ "pytest>=8.0",
22
+ ]
23
+ dropbox = [
24
+ "dropbox>=12.0",
25
+ ]
26
+
27
+ [tool.pytest.ini_options]
28
+ testpaths = ["tests"]
29
+
30
+ [build-system]
31
+ requires = ["hatchling"]
32
+ build-backend = "hatchling.build"
33
+
34
+ [tool.hatch.metadata]
35
+ allow-direct-references = true
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["src/yanantin"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = [
42
+ "src/yanantin",
43
+ "tests",
44
+ "conftest.py",
45
+ "pyproject.toml",
46
+ "README.md",
47
+ ]
@@ -0,0 +1,3 @@
1
+ """Yanantin: complementary duality between human and AI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,30 @@
1
+ """Activity stream layer — temporal fact storage and memory anchors.
2
+
3
+ Two stores, two concerns:
4
+ - **ActivityStreamStore** — temporal fact storage. High volume, append-only,
5
+ indexed by (provider_id, timestamp). Schema-agnostic.
6
+ - **ApachetaInterface** — authored tensor storage. Low volume, immutable,
7
+ queryable by epistemic metadata. Existing, unchanged.
8
+
9
+ The **MemoryAnchorService** bridges them: it tracks which providers have
10
+ new data (cursors), issues handles to callers, and only persists when
11
+ the write gate opens (updated AND referenced).
12
+ """
13
+
14
+ from yanantin.activity.anchor import MemoryAnchorService
15
+ from yanantin.activity.models import (
16
+ AnchorCursor,
17
+ AnchorView,
18
+ FactRecord,
19
+ MemoryAnchor,
20
+ )
21
+ from yanantin.activity.store import ActivityStreamStore
22
+
23
+ __all__ = [
24
+ "ActivityStreamStore",
25
+ "AnchorCursor",
26
+ "AnchorView",
27
+ "FactRecord",
28
+ "MemoryAnchor",
29
+ "MemoryAnchorService",
30
+ ]
@@ -0,0 +1,190 @@
1
+ """Memory anchor service — the bridge between facts and tensors.
2
+
3
+ Issues handles, tracks cursors, manages the two-flag write gate
4
+ (updated AND referenced). Implements Indaleko's ActivityContextService
5
+ pattern: write only when something changed AND someone asked for it.
6
+
7
+ The service owns the lifecycle:
8
+ - update_cursor() — provider reports new data (sets updated)
9
+ - get_handle() — caller requests current position (sets referenced)
10
+ - flush() — write gate check, persist if both flags set
11
+ - materialize() — resolve an anchor against current streams (always fresh)
12
+ - freeze() — pin a temporal view into a permanent tensor (authored act)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ from datetime import datetime, timezone
19
+ from uuid import UUID, uuid4
20
+
21
+ from yanantin.activity.models import AnchorCursor, AnchorView, FactRecord, MemoryAnchor
22
+ from yanantin.activity.store import ActivityStreamStore
23
+ from yanantin.apacheta.interface import ApachetaInterface
24
+ from yanantin.apacheta.models import (
25
+ ProvenanceEnvelope,
26
+ SourceIdentifier,
27
+ StrandRecord,
28
+ TensorRecord,
29
+ )
30
+
31
+
32
+ class MemoryAnchorService:
33
+ """Issues handles, tracks cursors, manages the write gate.
34
+
35
+ The service is the bridge between the fact store and the tensor store.
36
+ It implements Indaleko's two-flag write gate: updated AND referenced.
37
+ """
38
+
39
+ def __init__(self, store: ActivityStreamStore) -> None:
40
+ self._store = store
41
+ self._handle: UUID = uuid4()
42
+ self._timestamp: datetime = datetime.now(timezone.utc)
43
+ self._cursors: dict[UUID, AnchorCursor] = {}
44
+ self._updated: bool = False
45
+ self._referenced: bool = False
46
+
47
+ @property
48
+ def store(self) -> ActivityStreamStore:
49
+ """The underlying activity stream store."""
50
+ return self._store
51
+
52
+ def update_cursor(
53
+ self,
54
+ provider: UUID,
55
+ reference: UUID,
56
+ data: str | None = None,
57
+ attributes: dict[str, str] | None = None,
58
+ ) -> bool:
59
+ """Update a provider's cursor position.
60
+
61
+ If the provider exists and reference is unchanged, returns False
62
+ (no-op). Otherwise updates/adds the cursor and sets updated=True.
63
+ """
64
+ existing = self._cursors.get(provider)
65
+ if existing is not None and existing.reference == reference:
66
+ return False
67
+
68
+ self._cursors[provider] = AnchorCursor(
69
+ provider=provider,
70
+ reference=reference,
71
+ data=data,
72
+ attributes=attributes,
73
+ )
74
+ self._updated = True
75
+ return True
76
+
77
+ def get_handle(self) -> UUID:
78
+ """Get the current anchor handle, setting referenced=True."""
79
+ self._referenced = True
80
+ return self._handle
81
+
82
+ def flush(self) -> bool:
83
+ """Persist anchor if both flags are set (updated AND referenced).
84
+
85
+ If the write gate is closed, returns False without writing.
86
+ If both flags are set: builds MemoryAnchor, stores it, advances
87
+ handle and timestamp, resets flags. Returns True.
88
+ """
89
+ if not (self._updated and self._referenced):
90
+ return False
91
+
92
+ anchor = MemoryAnchor(
93
+ handle=self._handle,
94
+ timestamp=self._timestamp,
95
+ cursors=tuple(self._cursors.values()),
96
+ )
97
+ self._store.store_anchor(anchor)
98
+
99
+ # Advance: new handle, new timestamp, reset flags
100
+ self._handle = uuid4()
101
+ self._timestamp = datetime.now(timezone.utc)
102
+ self._updated = False
103
+ self._referenced = False
104
+
105
+ return True
106
+
107
+ def materialize(self, handle: UUID) -> AnchorView:
108
+ """Resolve an anchor against current streams.
109
+
110
+ Always fresh — queries list_providers() at resolution time
111
+ (late binding), not just the providers in the anchor's cursor
112
+ list. A new provider registered after the anchor was created
113
+ will appear in the view if it has facts before the anchor's
114
+ timestamp.
115
+ """
116
+ anchor = self._store.get_anchor(handle)
117
+
118
+ # Late binding: discover ALL current providers
119
+ all_providers = self._store.list_providers()
120
+
121
+ # For each provider, find the latest fact at or before anchor time
122
+ facts: dict[UUID, FactRecord] = {}
123
+ for provider_id in all_providers:
124
+ fact = self._store.query_latest(provider_id, before=anchor.timestamp)
125
+ if fact is not None:
126
+ facts[provider_id] = fact
127
+
128
+ return AnchorView(
129
+ handle=anchor.handle,
130
+ timestamp=anchor.timestamp,
131
+ facts=facts,
132
+ providers=tuple(all_providers),
133
+ anchor=anchor,
134
+ )
135
+
136
+ def freeze(self, handle: UUID, interface: ApachetaInterface) -> UUID:
137
+ """Pin a temporal view into a permanent tensor.
138
+
139
+ This is an authored act — it decides to freeze a temporal view.
140
+ The resulting tensor has provenance and structured content.
141
+ """
142
+ view = self.materialize(handle)
143
+
144
+ # Strand 0: Anchor summary
145
+ summary_lines = [
146
+ f"handle: {view.handle}",
147
+ f"timestamp: {view.timestamp.isoformat()}",
148
+ f"provider_count: {len(view.providers)}",
149
+ f"fact_count: {len(view.facts)}",
150
+ ]
151
+ summary_strand = StrandRecord(
152
+ strand_index=0,
153
+ title="Anchor Summary",
154
+ content="\n".join(summary_lines),
155
+ topics=("anchor", "summary"),
156
+ )
157
+
158
+ # Strand 1: Anchor data — cursor state + resolved facts
159
+ anchor_data = {
160
+ "cursors": [c.model_dump(mode="json") for c in view.anchor.cursors],
161
+ "resolved_facts": {
162
+ str(pid): f.model_dump(mode="json")
163
+ for pid, f in view.facts.items()
164
+ },
165
+ }
166
+ data_strand = StrandRecord(
167
+ strand_index=1,
168
+ title="Anchor Data",
169
+ content=json.dumps(anchor_data, sort_keys=True, separators=(",", ":")),
170
+ topics=("anchor", "data", "facts"),
171
+ )
172
+
173
+ tensor = TensorRecord(
174
+ provenance=ProvenanceEnvelope(
175
+ source=SourceIdentifier(
176
+ identifier=view.handle,
177
+ description="Memory anchor freeze",
178
+ ),
179
+ author_model_family="anchor-service",
180
+ ),
181
+ preamble=(
182
+ f"Frozen anchor {view.handle} at {view.timestamp.isoformat()} "
183
+ f"({len(view.providers)} providers, {len(view.facts)} facts)"
184
+ ),
185
+ strands=(summary_strand, data_strand),
186
+ lineage_tags=("anchor", "frozen-view"),
187
+ )
188
+
189
+ interface.store_tensor(tensor)
190
+ return tensor.id
@@ -0,0 +1,18 @@
1
+ """Activity stream store backends.
2
+
3
+ Three backends, same contract:
4
+ - InMemoryActivityStreamStore — dict + bisect, for tests
5
+ - DuckDBActivityStreamStore — SQL query pushdown, file-backed
6
+ - ArangoDBActivityStreamStore — AQL + persistent sorted index, production
7
+ """
8
+
9
+ from yanantin.activity.backends.memory import InMemoryActivityStreamStore
10
+
11
+ __all__ = [
12
+ "InMemoryActivityStreamStore",
13
+ ]
14
+
15
+ # ArangoDB and DuckDB backends are not imported at package level to avoid
16
+ # requiring their dependencies (python-arango, duckdb) for all users.
17
+ # Import directly: from yanantin.activity.backends.arango import ArangoDBActivityStreamStore
18
+ # Import directly: from yanantin.activity.backends.duckdb import DuckDBActivityStreamStore
@@ -0,0 +1,308 @@
1
+ """ArangoDB backend for the activity stream store.
2
+
3
+ Document-based persistent storage with index-driven temporal queries.
4
+ Uses AQL for query pushdown — the composite persistent index on
5
+ (provider_id, timestamp) makes temporal queries O(log n).
6
+
7
+ Design:
8
+ - Two collections: activity_facts and activity_anchors
9
+ - Persistent sorted index on activity_facts (provider_id, timestamp)
10
+ - Persistent sorted index on activity_anchors (timestamp)
11
+ - Thread safety via RLock
12
+ - Least-privilege: separate user with rw on these collections only
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import threading
18
+ from uuid import UUID
19
+ from datetime import datetime
20
+
21
+ from arango import ArangoClient
22
+ from arango.database import StandardDatabase
23
+
24
+ from yanantin.activity.models import FactRecord, MemoryAnchor
25
+ from yanantin.activity.store import ActivityStreamStore
26
+ from yanantin.apacheta.interface.errors import ImmutabilityError, NotFoundError
27
+ from yanantin.apacheta.storage_obfuscator import StorageObfuscator, TransparentObfuscator
28
+
29
+
30
+ _SEMANTIC_COLLECTIONS = ("activity_facts", "activity_anchors")
31
+
32
+
33
+ class ArangoDBActivityStreamStore(ActivityStreamStore):
34
+ """ArangoDB implementation of ActivityStreamStore.
35
+
36
+ Thread-safe via RLock. Enforces immutability. Uses AQL with
37
+ persistent sorted indexes for O(log n) temporal queries.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ host: str = "http://localhost:8529",
43
+ db_name: str = "apacheta",
44
+ username: str = "",
45
+ password: str = "",
46
+ obfuscator: StorageObfuscator | None = None,
47
+ ) -> None:
48
+ self._lock = threading.RLock()
49
+ self._map = obfuscator or TransparentObfuscator()
50
+ self._client = ArangoClient(hosts=host)
51
+ self._host = host
52
+ self._db_name = db_name
53
+ self._db = self._connect_database(username, password)
54
+ self._ensure_collections()
55
+
56
+ def _connect_database(self, username: str, password: str) -> StandardDatabase:
57
+ """Connect to the target database. Fail-stop if it doesn't exist."""
58
+ try:
59
+ db = self._client.db(self._db_name, username=username, password=password)
60
+ db.collections()
61
+ return db
62
+ except Exception as e:
63
+ raise ConnectionError(
64
+ f"Cannot connect to ArangoDB database '{self._db_name}' at {self._host}. "
65
+ f"Database must be provisioned by an admin before the application can use it. "
66
+ f"Error: {e}"
67
+ ) from e
68
+
69
+ def _ensure_collections(self) -> None:
70
+ """Create collections and indexes if they don't exist."""
71
+ for name in _SEMANTIC_COLLECTIONS:
72
+ mapped = self._map.collection_name(name)
73
+ if not self._db.has_collection(mapped):
74
+ self._db.create_collection(mapped)
75
+
76
+ # Persistent sorted index for temporal queries on facts
77
+ facts_col = self._db.collection(self._map.collection_name("activity_facts"))
78
+ facts_col.add_index(
79
+ {
80
+ "type": "persistent",
81
+ "fields": [
82
+ self._map.field_name("provider_id"),
83
+ self._map.field_name("timestamp"),
84
+ ],
85
+ "sparse": False,
86
+ },
87
+ )
88
+
89
+ # Persistent sorted index for temporal anchor queries
90
+ anchors_col = self._db.collection(self._map.collection_name("activity_anchors"))
91
+ anchors_col.add_index(
92
+ {
93
+ "type": "persistent",
94
+ "fields": [self._map.field_name("timestamp")],
95
+ "sparse": False,
96
+ },
97
+ )
98
+
99
+ def close(self) -> None:
100
+ self._client.close()
101
+
102
+ def __enter__(self):
103
+ return self
104
+
105
+ def __exit__(self, *args):
106
+ self.close()
107
+
108
+ # -- Fact operations -----------------------------------------------
109
+
110
+ def store_fact(self, fact: FactRecord) -> None:
111
+ with self._lock:
112
+ col = self._db.collection(self._map.collection_name("activity_facts"))
113
+ key = str(fact.id)
114
+ if col.has(key):
115
+ raise ImmutabilityError(
116
+ f"Fact {fact.id} already exists. "
117
+ "Facts are immutable — append, don't overwrite."
118
+ )
119
+ doc = fact.model_dump(mode="json")
120
+ # Build mapped document: _key + mapped field names
121
+ mapped_doc = {"_key": key}
122
+ for k, v in doc.items():
123
+ if k == "id":
124
+ continue # moved to _key
125
+ elif k == "provider_id":
126
+ mapped_doc[self._map.field_name(k)] = str(v)
127
+ elif k == "timestamp":
128
+ mapped_doc[self._map.field_name(k)] = fact.timestamp.isoformat()
129
+ else:
130
+ mapped_doc[self._map.field_name(k)] = v
131
+ col.insert(mapped_doc)
132
+
133
+ def get_fact(self, fact_id: UUID) -> FactRecord:
134
+ with self._lock:
135
+ col = self._db.collection(self._map.collection_name("activity_facts"))
136
+ doc = col.get(str(fact_id))
137
+ if doc is None:
138
+ raise NotFoundError(f"Fact {fact_id} not found.")
139
+ return self._doc_to_fact(doc)
140
+
141
+ def query_latest(
142
+ self,
143
+ provider_id: UUID,
144
+ before: datetime | None = None,
145
+ ) -> FactRecord | None:
146
+ with self._lock:
147
+ col = self._map.collection_name("activity_facts")
148
+ f_pid = self._map.field_name("provider_id")
149
+ f_ts = self._map.field_name("timestamp")
150
+ if before is not None:
151
+ cursor = self._db.aql.execute(
152
+ f"FOR doc IN {col} "
153
+ f" FILTER doc.{f_pid} == @provider_id "
154
+ f" FILTER doc.{f_ts} <= @before "
155
+ f" SORT doc.{f_ts} DESC "
156
+ f" LIMIT 1 "
157
+ f" RETURN doc",
158
+ bind_vars={
159
+ "provider_id": str(provider_id),
160
+ "before": before.isoformat(),
161
+ },
162
+ )
163
+ else:
164
+ cursor = self._db.aql.execute(
165
+ f"FOR doc IN {col} "
166
+ f" FILTER doc.{f_pid} == @provider_id "
167
+ f" SORT doc.{f_ts} DESC "
168
+ f" LIMIT 1 "
169
+ f" RETURN doc",
170
+ bind_vars={"provider_id": str(provider_id)},
171
+ )
172
+ docs = list(cursor)
173
+ if not docs:
174
+ return None
175
+ return self._doc_to_fact(docs[0])
176
+
177
+ def query_range(
178
+ self,
179
+ provider_id: UUID,
180
+ start: datetime | None = None,
181
+ end: datetime | None = None,
182
+ ) -> list[FactRecord]:
183
+ with self._lock:
184
+ col = self._map.collection_name("activity_facts")
185
+ f_pid = self._map.field_name("provider_id")
186
+ f_ts = self._map.field_name("timestamp")
187
+
188
+ filters = [f"doc.{f_pid} == @provider_id"]
189
+ bind_vars: dict = {"provider_id": str(provider_id)}
190
+
191
+ if start is not None:
192
+ filters.append(f"doc.{f_ts} >= @start")
193
+ bind_vars["start"] = start.isoformat()
194
+ if end is not None:
195
+ filters.append(f"doc.{f_ts} <= @end")
196
+ bind_vars["end"] = end.isoformat()
197
+
198
+ filter_clause = " FILTER ".join([""] + filters)
199
+ cursor = self._db.aql.execute(
200
+ f"FOR doc IN {col}"
201
+ f" {filter_clause}"
202
+ f" SORT doc.{f_ts} ASC"
203
+ f" RETURN doc",
204
+ bind_vars=bind_vars,
205
+ )
206
+ return [self._doc_to_fact(doc) for doc in cursor]
207
+
208
+ # -- Anchor operations ---------------------------------------------
209
+
210
+ def store_anchor(self, anchor: MemoryAnchor) -> None:
211
+ with self._lock:
212
+ col = self._db.collection(self._map.collection_name("activity_anchors"))
213
+ key = str(anchor.handle)
214
+ if col.has(key):
215
+ raise ImmutabilityError(
216
+ f"Anchor {anchor.handle} already exists. "
217
+ "Anchors are immutable — advance, don't overwrite."
218
+ )
219
+ doc = anchor.model_dump(mode="json")
220
+ # Build mapped document
221
+ mapped_doc = {"_key": key}
222
+ for k, v in doc.items():
223
+ if k == "handle":
224
+ continue # moved to _key
225
+ elif k == "timestamp":
226
+ mapped_doc[self._map.field_name(k)] = anchor.timestamp.isoformat()
227
+ elif k == "cursors":
228
+ # Cursors contain model fields --- obfuscate nested dicts
229
+ mapped_doc[self._map.field_name(k)] = [
230
+ self._map.obfuscate_document(c) if isinstance(c, dict) else c
231
+ for c in v
232
+ ]
233
+ else:
234
+ mapped_doc[self._map.field_name(k)] = v
235
+ col.insert(mapped_doc)
236
+
237
+ def get_anchor(self, handle: UUID) -> MemoryAnchor:
238
+ with self._lock:
239
+ col = self._db.collection(self._map.collection_name("activity_anchors"))
240
+ doc = col.get(str(handle))
241
+ if doc is None:
242
+ raise NotFoundError(f"Anchor {handle} not found.")
243
+ return self._doc_to_anchor(doc)
244
+
245
+ def get_latest_anchor(self) -> MemoryAnchor | None:
246
+ with self._lock:
247
+ col = self._map.collection_name("activity_anchors")
248
+ f_ts = self._map.field_name("timestamp")
249
+ cursor = self._db.aql.execute(
250
+ f"FOR doc IN {col} "
251
+ f" SORT doc.{f_ts} DESC "
252
+ f" LIMIT 1 "
253
+ f" RETURN doc"
254
+ )
255
+ docs = list(cursor)
256
+ if not docs:
257
+ return None
258
+ return self._doc_to_anchor(docs[0])
259
+
260
+ # -- Discovery -----------------------------------------------------
261
+
262
+ def list_providers(self) -> list[UUID]:
263
+ with self._lock:
264
+ col = self._map.collection_name("activity_facts")
265
+ f_pid = self._map.field_name("provider_id")
266
+ cursor = self._db.aql.execute(
267
+ f"FOR doc IN {col} "
268
+ f" COLLECT provider = doc.{f_pid} "
269
+ f" RETURN provider"
270
+ )
271
+ return [UUID(p) for p in cursor]
272
+
273
+ def count_facts(self, provider_id: UUID | None = None) -> int:
274
+ with self._lock:
275
+ col = self._map.collection_name("activity_facts")
276
+ f_pid = self._map.field_name("provider_id")
277
+ if provider_id is not None:
278
+ cursor = self._db.aql.execute(
279
+ f"RETURN LENGTH("
280
+ f" FOR doc IN {col} "
281
+ f" FILTER doc.{f_pid} == @provider_id "
282
+ f" RETURN 1"
283
+ f")",
284
+ bind_vars={"provider_id": str(provider_id)},
285
+ )
286
+ else:
287
+ cursor = self._db.aql.execute(
288
+ f"RETURN LENGTH({col})"
289
+ )
290
+ results = list(cursor)
291
+ return results[0] if results else 0
292
+
293
+ # -- Internal helpers ──────────────────────────────────────────────
294
+
295
+ def _doc_to_fact(self, doc: dict) -> FactRecord:
296
+ """Convert an ArangoDB document to a FactRecord."""
297
+ # Reverse-map field names, then strip ArangoDB metadata
298
+ deobfuscated = self._map.deobfuscate_document(doc)
299
+ data = {k: v for k, v in deobfuscated.items() if not k.startswith("_")}
300
+ data["id"] = doc["_key"]
301
+ return FactRecord.model_validate(data)
302
+
303
+ def _doc_to_anchor(self, doc: dict) -> MemoryAnchor:
304
+ """Convert an ArangoDB document to a MemoryAnchor."""
305
+ deobfuscated = self._map.deobfuscate_document(doc)
306
+ data = {k: v for k, v in deobfuscated.items() if not k.startswith("_")}
307
+ data["handle"] = doc["_key"]
308
+ return MemoryAnchor.model_validate(data)