| """General graph-reasoning evaluation metrics for the symbolic graph-reachability task. |
| |
| For each hop distance ``k`` we score the model's next-token prediction (after ``k-1`` |
| latents) into the paper's categories, with distances measured over the graph from the |
| root: |
| |
| - reachable: dist(root -> pred) <= k |
| - frontier: dist(root -> pred) == k |
| - optimal: dist(root -> pred) == k AND pred is on a shortest path to the target |
| (i.e. dist(root->pred) + dist(pred->target) == dist(root->target)) |
| |
| Categories are computed directly from (edges, root, target) — present in every data |
| file — so they are independent of which ``neighbor_k`` a variant's data ships and are |
| directly comparable across the standard / BFS Coconut variants. |
| """ |
| import json |
| import random |
| import collections |
|
|
| import torch |
| import torch.distributed as dist |
| from datasets import Dataset |
| from torch.utils.data import DataLoader |
| from torch.utils.data.distributed import DistributedSampler |
|
|
| from dataset import expand_data |
|
|
|
|
| def _bfs(adj, src): |
| """Distances from src over a directed adjacency dict (unreached nodes absent).""" |
| dist_map = {src: 0} |
| q = collections.deque([src]) |
| while q: |
| u = q.popleft() |
| for v in adj[u]: |
| if v not in dist_map: |
| dist_map[v] = dist_map[u] + 1 |
| q.append(v) |
| return dist_map |
|
|
|
|
| def _distances(edges, root, target): |
| """(fdist: root->node, bdist: node->target, L: shortest root->target).""" |
| fwd = collections.defaultdict(list) |
| bwd = collections.defaultdict(list) |
| for a, b in edges: |
| fwd[a].append(b) |
| bwd[b].append(a) |
| fdist = _bfs(fwd, root) |
| bdist = _bfs(bwd, target) |
| return fdist, bdist, fdist.get(target) |
|
|
|
|
| def _classify(g, k, fdist, bdist, L): |
| """(reachable, frontier, optimal) booleans for predicted node id g at hop k.""" |
| d = fdist.get(g) |
| if d is None: |
| return False, False, False |
| reachable = d <= k |
| frontier = d == k |
| optimal = frontier and L is not None and (g in bdist) and (d + bdist[g] == L) |
| return reachable, frontier, optimal |
|
|
|
|
| def _build_items(data_path, tokenizer, max_samples=None, stage_matched_q=False): |
| base = json.load(open(data_path)) |
| if max_samples is not None and max_samples < len(base): |
| base = random.Random(0).sample(base, max_samples) |
| items, meta = [], [] |
| for s in base: |
| L_steps = len(s["steps"]) |
| fdist, bdist, L = _distances(s["edges"], s["root"], s["target"]) |
| for k in range(1, L_steps + 1): |
| q, _ = expand_data(s, k, L_steps, stage_matched_q=stage_matched_q) |
| qtok = tokenizer.encode(q, add_special_tokens=False) |
| meta.append((k, fdist, bdist, L)) |
| items.append({ |
| "input_ids": qtok, |
| "attention_mask": [1] * len(qtok), |
| "position_ids": list(range(len(qtok))), |
| "idx": len(items), |
| }) |
| return Dataset.from_list(items), meta |
|
|
|
|
| def perhop_categorize(parallel_model, data_path, tokenizer, collator, rank, max_samples=None, stage_matched_q=False): |
| """Per hop k metrics (DDP-reduced). |
| |
| Returns {k: {reachable, frontier, optimal, superposition}} where: |
| reachable/frontier/optimal — same as before (from argmax next-token) |
| superposition — fraction of graphs where the top-|F| logits equal exactly |
| the reachable frontier set F = {n : dist(root,n) == k}. For the 2-arm |
| star |F|=2, so this is "top-2 tokens == both hop-k nodes". |
| ce_score — exp(log|F| - CE) where CE is the cross-entropy against |
| Uniform(F), i.e. the graded version of `superposition`. 1.0 means the |
| model is exactly at the log|F| floor (perfectly balanced over both arms) |
| and it falls off as either arm loses probability. Unlike `superposition` |
| this sees magnitude, not just rank. |
| """ |
| ds, meta = _build_items( |
| data_path, tokenizer, max_samples=max_samples, stage_matched_q=stage_matched_q |
| ) |
| max_k = max(k for k, *_ in meta) |
| dl = DataLoader( |
| ds, num_workers=1, pin_memory=True, batch_size=1, |
| collate_fn=collator, sampler=DistributedSampler(ds, shuffle=False), |
| ) |
| reach = torch.zeros(max_k + 1, device=rank) |
| front = torch.zeros(max_k + 1, device=rank) |
| opt = torch.zeros(max_k + 1, device=rank) |
| superpos = torch.zeros(max_k + 1, device=rank) |
| ce_score = torch.zeros(max_k + 1, device=rank) |
| tot = torch.zeros(max_k + 1, device=rank) |
| parallel_model.module.eval() |
| with torch.no_grad(): |
| for batch in dl: |
| i = int(batch["idx"][0]) |
| k, fdist, bdist, L = meta[i] |
| input_ids = batch["input_ids"].to(rank) |
| |
| |
| labels = input_ids.clone() |
| position_ids = torch.arange( |
| 0, input_ids.shape[1], dtype=torch.long, device=rank |
| ).reshape(1, -1) |
| outputs = parallel_model.module.forward( |
| input_ids, |
| torch.ones_like(input_ids, device=rank), |
| labels, |
| position_ids, |
| ) |
| logits = outputs.logits[0, -1] |
| g = int(torch.argmax(logits).item()) |
| r, f, o = _classify(g, k, fdist, bdist, L) |
| tot[k] += 1; reach[k] += r; front[k] += f; opt[k] += o |
| F = {n for n, d in fdist.items() if d == k} |
| if F: |
| top = torch.topk(logits, k=len(F)).indices.tolist() |
| if set(int(t) for t in top) == F: |
| superpos[k] += 1 |
| |
| |
| |
| |
| |
| |
| |
| |
| logp = torch.log_softmax(logits.float(), dim=-1) |
| ce = -torch.stack([logp[n] for n in F]).mean() |
| ce_score[k] += torch.exp( |
| torch.log(torch.tensor(float(len(F)), device=logits.device)) - ce |
| ).clamp(max=1.0) |
| for t in (reach, front, opt, superpos, ce_score, tot): |
| dist.all_reduce(t, op=dist.ReduceOp.SUM) |
| res = {} |
| for k in range(1, max_k + 1): |
| n = tot[k].item() |
| if n > 0: |
| res[k] = { |
| "reachable": reach[k].item() / n, |
| "frontier": front[k].item() / n, |
| "optimal": opt[k].item() / n, |
| "superposition": superpos[k].item() / n, |
| "ce_score": ce_score[k].item() / n, |
| } |
| return res |
|
|
|
|
| def category_log_dict(prefix, cats, acc_key): |
| """Flatten per-hop cats into wandb keys, aliasing acc_key as acc_hop*.""" |
| out = {} |
| for k, c in cats.items(): |
| out[f"{prefix}/reachable_hop{k}"] = c["reachable"] |
| out[f"{prefix}/frontier_hop{k}"] = c["frontier"] |
| out[f"{prefix}/optimal_hop{k}"] = c["optimal"] |
| if "superposition" in c: |
| out[f"{prefix}/superposition_hop{k}"] = c["superposition"] |
| if "ce_score" in c: |
| out[f"{prefix}/ce_score_hop{k}"] = c["ce_score"] |
| out[f"{prefix}/acc_hop{k}"] = c[acc_key] |
| return out |
|
|
|
|
| def _build_finalonly_items(data_path, tokenizer, max_samples=None): |
| """Per (sample, depth k) final-only eval prompt: |
| <edges> [Q] {reach,neg}(seeded order) [R] root <|latent|>*k [A] -> should emit reach. |
| Deterministic candidate choice (index 0 of each frontier) for reproducibility. |
| meta carries (k, reach, fdist, bdist, L) so the emitted [A] token can also be |
| classified reachable/frontier/optimal by its distance from root.""" |
| base = json.load(open(data_path)) |
| if max_samples is not None and max_samples < len(base): |
| base = random.Random(0).sample(base, max_samples) |
| items, meta = [], [] |
| for s in base: |
| L = len(s["steps"]) |
| fdist, bdist, Ld = _distances(s["edges"], s["root"], s["target"]) |
| for k in range(1, L + 1): |
| reach = s["neighbor_k"][str(k)][0] |
| neg = s["neg_neighbor_k"][str(k)][0] |
| cands = [reach, neg] |
| random.Random(len(items)).shuffle(cands) |
| q = ("<eos> " + "|".join([f" {e[0]} {e[1]} " for e in s["edges"]]).strip() |
| + " [Q] " + str(cands[0]) + " " + str(cands[1]) |
| + " [R] " + str(s["root"]) + " <|latent|>" * k + " [A] ") |
| qtok = tokenizer.encode(q, add_special_tokens=False) |
| meta.append((k, reach, fdist, bdist, Ld)) |
| items.append({ |
| "input_ids": qtok, |
| "attention_mask": [1] * len(qtok), |
| "position_ids": list(range(len(qtok))), |
| "idx": len(items), |
| }) |
| return Dataset.from_list(items), meta |
|
|
|
|
| def finalonly_categorize(parallel_model, data_path, tokenizer, collator, rank, max_samples=None): |
| """Per depth k over the final-only [A] prediction (DDP-reduced), returns |
| {k: {"acc","reachable","frontier","optimal"}}: |
| acc = emitted token == the designated reachable candidate (the trained target) |
| reachable = emitted node is reachable from root within k hops (dist <= k) |
| frontier = emitted node at distance exactly k from root |
| optimal = frontier AND on a shortest path to target |
| (acc - reachable = picked a different reachable node; the unreachable candidate |
| classifies as none of the three.)""" |
| ds, meta = _build_finalonly_items(data_path, tokenizer, max_samples=max_samples) |
| max_k = max(k for k, *_ in meta) |
| dl = DataLoader( |
| ds, num_workers=1, pin_memory=True, batch_size=1, |
| collate_fn=collator, sampler=DistributedSampler(ds, shuffle=False), |
| ) |
| acc = torch.zeros(max_k + 1, device=rank) |
| reach_t = torch.zeros(max_k + 1, device=rank) |
| front = torch.zeros(max_k + 1, device=rank) |
| opt = torch.zeros(max_k + 1, device=rank) |
| tot = torch.zeros(max_k + 1, device=rank) |
| parallel_model.module.eval() |
| with torch.no_grad(): |
| for batch in dl: |
| i = int(batch["idx"][0]) |
| k, reach, fdist, bdist, Ld = meta[i] |
| inp = {kk: v.to(rank) for kk, v in batch.items() |
| if v is not None and kk not in ["idx", "position_ids"]} |
| out = parallel_model.module.generate( |
| **inp, max_new_tokens=1, synced_gpus=True, |
| eos_token_id=tokenizer.eos_token_id, |
| ) |
| g = int(out[0, -1].item()) |
| r, f, o = _classify(g, k, fdist, bdist, Ld) |
| tot[k] += 1 |
| acc[k] += int(g == reach) |
| reach_t[k] += r |
| front[k] += f |
| opt[k] += o |
| for t in (acc, reach_t, front, opt, tot): |
| dist.all_reduce(t, op=dist.ReduceOp.SUM) |
| res = {} |
| for k in range(1, max_k + 1): |
| n = tot[k].item() |
| if n > 0: |
| res[k] = { |
| "acc": acc[k].item() / n, |
| "reachable": reach_t[k].item() / n, |
| "frontier": front[k].item() / n, |
| "optimal": opt[k].item() / n, |
| } |
| return res |
|
|