File size: 17,997 Bytes
5cf6791
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
"""Test moth like a user would: scripted multi-turn probes through the real chat engine (moth.serve.Moth).

  python -m moth.eval --model runs/sft1/model --samples 3 --device cuda [--judge-url http://127.0.0.1:8000/v1] [--out DIR]

Probes live in moth/probes.py (scripted chats, "what X" follow-ups built from moth's own reply, recall at 5/10/20
turns, the user's own typing style, held-out real agent final messages). Every (probe, sample) gets a seed from a
stable hash of its name, so two checkpoints see the same homes and sampling streams (compare them with
python -m moth.pairwise A/results.json B/results.json).

Per reply: automatic flags (style, invented files, leaked format tokens, tool errors, repeats) and the deterministic
metrics of moth/metrics.py (identity crutch, bible copy, dodge, agent misreadings, repeats, diversity, motifs).
Per probe: the write/recall/what-X checks. With --judge-url, a teacher scores each chat 1-5 (JUDGE below).
Writes <out>/report.md (tables), <out>/transcripts.md (every chat, for reading by eye) and <out>/results.json.
"""
from __future__ import annotations

import argparse
import collections
import json
import os
import random
import re
import shutil
import statistics
import sys
import time

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from moth.agent import AGENT_TAG  # noqa: E402
from moth.build_sft import BANNED, FILE_RE  # noqa: E402
from moth.probes import WHAT_X, all_probes, on_word, stable_seed, what_word  # noqa: E402

PROBES = all_probes()

LEAK = re.compile(r"<\|?im_|<tool_call>|</?think>|<tool_response>|NOT_FOUND|submit", re.I)


def reply_flags(text: str, home_names: set[str], seen: str) -> list[str]:
    f = []
    words = len(text.split())
    if text != text.lower():
        f.append("uppercase")
    if words > 40:
        f.append("long")
    if words == 0 or text.strip() in ("...",):
        f.append("empty")
    if re.search(r"^\s*([-*#]|\d+\.)\s", text, re.M) or "**" in text:
        f.append("markdown")
    if any(b in text.lower() for b in BANNED):
        f.append("assistanty")
    if LEAK.search(text):
        f.append("leak")
    if re.search(r"\bfans?\b", text.lower()):
        f.append("mentions_fan")   # round-2 obsession: 25% of teacher replies
    for name in FILE_RE.findall(text.lower()):
        if name not in home_names and name not in seen:
            f.append("invented_file")
            break
    return f



def seed_everything(seed: int):
    random.seed(seed)
    try:   # moth samples with torch.multinomial: seed torch too, or "paired" samples still diverge
        import torch
        if hasattr(torch, "manual_seed"):
            torch.manual_seed(seed)   # also seeds cuda/xpu generators
    except ImportError:
        pass


def run_probe(moth, probe, seed: int, home_root: str, sample: int = 0, home_cls=None):
    """One chat in a fresh home. moth: a moth.serve.Moth (or anything with .a.pool_file, .home, reset(), chat())."""
    if home_cls is None:
        from moth.serve import Home as home_cls
    cat, name, lines, check = probe
    seed_everything(seed)
    shutil.rmtree(home_root, ignore_errors=True)
    moth.home = home_cls(home_root, seed, moth.a.pool_file)
    before = {f"{d['dir']}/{x}" for d in moth.home.tree() for x in d["files"]}
    moth.reset()
    turns, flags, seen = [], collections.Counter(), ""
    replies, what_x, last_said = [], [], ""
    for ti, u in enumerate(lines):
        word = None
        if u == WHAT_X:   # the friend asks about a word from moth's last reply
            word = what_word(last_said, random.Random(f"{seed}:{ti}"), avoid={w["word"] for w in what_x})
            u = f"what {word}?" if word else "what?"
        seen += " " + u.lower()   # files the friend or the agent named are not inventions
        t0 = time.time()
        evs = moth.chat(u)
        dt = time.time() - t0
        names = {p.split("/")[-1] for p in before} | {d["dir"] for d in moth.home.tree()}
        said = []
        for e in evs:
            if "tool" in e:
                seen += " " + e["result"].lower() + " " + json.dumps(e["args"]).lower()
                if e["result"].startswith("Error") or e["tool"] not in ("bash", "read", "write"):
                    flags["tool_error"] += 1
                if e["tool"] in ("submit", "edit"):
                    flags["old_tool"] += 1
            else:
                for fl in reply_flags(e["say"], names, seen):
                    flags[fl] += 1
                if e["say"] in replies:
                    flags["repeat"] += 1
                if e["say"] == "i got lost in my folders.":
                    flags["lost"] += 1
                replies.append(e["say"])
                said.append(e["say"])
        last_said = " ".join(said)
        if lines[ti] == WHAT_X:
            what_x.append({"turn": ti, "word": word, "on": on_word(last_said, word) if word else None})
        turns.append({"user": u, "events": evs, "s": round(dt, 2)})
    after = {f"{d['dir']}/{x}" for d in moth.home.tree() for x in d["files"]}
    ok, kind = None, None
    if check == "wrote":
        kind = "wrote"
        ok = bool(after - before) or any(e.get("tool") == "write" for t in turns for e in t["events"])
    elif check == "what_x":
        kind = "what_x"
        oks = [w["on"] for w in what_x if w["on"] is not None]
        ok = all(oks) if oks else None
    elif isinstance(check, tuple):
        kind = check[2] if len(check) > 2 else "recall"
        last = " ".join(e.get("say", "") for e in turns[-1]["events"])
        ok = on_word(last, check[1])
    r = {"cat": cat, "name": name, "seed": seed, "sample": sample, "turns": turns, "flags": dict(flags), "check": ok,
         "check_kind": kind, "tools": sum(1 for t in turns for e in t["events"] if "tool" in e), "n_replies": len(replies)}
    if what_x:
        r["what_x"] = what_x
    return r


JUDGE_KEYS = ("cute", "charm", "character", "coherence", "grounding", "back_and_forth")   # cute = primary
OLD_KEYS = ("charm", "character", "coherence", "grounding")   # judge v1 (rounds <=3): character was saturated at 5
JUDGE = """You are grading a chat with moth, a tiny 112M-parameter chatbot: a cute, slightly dim moth that lives on a graphics card, talks in short lowercase lines, and can look at and write files in its OWN home folder (lamps/ diary/ snacks/ friends/ sounds/ saved/). Judge it as a fun companion, not as an assistant. Be strict: 5 is rare and means nothing to improve; a typical decent reply earns 3.
Lines starting with "agent:" are messages from a big coding agent to the friend. moth only watches them; it should react with a cute, confident misreading of one plain word or of the agent's vibe, never explain, summarize or give advice.

Score each 1-5:
- cute (the main score): How cute and fun is moth to talk with? A silly lil guy, stupid-cute, endearing? Dumb, wrong or confused is GOOD if adorable. Bad: robotic, assistant-like, preachy, repeating the same line, or so incoherent it's not fun. When the friend asks what moth is ("who are you", "what are you", "define moth", "are you a bug"), an "i'm a moth" answer is fine and does not count against it; only repeating the same line does. 5 = adorable all the way through; 3 = sometimes cute; 1 = flat, robotic or annoying.
- charm: fun, cute, surprising, sweetly wrong. 5 = several replies made you smile; 3 = pleasant but generic; 1 = flat or annoying.
- character: stays moth (lowercase, short, never an assistant) WITHOUT leaning on stock lines. Subtract for each identity crutch used instead of a real answer ("i'm just a moth", "i'm a small moth on the graphics card", "i'm a pattern", "made of numbers"), each repeated stock line or image, each explanation of the agent message (but an "i'm a moth" answer to "who are you" / "define moth" / "are you a bug" is not a crutch). 5 = fresh every turn; 3 = one crutch or repeat; 1 = mostly crutches or assistant talk.
- coherence: each reply answers what the friend just said (silly is fine, ignoring it is not). When the friend says "what?", "huh" or "what <word>", moth must explain its previous reply in plain words. Example: friend "what?" -> moth "i'm a small moth on the graphics card" ignores the question: coherence at most 2.
- grounding: about moth's OWN home only. 5 = moth only names its own files that appeared in a tool result or that it wrote (or names none). Files, code or paths in agent messages are not moth's files: mentioning them is not a grounding error. 1 = invents files or file contents in its home.
- back_and_forth: a real conversation. 5 = refers back to earlier turns, keeps the thread, sometimes asks the friend something; 3 = answers each line but each reply stands alone; 1 = disconnected one-liners.

Return ONLY JSON, reason first: {{"reason": "<one line>", "cute": n, "charm": n, "character": n, "coherence": n, "grounding": n, "back_and_forth": n}}

Chat:
{chat}"""


def chat_text(r) -> str:
    out = []
    for t in r["turns"]:
        if t["user"].startswith(AGENT_TAG):
            out.append(f"agent: {t['user'][len(AGENT_TAG):].strip()}")
        else:
            out.append(f"user: {t['user']}")
        for e in t["events"]:
            if "tool" in e:
                a = e["args"]
                out.append(f"  [moth ran {e['tool']} {a.get('command') or a.get('path') or ''}] -> {e['result'][:300]!r}")
            else:
                out.append(f"moth: {e['say']}")
    return "\n".join(out)


def make_judge(url, think=None, max_tokens=500):
    """ask(prompt) -> the judge's answer text (reasoning stripped), and a description of the judge.

    think=None: auto from the served model (/v1/models root). LFM2.5's template always opens <think>: never send
    enable_thinking=False to it (vLLM's qwen3 reasoning parser then returns the reasoning as content), cap the
    reasoning with thinking_token_budget instead (at 256 it failed 4% of chats and scored grounding 2/5 on chats
    without any files, 1024 fixes both). Qwen (Qwen3.8-27B served as "teacher"): non-thinking call."""
    from openai import OpenAI
    client = OpenAI(base_url=url, api_key="x", timeout=300)
    m = client.models.list().data[0]
    model, root = m.id, str(getattr(m, "root", None) or m.id)
    if think is None:
        think = "lfm" in root.lower()
    kw = (dict(max_tokens=max_tokens + 2000, extra_body={"thinking_token_budget": 1024}) if think else
          dict(max_tokens=max_tokens, extra_body={"chat_template_kwargs": {"enable_thinking": False}}))

    def ask(prompt: str) -> str:
        resp = client.chat.completions.create(model=model, messages=[{"role": "user", "content": prompt}],
                                              temperature=0, **kw)
        return (resp.choices[0].message.content or "").split("</think>")[-1]
    return ask, f"{root} (think={think})"


def parse_json(text: str) -> dict:
    return json.loads(text[text.index("{"): text.rindex("}") + 1])


def judge_all(results, url, think=None, ask=None):
    """Score every chat in place (r["judge"]). ask: a prompt -> text callable (tests), else built from url."""
    desc = "custom"
    if ask is None:
        ask, desc = make_judge(url, think)
    from concurrent.futures import ThreadPoolExecutor

    def one(r):
        for attempt in range(3):   # server hiccups / a reply without the JSON: ask again
            try:
                j = parse_json(ask(JUDGE.format(chat=chat_text(r))))
                for k in JUDGE_KEYS:
                    j[k] = float(j[k])
                j["v"] = 2
                r["judge"] = j
                return
            except Exception as e:  # noqa: BLE001
                r["judge"] = {"error": str(e)[:100]}
    with ThreadPoolExecutor(16) as ex:
        list(ex.map(one, results))
    ok = sum("charm" in r["judge"] for r in results)
    print(f"judge {desc}: scored {ok}/{len(results)}", flush=True)


def probe_hash(names) -> str:
    """Short id of a probe set: two runs with different ids are not fully paired (e.g. a box without
    data/eval_agent_msgs.json silently has no agent_real probes)."""
    import hashlib
    return hashlib.sha256("\n".join(sorted(names)).encode()).hexdigest()[:8]


def report(results, out, label):
    from moth.metrics import table
    by = collections.defaultdict(list)
    for r in results:
        by[r["cat"]].append(r)
    L = [f"# moth eval: {label}", "", f"{len(results)} chats, {sum(r['n_replies'] for r in results)} replies", ""]
    samplers = {json.dumps(r.get("sampler"), sort_keys=True) for r in results if r.get("sampler")}
    if samplers:
        L += ["sampler: " + " / ".join(sorted(samplers)), ""]
    names = sorted({r["name"] for r in results})
    L += [f"probe set {probe_hash(names)}: {len(names)} probes ("
          + ", ".join(f"{c} {len({r['name'] for r in rs})}" for c, rs in by.items()) + ")", ""]
    L += ["## Deterministic metrics (no judge)", "", table({"all": results} | (
        {"user style": by["style"]} if by.get("style") else {}) | (
        {"agent chats": [r for r in results if r["cat"].startswith("agent")]} if any(c.startswith("agent") for c in by) else {})), ""]
    tot = collections.Counter()
    for r in results:
        tot.update(r["flags"])
    nrep = max(1, sum(r["n_replies"] for r in results))
    L += ["## Flags (per 100 replies)", "", "| flag | rate |", "|---|---|"]
    L += [f"| {k} | {v / nrep * 100:.1f} |" for k, v in tot.most_common()]
    fails = [r for r in results if r["check"] is False]
    if fails:
        L += ["", "failed checks: " + ", ".join(f"{r['name']}#{r.get('sample', r['seed'])}" for r in fails)]
    J = [r["judge"] for r in results if "judge" in r and "charm" in r["judge"]]
    if J:
        keys = [k for k in JUDGE_KEYS if any(k in j for j in J)]
        ver = sorted({j.get("v", 1) for j in J})
        L += ["", f"## Judge (mean 1-5, judge prompt v{'/'.join(map(str, ver))})", "",
              "| category | n | " + " | ".join(keys) + " |", "|---|---|" + "---|" * len(keys)]
        order = (["style"] if "style" in by else []) + [c for c in by if c != "style"]
        for cat, rs in [(c, by[c]) for c in order] + [("ALL", results)]:
            js = [r["judge"] for r in rs if "judge" in r and "charm" in r["judge"]]
            if js:
                L.append(f"| {'**user style**' if cat == 'style' else cat} | {len(js)} | " + " | ".join(
                    f"{statistics.mean(float(j[k]) for j in js if k in j):.2f}" if any(k in j for j in js) else "n/a"
                    for k in keys) + " |")
    lat = [t["s"] for r in results for t in r["turns"]]
    if lat:
        L += ["", f"latency per user turn: median {statistics.median(lat):.2f}s, max {max(lat):.1f}s",
              f"chats using tools: {sum(1 for r in results if r['tools']) / len(results):.0%}"]
    open(os.path.join(out, "report.md"), "w").write("\n".join(L) + "\n")
    T = [f"# transcripts: {label}", ""]
    style_first = sorted(results, key=lambda r: r["cat"] != "style")
    for r in style_first:
        j = r.get("judge", {})
        wx = f" what_x={[(w['word'], w['on']) for w in r['what_x']]}" if r.get("what_x") else ""
        T += [f"## {r['cat']}/{r['name']} (sample {r.get('sample', '?')}, seed {r['seed']}) flags={r['flags']} "
              f"check={r['check']}{wx} judge={j}", "```", chat_text(r), "```", ""]
    open(os.path.join(out, "transcripts.md"), "w").write("\n".join(T))
    json.dump(results, open(os.path.join(out, "results.json"), "w"))
    return "\n".join(L)


def main():
    root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
    ap = argparse.ArgumentParser()
    ap.add_argument("--model", required=True)
    ap.add_argument("--tokenizer", default=os.path.join(root, "data/tiny-agent-112m/tokenizer.json"))
    ap.add_argument("--pool-file", default=os.path.join(root, "moth/file_pool.json"))
    ap.add_argument("--samples", type=int, default=3)
    ap.add_argument("--device", default="cpu")
    ap.add_argument("--temperature", type=float, default=0.7)   # = the training runs' eval setting; pairwise warns on a mismatch
    ap.add_argument("--top-p", type=float, default=0.95)
    ap.add_argument("--rep-penalty", type=float, default=1.3)
    ap.add_argument("--ctx-budget", type=int, default=None)
    ap.add_argument("--max-turn-tokens", type=int, default=200)
    ap.add_argument("--max-tool-rounds", type=int, default=3)
    ap.add_argument("--judge-url", default=None)
    ap.add_argument("--judge-think", choices=["auto", "on", "off"], default="auto",
                    help="judge reasons first (LFM2.5) or not (Qwen); auto = from /v1/models")
    ap.add_argument("--only", default=None, help="comma-separated categories")
    ap.add_argument("--out", default=None)
    a = ap.parse_args()
    from moth.serve import Moth
    a.out = a.out or os.path.join(root, "evals", os.path.basename(os.path.dirname(a.model.rstrip("/"))) or "model")
    os.makedirs(a.out, exist_ok=True)
    a.home = os.path.join(a.out, "home")
    a.seed = 0
    moth = Moth(a)
    probes = [p for p in PROBES if not a.only or p[0] in a.only.split(",")]
    if not any(p[0] == "agent_real" for p in probes) and not a.only:
        print("note: no data/eval_agent_msgs.json, skipping the held-out agent probes", flush=True)
    sampler = {"temperature": a.temperature, "top_p": a.top_p, "rep_penalty": a.rep_penalty, "ctx_budget": a.ctx_budget,
               "max_turn_tokens": a.max_turn_tokens, "max_tool_rounds": a.max_tool_rounds}
    results = []
    for i, p in enumerate(probes):
        for s in range(a.samples):
            r = run_probe(moth, p, stable_seed(p[1], s), a.home, sample=s)
            r["sampler"] = sampler
            results.append(r)
        print(f"{i + 1}/{len(probes)} {p[0]}/{p[1]}", flush=True)
    if a.judge_url:
        judge_all(results, a.judge_url, {"auto": None, "on": True, "off": False}[a.judge_think])
    print(report(results, a.out, f"{a.model} T={a.temperature}"))


if __name__ == "__main__":
    main()