@dataclass
class CGEntity:
    id: str             # stable id (e.g., fqn)
    kind: str           # module|function|class|test
    name: str
    file: str           # absolute path
    owner: Optional[str]
    start_line: int
    end_line: int

@dataclass
class CGEdge:
    src: str            # CGEntity.id
    dst: str            # CGEntity.id
    type: str           # imports|calls|owns|tests

class CodeGraph:
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.root = os.path.abspath(repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self.entities_by_id: Dict[str, CGEntity] = {}
        self.edges_list: List[CGEdge] = []
        self._file_hash: Dict[str, str] = {}
        self._id_by_module: Dict[str, str] = {}
        self._ids_by_file: Dict[str, List[str]] = {}
        self._index_identifiers: Dict[str, List[str]] = {}

    # Build
    def build(self) -> "CodeGraph":
        py_files = self._discover_py_files(self.root, self.ignore_rules)
        for abs_fp in py_files:
            mod = self._module_name_for(abs_fp)
            mid = f"py:{mod}"
            self._id_by_module[mod] = mid
            ent = CGEntity(
                id=mid, kind="module", name=mod, file=abs_fp, owner=None,
                start_line=1, end_line=self._safe_count_lines(abs_fp),
            )
            self.entities_by_id[mid] = ent
            self._ids_by_file.setdefault(abs_fp, []).append(mid)
            self._index_identifiers.setdefault(mod.lower(), []).append(mid)
            # Parse AST for defs/imports/calls
            try:
                with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                    src = fh.read()
                tree = ast.parse(src)


build

@dataclass
class CGEdge:
    src: str            # CGEntity.id
    dst: str            # CGEntity.id
    type: str           # imports|calls|owns|tests

class CodeGraph:
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.root = os.path.abspath(repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self.entities_by_id: Dict[str, CGEntity] = {}
        self.edges_list: List[CGEdge] = []
        self._file_hash: Dict[str, str] = {}
        self._id_by_module: Dict[str, str] = {}
        self._ids_by_file: Dict[str, List[str]] = {}
        self._index_identifiers: Dict[str, List[str]] = {}

    # Build
    def build(self) -> "CodeGraph":
        py_files = self._discover_py_files(self.root, self.ignore_rules)
        for abs_fp in py_files:
            mod = self._module_name_for(abs_fp)
            mid = f"py:{mod}"
            self._id_by_module[mod] = mid
            ent = CGEntity(
                id=mid, kind="module", name=mod, file=abs_fp, owner=None,
                start_line=1, end_line=self._safe_count_lines(abs_fp),
            )
            self.entities_by_id[mid] = ent
            self._ids_by_file.setdefault(abs_fp, []).append(mid)
            self._index_identifiers.setdefault(mod.lower(), []).append(mid)
            # Parse AST for defs/imports/calls
            try:
                with open(abs_fp, "r", encoding="utf-8", errors="ignore") as fh:
                    src = fh.read()
                tree = ast.parse(src)
            except Exception:
                tree = None
            if tree is None:
                continue
            # functions/classes
            for node in ast.walk(tree):
                if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
                    name = getattr(node, "name", "")
                    a = int(getattr(node, "lineno", 1))
                    b = int(getattr(node, "end_lineno", a))
                    fid = f"py:{mod}.{name}"
                    self.entities_by_id[fid] = CGEntity(
                        id=fid, kind="function", name=name, file=abs_fp, owner=mid,
                        start_line=a, end_line=b,
                    )
                    self._ids_by_file.setdefault(abs_fp, []).append(fid)
                    self.edges_list.append(CGEdge(src=mid, dst=fid, type="owns"))
                    self._index_identifiers.setdefault(name.lower(), []).append(fid)
                elif isinstance(node, ast.ClassDef):
                    name = getattr(node, "name", "")
                    a = int(getattr(node, "lineno", 1))
                    b = int(getattr(node, "end_lineno", a))
                    cid = f"py:{mod}.{name}"
                    self.entities_by_id[cid] = CGEntity(
                        id=cid, kind="class", name=name, file=abs_fp, owner=mid,
                        start_line=a, end_line=b,
                    )
                    self._ids_by_file.setdefault(abs_fp, []).append(cid)
                    self.edges_list.append(CGEdge(src=mid, dst=cid, type="owns"))
                    self._index_identifiers.setdefault(name.lower(), []).append(cid)
            # imports (module-level)
            try:
                for node in ast.walk(tree):
                    if isinstance(node, ast.Import):
                        for alias in node.names:
                            im = alias.name
                            if not im:
                                continue
                            tgt_mod = im
                            tid = f"py:{tgt_mod}"
                            self.edges_list.append(CGEdge(src=mid, dst=tid, type="imports"))
                    elif isinstance(node, ast.ImportFrom):
                        im = node.module or ""
                        if not im:
                            continue
                        tgt_mod = im
                        tid = f"py:{tgt_mod}"
                        self.edges_list.append(CGEdge(src=mid, dst=tid, type="imports"))
            except Exception:
                pass
            # calls (best-effort): record identifiers used in Call nodes
            try:
                for node in ast.walk(tree):
                    if isinstance(node, ast.Call):
                        fn = getattr(node, "func", None)
                        name = None
                        if isinstance(fn, ast.Attribute):
                            name = getattr(fn, "attr", None)
                        elif isinstance(fn, ast.Name):
                            name = fn.id
                        if name:
                            lid = str(name).lower()
                            for cand in self._index_identifiers.get(lid, []):
                                self.edges_list.append(CGEdge(src=mid, dst=cand, type="calls"))
            except Exception:
                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))


entities

                        name = None
                        if isinstance(fn, ast.Attribute):
                            name = getattr(fn, "attr", None)
                        elif isinstance(fn, ast.Name):
                            name = fn.id
                        if name:
                            lid = str(name).lower()
                            for cand in self._index_identifiers.get(lid, []):
                                self.edges_list.append(CGEdge(src=mid, dst=cand, type="calls"))
            except Exception:
                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []


edges

                        elif isinstance(fn, ast.Name):
                            name = fn.id
                        if name:
                            lid = str(name).lower()
                            for cand in self._index_identifiers.get(lid, []):
                                self.edges_list.append(CGEdge(src=mid, dst=cand, type="calls"))
            except Exception:
                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue


                pass
            # tests tag
            base = os.path.basename(abs_fp)
            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out


            if base.startswith("test_") or base.endswith("_test.py"):
                self.entities_by_id[mid].kind = "test_module"
        # finalize file hashes
        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots


        self._precompute_hashes(py_files)
        return self

    # Public accessors
    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]


    def entities(self) -> Iterable[CGEntity]:
        return self.entities_by_id.values()

    def edges(self) -> Iterable[CGEdge]:
        # Filter edges whose endpoints are known (post totality)
        known = set(self.entities_by_id.keys())
        for e in self.edges_list:
            if (e.src in known) and (e.dst in known):
                yield e

    def file_hash(self, abs_path: str) -> str:
        return self._file_hash.get(abs_path) or ""

    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:


    def ids_for_file(self, abs_path: str) -> List[str]:
        return list(self._ids_by_file.get(abs_path, []))

    def find_identifier_ids(self, token: str) -> List[str]:
        return list(self._index_identifiers.get(token.lower(), []))

    # Helpers
    def _discover_py_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:
                    raw = fh.read()
                h = hashlib.sha256(raw).hexdigest()
            except Exception:
                h = ""
            self._file_hash[fp] = h


        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            # naive ignore: drop segments that contain any ignore pattern
            if any(ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                if not fn.endswith(".py"):
                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:
                    raw = fh.read()
                h = hashlib.sha256(raw).hexdigest()
            except Exception:
                h = ""
            self._file_hash[fp] = h


                    continue
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _module_name_for(self, abs_file: str) -> str:
        # repo-relative without extension, path with dots
        rel = os.path.relpath(abs_file, self.root).replace("\\", "/")
        if rel.endswith(".py"):
            rel = rel[:-3]
        parts = [p for p in rel.split("/") if p and p != "__init__"]
        return ".".join(parts) or os.path.splitext(os.path.basename(abs_file))[0]

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _precompute_hashes(self, files: List[str]) -> None:
        for fp in files:
            try:
                with open(fp, "rb") as fh:
                    raw = fh.read()
                h = hashlib.sha256(raw).hexdigest()
            except Exception:
                h = ""
            self._file_hash[fp] = h


from __future__ import annotations

from typing import Any, Dict, List

def enforce(policy: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]:
	"""
	Apply simple safety gates like 'apply_patch requires tests_green' etc.
	Returns {'ok': bool, 'missing': [..]}.
	"""
	reqs: List[str] = list(policy.get("requires", []))
	passed = []
	missing = []
	for r in reqs:
		if context.get(r, False):
			passed.append(r)
		else:
			missing.append(r)
	return {"ok": len(missing) == 0, "missing": missing}


from __future__ import annotations

import subprocess
from typing import Any, Dict, List, Optional

def run_benchmark_verifier(cmd: Optional[List[str]], env: Optional[Dict[str, str]] = None, timeout_sec: int = 120) -> bool:
	"""
	Runs an external verifier command (official checker) and returns True on success.
	When cmd is None, returns True (no-op) for smoke runs.
	"""
	if not cmd:
		return True
	try:
		rc = subprocess.call(cmd, env=env, timeout=timeout_sec)  # type: ignore
	except Exception:
		return False
	return rc == 0


from __future__ import annotations

import os
import sys
import json
from pathlib import Path
from typing import List, Dict, Any

def synth_benchmark_heads(adapters_dir: str, datasets: List[str]) -> List[str]:
	"""
	Smoke-level synthesis of benchmark-aware adapter heads.
	Creates tiny shard stubs under adapters/shards/* so run.py can pick them up.
	"""
	adir = Path(adapters_dir)
	shards_dir = adir / "shards"
	shards_dir.mkdir(parents=True, exist_ok=True)
	created: List[str] = []
	for spec in datasets:
		ds_name = spec.replace(":", "_").replace("/", "_")
		path = shards_dir / f"benchmark_{ds_name}_head.json"
		obj: Dict[str, Any] = {
			"schema_version": 1,
			"type": "benchmark_head",
			"dataset": spec,
			"rank": 8,
			"prior": {"recent_pass_boost": 0.2},
		}
		path.write_text(json.dumps(obj, indent=2), encoding="utf-8")
		created.append(str(path))
	return created

def main() -> None:
	if len(sys.argv) < 3:
		print("usage: python benchmark_adapter_synth.py <adapters_dir> <datasets_csv>", file=sys.stderr)
		sys.exit(2)
	adapters_dir = sys.argv[1]
	datasets_csv = sys.argv[2]
	datasets = [s.strip() for s in datasets_csv.split(",") if s.strip()]
	paths = synth_benchmark_heads(adapters_dir, datasets)
	print("\n".join(paths))

if __name__ == "__main__":
	main()


main

	"""
	adir = Path(adapters_dir)
	shards_dir = adir / "shards"
	shards_dir.mkdir(parents=True, exist_ok=True)
	created: List[str] = []
	for spec in datasets:
		ds_name = spec.replace(":", "_").replace("/", "_")
		path = shards_dir / f"benchmark_{ds_name}_head.json"
		obj: Dict[str, Any] = {
			"schema_version": 1,
			"type": "benchmark_head",
			"dataset": spec,
			"rank": 8,
			"prior": {"recent_pass_boost": 0.2},
		}
		path.write_text(json.dumps(obj, indent=2), encoding="utf-8")
		created.append(str(path))
	return created

def main() -> None:
	if len(sys.argv) < 3:
		print("usage: python benchmark_adapter_synth.py <adapters_dir> <datasets_csv>", file=sys.stderr)
		sys.exit(2)
	adapters_dir = sys.argv[1]
	datasets_csv = sys.argv[2]
	datasets = [s.strip() for s in datasets_csv.split(",") if s.strip()]
	paths = synth_benchmark_heads(adapters_dir, datasets)
	print("\n".join(paths))

if __name__ == "__main__":
	main()


from __future__ import annotations

import os
from typing import Iterable, List, Tuple, Dict, Optional, Set

from modules.program_graph import (
    Entity,
    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)
from .codegraph_core import CodeGraph, CGEntity, CGEdge
from .repo_graph import RepoGraph, artifact_uri, program_id_for_repo, parse_program_uri

def _entity_uri(program_id: str, cg: CGEntity) -> str:
    if cg.kind == "module" or cg.kind == "test_module":
        resource = cg.name
        kind = "module"
    else:
        resource = cg.id.split("py:", 1)[-1]
        kind = cg.kind
    return f"program://{program_id}/{kind}/{resource}#L{cg.start_line}-L{cg.end_line}"

class PythonRepoGraph(RepoGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        super().__init__(repo_root, ignore=ignore)
        self._cg = CodeGraph(self.repo_root, ignore=ignore).build()
        self._ent_cache: Optional[List[Entity]] = None
        self._edge_cache: Optional[List[Edge]] = None
        self._ent_by_id: Dict[str, CGEntity] = {e.id: e for e in self._cg.entities_by_id.values()}
        self._ids_by_name: Dict[str, List[str]] = {}
        for e in self._cg.entities():
            self._ids_by_name.setdefault(e.name.lower(), []).append(e.id)

    def entities(self) -> Iterable[Entity]:
        if self._ent_cache is None:
            out: List[Entity] = []
            for cg in self._cg.entities():
                uri = _entity_uri(self.program_id, cg)
                out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
            self._ent_cache = out
        return list(self._ent_cache)

    def edges(self) -> Iterable[Edge]:
        if self._edge_cache is None:
            out: List[Edge] = []
            seen: Set[Tuple[str, str, str]] = set()
            for ce in self._cg.edges():
                key = (ce.src, ce.dst, ce.type)
                if key in seen:
                    continue
                seen.add(key)
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
            self._edge_cache = out
        return list(self._edge_cache)

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        t = (token or "").strip()
        if not t:
            return []
        ids = self._cg.find_identifier_ids(t) or []
        out: List[Tuple[EntityId, Span]] = []
        for eid in ids:
            cg = self._ent_by_id.get(eid)
            if not cg:
                continue
            out.append((eid, Span(start_line=int(cg.start_line), end_line=int(cg.end_line))))
        return out

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("source", "artifact"):
            return []
        out: List[Artifact] = []
        seen: Set[str] = set()
        for e in self._cg.entities():
            fp = e.file
            if fp in seen:
                continue
            seen.add(fp)
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            art_uri = artifact_uri(self.program_id, rel)
            out.append(Artifact(uri=art_uri, type="source", hash=self._cg.file_hash(fp), span=None))
        return out

    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        if kind in ("module", "function", "class"):
            ent_id = f"py:{resource}"
        else:
            ent_id = f"py:{resource}"
        base = self._ent_by_id.get(ent_id)
        if not base:
            raise KeyError(f"entity not found for uri resource: {resource}")
        abs_fp = base.file
        a = int(span[0]) if span else int(base.start_line)
        b = int(span[1]) if span else int(base.end_line)
        rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
        art_uri = artifact_uri(self.program_id, rel)
        return ResolvedAnchor(artifact_uri=art_uri, span=Span(start_line=a, end_line=b), hash=self._cg.file_hash(abs_fp))

    def subgraph(self, seeds: List[EntityId], radius: int) -> "PythonRepoGraph":
        if not seeds or radius <= 0:
            return self
        # Build adjacency over current edge view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)

class _FilteredPythonRepoGraph(PythonRepoGraph):
    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)
            out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
        return out

    def edges(self) -> Iterable[Edge]:
        out: List[Edge] = []
        inc = self._include_ids
        for ce in self._cg.edges():
            if (ce.src in inc) and (ce.dst in inc):
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
        return out


from __future__ import annotations

import os
from typing import Iterable, List, Tuple, Dict, Optional, Set

from modules.program_graph import (
    Entity,
    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)
from .codegraph_core import CodeGraph, CGEntity, CGEdge
from .repo_graph import RepoGraph, artifact_uri, program_id_for_repo, parse_program_uri

def _entity_uri(program_id: str, cg: CGEntity) -> str:
    if cg.kind == "module" or cg.kind == "test_module":
        resource = cg.name
        kind = "module"
    else:
        resource = cg.id.split("py:", 1)[-1]
        kind = cg.kind
    return f"program://{program_id}/{kind}/{resource}#L{cg.start_line}-L{cg.end_line}"

class PythonRepoGraph(RepoGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        super().__init__(repo_root, ignore=ignore)
        self._cg = CodeGraph(self.repo_root, ignore=ignore).build()
        self._ent_cache: Optional[List[Entity]] = None
        self._edge_cache: Optional[List[Edge]] = None
        self._ent_by_id: Dict[str, CGEntity] = {e.id: e for e in self._cg.entities_by_id.values()}
        self._ids_by_name: Dict[str, List[str]] = {}
        for e in self._cg.entities():
            self._ids_by_name.setdefault(e.name.lower(), []).append(e.id)

    def entities(self) -> Iterable[Entity]:
        if self._ent_cache is None:
            out: List[Entity] = []
            for cg in self._cg.entities():
                uri = _entity_uri(self.program_id, cg)
                out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
            self._ent_cache = out


PythonRepoGraph

    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)
from .codegraph_core import CodeGraph, CGEntity, CGEdge
from .repo_graph import RepoGraph, artifact_uri, program_id_for_repo, parse_program_uri

def _entity_uri(program_id: str, cg: CGEntity) -> str:
    if cg.kind == "module" or cg.kind == "test_module":
        resource = cg.name
        kind = "module"
    else:
        resource = cg.id.split("py:", 1)[-1]
        kind = cg.kind
    return f"program://{program_id}/{kind}/{resource}#L{cg.start_line}-L{cg.end_line}"

class PythonRepoGraph(RepoGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        super().__init__(repo_root, ignore=ignore)
        self._cg = CodeGraph(self.repo_root, ignore=ignore).build()
        self._ent_cache: Optional[List[Entity]] = None
        self._edge_cache: Optional[List[Edge]] = None
        self._ent_by_id: Dict[str, CGEntity] = {e.id: e for e in self._cg.entities_by_id.values()}
        self._ids_by_name: Dict[str, List[str]] = {}
        for e in self._cg.entities():
            self._ids_by_name.setdefault(e.name.lower(), []).append(e.id)

    def entities(self) -> Iterable[Entity]:
        if self._ent_cache is None:
            out: List[Entity] = []
            for cg in self._cg.entities():
                uri = _entity_uri(self.program_id, cg)
                out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
            self._ent_cache = out
        return list(self._ent_cache)

    def edges(self) -> Iterable[Edge]:
        if self._edge_cache is None:
            out: List[Edge] = []
            seen: Set[Tuple[str, str, str]] = set()
            for ce in self._cg.edges():
                key = (ce.src, ce.dst, ce.type)
                if key in seen:
                    continue
                seen.add(key)
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
            self._edge_cache = out
        return list(self._edge_cache)

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        t = (token or "").strip()
        if not t:
            return []
        ids = self._cg.find_identifier_ids(t) or []
        out: List[Tuple[EntityId, Span]] = []
        for eid in ids:
            cg = self._ent_by_id.get(eid)
            if not cg:
                continue
            out.append((eid, Span(start_line=int(cg.start_line), end_line=int(cg.end_line))))
        return out

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("source", "artifact"):
            return []
        out: List[Artifact] = []
        seen: Set[str] = set()
        for e in self._cg.entities():
            fp = e.file
            if fp in seen:
                continue
            seen.add(fp)
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            art_uri = artifact_uri(self.program_id, rel)
            out.append(Artifact(uri=art_uri, type="source", hash=self._cg.file_hash(fp), span=None))
        return out

    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        if kind in ("module", "function", "class"):
            ent_id = f"py:{resource}"
        else:
            ent_id = f"py:{resource}"
        base = self._ent_by_id.get(ent_id)
        if not base:
            raise KeyError(f"entity not found for uri resource: {resource}")
        abs_fp = base.file
        a = int(span[0]) if span else int(base.start_line)
        b = int(span[1]) if span else int(base.end_line)
        rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
        art_uri = artifact_uri(self.program_id, rel)
        return ResolvedAnchor(artifact_uri=art_uri, span=Span(start_line=a, end_line=b), hash=self._cg.file_hash(abs_fp))

    def subgraph(self, seeds: List[EntityId], radius: int) -> "PythonRepoGraph":
        if not seeds or radius <= 0:
            return self
        # Build adjacency over current edge view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)

class _FilteredPythonRepoGraph(PythonRepoGraph):
    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)


        if not seeds or radius <= 0:
            return self
        # Build adjacency over current edge view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)

class _FilteredPythonRepoGraph(PythonRepoGraph):
    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)
            out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
        return out

    def edges(self) -> Iterable[Edge]:
        out: List[Edge] = []
        inc = self._include_ids
        for ce in self._cg.edges():
            if (ce.src in inc) and (ce.dst in inc):
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
        return out


            return self
        # Build adjacency over current edge view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)

class _FilteredPythonRepoGraph(PythonRepoGraph):
    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)
            out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
        return out

    def edges(self) -> Iterable[Edge]:
        out: List[Edge] = []
        inc = self._include_ids
        for ce in self._cg.edges():
            if (ce.src in inc) and (ce.dst in inc):
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
        return out


entities

                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)

class _FilteredPythonRepoGraph(PythonRepoGraph):
    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)
            out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
        return out

    def edges(self) -> Iterable[Edge]:
        out: List[Edge] = []
        inc = self._include_ids
        for ce in self._cg.edges():
            if (ce.src in inc) and (ce.dst in inc):
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
        return out


edges

    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)
            out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
        return out

    def edges(self) -> Iterable[Edge]:
        out: List[Edge] = []
        inc = self._include_ids
        for ce in self._cg.edges():
            if (ce.src in inc) and (ce.dst in inc):
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
        return out


            out: List[Entity] = []
            for cg in self._cg.entities():
                uri = _entity_uri(self.program_id, cg)
                out.append(Entity(uri=uri, id=cg.id, kind=cg.kind if cg.kind != "test_module" else "module", name=cg.name, owner=cg.owner or None, labels=None))
            self._ent_cache = out
        return list(self._ent_cache)

    def edges(self) -> Iterable[Edge]:
        if self._edge_cache is None:
            out: List[Edge] = []
            seen: Set[Tuple[str, str, str]] = set()
            for ce in self._cg.edges():
                key = (ce.src, ce.dst, ce.type)
                if key in seen:
                    continue
                seen.add(key)
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
            self._edge_cache = out
        return list(self._edge_cache)

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        t = (token or "").strip()
        if not t:
            return []
        ids = self._cg.find_identifier_ids(t) or []
        out: List[Tuple[EntityId, Span]] = []
        for eid in ids:
            cg = self._ent_by_id.get(eid)
            if not cg:
                continue
            out.append((eid, Span(start_line=int(cg.start_line), end_line=int(cg.end_line))))
        return out

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("source", "artifact"):
            return []
        out: List[Artifact] = []
        seen: Set[str] = set()
        for e in self._cg.entities():
            fp = e.file
            if fp in seen:
                continue
            seen.add(fp)
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            art_uri = artifact_uri(self.program_id, rel)
            out.append(Artifact(uri=art_uri, type="source", hash=self._cg.file_hash(fp), span=None))
        return out

    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        if kind in ("module", "function", "class"):
            ent_id = f"py:{resource}"
        else:


artifacts

                if key in seen:
                    continue
                seen.add(key)
                out.append(Edge(src=ce.src, dst=ce.dst, type=ce.type, meta=None))
            self._edge_cache = out
        return list(self._edge_cache)

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        t = (token or "").strip()
        if not t:
            return []
        ids = self._cg.find_identifier_ids(t) or []
        out: List[Tuple[EntityId, Span]] = []
        for eid in ids:
            cg = self._ent_by_id.get(eid)
            if not cg:
                continue
            out.append((eid, Span(start_line=int(cg.start_line), end_line=int(cg.end_line))))
        return out

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("source", "artifact"):
            return []
        out: List[Artifact] = []
        seen: Set[str] = set()
        for e in self._cg.entities():
            fp = e.file
            if fp in seen:
                continue
            seen.add(fp)
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            art_uri = artifact_uri(self.program_id, rel)
            out.append(Artifact(uri=art_uri, type="source", hash=self._cg.file_hash(fp), span=None))
        return out

    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        if kind in ("module", "function", "class"):
            ent_id = f"py:{resource}"
        else:
            ent_id = f"py:{resource}"
        base = self._ent_by_id.get(ent_id)
        if not base:
            raise KeyError(f"entity not found for uri resource: {resource}")
        abs_fp = base.file
        a = int(span[0]) if span else int(base.start_line)
        b = int(span[1]) if span else int(base.end_line)
        rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
        art_uri = artifact_uri(self.program_id, rel)
        return ResolvedAnchor(artifact_uri=art_uri, span=Span(start_line=a, end_line=b), hash=self._cg.file_hash(abs_fp))

    def subgraph(self, seeds: List[EntityId], radius: int) -> "PythonRepoGraph":
        if not seeds or radius <= 0:
            return self
        # Build adjacency over current edge view


            if not cg:
                continue
            out.append((eid, Span(start_line=int(cg.start_line), end_line=int(cg.end_line))))
        return out

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("source", "artifact"):
            return []
        out: List[Artifact] = []
        seen: Set[str] = set()
        for e in self._cg.entities():
            fp = e.file
            if fp in seen:
                continue
            seen.add(fp)
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            art_uri = artifact_uri(self.program_id, rel)
            out.append(Artifact(uri=art_uri, type="source", hash=self._cg.file_hash(fp), span=None))
        return out

    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        if kind in ("module", "function", "class"):
            ent_id = f"py:{resource}"
        else:
            ent_id = f"py:{resource}"
        base = self._ent_by_id.get(ent_id)
        if not base:
            raise KeyError(f"entity not found for uri resource: {resource}")
        abs_fp = base.file
        a = int(span[0]) if span else int(base.start_line)
        b = int(span[1]) if span else int(base.end_line)
        rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
        art_uri = artifact_uri(self.program_id, rel)
        return ResolvedAnchor(artifact_uri=art_uri, span=Span(start_line=a, end_line=b), hash=self._cg.file_hash(abs_fp))

    def subgraph(self, seeds: List[EntityId], radius: int) -> "PythonRepoGraph":
        if not seeds or radius <= 0:
            return self
        # Build adjacency over current edge view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)


subgraph

            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            art_uri = artifact_uri(self.program_id, rel)
            out.append(Artifact(uri=art_uri, type="source", hash=self._cg.file_hash(fp), span=None))
        return out

    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        if kind in ("module", "function", "class"):
            ent_id = f"py:{resource}"
        else:
            ent_id = f"py:{resource}"
        base = self._ent_by_id.get(ent_id)
        if not base:
            raise KeyError(f"entity not found for uri resource: {resource}")
        abs_fp = base.file
        a = int(span[0]) if span else int(base.start_line)
        b = int(span[1]) if span else int(base.end_line)
        rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
        art_uri = artifact_uri(self.program_id, rel)
        return ResolvedAnchor(artifact_uri=art_uri, span=Span(start_line=a, end_line=b), hash=self._cg.file_hash(abs_fp))

    def subgraph(self, seeds: List[EntityId], radius: int) -> "PythonRepoGraph":
        if not seeds or radius <= 0:
            return self
        # Build adjacency over current edge view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return _FilteredPythonRepoGraph(self, include_ids=seen)

class _FilteredPythonRepoGraph(PythonRepoGraph):
    def __init__(self, base: PythonRepoGraph, include_ids: Set[str]):
        # Shallow copy of references; restrict to include_ids
        self.repo_root = base.repo_root
        self.program_id = base.program_id
        self._cg = base._cg
        self._ent_by_id = base._ent_by_id
        self._ids_by_name = base._ids_by_name
        self._include_ids = set(include_ids)
        self._ent_cache = None
        self._edge_cache = None

    def entities(self) -> Iterable[Entity]:
        out: List[Entity] = []
        for cg in self._cg.entities():
            if cg.id not in self._include_ids:
                continue
            uri = _entity_uri(self.program_id, cg)


from __future__ import annotations

import os
import re
import hashlib
from typing import Iterable, List, Tuple, Dict, Optional, Set

from modules.program_graph import (
    ProgramGraph,
    Entity,
    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)

def program_id_for_repo(repo_root: str) -> str:
    base = os.path.basename(os.path.abspath(repo_root)) or "repo"
    return base

def artifact_uri(program_id: str, rel_path: str) -> str:
    rel = rel_path.replace("\\", "/").lstrip("/")
    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return self  # default view is whole repo; subclasses may return filtered views

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("artifact", "source"):
            return []
        out: List[Artifact] = []
        for fp in self._discover_files(self.repo_root, self.ignore_rules):
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="source", hash=self._hash_for(fp), span=None))
        return out

    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)
            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _hash_for(self, abs_file: str) -> str:
        if abs_file in self._file_hash:
            return self._file_hash[abs_file]
        try:
            with open(abs_file, "rb") as fh:
                raw = fh.read()
            h = hashlib.sha256(raw).hexdigest()
        except Exception:
            h = ""
        self._file_hash[abs_file] = h
        return h


from __future__ import annotations

import os
import re
import hashlib
from typing import Iterable, List, Tuple, Dict, Optional, Set

from modules.program_graph import (
    ProgramGraph,
    Entity,
    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)

def program_id_for_repo(repo_root: str) -> str:
    base = os.path.basename(os.path.abspath(repo_root)) or "repo"
    return base

def artifact_uri(program_id: str, rel_path: str) -> str:
    rel = rel_path.replace("\\", "/").lstrip("/")
    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)


import re
import hashlib
from typing import Iterable, List, Tuple, Dict, Optional, Set

from modules.program_graph import (
    ProgramGraph,
    Entity,
    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)

def program_id_for_repo(repo_root: str) -> str:
    base = os.path.basename(os.path.abspath(repo_root)) or "repo"
    return base

def artifact_uri(program_id: str, rel_path: str) -> str:
    rel = rel_path.replace("\\", "/").lstrip("/")
    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:


    ProgramGraph,
    Entity,
    Edge,
    Artifact,
    Span,
    ResolvedAnchor,
    EntityId,
)

def program_id_for_repo(repo_root: str) -> str:
    base = os.path.basename(os.path.abspath(repo_root)) or "repo"
    return base

def artifact_uri(program_id: str, rel_path: str) -> str:
    rel = rel_path.replace("\\", "/").lstrip("/")
    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":


RepoGraph

def program_id_for_repo(repo_root: str) -> str:
    base = os.path.basename(os.path.abspath(repo_root)) or "repo"
    return base

def artifact_uri(program_id: str, rel_path: str) -> str:
    rel = rel_path.replace("\\", "/").lstrip("/")
    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return self  # default view is whole repo; subclasses may return filtered views

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("artifact", "source"):
            return []
        out: List[Artifact] = []
        for fp in self._discover_files(self.repo_root, self.ignore_rules):
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="source", hash=self._hash_for(fp), span=None))
        return out

    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)
            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _hash_for(self, abs_file: str) -> str:
        if abs_file in self._file_hash:
            return self._file_hash[abs_file]
        try:
            with open(abs_file, "rb") as fh:
                raw = fh.read()
            h = hashlib.sha256(raw).hexdigest()
        except Exception:
            h = ""
        self._file_hash[abs_file] = h
        return h


def program_id_for_repo(repo_root: str) -> str:
    base = os.path.basename(os.path.abspath(repo_root)) or "repo"
    return base

def artifact_uri(program_id: str, rel_path: str) -> str:
    rel = rel_path.replace("\\", "/").lstrip("/")
    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)


entities

    return f"program://{program_id}/artifact/{rel}"

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):


edges

def parse_program_uri(uri: str) -> Tuple[str, str, str, Optional[Tuple[int, int]]]:
    m = re.match(r"^program://([^/]+)/([^/]+)/(.+?)(?:#L(\d+)-L(\d+))?$", uri)
    if not m:
        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)


        raise ValueError(f"invalid program uri: {uri}")
    pid, kind, res, a, b = m.group(1), m.group(2), m.group(3), m.group(4), m.group(5)
    span = (int(a), int(b)) if (a and b) else None
    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return self  # default view is whole repo; subclasses may return filtered views


subgraph

    return pid, kind, res, span

class RepoGraph(ProgramGraph):
    def __init__(self, repo_root: str, ignore: Optional[List[str]] = None):
        self.repo_root = os.path.abspath(repo_root)
        self.program_id = program_id_for_repo(self.repo_root)
        self.ignore_rules = [s for s in (ignore or []) if s]
        self._file_hash: Dict[str, str] = {}

    # ProgramGraph defaults (repo-agnostic)
    def entities(self) -> Iterable[Entity]:
        return []

    def edges(self) -> Iterable[Edge]:
        return []

    def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
        return []

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return self  # default view is whole repo; subclasses may return filtered views

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("artifact", "source"):
            return []
        out: List[Artifact] = []
        for fp in self._discover_files(self.repo_root, self.ignore_rules):
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="source", hash=self._hash_for(fp), span=None))
        return out

    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)


artifacts

    def subgraph(self, seeds: List[EntityId], radius: int) -> "ProgramGraph":
        if not seeds or radius <= 0:
            return self
        # Generic BFS over current edges view
        adj: Dict[str, List[str]] = {}
        for e in self.edges():
            adj.setdefault(e.src, []).append(e.dst)
            adj.setdefault(e.dst, []).append(e.src)
        cur = set(seeds)
        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return self  # default view is whole repo; subclasses may return filtered views

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("artifact", "source"):
            return []
        out: List[Artifact] = []
        for fp in self._discover_files(self.repo_root, self.ignore_rules):
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="source", hash=self._hash_for(fp), span=None))
        return out

    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)
            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")


resolve

        seen = set(cur)
        for _ in range(max(1, radius)):
            nxt: Set[str] = set()
            for s in list(cur):
                for nb in adj.get(s, []):
                    if nb not in seen:
                        seen.add(nb)
                        nxt.add(nb)
            cur = nxt
        return self  # default view is whole repo; subclasses may return filtered views

    def artifacts(self, kind: str) -> Iterable[Artifact]:
        if kind not in ("artifact", "source"):
            return []
        out: List[Artifact] = []
        for fp in self._discover_files(self.repo_root, self.ignore_rules):
            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="source", hash=self._hash_for(fp), span=None))
        return out

    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)
            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)


            rel = os.path.relpath(fp, self.repo_root).replace("\\", "/")
            out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="source", hash=self._hash_for(fp), span=None))
        return out

    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)
            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _hash_for(self, abs_file: str) -> str:


    def resolve(self, uri: str) -> ResolvedAnchor:
        pid, kind, res, span = parse_program_uri(uri)
        if pid != self.program_id:
            raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
        if kind == "artifact":
            abs_fp = os.path.abspath(os.path.join(self.repo_root, res))
            if not os.path.isfile(abs_fp):
                raise FileNotFoundError(f"artifact not found: {abs_fp}")
            a = int(span[0]) if span else 1
            b = int(span[1]) if span else self._safe_count_lines(abs_fp)
            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _hash_for(self, abs_file: str) -> str:
        if abs_file in self._file_hash:
            return self._file_hash[abs_file]
        try:
            with open(abs_file, "rb") as fh:
                raw = fh.read()
            h = hashlib.sha256(raw).hexdigest()
        except Exception:
            h = ""
        self._file_hash[abs_file] = h
        return h


            rel = os.path.relpath(abs_fp, self.repo_root).replace("\\", "/")
            return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash=self._hash_for(abs_fp))
        # Let subclass handle entity URIs
        return self._resolve_entity_uri(kind, res, span)

    # Hooks for subclasses
    def _resolve_entity_uri(self, kind: str, resource: str, span: Optional[Tuple[int, int]]) -> ResolvedAnchor:
        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _hash_for(self, abs_file: str) -> str:
        if abs_file in self._file_hash:
            return self._file_hash[abs_file]
        try:
            with open(abs_file, "rb") as fh:
                raw = fh.read()
            h = hashlib.sha256(raw).hexdigest()
        except Exception:
            h = ""
        self._file_hash[abs_file] = h
        return h


        raise KeyError(f"unrecognized entity uri for kind={kind}, resource={resource}")

    # Utilities
    def _discover_files(self, root: str, ignore: List[str]) -> List[str]:
        out: List[str] = []
        for dirpath, dirnames, filenames in os.walk(root):
            if any(ig and ig in dirpath for ig in ignore):
                continue
            for fn in filenames:
                ap = os.path.abspath(os.path.join(dirpath, fn))
                out.append(ap)
        return out

    def _safe_count_lines(self, abs_file: str) -> int:
        try:
            with open(abs_file, "r", encoding="utf-8", errors="ignore") as fh:
                return sum(1 for _ in fh)
        except Exception:
            return 1

    def _hash_for(self, abs_file: str) -> str:
        if abs_file in self._file_hash:
            return self._file_hash[abs_file]
        try:
            with open(abs_file, "rb") as fh:
                raw = fh.read()
            h = hashlib.sha256(raw).hexdigest()
        except Exception:
            h = ""
        self._file_hash[abs_file] = h
        return h


from __future__ import annotations

from typing import Any, Dict

def allocate(budget: Dict[str, Any], expected_gain: float) -> Dict[str, Any]:
	"""
	Greedily allocate tokens/time by expected verifier-gain per unit cost (placeholder).
	"""
	out = dict(budget)
	out["allocated"] = {"tokens": int(budget.get("tokens", 64000) * 0.5)}
	out["expected_gain"] = float(expected_gain)
	return out


from __future__ import annotations

import os
from typing import Iterable, List, Tuple, Dict, Optional

from modules.program_graph import (
	ProgramGraph,
	Entity,
	Edge,
	Artifact,
	Span,
	ResolvedAnchor,
	EntityId,
)
from .repo_graph import artifact_uri, program_id_for_repo, parse_program_uri

class DatasetProgramGraph(ProgramGraph):
	"""
	Minimal Dataset→ProgramGraph backend.
	Represents benchmark datasets (MBPP, SWE-bench, etc.) as entities:
	- problem:<dataset>/<split>/<id>
	- reference:<dataset>/<split>/<id>
	- verifier:<dataset>/<split>/<id>
	Artifacts point to source JSON or local cache paths when available.
	"""

	def __init__(self, root: str, datasets: Optional[List[str]] = None):
		self.root = os.path.abspath(root)
		self.program_id = program_id_for_repo(self.root)
		self._datasets = datasets or ["mbpp:train", "mbpp:test", "swe_bench:train"]
		# Tiny synthetic index for smoke use
		self._index: Dict[str, Dict[str, str]] = {}
		for spec in self._datasets:
			ds, split = (spec.split(":", 1) + ["train"])[:2]
			key = f"{ds}/{split}/0001"
			self._index[key] = {
				"dataset": ds,
				"split": split,
				"problem": f"Solve sample problem 0001 from {ds}/{split}",
				"reference": "def solution(x):\n\treturn x\n",
				"verifier": "python verify_0001.py",
				"artifact_rel": f"datasets/{ds}/{split}/0001.json"
			}

	def entities(self) -> Iterable[Entity]:
		out: List[Entity] = []
		for key, meta in self._index.items():
			ds = meta["dataset"]
			split = meta["split"]
			# problem
			out.append(Entity(
				uri=f"program://{self.program_id}/problem/{key}",
				id=f"ds:problem:{key}",
				kind="problem",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
			# reference
			out.append(Entity(
				uri=f"program://{self.program_id}/reference/{key}",
				id=f"ds:reference:{key}",
				kind="reference",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
			# verifier
			out.append(Entity(
				uri=f"program://{self.program_id}/verifier/{key}",
				id=f"ds:verifier:{key}",
				kind="verifier",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
		return out

	def edges(self) -> Iterable[Edge]:
		out: List[Edge] = []
		for key in self._index.keys():
			pid = f"ds:problem:{key}"
			rid = f"ds:reference:{key}"
			vid = f"ds:verifier:{key}"
			out.append(Edge(src=pid, dst=rid, type="has_reference", meta=None))
			out.append(Edge(src=pid, dst=vid, type="has_verifier", meta=None))
		return out

	def artifacts(self, kind: str) -> Iterable[Artifact]:
		if kind not in ("artifact", "source"):
			return []
		out: List[Artifact] = []
		for key, meta in self._index.items():
			rel = meta.get("artifact_rel") or f"datasets/{key}.json"
			abs_fp = os.path.join(self.root, rel)
			# We may not have real files; just issue URIs with empty hash
			out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="artifact", hash="", span=None))
		return out

	def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
		t = (token or "").strip().lower()
		if not t:
			return []
		out: List[Tuple[EntityId, Span]] = []
		for key, meta in self._index.items():
			if t in (meta.get("problem") or "").lower():
				out.append((f"ds:problem:{key}", Span(start_line=1, end_line=1)))
		return out

	def resolve(self, uri: str) -> ResolvedAnchor:
		pid, kind, res, span = parse_program_uri(uri)
		if pid != self.program_id:
			raise ValueError(f"program id mismatch: {pid} != {self.program_id}")
		# Map entity URIs to synthetic artifact anchors
		if kind in ("problem", "reference", "verifier"):
			key = res
			base = f"datasets/{key}.json"
			a = int(span[0]) if span else 1
			b = int(span[1]) if span else a
			return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, base), span=Span(start_line=a, end_line=b), hash="")
		# Fallback to artifact resolution
		if kind == "artifact":
			# Defer to generic artifact handling through RepoGraph helpers (inline)
			abs_fp = os.path.abspath(os.path.join(self.root, res))
			a = int(span[0]) if span else 1
			b = int(span[1]) if span else max(1, a)
			rel = res.replace("\\", "/")
			return ResolvedAnchor(artifact_uri=artifact_uri(self.program_id, rel), span=Span(start_line=a, end_line=b), hash="")
		raise KeyError(f"unrecognized entity uri for kind={kind}, resource={res}")


	Entity,
	Edge,
	Artifact,
	Span,
	ResolvedAnchor,
	EntityId,
)
from .repo_graph import artifact_uri, program_id_for_repo, parse_program_uri

class DatasetProgramGraph(ProgramGraph):
	"""
	Minimal Dataset→ProgramGraph backend.
	Represents benchmark datasets (MBPP, SWE-bench, etc.) as entities:
	- problem:<dataset>/<split>/<id>
	- reference:<dataset>/<split>/<id>
	- verifier:<dataset>/<split>/<id>
	Artifacts point to source JSON or local cache paths when available.
	"""

	def __init__(self, root: str, datasets: Optional[List[str]] = None):
		self.root = os.path.abspath(root)
		self.program_id = program_id_for_repo(self.root)
		self._datasets = datasets or ["mbpp:train", "mbpp:test", "swe_bench:train"]
		# Tiny synthetic index for smoke use
		self._index: Dict[str, Dict[str, str]] = {}
		for spec in self._datasets:
			ds, split = (spec.split(":", 1) + ["train"])[:2]
			key = f"{ds}/{split}/0001"
			self._index[key] = {
				"dataset": ds,
				"split": split,
				"problem": f"Solve sample problem 0001 from {ds}/{split}",
				"reference": "def solution(x):\n\treturn x\n",
				"verifier": "python verify_0001.py",
				"artifact_rel": f"datasets/{ds}/{split}/0001.json"
			}

	def entities(self) -> Iterable[Entity]:
		out: List[Entity] = []
		for key, meta in self._index.items():
			ds = meta["dataset"]
			split = meta["split"]
			# problem
			out.append(Entity(
				uri=f"program://{self.program_id}/problem/{key}",
				id=f"ds:problem:{key}",
				kind="problem",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
			# reference
			out.append(Entity(
				uri=f"program://{self.program_id}/reference/{key}",
				id=f"ds:reference:{key}",
				kind="reference",


entities

	"""

	def __init__(self, root: str, datasets: Optional[List[str]] = None):
		self.root = os.path.abspath(root)
		self.program_id = program_id_for_repo(self.root)
		self._datasets = datasets or ["mbpp:train", "mbpp:test", "swe_bench:train"]
		# Tiny synthetic index for smoke use
		self._index: Dict[str, Dict[str, str]] = {}
		for spec in self._datasets:
			ds, split = (spec.split(":", 1) + ["train"])[:2]
			key = f"{ds}/{split}/0001"
			self._index[key] = {
				"dataset": ds,
				"split": split,
				"problem": f"Solve sample problem 0001 from {ds}/{split}",
				"reference": "def solution(x):\n\treturn x\n",
				"verifier": "python verify_0001.py",
				"artifact_rel": f"datasets/{ds}/{split}/0001.json"
			}

	def entities(self) -> Iterable[Entity]:
		out: List[Entity] = []
		for key, meta in self._index.items():
			ds = meta["dataset"]
			split = meta["split"]
			# problem
			out.append(Entity(
				uri=f"program://{self.program_id}/problem/{key}",
				id=f"ds:problem:{key}",
				kind="problem",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
			# reference
			out.append(Entity(
				uri=f"program://{self.program_id}/reference/{key}",
				id=f"ds:reference:{key}",
				kind="reference",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
			# verifier
			out.append(Entity(
				uri=f"program://{self.program_id}/verifier/{key}",
				id=f"ds:verifier:{key}",
				kind="verifier",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
		return out

	def edges(self) -> Iterable[Edge]:
		out: List[Edge] = []
		for key in self._index.keys():
			pid = f"ds:problem:{key}"
			rid = f"ds:reference:{key}"
			vid = f"ds:verifier:{key}"
			out.append(Edge(src=pid, dst=rid, type="has_reference", meta=None))
			out.append(Edge(src=pid, dst=vid, type="has_verifier", meta=None))
		return out

	def artifacts(self, kind: str) -> Iterable[Artifact]:
		if kind not in ("artifact", "source"):
			return []
		out: List[Artifact] = []
		for key, meta in self._index.items():
			rel = meta.get("artifact_rel") or f"datasets/{key}.json"
			abs_fp = os.path.join(self.root, rel)
			# We may not have real files; just issue URIs with empty hash
			out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="artifact", hash="", span=None))


edges

			# reference
			out.append(Entity(
				uri=f"program://{self.program_id}/reference/{key}",
				id=f"ds:reference:{key}",
				kind="reference",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
			# verifier
			out.append(Entity(
				uri=f"program://{self.program_id}/verifier/{key}",
				id=f"ds:verifier:{key}",
				kind="verifier",
				name=f"{ds}:{split}:{key.split('/')[-1]}",
				owner=None,
				labels=None,
			))
		return out

	def edges(self) -> Iterable[Edge]:
		out: List[Edge] = []
		for key in self._index.keys():
			pid = f"ds:problem:{key}"
			rid = f"ds:reference:{key}"
			vid = f"ds:verifier:{key}"
			out.append(Edge(src=pid, dst=rid, type="has_reference", meta=None))
			out.append(Edge(src=pid, dst=vid, type="has_verifier", meta=None))
		return out

	def artifacts(self, kind: str) -> Iterable[Artifact]:
		if kind not in ("artifact", "source"):
			return []
		out: List[Artifact] = []
		for key, meta in self._index.items():
			rel = meta.get("artifact_rel") or f"datasets/{key}.json"
			abs_fp = os.path.join(self.root, rel)
			# We may not have real files; just issue URIs with empty hash
			out.append(Artifact(uri=artifact_uri(self.program_id, rel), type="artifact", hash="", span=None))
		return out

	def search_refs(self, token: str) -> Iterable[Tuple[EntityId, Span]]:
		t = (token or "").strip().lower()
		if not t:
			return []
		out: List[Tuple[EntityId, Span]] = []
		for key, meta in self._index.items():
			if t in (meta.get("problem") or "").lower():
				out.append((f"ds:problem:{key}", Span(start_line=1, end_line=1)))