"""Deterministic bounded context compilation with explicit selection provenance.""" from __future__ import annotations from collections import deque from .errors import DocForgeError from .index import ProjectIndex from .models import ContextEntry, ContextProfile, Node, ProjectSnapshot def _estimate_tokens(text: str) -> int: return max(1, (len(text) + 3) // 4) def _node_text(node: Node) -> str: return ( f"ID: {node.node_id}\nTitle: {node.title}\nFamily: {node.family}\n" f"Authority: {node.authority}\nStatus: {node.status}\nSource: {node.source_path}\n" f"Summary: {node.summary}\n\n{node.content}" ) def _profile(snapshot: ProjectSnapshot, profile_id: str) -> ContextProfile: matches = [ profile for profile in snapshot.descriptor.profiles if profile.profile_id == profile_id ] if len(matches) != 1: raise DocForgeError( "missing_profile", "No context profile has the requested ID", id=profile_id ) return matches[0] def compile_context( index: ProjectIndex, profile_id: str, budget: int | None = None ) -> dict[str, object]: checked = index.check() snapshot = index.project.load() if snapshot.source_hash != checked["source_hash"] or snapshot.revision != checked["revision"]: raise DocForgeError("source_changed", "Canonical source changed before context selection") profile = _profile(snapshot, profile_id) selected_budget = profile.token_budget if budget is None else budget if ( not isinstance(selected_budget, int) or isinstance(selected_budget, bool) or selected_budget < 1 or selected_budget > snapshot.descriptor.limits.max_context_tokens ): raise DocForgeError("invalid_budget", "Context budget is outside the configured range") node_by_id = {node.node_id: node for node in snapshot.nodes} dependency_edges = { node_id: tuple( edge.target_id for edge in snapshot.edges if edge.source_id == node_id and edge.relation == "depends_on" ) for node_id in node_by_id } reasons: dict[str, str] = {node_id: "required by profile" for node_id in profile.required_nodes} queue = deque((node_id, 0) for node_id in profile.required_nodes) while queue: node_id, depth = queue.popleft() if depth >= profile.dependency_depth: continue for dependency in dependency_edges[node_id]: if dependency not in reasons: reasons[dependency] = f"dependency of {node_id}" queue.append((dependency, depth + 1)) eligible = [ node for node in snapshot.nodes if (not profile.families or node.family in profile.families) and (not profile.statuses or node.status in profile.statuses) ] ordered_ids = [*profile.required_nodes] ordered_ids.extend(sorted(set(reasons) - set(ordered_ids))) ordered_ids.extend(node.node_id for node in eligible if node.node_id not in reasons) entries: list[ContextEntry] = [] omissions: list[dict[str, str]] = [] used_tokens = 0 required = set(profile.required_nodes) for node_id in ordered_ids: node = node_by_id[node_id] text = _node_text(node) tokens = _estimate_tokens(text) if used_tokens + tokens > selected_budget: if node_id in required: raise DocForgeError( "budget_too_small", "Context budget cannot contain every required node", node_id=node_id, required_tokens=used_tokens + tokens, ) omissions.append({"node_id": node_id, "reason": "token budget"}) continue entries.append( ContextEntry( node_id=node_id, reason=reasons.get(node_id, "eligible profile node"), estimated_tokens=tokens, source_path=node.source_path, content_hash=node.content_hash, text=text, ) ) used_tokens += tokens after = index.check() if after["source_hash"] != checked["source_hash"] or after["revision"] != checked["revision"]: raise DocForgeError("source_changed", "Canonical source changed during context selection") return { "status": "ok", "project_id": checked["project_id"], "project_root_fingerprint": checked["project_root_fingerprint"], "revision": checked["revision"], "source_hash": checked["source_hash"], "adapter": checked["adapter"], "profile": profile.profile_id, "budget": selected_budget, "estimated_tokens": used_tokens, "entries": [entry.as_dict() for entry in entries], "omissions": omissions, }