From 23f598f3a051120020657b76f45b6edadbbba4ee Mon Sep 17 00:00:00 2001 From: Safi Date: Sat, 9 May 2026 21:17:25 +0100 Subject: [PATCH] fix MultiGraph crash, hollow LLM response retry, and skill --help (#796, #795, #792) #796: add edge_data()/edge_datas() helpers in build.py that tolerate MultiGraph/MultiDiGraph; replace all G.edges[u,v] 2-tuple call sites in __main__.py, serve.py, wiki.py, export.py, analyze.py, benchmark.py; fix same pattern in 10 skill file inline heredocs #795: all 12 skill files now short-circuit on /graphify --help or -h and print the Usage block without running any pipeline steps #792 (hollow response): add _response_is_hollow() predicate in llm.py; when Ollama (or any backend) returns empty/null/whitespace content or a parsed result with no nodes/edges, rewrite finish_reason="length" so _extract_with_adaptive_retry bisects the chunk instead of silently dropping it; applied to _call_openai_compat, _call_claude, _call_bedrock Co-Authored-By: Claude Sonnet 4.6 --- graphify/__main__.py | 6 +- graphify/analyze.py | 4 +- graphify/benchmark.py | 4 +- graphify/build.py | 21 +++++ graphify/export.py | 7 +- graphify/llm.py | 62 ++++++++++++- graphify/serve.py | 5 +- graphify/skill-aider.md | 8 +- graphify/skill-claw.md | 8 +- graphify/skill-codex.md | 8 +- graphify/skill-copilot.md | 8 +- graphify/skill-droid.md | 8 +- graphify/skill-kiro.md | 8 +- graphify/skill-opencode.md | 8 +- graphify/skill-pi.md | 8 +- graphify/skill-trae.md | 8 +- graphify/skill-vscode.md | 2 + graphify/skill-windows.md | 8 +- graphify/skill.md | 4 +- graphify/wiki.py | 6 +- tests/test_build.py | 88 ++++++++++++++++++- tests/test_llm_backends.py | 173 +++++++++++++++++++++++++++++++++++++ 22 files changed, 416 insertions(+), 46 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index e0d0bd59..500511b0 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1514,9 +1514,10 @@ def main() -> None: sys.exit(0) hops = len(path_nodes) - 1 segments = [] + from graphify.build import edge_data for i in range(len(path_nodes) - 1): u, v = path_nodes[i], path_nodes[i + 1] - edata = G.edges[u, v] + edata = edge_data(G, u, v) rel = edata.get("relation", "") conf = edata.get("confidence", "") conf_str = f" [{conf}]" if conf else "" @@ -1562,9 +1563,10 @@ def main() -> None: print(f" Degree: {G.degree(nid)}") neighbors = list(G.neighbors(nid)) if neighbors: + from graphify.build import edge_data print(f"\nConnections ({len(neighbors)}):") for nb in sorted(neighbors, key=lambda n: G.degree(n), reverse=True)[:20]: - edata = G.edges[nid, nb] + edata = edge_data(G, nid, nb) rel = edata.get("relation", "") conf = edata.get("confidence", "") print(f" --> {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]") diff --git a/graphify/analyze.py b/graphify/analyze.py index de07cc13..a436ca01 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -3,6 +3,8 @@ from __future__ import annotations from pathlib import Path import networkx as nx +from graphify.build import edge_data + # Language families — extensions sharing a runtime can legitimately call each other _LANG_FAMILY: dict[str, str] = { **{e: "python" for e in (".py", ".pyw")}, @@ -301,7 +303,7 @@ def _cross_community_surprises( top_edges = sorted(betweenness.items(), key=lambda x: x[1], reverse=True)[:top_n] result = [] for (u, v), score in top_edges: - data = G.edges[u, v] + data = edge_data(G, u, v) result.append({ "source": G.nodes[u].get("label", u), "target": G.nodes[v].get("label", v), diff --git a/graphify/benchmark.py b/graphify/benchmark.py index 2fb161a9..f362d5e7 100644 --- a/graphify/benchmark.py +++ b/graphify/benchmark.py @@ -6,6 +6,8 @@ from pathlib import Path import networkx as nx from networkx.readwrite import json_graph +from graphify.build import edge_data + _CHARS_PER_TOKEN = 4 # standard approximation @@ -66,7 +68,7 @@ def _query_subgraph_tokens(G: nx.Graph, question: str, depth: int = 3) -> int: lines.append(f"NODE {d.get('label', nid)} src={d.get('source_file', '')} loc={d.get('source_location', '')}") for u, v in edges_seen: if u in visited and v in visited: - d = G.edges[u, v] + d = edge_data(G, u, v) lines.append(f"EDGE {G.nodes[u].get('label', u)} --{d.get('relation', '')}--> {G.nodes[v].get('label', v)}") return _estimate_tokens("\n".join(lines)) diff --git a/graphify/build.py b/graphify/build.py index 82384361..e452c349 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -45,6 +45,27 @@ def _norm_source_file(p: str | None) -> str | None: return p.replace("\\", "/") if p else p +def edge_data(G: nx.Graph, u: str, v: str) -> dict: + """Return one edge attribute dict for (u, v), tolerating MultiGraph. + + For MultiGraph/MultiDiGraph there can be multiple parallel edges; + this returns the first one (sufficient for callers that only need + relation/confidence for rendering). Fixes #796. + """ + raw = G[u][v] + if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)): + return next(iter(raw.values()), {}) + return raw + + +def edge_datas(G: nx.Graph, u: str, v: str) -> list[dict]: + """Return every edge attribute dict for (u, v); always a list.""" + raw = G[u][v] + if isinstance(G, (nx.MultiGraph, nx.MultiDiGraph)): + return list(raw.values()) + return [raw] + + def build_from_json(extraction: dict, *, directed: bool = False) -> nx.Graph: """Build a NetworkX graph from an extraction dict. diff --git a/graphify/export.py b/graphify/export.py index 2e627114..8ddf2bdc 100644 --- a/graphify/export.py +++ b/graphify/export.py @@ -10,6 +10,7 @@ import networkx as nx from networkx.readwrite import json_graph from graphify.security import sanitize_label from graphify.analyze import _node_community_map +from graphify.build import edge_data def _obsidian_tag(name: str) -> str: """Sanitize a community name for use as an Obsidian tag. @@ -802,10 +803,10 @@ def to_obsidian( if neighbors: lines.append("## Connections") for neighbor in sorted(neighbors, key=lambda n: G.nodes[n].get("label", n)): - edge_data = G.edges[node_id, neighbor] + edata = edge_data(G, node_id, neighbor) neighbor_label = node_filename[neighbor] - relation = edge_data.get("relation", "") - confidence = edge_data.get("confidence", "EXTRACTED") + relation = edata.get("relation", "") + confidence = edata.get("confidence", "EXTRACTED") lines.append(f"- [[{neighbor_label}]] - `{relation}` [{confidence}]") lines.append("") diff --git a/graphify/llm.py b/graphify/llm.py index abde5bb2..ddac2e1c 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -170,6 +170,26 @@ def _parse_llm_json(raw: str) -> dict: return {"nodes": [], "edges": [], "hyperedges": []} +def _response_is_hollow(raw_content: str | None, parsed: dict) -> bool: + """Detect a successful HTTP response that yielded no usable extraction. + + A local model under load (most often Ollama) can return HTTP 200 with an + empty / null `message.content`, with whitespace, or with a half-generated + JSON prefix that fails to parse. All of these collapse to a "successful" + call producing zero nodes and zero edges. Without this check the chunk + is silently dropped from the corpus because no exception is raised and + `finish_reason` is `"stop"` rather than `"length"`. By flagging the + result as hollow, callers can re-route it through the same bisection + path used for context-window overflow and `finish_reason="length"`. + """ + if raw_content is None or not raw_content.strip(): + return True + nodes = parsed.get("nodes") + edges = parsed.get("edges") + hyperedges = parsed.get("hyperedges") + return not nodes and not edges and not hyperedges + + def _backend_env_keys(backend: str) -> list[str]: """Return accepted API-key environment variables for a backend.""" cfg = BACKENDS[backend] @@ -260,7 +280,8 @@ def _call_openai_compat( if "moonshot" in base_url: kwargs["extra_body"] = {"thinking": {"type": "disabled"}} resp = client.chat.completions.create(**kwargs) - result = _parse_llm_json(resp.choices[0].message.content or "{}") + raw_content = resp.choices[0].message.content + result = _parse_llm_json(raw_content or "{}") result["input_tokens"] = resp.usage.prompt_tokens if resp.usage else 0 result["output_tokens"] = resp.usage.completion_tokens if resp.usage else 0 result["model"] = model @@ -268,6 +289,20 @@ def _call_openai_compat( # mid-generation. The JSON we got back is truncated; callers should # treat this as a signal to retry with smaller input. result["finish_reason"] = resp.choices[0].finish_reason + # An overwhelmed local model (typically Ollama) can return HTTP 200 with + # empty / null content or unparseable half-generated JSON. The call looks + # successful, `finish_reason` is `"stop"`, and the chunk would be silently + # dropped from the corpus. Re-label as `"length"` so the adaptive retry + # layer bisects the chunk — same recovery as a true truncation. + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + f"[graphify] {backend or 'backend'} returned a hollow response " + f"(content={'empty' if not (raw_content or '').strip() else 'no nodes/edges'}, " + f"output_tokens={result['output_tokens']}); " + "treating as truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" output_tokens = result["output_tokens"] if output_tokens < 50 and backend == "ollama": print( @@ -296,7 +331,8 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = system=_EXTRACTION_SYSTEM, messages=[{"role": "user", "content": user_message}], ) - result = _parse_llm_json(resp.content[0].text if resp.content else "{}") + raw_content = resp.content[0].text if resp.content else None + result = _parse_llm_json(raw_content or "{}") result["input_tokens"] = resp.usage.input_tokens if resp.usage else 0 result["output_tokens"] = resp.usage.output_tokens if resp.usage else 0 result["model"] = model @@ -304,6 +340,13 @@ def _call_claude(api_key: str, model: str, user_message: str, max_tokens: int = # vocabulary so the adaptive-retry layer doesn't have to know which # backend produced the result. result["finish_reason"] = "length" if resp.stop_reason == "max_tokens" else "stop" + if _response_is_hollow(raw_content, result) and result["finish_reason"] != "length": + print( + "[graphify] claude returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" return result @@ -341,6 +384,13 @@ def _call_bedrock(model: str, user_message: str, max_tokens: int = 8192) -> dict result["output_tokens"] = usage.get("outputTokens", 0) result["model"] = model result["finish_reason"] = "length" if resp.get("stopReason") == "max_tokens" else "stop" + if _response_is_hollow(text, result) and result["finish_reason"] != "length": + print( + "[graphify] bedrock returned a hollow response; treating as " + "truncation so adaptive retry can bisect the chunk.", + file=sys.stderr, + ) + result["finish_reason"] = "length" return result @@ -505,7 +555,7 @@ def _extract_with_adaptive_retry( or the API rejects the prompt as too large for the model's context window, split the chunk in half and recurse. - Two signals drive the retry: + Three signals drive the retry, all funnelled through the same code: - `finish_reason == "length"` — the model accepted the input but ran out of `max_completion_tokens` mid-output. The truncated JSON is unparseable, so @@ -518,6 +568,12 @@ def _extract_with_adaptive_retry( half is the same recovery as for the `length` case and works for the same reason. + - hollow successful responses — the model returned HTTP 200 with empty, + null, or unparseable content (typical of a local Ollama under load). + `_call_openai_compat` re-labels these as `finish_reason="length"` so they + take the same recovery path; without that the chunk would be silently + dropped from the corpus. + Recursion is capped at `max_depth` to bound worst-case cost. A chunk of N files can split into up to 2**max_depth pieces — at depth=3 that's 8x. If still failing at the cap, we surface the (likely empty) result with a diff --git a/graphify/serve.py b/graphify/serve.py index fe781343..295d1e75 100644 --- a/graphify/serve.py +++ b/graphify/serve.py @@ -6,6 +6,7 @@ from pathlib import Path import networkx as nx from networkx.readwrite import json_graph from graphify.security import sanitize_label +from graphify.build import edge_data def _load_graph(graph_path: str) -> nx.Graph: @@ -403,7 +404,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None: nid = matches[0] lines = [f"Neighbors of {sanitize_label(G.nodes[nid].get('label', nid))}:"] for neighbor in G.neighbors(nid): - d = G.edges[nid, neighbor] + d = edge_data(G, nid, neighbor) rel = d.get("relation", "") if rel_filter and rel_filter not in rel.lower(): continue @@ -466,7 +467,7 @@ def serve(graph_path: str = "graphify-out/graph.json") -> None: segments = [] for i in range(len(path_nodes) - 1): u, v = path_nodes[i], path_nodes[i + 1] - edata = G.edges[u, v] + edata = edge_data(G, u, v) rel = edata.get("relation", "") conf = edata.get("confidence", "") conf_str = f" [{conf}]" if conf else "" diff --git a/graphify/skill-aider.md b/graphify/skill-aider.md index 3a734e9d..eeb4517e 100644 --- a/graphify/skill-aider.md +++ b/graphify/skill-aider.md @@ -51,6 +51,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -952,7 +954,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1024,7 +1026,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1094,7 +1096,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-claw.md b/graphify/skill-claw.md index 8d7da315..cc86d5c2 100644 --- a/graphify/skill-claw.md +++ b/graphify/skill-claw.md @@ -51,6 +51,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -952,7 +954,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1024,7 +1026,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1094,7 +1096,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-codex.md b/graphify/skill-codex.md index ecf39987..c04e6a0f 100644 --- a/graphify/skill-codex.md +++ b/graphify/skill-codex.md @@ -51,6 +51,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -1011,7 +1013,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1083,7 +1085,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1153,7 +1155,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-copilot.md b/graphify/skill-copilot.md index 04a70582..f25f7af9 100644 --- a/graphify/skill-copilot.md +++ b/graphify/skill-copilot.md @@ -53,6 +53,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -1040,7 +1042,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1112,7 +1114,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1182,7 +1184,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-droid.md b/graphify/skill-droid.md index 6fec34fa..5dbf50b8 100644 --- a/graphify/skill-droid.md +++ b/graphify/skill-droid.md @@ -51,6 +51,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -1008,7 +1010,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1080,7 +1082,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1150,7 +1152,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-kiro.md b/graphify/skill-kiro.md index 8109adbe..fe8638aa 100644 --- a/graphify/skill-kiro.md +++ b/graphify/skill-kiro.md @@ -50,6 +50,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -951,7 +953,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1023,7 +1025,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1093,7 +1095,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-opencode.md b/graphify/skill-opencode.md index dfa3541b..c679d725 100644 --- a/graphify/skill-opencode.md +++ b/graphify/skill-opencode.md @@ -51,6 +51,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -1061,7 +1063,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1133,7 +1135,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1203,7 +1205,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-pi.md b/graphify/skill-pi.md index f1fb81fc..1905e6ce 100644 --- a/graphify/skill-pi.md +++ b/graphify/skill-pi.md @@ -50,6 +50,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -951,7 +953,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")} [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1023,7 +1025,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1093,7 +1095,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-trae.md b/graphify/skill-trae.md index 794a4148..018aea18 100644 --- a/graphify/skill-trae.md +++ b/graphify/skill-trae.md @@ -51,6 +51,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -976,7 +978,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get(\"label\", nid)} [src={d.get(\"source_file\",\"\")} loc={d.get(\"source_location\",\"\")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get(\"label\",u)} --{d.get(\"relation\",\"\")}] [{d.get(\"confidence\",\"\")}]--> {G.nodes[v].get(\"label\",v)}') output = '\n'.join(lines) @@ -1048,7 +1050,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1117,7 +1119,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill-vscode.md b/graphify/skill-vscode.md index 1fafd671..3f059bb1 100644 --- a/graphify/skill-vscode.md +++ b/graphify/skill-vscode.md @@ -21,6 +21,8 @@ Turn any folder of files into a navigable knowledge graph with community detecti ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. diff --git a/graphify/skill-windows.md b/graphify/skill-windows.md index e01a99cb..37bbdf95 100644 --- a/graphify/skill-windows.md +++ b/graphify/skill-windows.md @@ -54,6 +54,8 @@ Use it for: ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. Follow these steps in order. Do not skip steps. @@ -1090,7 +1092,7 @@ for nid in ranked_nodes: lines.append(f' NODE {d.get("label", nid)} [src={d.get("source_file","")} loc={d.get("source_location","")}]') for u, v in subgraph_edges: if u in subgraph_nodes and v in subgraph_nodes: - d = G.edges[u, v] + _raw = G[u][v]; d = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw lines.append(f' EDGE {G.nodes[u].get("label",u)} --{d.get("relation","")} [{d.get("confidence","")}]--> {G.nodes[v].get("label",v)}') output = '\n'.join(lines) @@ -1166,7 +1168,7 @@ try: for i, nid in enumerate(path): label = G.nodes[nid].get('label', nid) if i < len(path) - 1: - edge = G.edges[nid, path[i+1]] + _raw = G[nid][path[i+1]]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw rel = edge.get('relation', '') conf = edge.get('confidence', '') print(f' {label} --{rel}--> [{conf}]') @@ -1240,7 +1242,7 @@ print(f' degree: {G.degree(nid)}') print() print('CONNECTIONS:') for neighbor in G.neighbors(nid): - edge = G.edges[nid, neighbor] + _raw = G[nid][neighbor]; edge = next(iter(_raw.values()), {}) if isinstance(G, nx.MultiGraph) else _raw nlabel = G.nodes[neighbor].get('label', neighbor) rel = edge.get('relation', '') conf = edge.get('confidence', '') diff --git a/graphify/skill.md b/graphify/skill.md index 9d238e6a..8c296b0c 100644 --- a/graphify/skill.md +++ b/graphify/skill.md @@ -47,9 +47,11 @@ Drop any folder of code, docs, papers, images, or video into graphify and get a ## What You Must Do When Invoked +If the user invoked `/graphify --help` or `/graphify -h` (with no other arguments), print the contents of the `## Usage` section above verbatim and stop. Do not run any commands, do not detect files, do not default the path to `.`. Just print the Usage block and return. + If no path was given, use `.` (current directory). Do not ask the user for a path. -If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL — run Step 0 before anything else, then continue with the resolved local path. +If the path argument starts with `https://github.com/` or `http://github.com/`, treat it as a GitHub URL - run Step 0 before anything else, then continue with the resolved local path. Follow these steps in order. Do not skip steps. diff --git a/graphify/wiki.py b/graphify/wiki.py index 973ed89a..8d8baa3e 100644 --- a/graphify/wiki.py +++ b/graphify/wiki.py @@ -5,6 +5,8 @@ from collections import Counter from pathlib import Path import networkx as nx +from graphify.build import edge_data + def _safe_filename(name: str) -> str: """Make a label safe for use as a filename across platforms. @@ -48,7 +50,7 @@ def _community_article( conf_counts: Counter = Counter() for nid in nodes: for neighbor in G.neighbors(nid): - ed = G.edges[nid, neighbor] + ed = edge_data(G, nid, neighbor) conf_counts[ed.get("confidence", "EXTRACTED")] += 1 total_edges = sum(conf_counts.values()) or 1 @@ -118,7 +120,7 @@ def _god_node_article(G: nx.Graph, nid: str, labels: dict[int, str]) -> str: by_relation: dict[str, list[str]] = {} for neighbor in sorted(G.neighbors(nid), key=lambda n: G.degree(n), reverse=True): nd = G.nodes[neighbor] - ed = G.edges[nid, neighbor] + ed = edge_data(G, nid, neighbor) rel = ed.get("relation", "related") neighbor_label = nd.get("label", neighbor) conf = ed.get("confidence", "") diff --git a/tests/test_build.py b/tests/test_build.py index 6cbe8681..95acc1d4 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -1,6 +1,8 @@ import json from pathlib import Path -from graphify.build import build_from_json, build, build_merge +import networkx as nx +from networkx.readwrite import json_graph +from graphify.build import build_from_json, build, build_merge, edge_data, edge_datas FIXTURES = Path(__file__).parent / "fixtures" @@ -195,3 +197,87 @@ def test_build_merge_preserves_call_edge_direction(tmp_path): f"calls edge target flipped after build_merge round-trip: " f"expected {truth_tgt} (b), got {reloaded_calls[0]['target']}" ) + + +# Regression tests for #796 — edge_data / edge_datas helpers must tolerate +# MultiGraph and MultiDiGraph, which networkx's node_link_graph() produces +# whenever the loaded JSON has multigraph: true. Plain G.edges[u, v] crashes +# on those with `ValueError: not enough values to unpack (expected 3, got 2)`. + +def test_edge_data_simple_graph(): + G = nx.Graph() + G.add_edge("a", "b", relation="calls", confidence="EXTRACTED") + d = edge_data(G, "a", "b") + assert isinstance(d, dict) + assert d["relation"] == "calls" + assert d["confidence"] == "EXTRACTED" + + +def test_edge_datas_simple_graph_returns_singleton_list(): + G = nx.Graph() + G.add_edge("a", "b", relation="calls", confidence="EXTRACTED") + ds = edge_datas(G, "a", "b") + assert isinstance(ds, list) + assert len(ds) == 1 + assert ds[0]["relation"] == "calls" + + +def test_edge_data_multigraph_with_parallel_edges(): + G = nx.MultiGraph() + G.add_edge("a", "b", relation="calls", confidence="EXTRACTED") + G.add_edge("a", "b", relation="references", confidence="INFERRED") + d = edge_data(G, "a", "b") + assert isinstance(d, dict) + # First parallel edge wins; should be one of the two attribute dicts above. + assert d.get("relation") in ("calls", "references") + + +def test_edge_datas_multigraph_returns_all_parallel_edges(): + G = nx.MultiGraph() + G.add_edge("a", "b", relation="calls", confidence="EXTRACTED") + G.add_edge("a", "b", relation="references", confidence="INFERRED") + ds = edge_datas(G, "a", "b") + assert isinstance(ds, list) + assert len(ds) == 2 + relations = {e.get("relation") for e in ds} + assert relations == {"calls", "references"} + + +def test_edge_data_multidigraph(): + G = nx.MultiDiGraph() + G.add_edge("a", "b", relation="calls") + G.add_edge("a", "b", relation="imports") + d = edge_data(G, "a", "b") + assert isinstance(d, dict) + assert d.get("relation") in ("calls", "imports") + ds = edge_datas(G, "a", "b") + assert len(ds) == 2 + + +def test_edge_data_node_link_multigraph_roundtrip(): + """A node_link JSON with multigraph: true must load as MultiGraph and the + helpers must operate on it without raising the 3-tuple unpack ValueError.""" + data = { + "directed": False, + "multigraph": True, + "graph": {}, + "nodes": [ + {"id": "a", "label": "A"}, + {"id": "b", "label": "B"}, + ], + "links": [ + {"source": "a", "target": "b", "relation": "calls", "confidence": "EXTRACTED"}, + {"source": "a", "target": "b", "relation": "references", "confidence": "INFERRED"}, + ], + } + try: + G = json_graph.node_link_graph(data, edges="links") + except TypeError: + G = json_graph.node_link_graph(data) + assert isinstance(G, nx.MultiGraph) + # Plain G.edges[u, v] would raise here; the helper must not. + d = edge_data(G, "a", "b") + assert isinstance(d, dict) + assert d.get("relation") in ("calls", "references") + ds = edge_datas(G, "a", "b") + assert len(ds) == 2 diff --git a/tests/test_llm_backends.py b/tests/test_llm_backends.py index 23c832b0..cd83853e 100644 --- a/tests/test_llm_backends.py +++ b/tests/test_llm_backends.py @@ -187,3 +187,176 @@ def test_adaptive_retry_re_raises_unrelated_errors(tmp_path): llm._extract_with_adaptive_retry( [f], backend="kimi", api_key="k", model="m", root=tmp_path, max_depth=3 ) + + +# --------------------------------------------------------------------------- +# Hollow-response detection: empty / null / unparseable content from a +# successful HTTP call must route into the same bisection path as a true +# `finish_reason="length"` truncation, not be silently dropped. +# --------------------------------------------------------------------------- + + +def test_response_is_hollow_flags_empty_string(): + assert llm._response_is_hollow("", {"nodes": [], "edges": [], "hyperedges": []}) + + +def test_response_is_hollow_flags_none_content(): + assert llm._response_is_hollow(None, {"nodes": [], "edges": [], "hyperedges": []}) + + +def test_response_is_hollow_flags_whitespace_only(): + assert llm._response_is_hollow(" \n\t ", {"nodes": [], "edges": [], "hyperedges": []}) + + +def test_response_is_hollow_flags_parsed_but_no_nodes_or_edges(): + # Content was non-empty (e.g. model said `{"sorry": "I cannot"}` or returned + # `{}` literally) but the parsed result has nothing usable. + assert llm._response_is_hollow('{"sorry": "I cannot"}', {}) + assert llm._response_is_hollow("{}", {"nodes": [], "edges": [], "hyperedges": []}) + + +def test_response_is_hollow_accepts_real_extraction(): + parsed = {"nodes": [{"id": "x"}], "edges": [], "hyperedges": []} + assert not llm._response_is_hollow('{"nodes":[{"id":"x"}]}', parsed) + parsed = {"nodes": [], "edges": [{"source": "a", "target": "b"}], "hyperedges": []} + assert not llm._response_is_hollow('{"edges":[...]}', parsed) + + +def _fake_openai_response(content, *, finish_reason="stop", prompt_tokens=100, completion_tokens=0): + """Build a minimal stand-in for an `openai` SDK ChatCompletion response.""" + class _Usage: + def __init__(self): + self.prompt_tokens = prompt_tokens + self.completion_tokens = completion_tokens + + class _Message: + def __init__(self): + self.content = content + + class _Choice: + def __init__(self): + self.message = _Message() + self.finish_reason = finish_reason + + class _Resp: + def __init__(self): + self.choices = [_Choice()] + self.usage = _Usage() + + return _Resp() + + +def _install_fake_openai(monkeypatch, fake_resp): + """Inject a stub `openai` module so `_call_openai_compat` can run without + the real SDK installed. The function does `from openai import OpenAI` + inside its body, so we satisfy that lookup via `sys.modules`.""" + import sys + import types + + class _FakeOpenAI: + def __init__(self, *_, **__): + self.chat = self + self.completions = self + def create(self, **__): + return fake_resp + + fake_module = types.ModuleType("openai") + fake_module.OpenAI = _FakeOpenAI + monkeypatch.setitem(sys.modules, "openai", fake_module) + + +def test_call_openai_compat_relabels_empty_content_as_length(monkeypatch): + # Simulates an overwhelmed Ollama: HTTP 200, empty content, finish_reason + # "stop", zero completion tokens. Pre-fix this would silently return an + # empty fragment and the chunk would be dropped. Post-fix `finish_reason` + # is rewritten to "length" so the adaptive retry layer bisects. + fake_resp = _fake_openai_response("", finish_reason="stop", completion_tokens=0) + _install_fake_openai(monkeypatch, fake_resp) + + result = llm._call_openai_compat( + "http://localhost:11434/v1", "ollama", "qwen2.5-coder:7b", + "user msg", temperature=0, max_completion_tokens=8192, backend="ollama", + ) + assert result["finish_reason"] == "length", ( + "empty content from a 'successful' call must be re-labelled so the " + "adaptive retry layer treats it as a truncation and bisects the chunk" + ) + + +def test_call_openai_compat_relabels_none_content_as_length(monkeypatch): + fake_resp = _fake_openai_response(None, finish_reason="stop") + _install_fake_openai(monkeypatch, fake_resp) + + result = llm._call_openai_compat( + "http://localhost:11434/v1", "ollama", "qwen2.5-coder:7b", + "u", temperature=0, max_completion_tokens=8192, backend="ollama", + ) + assert result["finish_reason"] == "length" + + +def test_call_openai_compat_relabels_unparseable_json_as_length(monkeypatch): + # A half-generated response: `{"nodes": [{"id":` parses to {} (empty + # fragment) via _parse_llm_json's JSONDecodeError fallback. That is also + # hollow and must trigger bisection. + fake_resp = _fake_openai_response('{"nodes": [{"id":', finish_reason="stop", completion_tokens=20) + _install_fake_openai(monkeypatch, fake_resp) + + result = llm._call_openai_compat( + "http://localhost:11434/v1", "ollama", "qwen2.5-coder:7b", + "u", temperature=0, max_completion_tokens=8192, backend="ollama", + ) + assert result["finish_reason"] == "length" + + +def test_call_openai_compat_preserves_real_finish_reason(monkeypatch): + # A genuine extraction with real nodes must NOT be re-labelled. + fake_resp = _fake_openai_response( + '{"nodes":[{"id":"a"}],"edges":[],"hyperedges":[]}', + finish_reason="stop", + completion_tokens=200, + ) + _install_fake_openai(monkeypatch, fake_resp) + + result = llm._call_openai_compat( + "http://localhost:11434/v1", "k", "m", + "u", temperature=0, max_completion_tokens=8192, backend="kimi", + ) + assert result["finish_reason"] == "stop" + assert result["nodes"] == [{"id": "a"}] + + +def test_adaptive_retry_bisects_on_hollow_ollama_response(tmp_path): + # End-to-end: an overwhelmed Ollama returns hollow on the full 4-file + # chunk; halves succeed. The bug being fixed is that pre-fix this + # produces zero nodes (chunk silently dropped). Post-fix the hollow + # response is relabelled `finish_reason="length"` and the existing + # bisection path recovers the full 4 nodes. + files = [tmp_path / f"f{i}.md" for i in range(4)] + for f in files: + f.write_text("hello") + + calls = {"n": 0} + + def fake_extract(chunk, *_, **__): + calls["n"] += 1 + if len(chunk) == 4: + # Hollow response: looks successful, finish_reason already + # rewritten to "length" by _call_openai_compat. + return { + "nodes": [], "edges": [], "hyperedges": [], + "input_tokens": 100, "output_tokens": 0, + "model": "m", "finish_reason": "length", + } + return _ok(nodes=[{"id": f.stem} for f in chunk]) + + with patch("graphify.llm.extract_files_direct", side_effect=fake_extract): + result = llm._extract_with_adaptive_retry( + files, backend="ollama", api_key="ollama", model="qwen2.5-coder:7b", + root=tmp_path, max_depth=3, + ) + + assert len(result["nodes"]) == 4, ( + "bisection should recover all 4 nodes from the two halves after the " + "full chunk came back hollow" + ) + assert calls["n"] == 3 # 1 hollow + 2 successful halves