loom-continuity 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.
- loom_continuity-0.1.0/PKG-INFO +151 -0
- loom_continuity-0.1.0/README.md +144 -0
- loom_continuity-0.1.0/pyproject.toml +14 -0
- loom_continuity-0.1.0/setup.cfg +4 -0
- loom_continuity-0.1.0/src/loom_continuity/__init__.py +44 -0
- loom_continuity-0.1.0/src/loom_continuity/backends/__init__.py +4 -0
- loom_continuity-0.1.0/src/loom_continuity/backends/base.py +16 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/__init__.py +25 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/lease_protocol.py +21 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/lease_types.py +27 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/outbox_protocol.py +27 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/outbox_types.py +32 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/sqlite_lease.py +84 -0
- loom_continuity-0.1.0/src/loom_continuity/coordination/sqlite_outbox.py +154 -0
- loom_continuity-0.1.0/src/loom_continuity/knowledge/__init__.py +12 -0
- loom_continuity-0.1.0/src/loom_continuity/knowledge/protocol.py +33 -0
- loom_continuity-0.1.0/src/loom_continuity/knowledge/sqlite.py +130 -0
- loom_continuity-0.1.0/src/loom_continuity/knowledge/types.py +37 -0
- loom_continuity-0.1.0/src/loom_continuity.egg-info/PKG-INFO +151 -0
- loom_continuity-0.1.0/src/loom_continuity.egg-info/SOURCES.txt +21 -0
- loom_continuity-0.1.0/src/loom_continuity.egg-info/dependency_links.txt +1 -0
- loom_continuity-0.1.0/src/loom_continuity.egg-info/top_level.txt +1 -0
- loom_continuity-0.1.0/tests/test_v01_core.py +131 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loom-continuity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime for durable derived knowledge: versioned results with evidence, staleness, safe recomputation across processes, and crash-safe notification.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# Loom Continuity
|
|
9
|
+
|
|
10
|
+
`loom_continuity` is a durable coordination and knowledge-state runtime.
|
|
11
|
+
It is not an agent framework.
|
|
12
|
+
It is designed to sit underneath application harnesses and agent frameworks.
|
|
13
|
+
|
|
14
|
+
It answers one question: a value was derived from some evidence; is that evidence still current, and if not, who may recompute it without duplicating the work, committing a stale result, or losing the notice that state changed.
|
|
15
|
+
|
|
16
|
+
The package does not choose the application shape. A caller reads knowledge, checks validity, claims a repair, commits only if the evidence versions still match, and leaves a durable notification. A web app, a worker, a graph runtime, a batch job, or a plain function decides when to make those calls.
|
|
17
|
+
|
|
18
|
+
## Using it
|
|
19
|
+
|
|
20
|
+
The first mode is a library inside one process. An application opens `SqliteKnowledgeStore`, `SqliteOutbox`, and `SqliteRepairLease` on one database file. A request looks up a fact. `VALID` returns the stored value. `MISSING` computes it. `STALE` claims the lease, computes outside the database transaction, and commits only if the dependency versions are unchanged. FastAPI, or any other process, is just a client of that runtime. Importing the package does not pull FastAPI in.
|
|
21
|
+
|
|
22
|
+
The production shape splits roles across processes that share the file. The request process writes evidence and can return before the expensive work. A worker finds stale or missing facts, claims the lease, computes, and commits. A dispatcher delivers pending outbox events and re-reads the current value. Any one of those processes can die. The fact, the pending obligation, and the lease expiry remain in the file.
|
|
23
|
+
|
|
24
|
+
Several processes can see the same stale key. One claim is `ACQUIRED`. The others are `DENIED` until the lease expires or is released, then they read the repaired value. That is the coordination, not a database wrapper. The SQLite implementation is for one shared file. Several machines should not share that file. The protocols are `KnowledgeStore`, `Outbox`, and `RepairLeaseStore`; a later backend would be a new implementation, not a change to the caller's questions.
|
|
25
|
+
|
|
26
|
+
An agent framework sits above this. A graph or agent node asks for a fact and skips the expensive chain while the lookup is `VALID`. It runs only when the fact is `STALE` or `MISSING`, then writes the result with provenance. Reuse lasts until the evidence version changes. It is not a time-to-live cache.
|
|
27
|
+
|
|
28
|
+
A batch job can do the same without a server: `get_valid_fact` for each item, skip what is still `VALID`, repair what is `STALE`, compute what is `MISSING`. A scheduler such as Temporal or Celery can start the activity. This package does not replace that scheduler. The scheduler decides when to run. The runtime decides whether the result is still current and whether the write is still safe.
|
|
29
|
+
|
|
30
|
+
A later HTTP service can wrap the same calls for other languages. That service is not this package. Callers that can import Python should use the library.
|
|
31
|
+
|
|
32
|
+
An agent tool can hide the protocol and return the current fact, or report `STALE` and leave repair to an orchestrator. Several agents can share one store. When evidence moves, the derived fact becomes `STALE` for all of them. That is shared institutional knowledge, not conversation history.
|
|
33
|
+
|
|
34
|
+
The compute step can be a Python function. Nothing in the core requires a model.
|
|
35
|
+
|
|
36
|
+
The distribution version is declared once, in `pyproject.toml`. This cut is `0.1.0`.
|
|
37
|
+
The base install has no FastAPI, torch, or model-runtime dependency.
|
|
38
|
+
|
|
39
|
+
## What it guarantees
|
|
40
|
+
|
|
41
|
+
- Versioned knowledge survives process death. A later process can reopen the same store.
|
|
42
|
+
- `LookupStatus.VALID` means every provenance dependency version is still current, including transitive dependencies.
|
|
43
|
+
- A fact whose dependency moved is `STALE`. The stored revision is not deleted.
|
|
44
|
+
- If `A -> B -> C` and `A` changes, `C` is stale even when `B`'s revision number has not changed.
|
|
45
|
+
- The store does not repair itself. A caller claims a per-scope, per-key lease, computes outside any database transaction, and commits only if the dependency versions still match.
|
|
46
|
+
- A lease expires. After the owner process dies, another worker can acquire it. The lease is not held as a long transaction across the computation.
|
|
47
|
+
- A repair computed against old dependency versions is rejected. That result never becomes the current committed value.
|
|
48
|
+
- A fact revision and its outbox obligation commit in one transaction. A crash after commit leaves both. A crash before commit leaves neither.
|
|
49
|
+
- Delivery is at-least-once. Pending events remain until the consumer returns.
|
|
50
|
+
- Reopen, stale detection, single-owner repair, rejected stale commits, crash boundaries, and current-state delivery are covered by `loom_continuity_e2e`.
|
|
51
|
+
|
|
52
|
+
## What it does not guarantee
|
|
53
|
+
|
|
54
|
+
- `VALID` does not mean semantically correct, true, safe, approved, or high confidence. A wrong value is `VALID` while its dependency chain is current.
|
|
55
|
+
- Delivery is not exactly-once.
|
|
56
|
+
- The package does not deduplicate consumer side effects.
|
|
57
|
+
- Outbox events are not an event log of authoritative values. This is not event sourcing.
|
|
58
|
+
- There is no agent loop, tool framework, conversation memory, or semantic/vector memory.
|
|
59
|
+
- There is no auth, UI, or deployment framework.
|
|
60
|
+
|
|
61
|
+
## Not in this cut
|
|
62
|
+
|
|
63
|
+
v0.1 is one SQLite file and process coordination around it. It is not a city. These are outside the frozen contract:
|
|
64
|
+
|
|
65
|
+
- identity service
|
|
66
|
+
- permissions
|
|
67
|
+
- scheduler
|
|
68
|
+
- service discovery
|
|
69
|
+
- policy enforcement beyond provenance freshness
|
|
70
|
+
- a second storage backend
|
|
71
|
+
- coordination across nodes
|
|
72
|
+
|
|
73
|
+
`KnowledgeStore`, `Outbox`, and `RepairLeaseStore` are protocols with a SQLite implementation. Another backend is an open slot, not a delivered adapter. `scope` is a namespace string, not an identity. Lease TTL is an expiry, not a scheduler.
|
|
74
|
+
|
|
75
|
+
## `scope`
|
|
76
|
+
|
|
77
|
+
`scope` is the isolation namespace for independent knowledge state.
|
|
78
|
+
It may name a tenant, workflow, project, incident, job, or another application domain.
|
|
79
|
+
It is not a conversation id.
|
|
80
|
+
|
|
81
|
+
Public methods take `scope`. The SQLite primary key column is already `scope`. There is no `session_id` column and no schema migration in this cut.
|
|
82
|
+
|
|
83
|
+
## Public import surface
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from loom_continuity import (
|
|
87
|
+
KnowledgeStore,
|
|
88
|
+
SqliteKnowledgeStore,
|
|
89
|
+
FactRevision,
|
|
90
|
+
ProvenanceRef,
|
|
91
|
+
Lookup,
|
|
92
|
+
LookupStatus,
|
|
93
|
+
Outbox,
|
|
94
|
+
SqliteOutbox,
|
|
95
|
+
OutboxEvent,
|
|
96
|
+
OutboxStatus,
|
|
97
|
+
commit_fact_with_event,
|
|
98
|
+
commit_fact_with_event_if_versions_match,
|
|
99
|
+
dispatch_pending,
|
|
100
|
+
RepairLeaseStore,
|
|
101
|
+
SqliteRepairLease,
|
|
102
|
+
Lease,
|
|
103
|
+
ClaimStatus,
|
|
104
|
+
ClaimResult,
|
|
105
|
+
LLMBackend,
|
|
106
|
+
StatefulLLMBackend,
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`KnowledgeStore` is the storage protocol. `SqliteKnowledgeStore` is the SQLite implementation.
|
|
111
|
+
`FactRevision` is the only fact value type. There is no separate `Fact` alias.
|
|
112
|
+
`Lease` is the only lease value type. `claim` returns `ClaimStatus.DENIED` and does not raise.
|
|
113
|
+
`commit_fact_with_event` and `commit_fact_with_event_if_versions_match` are the public atomic write and conditional-commit operations. They are not private helpers.
|
|
114
|
+
`LLMBackend` and `StatefulLLMBackend` are dependency-free call contracts. Model runtimes are not part of the base package.
|
|
115
|
+
|
|
116
|
+
## Current-State Notification Semantics
|
|
117
|
+
|
|
118
|
+
An outbox event identifies that state changed. It carries `scope`, `key`, `version`, and a stable `event_id`. It does not carry the authoritative fact value.
|
|
119
|
+
|
|
120
|
+
The consumer re-reads the current value from `SqliteKnowledgeStore`.
|
|
121
|
+
If revision 1 then revision 2 were committed, delivering the newer event and then the older event still reads revision 2 both times.
|
|
122
|
+
A delayed older notification cannot restore an older value.
|
|
123
|
+
|
|
124
|
+
This is not event sourcing.
|
|
125
|
+
|
|
126
|
+
## Consumer idempotency
|
|
127
|
+
|
|
128
|
+
Dispatch is at-least-once. The same `event_id` can be handed to the consumer more than once, including after a crash before acknowledgement.
|
|
129
|
+
|
|
130
|
+
The package does not suppress that repeat. A consumer that must apply a logical effect once deduplicates on `event_id` itself, for example with `INSERT OR IGNORE`.
|
|
131
|
+
|
|
132
|
+
## Install and acceptance
|
|
133
|
+
|
|
134
|
+
From a clean environment, install the package, then run the acceptance suite from a checkout of this repository. The suite is not inside the wheel, and the runner does not put the source tree on `PYTHONPATH`.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
python -m venv /tmp/loom-continuity-accept
|
|
138
|
+
source /tmp/loom-continuity-accept/bin/activate
|
|
139
|
+
pip install loom-continuity
|
|
140
|
+
python loom_continuity_e2e/run_all.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Expected result: `10/10 PASS`, and `loom_continuity.__file__` under that environment's `site-packages`.
|
|
144
|
+
|
|
145
|
+
Package-local unit checks, from this directory:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python -m unittest discover -s tests -v
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A separate minimal consumer lives at `examples/minimal_consumer/` in the repository. It imports only the public package API.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Loom Continuity
|
|
2
|
+
|
|
3
|
+
`loom_continuity` is a durable coordination and knowledge-state runtime.
|
|
4
|
+
It is not an agent framework.
|
|
5
|
+
It is designed to sit underneath application harnesses and agent frameworks.
|
|
6
|
+
|
|
7
|
+
It answers one question: a value was derived from some evidence; is that evidence still current, and if not, who may recompute it without duplicating the work, committing a stale result, or losing the notice that state changed.
|
|
8
|
+
|
|
9
|
+
The package does not choose the application shape. A caller reads knowledge, checks validity, claims a repair, commits only if the evidence versions still match, and leaves a durable notification. A web app, a worker, a graph runtime, a batch job, or a plain function decides when to make those calls.
|
|
10
|
+
|
|
11
|
+
## Using it
|
|
12
|
+
|
|
13
|
+
The first mode is a library inside one process. An application opens `SqliteKnowledgeStore`, `SqliteOutbox`, and `SqliteRepairLease` on one database file. A request looks up a fact. `VALID` returns the stored value. `MISSING` computes it. `STALE` claims the lease, computes outside the database transaction, and commits only if the dependency versions are unchanged. FastAPI, or any other process, is just a client of that runtime. Importing the package does not pull FastAPI in.
|
|
14
|
+
|
|
15
|
+
The production shape splits roles across processes that share the file. The request process writes evidence and can return before the expensive work. A worker finds stale or missing facts, claims the lease, computes, and commits. A dispatcher delivers pending outbox events and re-reads the current value. Any one of those processes can die. The fact, the pending obligation, and the lease expiry remain in the file.
|
|
16
|
+
|
|
17
|
+
Several processes can see the same stale key. One claim is `ACQUIRED`. The others are `DENIED` until the lease expires or is released, then they read the repaired value. That is the coordination, not a database wrapper. The SQLite implementation is for one shared file. Several machines should not share that file. The protocols are `KnowledgeStore`, `Outbox`, and `RepairLeaseStore`; a later backend would be a new implementation, not a change to the caller's questions.
|
|
18
|
+
|
|
19
|
+
An agent framework sits above this. A graph or agent node asks for a fact and skips the expensive chain while the lookup is `VALID`. It runs only when the fact is `STALE` or `MISSING`, then writes the result with provenance. Reuse lasts until the evidence version changes. It is not a time-to-live cache.
|
|
20
|
+
|
|
21
|
+
A batch job can do the same without a server: `get_valid_fact` for each item, skip what is still `VALID`, repair what is `STALE`, compute what is `MISSING`. A scheduler such as Temporal or Celery can start the activity. This package does not replace that scheduler. The scheduler decides when to run. The runtime decides whether the result is still current and whether the write is still safe.
|
|
22
|
+
|
|
23
|
+
A later HTTP service can wrap the same calls for other languages. That service is not this package. Callers that can import Python should use the library.
|
|
24
|
+
|
|
25
|
+
An agent tool can hide the protocol and return the current fact, or report `STALE` and leave repair to an orchestrator. Several agents can share one store. When evidence moves, the derived fact becomes `STALE` for all of them. That is shared institutional knowledge, not conversation history.
|
|
26
|
+
|
|
27
|
+
The compute step can be a Python function. Nothing in the core requires a model.
|
|
28
|
+
|
|
29
|
+
The distribution version is declared once, in `pyproject.toml`. This cut is `0.1.0`.
|
|
30
|
+
The base install has no FastAPI, torch, or model-runtime dependency.
|
|
31
|
+
|
|
32
|
+
## What it guarantees
|
|
33
|
+
|
|
34
|
+
- Versioned knowledge survives process death. A later process can reopen the same store.
|
|
35
|
+
- `LookupStatus.VALID` means every provenance dependency version is still current, including transitive dependencies.
|
|
36
|
+
- A fact whose dependency moved is `STALE`. The stored revision is not deleted.
|
|
37
|
+
- If `A -> B -> C` and `A` changes, `C` is stale even when `B`'s revision number has not changed.
|
|
38
|
+
- The store does not repair itself. A caller claims a per-scope, per-key lease, computes outside any database transaction, and commits only if the dependency versions still match.
|
|
39
|
+
- A lease expires. After the owner process dies, another worker can acquire it. The lease is not held as a long transaction across the computation.
|
|
40
|
+
- A repair computed against old dependency versions is rejected. That result never becomes the current committed value.
|
|
41
|
+
- A fact revision and its outbox obligation commit in one transaction. A crash after commit leaves both. A crash before commit leaves neither.
|
|
42
|
+
- Delivery is at-least-once. Pending events remain until the consumer returns.
|
|
43
|
+
- Reopen, stale detection, single-owner repair, rejected stale commits, crash boundaries, and current-state delivery are covered by `loom_continuity_e2e`.
|
|
44
|
+
|
|
45
|
+
## What it does not guarantee
|
|
46
|
+
|
|
47
|
+
- `VALID` does not mean semantically correct, true, safe, approved, or high confidence. A wrong value is `VALID` while its dependency chain is current.
|
|
48
|
+
- Delivery is not exactly-once.
|
|
49
|
+
- The package does not deduplicate consumer side effects.
|
|
50
|
+
- Outbox events are not an event log of authoritative values. This is not event sourcing.
|
|
51
|
+
- There is no agent loop, tool framework, conversation memory, or semantic/vector memory.
|
|
52
|
+
- There is no auth, UI, or deployment framework.
|
|
53
|
+
|
|
54
|
+
## Not in this cut
|
|
55
|
+
|
|
56
|
+
v0.1 is one SQLite file and process coordination around it. It is not a city. These are outside the frozen contract:
|
|
57
|
+
|
|
58
|
+
- identity service
|
|
59
|
+
- permissions
|
|
60
|
+
- scheduler
|
|
61
|
+
- service discovery
|
|
62
|
+
- policy enforcement beyond provenance freshness
|
|
63
|
+
- a second storage backend
|
|
64
|
+
- coordination across nodes
|
|
65
|
+
|
|
66
|
+
`KnowledgeStore`, `Outbox`, and `RepairLeaseStore` are protocols with a SQLite implementation. Another backend is an open slot, not a delivered adapter. `scope` is a namespace string, not an identity. Lease TTL is an expiry, not a scheduler.
|
|
67
|
+
|
|
68
|
+
## `scope`
|
|
69
|
+
|
|
70
|
+
`scope` is the isolation namespace for independent knowledge state.
|
|
71
|
+
It may name a tenant, workflow, project, incident, job, or another application domain.
|
|
72
|
+
It is not a conversation id.
|
|
73
|
+
|
|
74
|
+
Public methods take `scope`. The SQLite primary key column is already `scope`. There is no `session_id` column and no schema migration in this cut.
|
|
75
|
+
|
|
76
|
+
## Public import surface
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from loom_continuity import (
|
|
80
|
+
KnowledgeStore,
|
|
81
|
+
SqliteKnowledgeStore,
|
|
82
|
+
FactRevision,
|
|
83
|
+
ProvenanceRef,
|
|
84
|
+
Lookup,
|
|
85
|
+
LookupStatus,
|
|
86
|
+
Outbox,
|
|
87
|
+
SqliteOutbox,
|
|
88
|
+
OutboxEvent,
|
|
89
|
+
OutboxStatus,
|
|
90
|
+
commit_fact_with_event,
|
|
91
|
+
commit_fact_with_event_if_versions_match,
|
|
92
|
+
dispatch_pending,
|
|
93
|
+
RepairLeaseStore,
|
|
94
|
+
SqliteRepairLease,
|
|
95
|
+
Lease,
|
|
96
|
+
ClaimStatus,
|
|
97
|
+
ClaimResult,
|
|
98
|
+
LLMBackend,
|
|
99
|
+
StatefulLLMBackend,
|
|
100
|
+
)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
`KnowledgeStore` is the storage protocol. `SqliteKnowledgeStore` is the SQLite implementation.
|
|
104
|
+
`FactRevision` is the only fact value type. There is no separate `Fact` alias.
|
|
105
|
+
`Lease` is the only lease value type. `claim` returns `ClaimStatus.DENIED` and does not raise.
|
|
106
|
+
`commit_fact_with_event` and `commit_fact_with_event_if_versions_match` are the public atomic write and conditional-commit operations. They are not private helpers.
|
|
107
|
+
`LLMBackend` and `StatefulLLMBackend` are dependency-free call contracts. Model runtimes are not part of the base package.
|
|
108
|
+
|
|
109
|
+
## Current-State Notification Semantics
|
|
110
|
+
|
|
111
|
+
An outbox event identifies that state changed. It carries `scope`, `key`, `version`, and a stable `event_id`. It does not carry the authoritative fact value.
|
|
112
|
+
|
|
113
|
+
The consumer re-reads the current value from `SqliteKnowledgeStore`.
|
|
114
|
+
If revision 1 then revision 2 were committed, delivering the newer event and then the older event still reads revision 2 both times.
|
|
115
|
+
A delayed older notification cannot restore an older value.
|
|
116
|
+
|
|
117
|
+
This is not event sourcing.
|
|
118
|
+
|
|
119
|
+
## Consumer idempotency
|
|
120
|
+
|
|
121
|
+
Dispatch is at-least-once. The same `event_id` can be handed to the consumer more than once, including after a crash before acknowledgement.
|
|
122
|
+
|
|
123
|
+
The package does not suppress that repeat. A consumer that must apply a logical effect once deduplicates on `event_id` itself, for example with `INSERT OR IGNORE`.
|
|
124
|
+
|
|
125
|
+
## Install and acceptance
|
|
126
|
+
|
|
127
|
+
From a clean environment, install the package, then run the acceptance suite from a checkout of this repository. The suite is not inside the wheel, and the runner does not put the source tree on `PYTHONPATH`.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python -m venv /tmp/loom-continuity-accept
|
|
131
|
+
source /tmp/loom-continuity-accept/bin/activate
|
|
132
|
+
pip install loom-continuity
|
|
133
|
+
python loom_continuity_e2e/run_all.py
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Expected result: `10/10 PASS`, and `loom_continuity.__file__` under that environment's `site-packages`.
|
|
137
|
+
|
|
138
|
+
Package-local unit checks, from this directory:
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
python -m unittest discover -s tests -v
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
A separate minimal consumer lives at `examples/minimal_consumer/` in the repository. It imports only the public package API.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "loom-continuity"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Runtime for durable derived knowledge: versioned results with evidence, staleness, safe recomputation across processes, and crash-safe notification."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["setuptools>=68"]
|
|
11
|
+
build-backend = "setuptools.build_meta"
|
|
12
|
+
|
|
13
|
+
[tool.setuptools.packages.find]
|
|
14
|
+
where = ["src"]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""loom_continuity maintains durable, versioned knowledge whose reusability is
|
|
2
|
+
determined by current provenance, and coordinates safe repair and propagation
|
|
3
|
+
across process failure.
|
|
4
|
+
|
|
5
|
+
VALID means provenance dependencies are current, not that a value is true.
|
|
6
|
+
Outbox events notify that authoritative state changed; they do not carry it.
|
|
7
|
+
"""
|
|
8
|
+
from loom_continuity.backends.base import LLMBackend, StatefulLLMBackend
|
|
9
|
+
from loom_continuity.coordination.lease_protocol import RepairLeaseStore
|
|
10
|
+
from loom_continuity.coordination.lease_types import ClaimResult, ClaimStatus, Lease
|
|
11
|
+
from loom_continuity.coordination.outbox_protocol import Outbox, dispatch_pending
|
|
12
|
+
from loom_continuity.coordination.outbox_types import OutboxEvent, OutboxStatus
|
|
13
|
+
from loom_continuity.coordination.sqlite_lease import SqliteRepairLease
|
|
14
|
+
from loom_continuity.coordination.sqlite_outbox import (
|
|
15
|
+
SqliteOutbox,
|
|
16
|
+
commit_fact_with_event,
|
|
17
|
+
commit_fact_with_event_if_versions_match,
|
|
18
|
+
)
|
|
19
|
+
from loom_continuity.knowledge.protocol import KnowledgeStore
|
|
20
|
+
from loom_continuity.knowledge.sqlite import SqliteKnowledgeStore
|
|
21
|
+
from loom_continuity.knowledge.types import FactRevision, Lookup, LookupStatus, ProvenanceRef
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"LLMBackend",
|
|
25
|
+
"StatefulLLMBackend",
|
|
26
|
+
"FactRevision",
|
|
27
|
+
"ProvenanceRef",
|
|
28
|
+
"Lookup",
|
|
29
|
+
"LookupStatus",
|
|
30
|
+
"KnowledgeStore",
|
|
31
|
+
"SqliteKnowledgeStore",
|
|
32
|
+
"OutboxEvent",
|
|
33
|
+
"OutboxStatus",
|
|
34
|
+
"Outbox",
|
|
35
|
+
"SqliteOutbox",
|
|
36
|
+
"commit_fact_with_event",
|
|
37
|
+
"commit_fact_with_event_if_versions_match",
|
|
38
|
+
"dispatch_pending",
|
|
39
|
+
"Lease",
|
|
40
|
+
"ClaimStatus",
|
|
41
|
+
"ClaimResult",
|
|
42
|
+
"RepairLeaseStore",
|
|
43
|
+
"SqliteRepairLease",
|
|
44
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Backend contracts only. Importing this module loads no HTTP client and no model weights."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class LLMBackend(Protocol):
|
|
8
|
+
async def generate(self, messages: list[dict], format: str | dict | None = None,
|
|
9
|
+
options: dict | None = None, trace_id: str | None = None) -> str: ...
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StatefulLLMBackend(LLMBackend, Protocol):
|
|
13
|
+
def prefill(self, tokens: list[int]): ...
|
|
14
|
+
def continue_from(self, state, tokens: list[int]): ...
|
|
15
|
+
def clone_state(self, state): ...
|
|
16
|
+
def commit_token(self, state, token_id: int): ...
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from loom_continuity.coordination.lease_protocol import RepairLeaseStore
|
|
2
|
+
from loom_continuity.coordination.lease_types import ClaimResult, ClaimStatus, Lease
|
|
3
|
+
from loom_continuity.coordination.outbox_protocol import Outbox, dispatch_pending
|
|
4
|
+
from loom_continuity.coordination.outbox_types import OutboxEvent, OutboxStatus
|
|
5
|
+
from loom_continuity.coordination.sqlite_lease import SqliteRepairLease
|
|
6
|
+
from loom_continuity.coordination.sqlite_outbox import (
|
|
7
|
+
SqliteOutbox,
|
|
8
|
+
commit_fact_with_event,
|
|
9
|
+
commit_fact_with_event_if_versions_match,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"OutboxEvent",
|
|
14
|
+
"OutboxStatus",
|
|
15
|
+
"Outbox",
|
|
16
|
+
"SqliteOutbox",
|
|
17
|
+
"commit_fact_with_event",
|
|
18
|
+
"commit_fact_with_event_if_versions_match",
|
|
19
|
+
"dispatch_pending",
|
|
20
|
+
"Lease",
|
|
21
|
+
"ClaimResult",
|
|
22
|
+
"ClaimStatus",
|
|
23
|
+
"RepairLeaseStore",
|
|
24
|
+
"SqliteRepairLease",
|
|
25
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Storage-independent repair-lease contract."""
|
|
2
|
+
|
|
3
|
+
from typing import Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from loom_continuity.coordination.lease_types import ClaimResult, Lease
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class RepairLeaseStore(Protocol):
|
|
10
|
+
def claim(
|
|
11
|
+
self,
|
|
12
|
+
scope: str,
|
|
13
|
+
key: str,
|
|
14
|
+
owner_id: str,
|
|
15
|
+
expected_versions: dict[str, int],
|
|
16
|
+
lease_seconds: float = 30.0,
|
|
17
|
+
) -> ClaimResult: ...
|
|
18
|
+
|
|
19
|
+
def get_current(self, scope: str, key: str) -> Lease | None: ...
|
|
20
|
+
|
|
21
|
+
def release(self, scope: str, key: str, owner_id: str) -> None: ...
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Storage-independent repair-lease values.
|
|
2
|
+
|
|
3
|
+
`claim` reports a busy lease as `ClaimStatus.DENIED`. It does not raise.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import StrEnum
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Lease:
|
|
12
|
+
scope: str
|
|
13
|
+
key: str
|
|
14
|
+
owner_id: str
|
|
15
|
+
expected_versions: dict[str, int]
|
|
16
|
+
lease_until: str
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ClaimStatus(StrEnum):
|
|
20
|
+
ACQUIRED = "ACQUIRED"
|
|
21
|
+
DENIED = "DENIED"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ClaimResult:
|
|
26
|
+
status: ClaimStatus
|
|
27
|
+
lease: Lease | None = None
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Outbox and delivery contracts without persistence assumptions."""
|
|
2
|
+
|
|
3
|
+
from typing import Callable, Protocol, runtime_checkable
|
|
4
|
+
|
|
5
|
+
from loom_continuity.coordination.outbox_types import OutboxEvent
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@runtime_checkable
|
|
9
|
+
class Outbox(Protocol):
|
|
10
|
+
def pending(self, scope: str | None = None) -> list[OutboxEvent]: ...
|
|
11
|
+
|
|
12
|
+
def mark_delivered(self, event_id: str) -> None: ...
|
|
13
|
+
|
|
14
|
+
def get(self, event_id: str) -> OutboxEvent | None: ...
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def dispatch_pending(
|
|
18
|
+
outbox: Outbox,
|
|
19
|
+
consumer: Callable[[OutboxEvent], None],
|
|
20
|
+
scope: str | None = None,
|
|
21
|
+
) -> list[str]:
|
|
22
|
+
delivered = []
|
|
23
|
+
for event in outbox.pending(scope=scope):
|
|
24
|
+
consumer(event)
|
|
25
|
+
outbox.mark_delivered(event.event_id)
|
|
26
|
+
delivered.append(event.event_id)
|
|
27
|
+
return delivered
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Storage-independent outbox values.
|
|
2
|
+
|
|
3
|
+
Current-State Notification Semantics
|
|
4
|
+
|
|
5
|
+
Outbox events do not carry authoritative fact values.
|
|
6
|
+
A consumer reacts to the event by re-reading the current value from KnowledgeStore.
|
|
7
|
+
Therefore an older delayed notification does not restore an older value.
|
|
8
|
+
|
|
9
|
+
This is not event sourcing and does not preserve historical transition payload semantics.
|
|
10
|
+
`scope` on an event identifies the knowledge domain. `version` identifies which
|
|
11
|
+
revision was committed. Neither field is a copy of that revision's value.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class OutboxStatus(StrEnum):
|
|
19
|
+
PENDING = "pending"
|
|
20
|
+
DELIVERED = "delivered"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class OutboxEvent:
|
|
25
|
+
event_id: str
|
|
26
|
+
scope: str
|
|
27
|
+
key: str
|
|
28
|
+
version: int
|
|
29
|
+
previous_version: int | None
|
|
30
|
+
event_type: str
|
|
31
|
+
created_at: str
|
|
32
|
+
status: OutboxStatus
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""SQLite adapter for durable per-key repair leases."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from loom_continuity.coordination.lease_types import ClaimResult, ClaimStatus, Lease
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SqliteRepairLease:
|
|
12
|
+
def __init__(self, db_path: str):
|
|
13
|
+
self.db_path = db_path
|
|
14
|
+
self._init_schema()
|
|
15
|
+
|
|
16
|
+
def claim(
|
|
17
|
+
self,
|
|
18
|
+
scope: str,
|
|
19
|
+
key: str,
|
|
20
|
+
owner_id: str,
|
|
21
|
+
expected_versions: dict[str, int],
|
|
22
|
+
lease_seconds: float = 30.0,
|
|
23
|
+
) -> ClaimResult:
|
|
24
|
+
now = datetime.now(timezone.utc)
|
|
25
|
+
now_iso = now.isoformat()
|
|
26
|
+
lease_until = (now + timedelta(seconds=lease_seconds)).isoformat()
|
|
27
|
+
conn = sqlite3.connect(self.db_path, isolation_level=None, timeout=10)
|
|
28
|
+
try:
|
|
29
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
30
|
+
row = conn.execute(
|
|
31
|
+
"SELECT owner_id, lease_until FROM repair_leases WHERE scope = ? AND key = ?",
|
|
32
|
+
(scope, key),
|
|
33
|
+
).fetchone()
|
|
34
|
+
if row is not None:
|
|
35
|
+
existing_owner, existing_until = row
|
|
36
|
+
if existing_until > now_iso and existing_owner != owner_id:
|
|
37
|
+
conn.execute("ROLLBACK")
|
|
38
|
+
return ClaimResult(status=ClaimStatus.DENIED)
|
|
39
|
+
conn.execute(
|
|
40
|
+
"INSERT INTO repair_leases (scope, key, owner_id, expected_versions, lease_until) "
|
|
41
|
+
"VALUES (?, ?, ?, ?, ?) "
|
|
42
|
+
"ON CONFLICT(scope, key) DO UPDATE SET owner_id = excluded.owner_id, "
|
|
43
|
+
"expected_versions = excluded.expected_versions, lease_until = excluded.lease_until",
|
|
44
|
+
(scope, key, owner_id, json.dumps(expected_versions), lease_until),
|
|
45
|
+
)
|
|
46
|
+
conn.execute("COMMIT")
|
|
47
|
+
finally:
|
|
48
|
+
conn.close()
|
|
49
|
+
lease = Lease(scope, key, owner_id, expected_versions, lease_until)
|
|
50
|
+
return ClaimResult(status=ClaimStatus.ACQUIRED, lease=lease)
|
|
51
|
+
|
|
52
|
+
def get_current(self, scope: str, key: str) -> Lease | None:
|
|
53
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
54
|
+
row = conn.execute(
|
|
55
|
+
"SELECT owner_id, expected_versions, lease_until FROM repair_leases "
|
|
56
|
+
"WHERE scope = ? AND key = ?",
|
|
57
|
+
(scope, key),
|
|
58
|
+
).fetchone()
|
|
59
|
+
if row is None:
|
|
60
|
+
return None
|
|
61
|
+
owner_id, expected_versions, lease_until = row
|
|
62
|
+
return Lease(scope, key, owner_id, json.loads(expected_versions), lease_until)
|
|
63
|
+
|
|
64
|
+
def release(self, scope: str, key: str, owner_id: str) -> None:
|
|
65
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
66
|
+
conn.execute(
|
|
67
|
+
"DELETE FROM repair_leases WHERE scope = ? AND key = ? AND owner_id = ?",
|
|
68
|
+
(scope, key, owner_id),
|
|
69
|
+
)
|
|
70
|
+
conn.commit()
|
|
71
|
+
|
|
72
|
+
def _init_schema(self) -> None:
|
|
73
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
74
|
+
conn.execute(
|
|
75
|
+
"""CREATE TABLE IF NOT EXISTS repair_leases (
|
|
76
|
+
scope TEXT NOT NULL,
|
|
77
|
+
key TEXT NOT NULL,
|
|
78
|
+
owner_id TEXT NOT NULL,
|
|
79
|
+
expected_versions TEXT NOT NULL,
|
|
80
|
+
lease_until TEXT NOT NULL,
|
|
81
|
+
PRIMARY KEY (scope, key)
|
|
82
|
+
)"""
|
|
83
|
+
)
|
|
84
|
+
conn.commit()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""SQLite outbox adapter and atomic SQLite memory/outbox commit."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
import uuid
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Sequence
|
|
8
|
+
|
|
9
|
+
from loom_continuity.coordination.outbox_types import OutboxEvent, OutboxStatus
|
|
10
|
+
from loom_continuity.knowledge.sqlite import SqliteKnowledgeStore
|
|
11
|
+
from loom_continuity.knowledge.types import ProvenanceRef
|
|
12
|
+
|
|
13
|
+
EVENT_TYPE_FACT_REVISION_CREATED = "FactRevisionCreated"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SqliteOutbox:
|
|
17
|
+
def __init__(self, db_path: str):
|
|
18
|
+
self.db_path = db_path
|
|
19
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
20
|
+
conn.execute(
|
|
21
|
+
"""CREATE TABLE IF NOT EXISTS outbox (
|
|
22
|
+
event_id TEXT PRIMARY KEY,
|
|
23
|
+
scope TEXT NOT NULL,
|
|
24
|
+
key TEXT NOT NULL,
|
|
25
|
+
version INTEGER NOT NULL,
|
|
26
|
+
previous_version INTEGER,
|
|
27
|
+
event_type TEXT NOT NULL,
|
|
28
|
+
created_at TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL
|
|
30
|
+
)"""
|
|
31
|
+
)
|
|
32
|
+
conn.commit()
|
|
33
|
+
|
|
34
|
+
def enqueue(
|
|
35
|
+
self,
|
|
36
|
+
conn: sqlite3.Connection,
|
|
37
|
+
scope: str,
|
|
38
|
+
key: str,
|
|
39
|
+
version: int,
|
|
40
|
+
previous_version: int | None,
|
|
41
|
+
event_type: str = EVENT_TYPE_FACT_REVISION_CREATED,
|
|
42
|
+
) -> str:
|
|
43
|
+
event_id = str(uuid.uuid4())
|
|
44
|
+
conn.execute(
|
|
45
|
+
"INSERT INTO outbox (event_id, scope, key, version, previous_version, event_type, created_at, status) "
|
|
46
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, 'pending')",
|
|
47
|
+
(
|
|
48
|
+
event_id,
|
|
49
|
+
scope,
|
|
50
|
+
key,
|
|
51
|
+
version,
|
|
52
|
+
previous_version,
|
|
53
|
+
event_type,
|
|
54
|
+
datetime.now(timezone.utc).isoformat(),
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
return event_id
|
|
58
|
+
|
|
59
|
+
def pending(self, scope: str | None = None) -> list[OutboxEvent]:
|
|
60
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
61
|
+
if scope is None:
|
|
62
|
+
rows = conn.execute(
|
|
63
|
+
"SELECT event_id, scope, key, version, previous_version, event_type, created_at, status "
|
|
64
|
+
"FROM outbox WHERE status = 'pending' ORDER BY created_at"
|
|
65
|
+
).fetchall()
|
|
66
|
+
else:
|
|
67
|
+
rows = conn.execute(
|
|
68
|
+
"SELECT event_id, scope, key, version, previous_version, event_type, created_at, status "
|
|
69
|
+
"FROM outbox WHERE status = 'pending' AND scope = ? ORDER BY created_at",
|
|
70
|
+
(scope,),
|
|
71
|
+
).fetchall()
|
|
72
|
+
return [self._row_to_event(row) for row in rows]
|
|
73
|
+
|
|
74
|
+
def mark_delivered(self, event_id: str) -> None:
|
|
75
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
76
|
+
conn.execute("UPDATE outbox SET status = 'delivered' WHERE event_id = ?", (event_id,))
|
|
77
|
+
conn.commit()
|
|
78
|
+
|
|
79
|
+
def get(self, event_id: str) -> OutboxEvent | None:
|
|
80
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
81
|
+
row = conn.execute(
|
|
82
|
+
"SELECT event_id, scope, key, version, previous_version, event_type, created_at, status "
|
|
83
|
+
"FROM outbox WHERE event_id = ?",
|
|
84
|
+
(event_id,),
|
|
85
|
+
).fetchone()
|
|
86
|
+
return None if row is None else self._row_to_event(row)
|
|
87
|
+
|
|
88
|
+
@staticmethod
|
|
89
|
+
def _row_to_event(row) -> OutboxEvent:
|
|
90
|
+
return OutboxEvent(*row[:-1], status=OutboxStatus(row[-1]))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def commit_fact_with_event(
|
|
94
|
+
memory: SqliteKnowledgeStore,
|
|
95
|
+
outbox: SqliteOutbox,
|
|
96
|
+
scope: str,
|
|
97
|
+
key: str,
|
|
98
|
+
value: Any,
|
|
99
|
+
source_step: str,
|
|
100
|
+
provenance: Sequence[ProvenanceRef],
|
|
101
|
+
event_type: str = EVENT_TYPE_FACT_REVISION_CREATED,
|
|
102
|
+
) -> tuple[int, str]:
|
|
103
|
+
_require_shared_database(memory, outbox)
|
|
104
|
+
previous_version = memory.get_current_version(scope, key)
|
|
105
|
+
conn = sqlite3.connect(memory.db_path)
|
|
106
|
+
try:
|
|
107
|
+
version = memory.put_fact(scope, key, value, source_step, provenance, _conn=conn)
|
|
108
|
+
event_id = outbox.enqueue(conn, scope, key, version, previous_version, event_type)
|
|
109
|
+
conn.commit()
|
|
110
|
+
finally:
|
|
111
|
+
conn.close()
|
|
112
|
+
return version, event_id
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def commit_fact_with_event_if_versions_match(
|
|
116
|
+
memory: SqliteKnowledgeStore,
|
|
117
|
+
outbox: SqliteOutbox,
|
|
118
|
+
scope: str,
|
|
119
|
+
key: str,
|
|
120
|
+
value: Any,
|
|
121
|
+
source_step: str,
|
|
122
|
+
provenance: Sequence[ProvenanceRef],
|
|
123
|
+
expected_versions: dict[str, int],
|
|
124
|
+
event_type: str = EVENT_TYPE_FACT_REVISION_CREATED,
|
|
125
|
+
) -> tuple[int, str] | None:
|
|
126
|
+
_require_shared_database(memory, outbox)
|
|
127
|
+
conn = sqlite3.connect(memory.db_path, isolation_level=None, timeout=10)
|
|
128
|
+
try:
|
|
129
|
+
conn.execute("BEGIN IMMEDIATE")
|
|
130
|
+
for dependency_key, expected_version in expected_versions.items():
|
|
131
|
+
row = conn.execute(
|
|
132
|
+
"SELECT MAX(version) FROM facts WHERE scope = ? AND key = ?",
|
|
133
|
+
(scope, dependency_key),
|
|
134
|
+
).fetchone()
|
|
135
|
+
current_version = row[0] if row and row[0] is not None else None
|
|
136
|
+
if current_version != expected_version:
|
|
137
|
+
conn.execute("ROLLBACK")
|
|
138
|
+
return None
|
|
139
|
+
previous_row = conn.execute(
|
|
140
|
+
"SELECT MAX(version) FROM facts WHERE scope = ? AND key = ?",
|
|
141
|
+
(scope, key),
|
|
142
|
+
).fetchone()
|
|
143
|
+
previous_version = previous_row[0] if previous_row and previous_row[0] is not None else None
|
|
144
|
+
version = memory.put_fact(scope, key, value, source_step, provenance, _conn=conn)
|
|
145
|
+
event_id = outbox.enqueue(conn, scope, key, version, previous_version, event_type)
|
|
146
|
+
conn.execute("COMMIT")
|
|
147
|
+
finally:
|
|
148
|
+
conn.close()
|
|
149
|
+
return version, event_id
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _require_shared_database(memory: SqliteKnowledgeStore, outbox: SqliteOutbox) -> None:
|
|
153
|
+
if memory.db_path != outbox.db_path:
|
|
154
|
+
raise ValueError("SQLite memory and outbox must share the same database file")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from loom_continuity.knowledge.protocol import KnowledgeStore
|
|
2
|
+
from loom_continuity.knowledge.sqlite import SqliteKnowledgeStore
|
|
3
|
+
from loom_continuity.knowledge.types import FactRevision, Lookup, LookupStatus, ProvenanceRef
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"FactRevision",
|
|
7
|
+
"Lookup",
|
|
8
|
+
"LookupStatus",
|
|
9
|
+
"ProvenanceRef",
|
|
10
|
+
"KnowledgeStore",
|
|
11
|
+
"SqliteKnowledgeStore",
|
|
12
|
+
]
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Storage-independent durable-knowledge contract.
|
|
2
|
+
|
|
3
|
+
`scope` isolates an independent knowledge domain. It is not a chat session.
|
|
4
|
+
It may name a workflow, tenant, project, incident, job, or agent run.
|
|
5
|
+
|
|
6
|
+
`LookupStatus.VALID` means provenance dependencies are current.
|
|
7
|
+
It does not mean the stored value is semantically true.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any, Protocol, Sequence, runtime_checkable
|
|
11
|
+
|
|
12
|
+
from loom_continuity.knowledge.types import FactRevision, Lookup, ProvenanceRef
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@runtime_checkable
|
|
16
|
+
class KnowledgeStore(Protocol):
|
|
17
|
+
def put_fact(
|
|
18
|
+
self,
|
|
19
|
+
scope: str,
|
|
20
|
+
key: str,
|
|
21
|
+
value: Any,
|
|
22
|
+
source_step: str,
|
|
23
|
+
provenance: Sequence[ProvenanceRef],
|
|
24
|
+
status: str = "established",
|
|
25
|
+
) -> int: ...
|
|
26
|
+
|
|
27
|
+
def get_fact(self, scope: str, key: str) -> FactRevision | None: ...
|
|
28
|
+
|
|
29
|
+
def get_revisions(self, scope: str, key: str) -> list[FactRevision]: ...
|
|
30
|
+
|
|
31
|
+
def get_current_version(self, scope: str, key: str) -> int | None: ...
|
|
32
|
+
|
|
33
|
+
def get_valid_fact(self, scope: str, key: str) -> Lookup: ...
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""SQLite adapter for append-only, provenance-aware knowledge."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
from contextlib import closing
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from typing import Any, Sequence
|
|
8
|
+
|
|
9
|
+
from loom_continuity.knowledge.types import FactRevision, Lookup, LookupStatus, ProvenanceRef
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SqliteKnowledgeStore:
|
|
13
|
+
def __init__(self, db_path: str):
|
|
14
|
+
self.db_path = db_path
|
|
15
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
16
|
+
conn.execute(
|
|
17
|
+
"""CREATE TABLE IF NOT EXISTS facts (
|
|
18
|
+
scope TEXT NOT NULL,
|
|
19
|
+
key TEXT NOT NULL,
|
|
20
|
+
version INTEGER NOT NULL,
|
|
21
|
+
value TEXT NOT NULL,
|
|
22
|
+
source_step TEXT NOT NULL,
|
|
23
|
+
provenance TEXT NOT NULL,
|
|
24
|
+
status TEXT NOT NULL,
|
|
25
|
+
created_at TEXT NOT NULL,
|
|
26
|
+
PRIMARY KEY (scope, key, version)
|
|
27
|
+
)"""
|
|
28
|
+
)
|
|
29
|
+
conn.commit()
|
|
30
|
+
|
|
31
|
+
def put_fact(
|
|
32
|
+
self,
|
|
33
|
+
scope: str,
|
|
34
|
+
key: str,
|
|
35
|
+
value: Any,
|
|
36
|
+
source_step: str,
|
|
37
|
+
provenance: Sequence[ProvenanceRef],
|
|
38
|
+
status: str = "established",
|
|
39
|
+
_conn: sqlite3.Connection | None = None,
|
|
40
|
+
) -> int:
|
|
41
|
+
created_at = datetime.now(timezone.utc).isoformat()
|
|
42
|
+
owns_conn = _conn is None
|
|
43
|
+
conn = _conn if _conn is not None else sqlite3.connect(self.db_path)
|
|
44
|
+
try:
|
|
45
|
+
row = conn.execute(
|
|
46
|
+
"SELECT MAX(version) FROM facts WHERE scope = ? AND key = ?",
|
|
47
|
+
(scope, key),
|
|
48
|
+
).fetchone()
|
|
49
|
+
version = (row[0] + 1) if row and row[0] is not None else 1
|
|
50
|
+
conn.execute(
|
|
51
|
+
"INSERT INTO facts (scope, key, version, value, source_step, provenance, status, created_at) "
|
|
52
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
53
|
+
(
|
|
54
|
+
scope,
|
|
55
|
+
key,
|
|
56
|
+
version,
|
|
57
|
+
json.dumps(value),
|
|
58
|
+
source_step,
|
|
59
|
+
json.dumps(list(provenance)),
|
|
60
|
+
status,
|
|
61
|
+
created_at,
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
if owns_conn:
|
|
65
|
+
conn.commit()
|
|
66
|
+
finally:
|
|
67
|
+
if owns_conn:
|
|
68
|
+
conn.close()
|
|
69
|
+
return version
|
|
70
|
+
|
|
71
|
+
def get_fact(self, scope: str, key: str) -> FactRevision | None:
|
|
72
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
73
|
+
row = conn.execute(
|
|
74
|
+
"SELECT key, value, source_step, provenance, status, created_at, version FROM facts "
|
|
75
|
+
"WHERE scope = ? AND key = ? ORDER BY version DESC LIMIT 1",
|
|
76
|
+
(scope, key),
|
|
77
|
+
).fetchone()
|
|
78
|
+
return None if row is None else self._row_to_fact(row)
|
|
79
|
+
|
|
80
|
+
def get_revisions(self, scope: str, key: str) -> list[FactRevision]:
|
|
81
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
82
|
+
rows = conn.execute(
|
|
83
|
+
"SELECT key, value, source_step, provenance, status, created_at, version FROM facts "
|
|
84
|
+
"WHERE scope = ? AND key = ? ORDER BY version ASC",
|
|
85
|
+
(scope, key),
|
|
86
|
+
).fetchall()
|
|
87
|
+
return [self._row_to_fact(row) for row in rows]
|
|
88
|
+
|
|
89
|
+
def get_current_version(self, scope: str, key: str) -> int | None:
|
|
90
|
+
with closing(sqlite3.connect(self.db_path)) as conn:
|
|
91
|
+
row = conn.execute(
|
|
92
|
+
"SELECT MAX(version) FROM facts WHERE scope = ? AND key = ?",
|
|
93
|
+
(scope, key),
|
|
94
|
+
).fetchone()
|
|
95
|
+
return row[0] if row and row[0] is not None else None
|
|
96
|
+
|
|
97
|
+
def get_valid_fact(
|
|
98
|
+
self,
|
|
99
|
+
scope: str,
|
|
100
|
+
key: str,
|
|
101
|
+
_seen: frozenset[str] = frozenset(),
|
|
102
|
+
) -> Lookup:
|
|
103
|
+
fact = self.get_fact(scope, key)
|
|
104
|
+
if fact is None:
|
|
105
|
+
return Lookup(status=LookupStatus.MISSING, fact=None)
|
|
106
|
+
if key in _seen:
|
|
107
|
+
return Lookup(status=LookupStatus.VALID, fact=fact)
|
|
108
|
+
for dependency in fact.provenance:
|
|
109
|
+
if not isinstance(dependency, dict) or "key" not in dependency or "version" not in dependency:
|
|
110
|
+
continue
|
|
111
|
+
dependency_key = dependency["key"]
|
|
112
|
+
if self.get_current_version(scope, dependency_key) != dependency["version"]:
|
|
113
|
+
return Lookup(status=LookupStatus.STALE, fact=fact)
|
|
114
|
+
dependency_lookup = self.get_valid_fact(scope, dependency_key, _seen | {key})
|
|
115
|
+
if dependency_lookup.status != LookupStatus.VALID:
|
|
116
|
+
return Lookup(status=LookupStatus.STALE, fact=fact)
|
|
117
|
+
return Lookup(status=LookupStatus.VALID, fact=fact)
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def _row_to_fact(row) -> FactRevision:
|
|
121
|
+
key, value, source_step, provenance, status, created_at, version = row
|
|
122
|
+
return FactRevision(
|
|
123
|
+
key=key,
|
|
124
|
+
value=json.loads(value),
|
|
125
|
+
source_step=source_step,
|
|
126
|
+
provenance=json.loads(provenance),
|
|
127
|
+
status=status,
|
|
128
|
+
created_at=created_at,
|
|
129
|
+
version=version,
|
|
130
|
+
)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Stable value types for versioned durable knowledge.
|
|
2
|
+
|
|
3
|
+
`LookupStatus.VALID` means provenance dependencies are current.
|
|
4
|
+
It does not mean the stored value is semantically true.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any, TypedDict
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LookupStatus(StrEnum):
|
|
13
|
+
MISSING = "MISSING"
|
|
14
|
+
STALE = "STALE"
|
|
15
|
+
VALID = "VALID"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProvenanceRef(TypedDict):
|
|
19
|
+
key: str
|
|
20
|
+
version: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class FactRevision:
|
|
25
|
+
key: str
|
|
26
|
+
value: Any
|
|
27
|
+
source_step: str
|
|
28
|
+
provenance: list[ProvenanceRef]
|
|
29
|
+
status: str = "established"
|
|
30
|
+
created_at: str | None = None
|
|
31
|
+
version: int = 1
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Lookup:
|
|
36
|
+
status: LookupStatus
|
|
37
|
+
fact: FactRevision | None
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loom-continuity
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Runtime for durable derived knowledge: versioned results with evidence, staleness, safe recomputation across processes, and crash-safe notification.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# Loom Continuity
|
|
9
|
+
|
|
10
|
+
`loom_continuity` is a durable coordination and knowledge-state runtime.
|
|
11
|
+
It is not an agent framework.
|
|
12
|
+
It is designed to sit underneath application harnesses and agent frameworks.
|
|
13
|
+
|
|
14
|
+
It answers one question: a value was derived from some evidence; is that evidence still current, and if not, who may recompute it without duplicating the work, committing a stale result, or losing the notice that state changed.
|
|
15
|
+
|
|
16
|
+
The package does not choose the application shape. A caller reads knowledge, checks validity, claims a repair, commits only if the evidence versions still match, and leaves a durable notification. A web app, a worker, a graph runtime, a batch job, or a plain function decides when to make those calls.
|
|
17
|
+
|
|
18
|
+
## Using it
|
|
19
|
+
|
|
20
|
+
The first mode is a library inside one process. An application opens `SqliteKnowledgeStore`, `SqliteOutbox`, and `SqliteRepairLease` on one database file. A request looks up a fact. `VALID` returns the stored value. `MISSING` computes it. `STALE` claims the lease, computes outside the database transaction, and commits only if the dependency versions are unchanged. FastAPI, or any other process, is just a client of that runtime. Importing the package does not pull FastAPI in.
|
|
21
|
+
|
|
22
|
+
The production shape splits roles across processes that share the file. The request process writes evidence and can return before the expensive work. A worker finds stale or missing facts, claims the lease, computes, and commits. A dispatcher delivers pending outbox events and re-reads the current value. Any one of those processes can die. The fact, the pending obligation, and the lease expiry remain in the file.
|
|
23
|
+
|
|
24
|
+
Several processes can see the same stale key. One claim is `ACQUIRED`. The others are `DENIED` until the lease expires or is released, then they read the repaired value. That is the coordination, not a database wrapper. The SQLite implementation is for one shared file. Several machines should not share that file. The protocols are `KnowledgeStore`, `Outbox`, and `RepairLeaseStore`; a later backend would be a new implementation, not a change to the caller's questions.
|
|
25
|
+
|
|
26
|
+
An agent framework sits above this. A graph or agent node asks for a fact and skips the expensive chain while the lookup is `VALID`. It runs only when the fact is `STALE` or `MISSING`, then writes the result with provenance. Reuse lasts until the evidence version changes. It is not a time-to-live cache.
|
|
27
|
+
|
|
28
|
+
A batch job can do the same without a server: `get_valid_fact` for each item, skip what is still `VALID`, repair what is `STALE`, compute what is `MISSING`. A scheduler such as Temporal or Celery can start the activity. This package does not replace that scheduler. The scheduler decides when to run. The runtime decides whether the result is still current and whether the write is still safe.
|
|
29
|
+
|
|
30
|
+
A later HTTP service can wrap the same calls for other languages. That service is not this package. Callers that can import Python should use the library.
|
|
31
|
+
|
|
32
|
+
An agent tool can hide the protocol and return the current fact, or report `STALE` and leave repair to an orchestrator. Several agents can share one store. When evidence moves, the derived fact becomes `STALE` for all of them. That is shared institutional knowledge, not conversation history.
|
|
33
|
+
|
|
34
|
+
The compute step can be a Python function. Nothing in the core requires a model.
|
|
35
|
+
|
|
36
|
+
The distribution version is declared once, in `pyproject.toml`. This cut is `0.1.0`.
|
|
37
|
+
The base install has no FastAPI, torch, or model-runtime dependency.
|
|
38
|
+
|
|
39
|
+
## What it guarantees
|
|
40
|
+
|
|
41
|
+
- Versioned knowledge survives process death. A later process can reopen the same store.
|
|
42
|
+
- `LookupStatus.VALID` means every provenance dependency version is still current, including transitive dependencies.
|
|
43
|
+
- A fact whose dependency moved is `STALE`. The stored revision is not deleted.
|
|
44
|
+
- If `A -> B -> C` and `A` changes, `C` is stale even when `B`'s revision number has not changed.
|
|
45
|
+
- The store does not repair itself. A caller claims a per-scope, per-key lease, computes outside any database transaction, and commits only if the dependency versions still match.
|
|
46
|
+
- A lease expires. After the owner process dies, another worker can acquire it. The lease is not held as a long transaction across the computation.
|
|
47
|
+
- A repair computed against old dependency versions is rejected. That result never becomes the current committed value.
|
|
48
|
+
- A fact revision and its outbox obligation commit in one transaction. A crash after commit leaves both. A crash before commit leaves neither.
|
|
49
|
+
- Delivery is at-least-once. Pending events remain until the consumer returns.
|
|
50
|
+
- Reopen, stale detection, single-owner repair, rejected stale commits, crash boundaries, and current-state delivery are covered by `loom_continuity_e2e`.
|
|
51
|
+
|
|
52
|
+
## What it does not guarantee
|
|
53
|
+
|
|
54
|
+
- `VALID` does not mean semantically correct, true, safe, approved, or high confidence. A wrong value is `VALID` while its dependency chain is current.
|
|
55
|
+
- Delivery is not exactly-once.
|
|
56
|
+
- The package does not deduplicate consumer side effects.
|
|
57
|
+
- Outbox events are not an event log of authoritative values. This is not event sourcing.
|
|
58
|
+
- There is no agent loop, tool framework, conversation memory, or semantic/vector memory.
|
|
59
|
+
- There is no auth, UI, or deployment framework.
|
|
60
|
+
|
|
61
|
+
## Not in this cut
|
|
62
|
+
|
|
63
|
+
v0.1 is one SQLite file and process coordination around it. It is not a city. These are outside the frozen contract:
|
|
64
|
+
|
|
65
|
+
- identity service
|
|
66
|
+
- permissions
|
|
67
|
+
- scheduler
|
|
68
|
+
- service discovery
|
|
69
|
+
- policy enforcement beyond provenance freshness
|
|
70
|
+
- a second storage backend
|
|
71
|
+
- coordination across nodes
|
|
72
|
+
|
|
73
|
+
`KnowledgeStore`, `Outbox`, and `RepairLeaseStore` are protocols with a SQLite implementation. Another backend is an open slot, not a delivered adapter. `scope` is a namespace string, not an identity. Lease TTL is an expiry, not a scheduler.
|
|
74
|
+
|
|
75
|
+
## `scope`
|
|
76
|
+
|
|
77
|
+
`scope` is the isolation namespace for independent knowledge state.
|
|
78
|
+
It may name a tenant, workflow, project, incident, job, or another application domain.
|
|
79
|
+
It is not a conversation id.
|
|
80
|
+
|
|
81
|
+
Public methods take `scope`. The SQLite primary key column is already `scope`. There is no `session_id` column and no schema migration in this cut.
|
|
82
|
+
|
|
83
|
+
## Public import surface
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from loom_continuity import (
|
|
87
|
+
KnowledgeStore,
|
|
88
|
+
SqliteKnowledgeStore,
|
|
89
|
+
FactRevision,
|
|
90
|
+
ProvenanceRef,
|
|
91
|
+
Lookup,
|
|
92
|
+
LookupStatus,
|
|
93
|
+
Outbox,
|
|
94
|
+
SqliteOutbox,
|
|
95
|
+
OutboxEvent,
|
|
96
|
+
OutboxStatus,
|
|
97
|
+
commit_fact_with_event,
|
|
98
|
+
commit_fact_with_event_if_versions_match,
|
|
99
|
+
dispatch_pending,
|
|
100
|
+
RepairLeaseStore,
|
|
101
|
+
SqliteRepairLease,
|
|
102
|
+
Lease,
|
|
103
|
+
ClaimStatus,
|
|
104
|
+
ClaimResult,
|
|
105
|
+
LLMBackend,
|
|
106
|
+
StatefulLLMBackend,
|
|
107
|
+
)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`KnowledgeStore` is the storage protocol. `SqliteKnowledgeStore` is the SQLite implementation.
|
|
111
|
+
`FactRevision` is the only fact value type. There is no separate `Fact` alias.
|
|
112
|
+
`Lease` is the only lease value type. `claim` returns `ClaimStatus.DENIED` and does not raise.
|
|
113
|
+
`commit_fact_with_event` and `commit_fact_with_event_if_versions_match` are the public atomic write and conditional-commit operations. They are not private helpers.
|
|
114
|
+
`LLMBackend` and `StatefulLLMBackend` are dependency-free call contracts. Model runtimes are not part of the base package.
|
|
115
|
+
|
|
116
|
+
## Current-State Notification Semantics
|
|
117
|
+
|
|
118
|
+
An outbox event identifies that state changed. It carries `scope`, `key`, `version`, and a stable `event_id`. It does not carry the authoritative fact value.
|
|
119
|
+
|
|
120
|
+
The consumer re-reads the current value from `SqliteKnowledgeStore`.
|
|
121
|
+
If revision 1 then revision 2 were committed, delivering the newer event and then the older event still reads revision 2 both times.
|
|
122
|
+
A delayed older notification cannot restore an older value.
|
|
123
|
+
|
|
124
|
+
This is not event sourcing.
|
|
125
|
+
|
|
126
|
+
## Consumer idempotency
|
|
127
|
+
|
|
128
|
+
Dispatch is at-least-once. The same `event_id` can be handed to the consumer more than once, including after a crash before acknowledgement.
|
|
129
|
+
|
|
130
|
+
The package does not suppress that repeat. A consumer that must apply a logical effect once deduplicates on `event_id` itself, for example with `INSERT OR IGNORE`.
|
|
131
|
+
|
|
132
|
+
## Install and acceptance
|
|
133
|
+
|
|
134
|
+
From a clean environment, install the package, then run the acceptance suite from a checkout of this repository. The suite is not inside the wheel, and the runner does not put the source tree on `PYTHONPATH`.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
python -m venv /tmp/loom-continuity-accept
|
|
138
|
+
source /tmp/loom-continuity-accept/bin/activate
|
|
139
|
+
pip install loom-continuity
|
|
140
|
+
python loom_continuity_e2e/run_all.py
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Expected result: `10/10 PASS`, and `loom_continuity.__file__` under that environment's `site-packages`.
|
|
144
|
+
|
|
145
|
+
Package-local unit checks, from this directory:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
python -m unittest discover -s tests -v
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
A separate minimal consumer lives at `examples/minimal_consumer/` in the repository. It imports only the public package API.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
src/loom_continuity/__init__.py
|
|
4
|
+
src/loom_continuity.egg-info/PKG-INFO
|
|
5
|
+
src/loom_continuity.egg-info/SOURCES.txt
|
|
6
|
+
src/loom_continuity.egg-info/dependency_links.txt
|
|
7
|
+
src/loom_continuity.egg-info/top_level.txt
|
|
8
|
+
src/loom_continuity/backends/__init__.py
|
|
9
|
+
src/loom_continuity/backends/base.py
|
|
10
|
+
src/loom_continuity/coordination/__init__.py
|
|
11
|
+
src/loom_continuity/coordination/lease_protocol.py
|
|
12
|
+
src/loom_continuity/coordination/lease_types.py
|
|
13
|
+
src/loom_continuity/coordination/outbox_protocol.py
|
|
14
|
+
src/loom_continuity/coordination/outbox_types.py
|
|
15
|
+
src/loom_continuity/coordination/sqlite_lease.py
|
|
16
|
+
src/loom_continuity/coordination/sqlite_outbox.py
|
|
17
|
+
src/loom_continuity/knowledge/__init__.py
|
|
18
|
+
src/loom_continuity/knowledge/protocol.py
|
|
19
|
+
src/loom_continuity/knowledge/sqlite.py
|
|
20
|
+
src/loom_continuity/knowledge/types.py
|
|
21
|
+
tests/test_v01_core.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
loom_continuity
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Proofs that travel with the package, not with ticket_harness."""
|
|
2
|
+
import sys
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
PUBLIC_API = {
|
|
9
|
+
"LLMBackend",
|
|
10
|
+
"StatefulLLMBackend",
|
|
11
|
+
"FactRevision",
|
|
12
|
+
"ProvenanceRef",
|
|
13
|
+
"Lookup",
|
|
14
|
+
"LookupStatus",
|
|
15
|
+
"KnowledgeStore",
|
|
16
|
+
"SqliteKnowledgeStore",
|
|
17
|
+
"OutboxEvent",
|
|
18
|
+
"OutboxStatus",
|
|
19
|
+
"Outbox",
|
|
20
|
+
"SqliteOutbox",
|
|
21
|
+
"commit_fact_with_event",
|
|
22
|
+
"commit_fact_with_event_if_versions_match",
|
|
23
|
+
"dispatch_pending",
|
|
24
|
+
"Lease",
|
|
25
|
+
"ClaimStatus",
|
|
26
|
+
"ClaimResult",
|
|
27
|
+
"RepairLeaseStore",
|
|
28
|
+
"SqliteRepairLease",
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ImportBoundaryTest(unittest.TestCase):
|
|
33
|
+
def test_public_surface_matches_frozen_snapshot(self):
|
|
34
|
+
import loom_continuity
|
|
35
|
+
|
|
36
|
+
self.assertEqual(set(loom_continuity.__all__), PUBLIC_API)
|
|
37
|
+
for name in ("Fact", "RepairLease", "LeaseDenied", "SessionMemory", "DurableKnowledge"):
|
|
38
|
+
self.assertNotIn(name, loom_continuity.__all__)
|
|
39
|
+
self.assertFalse(hasattr(loom_continuity, name))
|
|
40
|
+
|
|
41
|
+
def test_root_import_pulls_no_adapter_or_app(self):
|
|
42
|
+
import loom_continuity
|
|
43
|
+
loaded = set(sys.modules)
|
|
44
|
+
self.assertIn("loom_continuity.knowledge.protocol", loaded)
|
|
45
|
+
self.assertIn("loom_continuity.knowledge.sqlite", loaded)
|
|
46
|
+
banned = ("torch", "transformers", "httpx", "fastapi", "sentence_transformers")
|
|
47
|
+
self.assertFalse(any(name == b or name.startswith(b + ".") for name in loaded for b in banned))
|
|
48
|
+
self.assertTrue(hasattr(loom_continuity, "SqliteKnowledgeStore"))
|
|
49
|
+
self.assertTrue(hasattr(loom_continuity, "LLMBackend"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class MemoryTest(unittest.TestCase):
|
|
53
|
+
def test_sqlite_adapter_satisfies_memory_protocol(self):
|
|
54
|
+
from loom_continuity import KnowledgeStore, SqliteKnowledgeStore
|
|
55
|
+
|
|
56
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
57
|
+
self.assertIsInstance(SqliteKnowledgeStore(str(Path(tmp) / "m.sqlite3")), KnowledgeStore)
|
|
58
|
+
|
|
59
|
+
def test_valid_means_dependency_versions_match(self):
|
|
60
|
+
from loom_continuity import SqliteKnowledgeStore
|
|
61
|
+
|
|
62
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
63
|
+
memory = SqliteKnowledgeStore(str(Path(tmp) / "m.sqlite3"))
|
|
64
|
+
memory.put_fact("s", "color", "blue", "seed", [])
|
|
65
|
+
v = memory.get_current_version("s", "color")
|
|
66
|
+
memory.put_fact("s", "action", "monitor", "derive", [{"key": "color", "version": v}])
|
|
67
|
+
self.assertEqual(memory.get_valid_fact("s", "action").status, "VALID")
|
|
68
|
+
memory.put_fact("s", "color", "red", "mutate", [])
|
|
69
|
+
lookup = memory.get_valid_fact("s", "action")
|
|
70
|
+
self.assertEqual(lookup.status, "STALE")
|
|
71
|
+
self.assertEqual(lookup.fact.value, "monitor")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class OutboxTest(unittest.TestCase):
|
|
75
|
+
def test_sqlite_adapter_satisfies_outbox_protocol(self):
|
|
76
|
+
from loom_continuity import Outbox, SqliteOutbox
|
|
77
|
+
|
|
78
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
79
|
+
self.assertIsInstance(SqliteOutbox(str(Path(tmp) / "o.sqlite3")), Outbox)
|
|
80
|
+
|
|
81
|
+
def test_commit_is_atomic_and_cas_rejects_moved_evidence(self):
|
|
82
|
+
from loom_continuity import SqliteOutbox, SqliteKnowledgeStore, commit_fact_with_event, commit_fact_with_event_if_versions_match
|
|
83
|
+
|
|
84
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
85
|
+
path = str(Path(tmp) / "o.sqlite3")
|
|
86
|
+
memory = SqliteKnowledgeStore(path)
|
|
87
|
+
outbox = SqliteOutbox(path)
|
|
88
|
+
memory.put_fact("s", "policy", "platform", "seed", [])
|
|
89
|
+
version, event_id = commit_fact_with_event(
|
|
90
|
+
memory, outbox, "s", "route", {"team": "platform"}, "compute",
|
|
91
|
+
[{"key": "policy", "version": 1}],
|
|
92
|
+
)
|
|
93
|
+
self.assertEqual(version, 1)
|
|
94
|
+
self.assertEqual(len(outbox.pending("s")), 1)
|
|
95
|
+
self.assertEqual(outbox.get(event_id).version, 1)
|
|
96
|
+
|
|
97
|
+
memory.put_fact("s", "policy", "escalations", "change", [])
|
|
98
|
+
rejected = commit_fact_with_event_if_versions_match(
|
|
99
|
+
memory, outbox, "s", "route", {"team": "old"}, "repair",
|
|
100
|
+
[{"key": "policy", "version": 1}],
|
|
101
|
+
{"policy": 1},
|
|
102
|
+
)
|
|
103
|
+
self.assertIsNone(rejected)
|
|
104
|
+
self.assertEqual(memory.get_fact("s", "route").value["team"], "platform")
|
|
105
|
+
self.assertEqual(len(outbox.pending("s")), 1)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class LeaseTest(unittest.TestCase):
|
|
109
|
+
def test_sqlite_adapter_satisfies_lease_protocol(self):
|
|
110
|
+
from loom_continuity import RepairLeaseStore, SqliteRepairLease
|
|
111
|
+
|
|
112
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
113
|
+
self.assertIsInstance(SqliteRepairLease(str(Path(tmp) / "l.sqlite3")), RepairLeaseStore)
|
|
114
|
+
|
|
115
|
+
def test_second_owner_is_denied_until_release(self):
|
|
116
|
+
from loom_continuity import ClaimStatus, SqliteRepairLease
|
|
117
|
+
|
|
118
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
119
|
+
path = str(Path(tmp) / "l.sqlite3")
|
|
120
|
+
leases = SqliteRepairLease(path)
|
|
121
|
+
first = leases.claim("s", "route", "owner-a", {"policy": 1}, lease_seconds=30)
|
|
122
|
+
denied = leases.claim("s", "route", "owner-b", {"policy": 1}, lease_seconds=30)
|
|
123
|
+
self.assertEqual(first.status, ClaimStatus.ACQUIRED)
|
|
124
|
+
self.assertEqual(denied.status, ClaimStatus.DENIED)
|
|
125
|
+
leases.release("s", "route", "owner-a")
|
|
126
|
+
acquired = leases.claim("s", "route", "owner-b", {"policy": 2}, lease_seconds=30)
|
|
127
|
+
self.assertEqual(acquired.status, ClaimStatus.ACQUIRED)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
if __name__ == "__main__":
|
|
131
|
+
unittest.main()
|