File size: 6,626 Bytes
2f06744 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 | #!/usr/bin/env python3
"""Bolt-on memory evaluation harness.
Evaluates a memory-injection method against a frozen language model.
* every test draws fresh random facts (entity names, values, bindings)
from a generator with >10^30 combinations;
* three baselines anchor every number:
floor -- the frozen model alone (no memory),
ceiling -- RAG prompt-stuffing (facts pasted into the context),
graft -- the memory-injection method under test;
* metrics: exact-match on the answer span, plus a contamination check
that the answer string does not occur in the injected context.
The fact generator is model-agnostic (sentences built from random
syllable-entities and typed values).
"""
from __future__ import annotations
import argparse
import json
import random
import string
from dataclasses import dataclass, asdict
def _syllable_entity(rng: random.Random) -> str:
"""A fresh pronounceable name, e.g. 'Vorelan Mirith' -- never a real word."""
onsets = ["b", "br", "c", "d", "dr", "f", "g", "gr", "k", "l", "m", "n",
"p", "pr", "r", "s", "st", "t", "tr", "v", "z"]
nuclei = ["a", "e", "i", "o", "u", "ai", "ei", "ou", "au"]
codas = ["", "n", "r", "l", "s", "th", "m"]
def syl():
return rng.choice(onsets) + rng.choice(nuclei) + rng.choice(codas)
name = (syl() + syl()).capitalize()
if rng.random() < 0.5:
name += " " + (syl() + syl()).capitalize()
return name
VALUE_TYPES = {
"password": lambda rng: "".join(rng.choice(string.ascii_lowercase)
for _ in range(rng.randint(6, 10))),
"color": lambda rng: rng.choice(["red", "blue", "green", "amber", "violet",
"white", "black", "teal", "crimson", "slate"]),
"time": lambda rng: f"{rng.randint(1,12)}{rng.choice(['am', 'pm'])}",
"number": lambda rng: str(rng.randint(2, 98)),
"city": lambda rng: _syllable_entity(rng), # fictional on purpose
"food": lambda rng: rng.choice(["stew", "bread", "plums", "cheese", "halva",
"porridge", "dates", "salmon", "biscuits"]),
}
TEMPLATES = {
"password": [
("{e} set the vault password to {v} before leaving.", "What is the vault password {e} set?"),
("The password for {e}'s account is {v}.", "What is the password for {e}'s account?"),
],
"color": [
("{e} painted the boat {v}.", "What color did {e} paint the boat?"),
("The door of {e}'s house is {v}.", "What color is the door of {e}'s house?"),
],
"time": [
("The meeting with {e} is at {v}.", "When is the meeting with {e}?"),
("{e} said the departure is at {v}.", "When did {e} say the departure is?"),
],
"number": [
("{e} counted {v} crates on the dock.", "How many crates did {e} count?"),
("There are {v} keys in {e}'s drawer.", "How many keys are in {e}'s drawer?"),
],
"city": [
("{e} was born in {v}.", "Where was {e} born?"),
("{e} is travelling to {v}.", "Where is {e} travelling?"),
],
"food": [
("{e} ordered {v} at the inn.", "What did {e} order at the inn?"),
("{e}'s favorite meal is {v}.", "What is {e}'s favorite meal?"),
],
}
FILLER = [
"The harbor was quiet that morning.",
"Gulls circled the masts while the tide went out.",
"A clerk stamped papers without looking up.",
"Somewhere a bell rang twice, then stopped.",
"The road east had been washed out again.",
"Dust settled over the market stalls.",
"Two children argued about a kite.",
"The lamp in the window burned all night.",
"A cart of turnips arrived late.",
"Rain threatened but never came.",
]
@dataclass
class FactCase:
doc: str # the document to ingest (facts buried in filler)
question: str # a question about ONE fact
answer: str # the exact answer span
fact_type: str
def make_case(rng: random.Random, n_facts: int, n_filler: int) -> FactCase:
"""One evaluation case: a document with n_facts fresh facts + filler, and a
question about one uniformly chosen fact."""
facts = []
used_entities = set()
for _ in range(n_facts):
ftype = rng.choice(list(VALUE_TYPES))
ent = _syllable_entity(rng)
while ent in used_entities:
ent = _syllable_entity(rng)
used_entities.add(ent)
val = VALUE_TYPES[ftype](rng)
sent_t, q_t = rng.choice(TEMPLATES[ftype])
facts.append((sent_t.format(e=ent, v=val), q_t.format(e=ent), str(val), ftype))
rng.shuffle(facts)
parts = []
for i, (sent, _, _, _) in enumerate(facts):
parts.append(sent)
for _ in range(rng.randint(1, max(1, n_filler // n_facts))):
parts.append(rng.choice(FILLER))
doc = " ".join(parts)
q_sent, ans, ftype = facts[rng.randrange(len(facts))][1], None, None
for sent, q, a, t in facts:
if q == q_sent:
ans, ftype = a, t
break
return FactCase(doc=doc, question=q_sent, answer=ans, fact_type=ftype)
def eval_report(name: str, preds: list[tuple[str, str]]) -> dict:
"""preds: list of (prediction, gold). Exact-match after normalisation."""
def norm(s: str) -> str:
return " ".join(s.lower().strip().strip(".,!?;:'\"").split())
correct = sum(1 for p, g in preds if norm(p) == norm(g))
acc = correct / max(1, len(preds))
return {"method": name, "n": len(preds), "exact_match": round(acc, 4)}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=200, help="cases per evaluation")
ap.add_argument("--facts-per-doc", type=int, default=8)
ap.add_argument("--filler-per-doc", type=int, default=20)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--emit", default="", help="write cases jsonl here")
args = ap.parse_args()
rng = random.Random(args.seed)
cases = [make_case(rng, args.facts_per_doc, args.filler_per_doc) for _ in range(args.n)]
if args.emit:
with open(args.emit, "w") as f:
for c in cases:
f.write(json.dumps(asdict(c)) + "\n")
# sanity: answers must not leak between cases; entities unique per case
for c in cases[:1000]:
assert c.answer in c.doc, "answer must appear in its own doc"
assert c.question not in c.doc
print(f"generated {len(cases)} cases; sample:")
c = cases[0]
print(" doc:", c.doc[:200], "...")
print(" q: ", c.question)
print(" a: ", c.answer)
if __name__ == "__main__":
main()
|