Documentation
¶
Overview ¶
Package model holds the types shared across Filiation: Work, HydratedWork, Edge, Author, FrontierItem, ExpansionResult.
A leaf package — it imports nothing else in internal/, which is what lets store and sources both use these types without importing each other.
The one thing to get right here is that a Work may be a stub (ADR-003): the OpenAlex ID is known and nothing else is. Two conventions carry that through the codebase, and both are narrower than "the compiler prevents mistakes", so it is worth being exact about what each one buys:
Optional fields are pointers where NULL and the zero value mean different things. This does not stop anyone constructing a nonsensical Work. What it does, at every call site, is force a reader of Title or Year or DOI to acknowledge that the value may be absent, rather than handing back a zero that reads like data. Use DisplayTitle for anything a person will see.
State a Work cannot answer for itself does not live on Work. A reference list belongs to HydratedWork, because a Work loaded from the store has no reference list and would answer the dead-end question wrongly rather than refusing it. In-graph in-degree is likewise on FrontierItem and computed from cites, not carried on the work.
Index ¶
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func Ptr ¶
func Ptr[T any](v T) *T
Ptr returns a pointer to v.
The optional fields on Work are pointers, which makes constructing one by hand — in a test, in a fixture, at a call site with a literal — needlessly wordy without this. It exists so that the pointer discipline on Work stays cheap enough that nobody is tempted to argue it away.
Types ¶
type Author ¶
type Author struct {
OpenAlexID string
Name string
ORCID *string
Position int // 0-based; first author is 0
}
Author is one row of author, carrying its position from the authorship join.
type Edge ¶
type Edge struct {
FromWork string // the citing work — the newer one
ToWork string // the cited work
// Context is the sentence around the citation marker in the citing paper.
// Nobody provides this for free; it is what makes retrieval better than
// keyword search over abstracts.
Context string
// Intent and Section are extracted, therefore fallible — unlike the edge
// itself. Confidence exists so a caller can tell one from the other.
Intent Intent
Section Section
// Confidence in the Intent and Section extraction, 0..1. nil means nothing
// has tried yet, which is different from having tried and scored zero.
Confidence *float64
}
Edge is one row of the cites table: from_work cited to_work.
The direction is the direction of the citation, and it is the opposite of the direction time runs — FromWork is the newer paper. Getting this backwards is the easiest mistake in the codebase to make and the hardest to see, because a reversed graph still looks like a graph.
An edge is ground truth. It comes from OpenAlex's resolved referenced_works, not from parsing a reference list (hard rule 2), so it is not a guess and nothing in this codebase is permitted to infer one.
type ExpansionResult ¶
type ExpansionResult struct {
SeedID string
Hydrated int // works fetched during this run
Stubs int // works left unhydrated on the frontier
Edges int // citation edges recorded during this run
Unresolved int // IDs OpenAlex had no record of
// Merged counts works OpenAlex holds twice — a second record under a new
// ID with the same DOI — folded into the record already in the library
// rather than added as a duplicate node (hard rule 6).
Merged int
// Skipped counts works left as stubs because their batch failed for a
// reason that may not recur. §7: one bad batch must not kill a 500-node
// run. They stay on the frontier, so the next run picks them up.
Skipped int
// DeadEnds counts hydrated works that came back with no reference list, so
// the graph cannot continue through them. Reporting it is not optional
// polish: in arts and humanities it is the great majority of the frontier
// (D12), and a user who is not told will read a graph that stops after one
// hop as a broken tool rather than as missing data.
DeadEnds int
MaxDepthReached int
StoppedBecause StopReason
Duration time.Duration
}
ExpansionResult is what an expansion did. It is a report, not an error type — a run that stops on its budget succeeded.
func (ExpansionResult) ReferenceCoverage ¶
func (r ExpansionResult) ReferenceCoverage() float64
ReferenceCoverage is the fraction of works hydrated in this run that arrived with a usable reference list, 0..1. It returns 0 for an empty run rather than dividing by zero.
type FrontierItem ¶
type FrontierItem struct {
OpenAlexID string
Depth int
// InDegree is how many works already in this library cite this one.
//
// It is the ranking signal that makes expansion best-first rather than
// breadth-first, and it is free: computed from edges already stored, with no
// API call. A work cited by five papers the user already has is a better use
// of the budget than one cited by a single paper, whatever the global count
// says about either.
InDegree int
}
FrontierItem is one candidate for hydration: a stub the expander may spend part of its budget on.
It is deliberately not a Work. The frontier query runs once per batch over a graph that may hold tens of thousands of stubs, and it needs three columns, not thirty.
type HydratedWork ¶
HydratedWork is a Work as it comes back from OpenAlex, with the reference list still attached.
The reference list is the reason this type exists. It arrives inside the work object, which is what lets RecordEdgesAndStubs write the edges and the stub rows in the same transaction that hydrates the work (ADR-003, §4). But it is never a column: the cites table is the record, and nothing reads a reference list back out of the database.
Keeping it off Work is not tidiness. A Work loaded from the store has FetchedRefs true and no reference list, which is indistinguishable from a work that was asked and had none — so a dead-end test living on Work would report every persisted work as a dead end. Splitting the types makes that unrepresentable rather than merely documented.
Only sources/openalex constructs one, and only the hydration path consumes it. Everything downstream takes the embedded Work.
func (*HydratedWork) IsDeadEnd ¶
func (h *HydratedWork) IsDeadEnd() bool
IsDeadEnd reports whether this work can never extend the graph: its references were fetched and there were none.
This is not a defect in the work or in the tool. Reference coverage is a property of the field — 6% dead ends in medicine against 84% in arts and humanities (D12) — and it has to be surfaced per node from day one, the same way OA status is, or users conclude the tool is broken when it is the data.
This answers the question only for a work in flight. For a work already in the library the answer comes from its out-degree in cites, computed by store at query time — the same way FrontierItem.InDegree is, and for the same reason: the edges are already stored, so the signal is free.
type Intent ¶
type Intent string
Intent classifies why one paper cited another (M6). Uncategorised is the honest default and must stay distinguishable from "not yet classified", which is IntentUnknown.
type OAStatus ¶
type OAStatus string
OAStatus mirrors work.oa_status.
const ( OAUnknown OAStatus = "" OAGold OAStatus = "gold" // published open in a fully OA venue OAGreen OAStatus = "green" // author copy in a repository OAHybrid OAStatus = "hybrid" // open article in a subscription venue OABronze OAStatus = "bronze" // free to read on the publisher site, no open licence OAClosed OAStatus = "closed" )
func (OAStatus) IsOpen ¶
IsOpen reports whether OpenAlex considers a free-to-read copy to exist.
It is a display and filtering predicate, not a download gate. Nothing may fetch a PDF on the strength of a status string: the only sanctioned input is an actual OA location, Work.OAURL. Bronze is the reason the distinction is not pedantic — free to read on the publisher's own site, with no licence permitting anything else. See hard rule 1.
type Section ¶
type Section string
Section is where in the citing paper the citation appeared. It is a useful prior on intent: a citation in Methods is far more likely to be a method citation than one in the introduction.
type Source ¶
type Source string
Source records how a work entered the library, mirroring work.source.
type StopReason ¶
type StopReason string
StopReason says why an expansion ended. It is a named reason rather than a bool because three of the four outcomes are entirely normal and the CLI has something different and useful to say about each: budget exhausted invites --max-nodes, an empty frontier means the graph genuinely ends there, and only cancellation is the user's own doing.
const ( StopFrontierEmpty StopReason = "frontier-empty" StopBudgetExhausted StopReason = "budget-exhausted" StopMaxDepth StopReason = "max-depth" StopCancelled StopReason = "cancelled" )
type Work ¶
type Work struct {
// OpenAlexID is the primary key and the deduplication key (hard rule 6).
// Bare form, "W2741809807" — not the https://openalex.org/... URL OpenAlex
// returns. Normalising that is internal/identity's job, and the store should
// reject anything else rather than silently create a second node for the same
// paper under a different spelling.
OpenAlexID string
// External identifiers. All absent on a stub, and often absent afterwards:
// roughly a third of hydrated works have no DOI.
DOI *string
ArXivID *string
PMID *string
// Bibliographic metadata. All zero on a stub.
Title *string
Abstract string
Year *int
Venue string
Type WorkType
// CitedByCount is OpenAlex's global count — how often the whole world cites
// this work. It is not the in-graph in-degree, which is computed from cites
// and is the better ranking signal because it is relative to this library.
// See FrontierItem.InDegree.
CitedByCount int
// Open access. OAURL is the only sanctioned route to a full text (hard rule 1).
OAStatus OAStatus
OAURL *string
// Full text on disk. Both nil until M3.
PDFSHA256 *string
PDFLicense *string
// State flags, all persisted so that expansion resumes from the frontier
// rather than starting over.
Hydrated bool // metadata fetched
FetchedRefs bool // this work's own references have been recorded
Unresolved bool // OpenAlex has no record of it — stop retrying
// Provenance.
Depth *int // hops from the nearest seed; nil until the work is first reached
IsSeed bool
Source Source
AddedAt time.Time
// Authors comes from the authorship join and is populated only when a caller
// asks for it, so the nil-versus-empty distinction carries meaning here the
// same way it does for the pointer fields above:
//
// nil not loaded — this query did not ask for authors
// []Author{} loaded, and the work genuinely has none
//
// Use AuthorsLoaded rather than len() to tell them apart. A byline hidden on
// len(Authors) == 0 disappears for every work whose authors were simply never
// queried.
Authors []Author
}
Work is one row of the work table, plus the fields that only exist in flight.
A Work is in one of two states (ADR-003):
stub Hydrated == false. The OpenAlex ID is known and almost nothing else.
Created the moment some other paper is seen to reference it.
hydrated Hydrated == true. Metadata has been fetched.
Stubs are the common case, not the exception: a 500-node graph reached from one seed is mostly stubs until the budget is spent on them. Every read path meets them. That is why the optional fields are pointers — the compiler asks the question rather than handing back a zero value that reads like real data.
Nullable columns are modelled as pointers only where NULL and the zero value mean different things. Two cases matter:
- DOI. The column is UNIQUE. SQLite allows many NULLs in a unique index but only one empty string, so a string-typed DOI would make the second stub without one fail to insert. This one is a correctness requirement.
- Year, Depth. Zero is a value a caller could believe. Absent is not.
Where the empty string is an honest way to say "not set" and nothing downstream can misread it — Abstract, Venue — a plain string is used and stored as NULL by the store layer.
func (*Work) AuthorsLoaded ¶
AuthorsLoaded reports whether the authorship join was performed for this work. It distinguishes a work with no authors from one whose authors were not asked for; see the Authors field.
func (*Work) DisplayTitle ¶
DisplayTitle is the safe way to name a work in output meant for a human. It never returns the empty string, so a CLI table or an export cannot end up with a blank row that looks like a bug in the tool rather than a work not yet fetched.