muster-memory 0.1.0__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.
- muster/__init__.py +27 -0
- muster/_version.py +1 -0
- muster/adapters.py +33 -0
- muster/bpe.py +130 -0
- muster/cache.py +68 -0
- muster/cli.py +248 -0
- muster/compat/__init__.py +1 -0
- muster/compat/bundles.py +145 -0
- muster/compat/ce_cache.py +47 -0
- muster/compat/env.py +39 -0
- muster/compat/intermediates.py +140 -0
- muster/compat/recover.py +75 -0
- muster/compat/verify.py +142 -0
- muster/cut.py +291 -0
- muster/derive.py +146 -0
- muster/errors.py +39 -0
- muster/index.py +102 -0
- muster/integrations/__init__.py +9 -0
- muster/integrations/_common.py +30 -0
- muster/integrations/langchain.py +26 -0
- muster/integrations/langgraph.py +19 -0
- muster/integrations/openai_agents.py +32 -0
- muster/io.py +33 -0
- muster/memory.py +254 -0
- muster/models.py +128 -0
- muster/plan.py +75 -0
- muster/presets/__init__.py +43 -0
- muster/presets/paper.json +1182 -0
- muster/record_serial.py +90 -0
- muster/records.py +120 -0
- muster/render.py +89 -0
- muster/search.py +328 -0
- muster/serve.py +158 -0
- muster/source_order.py +197 -0
- muster/stats.py +108 -0
- muster/store.py +186 -0
- muster_memory-0.1.0.dist-info/METADATA +203 -0
- muster_memory-0.1.0.dist-info/RECORD +42 -0
- muster_memory-0.1.0.dist-info/WHEEL +4 -0
- muster_memory-0.1.0.dist-info/entry_points.txt +2 -0
- muster_memory-0.1.0.dist-info/licenses/LICENSE +202 -0
- muster_memory-0.1.0.dist-info/licenses/NOTICE +6 -0
muster/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""MUSTER: long-term memory for agents that keeps every word, sets its own strategy from the stored text, and makes no
|
|
2
|
+
model call to build or grow the memory.
|
|
3
|
+
|
|
4
|
+
import muster
|
|
5
|
+
plan = muster.derive(records) # how this store is cut, grouped and read, decided from its own text
|
|
6
|
+
print(muster.explain(plan))
|
|
7
|
+
|
|
8
|
+
Heavy dependencies (numpy, torch, nltk, rank_bm25) are imported only when a step needs them.
|
|
9
|
+
"""
|
|
10
|
+
from ._version import __version__
|
|
11
|
+
from .derive import derive, derive_collection, explain
|
|
12
|
+
from .errors import (AssetMissing, EnvironmentRefused, MusterError, PlanError, PlanViolation, RecordError,
|
|
13
|
+
StaleSelection, StoreLocked, StoreRefused)
|
|
14
|
+
from .plan import ENCODER, RERANKER, ModelSpec, Plan
|
|
15
|
+
from .records import DEFAULT_TEMPLATE, Record, numbered, serialize
|
|
16
|
+
from . import presets
|
|
17
|
+
from .memory import Memory, build, context, render, search
|
|
18
|
+
from .models import Models
|
|
19
|
+
from .render import Context, compose
|
|
20
|
+
from .store import AddResult, Store
|
|
21
|
+
from .cache import ScoreCache
|
|
22
|
+
from .source_order import SourceOrder, OrderedRecords, normalize_ordered_records, build_declared
|
|
23
|
+
|
|
24
|
+
__all__ = ["__version__", "build", "search", "render", "context", "compose", "Memory", "Models", "Context", "Store", "AddResult", "ScoreCache", "derive", "derive_collection", "explain", "Plan", "ModelSpec", "ENCODER", "RERANKER",
|
|
25
|
+
"Record", "DEFAULT_TEMPLATE", "numbered", "serialize", "presets", "MusterError", "PlanError", "RecordError",
|
|
26
|
+
"StoreRefused", "EnvironmentRefused", "AssetMissing", "StaleSelection", "PlanViolation", "StoreLocked",
|
|
27
|
+
"SourceOrder", "OrderedRecords", "normalize_ordered_records", "build_declared"]
|
muster/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
muster/adapters.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Turn common conversation shapes into records."""
|
|
2
|
+
from .records import Record
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def chat(messages, *, session=None, start=0):
|
|
6
|
+
"""[{"role": ..., "content": ..., "time"?: ...}] -> one Record per message, id "S<session>:<n>"."""
|
|
7
|
+
out = []
|
|
8
|
+
for n, m in enumerate(messages, start + 1):
|
|
9
|
+
out.append(Record(text=str(m.get("content", "")), id=("S%s:%d" % (session, n)) if session is not None else None,
|
|
10
|
+
time=m.get("time") or m.get("timestamp"), speaker=m.get("role") or m.get("speaker"),
|
|
11
|
+
session=None if session is None else str(session)))
|
|
12
|
+
return out
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def numbered_lines(lines, start=1):
|
|
16
|
+
"""A numbered log: "1. <line>", "2. <line>", ... joined by newlines (one record per line)."""
|
|
17
|
+
return "\n".join("%d. %s" % (n, ln) for n, ln in enumerate(lines, start))
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def locomo(conversation):
|
|
21
|
+
"""LoCoMo's own record line, one per turn: "<dia_id> [<session date_time>] <speaker>: <text>", plus
|
|
22
|
+
" [image: <caption>]" when the turn has an image. Sessions in numeric order."""
|
|
23
|
+
import re
|
|
24
|
+
keys = sorted((k for k in conversation if re.fullmatch(r"session_\d+", k)), key=lambda k: int(k.split("_")[1]))
|
|
25
|
+
out = []
|
|
26
|
+
for k in keys:
|
|
27
|
+
date = conversation[k + "_date_time"]
|
|
28
|
+
for t in conversation[k]:
|
|
29
|
+
text = t["text"]
|
|
30
|
+
if t.get("blip_caption"):
|
|
31
|
+
text += " [image: %s]" % t["blip_caption"]
|
|
32
|
+
out.append(Record(text=text, id=t["dia_id"], time=date, speaker=t["speaker"], session=k.split("_")[1]))
|
|
33
|
+
return out
|
muster/bpe.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Byte-pair encodings.
|
|
2
|
+
|
|
3
|
+
cl100k_base counts tokens for the detection statistics; o200k_base cuts the store and counts unit sizes, as the
|
|
4
|
+
paper's harness does (harness/lme_pf29.py:259, scripts/derive.py:134).
|
|
5
|
+
|
|
6
|
+
The encodings come from tiktoken. If `muster fetch` has put the BPE files in muster's asset directory, they are read
|
|
7
|
+
from there, each checked against the hash tiktoken itself pins, so a run never depends on tiktoken's download cache
|
|
8
|
+
(which lives under the system temp directory). Otherwise tiktoken loads them in its usual way.
|
|
9
|
+
"""
|
|
10
|
+
import base64
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
|
|
14
|
+
from .errors import AssetMissing
|
|
15
|
+
|
|
16
|
+
NAMES = ("cl100k_base", "o200k_base")
|
|
17
|
+
_ENC = {}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def asset_dir():
|
|
21
|
+
"""Where `muster fetch` puts tokenizer and sentence-splitter files ($MUSTER_ASSETS, else a per-user data dir)."""
|
|
22
|
+
d = os.environ.get("MUSTER_ASSETS")
|
|
23
|
+
if d:
|
|
24
|
+
return d
|
|
25
|
+
base = os.environ.get("XDG_DATA_HOME") or os.path.join(os.path.expanduser("~"), ".local", "share")
|
|
26
|
+
return os.path.join(base, "muster")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _bpe_path(name):
|
|
30
|
+
return os.path.join(asset_dir(), "bpe", name + ".tiktoken")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _constructor(name, ranks_for):
|
|
34
|
+
"""Run tiktoken's own constructor for `name` with its file loader replaced by `ranks_for(url, sha256)`.
|
|
35
|
+
|
|
36
|
+
The pattern string and the special tokens therefore come from the installed tiktoken, not from a copy."""
|
|
37
|
+
import tiktoken_ext.openai_public as op
|
|
38
|
+
if name not in op.ENCODING_CONSTRUCTORS:
|
|
39
|
+
raise AssetMissing("tiktoken has no encoding named %r" % name)
|
|
40
|
+
orig = op.load_tiktoken_bpe
|
|
41
|
+
op.load_tiktoken_bpe = lambda url, expected_hash=None: ranks_for(url, expected_hash)
|
|
42
|
+
try:
|
|
43
|
+
return op.ENCODING_CONSTRUCTORS[name]()
|
|
44
|
+
finally:
|
|
45
|
+
op.load_tiktoken_bpe = orig
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def source(name):
|
|
49
|
+
"""(url, sha256) of the BPE file tiktoken's constructor names."""
|
|
50
|
+
seen = {}
|
|
51
|
+
|
|
52
|
+
def ranks_for(url, sha):
|
|
53
|
+
seen["url"], seen["sha256"] = url, sha
|
|
54
|
+
return {}
|
|
55
|
+
_constructor(name, ranks_for)
|
|
56
|
+
return seen["url"], seen["sha256"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _from_file(name, path):
|
|
60
|
+
import tiktoken
|
|
61
|
+
data = open(path, "rb").read()
|
|
62
|
+
|
|
63
|
+
def ranks_for(url, sha):
|
|
64
|
+
if sha and hashlib.sha256(data).hexdigest() != sha:
|
|
65
|
+
raise AssetMissing("%s does not match the sha256 tiktoken pins (%s); run `muster fetch` again" % (path, sha))
|
|
66
|
+
return {base64.b64decode(tok): int(rank) for tok, rank in (ln.split() for ln in data.splitlines() if ln)}
|
|
67
|
+
return tiktoken.Encoding(**_constructor(name, ranks_for))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def encoding(name):
|
|
71
|
+
"""The tiktoken Encoding `name`, from muster's asset directory when it is there."""
|
|
72
|
+
enc = _ENC.get(name)
|
|
73
|
+
if enc is None:
|
|
74
|
+
p = _bpe_path(name)
|
|
75
|
+
if os.path.exists(p):
|
|
76
|
+
enc = _from_file(name, p)
|
|
77
|
+
else:
|
|
78
|
+
import tiktoken
|
|
79
|
+
enc = tiktoken.get_encoding(name)
|
|
80
|
+
_ENC[name] = enc
|
|
81
|
+
return enc
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def o200k():
|
|
85
|
+
return encoding("o200k_base")
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def cl100k():
|
|
89
|
+
return encoding("cl100k_base")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# The three ways the harness encodes. They differ only on text that contains special-token strings.
|
|
93
|
+
def count(enc, text):
|
|
94
|
+
"""Token count with special-token strings read as plain text (lme_pf29.py:1127-1128, derive.py:72)."""
|
|
95
|
+
return len(enc.encode(text, disallowed_special=()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def encode_pack(enc, text):
|
|
99
|
+
"""What the packer encodes with (lme_pf29.py:1273): only <|endoftext|> may appear; any other special string raises."""
|
|
100
|
+
return enc.encode(text, allowed_special={"<|endoftext|>"})
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def encode_label(enc, text):
|
|
104
|
+
"""tiktoken's default mode, used for parent labels (lme_pf29.py:2237): any special-token string raises."""
|
|
105
|
+
return enc.encode(text)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch(names=NAMES, *, quiet=False):
|
|
109
|
+
"""Download the BPE files into the asset directory and check each against tiktoken's pinned hash."""
|
|
110
|
+
import urllib.request
|
|
111
|
+
out = []
|
|
112
|
+
for name in names:
|
|
113
|
+
url, sha = source(name)
|
|
114
|
+
dest = _bpe_path(name)
|
|
115
|
+
if os.path.exists(dest) and hashlib.sha256(open(dest, "rb").read()).hexdigest() == sha:
|
|
116
|
+
out.append((name, dest, "present"))
|
|
117
|
+
continue
|
|
118
|
+
os.makedirs(os.path.dirname(dest), exist_ok=True)
|
|
119
|
+
data = urllib.request.urlopen(url, timeout=60).read()
|
|
120
|
+
if hashlib.sha256(data).hexdigest() != sha:
|
|
121
|
+
raise AssetMissing("downloaded %s does not match its pinned sha256" % url)
|
|
122
|
+
tmp = dest + ".tmp"
|
|
123
|
+
with open(tmp, "wb") as f:
|
|
124
|
+
f.write(data)
|
|
125
|
+
os.replace(tmp, dest)
|
|
126
|
+
_ENC.pop(name, None)
|
|
127
|
+
out.append((name, dest, "fetched"))
|
|
128
|
+
if not quiet:
|
|
129
|
+
print("fetched %s -> %s" % (name, dest))
|
|
130
|
+
return out
|
muster/cache.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Text-keyed caches: reranker scores and embedding vectors, in one SQLite file or in memory.
|
|
2
|
+
|
|
3
|
+
A score is keyed by (reranker, query, unit text) and a vector by (encoder, key text), so entries stay valid when a
|
|
4
|
+
store grows and its unit indices move.
|
|
5
|
+
"""
|
|
6
|
+
import hashlib
|
|
7
|
+
import sqlite3
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sha(s):
|
|
12
|
+
return hashlib.sha1(s.encode("utf-8", "replace")).digest()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def model_key(obj):
|
|
16
|
+
return "%s@%s" % (getattr(obj, "name", type(obj).__name__), getattr(obj, "revision", "") or "")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ScoreCache:
|
|
20
|
+
"""Reranker scores. `path=None` keeps them in memory."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, path=None, *, model="reranker"):
|
|
23
|
+
self.model = model
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
self.db = sqlite3.connect(path or ":memory:", check_same_thread=False)
|
|
26
|
+
self.db.execute("CREATE TABLE IF NOT EXISTS scores(model TEXT, qsha BLOB, usha BLOB, score REAL, "
|
|
27
|
+
"PRIMARY KEY (model, qsha, usha))")
|
|
28
|
+
self.db.commit()
|
|
29
|
+
|
|
30
|
+
def get_many(self, query, texts):
|
|
31
|
+
q = _sha(query)
|
|
32
|
+
out = {}
|
|
33
|
+
with self._lock:
|
|
34
|
+
for t in set(texts):
|
|
35
|
+
r = self.db.execute("SELECT score FROM scores WHERE model=? AND qsha=? AND usha=?",
|
|
36
|
+
(self.model, q, _sha(t))).fetchone()
|
|
37
|
+
if r is not None:
|
|
38
|
+
out[t] = r[0]
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
def put_many(self, query, scores):
|
|
42
|
+
q = _sha(query)
|
|
43
|
+
with self._lock:
|
|
44
|
+
self.db.executemany("INSERT OR REPLACE INTO scores VALUES (?,?,?,?)",
|
|
45
|
+
[(self.model, q, _sha(t), float(v)) for t, v in scores.items()])
|
|
46
|
+
self.db.commit()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class VectorCache:
|
|
50
|
+
"""Embedding vectors (float32) keyed by key text."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, db, model, *, create=True):
|
|
53
|
+
self.db, self.model = db, model
|
|
54
|
+
if create:
|
|
55
|
+
self.db.execute("CREATE TABLE IF NOT EXISTS vectors(model TEXT, ksha BLOB, dim INT, vec BLOB, "
|
|
56
|
+
"PRIMARY KEY (model, ksha))")
|
|
57
|
+
|
|
58
|
+
def get_many(self, keys):
|
|
59
|
+
out = {}
|
|
60
|
+
for k in set(keys):
|
|
61
|
+
r = self.db.execute("SELECT vec FROM vectors WHERE model=? AND ksha=?", (self.model, _sha(k))).fetchone()
|
|
62
|
+
if r is not None:
|
|
63
|
+
out[k] = r[0]
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
def put_many(self, items):
|
|
67
|
+
self.db.executemany("INSERT OR REPLACE INTO vectors VALUES (?,?,?,?)",
|
|
68
|
+
[(self.model, _sha(k), len(v) // 4, v) for k, v in items.items()])
|
muster/cli.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""The `muster` command.
|
|
2
|
+
|
|
3
|
+
muster derive PATH [--json] the plan of a store (same output as scripts/derive.py, except a log
|
|
4
|
+
numbered under its own spelling, which the paper's script calls records)
|
|
5
|
+
muster query PATH "question" build a memory from a file once and print what a reader gets
|
|
6
|
+
muster add --store S.muster FILE.jsonl append records (one JSON object per line: text, speaker, time, ...)
|
|
7
|
+
muster context --store S.muster "query" what a reader gets for a query
|
|
8
|
+
muster mcp --store S.muster serve the store to MCP clients over stdio
|
|
9
|
+
muster http --store S.muster [--port N] serve the store as a JSON API
|
|
10
|
+
muster verify cuts --data DIR check the cut of every final-leg row against gate/cut_table.json
|
|
11
|
+
muster verify contexts --data DIR rerun the paper's searches (exact mode) and compare every context hash
|
|
12
|
+
muster presets the paper's presets and the legs each stands for
|
|
13
|
+
muster fetch download the tokenizer and sentence-splitter files once, for offline use
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from . import bpe
|
|
20
|
+
from .derive import MIN_PARENTS, MIN_UNIT, NUMBERED_TAU, PROSE_STEP, RECORD_STEP, RECORD_TAU
|
|
21
|
+
from .errors import MusterError
|
|
22
|
+
from .io import read_store
|
|
23
|
+
from .stats import LEAD_LINE_TOKENS, lead_serial, store_stats
|
|
24
|
+
|
|
25
|
+
P = 4096
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _decision(serial, record, total, lead=False):
|
|
29
|
+
"""The dict scripts/derive.py prints, from the same three numbers (`lead`: numbered under its own spelling)."""
|
|
30
|
+
if serial >= NUMBERED_TAU or lead:
|
|
31
|
+
return dict(store="numbered log", unit_tokens=None, units=None, parent_tokens=None, parents=None,
|
|
32
|
+
order="newest per slot wins", retrieval="mostly by slot",
|
|
33
|
+
channel_floors=dict(meaning=2, keyword=0, parent=8, per_selected_units=10))
|
|
34
|
+
if record >= RECORD_TAU:
|
|
35
|
+
store, unit = "record store", max(P // RECORD_STEP, MIN_UNIT)
|
|
36
|
+
else:
|
|
37
|
+
store, unit = "prose", (P // PROSE_STEP if P > MIN_UNIT else P)
|
|
38
|
+
units = total // unit
|
|
39
|
+
mult = next((m for m in (8, 4, 2) if units // m >= MIN_PARENTS), None)
|
|
40
|
+
return dict(store=store, unit_tokens=unit, units=units, parent_tokens=unit * mult if mult else None,
|
|
41
|
+
parents=units // mult if mult else 0, order="none: all versions kept", retrieval="balanced, 3 channels",
|
|
42
|
+
channel_floors=dict(meaning=4, keyword=3, parent=3, per_selected_units=10))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _show(path, total, d):
|
|
46
|
+
print("MUSTER %s %s tokens" % (path, format(total, ",")))
|
|
47
|
+
if d["store"] == "numbered log":
|
|
48
|
+
rows = [("store", "numbered log"), ("units", "one per record"), ("parents", "one per slot"),
|
|
49
|
+
("order", d["order"]), ("retrieval", d["retrieval"])]
|
|
50
|
+
if "leading_serial_share" in d:
|
|
51
|
+
rows.append(("serials", "each line's own prefix, shown as written"))
|
|
52
|
+
else:
|
|
53
|
+
if not d["units"]:
|
|
54
|
+
unit = "whole store (under one unit)"
|
|
55
|
+
elif d["store"] == "record store":
|
|
56
|
+
unit = "512 tokens, records whole"
|
|
57
|
+
else:
|
|
58
|
+
unit = "%s tokens, 4x as many" % format(d["unit_tokens"], ",")
|
|
59
|
+
par = "%s tokens, %d of them" % (format(d["parent_tokens"], ","), d["parents"]) if d["parent_tokens"] else "none (too short)"
|
|
60
|
+
rows = [("store", d["store"]), ("units", unit), ("parents", par), ("order", d["order"]),
|
|
61
|
+
("retrieval", d["retrieval"])]
|
|
62
|
+
for k, v in rows:
|
|
63
|
+
print(" %-10s %s" % (k, v))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_derive(a):
|
|
67
|
+
try:
|
|
68
|
+
text = read_store(a.path, a.column, a.row)
|
|
69
|
+
except FileNotFoundError:
|
|
70
|
+
sys.exit("no such file: %s" % a.path)
|
|
71
|
+
except UnicodeDecodeError:
|
|
72
|
+
sys.exit("%s is not UTF-8 text; save it as UTF-8 and run again" % a.path)
|
|
73
|
+
except MusterError as e:
|
|
74
|
+
sys.exit(str(e))
|
|
75
|
+
st = store_stats(text, P)
|
|
76
|
+
if st.tokens == 0:
|
|
77
|
+
sys.exit("%s is empty: nothing to configure" % a.path)
|
|
78
|
+
share, med = lead_serial(text) if st.serial < NUMBERED_TAU else (0.0, None)
|
|
79
|
+
lead = med is not None and med <= LEAD_LINE_TOKENS
|
|
80
|
+
d = _decision(st.serial, st.record_share, st.tokens, lead=lead)
|
|
81
|
+
if lead:
|
|
82
|
+
d = dict(leading_serial_share=round(share, 4), median_line_tokens=med, **d)
|
|
83
|
+
if a.json:
|
|
84
|
+
print(json.dumps(dict(path=a.path, tokens=st.tokens, serial_fraction=round(st.serial, 4),
|
|
85
|
+
record_share=round(st.record_share, 4), **d), indent=1))
|
|
86
|
+
else:
|
|
87
|
+
_show(a.path, st.tokens, d)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def cmd_verify(a):
|
|
91
|
+
import os
|
|
92
|
+
from .compat.verify import verify_cuts
|
|
93
|
+
root = a.repo or os.getcwd()
|
|
94
|
+
legs = set(a.legs.split(",")) if a.legs else None
|
|
95
|
+
if a.what == "cuts":
|
|
96
|
+
ok = verify_cuts(a.data, os.path.join(root, "gate", "cut_table.json"), legs=legs)
|
|
97
|
+
else:
|
|
98
|
+
from .compat.verify import verify_contexts
|
|
99
|
+
ok = verify_contexts(a.data, os.path.join(root, "gate", "ctx_table.jsonl"),
|
|
100
|
+
os.path.join(root, "gate", "cut_table.json"), legs=legs,
|
|
101
|
+
cache_dirs=[d for d in (a.cache or "").split(":") if d], nq=a.nq or None)
|
|
102
|
+
sys.exit(0 if ok else 1)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def cmd_presets(a):
|
|
106
|
+
from . import presets
|
|
107
|
+
by = {}
|
|
108
|
+
for tag, leg in presets.legs().items():
|
|
109
|
+
by.setdefault(leg["preset"], []).append(tag)
|
|
110
|
+
for name in presets.names():
|
|
111
|
+
p = presets.get(name)
|
|
112
|
+
shape = ("%s/%s/%s" % (p.atom, p.chunk, p.mscale)) if p.atom else p.branch
|
|
113
|
+
print("%-11s %-9s %-16s U=%-3d %-13s %-8s %s" % (name, p.branch, shape, p.U, p.policy, p.render,
|
|
114
|
+
", ".join(sorted(by.get(name, [])))))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def cmd_fetch(a):
|
|
118
|
+
for name, dest, what in bpe.fetch(quiet=True):
|
|
119
|
+
print("%-12s %-8s %s" % (name, what, dest))
|
|
120
|
+
from .cut import _ensure_punkt
|
|
121
|
+
_ensure_punkt(exact=False)
|
|
122
|
+
print("punkt_tab ready")
|
|
123
|
+
if a.models:
|
|
124
|
+
from huggingface_hub import snapshot_download
|
|
125
|
+
from .plan import ENCODER, RERANKER
|
|
126
|
+
for spec in (ENCODER, RERANKER):
|
|
127
|
+
path = snapshot_download(spec.name, revision=spec.revision)
|
|
128
|
+
print("%-12s ready %s" % (spec.name.split("/")[-1], path))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _models(a):
|
|
132
|
+
if getattr(a, "fake_models", False):
|
|
133
|
+
from .models import Models
|
|
134
|
+
return Models.fake(32)
|
|
135
|
+
return None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def cmd_add(a):
|
|
139
|
+
from .store import Store
|
|
140
|
+
recs = [json.loads(x) for x in open(a.file, encoding="utf-8") if x.strip()] if a.file != "-" else \
|
|
141
|
+
[json.loads(x) for x in sys.stdin if x.strip()]
|
|
142
|
+
with Store.open(a.store, models=_models(a)) as st:
|
|
143
|
+
r = st.add(recs, request_id=a.request_id)
|
|
144
|
+
print(json.dumps({"version": r.version, "duplicate": r.duplicate, "n_records": r.n_records}))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_context(a):
|
|
148
|
+
from .store import Store
|
|
149
|
+
with Store.open(a.store, models=_models(a), readonly=True) as st:
|
|
150
|
+
ctx = st.context(a.query, U=a.units or None)
|
|
151
|
+
print(ctx.text)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_query(a):
|
|
155
|
+
from .memory import build
|
|
156
|
+
from .records import as_record
|
|
157
|
+
if a.declared_records:
|
|
158
|
+
from .source_order import build_declared
|
|
159
|
+
with open(a.path, encoding="utf-8") as source:
|
|
160
|
+
envelope = json.load(source)
|
|
161
|
+
mem = build_declared(envelope, models=_models(a))
|
|
162
|
+
elif a.path.endswith(".jsonl") and not a.column_given:
|
|
163
|
+
store = [as_record(json.loads(x)) for x in open(a.path, encoding="utf-8") if x.strip()]
|
|
164
|
+
mem = build(store, models=_models(a))
|
|
165
|
+
else:
|
|
166
|
+
store = read_store(a.path, a.column, a.row)
|
|
167
|
+
mem = build(store, models=_models(a))
|
|
168
|
+
if a.explain:
|
|
169
|
+
from .derive import explain
|
|
170
|
+
print(explain(mem.plan), file=sys.stderr)
|
|
171
|
+
print(mem.context(a.question, U=a.units or None).text)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def cmd_mcp(a):
|
|
175
|
+
from .serve import Service, mcp_server
|
|
176
|
+
from .store import Store
|
|
177
|
+
mcp_server(Service(Store.open(a.store, models=_models(a)))).run(transport=a.transport)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def cmd_http(a):
|
|
181
|
+
from .serve import Service, http_server
|
|
182
|
+
from .store import Store
|
|
183
|
+
srv = http_server(Service(Store.open(a.store, models=_models(a)),
|
|
184
|
+
allow_declared_records=a.allow_declared_records), a.host, a.port)
|
|
185
|
+
print("muster http on %s:%d (POST /v1/add /v1/context /v1/search, GET /v1/info)" % (a.host, a.port), flush=True)
|
|
186
|
+
srv.serve_forever()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def main(argv=None):
|
|
190
|
+
ap = argparse.ArgumentParser(prog="muster", description="MUSTER memory: derive, presets, fetch")
|
|
191
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
192
|
+
d = sub.add_parser("derive", help="the plan of a store, from its own text")
|
|
193
|
+
d.add_argument("path")
|
|
194
|
+
d.add_argument("--column", default="context")
|
|
195
|
+
d.add_argument("--row", type=int, default=0)
|
|
196
|
+
d.add_argument("--json", action="store_true")
|
|
197
|
+
d.set_defaults(fn=cmd_derive)
|
|
198
|
+
q = sub.add_parser("query", help="build a memory from a file and print the context for a question")
|
|
199
|
+
q.add_argument("path")
|
|
200
|
+
q.add_argument("question")
|
|
201
|
+
q.add_argument("--units", type=int, default=0)
|
|
202
|
+
q.add_argument("--column", default=None)
|
|
203
|
+
q.add_argument("--row", type=int, default=0)
|
|
204
|
+
q.add_argument("--explain", action="store_true", help="print the plan and why, to stderr")
|
|
205
|
+
q.add_argument("--declared-records", action="store_true",
|
|
206
|
+
help="native opt-in: JSON envelope with explicit record boundaries and source-order provenance")
|
|
207
|
+
q.add_argument("--fake-models", action="store_true", help=argparse.SUPPRESS)
|
|
208
|
+
q.set_defaults(fn=cmd_query)
|
|
209
|
+
for name, fn, extra in (("add", cmd_add, ("file",)), ("context", cmd_context, ("query",)),
|
|
210
|
+
("mcp", cmd_mcp, ()), ("http", cmd_http, ())):
|
|
211
|
+
p = sub.add_parser(name)
|
|
212
|
+
p.add_argument("--store", required=True)
|
|
213
|
+
p.add_argument("--fake-models", action="store_true", help=argparse.SUPPRESS)
|
|
214
|
+
for x in extra:
|
|
215
|
+
p.add_argument(x)
|
|
216
|
+
if name == "add":
|
|
217
|
+
p.add_argument("--request-id", default=None)
|
|
218
|
+
if name == "context":
|
|
219
|
+
p.add_argument("--units", type=int, default=0)
|
|
220
|
+
if name == "mcp":
|
|
221
|
+
p.add_argument("--transport", default="stdio", choices=["stdio", "streamable-http"])
|
|
222
|
+
if name == "http":
|
|
223
|
+
p.add_argument("--host", default="127.0.0.1")
|
|
224
|
+
p.add_argument("--port", type=int, default=8765)
|
|
225
|
+
p.add_argument("--allow-declared-records", action="store_true",
|
|
226
|
+
help="enable stateless /v1/declared-context for explicit source-order envelopes")
|
|
227
|
+
p.set_defaults(fn=fn)
|
|
228
|
+
v = sub.add_parser("verify", help="check the library against the paper's tables (no benchmark text needed)")
|
|
229
|
+
v.add_argument("what", choices=["cuts", "contexts"])
|
|
230
|
+
v.add_argument("--cache", default="", help="contexts: colon-separated directories of published reranker caches")
|
|
231
|
+
v.add_argument("--nq", type=int, default=0, help="contexts: at most this many questions per store")
|
|
232
|
+
v.add_argument("--data", required=True, help="directory with the benchmark files at the legs' paths")
|
|
233
|
+
v.add_argument("--repo", default=None, help="repository root holding gate/ (default: the current directory)")
|
|
234
|
+
v.add_argument("--legs", default="", help="comma-separated leg tags (default: all)")
|
|
235
|
+
v.set_defaults(fn=cmd_verify)
|
|
236
|
+
sub.add_parser("presets", help="the paper's presets").set_defaults(fn=cmd_presets)
|
|
237
|
+
f = sub.add_parser("fetch", help="download tokenizer and sentence-splitter files (and, with --models, both models)")
|
|
238
|
+
f.add_argument("--models", action="store_true", help="also download bge-m3 and bge-reranker-v2-m3 at the pinned revisions")
|
|
239
|
+
f.set_defaults(fn=cmd_fetch)
|
|
240
|
+
a = ap.parse_args(argv)
|
|
241
|
+
if a.cmd == "query":
|
|
242
|
+
a.column_given = a.column is not None
|
|
243
|
+
a.column = a.column or "context"
|
|
244
|
+
a.fn(a)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Reproduction tools: the paper's legs, its reranker-cache files, its bundles. Not needed to use MUSTER."""
|
muster/compat/bundles.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Bundles: one JSON line per question with the context a published read consumed.
|
|
2
|
+
|
|
3
|
+
Library versions of harness/make_mab_bundle.py, harness/make_bundle.py, harness/render_rule.py (R4) and the
|
|
4
|
+
FactConsolidation tag strip of gate/run_mab_ctx.sh. They write the same bytes, so a regenerated file can be compared
|
|
5
|
+
with a published one by its sha256.
|
|
6
|
+
"""
|
|
7
|
+
import collections
|
|
8
|
+
import glob
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
from .recover import index80, recover_ids
|
|
14
|
+
from ..render import per_parent_groups
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _h(s):
|
|
18
|
+
return hashlib.sha1(s.encode()).hexdigest()[:12]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _gold(a):
|
|
22
|
+
return [str(x) for x in a] if hasattr(a, "__len__") and not isinstance(a, str) else [str(a)]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def read_dump(path, *, on_duplicate="error"):
|
|
26
|
+
"""{(row, qi): record} of a DRY dump. A repeated (row, qi) is an error unless on_duplicate="last"."""
|
|
27
|
+
recs, dup = {}, 0
|
|
28
|
+
for line in open(path):
|
|
29
|
+
if not line.strip():
|
|
30
|
+
continue
|
|
31
|
+
r = json.loads(line)
|
|
32
|
+
k = (r["row"], r["qi"])
|
|
33
|
+
dup += k in recs
|
|
34
|
+
recs[k] = r
|
|
35
|
+
if dup and on_duplicate == "error":
|
|
36
|
+
raise ValueError("%s: %d duplicated (row, qi)" % (path, dup))
|
|
37
|
+
return recs, dup
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def bundle_mab(dump_path, *, tag, arm, df, nq, out, dsname, view="prune_annot"):
|
|
41
|
+
"""make_mab_bundle.py: the scored arm's context per question, checked against its recorded hash."""
|
|
42
|
+
V = arm + "|" + view
|
|
43
|
+
recs, _ = read_dump(dump_path)
|
|
44
|
+
if len(recs) != nq:
|
|
45
|
+
raise ValueError("%s: partial export %d/%d" % (tag, len(recs), nq))
|
|
46
|
+
n = 0
|
|
47
|
+
with open(out, "w") as fo:
|
|
48
|
+
for k in sorted(recs):
|
|
49
|
+
r = recs[k]
|
|
50
|
+
row, qi = k
|
|
51
|
+
ctx = r["ctx|" + V]
|
|
52
|
+
h = _h(ctx)
|
|
53
|
+
if h != r["ctxhash|" + V]:
|
|
54
|
+
raise ValueError("%s %s: context does not hash to its recorded ctxhash" % (tag, k))
|
|
55
|
+
m = df["metadata"].iloc[row]
|
|
56
|
+
qs = list(df["questions"].iloc[row])
|
|
57
|
+
ans = df["answers"].iloc[row]
|
|
58
|
+
ids = list(m["question_ids"]) if m.get("question_ids") is not None else []
|
|
59
|
+
lines = [x for x in ctx.split("\n") if x.strip()]
|
|
60
|
+
c = ctx.count("[CURRENT]")
|
|
61
|
+
fo.write(json.dumps(dict(row=row, qi=qi, qt=r["qt"], dataset=dsname, question=str(qs[qi]), gold=_gold(ans[qi]),
|
|
62
|
+
gold_str=r["gold"], question_id=str(ids[qi]) if qi < len(ids) else "", arm=V,
|
|
63
|
+
src_tag=tag, ctxhash=h, tok=(r.get("tok_by_arm") or {}).get(V), n_lines=len(lines),
|
|
64
|
+
n_current=c, n_units=r.get("n_units"), context=ctx), ensure_ascii=False) + "\n")
|
|
65
|
+
n += 1
|
|
66
|
+
return n
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def bundle_beam(dump_paths, *, df, out, arm="flr_4_3_3|prune_annot"):
|
|
70
|
+
"""make_bundle.py: every question of every conversation, in parquet order. Later shards win a repeated key,
|
|
71
|
+
as the original did; the repeats are returned so a caller can report them."""
|
|
72
|
+
recs, dups = {}, []
|
|
73
|
+
for f in dump_paths:
|
|
74
|
+
for line in open(f):
|
|
75
|
+
r = json.loads(line)
|
|
76
|
+
k = (r["row"], r["qi"])
|
|
77
|
+
if k in recs:
|
|
78
|
+
dups.append((k, recs[k]["ctxhash|" + arm] == r["ctxhash|" + arm]))
|
|
79
|
+
recs[k] = r
|
|
80
|
+
nq = sum(len(df.iloc[i]["questions"]) for i in range(len(df)))
|
|
81
|
+
if len(recs) != nq:
|
|
82
|
+
raise ValueError("partial export: %d/%d" % (len(recs), nq))
|
|
83
|
+
n = 0
|
|
84
|
+
with open(out, "w") as fo:
|
|
85
|
+
for row in range(len(df)):
|
|
86
|
+
m = dict(df.iloc[row]["metadata"])
|
|
87
|
+
qs = list(df.iloc[row]["questions"])
|
|
88
|
+
ans = df.iloc[row]["answers"]
|
|
89
|
+
for qi in range(len(qs)):
|
|
90
|
+
r = recs[(row, qi)]
|
|
91
|
+
ctx = r["ctx|" + arm]
|
|
92
|
+
if _h(ctx) != r["ctxhash|" + arm]:
|
|
93
|
+
raise ValueError("%s: context does not hash to its recorded ctxhash" % ((row, qi),))
|
|
94
|
+
fo.write(json.dumps(dict(row=row, qi=qi, conv_id=str(m["conv_id"]), category=str(m["question_types"][qi]),
|
|
95
|
+
cat_index=int(m["cat_index"][qi]), question=str(qs[qi]), gold=_gold(ans[qi]),
|
|
96
|
+
rubric=[str(x) for x in m["rubric"][qi]], ctxhash=r["ctxhash|" + arm],
|
|
97
|
+
n_units=r.get("n_units"), tok=(r.get("tok_by_arm") or {}).get(arm),
|
|
98
|
+
context=ctx), ensure_ascii=False) + "\n")
|
|
99
|
+
n += 1
|
|
100
|
+
return n, dups
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def r4_bundle(bundle_in, out, units_of_row):
|
|
104
|
+
"""render_rule.py R4: recover the ids each context holds, render one block per parent. `units_of_row(row)` returns
|
|
105
|
+
(units, parent) of that row's store under the leg's cut."""
|
|
106
|
+
stat = collections.Counter()
|
|
107
|
+
cache = {}
|
|
108
|
+
with open(out, "w") as fo:
|
|
109
|
+
for line in open(bundle_in):
|
|
110
|
+
r = json.loads(line)
|
|
111
|
+
row = r["row"]
|
|
112
|
+
if row not in cache:
|
|
113
|
+
u, p = units_of_row(row)
|
|
114
|
+
cache[row] = (u, p, index80(u))
|
|
115
|
+
units, parent, idx = cache[row]
|
|
116
|
+
ids = recover_ids(r["context"], units, parent, idx)
|
|
117
|
+
groups = per_parent_groups(ids, parent)
|
|
118
|
+
new = "\n".join("Memory %d:\n%s" % (n + 1, "\n".join(units[i] for i in b)) for n, b in enumerate(groups))
|
|
119
|
+
stat["same_as_input"] += new == r["context"]
|
|
120
|
+
r["context"] = new
|
|
121
|
+
r["ctxhash"] = _h(new)
|
|
122
|
+
fo.write(json.dumps(r, ensure_ascii=False) + "\n")
|
|
123
|
+
stat["n"] += 1
|
|
124
|
+
return dict(stat)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def notag(bundle_in, out):
|
|
128
|
+
"""FactConsolidation: drop the "[CURRENT] " tag the structural render writes before the newest record of a slot."""
|
|
129
|
+
rows = [json.loads(line) for line in open(bundle_in)]
|
|
130
|
+
n_tag = 0
|
|
131
|
+
for d in rows:
|
|
132
|
+
n_tag += d["context"].count("[CURRENT] ")
|
|
133
|
+
d["context"] = d["context"].replace("[CURRENT] ", "")
|
|
134
|
+
if "[CURRENT]" in d["context"]:
|
|
135
|
+
raise ValueError("a [CURRENT] tag without its trailing space survived the strip")
|
|
136
|
+
d["ctxhash"] = _h(d["context"])
|
|
137
|
+
with open(out, "w") as fo:
|
|
138
|
+
for d in rows:
|
|
139
|
+
fo.write(json.dumps(d, ensure_ascii=False) + "\n")
|
|
140
|
+
return len(rows), n_tag
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def shards(results_dir, tag):
|
|
144
|
+
return sorted(glob.glob(os.path.join(results_dir, "%s_s*.jsonl" % tag)) or
|
|
145
|
+
glob.glob(os.path.join(results_dir, "%s_r*.jsonl" % tag)))
|