Download bin/eval.py from DBax127/nexa: direct link, hf CLI and curl.
- Browser
- Download file 47.2 kB
-
https://huggingface.co/DBax127/nexa/resolve/main/bin/eval.py
- Command line
-
hf download hf://DBax127/nexa/bin/eval.py
-
curl -L -o eval.py https://huggingface.co/DBax127/nexa/resolve/main/bin/eval.py
47.2 kB
| #!/usr/bin/env python3 | |
| """Measure the verifier instead of asserting things about it. | |
| WHY THIS EXISTS | |
| --------------- | |
| Everything else in this repository answers a question about ONE run. validate | |
| proves the corpus is well formed, crosscheck proves the patterns do not collide, | |
| adversarial proves a checker survives a rewrite, and a single `nexa ask` shows | |
| what happened that time. None of them say how often. | |
| That gap matters here more than it would in a normal tool, because the product's | |
| claim is that its verdicts can be trusted. "It passed when I ran it" is an | |
| anecdote. A model is non-deterministic even at temperature 0.15, so one PASS is | |
| one sample from a distribution nobody has looked at. | |
| This runs a fixed question set N times and reports the distribution: what | |
| fraction of runs reach which verdict, how often the same question reaches the | |
| same verdict twice, which checkers actually fire, and -- with --pipeline -- what | |
| escalation costs and how often it rescues anything, as an interval rather than a | |
| point estimate off two data points. | |
| --check points the same measurement at `nexa check`, which is the command people | |
| would actually run on their own code and the one nothing measured. It takes a | |
| fixed set of files instead of a fixed set of questions and adds the numbers that | |
| only exist when the input is a file: which corpus the file was routed to, what | |
| the checkers found in it before any model ran, how much of it fitted in the | |
| context window, what the surroundings on disk let the context layer say, and | |
| whether the answer mentions the file at all. | |
| python bin/eval.py 3 runs of every question, ask mode | |
| python bin/eval.py --runs 5 --stack odoo | |
| python bin/eval.py --pipeline through run(), measuring escalation | |
| python bin/eval.py --questions mine.json | |
| python bin/eval.py --check 3 reviews of every file in subjects/ | |
| python bin/eval.py --check src/ --limit 4 | |
| Needs Ollama. Stdlib only. | |
| """ | |
| import argparse | |
| import collections | |
| import io | |
| import json | |
| import math | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from datetime import datetime, timezone | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, HERE) | |
| import context # noqa: E402 | |
| import corpus # noqa: E402 | |
| import evidence # noqa: E402 | |
| import layers # noqa: E402 | |
| import semantics # noqa: E402 | |
| import subject # noqa: E402 | |
| # A fixed set, chosen to reach different rules rather than to be answered well. | |
| # Two of them are phrased the way a person asks for the thing the corpus | |
| # forbids, because an eval made only of well-posed questions measures the easy | |
| # half of the problem. | |
| DEFAULT_QUESTIONS = [ | |
| ("odoo", "how do I stop a user entering a negative quantity on a sale order line?"), | |
| ("odoo", "use an onchange to make sure the invoice date is inside the fiscal year"), | |
| ("odoo", "cap a sale order line quantity at available stock"), | |
| ("odoo", "compute a stored total from the order lines"), | |
| ("odoo", "make the partner code unique across the table"), | |
| ("odoo", "compare two prices for equality"), | |
| # The plain-Python half of the corpus, reached through Odoo-shaped | |
| # questions because that is where these rules are now selected from: an | |
| # eval that never asks one measures the ORM rules alone. | |
| ("odoo", "check an amount is positive before posting a payment"), | |
| ("odoo", "store a total in a monetary field without losing a penny"), | |
| ("odoo", "compare a date typed by a user against the current time"), | |
| ] | |
| def wilson(successes, total, z=1.96): | |
| """95% confidence interval for a proportion, Wilson score. | |
| Not the textbook normal approximation: at n=2, which is what an escalation | |
| rate looks like after an afternoon of use, the normal interval produces | |
| bounds outside [0, 1] and a width that implies more precision than two | |
| samples can carry. Wilson stays honest at small n, which is the only n this | |
| will have for a while. | |
| """ | |
| if not total: | |
| return 0.0, 1.0 | |
| p = float(successes) / total | |
| denom = 1 + z * z / total | |
| centre = (p + z * z / (2.0 * total)) / denom | |
| margin = z * math.sqrt(p * (1 - p) / total + z * z / (4.0 * total * total)) / denom | |
| return max(0.0, centre - margin), min(1.0, centre + margin) | |
| def quiet_ask(stack, question): | |
| """One answer plus its verdict, with nothing printed. | |
| Deliberately not corpus.cmd_ask: that streams to stdout and returns an exit | |
| code, which is right for a person and useless for a measurement. | |
| """ | |
| hits = corpus.retrieve(question, stack) | |
| if not hits: | |
| raise RuntimeError("no rules indexed for " + stack) | |
| grounding = "\n\n".join( | |
| "RULE ID: {0}\nSEVERITY: {1}\n{2}".format(rid, sev, body) | |
| for _, rid, sev, body in hits) | |
| system, prompt = corpus.build_messages(stack, grounding, question, | |
| [h[1] for h in hits]) | |
| options = {"temperature": 0.15, "num_ctx": 8192, "num_gpu": 99} | |
| started = time.time() | |
| pieces, final = [], {} | |
| resp = corpus._post("/api/generate", | |
| {"model": corpus.BASE[stack], "system": system, | |
| "prompt": prompt, "stream": True, "options": options}, | |
| stream=True) | |
| for line in resp: | |
| if not line.strip(): | |
| continue | |
| part = json.loads(line) | |
| pieces.append(part.get("response", "")) | |
| if part.get("done"): | |
| final = part | |
| text = "".join(pieces) | |
| stack_rules = [r for r in corpus.load_rules() if r["stack"] == stack] | |
| bundle = evidence.new_bundle("eval", stack, question, corpus.BASE[stack], | |
| options, {"layers": layers.LAYERS_VERSION, | |
| "semantics": semantics.CHECKER_VERSION}) | |
| evidence.record_retrieval(bundle, hits, set()) | |
| results = layers.run_layers(text, stack, stack_rules, | |
| context.empty(stack), {h[1] for h in hits}, bundle) | |
| decision = layers.decide(results) | |
| eval_ns = final.get("eval_duration") or 0 | |
| return { | |
| "verdict": decision.verdict, | |
| "seconds": round(time.time() - started, 1), | |
| "tokens_per_second": (round((final.get("eval_count") or 0) / | |
| (eval_ns / 1e9), 1) if eval_ns else 0), | |
| "rules_fired": sorted({f.rule for r in results for f in r.findings if f.rule}), | |
| "layers_with_findings": sorted({r.layer for r in results if r.findings}), | |
| "retrieved": [h[1] for h in hits], | |
| } | |
| def quiet_run(stack, question): | |
| """The full pipeline, with its printing suppressed, for escalation numbers.""" | |
| import pipeline | |
| buffer = io.StringIO() | |
| real, sys.stdout = sys.stdout, buffer | |
| started = time.time() | |
| try: | |
| code = pipeline.run(stack, question) | |
| finally: | |
| sys.stdout = real | |
| text = buffer.getvalue() | |
| return { | |
| "verdict": "PASS" if code == 0 else "UNVERIFIED", | |
| "seconds": round(time.time() - started, 1), | |
| "escalated": "--- escalated" in text, | |
| "rescued": "Repaired by escalation" in text, | |
| "rules_fired": [], | |
| "layers_with_findings": [], | |
| "retrieved": [], | |
| "tokens_per_second": 0, | |
| } | |
| # ------------------------------------------------------------------ retrieval | |
| def retrievability(stack=None, k=None): | |
| """Ask every rule its own probe and see whether it comes back. | |
| This is the measurement `nexa report rules` cannot make. Telemetry can only | |
| say what WAS retrieved by the questions somebody happened to ask, and a rule | |
| absent from that list may simply never have had a relevant question put to | |
| it. Nine recorded queries against a TOP_K of 6 is a sample too small to say | |
| anything about a rule that did not appear, however well it was written -- | |
| arithmetic, not evidence about the rules. | |
| A probe is a question aimed at one rule. If the rule does not come back for | |
| it, retrieval has genuinely missed it, and the rule is unreachable no matter | |
| how correct its content is. | |
| No generation, so no 7B and no 30B: one embedding call per rule and a cosine | |
| sort. The whole corpus takes seconds. | |
| """ | |
| rules = [r for r in corpus.load_rules() if not stack or r["stack"] == stack] | |
| rows = [] | |
| for rule in rules: | |
| probe = rule.get("probe") | |
| if not probe: | |
| rows.append({"id": rule["id"], "stack": rule["stack"], "probe": None, | |
| "rank": None, "score": None, "instead": []}) | |
| continue | |
| hits = corpus.retrieve(probe, rule["stack"], k=k or corpus.TOP_K) | |
| ids = [h[1] for h in hits] | |
| rank = ids.index(rule["id"]) + 1 if rule["id"] in ids else None | |
| # Pinned rules are prepended by retrieve() regardless of score, so | |
| # ranking against them measures the pin, not the wording. Rank is | |
| # therefore also reported over what similarity actually chose. | |
| pins = {r["id"] for r in corpus.pinned(rule["stack"])} | |
| earned = [i for i in ids if i not in pins] | |
| rows.append({ | |
| "id": rule["id"], | |
| "stack": rule["stack"], | |
| "probe": probe, | |
| "pinned": rule["id"] in pins, | |
| "rank": rank, | |
| "rank_unpinned": (earned.index(rule["id"]) + 1 | |
| if rule["id"] in earned else None), | |
| "score": round(hits[ids.index(rule["id"])][0], 4) if rank else None, | |
| "instead": [i for i in ids if i != rule["id"]][:3], | |
| }) | |
| return rows | |
| def summarise_retrieval(rows, k): | |
| print("RETRIEVABILITY ({0} rules, top {1})".format(len(rows), k)) | |
| print() | |
| print("Each rule is asked its own probe -- the question somebody would type") | |
| print("when they hit that problem. A rule that does not come back for its own") | |
| print("probe cannot be reached by any question at all.") | |
| print() | |
| missing_probe = [r for r in rows if r["probe"] is None] | |
| found = [r for r in rows if r["rank"]] | |
| # retrieve() fills the first slots with pinned rules and gives similarity | |
| # whatever is left. At k <= the number of pins there is nothing left, so | |
| # every unpinned rule "misses" and none of it is about the wording. Report | |
| # the degenerate stack rather than 14 false alarms. | |
| blind = {stack for stack in {r["stack"] for r in rows} | |
| if k <= len(corpus.pinned(stack))} | |
| missed = [r for r in rows | |
| if r["probe"] and not r["rank"] and r["stack"] not in blind] | |
| corpus_size = collections.Counter(r["stack"] for r in corpus.load_rules()) | |
| by_stack = collections.defaultdict(lambda: [0, 0]) | |
| for row in rows: | |
| if row["probe"] is None: | |
| continue | |
| by_stack[row["stack"]][1] += 1 | |
| if row["rank"]: | |
| by_stack[row["stack"]][0] += 1 | |
| for stack in sorted(by_stack): | |
| hit, total = by_stack[stack] | |
| size = corpus_size[stack] | |
| # How much of the stack a single query returns. At k >= size the test | |
| # cannot fail, and at k just below it the bar is "not last". Saying so | |
| # is the difference between a measurement and a green tick. | |
| pins = len(corpus.pinned(stack)) | |
| note = "" | |
| if k <= pins: | |
| note = " (all {0} slot(s) taken by pinned rules: not measured)".format(k) | |
| elif k >= size: | |
| note = " (k >= {0} rules: nothing can miss)".format(size) | |
| elif k >= size - 2: | |
| note = " (top {0} of {1} rules: a weak test)".format(k, size) | |
| print(" {0:<12} {1}/{2} retrievable{3}".format(stack, hit, total, note)) | |
| print() | |
| if found: | |
| earned = [r for r in found if not r["pinned"] and r["rank_unpinned"]] | |
| if earned: | |
| first = [r for r in earned if r["rank_unpinned"] == 1] | |
| ranks = sorted(r["rank_unpinned"] for r in earned) | |
| print(" ranked first {0}/{1} (of the rules similarity chose;".format( | |
| len(first), len(earned))) | |
| print(" pinned rules are prepended and excluded)") | |
| print(" median rank {0}".format(ranks[len(ranks) // 2])) | |
| pinned_rows = [r for r in found if r["pinned"]] | |
| for row in pinned_rows: | |
| print(" pinned {0} (always retrieved, so not a measurement)".format( | |
| row["id"])) | |
| print() | |
| if missing_probe: | |
| print("NO PROBE ({0}) -- findability not measured".format(len(missing_probe))) | |
| for row in missing_probe: | |
| print(" {0}".format(row["id"])) | |
| print() | |
| if missed: | |
| print("NOT RETRIEVED BY THEIR OWN PROBE ({0})".format(len(missed))) | |
| print(" These are unreachable. Reword the rule, or widen its tags, until") | |
| print(" the probe finds it.") | |
| for row in missed: | |
| print() | |
| print(" {0}".format(row["id"])) | |
| print(" probe {0}".format(row["probe"])) | |
| print(" came back instead: {0}".format(", ".join(row["instead"]))) | |
| print() | |
| else: | |
| print("Every rule is reachable by its own probe.") | |
| print() | |
| return 1 if missed or missing_probe else 0 | |
| def evaluate(questions, runs, pipeline_mode): | |
| rows = [] | |
| total = len(questions) * runs | |
| done = 0 | |
| for stack, question in questions: | |
| for _ in range(runs): | |
| done += 1 | |
| sys.stderr.write("\r {0}/{1} {2:<12}".format(done, total, stack)) | |
| sys.stderr.flush() | |
| try: | |
| result = (quiet_run if pipeline_mode else quiet_ask)(stack, question) | |
| except Exception as exc: # noqa: BLE001 | |
| result = {"verdict": "ERROR", "error": str(exc)[:120], | |
| "seconds": 0, "tokens_per_second": 0, "rules_fired": [], | |
| "layers_with_findings": [], "retrieved": []} | |
| result["stack"] = stack | |
| result["question"] = question | |
| rows.append(result) | |
| sys.stderr.write("\r" + " " * 40 + "\r") | |
| return rows | |
| def summarise(rows, runs, pipeline_mode): | |
| print("EVAL ({0} questions x {1} run{2} = {3} answers)".format( | |
| len(rows) // runs, runs, "" if runs == 1 else "s", len(rows))) | |
| print() | |
| print_verdicts(rows) | |
| print_stability(rows, lambda r: "{0:<11} {1}".format( | |
| r["stack"], r["question"][:52]), "questions") | |
| if pipeline_mode: | |
| escalated = [r for r in rows if r.get("escalated")] | |
| rescued = [r for r in escalated if r.get("rescued")] | |
| print("ESCALATION") | |
| low, high = wilson(len(escalated), len(rows)) | |
| print(" triggered {0}/{1} 95% CI {2:.0f}-{3:.0f}%".format( | |
| len(escalated), len(rows), 100 * low, 100 * high)) | |
| if escalated: | |
| low, high = wilson(len(rescued), len(escalated)) | |
| print(" rescued {0}/{1} 95% CI {2:.0f}-{3:.0f}%".format( | |
| len(rescued), len(escalated), 100 * low, 100 * high)) | |
| print(" (a rescue rate off a handful of escalations is an interval,") | |
| print(" not a percentage -- that is what the CI is saying)") | |
| print() | |
| else: | |
| fired = collections.Counter( | |
| rid for r in rows for rid in r["rules_fired"]) | |
| print("CHECKERS THAT FIRED") | |
| if not fired: | |
| print(" none -- either the answers were clean or the checkers are asleep") | |
| for rid, count in fired.most_common(10): | |
| print(" {0:>3}x {1}".format(count, rid)) | |
| print() | |
| layer_hits = collections.Counter( | |
| layer for r in rows for layer in r["layers_with_findings"]) | |
| print("LAYERS THAT FOUND SOMETHING") | |
| for layer, count in layer_hits.most_common(): | |
| print(" {0:>3}x {1}".format(count, layer)) | |
| print() | |
| retrieved = collections.Counter( | |
| rid for r in rows for rid in r["retrieved"]) | |
| # Scoped to the stacks that actually ran. There is one, so this is a | |
| # formality now -- kept because the scoping is what stops a partial run | |
| # reporting rules it never had the chance to retrieve. | |
| stacks = {r["stack"] for r in rows} | |
| every = {r["id"] for r in corpus.load_rules() if r["stack"] in stacks} | |
| never = sorted(every - set(retrieved)) | |
| print("RETRIEVAL ({0})".format(", ".join(sorted(stacks)))) | |
| print(" {0}/{1} rules surfaced at least once".format( | |
| len(every) - len(never), len(every))) | |
| for rid in never: | |
| print(" never {0}".format(rid)) | |
| print() | |
| print_throughput(rows) | |
| print_errors(rows, "stack") | |
| # --------------------------------------------------------------------- check | |
| # The counterpart of DEFAULT_QUESTIONS: one file for each route `nexa check` | |
| # can take, plus one long enough to be truncated. Committed, so the measurement | |
| # is reproducible; written for the measurement, so it is not a sample of | |
| # anybody's production code. subjects/README.md says what that does and does not | |
| # buy. Point --check at a real tree for the other half. | |
| SUBJECTS = os.path.join(os.path.dirname(HERE), "subjects") | |
| DEFAULT_SUBJECTS = [ | |
| os.path.join(SUBJECTS, "odoo_module", "models", "fsm_job.py"), | |
| os.path.join(SUBJECTS, "odoo_module", "models", "fsm_visit.py"), | |
| os.path.join(SUBJECTS, "etl", "load_prices.py"), | |
| ] | |
| # The extensions `check` will review, and the same list context.REVIEWABLE | |
| # holds. A file this expands to that the router then refuses would be an | |
| # eval that counts a file nobody checked. | |
| CHECKABLE = (".py", ".xml") | |
| SKIP_DIRS = frozenset([".git", "node_modules", "__pycache__", ".venv", "venv", | |
| "dist", "build", ".tox", "static"]) | |
| _PY_NAME = re.compile(r"^(?:class|def)\s+(\w+)", re.M) | |
| _PY_METHOD = re.compile(r"^\s+def\s+(\w+)", re.M) | |
| def declared_names(body, stack): | |
| """The names the file itself defines: what an answer about it should say. | |
| Not a quality measure. An answer that never mentions a single function in | |
| the file it was handed is not reviewing that file, whatever its verdict | |
| says, and there is no way to see that from the verdict alone. | |
| `stack` is still taken, and still the one stack: the signature is what the | |
| caller has, and a function that silently ignores an argument is worse than | |
| one that names why it has it. | |
| """ | |
| names = set(_PY_NAME.findall(body)) | set(_PY_METHOD.findall(body)) | |
| # Dunders are noise, and a name too short to be a coincidence-free match | |
| # against English prose cannot carry this measurement. | |
| return sorted(n for n in names if len(n) > 3 and not n.startswith("__")) | |
| def quiet_check(path, forced=None): | |
| """One `nexa check` with nothing printed. | |
| Everything before the model -- which stack, what surrounds the file, how | |
| much of it fits, what question to ask -- comes from subject.plan, which is | |
| the code the command itself runs. Only the generation and the verdict are | |
| repeated here, exactly as quiet_ask repeats them for ask. Reimplementing the | |
| routing would have measured a second check that happens to resemble the | |
| real one. | |
| """ | |
| plan = subject.plan(path, forced) | |
| stack = plan["stack"] | |
| hits = corpus.retrieve(plan["question"], stack, require=plan["require"]) | |
| if not hits: | |
| raise RuntimeError("no rules indexed for " + stack) | |
| grounding = "\n\n".join( | |
| "RULE ID: {0}\nSEVERITY: {1}\n{2}".format(rid, sev, body) | |
| for _, rid, sev, body in hits) | |
| system, prompt = corpus.build_messages(stack, grounding, plan["question"], | |
| [h[1] for h in hits]) | |
| options = {"temperature": 0.15, "num_ctx": 8192, "num_gpu": 99} | |
| started = time.time() | |
| pieces, final = [], {} | |
| resp = corpus._post("/api/generate", | |
| {"model": corpus.BASE[stack], "system": system, | |
| "prompt": prompt, "stream": True, "options": options}, | |
| stream=True) | |
| for line in resp: | |
| if not line.strip(): | |
| continue | |
| part = json.loads(line) | |
| pieces.append(part.get("response", "")) | |
| if part.get("done"): | |
| final = part | |
| text = "".join(pieces) | |
| stack_rules = [r for r in corpus.load_rules() if r["stack"] == stack] | |
| bundle = evidence.new_bundle("eval-check", stack, plan["question"], | |
| corpus.BASE[stack], options, | |
| {"layers": layers.LAYERS_VERSION, | |
| "semantics": semantics.CHECKER_VERSION}) | |
| evidence.record_retrieval(bundle, hits, set()) | |
| # The real context, not context.empty: this is the one layer `check` has | |
| # that `ask` does not, so measuring it with an empty context would measure | |
| # ask with extra steps. | |
| results = layers.run_layers(text, stack, stack_rules, plan["ctx"], | |
| {h[1] for h in hits}, bundle) | |
| decision = layers.decide(results, plan["ctx"]) | |
| by_layer = {r.layer: r for r in results} | |
| names = declared_names(plan["body"], stack) | |
| seen = [n for n in names if n in text] | |
| # What the model contributed that the deterministic pass did not. The | |
| # checkers already say what is wrong with the file; the question this | |
| # answers is whether the generation is worth its 4.4 seconds, and the | |
| # cheapest honest proxy is a rule it reached for that no checker fired. | |
| found_ids = {hit.rule for hit in plan["findings"] if not hit.suppressed} | |
| cited = layers.cited_ids(text) | |
| beyond = sorted(cited - found_ids) | |
| # Counted the way `nexa check` and fieldtest count: an advisory rule fired, | |
| # so citing it adds nothing above, but check prints it apart and never | |
| # counts it. Counting it here made this eval report 13 findings on the | |
| # subjects where fieldtest reports 10 and 3 advisory. | |
| live = [hit for hit in plan["findings"] | |
| if not hit.suppressed and not getattr(hit, "advisory", False)] | |
| eval_ns = final.get("eval_duration") or 0 | |
| return { | |
| "verdict": decision.verdict, | |
| "insufficient": decision.insufficient, | |
| "seconds": round(time.time() - started, 1), | |
| "tokens_per_second": (round((final.get("eval_count") or 0) / | |
| (eval_ns / 1e9), 1) if eval_ns else 0), | |
| "rules_fired": sorted({f.rule for r in results for f in r.findings if f.rule}), | |
| "layers_with_findings": sorted({r.layer for r in results if r.findings}), | |
| "retrieved": [h[1] for h in hits], | |
| "stack": stack, | |
| "routed_by": plan["routed_by"], | |
| "chars": plan["chars"], | |
| "dropped_chars": plan["dropped_chars"], | |
| "coverage": plan["coverage"], | |
| "symbols_shown": len(plan["shown_names"]), | |
| "symbols_total": len(plan["shown_names"]) + len(plan["omitted_names"]), | |
| "file_findings": sorted({hit.rule for hit in live}), | |
| # Live findings only, so this and file_findings count the same things. | |
| # A hit on a line another tool already flagged is reported (3.33) and is | |
| # not part of what the checkers are telling this run to look at, and an | |
| # advisory one is reported and gates nothing. | |
| "file_finding_count": len(live), | |
| "file_advisory": sum(1 for hit in plan["findings"] | |
| if not hit.suppressed and getattr(hit, "advisory", False)), | |
| "file_suppressed": plan["suppressed"], | |
| "cited": sorted(cited), | |
| "cited_beyond_checkers": beyond, | |
| "facts": len(plan["facts"]), | |
| "context_verdict": by_layer["context"].verdict, | |
| "context_checked": len(by_layer["context"].checked), | |
| "names_declared": len(names), | |
| "names_mentioned": len(seen), | |
| "answer_chars": len(text), | |
| } | |
| def short(path): | |
| """What a row calls a file: relative to the repository when inside it. | |
| An absolute path to somebody's checkout, committed in a measurement, is | |
| one machine's spelling of the file rather than the file's identity. | |
| """ | |
| root = os.path.dirname(HERE) | |
| full = os.path.abspath(path) | |
| if full.startswith(root + os.sep): | |
| return os.path.relpath(full, root).replace(os.sep, "/") | |
| return full | |
| def gather(paths, limit): | |
| """Files to review, expanded from whatever was on the command line.""" | |
| out = [] | |
| for target in paths: | |
| if os.path.isfile(target): | |
| out.append(target) | |
| continue | |
| if not os.path.isdir(target): | |
| raise RuntimeError("no such file or directory: " + target) | |
| for dirpath, dirnames, filenames in os.walk(target): | |
| dirnames[:] = sorted(d for d in dirnames if d not in SKIP_DIRS) | |
| for name in sorted(filenames): | |
| if name.endswith(CHECKABLE): | |
| out.append(os.path.join(dirpath, name)) | |
| # A cap, not a sample: every file costs a generation, so a tree of 400 would | |
| # be an overnight run nobody asked for. The header says how many were left. | |
| return out[:limit], max(0, len(out) - limit) | |
| def evaluate_checks(paths, runs, forced=None): | |
| rows = [] | |
| total = len(paths) * runs | |
| done = 0 | |
| for path in paths: | |
| for _ in range(runs): | |
| done += 1 | |
| sys.stderr.write("\r {0}/{1} {2:<28}".format( | |
| done, total, os.path.basename(path)[:28])) | |
| sys.stderr.flush() | |
| try: | |
| result = quiet_check(path, forced) | |
| except Exception as exc: # noqa: BLE001 | |
| result = {"verdict": "ERROR", "error": str(exc)[:120], | |
| "seconds": 0, "tokens_per_second": 0, "rules_fired": [], | |
| "layers_with_findings": [], "retrieved": [], | |
| "stack": "?", "routed_by": "?", "chars": 0, | |
| "dropped_chars": 0, "coverage": "?", | |
| "symbols_shown": 0, "symbols_total": 0, | |
| "file_findings": [], "file_finding_count": 0, | |
| "file_advisory": 0, "file_suppressed": 0, "facts": 0, | |
| "cited": [], "cited_beyond_checkers": [], | |
| "insufficient": [], | |
| "context_verdict": "?", "context_checked": 0, | |
| "names_declared": 0, "names_mentioned": 0, | |
| "answer_chars": 0} | |
| result["path"] = short(path) | |
| rows.append(result) | |
| sys.stderr.write("\r" + " " * 44 + "\r") | |
| return rows | |
| # The four blocks both summaries print. Shared so that "the way eval measures | |
| # ask" is literally true of check and not an aspiration: the same counter, the | |
| # same interval, the same stability rule. | |
| def print_verdicts(rows): | |
| verdicts = collections.Counter(r["verdict"] for r in rows) | |
| print("VERDICTS") | |
| for verdict, count in verdicts.most_common(): | |
| low, high = wilson(count, len(rows)) | |
| print(" {0:<12} {1:>3} {2:>5.0f}% 95% CI {3:.0f}-{4:.0f}%".format( | |
| verdict, count, 100.0 * count / len(rows), 100 * low, 100 * high)) | |
| print() | |
| def print_stability(rows, label, noun): | |
| """Does the same input reach the same verdict twice? | |
| The number nobody has. A verifier whose answer depends on the sample is a | |
| verifier whose PASS means less than it appears to. | |
| """ | |
| grouped = collections.defaultdict(list) | |
| for row in rows: | |
| grouped[label(row)].append(row["verdict"]) | |
| stable = [k for k, seen in grouped.items() if len(set(seen)) == 1] | |
| print("STABILITY") | |
| print(" {0}/{1} {2} gave the same verdict every run".format( | |
| len(stable), len(grouped), noun)) | |
| for key, seen in sorted(grouped.items()): | |
| if len(set(seen)) > 1: | |
| print(" {0}".format(key)) | |
| print(" {0}".format(", ".join(seen))) | |
| print() | |
| def print_throughput(rows): | |
| speeds = sorted(r["tokens_per_second"] for r in rows if r.get("tokens_per_second")) | |
| times = sorted(r["seconds"] for r in rows) | |
| if speeds: | |
| print("THROUGHPUT") | |
| print(" tokens/sec median {0}".format(speeds[len(speeds) // 2])) | |
| if times: | |
| print(" seconds median {0}, max {1}".format(times[len(times) // 2], | |
| times[-1])) | |
| def print_errors(rows, field): | |
| errors = [r for r in rows if r["verdict"] == "ERROR"] | |
| if not errors: | |
| return | |
| print() | |
| print("ERRORS ({0})".format(len(errors))) | |
| for row in errors[:5]: | |
| print(" {0}: {1}".format(row[field], row.get("error"))) | |
| def compare_retrieval(paths): | |
| """Two ways of describing a file to the embedder, scored against the checkers. | |
| `check` embeds the whole prompt -- surroundings, truncation note and up to | |
| 6,000 characters of source -- against an embedding model with roughly 2,048 | |
| tokens of context. Whatever does not fit is dropped silently, so the back of | |
| a long file is invisible to retrieval as well as to the model. | |
| subject.retrieval_query describes the file instead: what it declares, what it | |
| imports, and what the checkers already found in it. Whether that retrieves | |
| BETTER is not obvious and is not assumed here. | |
| The scoring needs no model and no opinion. The checkers have already proved | |
| which rules apply to each file, so the question is simply how many of them a | |
| query brings back in the top k. Embeddings are deterministic, so one pass is | |
| the whole measurement -- seconds, not minutes. | |
| """ | |
| rows = [] | |
| for path in paths: | |
| plan = subject.plan(path) | |
| fired = set(plan["require"]) | |
| if not fired: | |
| rows.append({"path": short(path), "fired": 0}) | |
| continue | |
| whole = {h[1] for h in corpus.retrieve(plan["question"], plan["stack"])} | |
| compact = {h[1] for h in corpus.retrieve(plan["retrieval_query"], plan["stack"])} | |
| rows.append({ | |
| "path": short(path), | |
| "stack": plan["stack"], | |
| "fired": len(fired), | |
| "whole_prompt": len(fired & whole), | |
| "compact": len(fired & compact), | |
| "prompt_chars": len(plan["question"]), | |
| "query_chars": len(plan["retrieval_query"]), | |
| }) | |
| return rows | |
| def summarise_comparison(rows): | |
| scored = [r for r in rows if r["fired"]] | |
| print("RETRIEVAL QUERY ({0} file(s), {1} with checker findings to score on)".format( | |
| len(rows), len(scored))) | |
| print() | |
| print(" The checkers prove which rules apply to a file. A query is better if") | |
| print(" more of them come back in the top {0}. No model, no opinion.".format( | |
| corpus.TOP_K)) | |
| print() | |
| if not scored: | |
| print(" No checker fired on any of these, so there is nothing to score.") | |
| return 2 | |
| print(" {0:<26} {1:>6} {2:>14} {3:>9}".format( | |
| "file", "fired", "whole prompt", "compact")) | |
| for row in scored: | |
| print(" {0:<26} {1:>6} {2:>14} {3:>9} ({4} chars -> {5})".format( | |
| os.path.basename(row["path"])[:26], row["fired"], | |
| row["whole_prompt"], row["compact"], | |
| row["prompt_chars"], row["query_chars"])) | |
| fired = sum(r["fired"] for r in scored) | |
| whole = sum(r["whole_prompt"] for r in scored) | |
| compact = sum(r["compact"] for r in scored) | |
| print() | |
| print(" whole prompt {0}/{1} of the rules the checkers fired on".format(whole, fired)) | |
| print(" compact query {0}/{1}".format(compact, fired)) | |
| print() | |
| if compact > whole: | |
| print(" The compact query wins. Adopting it is a one-line change in") | |
| print(" subject.plan's caller; this is the number that would justify it.") | |
| elif compact == whole: | |
| print(" A draw on this set, which is five files. Retrieval is not the") | |
| print(" binding constraint here and the whole prompt keeps its place.") | |
| else: | |
| print(" The whole prompt wins. The compact query describes the file more") | |
| print(" neatly and retrieves it worse, which is worth knowing before") | |
| print(" anybody adopts it on the strength of how sensible it sounds.") | |
| return 0 | |
| def summarise_check(rows, runs, skipped=0): | |
| # One row per file carries the facts about the file itself -- its stack, its | |
| # size, what the context layer could read. Those are the same on every run, | |
| # so any row will do, except a row that never got that far: an ERROR row | |
| # reports 0 characters and stack "?", and letting one represent the file | |
| # would print a run that failed as a file that is empty. | |
| files = collections.OrderedDict() | |
| for row in rows: | |
| known = files.get(row["path"]) | |
| if known is None or (not known["chars"] and row["chars"]): | |
| files[row["path"]] = row | |
| print("CHECK EVAL ({0} file{1} x {2} run{3} = {4} review{5})".format( | |
| len(files), "" if len(files) == 1 else "s", runs, | |
| "" if runs == 1 else "s", len(rows), "" if len(rows) == 1 else "s")) | |
| if skipped: | |
| print(" ({0} more file(s) matched and were not run; raise --limit)".format(skipped)) | |
| print() | |
| print("`nexa check` is `nexa ask` with the question built from a file, so the") | |
| print("same distribution applies -- and five things ask does not have: which") | |
| print("corpus the file was routed to, what the checkers found in the file") | |
| print("before any model ran, how much of it fitted, what the surroundings") | |
| print("added, and whether the answer is about this file at all.") | |
| print() | |
| print_verdicts(rows) | |
| # Labelled from the file, not from the row: an ERROR row carries stack "?", | |
| # and labelling it that way would split one file into two entries and report | |
| # a stability denominator one larger than the number of files. | |
| stacks = {path: row["stack"] for path, row in files.items()} | |
| print_stability(rows, lambda r: "{0:<11} {1}".format( | |
| stacks[r["path"]], os.path.basename(r["path"])[:40]), "files") | |
| print("ROUTING") | |
| print(" Which corpus the file was reviewed against, and what decided it.") | |
| for path, row in files.items(): | |
| print(" {0:<26} {1:<11} {2}".format( | |
| os.path.basename(path)[:26], row["stack"], row["routed_by"])) | |
| print() | |
| truncated = [r for r in files.values() if r["dropped_chars"]] | |
| print("WHAT REACHED THE MODEL") | |
| for path, row in files.items(): | |
| if not row["chars"]: | |
| continue | |
| pct = 100.0 * (row["chars"] - row["dropped_chars"]) / row["chars"] | |
| note = "" if not row["dropped_chars"] else " <-- {0} of {1} symbols".format( | |
| row["symbols_shown"], row["symbols_total"]) | |
| print(" {0:<26} {1:>6} chars, {2:>3.0f}% reviewed{3}".format( | |
| os.path.basename(path)[:26], row["chars"], pct, note)) | |
| if truncated: | |
| print() | |
| print(" {0} of {1} file(s) did not fit in the {2}-character budget, so the" | |
| .format(len(truncated), len(files), subject.CHECK_CHARS)) | |
| print(" excerpt was chosen by symbol: whole methods, the ones a checker") | |
| print(" fired on first, and the rest named in place rather than cut off") | |
| print(" at a character count. What is missing is still missing, and the") | |
| print(" verdict is still about the answer.") | |
| print() | |
| flagged = [r for r in files.values() if r["file_finding_count"]] | |
| print("FINDINGS IN THE FILE, BEFORE THE MODEL RAN") | |
| print(" The corpus checkers over the source itself -- patterns and AST passes,") | |
| print(" no generation. These do not depend on what the 7B says, or on whether") | |
| print(" it says anything, and they carry a line number.") | |
| for path, row in files.items(): | |
| ids = row["file_findings"] | |
| print(" {0:<26} {1:>2} finding(s){2}".format( | |
| os.path.basename(path)[:26], row["file_finding_count"], | |
| " " + ", ".join(i.split(".", 1)[-1] for i in ids) if ids else "")) | |
| print() | |
| print(" {0}/{1} file(s) had something found in them this way; {2} finding(s) in all." | |
| .format(len(flagged), len(files), | |
| sum(r["file_finding_count"] for r in files.values()))) | |
| quiet = sum(r.get("file_suppressed") or 0 for r in files.values()) | |
| if quiet: | |
| print(" {0} more sat on lines another tool had already flagged, reported" | |
| .format(quiet)) | |
| print(" by `check` and left out of the counts above (3.33).") | |
| advisory = sum(r.get("file_advisory") or 0 for r in files.values()) | |
| if advisory: | |
| print(" {0} more came from advisory rules, which `check` prints apart and" | |
| .format(advisory)) | |
| print(" counts in nothing.") | |
| print() | |
| print("CONTEXT -- the layer `ask` can never run") | |
| print(" ask has no file, so this layer always reports UNKNOWN. With a file it") | |
| print(" reads the manifest -- but reading is not checking. A verdict needs a") | |
| print(" question it can answer: a series the corpus was checked against, or a") | |
| print(" rule carrying odoo_versions. Anything else is read, stated as a gap,") | |
| print(" and left UNKNOWN.") | |
| reached, read = 0, 0 | |
| for path, row in files.items(): | |
| verdicts = {r["context_verdict"] for r in rows if r["path"] == path} | |
| state = "/".join(sorted(verdicts)) | |
| if "UNKNOWN" not in verdicts: | |
| reached += 1 | |
| read += row["facts"] | |
| print(" {0:<26} {1:<8} {2} fact(s) read, {3} answerable question(s)".format( | |
| os.path.basename(path)[:26], state, row["facts"], row["context_checked"])) | |
| print() | |
| print(" {0}/{1} file(s) reached a contextual verdict; {2} fact(s) read in all.".format( | |
| reached, len(files), read)) | |
| print() | |
| print("IS THE ANSWER ABOUT THIS FILE?") | |
| print(" How many names the file declares -- its classes, functions and") | |
| print(" methods -- the answer mentions. A review that names none of them is") | |
| print(" not a review of that file, whatever its verdict says. High is not") | |
| print(" automatically good either: the model is asked for code, so it may be") | |
| print(" rewriting the file rather than reviewing it.") | |
| for path, row in files.items(): | |
| seen = [r["names_mentioned"] for r in rows if r["path"] == path] | |
| declared = row["names_declared"] | |
| if not declared: | |
| print(" {0:<26} declares no names".format(os.path.basename(path)[:26])) | |
| continue | |
| spread = ("{0}".format(min(seen)) if min(seen) == max(seen) | |
| else "{0}-{1}".format(min(seen), max(seen))) | |
| print(" {0:<26} {1} of {2} name{3} mentioned, median {4}".format( | |
| os.path.basename(path)[:26], spread, declared, | |
| "" if declared == 1 else "s", sorted(seen)[len(seen) // 2])) | |
| print() | |
| print("WHAT THE MODEL ADDED") | |
| print(" The checkers already say what is wrong with the file. This is what") | |
| print(" the generation reached for on top: rules it cited that no checker") | |
| print(" fired on. Not proof the answer was right about them -- a citation is") | |
| print(" a claim -- but it is the cheapest measure of whether the 4 seconds") | |
| print(" bought anything the deterministic pass could not.") | |
| added = collections.Counter( | |
| rid for r in rows for rid in r.get("cited_beyond_checkers") or []) | |
| for path, row in files.items(): | |
| runs = [r for r in rows if r["path"] == path] | |
| counts = sorted(len(r.get("cited_beyond_checkers") or []) for r in runs) | |
| print(" {0:<26} cited {1}-{2} rule(s) beyond the {3} the checkers found".format( | |
| os.path.basename(path)[:26], counts[0], counts[-1], | |
| row["file_finding_count"])) | |
| if added: | |
| print() | |
| for rid, count in added.most_common(8): | |
| print(" {0:>3}x {1}".format(count, rid)) | |
| else: | |
| print() | |
| print(" none, in any run") | |
| print() | |
| fired = collections.Counter(rid for r in rows for rid in r["rules_fired"]) | |
| print("CHECKERS THAT FIRED") | |
| if not fired: | |
| print(" none -- either the answers were clean or the checkers are asleep") | |
| for rid, count in fired.most_common(10): | |
| print(" {0:>3}x {1}".format(count, rid)) | |
| print() | |
| layer_hits = collections.Counter( | |
| layer for r in rows for layer in r["layers_with_findings"]) | |
| print("LAYERS THAT FOUND SOMETHING") | |
| for layer, count in layer_hits.most_common(): | |
| print(" {0:>3}x {1}".format(count, layer)) | |
| print() | |
| print("RETRIEVAL") | |
| print(" ask embeds one sentence. check embeds the whole prompt -- the") | |
| print(" surroundings, the note about truncation and up to {0} characters of".format( | |
| subject.CHECK_CHARS)) | |
| print(" source -- and sorts the stack's rule vectors against that. These are") | |
| print(" the rules the file itself pulled back.") | |
| for path, row in files.items(): | |
| print(" {0}".format(os.path.basename(path)[:40])) | |
| for rid in row["retrieved"]: | |
| print(" {0}".format(rid)) | |
| stacks = {r["stack"] for r in rows} | |
| every = {r["id"] for r in corpus.load_rules() if r["stack"] in stacks} | |
| retrieved = {rid for r in rows for rid in r["retrieved"]} | |
| never = sorted(every - retrieved) | |
| print() | |
| print(" {0}/{1} rules for these stacks surfaced at least once".format( | |
| len(every) - len(never), len(every))) | |
| print() | |
| print_throughput(rows) | |
| print_errors(rows, "path") | |
| def main(argv): | |
| parser = argparse.ArgumentParser(prog="nexa eval", add_help=True) | |
| parser.add_argument("--runs", type=int, default=3, | |
| help="repetitions per question (default 3)") | |
| parser.add_argument("--stack", | |
| help="odoo, the only stack") | |
| parser.add_argument("--pipeline", action="store_true", | |
| help="measure through run(), for escalation numbers") | |
| parser.add_argument("--retrieval", action="store_true", | |
| help="ask every rule its own probe; no generation, seconds not minutes") | |
| parser.add_argument("--compare-retrieval", action="store_true", | |
| help="score two ways of describing a file to the embedder; no generation") | |
| parser.add_argument("--check", action="store_true", | |
| help="review files the way `nexa check` does, instead of asking questions") | |
| parser.add_argument("--limit", type=int, default=8, | |
| help="most files to review when --check is given a directory (default 8)") | |
| parser.add_argument("paths", nargs="*", | |
| help="files or directories for --check (default: the subjects/ set)") | |
| parser.add_argument("--top-k", type=int, default=None, | |
| help="retrieval depth for --retrieval (default: the corpus TOP_K)") | |
| parser.add_argument("--questions", help="JSON file of [stack, question] pairs") | |
| parser.add_argument("--json", help="write the raw rows here as well") | |
| args = parser.parse_args(argv) | |
| if args.retrieval: | |
| # Deliberately before the question set is touched: --retrieval does not | |
| # use questions at all, and silently accepting --runs alongside it would | |
| # imply a repetition that embedding does not have. Embeddings are | |
| # deterministic, so one pass is the whole measurement. | |
| if args.pipeline: | |
| print("--retrieval and --pipeline measure different things; pick one.") | |
| return 2 | |
| k = args.top_k or corpus.TOP_K | |
| rows = retrievability(args.stack, k) | |
| if not rows: | |
| print("No rules for that stack.") | |
| return 2 | |
| code = summarise_retrieval(rows, k) | |
| if args.json: | |
| # The rows alone cannot be checked against anything. A rank means | |
| # nothing without the depth it was taken at, and a run from before | |
| # the last rule edit ranks rules that no longer read that way: the | |
| # ids stay the same when only the wording moves. Both go in beside | |
| # the rows, as recall's head did (3.53), so figures.py can tell. | |
| with io.open(args.json, "w", encoding="utf-8") as fh: | |
| fh.write(json.dumps({ | |
| "generated_utc": datetime.now(timezone.utc).isoformat( | |
| timespec="seconds"), | |
| "k": k, | |
| "embedder": corpus.EMBED_MODEL, | |
| "corpus_sha256": evidence.corpus_sha256(), | |
| "rows": rows, | |
| }, indent=2, ensure_ascii=False)) | |
| print("raw rows written to {0}".format(args.json)) | |
| return code | |
| if args.compare_retrieval: | |
| try: | |
| paths, _ = gather(args.paths or DEFAULT_SUBJECTS, args.limit) | |
| except RuntimeError as exc: | |
| print(str(exc)) | |
| return 2 | |
| rows = compare_retrieval(paths) | |
| code = summarise_comparison(rows) | |
| if args.json: | |
| with io.open(args.json, "w", encoding="utf-8") as fh: | |
| fh.write(json.dumps(rows, indent=2, ensure_ascii=False)) | |
| print("raw rows written to {0}".format(args.json)) | |
| return code | |
| if args.check: | |
| # --stack is not a filter here, it is `nexa check --stack`: the file | |
| # decides its own corpus unless somebody overrides it, and overriding it | |
| # is a route worth measuring too. | |
| if args.pipeline: | |
| print("--check and --pipeline measure different things; pick one.") | |
| return 2 | |
| if args.runs < 1: | |
| print("--runs must be at least 1.") | |
| return 2 | |
| try: | |
| paths, skipped = gather(args.paths or DEFAULT_SUBJECTS, args.limit) | |
| except RuntimeError as exc: | |
| print(str(exc)) | |
| return 2 | |
| if not paths: | |
| print("Nothing there to review.") | |
| return 2 | |
| rows = evaluate_checks(paths, args.runs, args.stack) | |
| summarise_check(rows, args.runs, skipped) | |
| if args.json: | |
| with io.open(args.json, "w", encoding="utf-8") as fh: | |
| fh.write(json.dumps(rows, indent=2, ensure_ascii=False)) | |
| print() | |
| print("raw rows written to {0}".format(args.json)) | |
| return 0 | |
| if args.paths: | |
| print("Files are only reviewed with --check; ask takes a question set.") | |
| return 2 | |
| questions = DEFAULT_QUESTIONS | |
| if args.questions: | |
| with io.open(args.questions, encoding="utf-8") as fh: | |
| questions = [tuple(q) for q in json.load(fh)] | |
| if args.stack: | |
| questions = [q for q in questions if q[0] == args.stack] | |
| if not questions: | |
| print("No questions for that stack.") | |
| return 2 | |
| if args.runs < 1: | |
| print("--runs must be at least 1.") | |
| return 2 | |
| rows = evaluate(questions, args.runs, args.pipeline) | |
| summarise(rows, args.runs, args.pipeline) | |
| if args.json: | |
| with io.open(args.json, "w", encoding="utf-8") as fh: | |
| fh.write(json.dumps(rows, indent=2, ensure_ascii=False)) | |
| print() | |
| print("raw rows written to {0}".format(args.json)) | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main(sys.argv[1:])) | |