memware 0.1.1__py3-none-any.whl
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.
- memware/__init__.py +25 -0
- memware/cli.py +311 -0
- memware/eval.py +208 -0
- memware/index.py +380 -0
- memware/ingest/__init__.py +223 -0
- memware/ingest/claude_code.py +58 -0
- memware/ingest/generic.py +51 -0
- memware/ledger.py +347 -0
- memware/mcp_server.py +96 -0
- memware/passage.py +87 -0
- memware/py.typed +0 -0
- memware/review.py +175 -0
- memware/store.py +209 -0
- memware-0.1.1.dist-info/METADATA +163 -0
- memware-0.1.1.dist-info/RECORD +18 -0
- memware-0.1.1.dist-info/WHEEL +4 -0
- memware-0.1.1.dist-info/entry_points.txt +4 -0
- memware-0.1.1.dist-info/licenses/LICENSE +21 -0
memware/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""memware — memory for AI agents that only remembers the latest truth.
|
|
2
|
+
|
|
3
|
+
Two stores, one SQLite file:
|
|
4
|
+
|
|
5
|
+
* **turns** — immutable evidence: what was said in past sessions, indexed with
|
|
6
|
+
FTS5 for cheap, model-free recall.
|
|
7
|
+
* **beliefs** — a bi-temporal ledger of facts. A new value for the same
|
|
8
|
+
``(subject, relation)`` key supersedes the old one; recall only ever returns
|
|
9
|
+
the currently valid belief. History is kept for audit, never surfaced.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from memware.ledger import Outcome, Policy, approve, assert_belief, current, history, reject
|
|
13
|
+
from memware.store import Store
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"Outcome",
|
|
17
|
+
"Policy",
|
|
18
|
+
"Store",
|
|
19
|
+
"approve",
|
|
20
|
+
"assert_belief",
|
|
21
|
+
"current",
|
|
22
|
+
"history",
|
|
23
|
+
"reject",
|
|
24
|
+
]
|
|
25
|
+
__version__ = "0.1.1"
|
memware/cli.py
ADDED
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""``memware`` command-line interface. Every command is also usable from a hook:
|
|
2
|
+
pass ``--from-hook`` to read the harness's JSON payload on stdin."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from memware import __version__
|
|
12
|
+
from memware.index import (
|
|
13
|
+
read_turns,
|
|
14
|
+
search_beliefs,
|
|
15
|
+
search_beliefs_multi,
|
|
16
|
+
search_turns_multi,
|
|
17
|
+
)
|
|
18
|
+
from memware.ingest import capture_disabled, prune_sources, sync_file, sync_tree
|
|
19
|
+
from memware.ledger import Policy, approve, assert_belief, current, history, reject
|
|
20
|
+
from memware.review import HttpReviewBackend, JsonlReviewBackend, open_reviews, sync_reviews
|
|
21
|
+
from memware.store import DEFAULT_DB, Store
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _hook_payload() -> dict[str, object]:
|
|
25
|
+
try:
|
|
26
|
+
payload = json.load(sys.stdin) if not sys.stdin.isatty() else {}
|
|
27
|
+
except (json.JSONDecodeError, ValueError):
|
|
28
|
+
return {}
|
|
29
|
+
return payload if isinstance(payload, dict) else {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _out(obj: object, as_json: bool) -> None:
|
|
33
|
+
if as_json:
|
|
34
|
+
print(json.dumps(obj, indent=2, default=str))
|
|
35
|
+
elif isinstance(obj, list):
|
|
36
|
+
for row in obj:
|
|
37
|
+
print(row if isinstance(row, str) else json.dumps(row, default=str))
|
|
38
|
+
else:
|
|
39
|
+
print(obj if isinstance(obj, str) else json.dumps(obj, default=str))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_init(a: argparse.Namespace) -> int:
|
|
43
|
+
with Store(a.db) as s:
|
|
44
|
+
_out({"db": str(s.path), **s.stats()}, a.json)
|
|
45
|
+
return 0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cmd_sync(a: argparse.Namespace) -> int:
|
|
49
|
+
if a.from_hook and capture_disabled():
|
|
50
|
+
return 0 # MEMWARE_NO_CAPTURE=1: this run must not enter the store
|
|
51
|
+
paths = list(a.paths)
|
|
52
|
+
if a.from_hook:
|
|
53
|
+
tp = _hook_payload().get("transcript_path")
|
|
54
|
+
if tp:
|
|
55
|
+
paths.append(str(tp))
|
|
56
|
+
if not paths:
|
|
57
|
+
print("nothing to sync", file=sys.stderr)
|
|
58
|
+
return 0
|
|
59
|
+
with Store(a.db) as s:
|
|
60
|
+
report: dict[str, int] = {}
|
|
61
|
+
for p in paths:
|
|
62
|
+
path = Path(p).expanduser()
|
|
63
|
+
if path.is_dir():
|
|
64
|
+
report.update(
|
|
65
|
+
sync_tree(
|
|
66
|
+
s,
|
|
67
|
+
path,
|
|
68
|
+
harness=a.harness,
|
|
69
|
+
skip_if_contains=a.skip_if_contains,
|
|
70
|
+
exclude=a.exclude,
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
elif path.exists():
|
|
74
|
+
report[str(path)] = sync_file(
|
|
75
|
+
s, path, harness=a.harness, skip_if_contains=a.skip_if_contains
|
|
76
|
+
)
|
|
77
|
+
_out({"added": sum(report.values()), "files": len(report)}, a.json or a.from_hook)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def cmd_recall(a: argparse.Namespace) -> int:
|
|
82
|
+
with Store(a.db) as s:
|
|
83
|
+
hits = []
|
|
84
|
+
if a.what in ("all", "beliefs"):
|
|
85
|
+
hits += search_beliefs_multi(s, a.queries, k=a.k, record_use=not a.no_touch)
|
|
86
|
+
if a.what in ("all", "turns"):
|
|
87
|
+
hits += search_turns_multi(
|
|
88
|
+
s, a.queries, k=a.k, record_use=not a.no_touch, snippet_tokens=a.snippet_tokens
|
|
89
|
+
)
|
|
90
|
+
rows = [
|
|
91
|
+
{
|
|
92
|
+
"kind": h.kind,
|
|
93
|
+
"id": h.id,
|
|
94
|
+
"score": round(h.score, 4),
|
|
95
|
+
"session": h.session,
|
|
96
|
+
"ts": h.ts,
|
|
97
|
+
"role": h.role,
|
|
98
|
+
"subject": h.subject,
|
|
99
|
+
"relation": h.relation,
|
|
100
|
+
"source": h.source,
|
|
101
|
+
"offset": h.offset,
|
|
102
|
+
"snippet": h.snippet,
|
|
103
|
+
"text": h.text if a.full else h.text[:300],
|
|
104
|
+
}
|
|
105
|
+
for h in hits
|
|
106
|
+
]
|
|
107
|
+
_out(rows, a.json)
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def cmd_context(a: argparse.Namespace) -> int:
|
|
112
|
+
"""Prompt-time helper: print currently valid beliefs relevant to the prompt."""
|
|
113
|
+
prompt = a.prompt or str(_hook_payload().get("prompt", ""))
|
|
114
|
+
if not prompt.strip():
|
|
115
|
+
return 0
|
|
116
|
+
with Store(a.db) as s:
|
|
117
|
+
hits = search_beliefs(s, prompt, k=a.k, require_subject=True)
|
|
118
|
+
if not hits:
|
|
119
|
+
return 0
|
|
120
|
+
lines = []
|
|
121
|
+
for h in hits:
|
|
122
|
+
value = h.text.removeprefix(f"{h.subject} {h.relation} ")
|
|
123
|
+
since = f" (since {h.ts[:10]})" if h.ts else ""
|
|
124
|
+
lines.append(f"- {h.subject} {h.relation}: {value}{since}")
|
|
125
|
+
block = "Known facts (currently valid, from your memory ledger):\n" + "\n".join(lines)
|
|
126
|
+
if a.from_hook:
|
|
127
|
+
print(
|
|
128
|
+
json.dumps(
|
|
129
|
+
{
|
|
130
|
+
"hookSpecificOutput": {
|
|
131
|
+
"hookEventName": "UserPromptSubmit",
|
|
132
|
+
"additionalContext": block,
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
else:
|
|
138
|
+
print(block)
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_assert(a: argparse.Namespace) -> int:
|
|
143
|
+
with Store(a.db) as s:
|
|
144
|
+
r = assert_belief(
|
|
145
|
+
s,
|
|
146
|
+
a.subject,
|
|
147
|
+
a.relation,
|
|
148
|
+
a.value,
|
|
149
|
+
valid_from=a.valid_from,
|
|
150
|
+
source=a.source,
|
|
151
|
+
reliability=a.reliability,
|
|
152
|
+
policy=Policy(a.policy),
|
|
153
|
+
)
|
|
154
|
+
_out(
|
|
155
|
+
{
|
|
156
|
+
"outcome": r.outcome.value,
|
|
157
|
+
"belief_id": r.belief_id,
|
|
158
|
+
"incumbent_id": r.incumbent_id,
|
|
159
|
+
"review_id": r.review_id,
|
|
160
|
+
},
|
|
161
|
+
a.json,
|
|
162
|
+
)
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def cmd_beliefs(a: argparse.Namespace) -> int:
|
|
167
|
+
with Store(a.db) as s:
|
|
168
|
+
rows = history(s, a.subject, a.relation) if a.relation else current(s, a.subject)
|
|
169
|
+
_out(rows, a.json)
|
|
170
|
+
return 0
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def cmd_read(a: argparse.Namespace) -> int:
|
|
174
|
+
with Store(a.db) as s:
|
|
175
|
+
_out(read_turns(s, a.session, around=a.around, window=a.window), a.json)
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_review(a: argparse.Namespace) -> int:
|
|
180
|
+
with Store(a.db) as s:
|
|
181
|
+
if a.action == "list":
|
|
182
|
+
_out([r.__dict__ for r in open_reviews(s)], a.json)
|
|
183
|
+
elif a.action == "approve":
|
|
184
|
+
_out(approve(s, a.id).__dict__, a.json)
|
|
185
|
+
elif a.action == "reject":
|
|
186
|
+
_out(reject(s, a.id).__dict__, a.json)
|
|
187
|
+
elif a.action == "sync":
|
|
188
|
+
backend: HttpReviewBackend | JsonlReviewBackend = (
|
|
189
|
+
HttpReviewBackend(a.url, a.token)
|
|
190
|
+
if a.url
|
|
191
|
+
else JsonlReviewBackend(a.outbox, a.inbox)
|
|
192
|
+
)
|
|
193
|
+
_out(sync_reviews(s, backend), a.json)
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def cmd_prune(a: argparse.Namespace) -> int:
|
|
198
|
+
with Store(a.db) as s:
|
|
199
|
+
rep = prune_sources(s, glob=a.glob, containing=a.containing)
|
|
200
|
+
_out({"sources_pruned": len(rep), "turns_removed": sum(rep.values())}, a.json)
|
|
201
|
+
return 0
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def cmd_stats(a: argparse.Namespace) -> int:
|
|
205
|
+
with Store(a.db) as s:
|
|
206
|
+
_out({"db": str(s.path), **s.stats()}, a.json)
|
|
207
|
+
return 0
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
211
|
+
p = argparse.ArgumentParser(prog="memware", description=__doc__)
|
|
212
|
+
p.add_argument("--db", default=str(DEFAULT_DB), help="SQLite file (env MEMWARE_DB)")
|
|
213
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
214
|
+
p.add_argument("--version", action="version", version=__version__)
|
|
215
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
216
|
+
|
|
217
|
+
def add(name: str, help: str) -> argparse.ArgumentParser:
|
|
218
|
+
sp = sub.add_parser(name, help=help)
|
|
219
|
+
sp.add_argument("--json", action="store_true", default=argparse.SUPPRESS)
|
|
220
|
+
return sp
|
|
221
|
+
|
|
222
|
+
s = add("init", "create the database")
|
|
223
|
+
s.set_defaults(fn=cmd_init)
|
|
224
|
+
|
|
225
|
+
s = add("sync", "index new turns from transcripts")
|
|
226
|
+
s.add_argument("paths", nargs="*")
|
|
227
|
+
s.add_argument("--harness", default="claude-code")
|
|
228
|
+
s.add_argument("--from-hook", action="store_true")
|
|
229
|
+
s.add_argument(
|
|
230
|
+
"--skip-if-contains",
|
|
231
|
+
metavar="TEXT",
|
|
232
|
+
help="skip (and un-index) files whose head contains TEXT, e.g. an eval marker",
|
|
233
|
+
)
|
|
234
|
+
s.add_argument(
|
|
235
|
+
"--exclude",
|
|
236
|
+
action="append",
|
|
237
|
+
default=[],
|
|
238
|
+
metavar="GLOB",
|
|
239
|
+
help="path glob to skip when syncing a directory (repeatable)",
|
|
240
|
+
)
|
|
241
|
+
s.set_defaults(fn=cmd_sync)
|
|
242
|
+
|
|
243
|
+
s = add("recall", "search turns and beliefs; pass several phrasings to fuse them")
|
|
244
|
+
s.add_argument(
|
|
245
|
+
"queries",
|
|
246
|
+
nargs="+",
|
|
247
|
+
metavar="QUERY",
|
|
248
|
+
help="one or more phrasings: synonyms, related terms, the literal value you expect",
|
|
249
|
+
)
|
|
250
|
+
s.add_argument("-k", type=int, default=8)
|
|
251
|
+
s.add_argument("--what", choices=["all", "turns", "beliefs"], default="all")
|
|
252
|
+
s.add_argument("--full", action="store_true")
|
|
253
|
+
s.add_argument("--no-touch", action="store_true")
|
|
254
|
+
s.add_argument("--snippet-tokens", type=int, default=96, help="FTS5 snippet window (tokens)")
|
|
255
|
+
s.set_defaults(fn=cmd_recall)
|
|
256
|
+
|
|
257
|
+
s = add("context", "print valid beliefs relevant to a prompt (hook-friendly)")
|
|
258
|
+
s.add_argument("prompt", nargs="?")
|
|
259
|
+
s.add_argument("-k", type=int, default=6)
|
|
260
|
+
s.add_argument("--from-hook", action="store_true")
|
|
261
|
+
s.set_defaults(fn=cmd_context)
|
|
262
|
+
|
|
263
|
+
s = add("assert", "record a belief; supersedes the previous value")
|
|
264
|
+
s.add_argument("subject")
|
|
265
|
+
s.add_argument("relation")
|
|
266
|
+
s.add_argument("value")
|
|
267
|
+
s.add_argument("--valid-from")
|
|
268
|
+
s.add_argument("--source")
|
|
269
|
+
s.add_argument("--reliability", type=float, default=0.5)
|
|
270
|
+
s.add_argument(
|
|
271
|
+
"--policy", choices=[x.value for x in Policy], default=Policy.GATE_CONFLICTS.value
|
|
272
|
+
)
|
|
273
|
+
s.set_defaults(fn=cmd_assert)
|
|
274
|
+
|
|
275
|
+
s = add("beliefs", "current beliefs, or the history of one key")
|
|
276
|
+
s.add_argument("subject", nargs="?")
|
|
277
|
+
s.add_argument("relation", nargs="?")
|
|
278
|
+
s.set_defaults(fn=cmd_beliefs)
|
|
279
|
+
|
|
280
|
+
s = add("read", "read a session's turns")
|
|
281
|
+
s.add_argument("session")
|
|
282
|
+
s.add_argument("--around", type=int)
|
|
283
|
+
s.add_argument("--window", type=int, default=5)
|
|
284
|
+
s.set_defaults(fn=cmd_read)
|
|
285
|
+
|
|
286
|
+
s = add("review", "list/approve/reject/sync contested supersessions")
|
|
287
|
+
s.add_argument("action", choices=["list", "approve", "reject", "sync"])
|
|
288
|
+
s.add_argument("id", nargs="?", type=int)
|
|
289
|
+
s.add_argument("--outbox", default="~/.memware/review-outbox.jsonl")
|
|
290
|
+
s.add_argument("--inbox", default="~/.memware/review-inbox.jsonl")
|
|
291
|
+
s.add_argument("--url")
|
|
292
|
+
s.add_argument("--token")
|
|
293
|
+
s.set_defaults(fn=cmd_review)
|
|
294
|
+
|
|
295
|
+
s = add("prune", "un-index sources by path glob and/or content marker")
|
|
296
|
+
s.add_argument("--glob", metavar="GLOB")
|
|
297
|
+
s.add_argument("--containing", metavar="TEXT")
|
|
298
|
+
s.set_defaults(fn=cmd_prune)
|
|
299
|
+
|
|
300
|
+
s = add("stats", "counts")
|
|
301
|
+
s.set_defaults(fn=cmd_stats)
|
|
302
|
+
return p
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def main(argv: list[str] | None = None) -> int:
|
|
306
|
+
a = build_parser().parse_args(argv)
|
|
307
|
+
return int(a.fn(a))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
if __name__ == "__main__":
|
|
311
|
+
raise SystemExit(main())
|
memware/eval.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Retrieval-level evaluation: does recall surface the right evidence, and never the stale value?
|
|
2
|
+
|
|
3
|
+
A question set is JSONL, one object per line::
|
|
4
|
+
|
|
5
|
+
{"id": "q1", "question": "which port does the api listen on",
|
|
6
|
+
"expect_any": ["8443"], "not_expect": ["8080"], "type": "stale"}
|
|
7
|
+
|
|
8
|
+
``type`` is ``fact`` (answer exists), ``stale`` (answer changed over time; the
|
|
9
|
+
old value must not appear) or ``negative`` (nothing relevant should be found).
|
|
10
|
+
Scores are containment against the retrieved context, so no model is needed
|
|
11
|
+
and results are reproducible. Two contexts are scored for every question:
|
|
12
|
+
``beliefs`` (currently valid beliefs only — what a prompt-time hook injects)
|
|
13
|
+
and ``beliefs+turns`` (beliefs plus transcript evidence). Transcripts are
|
|
14
|
+
evidence and legitimately contain old values, so a stale value appearing in
|
|
15
|
+
``beliefs+turns`` is expected; appearing in ``beliefs`` is a defect. End-to-end
|
|
16
|
+
runs with a model are described in docs/eval.md.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import statistics
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from memware.index import search_beliefs, search_turns
|
|
30
|
+
from memware.ingest import sync_tree
|
|
31
|
+
from memware.store import DEFAULT_DB, Store
|
|
32
|
+
|
|
33
|
+
MARKER = "[memware-eval]"
|
|
34
|
+
"""Put this in every prompt an evaluation sends. Runs that carry it are excluded when
|
|
35
|
+
``--corpus`` rebuilds a clean store, and ``MEMWARE_NO_CAPTURE=1`` in the run's environment
|
|
36
|
+
keeps hooks from indexing them in the first place."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_clean_store(
|
|
40
|
+
db: str,
|
|
41
|
+
corpus: Path,
|
|
42
|
+
*,
|
|
43
|
+
harness: str = "claude-code",
|
|
44
|
+
beliefs_from: str | None = None,
|
|
45
|
+
exclude: list[str] | None = None,
|
|
46
|
+
also_skip: list[str] | None = None,
|
|
47
|
+
) -> dict[str, int]:
|
|
48
|
+
"""Index ``corpus`` into a fresh store at ``db``, skipping files that contain MARKER
|
|
49
|
+
(or any of ``also_skip`` — e.g. the prompt text of an older evaluation harness),
|
|
50
|
+
and copy the belief ledger from ``beliefs_from`` so retrieval is judged against the
|
|
51
|
+
same beliefs the live system has."""
|
|
52
|
+
for suffix in ("", "-wal", "-shm"):
|
|
53
|
+
Path(db + suffix).unlink(missing_ok=True)
|
|
54
|
+
with Store(db) as s:
|
|
55
|
+
rep = sync_tree(
|
|
56
|
+
s,
|
|
57
|
+
corpus,
|
|
58
|
+
harness=harness,
|
|
59
|
+
skip_if_contains=[MARKER, *(also_skip or [])],
|
|
60
|
+
exclude=exclude,
|
|
61
|
+
)
|
|
62
|
+
copied = 0
|
|
63
|
+
if beliefs_from:
|
|
64
|
+
s.conn.execute("ATTACH ? AS live", (str(Path(beliefs_from).expanduser()),))
|
|
65
|
+
s.conn.execute("INSERT INTO belief SELECT * FROM live.belief")
|
|
66
|
+
copied = int(s.conn.execute("SELECT count(*) FROM belief").fetchone()[0])
|
|
67
|
+
s.conn.execute("DETACH live")
|
|
68
|
+
return {"files": len(rep), "turns": sum(rep.values()), "beliefs": copied}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def retrieve(store: Store, question: str, k: int) -> tuple[str, str]:
|
|
72
|
+
"""Return (beliefs_context, beliefs_plus_turns_context).
|
|
73
|
+
|
|
74
|
+
The beliefs context mirrors what the prompt-time hook injects, so it uses the
|
|
75
|
+
same subject gate (``require_subject=True``); the turns context mirrors an
|
|
76
|
+
explicit, broad recall.
|
|
77
|
+
"""
|
|
78
|
+
beliefs = search_beliefs(store, question, k=k, record_use=False, require_subject=True)
|
|
79
|
+
turns = search_turns(store, question, k=k, record_use=False)
|
|
80
|
+
b = "\n".join(h.text for h in beliefs)
|
|
81
|
+
return b, "\n".join([b, *(h.text for h in turns)]).strip()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _first(ctx: str, needles: list[Any]) -> int:
|
|
85
|
+
pos = [ctx.find(str(n).lower()) for n in needles]
|
|
86
|
+
pos = [p for p in pos if p >= 0]
|
|
87
|
+
return min(pos) if pos else -1
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _score(q: dict[str, Any], ctx: str) -> dict[str, Any]:
|
|
91
|
+
"""``stale`` means the old value leads: it appears and the expected value does not
|
|
92
|
+
appear before it. Mentioning the old value as history after the current one is fine."""
|
|
93
|
+
ctx = ctx.lower()
|
|
94
|
+
found = any(str(e).lower() in ctx for e in q.get("expect_any", []))
|
|
95
|
+
old_at = _first(ctx, q.get("not_expect", []))
|
|
96
|
+
new_at = _first(ctx, q.get("expect_any", []))
|
|
97
|
+
stale = old_at >= 0 and (new_at < 0 or old_at < new_at)
|
|
98
|
+
qtype = str(q.get("type", "fact"))
|
|
99
|
+
ok = (not found) if qtype == "negative" else (found and not stale)
|
|
100
|
+
return {"found": found, "stale": stale, "ok": ok, "context_chars": len(ctx)}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def run(db: str, questions: Path, *, k: int = 8) -> dict[str, Any]:
|
|
104
|
+
rows = [
|
|
105
|
+
json.loads(line)
|
|
106
|
+
for line in questions.read_text(encoding="utf-8").splitlines()
|
|
107
|
+
if line.strip()
|
|
108
|
+
]
|
|
109
|
+
results: list[dict[str, Any]] = []
|
|
110
|
+
latencies: list[float] = []
|
|
111
|
+
with Store(db) as s:
|
|
112
|
+
for q in rows:
|
|
113
|
+
t0 = time.perf_counter()
|
|
114
|
+
b_ctx, bt_ctx = retrieve(s, q["question"], k)
|
|
115
|
+
latencies.append((time.perf_counter() - t0) * 1000)
|
|
116
|
+
results.append(
|
|
117
|
+
{
|
|
118
|
+
"id": q["id"],
|
|
119
|
+
"type": q.get("type", "fact"),
|
|
120
|
+
"beliefs_injected": bool(b_ctx),
|
|
121
|
+
"beliefs": _score(q, b_ctx),
|
|
122
|
+
"beliefs+turns": _score(q, bt_ctx),
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def summarize(ctx_key: str) -> dict[str, Any]:
|
|
127
|
+
by_type: dict[str, list[bool]] = {}
|
|
128
|
+
for r in results:
|
|
129
|
+
by_type.setdefault(str(r["type"]), []).append(bool(r[ctx_key]["ok"]))
|
|
130
|
+
n = max(1, len(results))
|
|
131
|
+
chars = [int(r[ctx_key]["context_chars"]) for r in results]
|
|
132
|
+
return {
|
|
133
|
+
"accuracy": sum(bool(r[ctx_key]["ok"]) for r in results) / n,
|
|
134
|
+
"stale_rate": sum(bool(r[ctx_key]["stale"]) for r in results) / n,
|
|
135
|
+
"by_type": {t: sum(v) / len(v) for t, v in by_type.items()},
|
|
136
|
+
"context_chars_median": statistics.median(chars) if chars else 0,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
n_all = max(1, len(results))
|
|
140
|
+
return {
|
|
141
|
+
"n": len(results),
|
|
142
|
+
"beliefs_injection_rate": sum(bool(r["beliefs_injected"]) for r in results) / n_all,
|
|
143
|
+
"beliefs_injection_rate_negatives": (
|
|
144
|
+
sum(bool(r["beliefs_injected"]) for r in results if r["type"] == "negative")
|
|
145
|
+
/ max(1, sum(1 for r in results if r["type"] == "negative"))
|
|
146
|
+
),
|
|
147
|
+
"beliefs": summarize("beliefs"),
|
|
148
|
+
"beliefs+turns": summarize("beliefs+turns"),
|
|
149
|
+
"latency_ms_median": statistics.median(latencies) if latencies else 0.0,
|
|
150
|
+
"results": results,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def main(argv: list[str] | None = None) -> int:
|
|
155
|
+
p = argparse.ArgumentParser(prog="memware-eval", description=__doc__)
|
|
156
|
+
p.add_argument("questions", type=Path)
|
|
157
|
+
p.add_argument("--db", default=str(DEFAULT_DB))
|
|
158
|
+
p.add_argument("-k", type=int, default=8)
|
|
159
|
+
p.add_argument("--json", action="store_true")
|
|
160
|
+
p.add_argument(
|
|
161
|
+
"--corpus",
|
|
162
|
+
type=Path,
|
|
163
|
+
help="rebuild --db as a clean scratch store from this transcript root first "
|
|
164
|
+
f"(files containing {MARKER!r} are skipped)",
|
|
165
|
+
)
|
|
166
|
+
p.add_argument(
|
|
167
|
+
"--beliefs-from", help="copy the belief ledger from this store into the scratch store"
|
|
168
|
+
)
|
|
169
|
+
p.add_argument("--harness", default="claude-code")
|
|
170
|
+
p.add_argument("--exclude", action="append", default=[], metavar="GLOB")
|
|
171
|
+
p.add_argument(
|
|
172
|
+
"--also-skip",
|
|
173
|
+
action="append",
|
|
174
|
+
default=[],
|
|
175
|
+
metavar="TEXT",
|
|
176
|
+
help="additional content markers that identify evaluation transcripts (repeatable)",
|
|
177
|
+
)
|
|
178
|
+
a = p.parse_args(argv)
|
|
179
|
+
if a.corpus:
|
|
180
|
+
if str(Path(a.db).expanduser()) == str(DEFAULT_DB):
|
|
181
|
+
raise SystemExit(
|
|
182
|
+
"--corpus rebuilds the store: pass a scratch --db, not the default one"
|
|
183
|
+
)
|
|
184
|
+
built = build_clean_store(
|
|
185
|
+
a.db, a.corpus, harness=a.harness, beliefs_from=a.beliefs_from, exclude=a.exclude
|
|
186
|
+
)
|
|
187
|
+
print(f"clean store: {built}", file=sys.stderr)
|
|
188
|
+
rep = run(a.db, a.questions, k=a.k)
|
|
189
|
+
if a.json:
|
|
190
|
+
print(json.dumps(rep, indent=2))
|
|
191
|
+
else:
|
|
192
|
+
for key in ("beliefs", "beliefs+turns"):
|
|
193
|
+
sm = rep[key]
|
|
194
|
+
print(
|
|
195
|
+
f"[{key}] n={rep['n']} accuracy={sm['accuracy']:.3f} "
|
|
196
|
+
f"stale_rate={sm['stale_rate']:.3f} by_type={sm['by_type']} "
|
|
197
|
+
f"context_chars={sm['context_chars_median']:.0f}"
|
|
198
|
+
)
|
|
199
|
+
print(
|
|
200
|
+
f"beliefs injected on {rep['beliefs_injection_rate']:.0%} of questions "
|
|
201
|
+
f"({rep['beliefs_injection_rate_negatives']:.0%} of negatives); "
|
|
202
|
+
f"median_latency={rep['latency_ms_median']:.1f}ms"
|
|
203
|
+
)
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
if __name__ == "__main__":
|
|
208
|
+
raise SystemExit(main())
|