copresence 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.
- copresence-0.1.0/.github/workflows/ci.yml +16 -0
- copresence-0.1.0/.gitignore +5 -0
- copresence-0.1.0/LICENSE +202 -0
- copresence-0.1.0/PKG-INFO +130 -0
- copresence-0.1.0/README.md +112 -0
- copresence-0.1.0/pyproject.toml +31 -0
- copresence-0.1.0/src/copresence/__init__.py +12 -0
- copresence-0.1.0/src/copresence/cli.py +63 -0
- copresence-0.1.0/src/copresence/core.py +223 -0
- copresence-0.1.0/src/copresence/demo.py +80 -0
- copresence-0.1.0/src/copresence/recall.py +154 -0
- copresence-0.1.0/tests/test_copresence.py +87 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
name: ci
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ${{ matrix.os }}
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
os: [ubuntu-latest, macos-latest]
|
|
9
|
+
python: ["3.10", "3.11", "3.12"]
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: actions/setup-python@v5
|
|
13
|
+
with: { python-version: "${{ matrix.python }}" }
|
|
14
|
+
- run: pip install -e . pytest
|
|
15
|
+
- run: pytest tests/ -q
|
|
16
|
+
- run: copresence demo
|
copresence-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: copresence
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Associative memory for event streams: link moments by co-presence (what was there), not just similarity (what it resembles).
|
|
5
|
+
Project-URL: Homepage, https://github.com/myfjin/copresence
|
|
6
|
+
Author: Illia Hladkyi
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: agents,associative,context,memory,recall,spreading-activation
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Provides-Extra: embeddings
|
|
16
|
+
Requires-Dist: sentence-transformers>=2.2; extra == 'embeddings'
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# copresence
|
|
20
|
+
|
|
21
|
+
**Associative memory for event streams: link moments by co-presence (what was
|
|
22
|
+
_there_), not just similarity (what it _resembles_). A complement to embedding
|
|
23
|
+
recall, not a replacement.**
|
|
24
|
+
|
|
25
|
+
Zero dependencies. Pure stdlib. `pip install copresence`
|
|
26
|
+
|
|
27
|
+
## The black t-shirt law
|
|
28
|
+
|
|
29
|
+
You're trying to remember a conversation from years ago. Searching your memory by
|
|
30
|
+
*content* gives you nothing — the conversation doesn't resemble anything you can
|
|
31
|
+
name. Then a friend says: *"you were wearing that black t-shirt… the kettle was
|
|
32
|
+
on… it was the day the heater broke"* — and the whole context **rebuilds itself**.
|
|
33
|
+
|
|
34
|
+
The t-shirt never resembled the conversation. It was simply **there**.
|
|
35
|
+
|
|
36
|
+
Embedding-based recall (RAG, vector search) is content-similarity: it finds what
|
|
37
|
+
*resembles* the query. It systematically misses everything that was merely
|
|
38
|
+
*co-present* — the peripheral details, the same-day decisions, the parallel
|
|
39
|
+
threads. `copresence` adds that missing axis: it links moments **by having shared
|
|
40
|
+
a scene, a day, an era**, and recalls by **spreading activation** through those
|
|
41
|
+
links.
|
|
42
|
+
|
|
43
|
+
## Quickstart
|
|
44
|
+
|
|
45
|
+
```python
|
|
46
|
+
from copresence import Store, ref, hash_embedder, probe
|
|
47
|
+
|
|
48
|
+
events = [ # any event stream: chat logs, journals, tickets, commits…
|
|
49
|
+
{"timestamp": "2026-01-05T10:00:00+00:00", "speaker": "a", "content": "…"},
|
|
50
|
+
# {"timestamp": ISO-8601, "speaker": str, "content": str}
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
store = Store("./layers")
|
|
54
|
+
store.link(events) # co-presence links: scene + day + era
|
|
55
|
+
store.notice(ref(events[3]), "me", "this mattered") # authored mark
|
|
56
|
+
|
|
57
|
+
# recall by cascade: seeds spread through co-presence, not similarity
|
|
58
|
+
adj = store.graph("me")
|
|
59
|
+
context = store.cascade({ref(events[3])}, adj)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or just run the built-in demonstration:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
pip install copresence
|
|
66
|
+
copresence demo
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
which builds a small synthetic corpus where a red kettle whistles near lighthouse
|
|
70
|
+
work (separate moments, same scenes), shows that similarity recall ranks the
|
|
71
|
+
target poorly for the kettle cue, and then rebuilds it through the co-presence
|
|
72
|
+
cascade. Deterministic; runs anywhere; no model download.
|
|
73
|
+
|
|
74
|
+
## Concepts
|
|
75
|
+
|
|
76
|
+
- **Layers are sovereign.** Every member (person, agent) gets their own link
|
|
77
|
+
files. `links.jsonl` is *authored* — appended, never rebuilt. `auto_links.jsonl`
|
|
78
|
+
is *derived* — rebuilt by the linker on every run. Structural co-presence is
|
|
79
|
+
shared because it's computable from timestamps alone.
|
|
80
|
+
- **"That day" is a memory node.** Co-presence works at three scales: adjacency
|
|
81
|
+
(the scene), day-hubs, era-hubs (the week). Touching one moment can pull what
|
|
82
|
+
else the day held.
|
|
83
|
+
- **Presence weighting.** Not every co-occurrence is equal: moments carrying
|
|
84
|
+
gravity markers or notice-marks weigh their links up; incidental co-occurrence
|
|
85
|
+
weighs half.
|
|
86
|
+
- **Decay with resurrection.** Unused links fade toward a dormant floor — dormancy,
|
|
87
|
+
never death. When a cascade touches a dormant link, it re-animates the *whole
|
|
88
|
+
scene* around it, not just the node.
|
|
89
|
+
- **Warming.** `warm()` scores which *dormant* moments are heating up under the
|
|
90
|
+
current context (heat = kinship-to-now × dormancy), with a `should_interrupt`
|
|
91
|
+
attention boundary — the caller decides whether to speak. Feed it the record's
|
|
92
|
+
**tail**, not a query: continuity anchors in time, not similarity. We learned
|
|
93
|
+
that from a failed probe and kept the law.
|
|
94
|
+
- **Discovery.** `discover_regular()` maps the day-to-day roads (themes alive
|
|
95
|
+
across many days); `discover_deep()` hunts dormant chains — strong kinship
|
|
96
|
+
across a long gap, never cross-referenced. These are **candidates, not claims**:
|
|
97
|
+
whether a deep pair "hits" is the member's subjective call.
|
|
98
|
+
- **Resonance.** Which moments live in one member's layer (private wealth), two,
|
|
99
|
+
or all? The field is computed; verdicts are not — private wealth is never
|
|
100
|
+
forced shared.
|
|
101
|
+
|
|
102
|
+
## Embedders
|
|
103
|
+
|
|
104
|
+
The cascade, linker, notices and layers are pure stdlib. `discover`, `warm` and
|
|
105
|
+
`probe` need an embedder — any callable `list[str] -> vectors`:
|
|
106
|
+
|
|
107
|
+
```python
|
|
108
|
+
from copresence import hash_embedder # zero-dep hashing bag-of-words (a TOY —
|
|
109
|
+
# fine for demos/tests, no real semantics)
|
|
110
|
+
from copresence import st_embedder # real: pip install copresence[embeddings]
|
|
111
|
+
embed = st_embedder() # sentence-transformers MiniLM
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
## Honest notes
|
|
115
|
+
|
|
116
|
+
- The mechanism is young. In our own (private) corpus of ~520 events, a target
|
|
117
|
+
that content-recall ranked 265th was rebuilt by a two-hop cascade from six
|
|
118
|
+
peripheral cues (similarity 0.03–0.18 to the query), and a negative control —
|
|
119
|
+
the same cues against a different-day target — was correctly *not* reached.
|
|
120
|
+
The bundled demo reproduces the same geometry synthetically on your machine;
|
|
121
|
+
it proves the mechanism, not magic on your data.
|
|
122
|
+
- Mood/register link heuristics exist but are **experimental and off by default**
|
|
123
|
+
(`Store(registers=...)`) — our own instance is too young to have proven them.
|
|
124
|
+
- The hashing embedder is a toy. Real corpora deserve a real embedder.
|
|
125
|
+
- `discover_deep` can surface echo-shaped pairs (a summary of a moment vs the
|
|
126
|
+
moment). The identity cap catches most; your judgment catches the rest.
|
|
127
|
+
|
|
128
|
+
## License
|
|
129
|
+
|
|
130
|
+
Apache-2.0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# copresence
|
|
2
|
+
|
|
3
|
+
**Associative memory for event streams: link moments by co-presence (what was
|
|
4
|
+
_there_), not just similarity (what it _resembles_). A complement to embedding
|
|
5
|
+
recall, not a replacement.**
|
|
6
|
+
|
|
7
|
+
Zero dependencies. Pure stdlib. `pip install copresence`
|
|
8
|
+
|
|
9
|
+
## The black t-shirt law
|
|
10
|
+
|
|
11
|
+
You're trying to remember a conversation from years ago. Searching your memory by
|
|
12
|
+
*content* gives you nothing — the conversation doesn't resemble anything you can
|
|
13
|
+
name. Then a friend says: *"you were wearing that black t-shirt… the kettle was
|
|
14
|
+
on… it was the day the heater broke"* — and the whole context **rebuilds itself**.
|
|
15
|
+
|
|
16
|
+
The t-shirt never resembled the conversation. It was simply **there**.
|
|
17
|
+
|
|
18
|
+
Embedding-based recall (RAG, vector search) is content-similarity: it finds what
|
|
19
|
+
*resembles* the query. It systematically misses everything that was merely
|
|
20
|
+
*co-present* — the peripheral details, the same-day decisions, the parallel
|
|
21
|
+
threads. `copresence` adds that missing axis: it links moments **by having shared
|
|
22
|
+
a scene, a day, an era**, and recalls by **spreading activation** through those
|
|
23
|
+
links.
|
|
24
|
+
|
|
25
|
+
## Quickstart
|
|
26
|
+
|
|
27
|
+
```python
|
|
28
|
+
from copresence import Store, ref, hash_embedder, probe
|
|
29
|
+
|
|
30
|
+
events = [ # any event stream: chat logs, journals, tickets, commits…
|
|
31
|
+
{"timestamp": "2026-01-05T10:00:00+00:00", "speaker": "a", "content": "…"},
|
|
32
|
+
# {"timestamp": ISO-8601, "speaker": str, "content": str}
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
store = Store("./layers")
|
|
36
|
+
store.link(events) # co-presence links: scene + day + era
|
|
37
|
+
store.notice(ref(events[3]), "me", "this mattered") # authored mark
|
|
38
|
+
|
|
39
|
+
# recall by cascade: seeds spread through co-presence, not similarity
|
|
40
|
+
adj = store.graph("me")
|
|
41
|
+
context = store.cascade({ref(events[3])}, adj)
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Or just run the built-in demonstration:
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
pip install copresence
|
|
48
|
+
copresence demo
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
which builds a small synthetic corpus where a red kettle whistles near lighthouse
|
|
52
|
+
work (separate moments, same scenes), shows that similarity recall ranks the
|
|
53
|
+
target poorly for the kettle cue, and then rebuilds it through the co-presence
|
|
54
|
+
cascade. Deterministic; runs anywhere; no model download.
|
|
55
|
+
|
|
56
|
+
## Concepts
|
|
57
|
+
|
|
58
|
+
- **Layers are sovereign.** Every member (person, agent) gets their own link
|
|
59
|
+
files. `links.jsonl` is *authored* — appended, never rebuilt. `auto_links.jsonl`
|
|
60
|
+
is *derived* — rebuilt by the linker on every run. Structural co-presence is
|
|
61
|
+
shared because it's computable from timestamps alone.
|
|
62
|
+
- **"That day" is a memory node.** Co-presence works at three scales: adjacency
|
|
63
|
+
(the scene), day-hubs, era-hubs (the week). Touching one moment can pull what
|
|
64
|
+
else the day held.
|
|
65
|
+
- **Presence weighting.** Not every co-occurrence is equal: moments carrying
|
|
66
|
+
gravity markers or notice-marks weigh their links up; incidental co-occurrence
|
|
67
|
+
weighs half.
|
|
68
|
+
- **Decay with resurrection.** Unused links fade toward a dormant floor — dormancy,
|
|
69
|
+
never death. When a cascade touches a dormant link, it re-animates the *whole
|
|
70
|
+
scene* around it, not just the node.
|
|
71
|
+
- **Warming.** `warm()` scores which *dormant* moments are heating up under the
|
|
72
|
+
current context (heat = kinship-to-now × dormancy), with a `should_interrupt`
|
|
73
|
+
attention boundary — the caller decides whether to speak. Feed it the record's
|
|
74
|
+
**tail**, not a query: continuity anchors in time, not similarity. We learned
|
|
75
|
+
that from a failed probe and kept the law.
|
|
76
|
+
- **Discovery.** `discover_regular()` maps the day-to-day roads (themes alive
|
|
77
|
+
across many days); `discover_deep()` hunts dormant chains — strong kinship
|
|
78
|
+
across a long gap, never cross-referenced. These are **candidates, not claims**:
|
|
79
|
+
whether a deep pair "hits" is the member's subjective call.
|
|
80
|
+
- **Resonance.** Which moments live in one member's layer (private wealth), two,
|
|
81
|
+
or all? The field is computed; verdicts are not — private wealth is never
|
|
82
|
+
forced shared.
|
|
83
|
+
|
|
84
|
+
## Embedders
|
|
85
|
+
|
|
86
|
+
The cascade, linker, notices and layers are pure stdlib. `discover`, `warm` and
|
|
87
|
+
`probe` need an embedder — any callable `list[str] -> vectors`:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from copresence import hash_embedder # zero-dep hashing bag-of-words (a TOY —
|
|
91
|
+
# fine for demos/tests, no real semantics)
|
|
92
|
+
from copresence import st_embedder # real: pip install copresence[embeddings]
|
|
93
|
+
embed = st_embedder() # sentence-transformers MiniLM
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Honest notes
|
|
97
|
+
|
|
98
|
+
- The mechanism is young. In our own (private) corpus of ~520 events, a target
|
|
99
|
+
that content-recall ranked 265th was rebuilt by a two-hop cascade from six
|
|
100
|
+
peripheral cues (similarity 0.03–0.18 to the query), and a negative control —
|
|
101
|
+
the same cues against a different-day target — was correctly *not* reached.
|
|
102
|
+
The bundled demo reproduces the same geometry synthetically on your machine;
|
|
103
|
+
it proves the mechanism, not magic on your data.
|
|
104
|
+
- Mood/register link heuristics exist but are **experimental and off by default**
|
|
105
|
+
(`Store(registers=...)`) — our own instance is too young to have proven them.
|
|
106
|
+
- The hashing embedder is a toy. Real corpora deserve a real embedder.
|
|
107
|
+
- `discover_deep` can surface echo-shaped pairs (a summary of a moment vs the
|
|
108
|
+
moment). The identity cap catches most; your judgment catches the rest.
|
|
109
|
+
|
|
110
|
+
## License
|
|
111
|
+
|
|
112
|
+
Apache-2.0
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "copresence"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Associative memory for event streams: link moments by co-presence (what was there), not just similarity (what it resembles)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [{ name = "Illia Hladkyi" }]
|
|
13
|
+
keywords = ["memory", "associative", "recall", "agents", "context", "spreading-activation"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
embeddings = ["sentence-transformers>=2.2"]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
copresence = "copresence.cli:main"
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://github.com/myfjin/copresence"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["src/copresence"]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""copresence — associative memory for event streams.
|
|
2
|
+
|
|
3
|
+
Link moments by co-presence (what was THERE), not just similarity (what it
|
|
4
|
+
RESEMBLES). A complement to embedding recall, not a replacement.
|
|
5
|
+
"""
|
|
6
|
+
from .core import Store, ref
|
|
7
|
+
from .recall import (discover_deep, discover_regular, hash_embedder, probe,
|
|
8
|
+
st_embedder, warm)
|
|
9
|
+
|
|
10
|
+
__version__ = "0.1.0"
|
|
11
|
+
__all__ = ["Store", "ref", "hash_embedder", "st_embedder",
|
|
12
|
+
"discover_regular", "discover_deep", "warm", "probe", "__version__"]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""copresence CLI — link, notice, discover, warm, demo over an events.jsonl.
|
|
2
|
+
|
|
3
|
+
Events file: one JSON object per line: {"timestamp": ISO-8601, "speaker": str,
|
|
4
|
+
"content": str}. Layers live under --root (default ./copresence_layers).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .core import Store, ref
|
|
13
|
+
from .recall import discover_deep, discover_regular, hash_embedder, warm
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _events(p: str) -> list[dict]:
|
|
17
|
+
return [json.loads(l) for l in Path(p).read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None):
|
|
21
|
+
ap = argparse.ArgumentParser(prog="copresence", description=__doc__)
|
|
22
|
+
ap.add_argument("cmd", choices=["link", "notice", "discover", "warm", "demo"])
|
|
23
|
+
ap.add_argument("--events", default="events.jsonl")
|
|
24
|
+
ap.add_argument("--root", default="copresence_layers")
|
|
25
|
+
ap.add_argument("--member", default="member")
|
|
26
|
+
ap.add_argument("--note", default="")
|
|
27
|
+
ap.add_argument("--ref", default="last")
|
|
28
|
+
ap.add_argument("--gap", type=int, default=7)
|
|
29
|
+
a = ap.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
if a.cmd == "demo":
|
|
32
|
+
from .demo import run
|
|
33
|
+
run()
|
|
34
|
+
return
|
|
35
|
+
store = Store(a.root)
|
|
36
|
+
ev = _events(a.events)
|
|
37
|
+
if a.cmd == "link":
|
|
38
|
+
print(json.dumps(store.link(ev)))
|
|
39
|
+
elif a.cmd == "notice":
|
|
40
|
+
r = ref(ev[-1]) if a.ref == "last" else a.ref
|
|
41
|
+
row = store.notice(r, a.member, a.note)
|
|
42
|
+
print(f"noticed {row['from']} by {a.member} ({row['link_id']})")
|
|
43
|
+
elif a.cmd == "discover":
|
|
44
|
+
X = hash_embedder([e["content"] for e in ev])
|
|
45
|
+
print("== regular roads (themes alive across days) ==")
|
|
46
|
+
for r in discover_regular(ev, X):
|
|
47
|
+
print(f" {r['days_alive']}d alive · {r['n_events']} events · “{r['exemplar'][:70]}”")
|
|
48
|
+
print(f"== deep candidates (kin across ≥{a.gap}d, never cross-referenced) ==")
|
|
49
|
+
for d in discover_deep(ev, X, gap_days=a.gap):
|
|
50
|
+
print(f" sim {d['sim']} across {d['gap_days']}d")
|
|
51
|
+
print(f" THEN “{d['then']['excerpt'][:70]}”\n NOW “{d['now']['excerpt'][:70]}”")
|
|
52
|
+
print("(candidates, not claims — whether one HITS is the member's call)")
|
|
53
|
+
elif a.cmd == "warm":
|
|
54
|
+
X = hash_embedder([e["content"] for e in ev])
|
|
55
|
+
tail = " ".join(e["content"][:400] for e in ev[-3:])
|
|
56
|
+
v = hash_embedder([tail])[0]
|
|
57
|
+
for w in warm(ev, X, v):
|
|
58
|
+
mark = " ⚡" if w["should_interrupt"] else ""
|
|
59
|
+
print(f" heat {w['heat']} · {w['age_days']}d dormant{mark} · “{w['excerpt'][:70]}”")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""copresence.core — link moments by co-presence (what was THERE),
|
|
2
|
+
not just similarity (what it RESEMBLES).
|
|
3
|
+
|
|
4
|
+
Pure stdlib. The mechanics:
|
|
5
|
+
|
|
6
|
+
EVENTS a list of {"timestamp": ISO-8601, "speaker": str, "content": str}
|
|
7
|
+
LAYERS per-member link files under a root dir:
|
|
8
|
+
<root>/<member>/links.jsonl AUTHORED — appended, never rebuilt
|
|
9
|
+
<root>/<member>/auto_links.jsonl DERIVED — rebuilt by the linker
|
|
10
|
+
<root>/_structural/ fact-derived co-presence (shared)
|
|
11
|
+
LINKER co-presence at three scales: adjacency (the scene), day-hubs ("that day"
|
|
12
|
+
is itself a memory node), era-hubs (the week). Weights carry PRESENCE
|
|
13
|
+
(gravity moments outweigh incidental co-occurrence) and DECAY at read
|
|
14
|
+
time (dormancy, never death).
|
|
15
|
+
CASCADE spreading activation with RESURRECTION: touching a dormant link
|
|
16
|
+
re-animates its whole scene, not just the node.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
from collections import defaultdict
|
|
23
|
+
from datetime import datetime, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# ── tunables (documented defaults; override via Store kwargs) ───────────────────
|
|
27
|
+
ADJ_MIN = 45 # minutes: adjacency = the scene
|
|
28
|
+
HALF_LIFE_D = 14 # days: effective link weight halves per half-life unused
|
|
29
|
+
DORMANT_FLOOR = 0.05 # links fade to dormancy, never to zero
|
|
30
|
+
RESURRECT_T = 0.15 # touching a link this weak re-animates its scene
|
|
31
|
+
CASCADE_HOPS = 2
|
|
32
|
+
|
|
33
|
+
# gravity: markers of moments that MATTER (generic defaults — pass your own)
|
|
34
|
+
GRAVITY = ("this matters", "important", "remember this", "decision",
|
|
35
|
+
"never forget", "sealed", "i was wrong")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _ts(s: str) -> datetime:
|
|
39
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _day(e: dict) -> str:
|
|
43
|
+
return e["timestamp"][:10]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ref(e: dict) -> str:
|
|
47
|
+
"""Stable reference for an event: ev:<timestamp>:<speaker>."""
|
|
48
|
+
return f"ev:{e['timestamp']}:{e.get('speaker', '?')}"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Store:
|
|
52
|
+
"""A copresence layer store rooted at a directory. Sovereignty by construction:
|
|
53
|
+
every member's layer is a separate file; authored rows are never rebuilt."""
|
|
54
|
+
|
|
55
|
+
def __init__(self, root: str | Path, adj_min: int = ADJ_MIN,
|
|
56
|
+
half_life_d: float = HALF_LIFE_D, gravity: tuple = GRAVITY,
|
|
57
|
+
registers: dict[str, tuple] | None = None):
|
|
58
|
+
self.root = Path(root)
|
|
59
|
+
self.adj_min = adj_min
|
|
60
|
+
self.half_life_d = half_life_d
|
|
61
|
+
self.gravity = tuple(g.lower() for g in gravity)
|
|
62
|
+
# registers: OPTIONAL per-member mood heuristics {register: (markers,)}.
|
|
63
|
+
# Experimental — off by default; mood links are openly subjective.
|
|
64
|
+
self.registers = registers or {}
|
|
65
|
+
|
|
66
|
+
# ── files ────────────────────────────────────────────────────────────────
|
|
67
|
+
def layer_file(self, member: str, auto: bool = False) -> Path:
|
|
68
|
+
d = self.root / member
|
|
69
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
return d / ("auto_links.jsonl" if auto else "links.jsonl")
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _load(p: Path) -> list[dict]:
|
|
74
|
+
if not p.exists():
|
|
75
|
+
return []
|
|
76
|
+
return [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
|
|
77
|
+
|
|
78
|
+
def _mk(self, member, kind, fr, to, weight, texture="", authored="linker", note=""):
|
|
79
|
+
return {"link_id": hashlib.sha1(f"{member}|{kind}|{fr}|{to}".encode()).hexdigest()[:12],
|
|
80
|
+
"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
81
|
+
"member": member, "kind": kind, "from": fr, "to": to,
|
|
82
|
+
"weight": round(float(weight), 3), "texture": texture,
|
|
83
|
+
"authored_by": authored, "note": note}
|
|
84
|
+
|
|
85
|
+
# ── presence & decay ─────────────────────────────────────────────────────
|
|
86
|
+
def _notice_refs(self) -> set:
|
|
87
|
+
refs = set()
|
|
88
|
+
if self.root.exists():
|
|
89
|
+
for p in self.root.glob("*/links.jsonl"):
|
|
90
|
+
for l in self._load(p):
|
|
91
|
+
if l.get("kind") == "notice":
|
|
92
|
+
refs.add(l["from"])
|
|
93
|
+
return refs
|
|
94
|
+
|
|
95
|
+
def presence(self, e: dict, notice_refs: set) -> float:
|
|
96
|
+
"""0.5 incidental · +0.3 gravity marker · +0.2 notice-marked · cap 1.0"""
|
|
97
|
+
p, low = 0.5, e["content"].lower()
|
|
98
|
+
if any(g in low for g in self.gravity):
|
|
99
|
+
p += 0.3
|
|
100
|
+
if ref(e) in notice_refs:
|
|
101
|
+
p += 0.2
|
|
102
|
+
return min(1.0, p)
|
|
103
|
+
|
|
104
|
+
def decayed(self, w: float, r: str, now: datetime | None = None) -> float:
|
|
105
|
+
"""Read-time decay toward the dormant floor. Hubs (day:/era:) don't decay —
|
|
106
|
+
they are places, not moments."""
|
|
107
|
+
if not r.startswith("ev:"):
|
|
108
|
+
return w
|
|
109
|
+
try:
|
|
110
|
+
age_d = ((now or datetime.now(timezone.utc))
|
|
111
|
+
- _ts(r.split(":", 1)[1].rsplit(":", 1)[0])).days
|
|
112
|
+
except ValueError:
|
|
113
|
+
return w
|
|
114
|
+
return max(DORMANT_FLOOR, w * (0.5 ** (age_d / self.half_life_d)))
|
|
115
|
+
|
|
116
|
+
# ── the linker (derived layers, rebuilt each run) ────────────────────────
|
|
117
|
+
def link(self, events: list[dict]) -> dict:
|
|
118
|
+
nrefs = self._notice_refs()
|
|
119
|
+
srows = []
|
|
120
|
+
for a, b in zip(events, events[1:]):
|
|
121
|
+
gap_min = (_ts(b["timestamp"]) - _ts(a["timestamp"])).total_seconds() / 60
|
|
122
|
+
if 0 <= gap_min <= self.adj_min:
|
|
123
|
+
base = max(0.3, 1 - gap_min / self.adj_min)
|
|
124
|
+
pres = (self.presence(a, nrefs) + self.presence(b, nrefs)) / 2
|
|
125
|
+
srows.append(self._mk("_structural", "co-presence", ref(a), ref(b), base * pres))
|
|
126
|
+
for e in events:
|
|
127
|
+
srows.append(self._mk("_structural", "day", f"day:{_day(e)}", ref(e),
|
|
128
|
+
0.4 * self.presence(e, nrefs)))
|
|
129
|
+
for d in sorted({_day(e) for e in events}):
|
|
130
|
+
y, w, _ = datetime.fromisoformat(d).isocalendar()
|
|
131
|
+
srows.append(self._mk("_structural", "era", f"era:{y}-W{w:02d}", f"day:{d}", 0.3))
|
|
132
|
+
self.layer_file("_structural", auto=True).write_text(
|
|
133
|
+
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in srows), encoding="utf-8")
|
|
134
|
+
out = {"structural": len(srows)}
|
|
135
|
+
# optional mood roads (experimental, per-member registers)
|
|
136
|
+
for member, regs in self.registers.items():
|
|
137
|
+
by = defaultdict(list)
|
|
138
|
+
for e in events:
|
|
139
|
+
best, hits = "", 0
|
|
140
|
+
low = e["content"].lower()
|
|
141
|
+
for k, sigs in regs.items():
|
|
142
|
+
h = sum(1 for s in sigs if s.lower() in low)
|
|
143
|
+
if h > hits:
|
|
144
|
+
best, hits = k, h
|
|
145
|
+
if best:
|
|
146
|
+
by[best].append(e)
|
|
147
|
+
arows = []
|
|
148
|
+
for reg, evs in by.items():
|
|
149
|
+
for a, b in zip(evs, evs[1:]):
|
|
150
|
+
if (_ts(b["timestamp"]) - _ts(a["timestamp"])).total_seconds() >= 12 * 3600:
|
|
151
|
+
arows.append(self._mk(member, "mood", ref(a), ref(b), 0.5, texture=reg))
|
|
152
|
+
self.layer_file(member, auto=True).write_text(
|
|
153
|
+
"".join(json.dumps(r, ensure_ascii=False) + "\n" for r in arows), encoding="utf-8")
|
|
154
|
+
out[f"{member}_mood"] = len(arows)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
# ── authored rows (never rebuilt) ────────────────────────────────────────
|
|
158
|
+
def notice(self, r: str, member: str, note: str = "") -> dict:
|
|
159
|
+
"""'This moment should be noticed' — an authored mark, a fact whose content
|
|
160
|
+
is subjective."""
|
|
161
|
+
row = self._mk(member, "notice", r, r, 1.0, authored="notice", note=note)
|
|
162
|
+
with self.layer_file(member).open("a", encoding="utf-8") as f:
|
|
163
|
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
164
|
+
return row
|
|
165
|
+
|
|
166
|
+
def add_link(self, member, kind, fr, to, weight=1.0, texture="", note="") -> dict:
|
|
167
|
+
row = self._mk(member, kind, fr, to, weight, texture=texture, authored="member", note=note)
|
|
168
|
+
with self.layer_file(member).open("a", encoding="utf-8") as f:
|
|
169
|
+
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
170
|
+
return row
|
|
171
|
+
|
|
172
|
+
# ── graph + cascade ──────────────────────────────────────────────────────
|
|
173
|
+
def graph(self, member: str) -> dict:
|
|
174
|
+
adj = defaultdict(list)
|
|
175
|
+
for src in ("_structural", member):
|
|
176
|
+
for auto in (False, True):
|
|
177
|
+
for l in self._load(self.layer_file(src, auto=auto)):
|
|
178
|
+
adj[l["from"]].append((l["to"], l["weight"], l["kind"]))
|
|
179
|
+
adj[l["to"]].append((l["from"], l["weight"], l["kind"]))
|
|
180
|
+
return adj
|
|
181
|
+
|
|
182
|
+
def cascade(self, seeds: set, adj: dict, hops: int = CASCADE_HOPS) -> set:
|
|
183
|
+
"""Spreading activation, hop-bounded. RESURRECTION: reaching a moment
|
|
184
|
+
through a dormant link re-animates its whole scene (its day-hub spokes)."""
|
|
185
|
+
now = datetime.now(timezone.utc)
|
|
186
|
+
seen, frontier = set(seeds), list(seeds)
|
|
187
|
+
for _ in range(hops):
|
|
188
|
+
nxt = []
|
|
189
|
+
for r in frontier:
|
|
190
|
+
for t, w, _k in adj.get(r, ()):
|
|
191
|
+
if t in seen:
|
|
192
|
+
continue
|
|
193
|
+
seen.add(t)
|
|
194
|
+
nxt.append(t)
|
|
195
|
+
if t.startswith("ev:") and self.decayed(w, t, now) <= RESURRECT_T:
|
|
196
|
+
for d in (x for x, _w2, k2 in adj.get(t, ()) if k2 == "day"):
|
|
197
|
+
for spoke, _w3, _k3 in adj.get(d, ()):
|
|
198
|
+
if spoke not in seen:
|
|
199
|
+
seen.add(spoke)
|
|
200
|
+
nxt.append(spoke)
|
|
201
|
+
frontier = nxt
|
|
202
|
+
return seen
|
|
203
|
+
|
|
204
|
+
def resonance(self) -> dict:
|
|
205
|
+
"""Which moments live in which members' layers: all-three / two / private
|
|
206
|
+
wealth. The field, not a verdict — private wealth is never forced shared."""
|
|
207
|
+
members = [d.name for d in self.root.iterdir()
|
|
208
|
+
if d.is_dir() and d.name != "_structural"] if self.root.exists() else []
|
|
209
|
+
holds = defaultdict(set)
|
|
210
|
+
for m in members:
|
|
211
|
+
for auto in (False, True):
|
|
212
|
+
for l in self._load(self.layer_file(m, auto=auto)):
|
|
213
|
+
for r in (l["from"], l["to"]):
|
|
214
|
+
if not r.startswith(("day:", "era:")):
|
|
215
|
+
holds[r].add(m)
|
|
216
|
+
field = {"members": members, "shared_by_all": [], "shared_by_two": [], "private_wealth": []}
|
|
217
|
+
for r, ms in holds.items():
|
|
218
|
+
key = ("shared_by_all" if len(ms) >= 3 else
|
|
219
|
+
"shared_by_two" if len(ms) == 2 else "private_wealth")
|
|
220
|
+
field[key].append({"ref": r, "held_by": sorted(ms)})
|
|
221
|
+
field["counts"] = {k: len(field[k]) for k in
|
|
222
|
+
("shared_by_all", "shared_by_two", "private_wealth")}
|
|
223
|
+
return field
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""The reproducible demo: a small life, a cold recall, the black t-shirt law.
|
|
2
|
+
|
|
3
|
+
A synthetic three-day corpus where a peripheral detail (a red kettle) co-occurs
|
|
4
|
+
with real content (lighthouse work) in SEPARATE moments of the same scenes —
|
|
5
|
+
so similarity alone cannot bridge cue → content. Then the probe: direct recall
|
|
6
|
+
ranks the target poorly; six peripheral cues + a two-hop cascade rebuild it.
|
|
7
|
+
|
|
8
|
+
Run: python -m copresence.cli demo (or: copresence demo)
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import tempfile
|
|
13
|
+
from datetime import datetime, timedelta, timezone
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .core import Store, ref
|
|
17
|
+
from .recall import hash_embedder, probe
|
|
18
|
+
|
|
19
|
+
T0 = datetime(2026, 1, 5, 10, 0, tzinfo=timezone.utc)
|
|
20
|
+
|
|
21
|
+
DAYS = [
|
|
22
|
+
(1, [("keeper", "Today I'm building the lighthouse dataset - labeling foghorn audio clips."),
|
|
23
|
+
("helper", "Understood - foghorn clips, labeling pass one."),
|
|
24
|
+
("keeper", "Listen to that - my red kettle has been whistling this whole time."),
|
|
25
|
+
("helper", "I hear it. Back to the clips."),
|
|
26
|
+
("keeper", "Rule for the lighthouse work: keep only clips longer than four seconds.")]),
|
|
27
|
+
(3, [("keeper", "Different job today: migrating the beehive logs to the new format."),
|
|
28
|
+
("helper", "Beehive logs, new format - on it."),
|
|
29
|
+
("keeper", "The neighbor's drill keeps buzzing through the wall."),
|
|
30
|
+
("helper", "Noted the drill. Migration continues."),
|
|
31
|
+
("keeper", "Beehive rule: hive identifiers stay stable across the migration.")]),
|
|
32
|
+
(8, [("keeper", "Back to the lighthouse dataset. Decision: detection threshold is 0.42."),
|
|
33
|
+
("helper", "Threshold 0.42 recorded for the detector."),
|
|
34
|
+
("keeper", "The red kettle is whistling again - same as on the first day.")]),
|
|
35
|
+
# filler days so the similarity slice can't cover the record by luck
|
|
36
|
+
(4, [("keeper", t) for t in (
|
|
37
|
+
"Repotted the tomato seedlings; the south bed drains badly.",
|
|
38
|
+
"Invoice pile again; the printer jammed twice on the tax forms.",
|
|
39
|
+
"Long walk at noon; the heron was back at the pond.",
|
|
40
|
+
"Reorganized the pantry shelves; found expired jars of lentils.",
|
|
41
|
+
"The gutter on the east side needs clearing before the storm.")]),
|
|
42
|
+
(5, [("keeper", t) for t in (
|
|
43
|
+
"Market day: tomatoes, rye bread, a bag of walnuts.",
|
|
44
|
+
"Fixed the squeaky hinge on the workshop door.",
|
|
45
|
+
"The neighbor's cat slept on the woodpile all afternoon.",
|
|
46
|
+
"Drafted a letter and kept only the second half.",
|
|
47
|
+
"Watched the storm roll in from the porch.")]),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def corpus() -> list[dict]:
|
|
52
|
+
events = []
|
|
53
|
+
for day, lines in sorted(DAYS):
|
|
54
|
+
for m, (speaker, content) in enumerate(lines):
|
|
55
|
+
ts = (T0 + timedelta(days=day - 1, minutes=m * 10)).isoformat(timespec="seconds")
|
|
56
|
+
events.append({"timestamp": ts, "speaker": speaker, "content": content})
|
|
57
|
+
return events
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def run() -> dict:
|
|
61
|
+
events = corpus()
|
|
62
|
+
with tempfile.TemporaryDirectory() as td:
|
|
63
|
+
store = Store(Path(td) / "layers")
|
|
64
|
+
counts = store.link(events)
|
|
65
|
+
# target: the day-1 lighthouse opener. cue: the red kettle (peripheral).
|
|
66
|
+
target = ref(events[0])
|
|
67
|
+
X = hash_embedder([e["content"] for e in events])
|
|
68
|
+
r = probe(store, events, X, hash_embedder,
|
|
69
|
+
query="the red kettle whistling", target_ref=target)
|
|
70
|
+
print(f"corpus: {len(events)} events across {len(DAYS)} days · links: {counts}")
|
|
71
|
+
print(f"direct recall rank of target for the kettle-cue query: "
|
|
72
|
+
f"{r['direct_rank']}/{r['n_events']}")
|
|
73
|
+
print(f"cascade from {r['cues']} peripheral cues activated {r['activated']} moments"
|
|
74
|
+
f" → target {'REBUILT ✓' if r['hit'] else 'NOT reached ✗'}")
|
|
75
|
+
print("(the kettle never resembles the lighthouse — it was simply THERE)")
|
|
76
|
+
return r
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
run()
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""copresence.recall — the embedder-facing half: discover, warm, probe.
|
|
2
|
+
|
|
3
|
+
An EMBEDDER is any callable: list[str] -> sequence of same-length numeric vectors.
|
|
4
|
+
Pure-stdlib fallback included (a hashing bag-of-words — honest toy: fine for demos
|
|
5
|
+
and tests, replace with a real model for real corpora):
|
|
6
|
+
|
|
7
|
+
from copresence.recall import hash_embedder # zero deps
|
|
8
|
+
from copresence.recall import st_embedder # pip install copresence[embeddings]
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import math
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
|
|
17
|
+
from .core import Store, ref, _ts
|
|
18
|
+
|
|
19
|
+
DEEP_GAP_DAYS = 7
|
|
20
|
+
DEEP_SIM = 0.60
|
|
21
|
+
DEEP_SIM_CAP = 0.97 # above this = a repeat, not a chain
|
|
22
|
+
WARM_INTERRUPT = 0.50
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def hash_embedder(texts: list[str], dim: int = 256) -> list[list[float]]:
|
|
26
|
+
"""Deterministic hashing bag-of-words. A TOY: no semantics beyond shared words.
|
|
27
|
+
Exists so the package runs with zero dependencies; swap in a real model
|
|
28
|
+
(st_embedder or your own) for anything beyond demos."""
|
|
29
|
+
out = []
|
|
30
|
+
for t in texts:
|
|
31
|
+
v = [0.0] * dim
|
|
32
|
+
for w in re.findall(r"[a-z0-9]+", t.lower()):
|
|
33
|
+
v[int(hashlib.sha1(w.encode()).hexdigest(), 16) % dim] += 1.0
|
|
34
|
+
n = math.sqrt(sum(x * x for x in v)) or 1.0
|
|
35
|
+
out.append([x / n for x in v])
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def st_embedder(model: str = "sentence-transformers/all-MiniLM-L6-v2"):
|
|
40
|
+
"""Factory for a real embedder (requires the [embeddings] extra)."""
|
|
41
|
+
from sentence_transformers import SentenceTransformer
|
|
42
|
+
m = SentenceTransformer(model)
|
|
43
|
+
|
|
44
|
+
def embed(texts: list[str]):
|
|
45
|
+
return m.encode(texts, normalize_embeddings=True)
|
|
46
|
+
return embed
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _dot(a, b) -> float:
|
|
50
|
+
return float(sum(x * y for x, y in zip(a, b)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def discover_regular(events, X, top: int = 12) -> list[dict]:
|
|
54
|
+
"""Day-to-day roads: greedy centroid-free clustering by nearest-neighbor
|
|
55
|
+
chaining (stdlib-friendly); themes alive across many distinct days."""
|
|
56
|
+
n = len(events)
|
|
57
|
+
assigned = [-1] * n
|
|
58
|
+
clusters = []
|
|
59
|
+
for i in range(n):
|
|
60
|
+
if assigned[i] >= 0:
|
|
61
|
+
continue
|
|
62
|
+
members = [i]
|
|
63
|
+
assigned[i] = len(clusters)
|
|
64
|
+
for j in range(i + 1, n):
|
|
65
|
+
if assigned[j] < 0 and _dot(X[i], X[j]) >= 0.5:
|
|
66
|
+
assigned[j] = len(clusters)
|
|
67
|
+
members.append(j)
|
|
68
|
+
clusters.append(members)
|
|
69
|
+
out = []
|
|
70
|
+
for members in clusters:
|
|
71
|
+
days = sorted({events[i]["timestamp"][:10] for i in members})
|
|
72
|
+
if len(days) < 2:
|
|
73
|
+
continue
|
|
74
|
+
out.append({"days_alive": len(days), "first": days[0], "last": days[-1],
|
|
75
|
+
"n_events": len(members),
|
|
76
|
+
"exemplar": events[members[0]]["content"][:160]})
|
|
77
|
+
out.sort(key=lambda r: -r["days_alive"])
|
|
78
|
+
return out[:top]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def discover_deep(events, X, top: int = 10, gap_days: int = DEEP_GAP_DAYS) -> list[dict]:
|
|
82
|
+
"""Dormant chains: strong kinship across a long gap, never cross-referenced,
|
|
83
|
+
below the identity cap. CANDIDATES — whether one HITS is the member's call.
|
|
84
|
+
Same-speaker near-identity pairs are echo-shaped; the cap catches most, the
|
|
85
|
+
member's judgment catches the rest."""
|
|
86
|
+
ts = [_ts(e["timestamp"]) for e in events]
|
|
87
|
+
cands = []
|
|
88
|
+
for i in range(len(events)):
|
|
89
|
+
for j in range(i + 1, len(events)):
|
|
90
|
+
gap = (ts[j] - ts[i]).days
|
|
91
|
+
if gap < gap_days:
|
|
92
|
+
continue
|
|
93
|
+
s = _dot(X[i], X[j])
|
|
94
|
+
if s < DEEP_SIM or s > DEEP_SIM_CAP:
|
|
95
|
+
continue
|
|
96
|
+
if events[i]["timestamp"][:10] in events[j]["content"]:
|
|
97
|
+
continue
|
|
98
|
+
cands.append((s, gap, i, j))
|
|
99
|
+
cands.sort(key=lambda c: -c[0])
|
|
100
|
+
picked, used = [], set()
|
|
101
|
+
for s, gap, i, j in cands:
|
|
102
|
+
if i in used or j in used:
|
|
103
|
+
continue
|
|
104
|
+
used |= {i, j}
|
|
105
|
+
picked.append({"sim": round(s, 3), "gap_days": gap,
|
|
106
|
+
"then": {"ts": events[i]["timestamp"], "excerpt": events[i]["content"][:200]},
|
|
107
|
+
"now": {"ts": events[j]["timestamp"], "excerpt": events[j]["content"][:200]}})
|
|
108
|
+
if len(picked) >= top:
|
|
109
|
+
break
|
|
110
|
+
return picked
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def warm(events, X, context_vec, half_life_d: float = 14.0, top: int = 6) -> list[dict]:
|
|
114
|
+
"""Which DORMANT moments are heating up under the current context? Heat =
|
|
115
|
+
kinship-to-now × dormancy. `should_interrupt` is an attention boundary — the
|
|
116
|
+
caller decides whether to speak. Context should be the record's TAIL, not a
|
|
117
|
+
query: continuity anchors in time (a law we paid for)."""
|
|
118
|
+
now = datetime.now(timezone.utc)
|
|
119
|
+
out = []
|
|
120
|
+
for i, e in enumerate(events):
|
|
121
|
+
age_d = max(0, (now - _ts(e["timestamp"])).days)
|
|
122
|
+
dormancy = 1 - (0.5 ** (age_d / half_life_d))
|
|
123
|
+
heat = _dot(X[i], context_vec) * dormancy
|
|
124
|
+
if heat > 0.15:
|
|
125
|
+
out.append({"ref": ref(e), "heat": round(heat, 3), "age_days": age_d,
|
|
126
|
+
"should_interrupt": heat >= WARM_INTERRUPT,
|
|
127
|
+
"excerpt": e["content"][:120]})
|
|
128
|
+
out.sort(key=lambda r: -r["heat"])
|
|
129
|
+
return out[:top]
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def probe(store: Store, events, X, embedder, query: str, target_ref: str,
|
|
133
|
+
member: str = "member", hops: int = 2) -> dict:
|
|
134
|
+
"""THE run-gate: can a PERIPHERAL cue-bundle rebuild a context that content
|
|
135
|
+
similarity missed? Direct recall ranks the target; the friend-mode takes the
|
|
136
|
+
era's co-present LOW-similarity neighbors (the black t-shirts) and cascades
|
|
137
|
+
from them only."""
|
|
138
|
+
refs = [ref(e) for e in events]
|
|
139
|
+
idx = {r: i for i, r in enumerate(refs)}
|
|
140
|
+
v = embedder([query])[0]
|
|
141
|
+
sims = [_dot(x, v) for x in X]
|
|
142
|
+
order = sorted(range(len(events)), key=lambda i: -sims[i])
|
|
143
|
+
rank = next((k for k, i in enumerate(order, 1) if refs[i] == target_ref), None)
|
|
144
|
+
adj = store.graph(member)
|
|
145
|
+
cues = []
|
|
146
|
+
for i in order[:3]:
|
|
147
|
+
for t, _w, _k in adj.get(refs[i], ()):
|
|
148
|
+
j = idx.get(t)
|
|
149
|
+
if j is not None and sims[j] < 0.30 and t != target_ref:
|
|
150
|
+
cues.append(t)
|
|
151
|
+
cues = list(dict.fromkeys(cues))[:8]
|
|
152
|
+
activated = store.cascade(set(cues), adj, hops)
|
|
153
|
+
return {"direct_rank": rank, "n_events": len(events), "cues": len(cues),
|
|
154
|
+
"activated": len(activated), "hit": target_ref in activated}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Port of the organ's proven selftest checks (10/10 on the private twin,
|
|
2
|
+
2026-07-05) plus the demo run-gate."""
|
|
3
|
+
import json
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
|
|
6
|
+
from copresence import Store, ref, hash_embedder, probe
|
|
7
|
+
from copresence.core import DORMANT_FLOOR
|
|
8
|
+
from copresence import demo
|
|
9
|
+
|
|
10
|
+
T0 = datetime(2026, 7, 1, 10, 0, tzinfo=timezone.utc)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ev(day, minutes, speaker, content, month=7):
|
|
14
|
+
return {"timestamp": T0.replace(month=month, day=day, hour=10 + minutes // 60,
|
|
15
|
+
minute=minutes % 60).isoformat(timespec="seconds"),
|
|
16
|
+
"speaker": speaker, "content": content * 12}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_events():
|
|
20
|
+
a0 = ev(1, 0, "illia", "old spring moment about the shore signal ", month=4)
|
|
21
|
+
a1 = ev(1, 10, "claude", "another old spring moment same scene ", month=4)
|
|
22
|
+
return [a0, a1,
|
|
23
|
+
ev(1, 0, "illia", "we made the decision tonight and sealed it "), # gravity
|
|
24
|
+
ev(1, 10, "claude", "black t-shirt detail: ring on the finger "), # peripheral
|
|
25
|
+
ev(1, 500, "steward", "same day much later scoreboard talk "), # day-scale
|
|
26
|
+
ev(3, 0, "claude", "different day building talk "), # other day
|
|
27
|
+
ev(3, 10, "claude", "plain follow-up chatter about nothing ")] # plain pair
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_linker_scales_and_presence(tmp_path):
|
|
31
|
+
events = make_events()
|
|
32
|
+
store = Store(tmp_path / "layers")
|
|
33
|
+
n = store.link(events)
|
|
34
|
+
assert n["structural"] == 3 + 7 + 3 # adjacency + day-hubs + era-hubs
|
|
35
|
+
rows = store._load(store.layer_file("_structural", auto=True))
|
|
36
|
+
w = {(r["from"], r["to"]): r["weight"] for r in rows if r["kind"] == "co-presence"}
|
|
37
|
+
gravity_pair = w[(ref(events[2]), ref(events[3]))]
|
|
38
|
+
plain_pair = w[(ref(events[5]), ref(events[6]))]
|
|
39
|
+
assert gravity_pair > plain_pair # presence weighting
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_decay_dormancy_never_death(tmp_path):
|
|
43
|
+
store = Store(tmp_path / "layers")
|
|
44
|
+
events = make_events()
|
|
45
|
+
assert store.decayed(1.0, ref(events[0])) == DORMANT_FLOOR # old → floor
|
|
46
|
+
assert store.decayed(1.0, ref(events[2])) > 0.5 # fresh → alive
|
|
47
|
+
assert store.decayed(0.4, "day:2026-07-01") == 0.4 # hubs don't decay
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_cascade_day_boundary_and_resurrection(tmp_path):
|
|
51
|
+
events = make_events()
|
|
52
|
+
store = Store(tmp_path / "layers")
|
|
53
|
+
store.link(events)
|
|
54
|
+
store.add_link("claude", "deep", ref(events[3]), ref(events[0]), 1.0)
|
|
55
|
+
adj = store.graph("claude")
|
|
56
|
+
reached = store.cascade({ref(events[3])}, adj, hops=2)
|
|
57
|
+
assert ref(events[2]) in reached # peripheral → target (the scene)
|
|
58
|
+
assert ref(events[4]) in reached # day-scale via the hub
|
|
59
|
+
assert ref(events[5]) not in reached # other DAY not pulled
|
|
60
|
+
assert ref(events[0]) in reached and ref(events[1]) in reached # RESURRECTION: whole scene
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_notice_and_sovereign_layers(tmp_path):
|
|
64
|
+
events = make_events()
|
|
65
|
+
store = Store(tmp_path / "layers")
|
|
66
|
+
store.link(events)
|
|
67
|
+
row = store.notice(ref(events[2]), "claude", "test")
|
|
68
|
+
assert row["link_id"]
|
|
69
|
+
assert store.layer_file("claude").exists()
|
|
70
|
+
assert store.layer_file("_structural", auto=True).exists()
|
|
71
|
+
field = store.resonance()
|
|
72
|
+
assert field["counts"]["private_wealth"] >= 1
|
|
73
|
+
assert "discordance" not in field # deliberately not computed
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_demo_probe_rebuilds_missed_target(capsys):
|
|
77
|
+
r = demo.run()
|
|
78
|
+
assert r["hit"] is True # cascade rebuilds the target
|
|
79
|
+
assert r["direct_rank"] > 5 # …which similarity ranked poorly
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_events_roundtrip_jsonl(tmp_path):
|
|
83
|
+
events = make_events()
|
|
84
|
+
p = tmp_path / "events.jsonl"
|
|
85
|
+
p.write_text("".join(json.dumps(e) + "\n" for e in events))
|
|
86
|
+
back = [json.loads(l) for l in p.read_text().splitlines()]
|
|
87
|
+
assert back == events
|