# Python API reference

<!-- Generated by rp-sdk/scripts/render_python_api.py. Do not edit by hand; edit the docstrings or python-api-directives.md. -->

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`.

## Capability by extra

| Surface | Import | Extra |
|---|---|---|
| Schema models, `ResearcherProfile`, OpenAlex parser | `researcher_profiles` | core |
| `resolve_person` | `researcher_profiles.resolve` | core |
| `prof.index` (`SqliteEmbeddingIndex`), `store.match` / `.centroids` / `.indexes` | `researcher_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), `FilesystemProfileStore` | `researcher_profiles.store` | core |
| `create_app` | `researcher_profiles.api.app` | `[api]` |
| `ArtifactStorage` (ABC), `DirectoryArtifactStorage` | `researcher_profiles.profile.storage` | core |
| `SqlProfileStore`, `SqlArtifactStorage`, `.from_db()`, the `rp_*` tables | `researcher_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.

---

## `ResearcherProfile`

`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)`

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.

#### Properties

**`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.

#### Class Methods

##### `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)`

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)`

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)`

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)`

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.

#### Methods

##### `add_post_commit_hook(hook: WriteHook)`

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.

##### `add_pre_commit_hook(hook: WriteHook)`

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.

##### `build_manifest(write: bool = False)`

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.

##### `close()`

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.

##### `content_hash()`

`"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.

##### `delete_summary(paper_id: str)`

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

##### `locate(parts: str = ())`

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.

##### `manifest()`

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

##### `persisted_document()`

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.

##### `refresh()`

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).

##### `require_directory(what: str)`

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.

##### `rid_or_empty()`

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)`

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`.

##### `save_citations(data: Any = UNSET)`

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.

##### `save_expertise(text: str | None = None)`

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

##### `save_grants(grants: list[GrantRecord] | None = None)`

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

##### `save_papers(papers: list[PaperRecord] | None = None)`

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

##### `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`.

##### `save_soul(text: str | None = None)`

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

##### `save_summary(paper_id: str, text: str)`

Persist one paper summary body.

##### `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.

##### `to_agent_seed()`

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.

##### `to_dict(include_summaries: bool = False)`

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()`

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.

##### `write_unit(kind: str)`

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.


### Owner edits

### *class* `EditManager(profile: ResearcherProfile)`

Owner-edit policy and mutation surface for one profile.

#### Methods

##### `patch_metadata(patch: dict[str, Any])`

Patch owner-editable metadata fields and persist the document.

##### `set_soul(soul: str)`

Replace the free-form SOUL/persona narrative.

##### `set_visibility(profile_visibility: str | None = None, artifacts: list[dict[str, Any]] | None = None)`

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


### Exceptions

### *class* `ProfileError`

Base exception for researcher_profiles.


### *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.

#### Properties

**`location`**

**`original`**


### *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.

#### Properties

**`location`**

**`original`**


### *class* `CapabilityUnavailableError`

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.


### *class* `ProfileValidationError(report: Any)`

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

#### Properties

**`report`**


### *class* `PersonaUnavailableError(slug: str)`

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.

#### Properties

**`slug`**


---

## Export for knowledge bases

`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`.

### Entry points

### `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)`

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)`

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)`

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])`

`"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)`

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.


### `ExportOptions`

### *class* `ExportOptions`

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.

#### Properties

**`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/<id>.summary.md body when present, falling back to its abstract.

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


### `ProfileExportBundle`

### *class* `ExportPaperRef`

One paper whose prose is inside the rendered text.

#### Properties

**`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*


### *class* `ProfileExportBundle`

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.

#### Properties

**`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*


### Exceptions

### *class* `ExportError`

Base error for the knowledge-base export surface.


### *class* `ExportVisibilityError`

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`)

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.

### *class* `DuplicateIdentityError`

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)

### *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.

#### Properties

**`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.

#### Methods

##### `embed_query(text: str)`

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

##### `invalidate()`

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

##### `snapshot()`

`(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.


### `store.match` (ranking and analysis)

### *class* `MatchManager(store: VectorStore, rostered: _RosterCache)`

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

#### Properties

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

#### Methods

##### `cluster(k: int | None = None)`

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

##### `diversify(matches: list[Match], k: int = 5, lambda_: float = 0.5)`

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

##### `invalidate()`

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)`

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

##### `topics()`

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


### `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)`

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

#### Methods

##### `coverage()`

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_all(force: bool = False)`

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.

##### `stats()`

`slug -> IndexStats`, one index open per profile.


---

## `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)`

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.

#### Properties

**`base_url`**

**`directory`**: *None*

**`key`**: *str*

**`rid_hint`**: *None*

**`slug`**: *str*

#### Methods

##### `artifact_bytes(content_url: str)`


##### `artifact_text(content_url: 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.

##### `build_manifest()`

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

##### `close()`


##### `collection_envelope(content_url: str)`


##### `content_hash()`

`"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.

##### `index(profile: ResearcherProfile)`

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

##### `load_citations()`


##### `load_document()`


##### `load_expertise()`


##### `load_grants()`


##### `load_papers()`


##### `load_soul()`


##### `load_summaries()`


##### `locate(parts: str = ())`

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

##### `persona(profile: ResearcherProfile)`

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

##### `refresh()`

Drop cached immutable resources so the next access refetches.


### *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.

#### Properties

**`base_url`**

**`directory`**: *None*

**`key`**: *str*

**`rid_hint`**: *None*

**`slug`**: *str*

#### Methods

##### `artifact_bytes(content_url: str)`


##### `artifact_text(content_url: 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`.

##### `build_manifest()`

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

##### `close()`


##### `collection_envelope(content_url: str)`


##### `content_hash()`

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

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

##### `load_citations()`


##### `load_document()`


##### `load_expertise()`


##### `load_grants()`


##### `load_papers()`


##### `load_soul()`


##### `load_summaries()`


##### `locate(parts: str = ())`

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


### `rank_against` (module-level)

### `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` (module-level)

### `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).


---

## OpenAlex parser

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

### `parse_work(raw: dict)`

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)`

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.


### `to_work_dict(raw: dict)`

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.


### `to_normalized_dict(raw: dict)`

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


---

## Embeddings store

`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)`

sqlite-vec index for a single profile.

#### Properties

**`SCHEMA_VERSION`**

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

**`db_path`**

**`index_dir`**

**`profile_dir`**

#### Methods

##### `build_index(force: bool = False, backend: EmbeddingBackend | str | None = None)`


##### `centroid()`

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.

##### `counts()`

`(n_chunks, n_papers)`.

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

##### `exists()`

True when `embeddings.sqlite` is present.

##### `index_meta()`

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

##### `mtime()`

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

##### `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)`


##### `stats()`

`exists` + `counts` + `index_meta` + `mtime`.


### `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.

### *class* `IndexManager(profile)`

`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.

#### Methods

##### `build(force: bool = False, backend = None)`

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

##### `embedding(kind: str = 'centroid')`

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

##### `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)`

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())`

#### Properties

**`chunk_index`**: *int*

**`cosine`**: *float*

**`meta`**: *dict*

**`score`**: *float*

**`section`**: *str | None*

**`source_id`**: *str*

**`source_type`**: *str*

**`text`**: *str*

#### Methods

##### `to_dict()`



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

#### Properties

**`added`**: *int*

**`backend_name`**: *str*

**`duration_s`**: *float*

**`removed`**: *int*

**`skipped`**: *int*

**`updated`**: *int*

#### Methods

##### `to_dict()`



### Exceptions

### *class* `MissingEmbeddingBackendError`

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


### *class* `IndexBackendMismatchError`

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

Pass `force=True` to drop and recreate.


### *class* `IndexNotBuiltError`

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


---

## Result dataclasses

`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)`

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`.

#### Properties

**`citations`**: *list[CitationRef]*

**`grounded`**: *bool*

**`model`**: *str*

**`raw`**: *Any*

**`refusal_reason`**: *Optional[str]*

**`refused`**: *bool*

**`request_id`**: *Optional[str]*

**`text`**: *str*

**`usage`**: *dict*


### *class* `Idea(hypothesis: str, approach: str, rationale: str, related_works: list[str])`

One proposed research direction from `ResearcherProfile.innovate`.

#### Properties

**`approach`**: *str*

**`hypothesis`**: *str*

**`rationale`**: *str*

**`related_works`**: *list[str]*

#### Methods

##### `resolve_citations(profile)`

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.


### *class* `Riff(angle: str, text: str, related_work: Optional[str] = None)`

One brainstorm fragment from `ResearcherProfile.riff`.

#### Properties

**`angle`**: *str*

**`related_work`**: *Optional[str]*

**`text`**: *str*


### *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.

#### Properties

**`paper_id`**: *str*

**`relevance`**: *float | None*

**`span`**: *str | None*

#### Methods

##### `to_dict()`



### *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.

#### Properties

**`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*

#### Methods

##### `to_bibtex()`


##### `to_csl()`


##### `to_dict()`



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

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

#### Properties

**`evidence`**: *MatchEvidence*

**`explanation`**: *Optional[str]*

**`profile`**: *Any*

**`score`**: *float*

#### Methods

##### `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.

##### `to_dict()`



### *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.

#### Properties

**`centroid_score`**: *float*

**`overlapping_topics`**: *list[str]*

**`top_chunks`**: *list[Any]*

**`top_papers`**: *list[str]*

#### Methods

##### `to_dict()`



### *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.

#### Properties

**`chunk_ids`**: *list[int]*

**`label`**: *str*

**`paper_ids`**: *list[str]*

**`weight`**: *float*

#### Methods

##### `to_dict()`



### *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.

#### Properties

**`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]]*

#### Methods

##### `to_dict()`



### *class* `GenerativeParseError(message: str, raw_text: str = '')`

Raised when the LLM repeatedly fails to return valid JSON.

#### Properties

**`raw_text`**


`PersonaUnavailableError` is documented under
[`ResearcherProfile` exceptions](#exceptions) above.

---

## `ProfileStore` (`researcher_profiles.store`)

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`](/researcher-profiles/rp-sdk/reference/api.md#running-the-server) takes. The "one filesystem
admission" (`store.root`) is explained in
`docs-dev/rp-sdk/explanation/sdk-architecture.md`.

### *class* `ProfileStore`

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.

#### Properties

**`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`.

#### Methods

##### `add_post_commit_hook(hook: WriteHook)`

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`.

##### `add_pre_commit_hook(hook: WriteHook)`

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)`

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)`

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.

##### `content_hash(ref: str)`

`"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)`

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.

##### `delete(ref: str)`

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

##### `document_bytes(ref: str)`

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.

##### `evict(ref: str)`

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.

##### `exists(ref: str)`

Whether `ref` names a profile in this store.

##### `export_directory(ref: str, dest: Path)`

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).

##### `get(ref: str)`

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.

##### `list_slugs()`

Every profile's display handle, sorted.

##### `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.

##### `resolve_slug(ref: str)`

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

##### `rid_for(ref: str)`

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).

##### `write_lookup_index()`

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)`

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.

#### Properties

**`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`.

#### Methods

##### `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.

##### `centroid(ref: str)`

The profile's centroid, from whichever index `vector_index` picked.

##### `centroids_matrix()`

`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)`

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`.

##### `content_hash(ref: str)`

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)`

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.

##### `delete(ref: str)`


##### `document_bytes(ref: str)`


##### `evict(ref: str)`

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.

##### `exists(ref: str)`


##### `export_directory(ref: str, dest: Path)`

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`.

##### `get(ref: str)`


##### `has_vector_index(ref: str)`

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

##### `list_slugs()`


##### `path_for(ref: str)`

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)`

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.

##### `resolve_slug(ref: str)`

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.

##### `rid_for(ref: str)`

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.

##### `vector_index(ref: str)`

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.

##### `write_lookup_index()`

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.


### *class* `SqlProfileStore(engine_or_url: Any)`

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.

#### Properties

**`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`**

#### Methods

##### `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`.

##### `centroid(ref: str)`

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.

##### `centroids_matrix()`

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)`

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.

##### `content_hash(ref: str)`

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)`

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_all()`

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

##### `delete(ref: str)`

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

##### `document_bytes(ref: str)`

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.

##### `evict(ref: str)`

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.

##### `exists(ref: str)`


##### `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`.

##### `get(ref: str, eager: bool = False)`

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

##### `has_vector_index(ref: str)`

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)`

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

##### `list_profiles()`

Every profile row in the store, ordered by slug.

##### `list_slugs()`

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.

##### `manifest_from_rows(ref: str)`

`(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)`

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)`

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.

##### `rename(rid: str, new_slug: str)`

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.

##### `resolve_slug(ref: str)`

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.

##### `rid_for(ref: str)`

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.

##### `session()`


##### `vector_index(ref: str)`

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.

##### `write_lookup_index()`

`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)`

Outcome of committing one staged profile directory into a store.

#### Properties

**`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*


### *class* `ProfileNotFoundError`

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.


### *class* `UploadError`

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)`

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`)

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)`

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, ...] = ())`

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.

#### Properties

**`candidates`**: *tuple[Candidate, ...]*

**`confidence`**: *str*

**`created`**: *bool*

**`rid`**: *Optional[str]*


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

One profile an undecidable name could mean.

#### Properties

**`affiliation`**: *Optional[str]*

**`name`**: *str*

**`rid`**: *str*


### *class* `ResolveError`

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`)

Requires the `[sql]` extra. See
[How to store profiles in a database](/researcher-profiles/rp-sdk/how-to/sql-layer.md).

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.

### Tables (`researcher_profiles.store.db`)

### *class* `ProfileRow`

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.

#### Properties

**`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.

#### Class Methods

##### `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.


### *class* `PaperRow`

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`.

#### Properties

**`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`

#### Class Methods

##### `from_record(profile_rid: str, paper: PaperRecord, ordinal: int)`

Build a row from a `PaperRecord`.


### *class* `GrantRow`

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.

#### Properties

**`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]*

#### Class Methods

##### `from_record(profile_rid: str, grant: GrantRecord, ordinal: int)`

Build a row from a `GrantRecord`.


### *class* `ExpertiseTopicRow`

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.

#### Properties

**`id`**: *Optional[int]* = `None`

**`ordinal`**: *int* = `0`

**`profile_rid`**: *str*

**`topic`**: *str*


### *class* `ArtifactRow`

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.

#### Properties

**`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'`

#### Class Methods

##### `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.

#### Methods

##### `apply_part(part: ArtifactRef)`

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.

##### `to_part()`

Rebuild the manifest entry this row records.


### *class* `ChunkVectorRow`

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.

#### Properties

**`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.


### *class* `ProfileVectorRow`

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.

#### Properties

**`backend_spec`**: *str* = `''`

**`dim`**: *int*

**`kind`**: *str* = `'centroid'`

**`profile_rid`**: *str*

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


### *class* `BuildStateRow`

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.

#### Properties

**`profile_rid`**: *str*

**`schema_version`**: *int*
: `BuildState`'s own private counter. Build-local; never published.

**`state`**: *dict*


### `create_all(engine: Engine)`

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)`

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.


### `reset_engine`

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


### `get_session`

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)`

`"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`)

`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`](#profilestore-researcher_profilesstore) protocol.

### *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`.

#### Properties

**`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*

#### Methods

##### `artifact_bytes(content_url: str)`


##### `artifact_text(content_url: 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.

##### `build_manifest()`

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

The rows are the directory listing.

##### `collection_envelope(content_url: str)`

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

##### `commit(ctx: WriteContext)`


##### `content_hash()`

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.

##### `delete_summary(paper_id: str)`


##### `index(profile: ResearcherProfile)`

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

##### `load_build_state()`

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.

##### `load_citations()`


##### `load_document()`


##### `load_expertise()`


##### `load_grants()`


##### `load_papers()`


##### `load_persisted_document()`

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.

##### `load_soul()`


##### `load_summaries()`


##### `locate(parts: str = ())`

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

##### `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`.

##### `refresh_derived(ctx: WriteContext)`

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.

##### `rollback(ctx: WriteContext)`

A real rollback: nothing this unit wrote survives.

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

##### `save_build_state(state: BuildState)`


##### `save_citations(data: Any)`

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`.

##### `save_document(data: Mapping[str, Any])`

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.

##### `save_expertise(text: str)`


##### `save_grants(grants: list[GrantRecord])`


##### `save_papers(papers: list[PaperRecord])`


##### `save_soul(text: str)`


##### `save_summary(paper_id: str, text: str)`



### CLI

`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`.
