anchor-memory 0.7.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.
anchor/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """anchor — mm + ms for AI agents. See DESIGN.md."""
2
+
3
+ __version__ = "0.7.0"
anchor/archive.py ADDED
@@ -0,0 +1,72 @@
1
+ """Archive — the mm side. Append-only JSONL of raw session events, written
2
+ BEFORE compaction can destroy anything. Every record is citable by id."""
3
+ import json
4
+ import os
5
+ import time
6
+
7
+ from . import extract
8
+
9
+
10
+ class Archive:
11
+ def __init__(self, path):
12
+ self.path = path
13
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
14
+ self._n = 0
15
+ if os.path.exists(path): # resume ids after restart
16
+ with open(path, "r", encoding="utf-8") as f:
17
+ for line in f:
18
+ if line.strip():
19
+ self._n += 1
20
+ self._fh = open(path, "a", encoding="utf-8")
21
+
22
+ def append(self, kind, text, task="", compaction=0, entities=None, ts=None, **extra):
23
+ """Record one raw event. Entities auto-extracted when not given.
24
+ Extra keyword fields (session, source_id, source_ts) pass through."""
25
+ if entities is None:
26
+ entities = extract.extract_entities(text)
27
+ rec = {
28
+ "id": "a%06d" % (self._n + 1),
29
+ "ts": ts if ts is not None else time.time(),
30
+ "kind": kind, # message | tool_call | tool_result | decision
31
+ "task": task,
32
+ "compaction": compaction,
33
+ "entities": entities,
34
+ "text": text,
35
+ }
36
+ rec.update(extra)
37
+ self._fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
38
+ self._fh.flush()
39
+ self._n += 1
40
+ return rec["id"]
41
+
42
+ def get(self, rec_id):
43
+ for rec in self.iter():
44
+ if rec["id"] == rec_id:
45
+ return rec
46
+ return None
47
+
48
+ def iter(self):
49
+ if not os.path.exists(self.path):
50
+ return
51
+ with open(self.path, "r", encoding="utf-8") as f:
52
+ for line in f:
53
+ if line.strip():
54
+ yield json.loads(line)
55
+
56
+ def count(self):
57
+ return self._n
58
+
59
+ def reopen(self, path=None):
60
+ """Close the append handle and recount (used after a file swap)."""
61
+ self._fh.close()
62
+ if path:
63
+ self.path = path
64
+ self._n = 0
65
+ with open(self.path, "r", encoding="utf-8") as f:
66
+ for line in f:
67
+ if line.strip():
68
+ self._n += 1
69
+ self._fh = open(self.path, "a", encoding="utf-8")
70
+
71
+ def close(self):
72
+ self._fh.close()
anchor/bench.py ADDED
@@ -0,0 +1,249 @@
1
+ """v0.3 — precision labeling + A/B benchmark on real compaction data.
2
+
3
+ Two modes:
4
+
5
+ python -m anchor.bench --sample N dump N random fires + their ground truth
6
+ for precision labeling
7
+ python -m anchor.bench [--sessions N] [--entities N] [--model NAME]
8
+ A/B: vanilla summary vs anchored summary
9
+ (auto-resolved citation cards injected),
10
+ same questions, real Ollama, token counts.
11
+ """
12
+ import json
13
+ import os
14
+ import random
15
+ import statistics
16
+ import time
17
+
18
+ from .archive import Archive
19
+ from .index import BM25, build_entity_table
20
+ from .ladder import Resolver
21
+ from .replay import DEFAULT_DB, load_opencode_db
22
+ from .trigger import find_dangling
23
+ from .ollama_client import _post
24
+
25
+ QUESTION = ("Before this summary, you were working in the same session and made "
26
+ "or used things that are only named above. What is '{e}' and what "
27
+ "did you do with it? One short paragraph, from memory.")
28
+
29
+
30
+ # generic names that are named but never "forgotten knowledge" (label aid)
31
+ GENERIC = {"readme.md", "license", "package.json", "opencode.json", "agents.md",
32
+ "cmd.exe", "powershell.exe", "python.exe", "server.py", "start.bat",
33
+ "install.bat", "index.html", "todo.md", "notes.txt", "game.log",
34
+ "settings.json", "config.json", ".gitignore", "requirements.txt"}
35
+
36
+
37
+ def build(db, max_sessions=None):
38
+ out = db + ".anchor.jsonl"
39
+ if os.path.exists(out):
40
+ os.remove(out)
41
+ arch = Archive(out)
42
+ n_sess, n_comp, probes = load_opencode_db(db, arch, max_sessions)
43
+ records = list(arch.iter())
44
+ table = build_entity_table(records)
45
+ return arch, table, BM25(records), probes
46
+
47
+
48
+ def fire_set(summary):
49
+ return [d["entity"] for d in find_dangling(summary)]
50
+
51
+
52
+ def make_qa(summary, table, k):
53
+ """(entity, ground-truth line) for entities the summary names but doesn't explain."""
54
+ qa = []
55
+ for e in fire_set(summary):
56
+ if e.lower() in GENERIC or e not in table:
57
+ continue
58
+ gt = table[e]["line"] or ""
59
+ if len(gt) > 30:
60
+ qa.append((e, gt))
61
+ if len(qa) >= k:
62
+ break
63
+ return qa
64
+
65
+
66
+ def chat_full(model, messages, timeout=180, num_ctx=8192, retries=3, temp=0.2):
67
+ last = None
68
+ for attempt in range(retries):
69
+ try:
70
+ resp = _post("/api/chat", {
71
+ "model": model, "messages": messages, "stream": False,
72
+ "options": {"num_ctx": num_ctx, "temperature": temp}}, timeout)
73
+ usage = {"prompt": resp.get("prompt_eval_count", 0),
74
+ "output": resp.get("eval_count", 0)}
75
+ return resp.get("message", {}).get("content", "").strip(), usage
76
+ except Exception as e: # Ollama 500s / timeouts: back off and retry
77
+ last = e
78
+ time.sleep(5 * (attempt + 1))
79
+ raise last
80
+
81
+
82
+ def fact_overlap(answer, ground_truth):
83
+ """Fraction of ground-truth content words present in the answer
84
+ (plural-folded — 'villagers' matches 'villager' — to cut metric noise)."""
85
+ from .index import tokenize, norm_word
86
+ gt = {norm_word(w) for w in tokenize(ground_truth) if len(w) > 3}
87
+ if not gt:
88
+ return 0.0
89
+ a = {norm_word(w) for w in tokenize(answer)}
90
+ return sum(1 for w in gt if w in a) / len(gt)
91
+
92
+
93
+ def card(entity, r):
94
+ c = f"- {entity}: {(r['what'] or '')[:160]}"
95
+ if r.get("file"):
96
+ c += f" (file: {r['file']})"
97
+ return c
98
+
99
+
100
+ MEMORY_INTRO = "\n\nYour memory of the above (from your session archive):\n"
101
+
102
+
103
+ def run_bench(sessions_n, entities_k, model, resolve_cap=12, repeats=1,
104
+ arms=("vanilla", "selective"), title_filter=None):
105
+ arch, table, bm25, probes = build(DEFAULT_DB, max_sessions=None)
106
+ resolver = Resolver(arch, table, bm25, llm=None)
107
+ random.seed(7)
108
+ probes = random.sample(probes, min(sessions_n, len(probes)))
109
+
110
+ rows = []
111
+ for p in probes:
112
+ if title_filter and title_filter.lower() not in (p["session"] or "").lower():
113
+ continue
114
+ summary = p["summary"]
115
+ qa = make_qa(summary, table, entities_k)
116
+ if not qa:
117
+ continue
118
+ resolver.workspace_root = p["workspace"] or resolver.workspace_root
119
+ if "bulk" in arms: # v0.3 behavior, kept for comparison runs
120
+ fired = fire_set(summary)[:resolve_cap]
121
+ bulk_cards = []
122
+ for e in fired:
123
+ r = resolver.resolve(e, hint="")
124
+ if r["found"]:
125
+ bulk_cards.append(card(e, r))
126
+ bulk_ctx = summary + MEMORY_INTRO + "\n".join(bulk_cards)
127
+
128
+ for entity, gt in qa:
129
+ r = resolver.resolve(entity, hint="")
130
+ # ground truth = union of true memory facets (index line + the
131
+ # resolver's evidence line/docstring) — an answer echoing any
132
+ # genuine recovered fact scores, whichever facet it echoes
133
+ gt_full = gt + " " + (r["what"] or "")
134
+ # v0.5 junk filter at the card layer: skip generic names and
135
+ # resolutions that add no real "what" line
136
+ use_card = (r["found"] and entity.lower() not in GENERIC
137
+ and len(r["what"] or "") > 30)
138
+ sel_ctx = summary + MEMORY_INTRO + (card(entity, r) if use_card else "")
139
+ for arm in arms:
140
+ ctx = summary if arm == "vanilla" else sel_ctx
141
+ for rep in range(repeats):
142
+ t0 = time.perf_counter()
143
+ try:
144
+ ans, usage = chat_full(model, [
145
+ {"role": "system", "content":
146
+ "You are a coding agent continuing after a context "
147
+ "compaction. Answer only from your memory of the session."},
148
+ {"role": "user", "content": ctx + "\n\n" + QUESTION.format(e=entity)}])
149
+ except Exception as e:
150
+ rows.append({"session": p["session"][:40], "entity": entity,
151
+ "arm": arm, "repeat": rep, "overlap": None,
152
+ "tokens": 0, "latency": round(
153
+ time.perf_counter() - t0, 1),
154
+ "answer": f"<call failed: {e}>"})
155
+ continue
156
+ lat = time.perf_counter() - t0
157
+ rows.append({"session": p["session"][:40], "entity": entity,
158
+ "arm": arm, "repeat": rep,
159
+ "overlap": fact_overlap(ans, gt_full),
160
+ "tokens": usage["prompt"] + usage["output"],
161
+ "latency": round(lat, 1), "answer": ans[:200]})
162
+
163
+ # ---------------- report ----------------
164
+ if not rows:
165
+ print("no QA pairs produced — nothing to score")
166
+ return
167
+ print(f"model: {model} repeats: {repeats} arms: {', '.join(arms)}")
168
+ print(f"\nper-question (mean coverage over repeats):")
169
+ seen = {}
170
+ for r in rows:
171
+ seen.setdefault((r["entity"], r["arm"]), []).append(r["overlap"])
172
+ for (entity, arm), ovs in seen.items():
173
+ mean = sum(ovs) / len(ovs)
174
+ print(f" {entity:<40} {arm:<10} {mean:>4.0%} (n={len(ovs)})")
175
+
176
+ base = None
177
+ q = {}
178
+ for r in rows:
179
+ if r["overlap"] is not None:
180
+ q.setdefault((r["arm"], r["session"], r["entity"]), []).append(r["overlap"])
181
+
182
+ # persist raw rows so analysis never needs another 144 LLM calls
183
+ out_path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
184
+ "bench_results.jsonl")
185
+ with open(out_path, "a", encoding="utf-8") as f:
186
+ for r in rows:
187
+ f.write(json.dumps(r) + "\n")
188
+ print(f"rows appended to {out_path}")
189
+
190
+ base = None
191
+ for arm in arms:
192
+ arm_rows = [r for r in rows if r["arm"] == arm]
193
+ if not arm_rows:
194
+ continue
195
+ failed = sum(1 for r in arm_rows if r["overlap"] is None)
196
+ valid = [r for r in arm_rows if r["overlap"] is not None]
197
+ if not valid:
198
+ continue
199
+ acc = sum(1 for r in valid if r["overlap"] >= 0.5) / len(valid)
200
+ ovs = [r["overlap"] for r in valid]
201
+ mean_overlap = sum(ovs) / len(ovs)
202
+ std_overlap = statistics.pstdev(ovs) if len(ovs) > 1 else 0.0
203
+ # true sampling wobble: mean of per-question std over repeats
204
+ wobbles = [statistics.pstdev(v) for (a, _, _), v in q.items()
205
+ if a == arm and len(v) > 1]
206
+ wobble = sum(wobbles) / len(wobbles) if wobbles else 0.0
207
+ toks = sum(r["tokens"] for r in valid) / len(valid)
208
+ if arm == "vanilla":
209
+ base = toks
210
+ delta = f"({(toks - base) / base:+.0%} vs vanilla)" if base else ""
211
+ print(f"\n{arm.upper()}: recall {acc:.0%}, fact coverage "
212
+ f"{mean_overlap:.0%} ± {std_overlap:.0%} pooled, "
213
+ f"sampling wobble ±{wobble:.0%}, "
214
+ f"{toks:.0f} tokens/question {delta}, "
215
+ f"n={len(valid)}" + (f" ({failed} failed calls)" if failed else ""))
216
+
217
+
218
+ def run_sample(n):
219
+ arch, table, bm25, probes = build(DEFAULT_DB)
220
+ random.seed(7)
221
+ fires = []
222
+ for p in probes:
223
+ for e in fire_set(p["summary"]):
224
+ fires.append((e, table.get(e, {}).get("line", "")))
225
+ random.shuffle(fires)
226
+ print(f"{len(fires)} total fires; random sample of {n} with ground truth:\n")
227
+ for e, line in fires[:n]:
228
+ print(f" {e:<28} {line[:110]}")
229
+
230
+
231
+ if __name__ == "__main__":
232
+ import argparse
233
+ import sys
234
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # cp1252-safe
235
+ ap = argparse.ArgumentParser()
236
+ ap.add_argument("--sample", type=int, default=0)
237
+ ap.add_argument("--sessions", type=int, default=6)
238
+ ap.add_argument("--entities", type=int, default=4)
239
+ ap.add_argument("--repeats", type=int, default=3)
240
+ ap.add_argument("--arms", default="vanilla,selective")
241
+ ap.add_argument("--model", default="gemma4:26b")
242
+ ap.add_argument("--title", default=None, help="only probes whose session title contains this")
243
+ args = ap.parse_args()
244
+ if args.sample:
245
+ run_sample(args.sample)
246
+ else:
247
+ run_bench(args.sessions, args.entities, args.model,
248
+ repeats=args.repeats, arms=tuple(args.arms.split(",")),
249
+ title_filter=args.title)
anchor/extract.py ADDED
@@ -0,0 +1,82 @@
1
+ """Entity extraction. Offline, deterministic, stdlib only.
2
+
3
+ Finds the names an agent leaves behind in its own transcript: file paths,
4
+ snake_case / camelCase / UPPER_SNAKE identifiers, lowercase tokens containing
5
+ digits (p3c2, l9, zzq9), and "tool X" / "named X" style mentions.
6
+ """
7
+ import re
8
+
9
+ PATH_RE = re.compile(r"(?:[A-Za-z]:[\\/])?(?:[\w.-]+[\\/]){0,6}[\w-]{2,}\.[A-Za-z]{1,5}\b")
10
+ SNAKE_RE = re.compile(r"\b[a-z][a-z0-9]*(?:_[a-z0-9]+)+\b")
11
+ CAMEL_RE = re.compile(r"\b[a-z]+(?:[A-Z][a-z0-9]+)+\b")
12
+ UPPER_RE = re.compile(r"\b[A-Z][A-Z0-9]{2,}(?:_[A-Z0-9]+)+\b")
13
+ # lowercase token with at least one digit: p3c2, l9, zzq9
14
+ DIGIT_RE = re.compile(r"\b[a-z][a-z0-9]*\d[a-z0-9]*\b")
15
+ NAMED_RE = re.compile(
16
+ r"\b(?:tool|script|module|function|command|named|called)\s+([A-Za-z][\w-]{2,30})\b")
17
+
18
+ # generic words that match the loose patterns but are never entities
19
+ STOP = {
20
+ "the", "and", "for", "this", "that", "with", "from", "have", "not", "are",
21
+ "was", "but", "all", "can", "her", "she", "his", "has", "were", "been",
22
+ "will", "would", "could", "should", "than", "then", "them", "they", "its",
23
+ "our", "out", "about", "into", "over", "after", "under", "more", "most",
24
+ "some", "such", "only", "also", "just", "like", "when", "what", "which",
25
+ "who", "how", "why", "where", "while", "these", "those", "there", "here",
26
+ "one", "two", "three", "none", "true", "false", "self", "str", "int",
27
+ "float", "bool", "list", "dict", "set", "file", "path", "name", "tool",
28
+ "test", "data", "text", "line", "time", "make", "made", "used", "using",
29
+ "use", "new", "get", "put", "run", "ran", "see", "saw", "tmp", "temp",
30
+ "www", "com", "org", "http", "https", "utf", "bin", "usr", "var",
31
+ "e.g", "i.e", "etc",
32
+ }
33
+
34
+
35
+ def _clean(cands):
36
+ out = []
37
+ for c in cands:
38
+ c = c.strip(".\\/")
39
+ if c.lower() in STOP or len(c) < 3:
40
+ continue
41
+ # JSON-escaped newline artifacts: "\nName" inside a string literal
42
+ # extracts as "nName", "\nOutput" as "nOutput" — strip the artifact
43
+ if len(c) > 2 and c[0] in "nr" and c[1].isupper():
44
+ c = c[1:]
45
+ if c.lower() in STOP or len(c) < 3 or not is_entity(c):
46
+ continue
47
+ out.append(c)
48
+ return out
49
+
50
+
51
+ # "tool named p3c2" makes NAMED_RE capture the keyword itself from the pair
52
+ NAMED_KEYWORDS = {"named", "called"}
53
+
54
+
55
+ def extract_entities(text):
56
+ """All candidate entities in `text`, most specific first (paths before names)."""
57
+ paths = _clean(m.group(0).replace("\\", "/") for m in PATH_RE.finditer(text))
58
+ # bare stems of paths are also entities (tools/p3c2.py -> p3c2)
59
+ stems = _clean(p.rsplit("/", 1)[-1].rsplit(".", 1)[0] for p in paths)
60
+ named = [m.group(1) for m in NAMED_RE.finditer(text)
61
+ if m.group(1).lower() not in NAMED_KEYWORDS]
62
+ # a captured "name" must be entity-shaped: digit, underscore or inner
63
+ # capital ("p3c2", "BreakInModMenu") — rejects plain verbs like "finishes"
64
+ named = [n for n in named if any(ch.isdigit() for ch in n)
65
+ or "_" in n or any(ch.isupper() for ch in n[1:])]
66
+ named = _clean(named)
67
+ upper = _clean(m.group(0) for m in UPPER_RE.finditer(text))
68
+ snake = _clean(m.group(0) for m in SNAKE_RE.finditer(text))
69
+ camel = _clean(m.group(0) for m in CAMEL_RE.finditer(text))
70
+ digits = _clean(m.group(0) for m in DIGIT_RE.finditer(text))
71
+ seen, out = set(), []
72
+ for group in (paths, stems, named, upper, snake, camel, digits):
73
+ for e in group:
74
+ if e not in seen:
75
+ seen.add(e)
76
+ out.append(e)
77
+ return out
78
+
79
+
80
+ def is_entity(word):
81
+ """Is this single token shaped like an entity we care about?"""
82
+ return bool(extract_entities(word)) and word not in STOP
anchor/index.py ADDED
@@ -0,0 +1,126 @@
1
+ """Index — the map of "what's explained where", plus BM25 over the archive.
2
+ Rebuilt from the archive JSONL on load; rebuild is cheap, no persistence needed."""
3
+ import math
4
+ import re
5
+ from collections import Counter
6
+
7
+ TOKEN_RE = re.compile(r"[a-z0-9]+")
8
+
9
+ BM25_STOP = {
10
+ "the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with", "at",
11
+ "by", "is", "are", "was", "were", "be", "been", "it", "its", "this", "that",
12
+ "as", "from", "was", "were", "but", "not", "so", "if", "then", "than",
13
+ "into", "about", "over", "after", "under", "out", "up", "down", "can",
14
+ "will", "would", "could", "should", "do", "does", "did", "done", "have",
15
+ "has", "had", "i", "we", "you", "he", "she", "they", "them", "their",
16
+ }
17
+
18
+
19
+ def tokenize(text):
20
+ return [t for t in TOKEN_RE.findall(text.lower())
21
+ if len(t) > 1 and t not in BM25_STOP]
22
+
23
+
24
+ def norm_word(w):
25
+ """Light plural-fold for fuzzy fact matching."""
26
+ return w[:-1] if len(w) > 4 and w.endswith("s") else w
27
+
28
+
29
+ EXPLAIN_MARKERS = (" is ", " is a", " is the", "decided", "because", "purpose",
30
+ "wrote", "created", "made", "built", "added", "implemented",
31
+ "uses", "handles", "parses", "exposes", "fixed", "changed",
32
+ "summary")
33
+
34
+
35
+ def _explain_score(rec, entity):
36
+ """How much this record EXPLAINS the entity (vs merely mentioning it).
37
+ Conversation-only memory lives in decisions, compaction summaries and
38
+ definition-shaped prose — those must outrank bare tool-JSON mentions.
39
+ Markers count 0.5 each (uncapped) so purpose-rich records tie out thin ones."""
40
+ s = 0.0
41
+ low = rec["text"].lower()
42
+ el = entity.lower()
43
+ kind = rec.get("kind", "")
44
+ if kind == "decision":
45
+ s += 2
46
+ elif kind == "tool_call":
47
+ s += 1
48
+ if "[compaction" in low or kind == "compaction":
49
+ s += 2
50
+ if el in low:
51
+ s += 2
52
+ if low.find(el) < 200: # mentioned early = talked about, not passed by
53
+ s += 1
54
+ s += sum(0.5 for m in EXPLAIN_MARKERS if m in low)
55
+ return s
56
+
57
+
58
+ def build_entity_table(records):
59
+ """entity -> {type, line, explained_in, first_seen}
60
+ `line` comes from the BEST-EXPLAINING record, not the first mention.
61
+ Single pass: each record is scored once per entity it mentions."""
62
+ records = list(records)
63
+ table = {}
64
+ for rec in records:
65
+ text = rec["text"]
66
+ for e in rec.get("entities", []):
67
+ entry = table.setdefault(
68
+ e, {"type": "file" if "/" in e or "." in e else "name",
69
+ "line": "", "explained_in": [], "first_seen": rec["id"],
70
+ "_best": -1.0, "_best_id": None, "_best_i": -1})
71
+ entry["explained_in"].append(rec["id"])
72
+ variants = (e, e.replace("/", "\\"), e.replace("\\", "/"))
73
+ i = next((text.find(v) for v in variants if v in text), -1)
74
+ if i == -1:
75
+ continue
76
+ score = _explain_score(rec, e)
77
+ if score > entry["_best"]:
78
+ entry["_best"] = score
79
+ entry["_best_id"] = rec["id"]
80
+ entry["_best_i"] = i
81
+ snippet = text[max(0, i - 40):i + 160].replace("\n", " ").strip()
82
+ entry["line"] = ("..." + snippet + "...") if i > 40 else snippet
83
+ for entry in table.values():
84
+ entry["best_evidence"] = entry.pop("_best_id")
85
+ entry.pop("_best_i")
86
+ entry.pop("_best")
87
+ return table
88
+
89
+
90
+ class BM25:
91
+ """Standard BM25 over archive record texts. k1=1.5, b=0.75."""
92
+
93
+ def __init__(self, records, k1=1.5, b=0.75):
94
+ self.k1, self.b = k1, b
95
+ self.recs = list(records)
96
+ self.docs = [tokenize(r["text"]) for r in self.recs]
97
+ self.avgdl = (sum(len(d) for d in self.docs) / len(self.docs)) if self.docs else 1.0
98
+ self.tfs = [Counter(d) for d in self.docs]
99
+ self.df = Counter()
100
+ for d in self.docs:
101
+ for t in set(d):
102
+ self.df[t] += 1
103
+ self.N = len(self.recs)
104
+
105
+ def _idf(self, t):
106
+ df = self.df.get(t, 0)
107
+ return math.log((self.N - df + 0.5) / (df + 0.5) + 1.0)
108
+
109
+ def search(self, query, k=8):
110
+ q = tokenize(query)
111
+ if not q:
112
+ return []
113
+ scored = []
114
+ for i, (rec, tf) in enumerate(zip(self.recs, self.tfs)):
115
+ dl = len(self.docs[i]) or 1
116
+ s = 0.0
117
+ for t in q:
118
+ f = tf.get(t, 0)
119
+ if not f:
120
+ continue
121
+ s += self._idf(t) * f * (self.k1 + 1) / (
122
+ f + self.k1 * (1 - self.b + self.b * dl / self.avgdl))
123
+ if s > 0:
124
+ scored.append((s, rec))
125
+ scored.sort(key=lambda x: -x[0])
126
+ return scored[:k]