Skip to content

Python API reference

Key classes and functions in researcher_profiles. The core install (pydantic + pyyaml) provides the schema models, ResearcherProfile, and the OpenAlex parser. Other surfaces attach or import only when their extra is present.

For the narrative behind the module layout, the storage interface, the capability managers, and the export idempotency contract, see the developer notes in docs-dev/rp-sdk/explanation/sdk-architecture.md.

SurfaceImportExtra
Schema models, ResearcherProfile, OpenAlex parserresearcher_profilescore
resolve_personresearcher_profiles.resolvecore
prof.index (SqliteEmbeddingIndex), store.match / .centroids / .indexesresearcher_profiles.embeddings, researcher_profiles.analytics[vectors,st]
prof.persona (ask / review / innovate / riff / chat)researcher_profiles.generative[llm]
ApiArtifactStorage, StaticArtifactStorage, .from_api(), .from_url(), rank_against(), resolve_rid()researcher_profiles.client[client]
ProfileStore (protocol), FilesystemProfileStoreresearcher_profiles.storecore
create_appresearcher_profiles.api.app[api]
ArtifactStorage (ABC), DirectoryArtifactStorageresearcher_profiles.profile.storagecore
SqlProfileStore, SqlArtifactStorage, .from_db(), the rp_* tablesresearcher_profiles.store.db, researcher_profiles.store.sql[sql]

Each capability manager is built lazily on first access, so a core-only install still imports the package and a missing extra raises an ImportError naming it at first use. The optional public names (LLMClient, ApiArtifactStorage, SqlProfileStore, …) do the same when accessed.


researcher_profiles.ResearcherProfile is a profile loaded from a directory. Persistence goes through a composed ArtifactStorage, and capability managers (prof.persona, prof.index, prof.cite, prof.coverage, prof.topics, prof.edit) hang off the aggregate. Both are described in docs-dev/rp-sdk/explanation/sdk-architecture.md.

class ResearcherProfile(storage: ArtifactStorage, eager: bool = False)

Section titled “class ResearcherProfile(storage: ArtifactStorage, eager: bool = False)”

A researcher profile loaded from a directory on disk.

Use from_files as the documented entry point; the bare constructor is the same code path with no validation.

affiliation: str | None

build_state: BuildState : Build bookkeeping from .build/<slug>/meta/build_state.json.

citations: Any

cite: Any : prof.cite: get, many, verify, export.

coverage: Any : prof.coverage: get, staleness, recent_work, last_updated.

directory: Path | None : The filesystem directory backing this profile, or None.

edit: Any : Owner metadata, SOUL, and visibility changes.

expertise: str

field: str | None

grants: list[GrantRecord] : Grant records from sources/grants.jsonld; empty when absent.

has_persona: bool : Whether this profile can role-play as a synthesized persona.

index: Any : prof.index: build, search, search_similar, embedding.

level: str : Profile depth tier: "lite" / "full" / "deep".

license: str | None : Reuse terms for the published record.

metadata: ProfileDocument : The parsed profile.jsonld document.

name: str

orcid: str | None : Derived from rid; None for a locally-minted identity.

papers: list[PaperRecord]

persona: Any : prof.persona: ask, review, innovate, riff, chat.

provenance: str : Who asserted this profile and on what basis.

rid: str : The identity of this profile: a canonical ORCID or a local: id.

slug: str : Display handle, derived from the profile directory name.

soul: str

storage: ArtifactStorage : The composed backend. Where every artifact of this profile lives.

summaries: Mapping[str, str]

summary: str | None

topics: Any : prof.topics: get, relevance.

from_api(url: str, slug: str | None = None, token: str | None = None, timeout: float = 60.0, client: Any = None)
Section titled “from_api(url: str, slug: str | None = None, token: str | None = None, timeout: float = 60.0, client: Any = None)”

Construct from a live researcher_profiles.api server.

url may be the server root (e.g. http://localhost:8109), in which case slug must be provided, or a slug-qualified URL like http://localhost:8109/api/v1/profiles/jane-doe.

The returned object is an ordinary ResearcherProfile over an ApiArtifactStorage: read-only, with an HTTP-backed persona and a search-only index. The import is deferred, same shape as from_db, so a core-only install still imports this module.

from_db(engine_or_url: Any, ref: str, eager: bool = False)
Section titled “from_db(engine_or_url: Any, ref: str, eager: bool = False)”

Construct from the SQL profile store. Requires the sql extra.

engine_or_url is a SQLAlchemy Engine or a connection URL; ref is a rid or a slug (rid wins, as everywhere)::

prof = ResearcherProfile.from_db("postgresql://…/rp", "jane-doe")

The import below is deferred, the same shape as from_api / from_url: naming this method must not pull SQLAlchemy, which is exactly the cost the sql extra exists to avoid.

from_files(path: str | os.PathLike, eager: bool = False, validate: bool = False)
Section titled “from_files(path: str | os.PathLike, eager: bool = False, validate: bool = False)”

Construct from a local directory or a published profile URL.

path may also be an http(s):// or s3:// URL pointing at a statically published profile directory, in which case this delegates to from_url.

validate: run the on-disk format check and raise on any error. Off by default: most callers tolerate gaps (missing sources/, missing citations.json, etc.) which are common during profile construction. Requires a local directory.

from_url(url: str, eager: bool = False, timeout: float = 30.0, client: Any = None)
Section titled “from_url(url: str, eager: bool = False, timeout: float = 30.0, client: Any = None)”

Construct from a published profile directory on a static host.

url points at one profile’s directory, e.g. https://profiles.example.org/jane-doe or s3://my-bucket/profiles/jane-doe (public buckets only; the s3 scheme is translated to the HTTPS endpoint). For a live API server, use from_api instead.

list_remote(url: str, token: str | None = None, timeout: float = 30.0, client: Any = None)
Section titled “list_remote(url: str, token: str | None = None, timeout: float = 30.0, client: Any = None)”

List the profiles a remote server holds.

Returns plain dicts shaped like ProfileSummary. A listing is not a profile, so this is a classmethod on the entry-point class rather than anything on an instance.

Register a callable to run after a write unit commits successfully.

The pre-commit/post-commit distinction is about what a hook is allowed to do, not only when it runs. A pre-commit hook observes a write in progress and may abort it (a raise rolls the unit back). A post-commit hook observes a write that has already landed and must not abort anything, because there is nothing left to roll back. Use this for fire-and-forget notifications to something outside the store (e.g. pushing to an external search index) where blocking, or failing, a profile write on that system’s availability would be wrong.

Fires exactly once per outermost write_unit, after that unit’s own commit. Nested units never fire it, mirroring how pre-commit hooks collapse a batch of writes wrapped in one explicit write_unit into a single hook run. A raising hook is caught and logged at WARNING, never propagated; see _fire_post_commit_hooks.

Registration belongs on the store, not on an HTTP app, for the same reason add_pre_commit_hook does: it must fire for API routes, CLI writes, and out-of-process pipeline writes alike.

Register a callable to run inside every write unit, before commit.

Hooks run in registration order and receive one WriteContext. A hook that raises aborts the write (see write_unit).

Registration belongs on the store, not on an HTTP app: a hook registered here fires for API routes, CLI writes, and out-of-process pipeline writes alike. ProfileStore.add_pre_commit_hook is the store-level entry point that threads hooks onto every profile it hands out.

Generate the manifest from what the backend actually holds.

The backend answers with a directory walk for files, the rp_artifacts rows for SQL, or the recorded manifest for a static host, so there is no directory walk here.

With write=True the regenerated hasPart / subjectOf are stored back through save_profile, which stamps dateModified: a manifest whose sha256 entries moved is a real content change and the vintage must follow it.

Release whatever the backend holds open (an HTTP client, say).

A no-op on a backend with nothing to close, so with ResearcherProfile.from_api(...) as prof: works everywhere.

"sha256:<hex>" over the canonical document and the SOUL text.

Store-maintained derived state, refreshed inside the write unit before the pre-commit hooks run, so a hook reading it observes post-write content. Identical across backends by construction.

Remove one paper summary body; absent is not an error.

A human-readable locator for a logical artifact, display only.

Never parse this, never join to it, never open it. Storage backends return whatever names the artifact best: a path, a URL, a table/row reference. It exists so error messages and CLI output can say where something lives without anyone assuming that where is a directory.

The manifest as recorded in profile.jsonld (hasPart + subjectOf).

The serialized document the store currently holds; {} when absent.

The dateModified comparison basis. Public because an ingest legitimately needs it, though it is not metadata, which is validated and may carry unsaved edits.

Drop cached reads so the next access re-fetches.

Clears this profile’s own lazy slots and asks the backend to drop any caching of its own (ApiArtifactStorage holds the combined detail payload).

The backing directory, or a CapabilityUnavailableError.

The one place a directory-needing capability asks, so the message naming the way out is written once instead of in three backends.

The rid, or "" when no document is readable yet.

A profile being created has no readable document; identity is not a precondition for writing one. Used to name a write unit.

save_build_state(state: BuildState | None = None)
Section titled “save_build_state(state: BuildState | None = None)”

Persist build state; None persists what is in memory.

Build state is separate from published content. A backend may serve published content while having no build state at all. That is exactly what ReadOnlyArtifactStorage.load_build_state models by returning an empty BuildState.

Persist the citation graph; None deletes it.

Omitting data persists what is in memory. That is why the default is the UNSET sentinel and not None: None is a meaningful value here.

Persist the expertise narrative; None persists what is in memory.

save_grants(grants: list[GrantRecord] | None = None)
Section titled “save_grants(grants: list[GrantRecord] | None = None)”

Persist the grant records; None persists what is in memory.

save_papers(papers: list[PaperRecord] | None = None)
Section titled “save_papers(papers: list[PaperRecord] | None = None)”

Persist the paper corpus; None persists what is in memory.

save_profile(doc: ProfileDocument | None = None)
Section titled “save_profile(doc: ProfileDocument | None = None)”

Validate, stamp dateModified, and persist the profile document.

The single write path for the profile document. doc defaults to the current in-memory metadata.

The order matters and is fixed: dump -> stamp against what the store already holds -> canonicalize -> re-validate -> persist. Re-validation happens before the storage hook is called, so a document that would fail to load never reaches the store and the in-memory profile is untouched.

Stamping compares serialized dicts, not models: pydantic prunes None and empty collections, and comparing an un-normalized input against a normalized stored document would report a change on every write (see researcher_profiles.utils.date_modified). Any dateModified already sitting in the dumped document is ignored: it was carried over from whatever was loaded, and honouring it would freeze the stamp at whatever the first build wrote.

Returns the re-parsed ProfileDocument.

Persist the SOUL narrative; None persists what is in memory.

Persist one paper summary body.

set_paper_contaminated(paper_id: str, contaminated: bool)
Section titled “set_paper_contaminated(paper_id: str, contaminated: bool)”

Flag a paper contaminated. Returns True if the paper exists.

Contamination is build state, so this mutates .build/<slug>/meta/build_state.json through save_build_state and never touches the published record.

Return the minimal dict an external agent runtime needs to seed an agent row from this profile.

Intended for downstream tools that want to ground a simulated agent in a real researcher’s profile but do not need the full to_dict() payload at seeding time. Includes only identity fields plus the full expertise/soul markdown bodies and a paper count.

Return a JSON-serializable dict representation.

By default summary_ids (only the keys) is included to keep payloads small. Pass include_summaries=True to substitute summaries: {id: text} with the full bodies.

Validate this profile directory against the on-disk format schemas.

Returns a researcher_profiles.validate.ProfileValidationReport, which carries an ok property, the per-artifact results, and the violations behind them.

The transactional boundary for one logical write.

A one-line delegation to researcher_profiles.profile.write_unit.WriteUnit.open, which owns the ordering and failure contracts. This stays a real method because api.deps and the guardrail suite monkeypatch it.

class EditManager(profile: ResearcherProfile)

Section titled “class EditManager(profile: ResearcherProfile)”

Owner-edit policy and mutation surface for one profile.

Patch owner-editable metadata fields and persist the document.

Replace the free-form SOUL/persona narrative.

set_visibility(profile_visibility: str | None = None, artifacts: list[dict[str, Any]] | None = None)
Section titled “set_visibility(profile_visibility: str | None = None, artifacts: list[dict[str, Any]] | None = None)”

Set the profile-level and per-artifact privacy tiers.

Base exception for researcher_profiles.

class ProfileLoadError(location: Any, message: str, original: Exception | None = None)

Section titled “class ProfileLoadError(location: Any, message: str, original: Exception | None = None)”

Raised when a stored profile artifact is malformed or unreadable.

location is untyped, mirroring ProfileWriteError: the filesystem backend passes a Path, a static host passes a URL, a database backend passes a table/row reference.

location

original

class ProfileWriteError(location: Any, message: str, original: Exception | None = None)

Section titled “class ProfileWriteError(location: Any, message: str, original: Exception | None = None)”

Raised when a profile artifact cannot be persisted, or when the backing store is read-only.

Mirrors ProfileLoadError’s signature, but location is untyped: the filesystem backend passes a Path, a static host passes a URL, a database backend passes a table/row reference.

location

original

A capability needs something this backend does not have.

both a ProfileError and a NotImplementedError, for the same reason ProfileNotFoundError is both a ProfileError and a KeyError: existing callers catch NotImplementedError, while a management host wants to catch it with the package’s other errors.

Raised when from_files(..., validate=True) produces a non-ok report.

report

Raised when a persona method is called on a profile with no persona.

The persona endpoints (.ask/.review/.innovate/.riff) require a fully-synthesized profile: both expertise.md and SOUL.md must be present and non-empty (see ResearcherProfile.has_persona). An incomplete / “lite” profile is a client-visible precondition failure, not a server error, and must not silently role-play an empty persona. Subclasses ProfileError so library callers can catch either this or the base.

slug


researcher_profiles.profile.export turns one profile into a single block of prose plus the metadata that travels with it, so a connector pushing profiles into a knowledge base (KB) never has to open profile.jsonld, expertise.md, SOUL.md and papers.jsonld itself, re-implement the privacy rule, or invent a change detector. The idempotency contract behind content_hash is explained in docs-dev/rp-sdk/explanation/sdk-architecture.md.

render_export_text(profile: ResearcherProfile, options: ExportOptions | None = None, papers: Sequence[PaperRecord] | None = None)

Section titled “render_export_text(profile: ResearcherProfile, options: ExportOptions | None = None, papers: Sequence[PaperRecord] | None = None)”

Render one profile as a single deterministic markdown blob.

The blob is the whole payload a knowledge base ingests: a synthesized narrative (identity, expertise, interests, career, then the expertise.md and SOUL.md bodies verbatim) followed by the prose of a selected, topic-representative set of papers. How it is chunked and embedded is entirely the consumer’s business.

Deterministic by contract. There is no timestamp, counter, hash, or host-dependent value anywhere in the output: two renders of an unchanged directory are byte-identical. ProfileExportBundle.content_hash depends on that.

Pass papers to skip selection entirely. That is the override for a caller that has embeddings and can choose better than this module can.

Raises ExportVisibilityError when the profile document’s visibility is above public and options.allow_nonpublic is not set.

select_export_papers(profile: ResearcherProfile, options: ExportOptions | None = None)

Section titled “select_export_papers(profile: ResearcherProfile, options: ExportOptions | None = None)”

The topic-representative subset of a profile’s papers, deterministically.

Filter (untitled, contaminated, bodyless, non-public), score (authorship + impact + recency), then diversify greedily so the selection spans the researcher’s topics instead of stacking their most-cited cluster. No embeddings are involved. A caller that has vectors should select its own list and pass it to render_export_text instead.

Returned in presentation order (year desc, then title), which is not selection order.

build_export_bundle(profile: ResearcherProfile, options: ExportOptions | None = None, papers: Sequence[PaperRecord] | None = None, now: str | None = None)

Section titled “build_export_bundle(profile: ResearcherProfile, options: ExportOptions | None = None, papers: Sequence[PaperRecord] | None = None, now: str | None = None)”

Render a profile and wrap it with the metadata a knowledge base needs.

The returned bundle is the whole handoff: a connector maps its fields onto the destination’s document schema, upserts keyed on rid, and uses content_hash as the change detector. Re-running a backfill is then a no-op, which is what makes a first load and a later refresh the same operation.

explore_url(profile_url: str | None, explore_base: str | None = None)

Section titled “explore_url(profile_url: str | None, explore_base: str | None = None)”

The browser-app backlink for a published profile directory.

Returns None unless both a profile URL and an explore base are given. The hash shape matches what the browser app parses back to the same directory: a trailing slash, profile.jsonld stripped, percent-encoded.

export_content_hash(payload: Mapping[str, Any])

Section titled “export_content_hash(payload: Mapping[str, Any])”

"sha256:<64 hex>" over payload in canonical JSON form.

jsonld.canonical_dumps fixes key order and formatting, so the digest is reproducible across machines and Python versions rather than depending on dict insertion order.

export_paper_body(profile: ResearcherProfile, paper: PaperRecord, options: ExportOptions | None = None)

Section titled “export_paper_body(profile: ResearcherProfile, paper: PaperRecord, options: ExportOptions | None = None)”

Return (text, source) for one paper’s prose.

source is "summary" or "abstract". The summary body is the markdown after any YAML frontmatter fence (whose shape is schema.SummaryFile), with the [abstract-only] bookkeeping marker removed and whitespace normalized. Returns ("", "abstract") when the paper has no usable body at all.

Every knob the export surface has.

Options live on this model rather than in positional parameters so a downstream connector never breaks on a signature change: a new knob is a new field with a default, and existing callers keep working untouched.

allow_nonpublic: bool = False : Permit export of a profile whose document visibility is above public.

char_budget: int | None = 120000 : Soft cap on total blob characters. Whole paper blocks are dropped from the end; a body is never truncated mid-text. None disables it.

diversity: float = 0.7 : Overlap-penalty weight in the greedy paper selector (0..1).

explore_base: str | None = None : Base URL of a profile browser app. When unset, the bundle carries no explore_url.

include_expertise_doc: bool = True : Include personality/expertise.md.

include_soul: bool = True : Include personality/SOUL.md.

max_papers: int = 40 : Cap on papers whose text enters the blob.

prefer_summaries: bool = True : Use a paper’s sources/summaries/.summary.md body when present, falling back to its abstract.

profile_url: str | None = None : Override for the published profile URL (default: metadata.url).

One paper whose prose is inside the rendered text.

body_source: Literal[‘summary’, ‘abstract’]

doi: str | None : Normalized and bare (10.xxxx/yyy), never a resolver URL.

paper_id: str | None

title: str

year: int | None

One profile, rendered for a knowledge base: the text plus what travels with it.

dois and papers are different things and must not be conflated. dois is the profile’s full corpus DOI list: every paper in sources/papers.jsonld, normalized and de-duplicated, in document order. A KB uses it to link this profile to works it already holds, whether or not their prose is in this export. papers is the much smaller subset whose prose is actually inside text.

content_hash is the idempotency key. It covers every field except itself and built_at (including summary, which is real content, so an edited summary triggers a refresh), hence two builds seconds apart hash identically and a KB upserts only when the hash changes.

affiliation: str | None

built_at: str

conforms_to: str

content_hash: str

dois: list[str]

explore_url: str | None

export_version: int

field: str | None

level: str

license: str | None

name: str

orcid: str | None

paper_count: int

papers: list[ExportPaperRef]

profile_url: str | None

provenance: str

rid: str

sdk_version: str

slug: str

summary: str | None : The profile’s one-paragraph self-description (metadata.summary), the same value rendered into the blob’s ## Overview. Carried as its own field so a connector can use it as a short document description without re-opening profile.jsonld. This package owns profile reading.

text: str

text_chars: int

visibility: str

Base error for the knowledge-base export surface.

The profile is not public and the caller did not opt in.

Pass allow_nonpublic=True (CLI: --allow-nonpublic) when the destination is authorized to hold the profile’s tier.


Cross-profile analytics (store.match, store.centroids, store.indexes)

Section titled “Cross-profile analytics (store.match, store.centroids, store.indexes)”

There is no aggregate object over a store: a collection of profiles is the store. The three cross-profile concerns hang off it as accessors, the same way prof.cite hangs off a profile. They require the [vectors,st] extras (they need numpy) and are imported on first access, so a core-only install still uses the store for everything else.

Ranking and centroids also need the VectorStore capability; a store without it raises CapabilityUnavailableError naming what to do instead.

Two profiles in one store claim the same rid.

A store is the only place this can be detected, because it is the only place that sees every profile at once. The filesystem backend is the only one that can hit it: SQL makes it structurally impossible (rid is the primary key) and a published site’s by-rid.json is a mapping that cannot hold two entries under one key.

store.centroids (centroids and the query backend)

Section titled “store.centroids (centroids and the query backend)”

class CentroidManager(store: VectorStore, rostered: _RosterCache)

Section titled “class CentroidManager(store: VectorStore, rostered: _RosterCache)”

store.centroids, the centroid matrix and the query embedder.

Both live here because they must agree: a query vector is only comparable to the matrix when it came out of the same backend the indexes were built with.

backend : The backend queries are embedded with, resolved once and kept.

cache_path: Optional[Path] : <root>/.cache/centroids.npz, or None without a root.

matrix: np.ndarray : L2-normalized centroids, one row per profile, slug-ordered.

text as a unit vector in the same space as matrix.

Drop the in-memory matrix and unlink the .npz.

(roster, matrix), guaranteed row-aligned.

The pair, not the matrix alone, is what ranking needs: row i of the matrix is roster.slugs[i] for as long as the caller holds both. A caller that fetched the roster and the matrix in two steps could be handed a matrix rebuilt across a write in between, and would then read every score off the wrong profile.

class MatchManager(store: VectorStore, rostered: _RosterCache)

Section titled “class MatchManager(store: VectorStore, rostered: _RosterCache)”

store.match: ranking, diversification, topics, clustering.

topics_cache_path: Optional[Path] : <root>/.cache/topics.json, or None without a root.

Group profiles by centroid; each group is named for its exemplar.

diversify(matches: list[Match], k: int = 5, lambda_: float = 0.5)
Section titled “diversify(matches: list[Match], k: int = 5, lambda_: float = 0.5)”

Re-order by maximal marginal relevance: relevant but not redundant.

Unlink the topics view.

rank(text: str, k: int = 5, prefilter: int = 10, normalize: bool = True, require_topics: list[str] | None = None, diversify: bool = True, lambda_: float = 0.5, topk_chunks: int = 5)
Section titled “rank(text: str, k: int = 5, prefilter: int = 10, normalize: bool = True, require_topics: list[str] | None = None, diversify: bool = True, lambda_: float = 0.5, topk_chunks: int = 5)”

Rank every profile in the store against a free-text query.

Topic label -> the profiles claiming it. Also written to disk.

store.indexes (embedding-index maintenance)

Section titled “store.indexes (embedding-index maintenance)”

The object is analytics.IndexFleetManager, distinct from the per-profile profile.index.IndexManager that prof.index hands back.

class IndexFleetManager(store: ProfileStore, rostered: _RosterCache)

Section titled “class IndexFleetManager(store: ProfileStore, rostered: _RosterCache)”

store.indexes: build and report on every profile’s index.

Per-profile index / topic / calibration status, slug-ordered.

The rendered form of stats plus the two derived caches that do not live in the index file.

Rebuild every profile’s index, then drop everything derived from it.

Fail-soft per profile: one broken profile is reported as None and does not stop the rest.

slug -> IndexStats, one index open per profile.


ApiArtifactStorage and StaticArtifactStorage

Section titled “ApiArtifactStorage and StaticArtifactStorage”

researcher_profiles.client provides the two HTTP backends. It requires the [client] extra. Neither is a profile: build an ordinary ResearcherProfile over one with ResearcherProfile.from_api(url) or .from_url(url), and it behaves like a local profile, with the same properties and method signatures.

class ApiArtifactStorage(slug: str, base_url: str, token: Optional[str] = None, timeout: float = 60.0, client: Any = None)

Section titled “class ApiArtifactStorage(slug: str, base_url: str, token: Optional[str] = None, timeout: float = 60.0, client: Any = None)”

A profile served by a live researcher_profiles.api server.

Read-only: every writer is refused by ReadOnlyArtifactStorage. Reads come off the combined detail payload where the API has one, and off the dedicated routes otherwise.

base_url

directory: None

key: str

rid_hint: None

slug: str

Only what v1 serves: soul, expertise, and paper summaries.

Returns None otherwise. An invented empty body is worse than an empty column, and the v1 API has no route that hands back an arbitrary artifact’s bytes.

The manifest the served document records; there is nothing to walk.

"sha256:<hex>" over the served document + soul.

A read, so it stays supported on a read-only view. Computed from the combined detail payload rather than raw bytes, because the API serves a document, not a file.

search over HTTP; everything else needs the local sqlite handle.

Display-only locator: the API URL naming this artifact.

HTTP-backed persona: the server owns model, corpus and refusals.

Drop cached immutable resources so the next access refetches.

class StaticArtifactStorage(url: str, timeout: float = 30.0, client: Any = None)

Section titled “class StaticArtifactStorage(url: str, timeout: float = 30.0, client: Any = None)”

A published profile directory on a dumb static host (Pages, S3, …).

Unlike ApiArtifactStorage (which talks to a live researcher_profiles.api server), this fetches the published files themselves (profile.jsonld, personality/*.md, sources/*.jsonld) with one lazy GET per artifact. Summaries are enumerated from the manifest in profile.jsonld, since a static host has no directory listing.

persona and index are not overridden: the local managers are built, and the index’s require_directory refuses with the install_profile() message.

base_url

directory: None

key: str

rid_hint: None

slug: str

Whatever the host serves at that relative path.

A published static directory is the manifest’s address space, so any contentUrl is fetchable, subject to the host’s own access rules, which surface as PermissionError.

The recorded manifest: a static host has no directory to walk.

"sha256:<hex>" over the fetched document + soul.

A read, so it stays supported on a read-only view.

Display-only locator: the published URL naming this artifact.

rank_against(base_url: str, query: str, k: int = 5, token: Optional[str] = None, prefilter: int = 10, require_topics: Optional[list[str]] = None, diversify: bool = True, lambda_: float = 0.5, topk_chunks: int = 5, normalize: bool = True, include_chunks: bool = False, timeout: float = 60.0, client: Any = None)

Section titled “rank_against(base_url: str, query: str, k: int = 5, token: Optional[str] = None, prefilter: int = 10, require_topics: Optional[list[str]] = None, diversify: bool = True, lambda_: float = 0.5, topk_chunks: int = 5, normalize: bool = True, include_chunks: bool = False, timeout: float = 60.0, client: Any = None)”

Rank indexed profiles on a remote server against query.

Registry ranking is not profile-scoped, so this is a module-level function (not an ApiArtifactStorage method). POSTs to /api/v1/match and returns the parsed matches list (plain dicts shaped like MatchResult: {slug, name, orcid, score, evidence{...}}).

resolve_rid(base_url: str, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False, token: Optional[str] = None, timeout: float = 30.0, client: Any = None)

Section titled “resolve_rid(base_url: str, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False, token: Optional[str] = None, timeout: float = 30.0, client: Any = None)”

Resolve a person descriptor to a rid against the identity authority.

The consumer-side half of POST /api/v1/identity/resolve: a service holding a free-text name or a bare ORCID calls this at its boundary and stores the returned rid (as a scholarcore PersonRef), instead of hand-rolling HTTP or minting its own local id, which is worse. Resolution is authoritative and idempotent server-side: the same person resolved from two services gets the same rid.

rid is an ORCID or a local: id the resolver minted. token must carry the resolve scope (falls back to the RESEARCHER_PROFILES_TOKEN env var). A rid of None on the result means the name was undecidable. Inspect candidates and re-call with the chosen profile’s rid, or with create_new=True when none of the candidates is your person (the server mints a fresh identity; never guess between candidates).


researcher_profiles.openalex does pure record parsing, with no HTTP. It is available in core.

Parse a raw OpenAlex work dict into a PaperRecord.

Extracts the fields a profile needs: title/year (with a guard), first-author last name, journal (primary_location then host_venue), preferred pdf_url, bare doi, pmid (from ids), pmcid (from locations), cited_by_count, lowercased type, and the de-inverted abstract.

This function does not compute paper_id. That is ID policy, not OpenAlex parsing, so paper_id is left None for the caller to fill. cited_by_count, pmid and pmcid are carried as extra fields (PaperRecord allows extras).

Returns: A PaperRecord (status="pending"), or None when the work has no title or no publication year (not usable).

decode_abstract_inverted_index(idx: dict[str, list[int]] | None)

Section titled “decode_abstract_inverted_index(idx: dict[str, list[int]] | None)”

Reconstruct an abstract string from an OpenAlex abstract_inverted_index.

The OpenAlex inverted index format maps each word to the list of positions (0-based) where that word appears in the abstract:

{"Despite": [0], "decades": [1], "of": [2, 20, 38], ...}

Algorithm:

  1. If idx is None or empty -> return "" (no exception raised).
  2. Build a flat list of (position, word) pairs from all entries.
  3. Sort by position ascending.
  4. Detect duplicate positions: if two words claim the same position, log a warning and keep the first occurrence (stable-sort order).
  5. Detect gaps: if positions are not contiguous from 0 to max(pos), fill each missing position with a single space (log at DEBUG level).
  6. Join the resolved words with single spaces.
  7. Strip leading/trailing whitespace; collapse repeated internal whitespace.

Returns: The reconstructed abstract string, or "" on any structural failure.

The {title, abstract, year, doi, openalex_id, coauthors} lite shape.

doi is the bare DOI (no https://doi.org/ prefix). abstract is None (not "") when absent. This adapter maps the empty de-inversion result to None so an absent abstract is distinguishable from an empty one.

The single-work-match shape: identical to to_work_dict but with authors in place of coauthors (the citation checker keys off author names).


researcher_profiles.embeddings is a per-profile vector store. It requires the [vectors,st] extras.

class SqliteEmbeddingIndex(profile_dir: str | os.PathLike, profile_document: Any = None)

Section titled “class SqliteEmbeddingIndex(profile_dir: str | os.PathLike, profile_document: Any = None)”

sqlite-vec index for a single profile.

SCHEMA_VERSION

backend_spec: str : The embedding model this index was built with; "" when unbuilt.

db_path

index_dir

profile_dir

build_index(force: bool = False, backend: EmbeddingBackend | str | None = None)
Section titled “build_index(force: bool = False, backend: EmbeddingBackend | str | None = None)”

The L2-normalized mean of every chunk vector, npz-cached.

The other half of VectorIndex. Delegates to index_centroid, which is also what prof.index.embedding("centroid") reaches, so a centroid read through a store and one read through a profile are the same number out of the same cache. Imported inside the method: numpy is the vectors extra and the facts-about-the-file half of this class stays usable without it.

(n_chunks, n_papers).

Papers counts DISTINCT source_id over PAPER_CHUNK_TYPES, so a lite profile’s abstracts count as papers.

True when embeddings.sqlite is present.

index_meta as a dict; {} when there is no readable index.

The index file’s mtime, or None when it is absent.

search(query: str, k: int = 5, filter: dict | None = None)
Section titled “search(query: str, k: int = 5, filter: dict | None = None)”
search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5)
Section titled “search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5)”

exists + counts + index_meta + mtime.

build_index(profile, force: bool = False, backend = None)

Section titled “build_index(profile, force: bool = False, backend = None)”

Build (or refresh) the embedding index for a profile.

profile may be either a ResearcherProfile or a path-like pointing at a profile directory. The function is the thin, importable entry point used by the create/update pipeline; the heavy lifting lives on SqliteEmbeddingIndex.

researcher_profiles.profile.index.IndexManager is what prof.index hands back: build, search, search_similar, embedding. Distinct from analytics.IndexFleetManager (above), which operates over every profile in a root rather than one profile’s own index.

prof.index: the per-profile embedding index.

Needs a directory: .cache/embeddings.sqlite is a sqlite handle and the derived caches are not covered by ArtifactStorage. The check lives here, once, instead of in three subclass overrides.

build(force: bool = False, backend = None)
Section titled “build(force: bool = False, backend = None)”

Build (or refresh) this profile’s embedding index.

The profile-level vector: centroid / summary / expertise.

search(query: str, k: int = 5, filter: dict | None = None)
Section titled “search(query: str, k: int = 5, filter: dict | None = None)”

Semantic search over this profile’s chunks.

search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5)
Section titled “search_similar(source_type: str, source_id: str, chunk_index: int = 0, k: int = 5)”

Chunks nearest to one this profile already holds.

class SearchHit(text: str, source_type: str, source_id: str, chunk_index: int, section: str | None, cosine: float, score: float, meta: dict = dict())

Section titled “class SearchHit(text: str, source_type: str, source_id: str, chunk_index: int, section: str | None, cosine: float, score: float, meta: dict = dict())”

chunk_index: int

cosine: float

meta: dict

score: float

section: str | None

source_id: str

source_type: str

text: str

class IndexReport(added: int = 0, updated: int = 0, skipped: int = 0, removed: int = 0, backend_name: str = '', duration_s: float = 0.0)

Section titled “class IndexReport(added: int = 0, updated: int = 0, skipped: int = 0, removed: int = 0, backend_name: str = '', duration_s: float = 0.0)”

added: int

backend_name: str

duration_s: float

removed: int

skipped: int

updated: int

Raised when the chosen embedding backend is not importable / usable.

Raised when the existing index was built with a different backend.

Pass force=True to drop and recreate.

Raised when an index is read but none exists yet at that path.


researcher_profiles.models.results holds the return types for capability methods.

class PersonaResponse(text: str, citations: list[CitationRef], model: str, usage: dict, raw: Any, request_id: Optional[str] = None, refused: bool = False, refusal_reason: Optional[str] = None, grounded: bool = True)

Section titled “class PersonaResponse(text: str, citations: list[CitationRef], model: str, usage: dict, raw: Any, request_id: Optional[str] = None, refused: bool = False, refusal_reason: Optional[str] = None, grounded: bool = True)”

What every persona method returns: .ask, .review, Chat.send.

One shape, because the three answer the same kind of question: a piece of text written in the researcher’s voice, plus the papers it leaned on and the accounting for the call that produced it.

grounded is False when the model cited a paper_id that is not in this profile’s paper set: the answer may still be useful, but it left the corpus. refused is True when strict-corpus retrieval came back too weak to answer from and the persona declined rather than improvised; refusal_reason then carries the retrieval score and threshold. A refusal is a successful call, not an error.

raw is the provider’s own response object, for callers that need something this dataclass does not expose. It is local-only: the HTTP API never serializes it, so a response that arrived over the wire has raw=None.

citations: list[CitationRef]

grounded: bool

model: str

raw: Any

refusal_reason: Optional[str]

refused: bool

request_id: Optional[str]

text: str

usage: dict

Section titled “class Idea(hypothesis: str, approach: str, rationale: str, related_works: list[str])”

One proposed research direction from ResearcherProfile.innovate.

approach: str

hypothesis: str

rationale: str

related_works: list[str]

Map related_works citation keys to paper records (or None).

Returns a mapping {citation_key: PaperRecord | None}. Unknown keys map to None rather than raising.

Section titled “class Riff(angle: str, text: str, related_work: Optional[str] = None)”

One brainstorm fragment from ResearcherProfile.riff.

angle: str

related_work: Optional[str]

text: str

class CitationRef(paper_id: str, relevance: float | None = None, span: str | None = None)

Section titled “class CitationRef(paper_id: str, relevance: float | None = None, span: str | None = None)”

A lightweight reference to a paper used as evidence for a response.

paper_id: str

relevance: float | None

span: str | None

class Citation(paper_id: str, title: str, authors: list[str] = list(), year: int | None = None, venue: str | None = None, doi: str | None = None, url: str | None = None, pdf_url: str | None = None, abstract: str | None = None)

Section titled “class Citation(paper_id: str, title: str, authors: list[str] = list(), year: int | None = None, venue: str | None = None, doi: str | None = None, url: str | None = None, pdf_url: str | None = None, abstract: str | None = None)”

A fully-expanded citation record for a single paper.

abstract: str | None

authors: list[str]

doi: str | None

paper_id: str

pdf_url: str | None

title: str

url: str | None

venue: str | None

year: int | None

class Match(profile: Any, score: float, evidence: MatchEvidence, explanation: Optional[str] = None)

Section titled “class Match(profile: Any, score: float, evidence: MatchEvidence, explanation: Optional[str] = None)”

One profile match against a query, with score, evidence, and lazy explanation.

evidence: MatchEvidence

explanation: Optional[str]

profile: Any

score: float

explain(query: str = '', client: Any = None)
Section titled “explain(query: str = '', client: Any = None)”

Produce a short LLM-backed explanation of why this profile matched.

Cached on first call. Subsequent calls return the cached value.

class MatchEvidence(top_chunks: list[Any], top_papers: list[str], overlapping_topics: list[str], centroid_score: float)

Section titled “class MatchEvidence(top_chunks: list[Any], top_papers: list[str], overlapping_topics: list[str], centroid_score: float)”

Evidence supporting a profile match for a query.

centroid_score: float

overlapping_topics: list[str]

top_chunks: list[Any]

top_papers: list[str]

class Topic(label: str, weight: float, paper_ids: list[str] = list(), chunk_ids: list[int] = list())

Section titled “class Topic(label: str, weight: float, paper_ids: list[str] = list(), chunk_ids: list[int] = list())”

A topic cluster extracted from a profile’s chunk embeddings.

chunk_ids: list[int]

label: str

paper_ids: list[str]

weight: float

class Coverage(name: str, affiliation: str | None, one_liner: str | None, topics: list[str], expertise_summary: str, year_range: Optional[tuple[int, int]], paper_count: int, last_updated: datetime, staleness_label: str, suggested_questions: list[str], coverage_caveats: list[str])

Section titled “class Coverage(name: str, affiliation: str | None, one_liner: str | None, topics: list[str], expertise_summary: str, year_range: Optional[tuple[int, int]], paper_count: int, last_updated: datetime, staleness_label: str, suggested_questions: list[str], coverage_caveats: list[str])”

Landing-page metadata: what a profile covers and how fresh it is.

affiliation: str | None

coverage_caveats: list[str]

expertise_summary: str

last_updated: datetime

name: str

one_liner: str | None

paper_count: int

staleness_label: str

suggested_questions: list[str]

topics: list[str]

year_range: Optional[tuple[int, int]]

class GenerativeParseError(message: str, raw_text: str = '')

Section titled “class GenerativeParseError(message: str, raw_text: str = '')”

Raised when the LLM repeatedly fails to return valid JSON.

raw_text

PersonaUnavailableError is documented under ResearcherProfile exceptions above.


The collection-level interface: a set of profiles. Each profile’s artifacts live one level down in ArtifactStorage. It is what an HTTP app, an ingest path, or a management host is handed, and it is what create_app takes. The “one filesystem admission” (store.root) is explained in docs-dev/rp-sdk/explanation/sdk-architecture.md.

A set of profiles, whatever they are stored in.

Every method takes ref: a slug or a rid. Resolution is the store’s job, not the caller’s. An HTTP route that had to know which namespace it was handed would be re-implementing resolve_slug at every entry point.

location: str : A human-readable locator for this store. Display only.

root: Optional[Path] : The filesystem root when this store is a directory, else None.

Register a callable to run after a write commits successfully.

Same reach as add_pre_commit_hook. The difference is what the hook may do: a pre-commit hook can still abort the write by raising; a post-commit hook cannot, because the write already landed. Use this for fire-and-forget notifications to something outside the store (for example, pushing to an external search index). See researcher_profiles.profile.ResearcherProfile.add_post_commit_hook.

Register a callable to run inside every write, before commit.

Registration belongs here, on the store, and not on an HTTP app: a hook registered on an app fires only for writes that went through a route that remembered to fire it, so a background writer that bypasses the routes would skip it. Applies to profiles handed out from now on and to any already handed out, so registration order relative to a first get does not matter.

artifact_bytes(ref: str, content_url: str)
Section titled “artifact_bytes(ref: str, content_url: str)”

One manifest artifact’s bytes, addressed by its contentUrl.

Raises ProfileNotFoundError when the profile or the artifact is absent. Applies no privacy policy: the caller has already decided who may read this (see api.routers.read.get_profile_artifact).

commit_directory(slug: str, staging: Path, build_missing_index: bool = False)
Section titled “commit_directory(slug: str, staging: Path, build_missing_index: bool = False)”

Make a fully-staged profile directory live in this store.

staging is a complete profile directory (profile.jsonld at its root). The directory is the interchange format, not the storage format: a tarball push, a URL import and rp db push all speak it. Each backend decides what “live” means: an atomic rename for the filesystem, a transaction for SQL.

build_missing_index asks for a best-effort embedding-index build afterwards, for a host that wants an ingested profile immediately rankable. A backend with no filesystem accepts it and ignores it.

Raises UploadError if the staged directory does not load.

"sha256:<hex>" over the canonical document and the SOUL text.

Store-maintained derived state, refreshed inside the write unit before the pre-commit hooks run, so a hook reading it observes post-write content. Identical across backends by construction; see researcher_profiles.store.db.content_hash_for.

create(document: ProfileDocument, slug: str)
Section titled “create(document: ProfileDocument, slug: str)”

Create a new profile from a validated document. Returns it.

Runs in one write unit of kind "create", so a management host’s pre-commit hook can write its ownership row in the same transaction. That closes the orphan-profile window between “the profile exists” and “somebody owns it”.

Raises ProfileWriteError if slug or the document’s rid is already taken.

Remove a profile and everything belonging to it. Returns its rid.

The canonical profile.jsonld bytes, exactly as persisted.

What a crawler fetching /profiles/{slug}/profile.jsonld receives has to be the published document, byte for byte, or the conformsTo claim is about a file nobody can retrieve.

Drop any cached view of ref so the next get reloads.

A no-op on a store that does not cache. Cache invalidation only: dependent-state maintenance belongs on add_pre_commit_hook, where it runs inside the write instead of after it.

Whether ref names a profile in this store.

Materialize a profile as a directory at dest. Returns dest.

The inverse of commit_directory, and the way out of any store for the capabilities that need a real directory (validation, the embedding index).

Load a profile. Raises ProfileNotFoundError.

The returned profile carries every hook registered through add_pre_commit_hook, so a write through it fires them whether it came from an HTTP route, the CLI, or an out-of-process pipeline run.

Every profile’s display handle, sorted.

put_document(slug: str, document: ProfileDocument)
Section titled “put_document(slug: str, document: ProfileDocument)”

Create-or-replace a profile’s canonical profile.jsonld.

Runs in one write unit (kind "create" if new, "edit" if it exists), so hooks fire and dateModified is stamped. Derived artifacts (sources/, .cache/) are left untouched: this writes the document, not the bundle.

This is the JSON upsert path: what PUT /api/v1/profiles/{slug} dispatches to when the request body is application/json rather than a tarball. It writes identity + expertise + metadata; enrichment (papers, embeddings) is added later by a push or a build.

Returns the profile, so the caller can read rid / name / level for the response.

Map a slug or a rid to the slug. Raises ProfileNotFoundError.

Map a slug or a rid to the rid. Raises ProfileNotFoundError.

The mirror of resolve_slug. Both exist because the two are used for different things: rid is the identity every cross-system mapping joins on; slug is what a URL and a human say. A bare ORCID resolves here too, because an ORCID rid is its ORCID (scholarcore.identity.orcid_of derives one from the other and invents nothing).

Persist rid <-> slug so shell callers resolve without importing.

A shell caller resolving a rid to a directory reads this file, which is what lets a rid work anywhere a slug does regardless of directory name. Nothing writes it implicitly: a caller that owns a writable root calls this when it wants the file fresh. Returns None on a store with no directory to write into, which is not a degradation: such a store answers the same question in process through rid_for.

class FilesystemProfileStore(root: str | os.PathLike, capacity: int = 32)

Section titled “class FilesystemProfileStore(root: str | os.PathLike, capacity: int = 32)”

Profiles as directories under one root, with a small LRU over the loaded ResearcherProfile objects.

Eviction is a no-op beyond dropping the reference. The sqlite handles are GC’d along with the index objects on the profile.

backend_spec: str | None : The first readable backend name across the root, sqlite or flat.

capacity

location: str

root: Path : The profiles root. This backend is a directory, so never None.

artifact_bytes(ref: str, content_url: str)
Section titled “artifact_bytes(ref: str, content_url: str)”

Read one artifact, refusing anything that escapes the profile dir.

The traversal check is here rather than at the call site, so every caller inherits it. A store that can be talked into reading ../../etc/passwd has a bug in the store, not in one route.

The profile’s centroid, from whichever index vector_index picked.

None: a directory holds no stacked matrix, only per-profile vectors.

<root>/.cache/centroids.npz is not it. That file is the memo store.centroids keeps of its own computed matrix; returning it here would make the manager read its own cache back through the store and call the result authoritative.

commit_directory(slug: str, staging: Path, build_missing_index: bool = False)
Section titled “commit_directory(slug: str, staging: Path, build_missing_index: bool = False)”

Validate a staged directory loads, then atomically swap it live.

build_missing_index is off by default because the operator push (PUT /api/v1/profiles/{slug}) ships its own index and a rebuild there is wasted minutes; a host ingesting on a person’s behalf, who wants their profile rankable immediately, turns it on. When it is on and .cache/embeddings.sqlite is absent, a best-effort build runs afterwards: a core-only install or a build failure leaves the profile committed but unindexed rather than raising. The profile is hosted, but not yet in /match.

Recomputed per call: this backend stores no digest column.

Same two-artifact, NUL-separated surface every backend reports; see researcher_profiles.profile.ResearcherProfile.content_hash.

create(document: ProfileDocument, slug: str)
Section titled “create(document: ProfileDocument, slug: str)”

Create <root>/<slug>/ and persist document into it.

One write unit of kind "create", so a host’s pre-commit hook lands its own state in the same logical write. On the filesystem that unit is not atomic (ctx.atomic is False). A raising hook leaves the directory behind, which is why this backend removes it explicitly rather than pretending a rename undid anything.

Drop a cached profile so the next get reloads from disk.

Bumps the write generation too. evict is what the API layer calls after every write, including one that arrived by a path this store never saw (an extracted archive swapped in over its directory), so treating it as “something changed” is what keeps the analytics honest without making that caller import them.

Copy a profile directory to dest. Build state is not copied.

The build root is a sibling tree outside the content root, so a copy of the content directory is the published record and nothing else. That is the same thing SqlProfileStore.export_directory produces without with_build.

Whether the profile ships either vector form. Two stats, no parsing.

The absolute profile directory for a slug or a rid.

Numpy-free, which is the point: rp where is a path lookup and used to import the whole vector stack to do it.

put_document(slug: str, document: ProfileDocument)
Section titled “put_document(slug: str, document: ProfileDocument)”

Create-or-replace a profile’s canonical document only.

If the profile exists, runs an "edit" write unit; if new, runs a "create" unit so a host’s pre-commit hook can write ownership. The write uses the profile’s save_profile so dateModified is stamped correctly and hooks fire.

Map a profile reference to a directory name.

ref is either the directory name itself or a rid (a canonical ORCID or a local: id). Directory-first, because that is the common case and a directory name can never be mistaken for a rid.

Map a slug or a rid to the rid. Raises ProfileNotFoundError.

A bare ORCID resolves here without any extra indexing: an ORCID rid is its ORCID (scholarcore.identity.orcid_of derives one from the other and invents nothing), so it is already a key of the rid map.

The profile’s vectors: the build-local sqlite, else the served flat form.

sqlite first because it is the richer index: it carries every chunk, including the restricted ones the public export drops, and its hits carry text. A directory that only holds a published profile (no .cache/) still ranks, at the public subset, through the flat form.

The imports are inside the method. This module is on the core import path and both readers pull numpy.

Persist rid <-> slug into <root>/.cache/index.json.

A shell caller resolving a rid to a directory reads this, which is what lets a rid work anywhere a slug does, regardless of directory name. Nothing writes it implicitly: a caller that owns a writable root calls this when it wants the file fresh.

A set of profiles living in SQL tables.

engine_or_url may be a SQLAlchemy Engine or a connection URL. The store owns no per-profile state: every operation opens its own session, and the SqlArtifactStorage behind a profile handed out by get opens its own for each read and each write unit.

An in-memory SQLite URL (sqlite:// / sqlite:///:memory:) gets StaticPool: an in-memory database is private per connection, and the default per-thread pool would let this store and the threadpool serving HTTP requests over it silently see two different empty databases. StaticPool keeps the one connection that holds the data.

backend_spec: str | None : The embedding space this store’s vectors live in, or None.

engine

location: str : The database URL. Display only.

root: None : Always None: this store is not a directory.

url

artifact_bytes(ref: str, content_url: str)
Section titled “artifact_bytes(ref: str, content_url: str)”

One manifest artifact’s bytes.

There is no traversal check because there is no path: content_url is a key in rp_artifacts, and a key that is not there is a 404 rather than a walk up the filesystem. A rendered collection is regenerated from rp_papers / rp_grants on the way out, exactly as it is for export_directory.

The profile’s stored centroid: one row, already normalized.

Not derived from vector_index. The centroid is computed at ingest over the whole index, restricted chunks included (one averaged vector is not invertible, spec section 6), exactly as the published collection/embeddings/ blob is, while the chunk rows are the public subset. Recomputing it from the chunk rows would quietly answer a different question.

The whole roster’s centroids in one SELECT, or None.

This is the payoff of storing profile vectors in their own table: ranking N profiles costs one query rather than N index reads, the same way a published site’s single stacked blob costs one fetch.

commit_directory(slug: str, staging: Path, build_missing_index: bool = False)
Section titled “commit_directory(slug: str, staging: Path, build_missing_index: bool = False)”

Make a fully-staged profile directory live in this store.

One transaction, which is what replaces the filesystem backend’s rename-aside-and-swap: there is no window in which the profile half exists, so a host’s pre-commit hook granting ownership commits with it or not at all.

When the staged profile carries a built embedding index (.cache/embeddings.sqlite), its public chunks are shredded into rp_chunk_vectors and its centroid into rp_profile_vectors, and indexed=True is reported; /match then queries those rows directly, with no directory and no runtime embedding backend needed to serve them.

build_missing_index builds one in the staging directory first, exactly as the filesystem backend does and for the same reason: ingest is now the only moment a vector can enter this store, so a profile staged without an index would otherwise be hosted and permanently unrankable. Best-effort: a core-only install or a build failure leaves the profile committed but unindexed, never unhosted.

The stored content_hash column: derived state, not a recompute.

Refreshed inside the write unit before the pre-commit hooks run, so a hook reading it observes post-write content. Spans the canonical document and the SOUL text, NUL-separated, identically to every other backend.

create(document: ProfileDocument, slug: str)
Section titled “create(document: ProfileDocument, slug: str)”

Create a new profile from a validated document, in one write unit.

The row is inserted bare and then written through ResearcherProfile.save_profile, so the create takes exactly the same validate -> stamp -> canonicalize -> persist path every other write takes. A management host’s pre-commit hook therefore sees kind="create" with a live session and can write its ownership row in the same transaction, closing the window where a profile exists that nobody owns.

Create the rp_* tables if absent. Fresh-instance convenience.

Remove a profile and every child row, including its build state.

The canonical profile.jsonld bytes for ref.

Serialized on demand from the stored document rather than kept in a second document_bytes column. researcher_profiles.schema.jsonld.canonical_dumps is the package’s only writer of .jsonld bytes and is a pure function of the mapping (one fixed key order, one fixed formatting), so these bytes are the bytes save_profile persisted and export_directory writes. A byte column would be a second representation of the same fact, and two representations of one fact drift.

Bump the write generation. There are no cached rows to drop.

This store caches nothing of its own, but evict is the protocol’s “drop any view that could still be pre-write”, and the analytics on this store hold exactly such a view. The API layer calls it after every write, so bumping here is what keeps /match current without that caller importing anything vector-shaped.

export_directory(ref: str, dest: str | Path, with_build: bool = False)
Section titled “export_directory(ref: str, dest: str | Path, with_build: bool = False)”

Materialize a stored profile as a directory. Returns the directory.

profile.jsonld is canonical_dumps(document); every artifact body is written to its own contentUrl; the works and grants collections are re-rendered from rp_papers / rp_grants inside their stored envelope, so the collection metadata (about, dateModified, @context) survives the round trip.

Vectors are written as the published flat form (embeddings/index.json + <backend>.bin + <backend>.chunks.json) regenerated from rp_chunk_vectors, after the artifact bodies and overwriting whatever they wrote, so the declared count and sha256 always describe the blob beside them. No .cache/embeddings.sqlite is reconstituted: nothing needs one, since the filesystem backend reads the flat form when there is no sqlite, and this store answers /match from its rows without exporting at all.

Build state is written only when with_build=True, and then into the build root beside the directory, never inside it. See researcher_profiles.store.db.BuildStateRow.

Load one profile, backed by a SqlArtifactStorage over its rows.

Whether ref has chunk vectors. One row, by contract cheap.

The /match dependency scans every profile in the store with it to decide whether ranking is possible at all, so it is a LIMIT 1 and never a fetch.

import_directory(path: str | Path, include_binary: bool = False)
Section titled “import_directory(path: str | Path, include_binary: bool = False)”

Sugar for put(ResearcherProfile.from_files(path)). Returns the rid.

Every profile row in the store, ordered by slug.

Every profile’s display handle, sorted. One column, not one row each.

A projection query: a management host calls this to render a list page, and loading every document blob to read one string off each is how a store gets a reputation for being slow.

(hasPart, subjectOf) from rp_artifacts.

The DB twin of researcher_profiles.manifest.build_manifest. That one walks a directory and this one reads the rows; both answer the same question, what a profile contains.

put(profile: ResearcherProfile, slug: Optional[str] = None, include_binary: bool = False)
Section titled “put(profile: ResearcherProfile, slug: Optional[str] = None, include_binary: bool = False)”

Write profile into the store, in one transaction. Returns its rid.

Takes any ResearcherProfile: a directory, a static or remote published one, or one belonging to another store. Upserts on rid and replaces every child row (delete, then insert) rather than diffing: a profile is small, a diff has more ways to be wrong than to be right, and “the rows equal the source” is the only invariant worth having.

Artifacts come from the profile’s recorded manifest (ResearcherProfile.manifest) when it has one, falling back to walking the directory. The recorded manifest is preferred because it is what the published document actually claims; regenerating it here would silently rewrite a published record during an ingest.

include_binary opts into storing binary bodies (a published .bin or an image); without it a binary artifact keeps its manifest row and loses only its bytes. It does not gate vectors: those are shredded into rp_chunk_vectors / rp_profile_vectors on every put, because a queryable vector is not a file body.

put_document(slug: str, document: ProfileDocument)
Section titled “put_document(slug: str, document: ProfileDocument)”

Create-or-replace a profile’s canonical document only.

If the profile exists, runs an "edit" write unit via save_profile; if new, runs a "create" unit. The write uses the profile’s save_profile so dateModified is stamped correctly and hooks fire.

Change a profile’s display handle. one update; no child row moves.

This is the whole payoff of keying on rid: a rename is not a data migration. The unique constraint on slug still applies: a store cannot hold two profiles under one handle.

Map a slug or a rid to the slug.

The mirror of rid_for, which answers with the rid. Both exist because the two are used for different things: rid is the join key every row is written under; slug is what a URL and a human say.

The rid of the profile ref names. ref may be a rid or a slug.

Rid wins, mirroring rp where accepting a slug or an ORCID. A bare ORCID needs no separate lookup: an ORCID rid is its ORCID, so it is already the primary key being queried. Raises ProfileNotFoundError naming both lookups that were tried, so “not found” never leaves a caller guessing which namespace was searched.

The profile’s chunk-level index, built from its rows.

The rows are serialized into the three published byte shapes and read back by FlatEmbeddingIndex, which is the single read implementation behind all three backends. The alternative, a fourth index class that happens to hold SQL rows, would be a fourth place for cosine to be subtly different.

None: there is no directory to write a lookup file into.

Not a degradation. The file exists so a shell caller can resolve a rid without importing the package; a database has rid_for, which is one indexed query and always current.

Also exported from researcher_profiles.store:

class IngestResult(slug: str, rid: str | None, name: str, level: str, indexed: bool)

Section titled “class IngestResult(slug: str, rid: str | None, name: str, level: str, indexed: bool)”

Outcome of committing one staged profile directory into a store.

indexed: bool : Whether a built embedding index is present afterwards. Always False on a store with no filesystem: the index is a .cache/embeddings.sqlite handle, which ArtifactStorage does not cover (see the module docstring).

level: str

name: str

rid: str | None

slug: str

No profile in the store answers to a given rid or slug.

Both a ProfileError and a KeyError. The API layer’s 404 path and every except KeyError around a store lookup read “no such key” (which is exactly what this is), while a management host wants to catch it alongside the package’s other errors. Making it one or the other would force every caller of the other kind to grow a second except clause.

A staged profile directory is malformed, unsafe, or does not load.

Raised by the archive helpers in researcher_profiles.api.upload and by ProfileStore.commit_directory. It lives here, not there, because committing a staged directory is a store operation that both backends perform and neither should have to import the HTTP layer to signal.

build_store(database_url: Optional[str] = None, profiles_dir: Optional[str] = None)

Section titled “build_store(database_url: Optional[str] = None, profiles_dir: Optional[str] = None)”

Build the store an operator’s configuration describes.

database_url wins over profiles_dir: naming a database is an explicit act, and a host that has done it does not want the directory that happens to still be on the box. Exactly one composition rule, shared by create_app’s env-driven default and python -m researcher_profiles.api, so the two can never disagree about which store a given environment means.


Identity resolution (researcher_profiles.resolve)

Section titled “Identity resolution (researcher_profiles.resolve)”

The mint-vs-bind policy behind schema.mint_local_rid: given a rid or a free-text name, decide whether it names an existing profile, an undecided one (deferral), or nobody yet (mint). It runs against any ProfileStore, so it works the same whether the caller is the HTTP route (POST /identity/resolve) or a script holding a store directly.

resolve_person(store: Any, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False)

Section titled “resolve_person(store: Any, rid: Optional[str] = None, name: Optional[str] = None, affiliation: Optional[str] = None, create_new: bool = False)”

Resolve a person descriptor to a rid, minting a stub on a true miss.

Resolution is rid-first, cautious on names, and deterministic on mints; see the module docstring for the full pipeline. affiliation is stamped on any minted stub, selects among same-named candidates, and vetoes a match it contradicts. create_new=True skips matching and mints a fresh identity for name, the answer to a deferral whose candidates are all wrong. The resolver picks the id, and calling it twice creates two people.

Raises ResolveError when the request itself is unusable. It never raises for “no such person”; that is the mint path, not an error.

class ResolveResult(rid: Optional[str], created: bool, confidence: str, candidates: tuple[Candidate, ...] = ())

Section titled “class ResolveResult(rid: Optional[str], created: bool, confidence: str, candidates: tuple[Candidate, ...] = ())”

The outcome of one resolve.

rid is None only in the deferred case, distinct from “no match”, which mints. created says whether this call minted the profile. confidence is "exact" for a rid identity, "high" for a corroborated name match or a fresh mint, "low" for a deferral. When a deferral’s candidates are all wrong, re-resolve with create_new=True to mint a fresh identity.

candidates: tuple[Candidate, …]

confidence: str

created: bool

rid: Optional[str]

class Candidate(rid: str, name: str, affiliation: Optional[str] = None)

Section titled “class Candidate(rid: str, name: str, affiliation: Optional[str] = None)”

One profile an undecidable name could mean.

affiliation: Optional[str]

name: str

rid: str

The resolve request is unusable: no identity fields, a malformed rid, an unknown local: rid, a placeholder name, or an ORCID miss with no usable name to mint a stub from.


SQL profile store (researcher_profiles.store.db, researcher_profiles.store.sql)

Section titled “SQL profile store (researcher_profiles.store.db, researcher_profiles.store.sql)”

Requires the [sql] extra. See How to store profiles in a database.

This is a peer backing store. A profile in the rp_* tables is a profile: it round-trips back to a byte-identical directory, and SqlArtifactStorage reads and writes it through the same ArtifactStorage contract the filesystem backend implements.

A whole profile document. Primary key: rid.

document is the record; everything below it is a derived projection rebuilt by from_document on every write. Do not read a projection back into a model, and do not write one from anywhere else.

affiliation: Optional[str]

affiliation_id: Optional[str]

anchor: Optional[dict] = None

career: list

career_stage_as_of: Optional[str] = None

clinical_training_end_year: Optional[int]

collaborators: list

conforms_to: Optional[str]

content_hash: str = '' : Store-maintained derived state: sha256 over the canonical document and the SOUL text (see content_hash_for). Refreshed inside the write unit, before the pre-commit hooks run.

critiques: list

current_rank: Optional[str] = None

date_modified: Optional[str] = None : The published vintage. Nullable and never defaulted: 45 of the 47 profiles in the reference corpus carry no dateModified at all, and inventing one would be exactly the confident lie researcher_profiles.utils.date_modified exists to prevent.

document: dict : The whole profile.jsonld payload. The source of truth.

email: Optional[str]

expertise_cites_paper_ids: Optional[bool]

field: Optional[str]

first_independent_appointment_year: Optional[int] = None

first_r01_equivalent_year: Optional[int] = None

has_citation_graph: Optional[bool]

has_embedding_index: Optional[bool]

identifier: list

independence: Optional[str]

intellectual_lineage: list

interests: list

job_title: Optional[str]

level: str = 'full'

license: Optional[str] : Reuse terms for the published record (an IRI), when declared.

methodological_commitments: list

name: str

not_interests: list

openalex_id: Optional[str]

orcid: Optional[str] = None : Derived from rid (NULL when the rid is local). A convenience for ORCID joins; join on rid.

paper_stats: Optional[dict] = None

proof: list

provenance: str = '' : Who asserted this profile and on what basis. Required on disk, so it is non-null here too: a consumer must never have to guess whether a row describes a verified self-publication or a third-party assertion.

recurring_positions: list

research_outputs: list

rid: str : The identity: a canonical ORCID or a local: id.

same_as: list

scholar_url: Optional[str]

slug: str : Directory name / display handle. Unique within this store (one store cannot hold two profiles under one handle): a store constraint, never an identity claim. Renameable; see ProfileStore.rename.

subfields: list

summary: Optional[str]

tenure_status: Optional[str]

terminal_degree_type: Optional[str]

terminal_degree_year: Optional[int] = None

training: list

url: Optional[str]

visibility: str = 'public' : Profile-level default privacy tier.

from_document(meta: ProfileDocument, slug: str, document: Optional[dict] = None, soul: str = '')
Section titled “from_document(meta: ProfileDocument, slug: str, document: Optional[dict] = None, soul: str = '')”

The one writer of document and every projection derived from it.

meta supplies the identity and the projections; document is the exact serialized payload to store, defaulting to meta’s canonical dump. Pass it explicitly when ingesting a profile whose stored bytes must survive verbatim. A nested node’s un-pruned null is content, and re-dumping the model would rewrite it.

rid comes from meta.rid; there is no override parameter, because a document without a rid cannot load in the first place.

One work from sources/papers.jsonld.

Keyed by position within the profile, never by paper_id. There is no UNIQUE(profile_rid, paper_id): paper_id is a generated citekey and is not unique within a profile in the real corpus. 13 of 47 reference profiles carry duplicates, and one has 17 genuinely distinct works that collided on the same key. A uniqueness constraint here would refuse to ingest a quarter of the corpus and would silently redefine what a work is. ordinal is the key that actually holds.

record is the whole PaperRecord; the columns beside it are a derived projection for querying. abstract and summary are not projected: they are large and they live in record.

No build field (status, identity_verified, contaminated, …) appears here. Publishing a profile publishes the bibliographic record, not the build’s dirty laundry; build state lives in rp_build_state.

author_index: Optional[int]

author_position: Optional[str]

cited_by_count: Optional[int]

doi: Optional[str] = None

first_author: Optional[str]

id: Optional[int] = None

is_corresponding: Optional[bool]

is_oa: Optional[bool]

journal: Optional[str]

last_author: Optional[str]

oa_status: Optional[str]

open_access: Optional[bool]

openalex_id: Optional[str]

ordinal: int = 0 : Position in the collection’s hasPart. Re-rendering papers.jsonld from these rows reproduces the original array order.

paper_id: Optional[str] = None

pmcid: Optional[str]

pmid: Optional[str]

profile_rid: str

record: dict : The whole PaperRecord. The source of truth for this work.

source: Optional[str]

title: str

total_authors: Optional[int]

type: Optional[str]

url: Optional[str]

venue: Optional[str]

year: Optional[int] = None

from_record(profile_rid: str, paper: PaperRecord, ordinal: int)
Section titled “from_record(profile_rid: str, paper: PaperRecord, ordinal: int)”

Build a row from a PaperRecord.

One award from sources/grants.jsonld.

Grants earn a table of their own rather than living only inside the document because cross-profile grant queries (R01-equivalent history for an ESI determination, funder rollups) are the reason grants are in the format at all.

The projection column is grant_id, not id: id is already the autoincrement primary key, and two columns called id meaning different things is how a join goes quietly wrong.

activity_code: Optional[str] = None

end: Optional[str]

funder: Optional[str] = None

grant_id: str : id: the award’s own id.

id: Optional[int] = None

number: Optional[str]

ordinal: int = 0

profile_rid: str

record: dict : The whole GrantRecord. The source of truth for this award.

role: Optional[str]

source: Optional[str]

start: Optional[str]

status: Optional[str] = None

title: str

url: Optional[str]

from_record(profile_rid: str, grant: GrantRecord, ordinal: int)
Section titled “from_record(profile_rid: str, grant: GrantRecord, ordinal: int)”

Build a row from a GrantRecord.

One expertise label belonging to a profile.

ordinal preserves the declared order: the labels are a curated, ordered list in profile.jsonld, not a set.

id: Optional[int] = None

ordinal: int = 0

profile_rid: str

topic: str

One file the profile contains. rp_artifacts is the manifest.

Keyed (profile_rid, content_url), the manifest’s own address space, so personality/SOUL.md, personality/expertise.md, sources/summaries/<paper_id>.summary.md, sources/cv.md, sources/papers/<id>.md, sources/web/<n>-<host>.md, SKILL.md, index.html, sources/citations.json and embeddings/index.json all land in one uniform place: retrievable by role, and privacy-filterable with WHERE visibility = 'public' instead of a second implementation of researcher_profiles.privacy.effective_tiers.

The columns mirror ArtifactRef one for one, with one rename: ArtifactRef.bytes is size_bytes here, because bytes cannot be a column name. The mapping is ArtifactRef.bytes <-> ArtifactRow.size_bytes, both directions, and from_part / to_part are the only places it is applied.

Bodies: text for markdown / JSON / JSON-LD / HTML, data for binary. Binary bodies are opt-in (ProfileStore.put(..., include_binary=True)) because .cache/embeddings.sqlite can be tens of megabytes.

rendered=True marks an artifact whose bytes are regenerated from the relational tables (sources/papers.jsonld, sources/grants.jsonld); both bodies are NULL for those and envelope holds their collection node minus hasPart, so a round-trip is byte-identical.

content_url: str : The relative path, exactly as ArtifactRef.contentUrl.

data: Optional[bytes] = None

derived_from: list

encoding_format: Optional[str]

envelope: Optional[dict] = None : For a rendered collection: the node minus hasPart (about, dateModified, @context, conformsTo, …).

id: Optional[int] = None

manifest_slot: str = DEFAULT_MANIFEST_SLOT : Which manifest list this entry belongs to: hasPart or subjectOf.

name: Optional[str]

ordinal: int = 0

paper_id: Optional[str] = None

profile_rid: str

rendered: bool = False

role: Optional[str] = None

sha256: Optional[str]

size_bytes: Optional[int] : ArtifactRef.bytes. Renamed; see the class docstring.

text: Optional[str]

type_: Optional[str]

visibility: str = 'public'

from_part(profile_rid: str, part: ArtifactRef, manifest_slot: str = DEFAULT_MANIFEST_SLOT, ordinal: int = 0, text: Optional[str] = None, data: Optional[bytes] = None, rendered: bool = False, envelope: Optional[dict] = None)
Section titled “from_part(profile_rid: str, part: ArtifactRef, manifest_slot: str = DEFAULT_MANIFEST_SLOT, ordinal: int = 0, text: Optional[str] = None, data: Optional[bytes] = None, rendered: bool = False, envelope: Optional[dict] = None)”

Build a row from one manifest entry plus its body.

Copy a manifest entry’s fields onto this row, in place.

The write path for a document whose manifest changed (the SQL store’s save_profile, reached from edit.set_visibility and rp profile visibility): the entry’s metadata lands on the row, and the document’s manifest is then regenerated from the rows, so the two cannot disagree.

Rebuild the manifest entry this row records.

One chunk’s embedding, as a queryable row. rp_chunk_vectors.

The SQL store’s vectors, shredded out of the per-profile .cache/embeddings.sqlite at ingest so they can be selected rather than unpacked. Before this table the whole index file lived in a single rp_artifacts BLOB, which meant a SQL-backed deployment had to export every profile to a temp directory before it could rank anything.

Storage: vector is row-major little-endian float32, the same bytes researcher_profiles.embeddings._sqlite.serialize_vec writes, in a portable LargeBinary column. Deliberately not sqlite-vec (a SQLite-only extension needing raw CREATE VIRTUAL TABLE DDL) and not pgvector (Postgres-only): these tables must load unchanged on SQLite in tests and on Postgres in production, which is the whole point of this module. Cosine runs in numpy on the read side, through FlatEmbeddingIndex, which is brute force over a few hundred rows per profile and is exactly what the filesystem and HTTP backends already do.

Rows are the public subset, the same one write_flat_export publishes: embeddings are partially invertible, so a chunk built from a restricted source never reaches this table any more than it reaches a .bin. text is not stored for the same reason the published form drops it.

The key mirrors the sqlite index’s own UNIQUE(source_type, source_id, chunk_index), scoped to the profile.

backend_spec: str = ''

char_count: Optional[int]

chunk_index: int

dim: int

id: Optional[int] = None

ordinal: int = 0 : Blob order: source_type, source_id, chunk_index, assigned at insert so a read can restore the published row order without re-sorting.

profile_rid: str

section: Optional[str] : chunks.json mirror; carried so the flat form round-trips exactly.

source_id: str

source_type: str

vector: bytes : Row-major little-endian float32, dim floats. See the class docstring.

One profile-level vector. rp_profile_vectors.

kind="centroid" is the one that exists today: the L2-normalized mean of every chunk vector in the profile’s index, computed at ingest by the same function the filesystem backend uses (researcher_profiles.embeddings.profile_vec._centroid_vec) so the two backends report the same number for the same profile.

Separate from ChunkVectorRow because the registry’s hot path wants the whole roster’s centroids in one SELECT of N rows, not N scans of every profile’s chunks. This table is the relational form of the published collection/embeddings/<backend>.bin.

kind is part of the key so summary / expertise vectors can join later without a migration.

backend_spec: str = ''

dim: int

kind: str = 'centroid'

profile_rid: str

vector: bytes : Little-endian float32, dim floats. Already L2-normalized.

Build bookkeeping. Not part of the published record.

The SQL analogue of .build/<slug>/ being a sibling tree outside the content root. “Publish this store” means copy rp_profiles, rp_papers, rp_grants, rp_expertise_topics and rp_artifacts and not copy this table; DROP TABLE rp_build_state must stay as free as rm -rf .build/, and every profile must still load afterwards.

It is never rendered into the manifest, never reaches a ProfileExportBundle, and SqlProfileStore.export_directory writes it only when passed with_build=True.

The state is stored whole rather than shredded because BuildState keeps a build-local integer schema_version and makes no external promise; giving its fields columns would create one.

profile_rid: str

schema_version: int : BuildState’s own private counter. Build-local; never published.

state: dict

Create every rp_* table on engine if not present.

Fresh-instance convenience only. On a deployed Postgres, column evolution goes through numbered SQL migration files, not this::

from sqlmodel import create_engine
from researcher_profiles.store.db import create_all
engine = create_engine("postgresql://user@host/db")
create_all(engine)

get_engine(url: Optional[str] = None, echo: bool = False)

Section titled “get_engine(url: Optional[str] = None, echo: bool = False)”

Return (building once per URL) the process-wide SQLAlchemy engine.

The URL resolves through researcher_profiles.store.config.resolve_database_url: explicit argument, then $RESEARCHER_PROFILES_DATABASE_URL. SQLite URLs get check_same_thread=False so one dev/in-memory database is shared across a threadpool.

Drop the cached engine (tests reconfigure the database between cases).

Yield a Session bound to the shared engine.

Generator form, so it plugs straight into FastAPI’s Depends and can be driven manually elsewhere via next(get_session()).

content_hash_for(document: dict, soul: str)

Section titled “content_hash_for(document: dict, soul: str)”

"sha256:<hex>" over a profile’s canonical content.

Spans two artifacts, NUL-separated: the canonical profile document bytes and the SOUL text, byte-for-byte identically to the filesystem backend’s researcher_profiles.profile.storage.ArtifactStorage.content_hash. Backends that disagree here are not interchangeable, so this is the one definition and the SQL backend stores its output in a column rather than recomputing it per read.

SqlArtifactStorage (researcher_profiles.store.sql)

Section titled “SqlArtifactStorage (researcher_profiles.store.sql)”

SqlArtifactStorage(engine_or_url) accepts a SQLAlchemy Engine or a URL. It implements the whole ArtifactStorage contract for one profile’s rows, and SqlProfileStore (above) implements the whole ProfileStore protocol.

class SqlArtifactStorage(store: SqlProfileStore, rid: str, slug: str)

Section titled “class SqlArtifactStorage(store: SqlProfileStore, rid: str, slug: str)”

One profile’s rp_* rows.

Writes run in a real transaction: session is the SQLAlchemy Session the unit opened, WriteContext.atomic is True, and the whole unit (every save_* call inside it, the derived-state refresh, and every pre-commit hook) commits or rolls back together.

Nothing here ever touches a filesystem: directory is None and the capabilities that need one refuse with a message naming SqlProfileStore.export_directory.

EXPORT_REMEDY : What every directory-needing capability says on this backend.

directory: None : Always None: rows are not a directory.

key: str

rid_hint: str : The row key, known without reading a document.

session: Optional[Session] : The session of the open write unit, or None. Every writer goes through this; opening a second connection inside a unit would deadlock against rows the unit already holds. Public because SqlProfileStore.create inserts the bare row through the unit’s own session, via WriteContext.session.

slug: str

One artifact’s stored text body, or None.

A rendered collection has no stored body; it is regenerated from rp_papers / rp_grants on the way out.

Generate the manifest from rp_artifacts, not by walking a path.

The rows are the directory listing.

A collection node minus hasPart, so a re-render is faithful.

The stored content_hash column: derived state, not recomputed.

The write unit refreshes it (refresh_derived) before the pre-commit hooks run, so a hook reading this observes the new content.

No directory, so no sqlite index handle: every operation refuses.

Build state from rp_build_state; an empty state when absent.

Absent is normal: a published store legitimately has none, the same contract a published directory has. A missing table is absent too: DROP TABLE rp_build_state has to stay as free as rm -rf .build/, so a store published without that table must still serve every profile.

The document currently in the store; {} when absent.

Comparison basis for dateModified, not a load path: errors here are swallowed, exactly as on the filesystem.

The stored document carries hasPart/subjectOf regenerated from the artifact rows, so an empty manifest is persisted as [] here while the canonical model dump the incoming write is compared against omits those keys entirely. Left as-is, content_changed would read the extra [] slots as a change on every write (including an identical re-push) and churn dateModified and content_hash, which would make every re-push look like an edit and break If-Match sync. Empty manifest slots are dropped so this basis matches the model dump; a genuinely non-empty slot is untouched, so removing real parts is still seen as the content change it is.

Display-only locator: a db:// reference naming the row.

new_write_context(profile: ResearcherProfile, kind: str)
Section titled “new_write_context(profile: ResearcherProfile, kind: str)”

Open a transaction and hand it to the unit.

atomic=True: everything inside this unit, hooks included, commits or rolls back together. That is the whole reason a hook is told to branch on ctx.atomic rather than on session is None.

Recompute content_hash inside the unit, before the hooks run.

The digest spans the document and the SOUL, so a soul-only write has to refresh it too. That is why this is the write unit’s job and not save_document’s.

A real rollback: nothing this unit wrote survives.

Contrast the filesystem backend, which can only issue a compensating write for the profile document.

Write the citation graph; None DELETES the artifact.

Same asymmetry the filesystem backend has: load_citations already models “legitimately absent” as None, so None here is a delete rather than a stored JSON null.

Persist the document, and keep it consistent with rp_artifacts.

Three things happen here, in order, and the order is the contract:

  1. The incoming manifest is applied onto the artifact rows, so an owner edit (set_visibility) reaches the rows rather than living only in a JSON blob the rows disagree with.
  2. hasPart / subjectOf are regenerated from the rows. The rows are the manifest; the document’s copy is a projection of them, which is what makes “the document and the rows disagree” unrepresentable.
  3. The regenerated document and every scalar projection are written through the single writer, ProfileRow.from_document.

data arrives already stamped, canonicalized and re-validated by ResearcherProfile.save_profile, so nothing that would fail to load reaches the store.

rp db init | push | pull | list | rm. The database URL resolves --database-url$RESEARCHER_PROFILES_DATABASE_URL, with no built-in default; a missing URL exits 2.