File size: 16,176 Bytes
3b09ba9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
#!/usr/bin/env python3
"""
Deterministic verifier for narrative.json files β€” checks code citations,
the Overview, the Model diagram's block/edge structure, and the results
tables. No LLM involved on purpose: this is the local, cheap, non-negotiable
check that catches fabricated citations, or a diagram that references nodes
that don't exist, before a case is called done.

Pure stdlib β€” no dependencies, no venv needed.

Usage:
    python3 scripts/verify_case.py cases/<slug>/narrative.json
    python3 scripts/verify_case.py cases/<slug>/narrative.json --repo cases/<slug>/repo

Exit code 0  -> no hard failures (warnings may still be printed)
Exit code 1  -> at least one hard failure
"""
import argparse
import ast
import base64
import json
import re
import sys
from pathlib import Path

STOPWORDS = {
    "the", "a", "an", "of", "for", "and", "or", "to", "in", "on", "is",
    "are", "with", "this", "that", "as", "by", "at", "we", "our", "it",
    "be", "into", "from", "which", "layer", "module", "function", "class",
}


def split_identifier(token: str):
    """camelCase / snake_case / PascalCase -> lowercase word parts."""
    token = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", token)
    parts = re.split(r"[_\-\s]+", token)
    return [p.lower() for p in parts if p]


def keywords_from(*texts):
    words = set()
    for text in texts:
        if not text:
            continue
        for raw in re.findall(r"[A-Za-z0-9]+", text):
            for w in split_identifier(raw):
                if len(w) > 2 and w not in STOPWORDS:
                    words.add(w)
    return words


class Result:
    def __init__(self):
        self.failures = []
        self.warnings = []

    def fail(self, node_id, msg):
        self.failures.append(f"[FAIL] {node_id}: {msg}")

    def warn(self, node_id, msg):
        self.warnings.append(f"[warn] {node_id}: {msg}")


def check_code_ref(result: Result, node_id: str, node: dict, ref: dict, repo_root: Path):
    file_rel = ref.get("file", "")
    start = ref.get("start_line")
    end = ref.get("end_line")

    if not file_rel:
        result.fail(node_id, "code_ref missing 'file'")
        return
    if not isinstance(start, int) or not isinstance(end, int) or start < 1 or end < start:
        result.fail(node_id, f"invalid line range {start}-{end} for {file_rel}")
        return

    target = repo_root / file_rel
    if not target.is_file():
        # Tolerate the common mistake of including the repo dir's own name
        # in `file` (e.g. "repo/foo.py" when repo_path is already "repo") --
        # `file` is meant to be repo_path-relative, but every other tool a
        # grounding session uses (read_file, bash) is case-dir-relative, so
        # this ambiguity is an easy slip, not necessarily a bad citation.
        fallback = repo_root.parent / file_rel
        if fallback.is_file():
            target = fallback
        else:
            result.fail(node_id, f"file not found on disk: {file_rel} (looked under {repo_root})")
            return

    lines = target.read_text(errors="replace").splitlines()
    if end > len(lines):
        result.fail(node_id, f"end_line {end} exceeds file length {len(lines)} in {file_rel}")
        return

    actual = "\n".join(lines[start - 1:end])
    claimed = (ref.get("snippet") or "").rstrip("\n")

    def normalize(s):
        return "\n".join(line.rstrip() for line in s.splitlines())

    if normalize(actual) != normalize(claimed):
        result.fail(
            node_id,
            f"embedded snippet does not match {file_rel}:{start}-{end} on disk "
            f"(citation has drifted or 'snippet' was not copied verbatim)",
        )
        return

    if target.suffix == ".py":
        try:
            tree = ast.parse(target.read_text(errors="replace"))
        except SyntaxError:
            tree = None
        if tree is not None:
            covers = False
            for n in ast.walk(tree):
                if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                    n_end = getattr(n, "end_lineno", n.lineno)
                    if n.lineno <= end and n_end >= start:
                        covers = True
                        break
            if not covers:
                result.warn(
                    node_id,
                    f"{file_rel}:{start}-{end} doesn't overlap any def/class β€” double-check the span",
                )

    node_words = keywords_from(node.get("title"), ref.get("symbol"),
                                node.get("paper_ref", {}).get("quote"))
    code_words = keywords_from(actual, ref.get("symbol"))
    if node_words and not (node_words & code_words):
        result.warn(
            node_id,
            f"no shared terminology between node title/quote and {file_rel}:{start}-{end} "
            f"β€” confidence {ref.get('confidence')} may be optimistic",
        )


IMAGE_URI_RE = re.compile(r"^data:image/(png|jpe?g|webp);base64,([A-Za-z0-9+/=]+)$")


def check_figure_ref(result: Result, where: str, fig: dict):
    if not (fig.get("label") or "").strip():
        result.fail(where, "a figure entry is missing 'label'")
    if not (fig.get("caption") or "").strip():
        result.fail(where, f"figure '{fig.get('label', '?')}' is missing 'caption'")
    image = fig.get("image")
    if image is not None:
        m = IMAGE_URI_RE.match(image)
        if not m:
            result.fail(where, f"figure '{fig.get('label', '?')}' has an 'image' that isn't a well-formed data:image/...;base64,... URI")
        else:
            try:
                decoded = base64.b64decode(m.group(2), validate=True)
            except Exception:
                result.fail(where, f"figure '{fig.get('label', '?')}' has an 'image' whose base64 payload doesn't decode")
            else:
                if len(decoded) < 512:
                    result.warn(where, f"figure '{fig.get('label', '?')}' has a suspiciously tiny embedded image ({len(decoded)} bytes)")


def check_bullets(result: Result, where: str, field: str, value):
    if not isinstance(value, list) or not value:
        result.fail(where, f"{field} must be a non-empty list of bullet points")
        return
    for i, item in enumerate(value):
        if not isinstance(item, str) or not item.strip():
            result.fail(where, f"{field}[{i}] is empty")


def verify(data: dict, repo_root: Path) -> Result:
    result = Result()
    nodes = data.get("nodes", [])
    node_ids = [n.get("id") for n in nodes]

    dupes = {i for i in node_ids if node_ids.count(i) > 1}
    if dupes:
        result.fail("(file)", f"duplicate node ids: {sorted(dupes)}")
    node_id_set = set(node_ids)

    # --- case.venue ---
    if not (data.get("case", {}).get("venue") or "").strip():
        result.fail("(file)", "case.venue is missing β€” every case needs a conference/journal + year")

    # --- case.overview ---
    case_overview = data.get("case", {}).get("overview")
    if not case_overview:
        result.fail("(file)", "case.overview is missing β€” every case needs a task diagram + motivation + framework")
    else:
        task = case_overview.get("task")
        if not task:
            result.fail("(case.overview)", "missing 'task' β€” every case needs the Input -> Task -> Output schematic")
        else:
            for field in ("input", "task", "output"):
                if not (task.get(field) or "").strip():
                    result.fail("(case.overview.task)", f"'{field}' is empty")
        check_bullets(result, "(case.overview)", "motivation", case_overview.get("motivation"))
        check_bullets(result, "(case.overview)", "framework", case_overview.get("framework"))

        overview_figure = case_overview.get("figure")
        if overview_figure:
            check_figure_ref(result, "(case.overview.figure)", overview_figure)

        framework_text = " ".join(case_overview.get("framework") or [])
        for node in nodes:
            title = node.get("title") or ""
            if title and f"**{title}**" not in framework_text:
                result.warn(
                    "(case.overview)",
                    f"no framework bullet names block '{title}' in **bold** β€” a reader should see where "
                    f"each contribution sits before reaching the Model section",
                )

    # --- case.model_diagram ---
    diagram = data.get("case", {}).get("model_diagram")
    if not diagram:
        result.fail("(file)", "case.model_diagram is missing")
    else:
        blocks = diagram.get("blocks", [])
        block_ids = [b.get("id") for b in blocks]
        dupe_blocks = {i for i in block_ids if block_ids.count(i) > 1}
        if dupe_blocks:
            result.fail("(model_diagram)", f"duplicate block ids: {sorted(dupe_blocks)}")
        block_id_set = set(block_ids)

        referenced_node_ids = []
        for b in blocks:
            bid = b.get("id", "(missing id)")
            ref = b.get("node_ref")
            if not ref:
                result.fail(f"block:{bid}", "block missing 'node_ref'")
            elif ref not in node_id_set:
                result.fail(f"block:{bid}", f"node_ref '{ref}' does not match any id in the top-level 'nodes' array")
            else:
                referenced_node_ids.append(ref)

        unreferenced = node_id_set - set(referenced_node_ids)
        if unreferenced:
            result.fail("(model_diagram)", f"nodes not referenced by any block: {sorted(unreferenced)}")
        dupe_refs = {i for i in referenced_node_ids if referenced_node_ids.count(i) > 1}
        if dupe_refs:
            result.fail("(model_diagram)", f"nodes referenced by more than one block: {sorted(dupe_refs)}")

        for edge in diagram.get("edges", []):
            if len(edge) != 2:
                result.fail("(model_diagram)", f"edge {edge} must be [from_id, to_id]")
                continue
            a, b_ = edge
            if a not in block_id_set:
                result.fail("(model_diagram)", f"edge references unknown block id '{a}'")
            if b_ not in block_id_set:
                result.fail("(model_diagram)", f"edge references unknown block id '{b_}'")

        if len(blocks) > 6:
            result.warn(
                "(model_diagram)",
                f"{len(blocks)} blocks β€” most papers should land at 2-5 genuine contributions; "
                f"double-check each is really the paper's own claim, not a background component",
            )

    # --- case.results ---
    results = data.get("case", {}).get("results")
    if not results:
        result.fail("(file)", "case.results is missing or empty β€” every case needs at least one results table")
    else:
        for i, table in enumerate(results):
            where = f"(results[{i}] {table.get('table_label', '?')})"
            if not (table.get("table_label") or "").strip():
                result.fail(where, "missing 'table_label'")
            if not (table.get("caption") or "").strip():
                result.fail(where, "missing 'caption'")
            check_bullets(result, where, "takeaways", table.get("takeaways"))
            cols = table.get("columns") or []
            if not cols:
                result.fail(where, "'columns' is empty")
            rows = table.get("rows") or []
            if not rows:
                result.fail(where, "'rows' is empty")
            for r_i, row in enumerate(rows):
                if len(row) != len(cols):
                    result.fail(where, f"row {r_i} has {len(row)} cells but there are {len(cols)} columns")
            for hr in table.get("highlight_rows", []):
                if not (0 <= hr < len(rows)):
                    result.fail(where, f"highlight_rows index {hr} out of range (0-{len(rows) - 1})")
        if len(results) > 2:
            result.warn("(file)", f"{len(results)} results tables β€” keep it to the headline table(s), typically 1")

    # --- case.related_papers ---
    related = data.get("case", {}).get("related_papers")
    if not related:
        result.fail("(file)", "case.related_papers is missing or empty β€” every case needs the small set of papers it builds on/compares against")
    else:
        for i, rp in enumerate(related):
            where = f"(related_papers[{i}] {rp.get('title', '?')})"
            if not (rp.get("title") or "").strip():
                result.fail(where, "missing 'title'")
            if rp.get("role") not in ("foundational", "baseline"):
                result.fail(where, f"role must be 'foundational' or 'baseline', got {rp.get('role')!r}")
            if not (rp.get("note") or "").strip():
                result.fail(where, "missing 'note' β€” one short phrase on why this paper is relevant")
        if len(related) > 12:
            result.warn("(file)", f"{len(related)} related_papers β€” this should be a small, deliberate set, not the full bibliography")

    # --- nodes ---
    for node in nodes:
        node_id = node.get("id", "(missing id)")
        status = node.get("status")
        refs = node.get("code_refs", [])

        if not (node.get("section") or "").strip():
            result.fail(node_id, "missing/empty 'section' β€” every node must carry the paper's own subsection heading")

        why = (node.get("why") or "").strip()
        if not why:
            result.fail(node_id, "missing/empty 'why' β€” every node must say why this block belongs in the story")
        elif why.count(".") > 1 or len(why.split()) > 40:
            result.warn(node_id, "'why' reads like more than one sentence β€” keep it to a single sentence")

        check_bullets(result, node_id, "summary", node.get("summary"))

        for eq in node.get("equations", []):
            if not (eq.get("label") or "").strip():
                result.fail(node_id, "an equation entry is missing 'label'")
            if not (eq.get("expression") or "").strip():
                result.fail(node_id, f"equation '{eq.get('label', '?')}' is missing 'expression'")

        for fig in node.get("figure_refs", []):
            check_figure_ref(result, node_id, fig)

        if status == "resolved" and not refs:
            result.fail(node_id, "status is 'resolved' but code_refs is empty")
        if status == "unresolved" and refs:
            result.fail(node_id, "status is 'unresolved' but code_refs is non-empty")
        if status == "ambiguous" and len(refs) < 2:
            result.warn(node_id, "status is 'ambiguous' but fewer than 2 code_refs given")

        for ref in refs:
            check_code_ref(result, node_id, node, ref, repo_root)

    return result


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("narrative_json", type=Path)
    parser.add_argument("--repo", type=Path, default=None,
                         help="path to the cloned repo (default: <case_dir>/repo)")
    args = parser.parse_args()

    if not args.narrative_json.is_file():
        print(f"error: {args.narrative_json} not found", file=sys.stderr)
        sys.exit(1)

    data = json.loads(args.narrative_json.read_text())
    case_dir = args.narrative_json.parent
    repo_root = args.repo or (case_dir / (data.get("case", {}).get("repo_path") or "repo"))

    if not repo_root.is_dir():
        print(f"error: repo directory not found at {repo_root}", file=sys.stderr)
        sys.exit(1)

    result = verify(data, repo_root)

    n_nodes = len(data.get("nodes", []))
    n_resolved = sum(1 for n in data.get("nodes", []) if n.get("status") == "resolved")
    n_blocks = len((data.get("case", {}).get("model_diagram") or {}).get("blocks", []))
    print(f"{args.narrative_json}: {n_blocks} innovation blocks, "
          f"{n_nodes} nodes, {n_resolved} code-resolved\n")

    for w in result.warnings:
        print(w)
    if result.warnings:
        print()
    for f in result.failures:
        print(f)

    if result.failures:
        print(f"\n{len(result.failures)} hard failure(s). Fix these before calling the case done.")
        sys.exit(1)
    else:
        print(f"\nno hard failures ({len(result.warnings)} warning(s)).")
        sys.exit(0)


if __name__ == "__main__":
    main()