From 687e8d39d9ff1506240b4fbc0cdeeaaabf1abf4d Mon Sep 17 00:00:00 2001 From: mrchatam Date: Sat, 26 Sep 2026 19:57:29 +0330 Subject: [PATCH] fix(retrieval): keep scope_id under node budget; honest scoped truncation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scope_id is derived per included node from the outgoing links already read in collectEdgesForNodes (before the included-target filter), so truncated and exact-fit project graphs keep scope clustering; no unbounded scope_member scan. - nodes are sorted once on every return path. - scoped mode: truncated only when a candidate is actually dropped; total_entities = included + distinct dropped (lower bound); no lookup for members beyond the budget. - GET /v1/graph mode=project&scope=…: depth must be 1..2 (400 otherwise); OpenAPI depth description updated. Patch produced by the Kilo worker (Nemotron 3 Ultra), independently reviewed. --- api/openapi.yaml | 4 +- internal/httpapi/handlers_retrieval.go | 13 +- internal/httpapi/httpapi_test.go | 93 +++++++++ internal/retrieval/project_graph.go | 162 ++++++++------- internal/retrieval/scope_graph_budget_test.go | 190 ++++++++++++++++++ 5 files changed, 387 insertions(+), 75 deletions(-) create mode 100644 internal/retrieval/scope_graph_budget_test.go diff --git a/api/openapi.yaml b/api/openapi.yaml index 5e6e15f..f4bdbda 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -668,7 +668,9 @@ paths: minimum: 1 maximum: 8 default: 2 - description: Hop depth for neighborhood mode only + description: | + Hop depth for neighborhood mode (max 8). + For mode=project with scope, depth applies (1..2, default 1). - name: scope in: query required: false diff --git a/internal/httpapi/handlers_retrieval.go b/internal/httpapi/handlers_retrieval.go index dc26566..1a3de67 100644 --- a/internal/httpapi/handlers_retrieval.go +++ b/internal/httpapi/handlers_retrieval.go @@ -174,7 +174,18 @@ func (s *Server) handleGraph(w http.ResponseWriter, r *http.Request) { if mode == "project" { scope := strings.TrimSpace(r.URL.Query().Get("scope")) - depth, _ := queryInt(r, "depth", 1) + depth := 1 + if scope != "" { + depthStr := strings.TrimSpace(r.URL.Query().Get("depth")) + if depthStr != "" { + d, err := queryInt(r, "depth", 1) + if err != nil || d < 1 || d > 2 { + writeEnvelope(w, http.StatusBadRequest, "VALIDATION_ERROR", "depth must be an integer in 1..2 for mode=project with scope", nil) + return + } + depth = d + } + } g, err := eng.ProjectGraph(r.Context(), retrieval.ProjectGraphOpts{ MaxNodes: maxNodes, Scope: scope, diff --git a/internal/httpapi/httpapi_test.go b/internal/httpapi/httpapi_test.go index 452dd9b..270ab5f 100644 --- a/internal/httpapi/httpapi_test.go +++ b/internal/httpapi/httpapi_test.go @@ -347,6 +347,99 @@ func TestGraphBudgetAndDefer(t *testing.T) { } } +func TestGraphProjectModeDepthValidation(t *testing.T) { + dir, srv := openFixture(t) + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + svc := domain.New(st) + ctx := context.Background() + sc, err := svc.CreateScope(ctx, domain.ScopeInput{ + Slug: "test-scope", Title: "Test Scope", Kind: "feature", + }) + if err != nil { + t.Fatal(err) + } + tk, err := svc.CreateTask(ctx, domain.TaskInput{Title: "Task 1"}) + if err != nil { + t.Fatal(err) + } + if err := svc.LinkScopeMember(ctx, tk.ID, sc.ID, domain.LinkMeta{}); err != nil { + t.Fatal(err) + } + st.Close() + + h := srv.Handler() + + // depth=abc (invalid) -> 400 VALIDATION_ERROR + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&scope=test-scope&depth=abc", nil)) + if rr.Code != 400 { + t.Fatalf("depth=abc: expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + var env map[string]any + _ = json.Unmarshal(rr.Body.Bytes(), &env) + errObj := env["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("depth=abc: expected VALIDATION_ERROR, got %v", errObj) + } + + // depth=3 (out of range for mode=project) -> 400 VALIDATION_ERROR + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&scope=test-scope&depth=3", nil)) + if rr.Code != 400 { + t.Fatalf("depth=3: expected 400, got %d: %s", rr.Code, rr.Body.String()) + } + _ = json.Unmarshal(rr.Body.Bytes(), &env) + errObj = env["error"].(map[string]any) + if errObj["code"] != "VALIDATION_ERROR" { + t.Fatalf("depth=3: expected VALIDATION_ERROR, got %v", errObj) + } + + // depth=2 (valid) -> 200 OK + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&scope=test-scope&depth=2", nil)) + if rr.Code != 200 { + t.Fatalf("depth=2: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // depth=1 (valid, default) -> 200 OK + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&scope=test-scope&depth=1", nil)) + if rr.Code != 200 { + t.Fatalf("depth=1: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // no depth (default 1) -> 200 OK + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&scope=test-scope", nil)) + if rr.Code != 200 { + t.Fatalf("no depth: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // Neighborhood mode should still accept depth up to 8 (not tested here, but ensure we didn't break it) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?center="+tk.ID+"&max_nodes=10&depth=8", nil)) + if rr.Code != 200 { + t.Fatalf("neighborhood depth=8: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // mode=project WITHOUT scope, depth=3 -> 200 OK (depth ignored when no scope) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&depth=3", nil)) + if rr.Code != 200 { + t.Fatalf("mode=project no scope depth=3: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } + + // mode=project WITHOUT scope, depth=abc -> 200 OK (depth ignored when no scope) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/graph?mode=project&max_nodes=10&depth=abc", nil)) + if rr.Code != 200 { + t.Fatalf("mode=project no scope depth=abc: expected 200, got %d: %s", rr.Code, rr.Body.String()) + } +} + func TestSeedPathConfinement(t *testing.T) { _, srv := openFixture(t) h := srv.Handler() diff --git a/internal/retrieval/project_graph.go b/internal/retrieval/project_graph.go index ea2f58e..af79194 100644 --- a/internal/retrieval/project_graph.go +++ b/internal/retrieval/project_graph.go @@ -25,6 +25,20 @@ var projectGraphKindOrder = map[string]int{ "change": 10, "regression": 11, "scope": 12, } +func projectGraphSortLess(nodes []GraphNode) func(i, j int) bool { + return func(i, j int) bool { + oi, oki := projectGraphKindOrder[nodes[i].Kind] + oj, okj := projectGraphKindOrder[nodes[j].Kind] + if oki && okj && oi != oj { + return oi < oj + } + if oki != okj { + return oki + } + return nodes[i].ID < nodes[j].ID + } +} + // ProjectGraph returns a bounded view of project entities and edges between them. // max_nodes is required (1..5000). Truncated=true when total entities exceed the budget. // When Scope is set, returns scope members + N-hop neighbors only (still bounded by max_nodes). @@ -111,10 +125,21 @@ func (e *Engine) projectGraphScoped(opts ProjectGraphOpts) (*BoundedGraph, error seen[hitKey("scope", sc.ID)] = seedHit frontier := []frontierItem{{h: seedHit}} + // Track candidates that were seen but not added due to budget. + dropped := map[string]struct{}{} + for _, l := range memberLinks { if l.Rel != "scope_member" { continue } + k := hitKey(l.FromType, l.FromID) + if _, ok := seen[k]; ok { + continue + } + if len(seen) >= opts.MaxNodes { + dropped[k] = struct{}{} + continue + } nh, lerr := e.lookupEntity(l.FromType, l.FromID, "scope_member", 0, 1.0) if lerr != nil { if isNotFound(lerr) { @@ -123,18 +148,11 @@ func (e *Engine) projectGraphScoped(opts ProjectGraphOpts) (*BoundedGraph, error return nil, lerr } nh.Distance = 0 - k := hitKey(nh.EntityType, nh.EntityID) - if _, ok := seen[k]; ok { - continue - } - if len(seen) >= opts.MaxNodes { - break - } seen[k] = nh frontier = append(frontier, frontierItem{h: nh}) } - truncated := len(seen) >= opts.MaxNodes + truncated := false edgeSeen := map[string]struct{}{} var edges []GraphEdge addEdge := func(ed GraphEdge) { @@ -163,6 +181,7 @@ func (e *Engine) projectGraphScoped(opts ProjectGraphOpts) (*BoundedGraph, error } if len(seen) >= opts.MaxNodes { truncated = true + dropped[k] = struct{}{} break } seen[k] = nh @@ -220,12 +239,16 @@ func (e *Engine) projectGraphScoped(opts ProjectGraphOpts) (*BoundedGraph, error addEdge(ed) } + // Truncated is true only when at least one candidate was dropped. + // TotalEntities is a lower bound: included + distinct dropped candidates. + totalEntities := len(nodes) + len(dropped) + center := sc.ID return &BoundedGraph{ Mode: "project", Center: center, MaxNodes: opts.MaxNodes, - TotalEntities: len(nodes), + TotalEntities: totalEntities, Nodes: nodes, Edges: edges, Truncated: truncated, @@ -265,6 +288,15 @@ func (e *Engine) collectProjectNodes(maxNodes int) ([]GraphNode, int, error) { total += n } + nodes, err := e.collectProjectNodesUnsorted(maxNodes) + if err != nil { + return nil, 0, err + } + sort.SliceStable(nodes, projectGraphSortLess(nodes)) + return nodes, total, nil +} + +func (e *Engine) collectProjectNodesUnsorted(maxNodes int) ([]GraphNode, error) { var nodes []GraphNode appendNode := func(n GraphNode) bool { if len(nodes) >= maxNodes { @@ -284,21 +316,21 @@ func (e *Engine) collectProjectNodes(maxNodes int) ([]GraphNode, int, error) { if rem := remaining(); rem > 0 { goals, err := e.store.ListGoalsLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, g := range goals { if !appendNode(GraphNode{ID: g.ID, Kind: "goal", Title: g.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { res, err := e.store.ListTasksFiltered(store.TaskListFilter{Limit: rem}) if err != nil { - return nil, 0, err + return nil, err } for _, t := range res.Tasks { gn := GraphNode{ID: t.ID, Kind: "task", Title: t.Title} @@ -306,129 +338,129 @@ func (e *Engine) collectProjectNodes(maxNodes int) ([]GraphNode, int, error) { gn.GoalID = *t.GoalID } if !appendNode(gn) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { decisions, err := e.store.ListDecisionsLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, d := range decisions { if !appendNode(GraphNode{ID: d.ID, Kind: "decision", Title: d.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { assumptions, err := e.store.ListAssumptionsLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, a := range assumptions { if !appendNode(GraphNode{ID: a.ID, Kind: "assumption", Title: a.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { discoveries, err := e.store.ListDiscoveriesLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, d := range discoveries { if !appendNode(GraphNode{ID: d.ID, Kind: "discovery", Title: d.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { planChanges, err := e.store.ListPlanChangesLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, p := range planChanges { if !appendNode(GraphNode{ID: p.ID, Kind: "plan_change", Title: p.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { claims, err := e.store.ListClaimsLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, c := range claims { if !appendNode(GraphNode{ID: c.ID, Kind: "claim", Title: c.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { evidence, err := e.store.ListEvidenceLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, ev := range evidence { if !appendNode(GraphNode{ID: ev.ID, Kind: "evidence", Title: ev.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { reviews, err := e.store.ListReviewsLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, r := range reviews { if !appendNode(GraphNode{ID: r.ID, Kind: "review", Title: r.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { caps, err := e.store.ListCapabilities(store.CapabilityListFilter{Limit: rem}) if err != nil { - return nil, 0, err + return nil, err } for _, c := range caps { if !appendNode(GraphNode{ID: c.ID, Kind: "capability", Title: c.Title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { changes, err := e.store.ListChangesLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, c := range changes { title := c.Reason @@ -436,17 +468,17 @@ func (e *Engine) collectProjectNodes(maxNodes int) ([]GraphNode, int, error) { title = c.ID } if !appendNode(GraphNode{ID: c.ID, Kind: "change", Title: title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { regressions, err := e.store.ListRegressionsLimited(rem) if err != nil { - return nil, 0, err + return nil, err } for _, r := range regressions { title := r.Summary @@ -454,17 +486,17 @@ func (e *Engine) collectProjectNodes(maxNodes int) ([]GraphNode, int, error) { title = r.ID } if !appendNode(GraphNode{ID: r.ID, Kind: "regression", Title: title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil + return nodes, nil } if rem := remaining(); rem > 0 { allScopes, err := e.store.ListScopes() if err != nil { - return nil, 0, err + return nil, err } scopes := allScopes if len(scopes) > rem { @@ -476,37 +508,14 @@ func (e *Engine) collectProjectNodes(maxNodes int) ([]GraphNode, int, error) { title = sc.Slug } if !appendNode(GraphNode{ID: sc.ID, Kind: "scope", Title: title}) { - return nodes, total, nil + return nodes, nil } } } else { - return nodes, total, nil - } - - // Populate scope_id on members when a single membership is cheap to map. - memberOf, err := e.memberScopeIndex() - if err != nil { - return nil, 0, err - } - for i := range nodes { - if sid, ok := memberOf[nodes[i].ID]; ok { - nodes[i].ScopeID = sid - } + return nodes, nil } - sort.SliceStable(nodes, func(i, j int) bool { - oi, oki := projectGraphKindOrder[nodes[i].Kind] - oj, okj := projectGraphKindOrder[nodes[j].Kind] - if oki && okj && oi != oj { - return oi < oj - } - if oki != okj { - return oki - } - return nodes[i].ID < nodes[j].ID - }) - - return nodes, total, nil + return nodes, nil } // memberScopeIndex maps entity id → scope id for scope_member links. @@ -532,13 +541,20 @@ func (e *Engine) memberScopeIndex() (map[string]string, error) { func (e *Engine) collectEdgesForNodes(nodes []GraphNode, included map[string]struct{}) ([]GraphEdge, error) { edgeSeen := map[string]struct{}{} var edges []GraphEdge - for _, n := range nodes { + for i := range nodes { + n := &nodes[i] fromType := domainEntityType(n.Kind) links, err := e.store.ListLinksFrom(fromType, n.ID) if err != nil { return nil, err } for _, l := range links { + // Populate ScopeID from first scope_member link to a scope entity. + // Must be BEFORE the included/edgeSeen checks so it works even when + // the target scope node is not included (truncated graph). + if n.ScopeID == "" && l.Rel == "scope_member" && l.ToType == "scope" { + n.ScopeID = l.ToID + } if _, ok := included[l.ToID]; !ok { continue } diff --git a/internal/retrieval/scope_graph_budget_test.go b/internal/retrieval/scope_graph_budget_test.go new file mode 100644 index 0000000..c11bd15 --- /dev/null +++ b/internal/retrieval/scope_graph_budget_test.go @@ -0,0 +1,190 @@ +package retrieval_test + +import ( + "context" + "testing" + + "github.com/mrchatam/Trace/internal/domain" + "github.com/mrchatam/Trace/internal/retrieval" + "github.com/mrchatam/Trace/internal/store" +) + +func TestProjectGraphScopeIDPopulatedUnderBudget(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer st.Close() + svc := domain.New(st) + ctx := context.Background() + + // Create a scope + sc, err := svc.CreateScope(ctx, domain.ScopeInput{ + Slug: "test-scope", Title: "Test Scope", Kind: "feature", + }) + if err != nil { + t.Fatal(err) + } + + // Create 5 tasks linked to the scope + taskIDs := make([]string, 5) + for i := 0; i < 5; i++ { + tk, err := svc.CreateTask(ctx, domain.TaskInput{Title: "Task " + string(rune('A'+i))}) + if err != nil { + t.Fatal(err) + } + if err := svc.LinkScopeMember(ctx, tk.ID, sc.ID, domain.LinkMeta{}); err != nil { + t.Fatal(err) + } + taskIDs[i] = tk.ID + } + + eng := retrieval.New(st) + + // Test various MaxNodes values: 3, 5, 6, 100 + for _, maxNodes := range []int{3, 5, 6, 100} { + g, err := eng.ProjectGraph(ctx, retrieval.ProjectGraphOpts{MaxNodes: maxNodes}) + if err != nil { + t.Fatalf("MaxNodes=%d: %v", maxNodes, err) + } + hasTaskWithScope := false + hasScopeNode := false + for _, n := range g.Nodes { + if n.Kind == "task" { + if n.ScopeID != sc.ID { + t.Fatalf("MaxNodes=%d: task %s has ScopeID=%q, want %q", maxNodes, n.ID, n.ScopeID, sc.ID) + } + hasTaskWithScope = true + } + if n.Kind == "scope" && n.ID == sc.ID { + hasScopeNode = true + } + } + if !hasTaskWithScope { + t.Fatalf("MaxNodes=%d: expected at least one task node with scope_id", maxNodes) + } + // For MaxNodes=3 and 5, the scope node (kind=scope, order 12) should be excluded + // because tasks (kind=task, order 1) come first in kind order. + if maxNodes == 3 || maxNodes == 5 { + if hasScopeNode { + t.Fatalf("MaxNodes=%d: scope node should be excluded by budget, but was included", maxNodes) + } + } + } +} + +func TestProjectGraphScopedModeTruncationHonesty(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer st.Close() + svc := domain.New(st) + ctx := context.Background() + + // Create a scope + sc, err := svc.CreateScope(ctx, domain.ScopeInput{ + Slug: "auth", Title: "Auth", Kind: "feature", + }) + if err != nil { + t.Fatal(err) + } + + // Create 4 tasks linked to the scope + taskIDs := make([]string, 4) + for i := 0; i < 4; i++ { + tk, err := svc.CreateTask(ctx, domain.TaskInput{Title: "Task " + string(rune('A'+i))}) + if err != nil { + t.Fatal(err) + } + if err := svc.LinkScopeMember(ctx, tk.ID, sc.ID, domain.LinkMeta{}); err != nil { + t.Fatal(err) + } + taskIDs[i] = tk.ID + } + + eng := retrieval.New(st) + + // MaxNodes = 5 (scope + 4 members = 5 nodes, exact fit) + filtered5, err := eng.ProjectGraph(ctx, retrieval.ProjectGraphOpts{ + MaxNodes: 5, Scope: "auth", Depth: 1, + }) + if err != nil { + t.Fatal(err) + } + if filtered5.Truncated { + t.Fatalf("MaxNodes=5: expected Truncated=false for exact fit, got true") + } + if filtered5.TotalEntities != 5 { + t.Fatalf("MaxNodes=5: expected TotalEntities=5, got %d", filtered5.TotalEntities) + } + if len(filtered5.Nodes) != 5 { + t.Fatalf("MaxNodes=5: expected 5 nodes, got %d", len(filtered5.Nodes)) + } + + // MaxNodes = 3 (scope + 4 members, budget 3 -> scope + 2 members) + filtered3, err := eng.ProjectGraph(ctx, retrieval.ProjectGraphOpts{ + MaxNodes: 3, Scope: "auth", Depth: 1, + }) + if err != nil { + t.Fatal(err) + } + if !filtered3.Truncated { + t.Fatalf("MaxNodes=3: expected Truncated=true when nodes dropped, got false") + } + if filtered3.TotalEntities <= len(filtered3.Nodes) { + t.Fatalf("MaxNodes=3: expected TotalEntities > len(Nodes) (%d > %d), got %d <= %d", + filtered3.TotalEntities, len(filtered3.Nodes), filtered3.TotalEntities, len(filtered3.Nodes)) + } + if len(filtered3.Nodes) != 3 { + t.Fatalf("MaxNodes=3: expected 3 nodes, got %d", len(filtered3.Nodes)) + } +} + +func TestProjectGraphScopedModeDepthValidation(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer st.Close() + svc := domain.New(st) + ctx := context.Background() + + sc, err := svc.CreateScope(ctx, domain.ScopeInput{ + Slug: "test", Title: "Test", Kind: "feature", + }) + if err != nil { + t.Fatal(err) + } + tk, err := svc.CreateTask(ctx, domain.TaskInput{Title: "T"}) + if err != nil { + t.Fatal(err) + } + if err := svc.LinkScopeMember(ctx, tk.ID, sc.ID, domain.LinkMeta{}); err != nil { + t.Fatal(err) + } + + eng := retrieval.New(st) + + // Depth 3 should be capped to 2 internally (not an error at Engine level) + _, err = eng.ProjectGraph(ctx, retrieval.ProjectGraphOpts{ + MaxNodes: 10, Scope: "test", Depth: 3, + }) + if err != nil { + t.Fatalf("depth=3 should be capped, not error: %v", err) + } + + // Depth 0 should default to 1 + g1, err := eng.ProjectGraph(ctx, retrieval.ProjectGraphOpts{ + MaxNodes: 10, Scope: "test", Depth: 0, + }) + if err != nil { + t.Fatal(err) + } + if g1.MaxNodes != 10 { + t.Fatalf("MaxNodes not preserved: %d", g1.MaxNodes) + } +}