graph

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 25, 2026 License: Apache-2.0 Imports: 10 Imported by: 0

Documentation

Overview

Package graph grows and traverses the citation graph.

Two jobs live here:

  • Expansion. Follow referenced_works outward from a seed under a node budget and a priority order. At ~40 references per paper, depth 3 is ~64,000 nodes, so breadth-first without a limit is never acceptable.
  • Identity resolution. Deduplicate on openalex_id, and reconcile the DOI, arXiv ID and PMID that arrive attached to the same work.

This is the only package that knows about both internal/store and internal/sources. Keeping that pairing here is what lets store stay swappable.

Traversal (neighbours, shortest path) is SQL — a breadth-first search, one set-based query per step, not a recursive CTE (see store.ShortestPath) — so it lives in store. Whole-graph algorithms (PageRank, communities) load edges into memory through gonum/graph and live here.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type DuplicateGroup

type DuplicateGroup struct {
	Title string       // as the first work in the group spells it
	Works []model.Work // two or more, ordered by year, then ID
}

DuplicateGroup is a set of fetched works with the same title: probably one paper that OpenAlex holds as more than one record, under different DOIs or none.

type ExpandOptions

type ExpandOptions struct {
	// MaxNodes is the budget: the most works this run may try to hydrate.
	// It is the primary control (ADR-002). Depth is only a guard.
	MaxNodes int

	// MaxDepth is the deepest a hydrated work may sit, in hops from the
	// nearest seed. Its references are still recorded — edges are free — but
	// the stubs they create are not fetched in this run.
	MaxDepth int

	// MaxRefsPerWork caps how many of one work's references may compete for
	// the budget (§4). Zero means no cap.
	MaxRefsPerWork int

	// BatchSize is how many works to fetch per request. Zero means
	// openalex.MaxBatch, the most one request may carry.
	BatchSize int

	// Progress, if set, is called after each committed batch with the running
	// totals. The CLI draws its progress line from it.
	Progress func(model.ExpansionResult)
}

ExpandOptions bound one expansion run.

type Graph

type Graph struct {
	// contains filtered or unexported fields
}

Graph grows the citation graph: it takes works from a Source and writes them to the store. It is the one place data crosses from sources to store, which is what keeps the two swappable independently.

func New

func New(db *store.DB, src Source) *Graph

New builds a Graph over an open, migrated library.

func (*Graph) AddSeed

func (g *Graph) AddSeed(ctx context.Context, id identity.ID) (Seeded, error)

AddSeed resolves a deterministic identifier and adds the work it names as a seed. A title is refused; see SearchTitle and AddSeedWork.

Nothing is written unless OpenAlex has the work: an unresolvable seed wraps errs.ErrUnresolved and leaves the library untouched, because there is no node to hang the failure on.

func (*Graph) AddSeedWork

func (g *Graph) AddSeedWork(ctx context.Context, h model.HydratedWork) (Seeded, error)

AddSeedWork adds an already-fetched work as a seed: the work itself, a stub for every work it cites, and the edges between them, in one transaction (§4). It is how a title-search candidate the user chose gets added without fetching it a second time.

The seed is depth 0 and marked as a seed. If it was already in the library — reached as a stub from some other seed, or added before — hydration keeps the shorter depth and the original source (see store.UpsertWork), so adding it again is safe and idempotent.

func (*Graph) Expand

Expand grows the graph from the frontier under a budget, best first.

Each round asks the store for the stubs with the highest in-graph in-degree, hydrates them in one request, and commits the batch — works, their references as new stubs and edges, merged records folded, unknown IDs marked unresolved — in one transaction. Ctrl-C therefore loses at most the batch in flight, and never leaves a half-written graph (§4).

There is no queue to save. The frontier is a query over persisted state, so a run that stops for any reason resumes by running again, and running again on an exhausted frontier makes no requests and writes nothing.

The run ends normally for one of four reasons, reported in StoppedBecause with a nil error: the budget is spent, the frontier is empty, the only stubs left are past MaxDepth, or ctx was cancelled. An error means something actually failed; the result still reports what was committed before it.

func (*Graph) ProbableDuplicates

func (g *Graph) ProbableDuplicates(ctx context.Context) ([]DuplicateGroup, error)

ProbableDuplicates lists groups of fetched works whose titles match once case, punctuation and spacing are ignored.

It reports and never merges (D14). A matching title is evidence, not proof: a book review routinely carries the title of the book it reviews, and a preprint and its journal version are one work to a reader but two records that may differ. Duplicates that are certain — the same OpenAlex ID, a merged record, the same DOI — are already folded on the way in (D2, graph.hydrate); what is left here is for a person to judge.

func (*Graph) SearchTitle

func (g *Graph) SearchTitle(ctx context.Context, title string, limit int) ([]openalex.Candidate, error)

SearchTitle returns candidates for a title. It writes nothing: choosing is the user's job (ADR-005), and a wrong seed poisons every node grown from it.

type Seeded

type Seeded struct {
	// Work is the seed as stored, read back after the commit — so its depth,
	// flags and provenance are the library's, not the response's.
	Work model.Work

	// WasSeed reports the work was already a seed before this call.
	WasSeed bool

	// Recorded counts what was written for the seed's reference list. Adding a
	// seed that is already in the library records no new stubs or edges, which
	// is how the CLI can say "already there" without a special case.
	Recorded store.Recorded
}

Seeded reports what adding a seed did.

type Source

type Source interface {
	Resolve(ctx context.Context, id identity.ID) (model.HydratedWork, error)
	SearchByTitle(ctx context.Context, title string, limit int) ([]openalex.Candidate, error)
	GetWorksBatch(ctx context.Context, ids []string) (openalex.Batch, error)
}

Source is what the graph needs from an index of works. It is declared here, where it is consumed, and says only what this package uses — not a guess at the shape a second source will one day have. *openalex.Client satisfies it; tests satisfy it with a map.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL