model-d 0.4.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.
- model_d-0.4.0.dist-info/METADATA +309 -0
- model_d-0.4.0.dist-info/RECORD +39 -0
- model_d-0.4.0.dist-info/WHEEL +5 -0
- model_d-0.4.0.dist-info/entry_points.txt +4 -0
- model_d-0.4.0.dist-info/licenses/LICENSE +202 -0
- model_d-0.4.0.dist-info/licenses/NOTICE +7 -0
- model_d-0.4.0.dist-info/licenses/THIRD_PARTY_NOTICES +11 -0
- model_d-0.4.0.dist-info/licenses/ocm/LIFE_LICENSE +21 -0
- model_d-0.4.0.dist-info/top_level.txt +1 -0
- ocm/LIFE_LICENSE +21 -0
- ocm/__init__.py +2 -0
- ocm/__main__.py +14 -0
- ocm/build.py +77 -0
- ocm/calc.py +51 -0
- ocm/cli.py +182 -0
- ocm/cmlm_vector/__init__.py +6 -0
- ocm/cmlm_vector/chunk.py +38 -0
- ocm/cmlm_vector/cli.py +75 -0
- ocm/cmlm_vector/embed.py +66 -0
- ocm/cmlm_vector/lawsearch.py +146 -0
- ocm/cmlm_vector/memory.py +368 -0
- ocm/data/pools.json +503 -0
- ocm/embed_cache.py +41 -0
- ocm/hybrid.py +97 -0
- ocm/init.py +143 -0
- ocm/install.py +89 -0
- ocm/life.py +303 -0
- ocm/loop.py +127 -0
- ocm/mcp.py +100 -0
- ocm/measure.py +72 -0
- ocm/providers/__init__.py +2 -0
- ocm/providers/claude_api.py +43 -0
- ocm/providers/gemini_api.py +70 -0
- ocm/providers/openai_api.py +37 -0
- ocm/router/__init__.py +0 -0
- ocm/router/lanes.py +111 -0
- ocm/router/router.py +93 -0
- ocm/server.py +280 -0
- ocm/units.py +104 -0
ocm/build.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Build a workspace over one or more repositories (and their docs).
|
|
2
|
+
|
|
3
|
+
python -m ocm build <folder> [<folder> ...] --out <workspace folder>
|
|
4
|
+
|
|
5
|
+
Creates <workspace>/memory (the fingerprints: 1,024 bits per unit, from a small embedding model that runs on this
|
|
6
|
+
machine), <workspace>/keyword.db (a keyword index for the router's keyword lane), <workspace>/spans.json (the first and
|
|
7
|
+
last line of every unit, so a taught question's vector can be put on the unit holding a given line) and
|
|
8
|
+
<workspace>/workspace.json (which folders, how many units, the router's rare-word threshold). Rebuild after large code changes; teaching later only adds."""
|
|
9
|
+
import argparse, json, os, shutil, sqlite3, time
|
|
10
|
+
|
|
11
|
+
from .cmlm_vector import Memory
|
|
12
|
+
from .units import files, units
|
|
13
|
+
|
|
14
|
+
THETA_DEFAULT = 9.349 # the router law's 44% rule on code questions (measured on transformers)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build(roots, out, model=None, device=None, log=print, carry=None, prune_after=None, cache=None):
|
|
18
|
+
if os.path.exists(os.path.join(out, "memory", "manifest.json")):
|
|
19
|
+
raise SystemExit(f"{out} already holds a workspace; remove it or choose another --out")
|
|
20
|
+
os.makedirs(out, exist_ok=True)
|
|
21
|
+
roots = {os.path.basename(os.path.abspath(r).rstrip("/")) or "root": os.path.abspath(r) for r in roots}
|
|
22
|
+
m = Memory.create(os.path.join(out, "memory"), bits=1024, seed=0, model=model, device=device)
|
|
23
|
+
cache = cache or os.environ.get("MODELD_CACHE")
|
|
24
|
+
if cache: # rebuilds embed only the units whose text changed
|
|
25
|
+
from .embed_cache import CachedEmbedder
|
|
26
|
+
m._embedder = CachedEmbedder(m.embedder, os.path.expanduser(cache), m.man.get("model", ""))
|
|
27
|
+
F = files(roots); U = []
|
|
28
|
+
for name, rel, path in F:
|
|
29
|
+
try:
|
|
30
|
+
text = open(path, encoding="utf-8").read()
|
|
31
|
+
except (UnicodeDecodeError, OSError):
|
|
32
|
+
continue
|
|
33
|
+
us = units(rel, text)
|
|
34
|
+
m.sources.append({"id": len(m.sources), "path": rel, "sha256": "", "units": len(us)})
|
|
35
|
+
U.extend((t, len(m.sources) - 1, a, b) for t, a, b in us)
|
|
36
|
+
if not U:
|
|
37
|
+
raise SystemExit("no code or documentation found in those folders")
|
|
38
|
+
step = max(1, len(U) // 20000)
|
|
39
|
+
m.freeze_mean([u[0] for u in U[::step][:20000]])
|
|
40
|
+
log(f"{len(m.sources):,} files, {len(U):,} units; fingerprinting (first run downloads the embedding model)")
|
|
41
|
+
t0, seen, spans = time.time(), set(), []
|
|
42
|
+
for s in range(0, len(U), 2048):
|
|
43
|
+
chunk = [u for u in U[s:s + 2048] if u[0] not in seen]; seen.update(u[0] for u in chunk)
|
|
44
|
+
for (t, sid, a, last), b in zip(chunk, m.measure([u[0] for u in chunk])):
|
|
45
|
+
m._add_fact(t, sid, a, 0, True, b); spans.append([a, last])
|
|
46
|
+
if len(m.pending_f) >= 200_000:
|
|
47
|
+
m.save()
|
|
48
|
+
log(f" {min(s + 2048, len(U)):,} / {len(U):,} units, {time.time() - t0:.0f}s")
|
|
49
|
+
m.save()
|
|
50
|
+
db = os.path.join(out, "keyword.db")
|
|
51
|
+
con = sqlite3.connect(db); con.execute("create virtual table s using fts5(t)")
|
|
52
|
+
con.executemany("insert into s(rowid, t) values (?, ?)", ((j + 1, t) for j, t in enumerate(m.texts))); con.commit(); con.close()
|
|
53
|
+
json.dump(spans, open(os.path.join(out, "spans.json"), "w"))
|
|
54
|
+
json.dump({"roots": roots, "files": len(m.sources), "units": len(m.texts), "theta": THETA_DEFAULT,
|
|
55
|
+
"built": time.strftime("%Y-%m-%dT%H:%M:%S")}, open(os.path.join(out, "workspace.json"), "w"), indent=1)
|
|
56
|
+
shutil.copytree(os.path.join(out, "memory"), os.path.join(out, "memory_at_build"))
|
|
57
|
+
reuse = f"; {m._embedder.embedded:,} newly embedded, the rest from the cache" if cache else ""
|
|
58
|
+
log(f"workspace ready: {out} ({len(m.texts):,} units from {len(m.sources):,} files{reuse})")
|
|
59
|
+
if carry:
|
|
60
|
+
from .server import Workspace
|
|
61
|
+
r = Workspace(out).carry(carry, prune_after)
|
|
62
|
+
log(f"carried from {carry}: {r['carried']} of {r['entries']} taught entries ({r['moved']} moved, "
|
|
63
|
+
f"{r['reformatted']} found after reformatting), {r['abstained']} abstained (their line is gone), "
|
|
64
|
+
f"{r['pruned']} pruned; Life sha {r['life sha']}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main(argv=None):
|
|
68
|
+
ap = argparse.ArgumentParser(prog="ocm build"); ap.add_argument("folders", nargs="+"); ap.add_argument("--out", required=True)
|
|
69
|
+
ap.add_argument("--model"); ap.add_argument("--device")
|
|
70
|
+
ap.add_argument("--cache", help="an embedding cache file ($MODELD_CACHE): a rebuild embeds only changed code")
|
|
71
|
+
ap.add_argument("--carry", help="an earlier workspace whose taught knowledge (its Life) is carried into this one")
|
|
72
|
+
ap.add_argument("--prune-after", type=int, metavar="N",
|
|
73
|
+
help="with --carry: remove a taught entry whose line has not been found in N carries in a row")
|
|
74
|
+
a = ap.parse_args(argv)
|
|
75
|
+
if a.prune_after is not None and (a.prune_after < 1 or not a.carry):
|
|
76
|
+
ap.error("--prune-after needs --carry and a whole number of at least 1")
|
|
77
|
+
build(a.folders, a.out, a.model, a.device, carry=a.carry, prune_after=a.prune_after, cache=a.cache)
|
ocm/calc.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""The savings calculator: expected model requests with and without the workspace, from per-question costs measured in
|
|
2
|
+
live tests (ocm/data/pools.json), with a 95% bootstrap interval.
|
|
3
|
+
|
|
4
|
+
python -m ocm calc --repeat 0.5 --deep 0.3 [--questions-per-month 16800]
|
|
5
|
+
|
|
6
|
+
--repeat share of questions that repeat something a teammate already asked (in any words)
|
|
7
|
+
--deep share of questions that are multi-file deep dives (the rest are lookups and short scans)
|
|
8
|
+
Requests per question = search-and-read calls + a fixed session overhead per question (both arms) + batched teaching
|
|
9
|
+
(one call per session of about six questions, workspace arm). This arithmetic predicted a held-out live run before it
|
|
10
|
+
ran: 83 requests predicted, 84 measured; 1.66x predicted, 1.66x measured (results/RESULTS.md)."""
|
|
11
|
+
import argparse, json, os, random
|
|
12
|
+
|
|
13
|
+
POOLS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", "pools.json") # shipped inside the package
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def expect(P, p, d, over, teach, rng=None):
|
|
17
|
+
pick = (lambda xs: [rng.choice(xs) for _ in xs]) if rng else (lambda xs: xs)
|
|
18
|
+
m = {k: sum(x) / len(x) for k, x in ((k, pick(v)) for k, v in P.items())}
|
|
19
|
+
def arm(a):
|
|
20
|
+
look = (1 - p) * m[f"lookup first {a}"] + p * m[f"lookup repeat {a}"]
|
|
21
|
+
deep = (1 - p) * m[f"deep first {a}"] + p * m[f"deep repeat {a}"]
|
|
22
|
+
return (1 - d) * look + d * deep + over + (teach if a == "memory" else 0)
|
|
23
|
+
return arm("tools"), arm("memory")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main(argv=None):
|
|
27
|
+
ap = argparse.ArgumentParser(prog="ocm calc")
|
|
28
|
+
ap.add_argument("--repeat", type=float); ap.add_argument("--deep", type=float, default=0.0)
|
|
29
|
+
ap.add_argument("--questions-per-month", type=int, default=0); ap.add_argument("--seed", type=int, default=20261010)
|
|
30
|
+
ap.add_argument("--table", action="store_true", help="the published table: repeats 0 to 90%%, with and without 30%% deep dives")
|
|
31
|
+
a = ap.parse_args(argv)
|
|
32
|
+
D = json.load(open(POOLS)); P = D["pools"]; over, teach = D["overhead per question"], D["teach per question, batched"]
|
|
33
|
+
if a.table: # one generator over every row, in this order, as the published table
|
|
34
|
+
rng = random.Random(a.seed); q = a.questions_per_month or 16800
|
|
35
|
+
print(f"{'deep dives':>10} {'repeats':>8} {'without':>8} {'with':>6} {'saving':>7} 95% interval fewer per month at {q:,} questions")
|
|
36
|
+
for d in (0.0, 0.3):
|
|
37
|
+
for p in (0.0, 0.25, 0.5, 0.75, 0.9):
|
|
38
|
+
t, m = expect(P, p, d, over, teach)
|
|
39
|
+
bs = sorted(x / y for x, y in (expect(P, p, d, over, teach, rng) for _ in range(5000)))
|
|
40
|
+
print(f"{d:>10.0%} {p:>8.0%} {t:>8.2f} {m:>6.2f} {t / m:>6.2f}x [{bs[125]:.2f}, {bs[4875]:.2f}] {q * (t - m):>9,.0f}")
|
|
41
|
+
return
|
|
42
|
+
if a.repeat is None:
|
|
43
|
+
ap.error("--repeat is required (or use --table)")
|
|
44
|
+
t, m = expect(P, a.repeat, a.deep, over, teach)
|
|
45
|
+
rng = random.Random(a.seed); boots = sorted(x / y for x, y in (expect(P, a.repeat, a.deep, over, teach, rng) for _ in range(5000)))
|
|
46
|
+
print(f"requests per question: without the workspace {t:.2f}, with it {m:.2f}")
|
|
47
|
+
print(f"saving: {t / m:.2f}x fewer requests (95% interval {boots[125]:.2f} to {boots[4875]:.2f})")
|
|
48
|
+
if a.questions_per_month:
|
|
49
|
+
q = a.questions_per_month
|
|
50
|
+
print(f"per month at {q:,} questions: {q * t:,.0f} requests without, {q * m:,.0f} with, {q * (t - m):,.0f} fewer")
|
|
51
|
+
print("billed input falls less than requests (measured: 1.40x when requests fell 1.66x), because readouts add reading.")
|
ocm/cli.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""modeld: map a repository once, ask it where the answer is, teach it what was found.
|
|
2
|
+
|
|
3
|
+
modeld build [path] [--out DIR] map the repository into <path>/modeld-out (alias: modeld .)
|
|
4
|
+
modeld ask "question" [--scope code] the ranked places to look; --scope code leaves documentation out
|
|
5
|
+
modeld next "question" the next readout for the same question
|
|
6
|
+
modeld teach "question" path:line teach the line the answer is on
|
|
7
|
+
modeld stats units, files, questions taught
|
|
8
|
+
modeld install [--team URL] register Model D with the coding assistants found on this machine
|
|
9
|
+
modeld init <claude|kiro|codex|gemini|all> [repo] [--url URL]
|
|
10
|
+
wire one repository's agent to a team server
|
|
11
|
+
modeld serve [workspace] [--port 7700] [--host 127.0.0.1] [--build REPO ...]
|
|
12
|
+
the team's shared workspace server (--build maps the repos first if empty)
|
|
13
|
+
modeld mcp the MCP server over stdio (a local workspace, or $OCM_URL)
|
|
14
|
+
modeld fetch-model [--to DIR] download the embedding model once, for offline and air-gapped machines
|
|
15
|
+
modeld hybrid | measure | calc | hook ...
|
|
16
|
+
as in `python -m ocm` (see README)
|
|
17
|
+
|
|
18
|
+
ask, next, teach and stats use the nearest modeld-out folder (this folder or a parent), $MODELD_WORKSPACE, or
|
|
19
|
+
--workspace; with --url (or $OCM_URL) they ask a team server instead."""
|
|
20
|
+
import argparse, json, os, sys, urllib.request
|
|
21
|
+
|
|
22
|
+
OUT = "modeld-out"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def find_workspace(start=None):
|
|
26
|
+
"""--workspace, else $MODELD_WORKSPACE, else the nearest modeld-out above the current folder, else None."""
|
|
27
|
+
env = os.environ.get("MODELD_WORKSPACE")
|
|
28
|
+
if env:
|
|
29
|
+
return env
|
|
30
|
+
d = os.path.abspath(start or os.getcwd())
|
|
31
|
+
while True:
|
|
32
|
+
cand = os.path.join(d, OUT)
|
|
33
|
+
if os.path.exists(os.path.join(cand, "workspace.json")):
|
|
34
|
+
return cand
|
|
35
|
+
parent = os.path.dirname(d)
|
|
36
|
+
if parent == d:
|
|
37
|
+
return None
|
|
38
|
+
d = parent
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _post(url, path, body):
|
|
42
|
+
req = urllib.request.Request(f"{url.rstrip('/')}/{path}", data=json.dumps(body).encode(),
|
|
43
|
+
headers={"Content-Type": "application/json"})
|
|
44
|
+
return urllib.request.urlopen(req, timeout=300).read().decode()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _call(args, path, body, then=None):
|
|
48
|
+
"""Run one workspace call (or two, for `next`) locally or on the team server; print the result."""
|
|
49
|
+
url = args.url or os.environ.get("OCM_URL")
|
|
50
|
+
if url:
|
|
51
|
+
print(_post(url, then or path, body)); return
|
|
52
|
+
ws = args.workspace or find_workspace()
|
|
53
|
+
if not ws:
|
|
54
|
+
sys.exit(f"no {OUT} folder here or above: run `modeld build .` first, or pass --workspace or --url")
|
|
55
|
+
os.environ.setdefault("OCM_OFFLINE", "1")
|
|
56
|
+
from .server import Workspace
|
|
57
|
+
w = Workspace(ws)
|
|
58
|
+
out = w.handle(path, body)
|
|
59
|
+
if then: # a local process has no earlier readout to advance from: ask, then next
|
|
60
|
+
out = w.handle(then, body)
|
|
61
|
+
print(out)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _common(ap):
|
|
65
|
+
ap.add_argument("--workspace"); ap.add_argument("--url")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_cmd(argv):
|
|
69
|
+
ap = argparse.ArgumentParser(prog="modeld build")
|
|
70
|
+
ap.add_argument("path", nargs="*", default=["."]); ap.add_argument("--out")
|
|
71
|
+
ap.add_argument("--model"); ap.add_argument("--device"); ap.add_argument("--carry")
|
|
72
|
+
ap.add_argument("--cache", help="an embedding cache file ($MODELD_CACHE): a rebuild embeds only changed code")
|
|
73
|
+
a = ap.parse_args(argv)
|
|
74
|
+
roots = [os.path.abspath(p) for p in a.path]
|
|
75
|
+
out = os.path.abspath(a.out or os.path.join(roots[0], OUT))
|
|
76
|
+
carry = a.carry or (out if os.path.exists(os.path.join(out, "workspace.json")) else None)
|
|
77
|
+
from .build import build
|
|
78
|
+
if carry and os.path.abspath(carry) == out: # a rebuild in place carries what was taught
|
|
79
|
+
import shutil, tempfile
|
|
80
|
+
tmp = tempfile.mkdtemp(prefix="modeld-out.new-")
|
|
81
|
+
build(roots, tmp, a.model, a.device, carry=out, cache=a.cache)
|
|
82
|
+
for name in os.listdir(out): # replace the contents, not the folder: it may be a mount
|
|
83
|
+
p = os.path.join(out, name)
|
|
84
|
+
shutil.rmtree(p) if os.path.isdir(p) and not os.path.islink(p) else os.remove(p)
|
|
85
|
+
for name in os.listdir(tmp):
|
|
86
|
+
shutil.move(os.path.join(tmp, name), os.path.join(out, name))
|
|
87
|
+
os.rmdir(tmp)
|
|
88
|
+
else:
|
|
89
|
+
build(roots, out, a.model, a.device, carry=carry, cache=a.cache)
|
|
90
|
+
open(os.path.join(out, ".gitignore"), "w").write("*\n") # the workspace never lands in git by accident
|
|
91
|
+
print(f"\nmapped into {out}\nnext: modeld ask \"your question\" --scope code")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def ask_cmd(argv, path="ask"):
|
|
95
|
+
ap = argparse.ArgumentParser(prog=f"modeld {path}"); ap.add_argument("question")
|
|
96
|
+
ap.add_argument("--scope", choices=["all", "code"]); _common(ap)
|
|
97
|
+
a = ap.parse_args(argv)
|
|
98
|
+
body = {"question": a.question, **({"scope": a.scope} if a.scope else {})}
|
|
99
|
+
if path == "next":
|
|
100
|
+
_call(a, "ask", body, then="next")
|
|
101
|
+
else:
|
|
102
|
+
_call(a, "ask", body)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def teach_cmd(argv):
|
|
106
|
+
ap = argparse.ArgumentParser(prog="modeld teach"); ap.add_argument("question"); ap.add_argument("at")
|
|
107
|
+
_common(ap)
|
|
108
|
+
a = ap.parse_args(argv)
|
|
109
|
+
_call(a, "teach", {"pairs": [{"question": a.question, "at": a.at}]})
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def stats_cmd(argv):
|
|
113
|
+
ap = argparse.ArgumentParser(prog="modeld stats"); _common(ap)
|
|
114
|
+
_call(ap.parse_args(argv), "stats", {})
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def serve_cmd(argv):
|
|
118
|
+
ap = argparse.ArgumentParser(prog="modeld serve"); ap.add_argument("workspace", nargs="?")
|
|
119
|
+
ap.add_argument("--port", type=int, default=7700); ap.add_argument("--host", default="127.0.0.1")
|
|
120
|
+
ap.add_argument("--build", nargs="+", metavar="REPO", help="map these repositories into the workspace first, if it is empty")
|
|
121
|
+
a = ap.parse_args(argv)
|
|
122
|
+
ws = a.workspace or find_workspace()
|
|
123
|
+
if a.build and ws and not os.path.exists(os.path.join(ws, "workspace.json")):
|
|
124
|
+
build_cmd([*a.build, "--out", ws])
|
|
125
|
+
if not ws or not os.path.exists(os.path.join(ws, "workspace.json")):
|
|
126
|
+
sys.exit(f"no workspace at {ws or 'a ' + OUT + ' folder here or above'}: run `modeld build <repo>` first, or pass --build")
|
|
127
|
+
os.environ.setdefault("OCM_OFFLINE", "1")
|
|
128
|
+
from .server import serve
|
|
129
|
+
print(f"serving {ws} at http://{a.host}:{a.port}"); sys.stdout.flush()
|
|
130
|
+
serve(ws, a.port, a.host)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def fetch_model_cmd(argv):
|
|
134
|
+
ap = argparse.ArgumentParser(prog="modeld fetch-model")
|
|
135
|
+
ap.add_argument("--to", help="a folder to hold the model (used as HF_HOME); default: the normal Hugging Face cache")
|
|
136
|
+
a = ap.parse_args(argv)
|
|
137
|
+
if a.to:
|
|
138
|
+
os.environ["HF_HOME"] = os.path.abspath(a.to)
|
|
139
|
+
os.environ.pop("HF_HUB_OFFLINE", None); os.environ.pop("TRANSFORMERS_OFFLINE", None)
|
|
140
|
+
from .cmlm_vector.embed import DEFAULT_MODEL
|
|
141
|
+
from huggingface_hub import snapshot_download
|
|
142
|
+
path = snapshot_download(DEFAULT_MODEL, allow_patterns=["*.json", "*.txt", "model.safetensors"]) # weights + tokenizer only
|
|
143
|
+
print(f"{DEFAULT_MODEL} is at {path}")
|
|
144
|
+
if a.to:
|
|
145
|
+
print(f"on the offline machine: export HF_HOME={os.path.abspath(a.to)} OCM_OFFLINE=1")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def main(argv=None):
|
|
149
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
150
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
151
|
+
print(__doc__); return
|
|
152
|
+
cmd, rest = argv[0], argv[1:]
|
|
153
|
+
if cmd == "--version":
|
|
154
|
+
from . import __version__
|
|
155
|
+
print(f"modeld {__version__}"); return
|
|
156
|
+
if cmd not in COMMANDS and os.path.isdir(cmd): # modeld . == modeld build .
|
|
157
|
+
cmd, rest = "build", argv
|
|
158
|
+
if cmd not in COMMANDS:
|
|
159
|
+
print(__doc__); sys.exit(2)
|
|
160
|
+
COMMANDS[cmd](rest)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _module(name):
|
|
164
|
+
def run(rest):
|
|
165
|
+
import importlib
|
|
166
|
+
importlib.import_module(f"ocm.{name}").main(rest)
|
|
167
|
+
return run
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def install_cmd(argv):
|
|
171
|
+
from .install import main as install_main
|
|
172
|
+
install_main(argv)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
COMMANDS = {"build": build_cmd, "ask": ask_cmd, "next": lambda r: ask_cmd(r, "next"), "teach": teach_cmd,
|
|
176
|
+
"stats": stats_cmd, "serve": serve_cmd, "fetch-model": fetch_model_cmd, "install": install_cmd,
|
|
177
|
+
"init": _module("init"), "mcp": _module("mcp"), "hybrid": _module("hybrid"), "measure": _module("measure"),
|
|
178
|
+
"calc": _module("calc"), "hook": _module("loop")}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
if __name__ == "__main__":
|
|
182
|
+
main()
|
ocm/cmlm_vector/chunk.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Text into units: the sentences a memory stores and returns.
|
|
2
|
+
|
|
3
|
+
Paragraphs are split on blank lines, then into sentences; a line break inside a paragraph is kept as a boundary
|
|
4
|
+
only when the next line starts a new thought (a capital, a digit or a bullet), so wrapped prose is rejoined. Units
|
|
5
|
+
under `min_words` words are dropped; a unit over `max_chars` is cut at word boundaries. Deterministic: the same
|
|
6
|
+
text gives the same units in the same order."""
|
|
7
|
+
import re
|
|
8
|
+
|
|
9
|
+
_SENT = re.compile(r"(?<=[.!?])\s+(?=[A-Z0-9\"'(\[])")
|
|
10
|
+
# mail and news metadata: near-identical lines in every message, which crowd a readout and rarely answer anything
|
|
11
|
+
HEADER = re.compile(r"^(From|Subject|Organi[sz]ation|Lines|Reply-To|NNTP-Posting-Host|Distribution|Keywords|Summary|"
|
|
12
|
+
r"Article-I\.D\.|Expires|X-[\w-]+|Sender|Path|Newsgroups|Message-ID|References|Date|To|Cc):|^In article <|"
|
|
13
|
+
r"(writes|wrote):$", re.I)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def units(text, min_words=4, max_chars=400, drop_headers=False):
|
|
17
|
+
if drop_headers:
|
|
18
|
+
return [u for u in units(text, min_words, max_chars) if not HEADER.search(u)]
|
|
19
|
+
out = []
|
|
20
|
+
for para in re.split(r"\n\s*\n", text.replace("\r", "")):
|
|
21
|
+
lines = [l.strip() for l in para.split("\n") if l.strip()]
|
|
22
|
+
if not lines:
|
|
23
|
+
continue
|
|
24
|
+
joined = lines[0]
|
|
25
|
+
for l in lines[1:]:
|
|
26
|
+
joined += ("\n" if re.match(r"[A-Z0-9\-*>•]", l) and re.search(r"[.!?:]$", joined) else " ") + l
|
|
27
|
+
for piece in joined.split("\n"):
|
|
28
|
+
for s in _SENT.split(piece):
|
|
29
|
+
s = " ".join(s.split())
|
|
30
|
+
while len(s) > max_chars:
|
|
31
|
+
cut = s.rfind(" ", 0, max_chars)
|
|
32
|
+
cut = cut if cut > max_chars // 2 else max_chars
|
|
33
|
+
head, s = s[:cut], s[cut:].strip()
|
|
34
|
+
if len(head.split()) >= min_words:
|
|
35
|
+
out.append(head)
|
|
36
|
+
if len(s.split()) >= min_words:
|
|
37
|
+
out.append(s)
|
|
38
|
+
return out
|
ocm/cmlm_vector/cli.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""cmlm-vector: a memory a frontier model asks and teaches, instead of a transcript it rereads.
|
|
2
|
+
|
|
3
|
+
cmlm-vector init MEM [--bits 1024] [--seed 0] [--model BAAI/bge-base-en-v1.5] [--device cpu|mps|cuda]
|
|
4
|
+
cmlm-vector ingest MEM PATH... [--glob '*.txt'] [--drop-headers] files or folders; already-held files are skipped
|
|
5
|
+
cmlm-vector ask MEM "question" [-k 5] [--context 1] [--json] single shot: the top k facts with their sources
|
|
6
|
+
cmlm-vector teach MEM --cue "how a person might ask" --fact "the one sentence that answers it"
|
|
7
|
+
cmlm-vector info MEM
|
|
8
|
+
cmlm-vector verify MEM every file against its sha256, planes against seed
|
|
9
|
+
|
|
10
|
+
A memory is a folder; copy it and it answers the same way on another machine (use the same device for bit-exact
|
|
11
|
+
replication). Nothing is sent anywhere: embeddings are computed locally."""
|
|
12
|
+
import argparse, glob as globmod, json, os, sys
|
|
13
|
+
|
|
14
|
+
from .memory import Memory
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _files(paths, pattern):
|
|
18
|
+
out = []
|
|
19
|
+
for p in paths:
|
|
20
|
+
if os.path.isdir(p):
|
|
21
|
+
out += [f for f in globmod.glob(os.path.join(p, "**", pattern), recursive=True) if os.path.isfile(f)]
|
|
22
|
+
elif os.path.isfile(p):
|
|
23
|
+
out.append(p)
|
|
24
|
+
return sorted(set(out))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main(argv=None):
|
|
28
|
+
p = argparse.ArgumentParser(prog="cmlm-vector", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
30
|
+
a = sub.add_parser("init"); a.add_argument("mem"); a.add_argument("--bits", type=int, default=1024)
|
|
31
|
+
a.add_argument("--seed", type=int, default=0); a.add_argument("--model"); a.add_argument("--device")
|
|
32
|
+
a = sub.add_parser("ingest"); a.add_argument("mem"); a.add_argument("paths", nargs="+")
|
|
33
|
+
a.add_argument("--glob", default="*.txt"); a.add_argument("--keep-float", action="store_true")
|
|
34
|
+
a.add_argument("--drop-headers", action="store_true", help="skip mail/news metadata lines (From:, Subject:, ...)")
|
|
35
|
+
a.add_argument("--no-center", action="store_true", help="do not centre the bits on the corpus mean")
|
|
36
|
+
a = sub.add_parser("ask"); a.add_argument("mem"); a.add_argument("question"); a.add_argument("-k", type=int, default=5)
|
|
37
|
+
a.add_argument("--context", type=int, default=1); a.add_argument("--json", action="store_true")
|
|
38
|
+
a = sub.add_parser("teach"); a.add_argument("mem"); a.add_argument("--cue", required=True); a.add_argument("--fact", required=True)
|
|
39
|
+
a = sub.add_parser("info"); a.add_argument("mem")
|
|
40
|
+
a = sub.add_parser("verify"); a.add_argument("mem")
|
|
41
|
+
x = p.parse_args(argv)
|
|
42
|
+
|
|
43
|
+
if x.cmd == "init":
|
|
44
|
+
m = Memory.create(x.mem, bits=x.bits, seed=x.seed, model=x.model, device=x.device)
|
|
45
|
+
print(json.dumps(m.info(), indent=1)); return 0
|
|
46
|
+
if x.cmd == "verify": # before opening: a damaged memory may not open at all
|
|
47
|
+
bad = Memory.verify_path(x.mem)
|
|
48
|
+
print("verified: every file matches its sha256 and the planes match the seed" if not bad else f"MISMATCH: {bad}")
|
|
49
|
+
return 0 if not bad else 1
|
|
50
|
+
m = Memory(x.mem)
|
|
51
|
+
if x.cmd == "ingest":
|
|
52
|
+
fs = _files(x.paths, x.glob)
|
|
53
|
+
print(f"ingesting {len(fs):,} files into {x.mem}", flush=True)
|
|
54
|
+
r = m.ingest(fs, keep_float=x.keep_float, log=lambda s: print(s, flush=True), drop_headers=x.drop_headers, center=not x.no_center)
|
|
55
|
+
print(json.dumps({**r, **m.info()}, indent=1)); return 0
|
|
56
|
+
if x.cmd == "ask":
|
|
57
|
+
r = m.recall(x.question, k=x.k, context=x.context)
|
|
58
|
+
if x.json:
|
|
59
|
+
print(json.dumps(r, indent=1)); return 0
|
|
60
|
+
if r["status"] == "abstain":
|
|
61
|
+
print("NOTHING CLOSE: no stored fact stands out for this question; these are the nearest. Ask again in other words if none answers it.")
|
|
62
|
+
for i, row in enumerate(r["rows"], 1):
|
|
63
|
+
flag = " [ambiguous: check against the question]" if row["flag"] else ""
|
|
64
|
+
print(f"{i}. {row['fact']}{flag}\n source: {row['source']} (unit {row['unit']})")
|
|
65
|
+
if row["context"]:
|
|
66
|
+
print(" context: " + " | ".join(row["context"]))
|
|
67
|
+
return 0
|
|
68
|
+
if x.cmd == "teach":
|
|
69
|
+
r = m.teach([(x.cue, x.fact)]); m.save(); print(json.dumps(r)); return 0
|
|
70
|
+
if x.cmd == "info":
|
|
71
|
+
print(json.dumps(m.info(), indent=1)); return 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
sys.exit(main())
|
ocm/cmlm_vector/embed.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Sentence embeddings from a small local model, at no token cost: nothing leaves the machine.
|
|
2
|
+
|
|
3
|
+
BAAI/bge-base-en-v1.5 by default (768 dimensions, CLS pooled, normalized), read from the local Hugging Face cache.
|
|
4
|
+
Batched in order of length so padding stays small, on Apple's GPU or CUDA when present. The device is recorded in
|
|
5
|
+
the memory's manifest: CPU and GPU arithmetic differ in the last bits, so an exact replication uses the same device
|
|
6
|
+
(or --device cpu everywhere)."""
|
|
7
|
+
import os
|
|
8
|
+
|
|
9
|
+
DEFAULT_MODEL = os.environ.get("CMLM_EMBED_MODEL", "BAAI/bge-base-en-v1.5")
|
|
10
|
+
# The instruction a model's card says to put before a search query matched against passages (not before passages,
|
|
11
|
+
# and not before a query matched against other questions).
|
|
12
|
+
QUERY_PREFIX = {"BAAI/bge-small-en-v1.5": "Represent this sentence for searching relevant passages: ",
|
|
13
|
+
"BAAI/bge-base-en-v1.5": "Represent this sentence for searching relevant passages: ",
|
|
14
|
+
"BAAI/bge-large-en-v1.5": "Represent this sentence for searching relevant passages: "}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def pick_device(device=None):
|
|
18
|
+
import torch
|
|
19
|
+
if device:
|
|
20
|
+
return device
|
|
21
|
+
if torch.cuda.is_available():
|
|
22
|
+
return "cuda"
|
|
23
|
+
if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
|
|
24
|
+
return "mps"
|
|
25
|
+
return "cpu"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Embedder:
|
|
29
|
+
def __init__(self, name=DEFAULT_MODEL, device=None, offline=None, max_length=128):
|
|
30
|
+
# The first run downloads the embedding model (about 440 MB) from Hugging Face; set OCM_OFFLINE=1 afterwards to
|
|
31
|
+
# guarantee nothing is fetched. Your code and questions are never sent anywhere: embedding runs on this machine.
|
|
32
|
+
offline = os.environ.get("OCM_OFFLINE") == "1" if offline is None else offline
|
|
33
|
+
if offline:
|
|
34
|
+
os.environ.setdefault("HF_HUB_OFFLINE", "1"); os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
|
|
35
|
+
import torch
|
|
36
|
+
from transformers import AutoModel, AutoTokenizer
|
|
37
|
+
self.torch, self.name, self.max_length = torch, name, max_length
|
|
38
|
+
self.device = pick_device(device)
|
|
39
|
+
self.tok = AutoTokenizer.from_pretrained(name)
|
|
40
|
+
self.model = AutoModel.from_pretrained(name).train(False).to(self.device) # inference only: nothing learns
|
|
41
|
+
self.dim = self.model.config.hidden_size
|
|
42
|
+
|
|
43
|
+
def __call__(self, texts, batch=256):
|
|
44
|
+
import numpy as np
|
|
45
|
+
texts = list(texts)
|
|
46
|
+
out = np.zeros((len(texts), self.dim), dtype=np.float32)
|
|
47
|
+
order = sorted(range(len(texts)), key=lambda i: len(texts[i]))
|
|
48
|
+
with self.torch.no_grad():
|
|
49
|
+
for s in range(0, len(order), batch):
|
|
50
|
+
idx = order[s:s + batch]
|
|
51
|
+
b = self.tok([texts[i] for i in idx], padding=True, truncation=True, max_length=self.max_length, return_tensors="pt")
|
|
52
|
+
b = {k: v.to(self.device) for k, v in b.items()}
|
|
53
|
+
v = self.model(**b).last_hidden_state[:, 0]
|
|
54
|
+
v = self.torch.nn.functional.normalize(v.float(), dim=-1).cpu().numpy()
|
|
55
|
+
out[idx] = v
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
_SHARED = {}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def shared(name=DEFAULT_MODEL, device=None):
|
|
63
|
+
key = (name, device)
|
|
64
|
+
if key not in _SHARED:
|
|
65
|
+
_SHARED[key] = Embedder(name, device)
|
|
66
|
+
return _SHARED[key]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The learned law: fluidnet's searched integer law over the vector law's top candidates, learned from Claude's teaching.
|
|
2
|
+
|
|
3
|
+
The vector law ranks by closeness alone. The learned law decides which of its top N candidates comes first, from an
|
|
4
|
+
8-bit word measured per candidate, and it is SEARCHED, not trained: each lane is STRONG, WEAK or OFF (3^8 = 6,561
|
|
5
|
+
laws), the rank is the lexicographic (strong count, weak count, the vector law's order), one strong beats any number
|
|
6
|
+
of weak because the rank says so, and every law is monotone (a lane turning on never lowers a candidate), which
|
|
7
|
+
`monotone` checks on all 256 words as fluidnet checks its laws.
|
|
8
|
+
|
|
9
|
+
b0 CUE1 best of all facts by cue bits b1 FACT1 best by fact-text bits
|
|
10
|
+
b2 CUE~ within 1% of the bits of the best cue b3 FACT~ the same by fact text
|
|
11
|
+
b4 CUE3 top 3 by cue b5 FACT3 top 3 by fact text
|
|
12
|
+
b6 TYPE the question asks for a date / amount / count / person / place / percent and the fact holds one;
|
|
13
|
+
silent when it asks for none (fluidnet's revision 2: unmeasured is silent, not weak)
|
|
14
|
+
b7 BOTH3 top 3 by cue and by fact text
|
|
15
|
+
|
|
16
|
+
The examples are the pairs Claude teaches for a fact the memory already holds: a new wording of a known fact, which
|
|
17
|
+
is what learning from a miss produces. Each is measured before its cue is stored, so it cannot match itself. The
|
|
18
|
+
gate is prequential and honest: every example is first scored by the law in use and by the plain vector order, and
|
|
19
|
+
the law takes over only when it has put the right fact first more often, on examples it had not yet learned from,
|
|
20
|
+
over the last 50 examples (and it is searched on those 50), so a law that stops winning loses its place.
|
|
21
|
+
sandbox/general_check/law_search.py is the same law judged on held-out facts and topics."""
|
|
22
|
+
import itertools
|
|
23
|
+
import re
|
|
24
|
+
|
|
25
|
+
import numpy as np
|
|
26
|
+
|
|
27
|
+
LANES = ("CUE1", "FACT1", "CUE~", "FACT~", "CUE3", "FACT3", "TYPE", "BOTH3")
|
|
28
|
+
NEAR = 0.01
|
|
29
|
+
|
|
30
|
+
ASK = {
|
|
31
|
+
"date": r"\bwhen\b|\bdates?\b|\bdays?\b|\bdeadline\b|\bwhat time\b|\bmoment\b|\bwhich year\b|\bwhat year\b",
|
|
32
|
+
"money": r"\bhow much\b|\bmoney\b|\bprice\b|\bcosts?\b|\bfees?\b|\bcharges?\b|\bspend|\bbudget|\bbill\b|\bpay\b|\bpaid\b|\bowe\b"
|
|
33
|
+
r"|\bsalary\b|\bcompensation\b|\bdollar|\bfunding\b|\ballowance\b|\breimburse|\bbonus\b|\bstipend\b|\bearn\b|\bpayout\b",
|
|
34
|
+
"count": r"\bhow many\b|\bnumber of\b|\bcapacity\b|\bheadcount\b|\bhold\b|\bfit\b|\bcount\b|\bsample size\b|\battendance\b",
|
|
35
|
+
"person": r"\bwho\b|\bwhom\b|\bwhose\b|\bwhich (colleague|individual|person|people|employee|staff|hire|specialist)\b",
|
|
36
|
+
"place": r"\bwhere\b|\baddress\b|\blocation\b|\bvenue\b|\bwhich city\b|\bregion\b|\bzone\b",
|
|
37
|
+
"percent": r"\bpercent|\bproportion\b|\bshare of\b|\bfraction\b|\bportion\b",
|
|
38
|
+
}
|
|
39
|
+
MONTHS = "January|February|March|April|May|June|July|August|September|October|November|December"
|
|
40
|
+
HOLDS = {
|
|
41
|
+
"date": rf"\b({MONTHS}|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday)\b|\b(19|20)\d\d\b|\bQ[1-4]\b",
|
|
42
|
+
"money": r"\$\s?\d",
|
|
43
|
+
"count": r"(?<![$\d.,])\b\d[\d,]*\b(?!\s*(percent|%))",
|
|
44
|
+
"person": r"\b[A-Z][a-z]+ [A-Z][a-z]+\b",
|
|
45
|
+
"place": r"\b(Street|Avenue|Road|Boulevard|floor|Room|Suite|region)\b",
|
|
46
|
+
"percent": r"\bpercent\b|%",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def asks(q):
|
|
51
|
+
return {t for t, rx in ASK.items() if re.search(rx, q.lower())}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def holds(f):
|
|
55
|
+
return {t for t, rx in HOLDS.items() if re.search(rx, f)}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
LAWS = np.array(list(itertools.product((0, 1, 2), repeat=8)), dtype=np.int16) # 0 off, 1 weak, 2 strong
|
|
59
|
+
BASE = int(np.where(np.all(LAWS == 0, axis=1))[0][0]) # every lane off: the vector order
|
|
60
|
+
_BITS = ((np.arange(256)[:, None] >> np.arange(8)[None, :]) & 1).astype(np.int16)
|
|
61
|
+
VALUE = ((_BITS[None] * (LAWS[:, None, :] == 2)).sum(-1) * 16
|
|
62
|
+
+ (_BITS[None] * (LAWS[:, None, :] == 1)).sum(-1)).astype(np.int16) # [law, word]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def words(order, strong, weak, bits, qtypes, ftypes, n=8):
|
|
66
|
+
"""The 8-bit word of each of the top-n candidates in `order` (the vector law's order)."""
|
|
67
|
+
sr = np.empty(len(strong), dtype=np.int64); sr[np.argsort(-strong, kind="stable")] = np.arange(len(strong))
|
|
68
|
+
wr = np.empty(len(weak), dtype=np.int64); wr[np.argsort(-weak, kind="stable")] = np.arange(len(weak))
|
|
69
|
+
bs, bw = int(strong.max()), int(weak.max())
|
|
70
|
+
out = []
|
|
71
|
+
for j in order[:n]:
|
|
72
|
+
b = (sr[j] == 0, wr[j] == 0, strong[j] >= bs - NEAR * bits, weak[j] >= bw - NEAR * bits,
|
|
73
|
+
sr[j] < 3, wr[j] < 3, bool(qtypes & ftypes[j]), sr[j] < 3 and wr[j] < 3)
|
|
74
|
+
out.append(sum(int(v) << i for i, v in enumerate(b)))
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def rank_of(law, ws, right):
|
|
79
|
+
"""Where `law` puts the candidate at position `right` (0 = first); ties keep the vector law's order."""
|
|
80
|
+
if right < 0:
|
|
81
|
+
return 99
|
|
82
|
+
v = VALUE[law][ws].astype(np.int32) * 64 - np.arange(len(ws))
|
|
83
|
+
return int((v > v[right]).sum())
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def apply(law, ws):
|
|
87
|
+
"""The permutation of the candidates that `law` ranks."""
|
|
88
|
+
v = VALUE[law][ws].astype(np.int32) * 64 - np.arange(len(ws))
|
|
89
|
+
return list(np.argsort(-v, kind="stable"))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def search(examples, n=8):
|
|
93
|
+
"""fluidnet: the law that puts the right fact first most often; ties -> better top-3, then fewer lanes on."""
|
|
94
|
+
ex = [(w + [0] * (n - len(w)), r) for w, r in examples if r >= 0]
|
|
95
|
+
if not ex:
|
|
96
|
+
return BASE
|
|
97
|
+
Wd = np.array([w for w, _ in ex]); R = np.array([r for _, r in ex])
|
|
98
|
+
v = VALUE[:, Wd].astype(np.int32) * 64 - np.arange(n, dtype=np.int32)[None, None, :]
|
|
99
|
+
vr = np.take_along_axis(v, np.broadcast_to(R[None, :, None], v.shape[:2] + (1,)), 2)
|
|
100
|
+
got = (v > vr).sum(-1)
|
|
101
|
+
key = (got == 0).mean(1) * 1e6 + (got < 3).mean(1) * 1e3 - (LAWS > 0).sum(1)
|
|
102
|
+
return int(np.argmax(key))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def monotone(law):
|
|
106
|
+
return all(VALUE[law][w | (1 << b)] >= VALUE[law][w] for w in range(256) for b in range(8))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def describe(law):
|
|
110
|
+
s = [x for x, t in zip(LANES, LAWS[law]) if t == 2]; w = [x for x, t in zip(LANES, LAWS[law]) if t == 1]
|
|
111
|
+
return f"strong {{{', '.join(s)}}} weak {{{', '.join(w)}}}" if (s or w) else "the vector order"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class LearnedLaw:
|
|
115
|
+
def __init__(self, n=8, min_examples=12, window=50):
|
|
116
|
+
"""window: the law is searched on, and must be winning over, the last `window` examples (None: all of
|
|
117
|
+
them). The memory changes as Claude teaches (cues pile up and the vector order gets better), so a law
|
|
118
|
+
that won early must keep winning to keep its place."""
|
|
119
|
+
self.n, self.min_examples, self.window = n, min_examples, window
|
|
120
|
+
self.examples = [] # (words, position of the right fact among the candidates, or -1)
|
|
121
|
+
self.scored = [] # prequential, per example: (law in use first, vector order first)
|
|
122
|
+
self.law = BASE
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def hits(self):
|
|
126
|
+
recent = self.scored[-self.window:] if self.window else self.scored
|
|
127
|
+
return {"law": sum(a for a, _ in recent), "vector": sum(b for _, b in recent)}
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def active(self):
|
|
131
|
+
h = self.hits
|
|
132
|
+
return len(self.examples) >= self.min_examples and self.law != BASE and h["law"] > h["vector"]
|
|
133
|
+
|
|
134
|
+
def observe(self, ws, right):
|
|
135
|
+
self.scored.append((int(rank_of(self.law, ws, right) == 0), int(right == 0)))
|
|
136
|
+
self.examples.append((list(ws), int(right)))
|
|
137
|
+
self.law = search(self.examples[-self.window:] if self.window else self.examples, self.n)
|
|
138
|
+
assert monotone(self.law)
|
|
139
|
+
|
|
140
|
+
def order(self, cand, ws):
|
|
141
|
+
"""The candidates reordered by the law when it has earned it, else as they came."""
|
|
142
|
+
return [cand[i] for i in apply(self.law, ws)] if self.active else list(cand)
|
|
143
|
+
|
|
144
|
+
def state(self):
|
|
145
|
+
return {"law": describe(self.law), "active": self.active, "examples": len(self.examples),
|
|
146
|
+
"prequential first-right": dict(self.hits)}
|