Skip to content

Commit 7bf575c

Browse files
committed
Improve the knowledge graph
1 parent 6ba922e commit 7bf575c

11 files changed

Lines changed: 781 additions & 19 deletions

AGENTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ This file provides guidance to coding agents collaborating on this repository.
66

77
This project builds graph databases from the [Meta Kaggle](https://www.kaggle.com/datasets/kaggle/meta-kaggle) dataset and loads them
88
into [IssunDB](https://github.com/habedi/issun-db), an embedded graph database.
9-
The pipeline stages source files with DuckDB, parses code imports with Polars, and bulk-loads nodes and edges through the IssunDB CLI.
9+
The pipeline stages source files with DuckDB, parses code imports with Polars, parses Python API calls with Tree-sitter, and bulk-loads nodes and edges through the IssunDB CLI.
1010
The project priorities include correct graph construction, reproducible builds, scoped subset testing, and idiomatic Python.
1111

1212
## Core Rules
1313

1414
- Use English for code, comments, documentation, and tests.
15-
- Stage with DuckDB, parse code with Polars, and load through the IssunDB command line interface. Do not mix these roles.
15+
- Stage with DuckDB, parse code imports with Polars, parse Python API calls with Tree-sitter, and load through the IssunDB command line interface. Do not mix these roles.
1616
- Bulk-load nodes and edges with command line tools instead of Cypher queries.
1717
- Keep the large local source data out of the repository.
1818
- Ensure staging is deterministic by using a fixed seed rule and fixed source data.
@@ -45,6 +45,7 @@ The project priorities include correct graph construction, reproducible builds,
4545
- `scripts/stage_kernel_subset.py` stages top-voted kernels.
4646
- `scripts/stage_competition_subset.py` stages competition metadata.
4747
- `scripts/parse_imports.py` parses code imports.
48+
- `scripts/parse_api_calls.py` parses Python API calls into `ApiCall` nodes and `CALLS` edges.
4849
- `scripts/load_competition_kg.py` loads the Kaggle knowledge graph.
4950
- `scripts/import_to_issundb.py` loads the kernel graph.
5051
- `scripts/issundb_load.py` holds the loader logic shared by both load scripts.

Makefile

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,12 @@ kg-stage-with-message-text: ## Stage subset and include forum message text colum
110110
kg-parse-imports: ## Parse imports from Meta Kaggle Code for staged kernel versions
111111
META_KAGGLE_CODE_DIR="$(META_KAGGLE_CODE_DIR)" STAGE_DIR="$(STAGE_DIR)" .venv/bin/python scripts/parse_imports.py
112112

113+
.PHONY: kg-parse-api-calls
114+
kg-parse-api-calls: ## Parse Python API calls from Meta Kaggle Code for staged kernel versions
115+
META_KAGGLE_CODE_DIR="$(META_KAGGLE_CODE_DIR)" STAGE_DIR="$(STAGE_DIR)" .venv/bin/python scripts/parse_api_calls.py
116+
113117
.PHONY: kg-stage-all
114-
kg-stage-all: kg-stage kg-parse-imports ## Stage metadata and parsed import edges
118+
kg-stage-all: kg-stage kg-parse-imports kg-parse-api-calls ## Stage metadata, parsed import edges, and parsed API call edges
115119

116120
.PHONY: kg-load
117121
kg-load: ## Load the staged kernel subset, add constraints and indexes, and validate
@@ -138,12 +142,16 @@ comp-stage: ## Stage the post-2020 competition subset (with forum message text)
138142
comp-parse-imports: ## Parse library imports for the staged competition kernel versions
139143
META_KAGGLE_CODE_DIR="$(META_KAGGLE_CODE_DIR)" .venv/bin/python scripts/parse_imports.py --stage-dir "$(COMP_STAGE_DIR)"
140144

145+
.PHONY: comp-parse-api-calls
146+
comp-parse-api-calls: ## Parse Python API calls for the staged competition kernel versions
147+
META_KAGGLE_CODE_DIR="$(META_KAGGLE_CODE_DIR)" .venv/bin/python scripts/parse_api_calls.py --stage-dir "$(COMP_STAGE_DIR)"
148+
141149
.PHONY: comp-load
142150
comp-load: ## Load the staged competition subset, add constraints and indexes, and validate
143151
.venv/bin/python scripts/load_competition_kg.py --stage-dir "$(COMP_STAGE_DIR)" --db "$(COMP_DB)" --cli "$(ISSUNDB_CLI)" --map-size-gb $(MAP_SIZE_GB)
144152

145153
.PHONY: graph-kc
146-
graph-kc: kg-inspect comp-stage comp-parse-imports comp-load ## Build the Kaggle knowledge graph end to end into $(COMP_DB)
154+
graph-kc: kg-inspect comp-stage comp-parse-imports comp-parse-api-calls comp-load ## Build the Kaggle knowledge graph end to end into $(COMP_DB)
147155
@echo "comp-kg ready at $(COMP_DB)"
148156

149157
.PHONY: comp-cli

README.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,10 @@ export META_KAGGLE_DIR="/path/to/meta-kaggle"
3131

3232
Build the Kaggle knowledge graph and launch the CLI or MCP server:
3333

34-
- `make graph-kc` builds the competition-centered knowledge graph from the Meta Kaggle dataset.
34+
- `make graph-kc` builds the competition-centered knowledge graph from the Meta Kaggle dataset (needs `META_KAGGLE_CODE_DIR` for import and API call parsing).
3535
- `make comp-cli` opens the competition knowledge graph in the IssunDB CLI.
3636
- `make comp-mcp` runs the IssunDB MCP server for the competition knowledge graph.
37-
- `make graph-kernel` builds the kernel-centered knowledge graph from the Meta Kaggle dataset (needs `META_KAGGLE_CODE_DIR` for import parsing).
37+
- `make graph-kernel` builds the kernel-centered knowledge graph from the Meta Kaggle dataset (needs `META_KAGGLE_CODE_DIR` for import and API call parsing).
3838
- `make kernel-cli` opens the kernel knowledge graph in the IssunDB CLI.
3939
- `make kernel-mcp` runs the IssunDB MCP server for the kernel knowledge graph.
4040
- `make help` shows all available Makefile targets.
@@ -63,6 +63,13 @@ Replace `/path/to/kaggle-knowledge-graph` with the absolute path to your reposit
6363

6464
#### Knowledge Graph Schema
6565

66+
Both graphs share a code layer parsed from the Meta Kaggle Code dataset. Each staged kernel
67+
version links to the libraries it imports through `IMPORTS` edges, and to the qualified
68+
Python API calls it makes (for example, `sklearn.ensemble.RandomForestClassifier`) through
69+
`CALLS` edges. Each `ApiCall` node links back to its `Library` node through an `IN_LIBRARY`
70+
edge. The competition graph also stages the datasets and dataset versions used by its
71+
kernel versions, so leaderboard results, code, and data sources are connected in one graph.
72+
6673
##### Property Graph Model
6774

6875
<div align="center">

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ dependencies = [
88
"kaggle>=1.7.4.5",
99
"polars>=1.42.0",
1010
"python-dotenv>=1.1.0",
11+
"tree-sitter>=0.26.0",
12+
"tree-sitter-python>=0.25.0",
1113
]
1214

1315
[project.optional-dependencies]

scripts/import_to_issundb.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
("nodes_competition.parquet", "Competition"),
3131
("nodes_tag.parquet", "Tag"),
3232
("nodes_library.parquet", "Library"),
33+
("nodes_api_call.parquet", "ApiCall"),
3334
("nodes_forum.parquet", "Forum"),
3435
("nodes_forum_topic.parquet", "ForumTopic"),
3536
("nodes_forum_message.parquet", "ForumMessage"),
@@ -50,6 +51,8 @@
5051
"FORKED_FROM",
5152
),
5253
("edges_kernel_version_imports_library.parquet", "KernelVersion", "Library", "IMPORTS"),
54+
("edges_kernel_version_calls_api_call.parquet", "KernelVersion", "ApiCall", "CALLS"),
55+
("edges_api_call_in_library.parquet", "ApiCall", "Library", "IN_LIBRARY"),
5356
(
5457
"edges_kernel_version_uses_dataset_version.parquet",
5558
"KernelVersion",

scripts/load_competition_kg.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@
3232
("nodes_submission.parquet", "Submission"),
3333
("nodes_kernel.parquet", "Kernel"),
3434
("nodes_kernel_version.parquet", "KernelVersion"),
35+
("nodes_dataset.parquet", "Dataset"),
36+
("nodes_dataset_version.parquet", "DatasetVersion"),
3537
("nodes_tag.parquet", "Tag"),
3638
("nodes_forum.parquet", "Forum"),
3739
("nodes_forum_topic.parquet", "ForumTopic"),
3840
("nodes_forum_message.parquet", "ForumMessage"),
3941
("nodes_library.parquet", "Library"),
42+
("nodes_api_call.parquet", "ApiCall"),
4043
("nodes_organization.parquet", "Organization"),
4144
]
4245

@@ -82,7 +85,24 @@
8285
"USES_COMPETITION",
8386
),
8487
("edges_kernel_version_imports_library.parquet", "KernelVersion", "Library", "IMPORTS"),
88+
("edges_kernel_version_calls_api_call.parquet", "KernelVersion", "ApiCall", "CALLS"),
89+
("edges_api_call_in_library.parquet", "ApiCall", "Library", "IN_LIBRARY"),
90+
(
91+
"edges_kernel_version_uses_dataset_version.parquet",
92+
"KernelVersion",
93+
"DatasetVersion",
94+
"USES_DATASET_VERSION",
95+
),
96+
("edges_dataset_has_version.parquet", "Dataset", "DatasetVersion", "HAS_VERSION"),
97+
("edges_dataset_current_version.parquet", "Dataset", "DatasetVersion", "CURRENT_VERSION"),
98+
(
99+
"edges_dataset_owned_by_organization.parquet",
100+
"Dataset",
101+
"Organization",
102+
"OWNED_BY_ORGANIZATION",
103+
),
85104
("edges_kernel_tagged_with_tag.parquet", "Kernel", "Tag", "TAGGED_WITH"),
105+
("edges_dataset_tagged_with_tag.parquet", "Dataset", "Tag", "TAGGED_WITH"),
86106
("edges_competition_tagged_with_tag.parquet", "Competition", "Tag", "TAGGED_WITH"),
87107
("edges_competition_has_forum.parquet", "Competition", "Forum", "HAS_FORUM"),
88108
(
@@ -111,11 +131,12 @@
111131
("Competition", "Title"),
112132
("ForumTopic", "Title"),
113133
("KernelVersion", "Title"),
134+
("DatasetVersion", "Title"),
114135
("User", "DisplayName"),
115136
("Organization", "Name"),
116137
]
117138

118-
MISSING_HINT = "run `make comp-stage comp-parse-imports` first"
139+
MISSING_HINT = "run `make comp-stage comp-parse-imports comp-parse-api-calls` first"
119140

120141

121142
def parse_args() -> argparse.Namespace:

scripts/parse_api_calls.py

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
"""Parse Python API invocations for staged kernel versions with Tree-sitter.
2+
3+
The parser builds an import alias map for each file and keeps only calls whose
4+
root name resolves to an imported module. A call such as `np.mean(x)` after
5+
`import numpy as np` becomes the qualified name `numpy.mean`, while calls to
6+
local functions and methods on local variables are excluded. Tree-sitter
7+
tolerates broken code blocks, so partial notebooks still yield calls from the
8+
cells that parse.
9+
10+
Outputs, written to the stage directory:
11+
- `nodes_api_call.parquet` with the qualified call name as `Id` and the
12+
top-level library as `Library`.
13+
- `edges_kernel_version_calls_api_call.parquet` linking kernel versions to the
14+
API calls they make.
15+
- `edges_api_call_in_library.parquet` linking API calls to staged libraries.
16+
17+
Run `parse_imports.py` first; the library edge endpoints are validated against
18+
`nodes_library.parquet` so no staged edge points to a missing library node.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
import argparse
24+
from pathlib import Path
25+
from typing import Iterator
26+
27+
import polars as pl
28+
import tree_sitter_python
29+
from tree_sitter import Language, Node, Parser
30+
31+
from parse_imports import (
32+
DEFAULT_CODE_DIR,
33+
DEFAULT_STAGE_DIR,
34+
candidate_paths,
35+
code_text,
36+
load_kernel_version_ids,
37+
)
38+
39+
PYTHON_SUFFIXES = {".py", ".ipynb"}
40+
41+
_PARSER = Parser(Language(tree_sitter_python.language()))
42+
43+
44+
def parse_args() -> argparse.Namespace:
45+
parser = argparse.ArgumentParser(description=__doc__)
46+
parser.add_argument("--code-dir", type=Path, default=DEFAULT_CODE_DIR)
47+
parser.add_argument("--stage-dir", type=Path, default=DEFAULT_STAGE_DIR)
48+
return parser.parse_args()
49+
50+
51+
def _text(node: Node) -> str:
52+
return (node.text or b"").decode("utf-8", errors="ignore")
53+
54+
55+
def _walk(root: Node) -> Iterator[Node]:
56+
stack = [root]
57+
while stack:
58+
node = stack.pop()
59+
yield node
60+
stack.extend(node.named_children)
61+
62+
63+
def _aliased_import_parts(node: Node) -> tuple[str, str] | None:
64+
"""Return (alias, name) for an `aliased_import` node, or None if malformed."""
65+
name = node.child_by_field_name("name")
66+
alias = node.child_by_field_name("alias")
67+
if name is None or alias is None:
68+
return None
69+
return _text(alias), _text(name)
70+
71+
72+
def _collect_import_statement(node: Node, aliases: dict[str, str]) -> None:
73+
for child in node.named_children:
74+
if child.type == "dotted_name":
75+
top = _text(child).split(".", maxsplit=1)[0]
76+
aliases[top] = top
77+
elif child.type == "aliased_import":
78+
parts = _aliased_import_parts(child)
79+
if parts is not None:
80+
aliases[parts[0]] = parts[1]
81+
82+
83+
def _collect_import_from_statement(node: Node, aliases: dict[str, str]) -> None:
84+
module = node.child_by_field_name("module_name")
85+
if module is None or module.type != "dotted_name":
86+
return
87+
module_name = _text(module)
88+
for child in node.named_children:
89+
if child.id == module.id:
90+
continue
91+
if child.type == "dotted_name":
92+
aliases[_text(child)] = f"{module_name}.{_text(child)}"
93+
elif child.type == "aliased_import":
94+
parts = _aliased_import_parts(child)
95+
if parts is not None:
96+
aliases[parts[0]] = f"{module_name}.{parts[1]}"
97+
98+
99+
def _aliases_from_root(root: Node) -> dict[str, str]:
100+
aliases: dict[str, str] = {}
101+
for node in _walk(root):
102+
if node.type == "import_statement":
103+
_collect_import_statement(node, aliases)
104+
elif node.type == "import_from_statement":
105+
_collect_import_from_statement(node, aliases)
106+
return aliases
107+
108+
109+
def import_aliases(text: str) -> dict[str, str]:
110+
"""Map each locally bound import name to the qualified name it stands for."""
111+
tree = _PARSER.parse(text.encode("utf-8"))
112+
return _aliases_from_root(tree.root_node)
113+
114+
115+
def _call_chain(node: Node | None) -> list[str] | None:
116+
"""Return the dotted name chain of a call target, or None if it is not one."""
117+
if node is None:
118+
return None
119+
if node.type == "identifier":
120+
return [_text(node)]
121+
if node.type == "attribute":
122+
base = _call_chain(node.child_by_field_name("object"))
123+
attribute = node.child_by_field_name("attribute")
124+
if base is None or attribute is None:
125+
return None
126+
return [*base, _text(attribute)]
127+
return None
128+
129+
130+
def api_calls(text: str) -> set[str]:
131+
"""Return the qualified names of calls that resolve to an imported module."""
132+
tree = _PARSER.parse(text.encode("utf-8"))
133+
root = tree.root_node
134+
aliases = _aliases_from_root(root)
135+
calls: set[str] = set()
136+
for node in _walk(root):
137+
if node.type != "call":
138+
continue
139+
chain = _call_chain(node.child_by_field_name("function"))
140+
if not chain:
141+
continue
142+
head, *rest = chain
143+
if head not in aliases:
144+
continue
145+
calls.add(".".join([aliases[head], *rest]))
146+
return calls
147+
148+
149+
def library_of(qualified_name: str) -> str:
150+
"""Return the lowercased top-level library of a qualified call name."""
151+
return qualified_name.split(".", maxsplit=1)[0].lower()
152+
153+
154+
def load_staged_library_ids(stage_dir: Path) -> set[str]:
155+
path = stage_dir / "nodes_library.parquet"
156+
if not path.exists():
157+
raise SystemExit(f"missing {path} (run `parse_imports.py` on this stage directory first)")
158+
return set(pl.read_parquet(path, columns=["Id"])["Id"].cast(pl.Utf8))
159+
160+
161+
def main() -> None:
162+
args = parse_args()
163+
wanted_ids = load_kernel_version_ids(args.stage_dir)
164+
staged_libraries = load_staged_library_ids(args.stage_dir)
165+
rows: list[tuple[str, str]] = []
166+
167+
for kernel_version_id in sorted(wanted_ids, key=int):
168+
for path in candidate_paths(args.code_dir, kernel_version_id):
169+
if path.suffix.lower() not in PYTHON_SUFFIXES or not path.exists():
170+
continue
171+
try:
172+
text = code_text(path)
173+
except OSError:
174+
continue
175+
for call in api_calls(text):
176+
rows.append((kernel_version_id, call))
177+
178+
calls = pl.DataFrame(
179+
rows,
180+
schema={"KernelVersionId": pl.Utf8, "ApiCall": pl.Utf8},
181+
orient="row",
182+
)
183+
calls = calls.unique().sort(["KernelVersionId", "ApiCall"])
184+
185+
nodes = (
186+
calls.select(pl.col("ApiCall").alias("Id"))
187+
.unique()
188+
.with_columns(pl.col("Id").map_elements(library_of, return_dtype=pl.Utf8).alias("Library"))
189+
.sort("Id")
190+
)
191+
nodes.write_parquet(args.stage_dir / "nodes_api_call.parquet")
192+
193+
calls.rename(
194+
{"KernelVersionId": "from_kernel_version_id", "ApiCall": "to_api_call_id"}
195+
).write_parquet(args.stage_dir / "edges_kernel_version_calls_api_call.parquet")
196+
197+
in_library = (
198+
nodes.filter(pl.col("Library").is_in(sorted(staged_libraries)))
199+
.select(
200+
pl.col("Id").alias("from_api_call_id"),
201+
pl.col("Library").alias("to_library_id"),
202+
)
203+
.sort("from_api_call_id")
204+
)
205+
in_library.write_parquet(args.stage_dir / "edges_api_call_in_library.parquet")
206+
207+
print(
208+
f"Wrote {nodes.height} API call nodes, {calls.height} call edges, "
209+
f"and {in_library.height} library edges"
210+
)
211+
212+
213+
if __name__ == "__main__":
214+
main()

0 commit comments

Comments
 (0)