Writing an extractor¶
An extractor turns raw source bytes into structured
CandidateParticle instances. It's the SDK's pluggable interface
to a new domain.
The contract (in 30 seconds)¶
from particles.core.schema import ApplicabilityClause
# Module-level identity. Stored in the database; see "Identity constants" below.
SOURCE_TYPE = "MY_SOURCE_TYPE"
EXTRACTOR_ID = "my-extractor"
EXTRACTOR_VERSION = "0.1.0"
DEFAULT_TRUST_WEIGHT = 0.50
APPLICABILITY = [
ApplicabilityClause(
keyword="MUST", # RFC 2119 keyword
domain_uri="http://www.wikidata.org/entity/Q…",
domain_label="short human label",
source_types=[SOURCE_TYPE],
)
]
class MyExtractor:
EXTRACTOR_ID = EXTRACTOR_ID # unique slug
EXTRACTOR_VERSION = EXTRACTOR_VERSION # SemVer; bump when behaviour changes
DEFAULT_TRUST_WEIGHT = DEFAULT_TRUST_WEIGHT # in [0.0, 1.0]; demotion-only
APPLICABILITY = APPLICABILITY
def accepts(self, source_type: str) -> bool:
return source_type == SOURCE_TYPE
async def extract(
self,
snapshot: Snapshot,
content: bytes,
**kwargs: object,
) -> ExtractionResult:
...
return ExtractionResult(candidates=[...], quality_notes=[...])
Then add one line to particles/extraction/registry.py::_make_extractors().
That's it.
The protocol itself requires only EXTRACTOR_ID, EXTRACTOR_VERSION,
accepts() and extract(). DEFAULT_TRUST_WEIGHT and APPLICABILITY are
read with getattr, so omitting them does not fail at import, but every
shipped extractor sets both and you should too: an extractor without
DEFAULT_TRUST_WEIGHT is registered at a silent 0.7, and one without
APPLICABILITY gives its source type no domain (unless the operator maps
one in config), so domain-scoped trust and domain-gated subject authorities
cannot see its particles.
DEFAULT_TRUST_WEIGHT is the value you ship; it is written to the store once,
when the extractor is first registered, and an operator can demote it further
from there. It is one of the four factors composing
effective confidence; see
Operator guide → extractor trust weight
for the lever on the other end. Because the stored value is never overwritten
by a later registration, changing the default in a new release affects only
stores that have never seen your extractor.
Worked examples in the tree¶
| Style | File | Notes |
|---|---|---|
| HTTP API → JSON parse | particles/extraction/numista/ |
Three extractors (coin, issuer, listing) for one host; structured properties particles |
| HTML scraping | particles/extraction/github/ |
repo + gist + pages variants; _shared.py helper |
| LLM-extracted prose | particles/extraction/general.py |
The catch-all; calibrates per-chunk |
| Social link card | particles/extraction/reddit.py |
The reference implementation of the two-step LLM shape below |
| Hybrid prose + metadata | particles/extraction/hackernews.py, mastodon.py |
The reference implementations of the three-step LLM shape below |
| Deterministic AST parse | particles/extraction/docstrings.py |
LLM-free; PYTHON_SOURCE docstrings → one particle per symbol, fixed 0.95 confidence, no calibration / benchmark. Reads the file's dotted module path from the entry_uri_r kwarg |
| Structure-canonical parse | particles/extraction/rdf.py |
LLM-free and network-free; RDF_GRAPH → one particle per triple, canonical_form: STRUCTURED: the triple is the assertion and content is a derived verbalization |
| Migration from another store | particles/extraction/mcp_memory.py |
LLM-free and network-free; reads an @modelcontextprotocol/server-memory memory.jsonl export and turns each record into a particle. The special case worth reading is not the parsing but the attribution; see below |
| Structure-canonical API reading | particles/extraction/wikidata.py |
LLM-free; WIKIDATA_API → one particle per statement, carrying the wd: / wdt: triple Wikidata published. Labels for content are fetched live, the documented exception below |
Identity constants: what must never change¶
Each extractor module declares its identity as module-level constants and mirrors them as class attributes. They are persisted, so they carry compatibility rules that no type signature states:
| Constant | Stored where | Rule |
|---|---|---|
SOURCE_TYPE |
on every corpus entry, and as the key of applicability clauses, trust statements, and several config lists | Unique across the registry and never renamed. A rename orphans every stored entry of the old type: nothing will route to your extractor again. |
EXTRACTOR_ID |
on every particle's provenance, and as the key of the stored extractor record (its trust weight, its conformance verdict) | Unique and never renamed. A rename registers a brand-new extractor at the default trust weight and strands the operator's settings on the old id. |
EXTRACTOR_VERSION |
on every particle's provenance | SemVer, bumped whenever output changes; see Modifying an existing extractor. |
DEFAULT_TRUST_WEIGHT |
the extractor record, on first registration only | In [0.0, 1.0]. |
APPLICABILITY |
the extractor record | A list of ApplicabilityClauses (MUST / SHOULD / MUST_NOT over a domain and a list of source types). The first MUST clause covering a source type names that source type's domain. |
Naming: SOURCE_TYPE is UPPER_SNAKE_CASE; EXTRACTOR_ID is a lower-case,
hyphenated slug, conventionally ending -extractor (numista-coin-extractor, general-extractor).
Registering: the two-file rule¶
A new extractor is one new module under particles/extraction/ plus one line
in
registry.py::_make_extractors().
No pipeline, schema, store, CLI or API change is needed.
def _make_extractors() -> list[ExtractorPlugin]:
from particles.extraction.myextractor import MyExtractor
...
return [
...,
MyExtractor(), # before GeneralExtractor
GeneralExtractor(), # fallback: must stay last
]
Import your class inside the factory, as the existing entries do, so a broken import in one plugin cannot break every consumer of the registry.
Order is routing. The pipeline hands a snapshot to the first extractor, in
registry order, that has no MUST_NOT clause for its source type and whose
accepts() returns True. GeneralExtractor accepts every source type, so an
entry placed after it is never reached. Keep accepts() narrow: return True
only for the source types you handle, or you will capture another extractor's
traffic.
Extractors are Client-layer code. They produce store-free candidates;
the Engine reconciles and persists them. Your module must not import
particles.store, particles.corpus, particles.db, or particles.ingest;
the project's import-layer check fails the build if it does. Fetching a URL
and writing the corpus blob is a separate role, the importer, which lives
on the Engine side in particles/ingest/importers/; many sources need none,
because a plain HTTP GET or a file deposit is the default.
One host, several source types: the package layout¶
When one host emits more than one SOURCE_TYPE and the code outgrows a single
readable file, make the module a package: one extractor per file plus a shared
helper module. particles/extraction/github/ is the reference:
particles/extraction/github/
__init__.py # re-exports identity constants + extractor classes
_shared.py # auth helpers, HTTP retry policy, URL parsing
repo.py # GitHubRepoExtractor
gist.py # GitHubGistExtractor
pages.py # GitHubPagesExtractor
The package __init__.py re-exports every SOURCE_TYPE_*, EXTRACTOR_ID_*,
EXTRACTOR_VERSION_*, and extractor class, so the registry and callers import
from particles.extraction.<host> whichever layout you choose. Use the single
module until a host genuinely has more than one extractor.
What the pipeline passes to extract()¶
extract() receives the Snapshot, the stored content bytes, and keyword
arguments. Always accept **kwargs and ignore the ones you do not need; new
ones are added over time. Today the pipeline passes:
| Kwarg | Meaning |
|---|---|
session |
The database session. Pass it through to helpers that need it; never use it to import or call the store yourself. |
corpus_entry_id |
The corpus entry being extracted. |
source_type |
The entry's source type. |
entry_uri_r |
The entry's URL, for extractors that parse identity out of it (GitHub repo paths, the docstring extractor's module path). |
deposited_by |
Who deposited the entry. Only migration extractors use it; see below. |
completion_pool |
A shared batch for LLM requests. Only pool-aware LLM extractors use it. |
append_prefix |
The raw bytes of an APPEND_ONLY snapshot the store has already extracted, when the new snapshot extends them. Only the general extractor reads it, as a delta read of the text after the prefix; every other extractor ignores it and reads the whole snapshot. |
supersede_ids |
Set during reindex. If you use the carry-forward helper, pass it through, or reindex treats the particles it is replacing as cache hits and never re-runs the model. |
LLM-driven extractors: two shapes¶
These are conventions, not protocol extensions: ExtractorPlugin stays four
members. Pick one shape up front.
Shape 1: two-step. For sources whose whole substance is prose. Split
extract() into two private methods:
def _normalise(self, content: bytes, snapshot: Snapshot) -> NormalizedDocument:
"""Source-format parsing only: JSON / HTML / etc. → prose chunks.
No LLM calls. Surface author_id, content_published_at, quality_notes,
and any domain-injected subjects as fields on NormalizedDocument.
"""
...
async def _extract_claims(self, doc: NormalizedDocument, **kwargs) -> ExtractionResult:
"""LLM claim extraction over the NormalizedDocument. Typically
extract_with_carry_forward(doc.chunks, ...) plus any post-extraction
stamping (e.g. injecting doc.injected_subjects on every candidate).
"""
...
async def extract(self, snapshot: Snapshot, content: bytes, **kwargs) -> ExtractionResult:
doc = self._normalise(content, snapshot)
return await self._extract_claims(doc, **kwargs)
NormalizedDocument is defined in particles/extraction/general.py.
RedditExtractor is the reference implementation. The carry-forward helper
hashes each chunk's prompt text so an unchanged chunk re-uses its existing
particles instead of calling the model again.
Shape 2: three-step. For hybrid sources whose blob also carries structured
metadata (scores, counts, identifiers, instance or host info) that belongs in a
particle's properties rather than in the prose the model sees. _normalise
returns the prose document and the parse context, and extract() prepends a
synthesised metadata candidate:
def _normalise(self, content: bytes, snapshot: Snapshot) -> tuple[NormalizedDocument, Ctx]: ...
async def extract(self, snapshot, content, **kwargs):
doc, ctx = self._normalise(content, snapshot)
result = await self._extract_claims(doc, **kwargs)
meta = _build_ < noun > _meta_candidate(ctx, doc.injected_subjects)
if meta is not None:
result.candidates.insert(0, meta)
return result
References: HackerNewsExtractor (_build_story_meta_candidate) and
MastodonExtractor (_build_status_meta_candidate). Pick Shape 2 when the
source carries structured metadata that should land in properties, or
identifiers a future relation kind will need (see
Relation kinds). Its rules:
- The synthesiser is a module-level function,
_build_<noun>_meta_candidate(ctx, injected_subjects) -> CandidateParticle | None, so it can be unit-tested without instantiating the extractor. - It returns
Nonerather than fabricating a metadata particle from defaults when the source is missing the fields that identify it. The model-derived claims then stand alone. - The metadata candidate goes first in
result.candidates, so output order is deterministic. - It uses
UncertaintyNature.EPISTEMIC(engagement numbers are observed, not inferred) and a confidence of0.95, since it is read directly from structured data.
A structured / deterministic extractor (Wikidata, Numista, Nomisma, RDF,
the docstring extractor) uses neither shape: it has no prose stage and no LLM
call, overrides extract() directly, and returns a fixed candidate list for a
fixed input blob.
The extraction-model stamp¶
Every particle records the "<provider>:<model>" pairing that produced it, in
Particle.extraction_provider_model; operators use it to re-extract exactly
what one model produced (particles reindex --provider-model). The stamp is
written at the completion call, not by your extractor logic and not by the
pipeline: the call site uses particles.llm.complete_with_provider_model, which
returns (text, provider_model), and copies the pairing onto
CandidateParticle.provider_model for every candidate it parsed.
- Route through an existing call site and you have nothing to do. The
general extractor's
_call_llmand the journal extractor's_call_journal_llmalready stamp; the carry-forward helper goes through the former. - If you call the model directly, call
complete_with_provider_model(not plaincomplete()) and setcandidate.provider_model, or your particles are silently unstamped. - A deterministic extractor stamps nothing. Its candidates carry
None, which is the correct record. Do not fill it from the configured LLM: that is false provenance, and it would makereindex --provider-modelsweep in particles no model touched. - Never recompute or backfill the stamp. Carried-forward particles keep the pairing that actually produced them.
Structure-canonical extractors¶
Almost every extractor produces canonical_form: PROSE: content is the
assertion, and any structured_claim annotates it. A structure-native source
inverts that: the RDF extractor parses a triple that is the assertion and
derives readable prose from it, so the prose is what may be regenerated. If you
write one, these rules follow that do not apply elsewhere:
- Build the triple from the deposited bytes alone. It is the assertion, so it must be bit-identical on every re-extraction of the same snapshot.
- Prefer to build
contentfrom those bytes too, and say so when you can't. A derivedcontentshould be reproducible, which for the RDF extractor means every label comes from the document; it never fetches.wikidata.pyis the one documented exception: a Wikibase entity blob carries labels for itself and for nothing it references, so renderingP19 → Q350as "place of birth: Cambridge" is only possible from the API that published the statement. Its module docstring records the trade; do not copy the pattern without the same argument, and never let a fetch reach the triple. - Improving the verbalization is an
EXTRACTOR_VERSIONbump plusparticles reindex --extractor-version <old>, never an edit of stored rows, and neverparticles structure: the backfill regenerates derived annotations and skipsSTRUCTUREDparticles for exactly this reason. (The operator's view of both verbs: reindex and structured claims.) "Derived" describes what a regeneration pass may produce; it is not a licence to mutatecontent, which stays immutable. contentmust never be empty.Particle.contentrequires at least one character. End your verbalization ladder at something that always exists; the RDF extractor's last rung is the full IRI.STRUCTUREDis a property of a candidate, not of an extractor. A candidate isSTRUCTUREDexactly when itscontentis a deterministic rendering of itsstructured_claimand of no other fact, so oneextract()call may emit both forms (the Numista coin extractor does). Three shapes recur:- one parsed field, templated →
STRUCTURED; - an entity infobox (one subject, several properties, one summary
sentence) →
PROSE, becausecontentstates several facts and a triple states one. It may still carry astructured_claimannotation (for example anrdf:typetriple); Nomisma's candidates are this shape; - the source's own free text, passed through →
PROSE, with no annotation. Thatcontentis asserted by the source, and marking itSTRUCTUREDwould claim the SDK may regenerate someone else's words.
- one parsed field, templated →
Set canonical_form on the CandidateParticle; candidate_to_particle passes
it through. A candidate that claims STRUCTURED without a structured_claim
is demoted to PROSE with a logged warning rather than raising, so one
malformed candidate cannot lose the whole pass; do not rely on it. Attach
external_refs keyed by subject name so the triple's IRI subject can be bound
to the resolved Subject; without them the claim's subject stays unbound for
exactly the particles carrying the best identifiers.
A structured extractor carries no benchmark / ECE gate, because
there is no probabilistic output to calibrate (calibration is
identity; the operator-side picture is
extractor calibration).
When a structured extractor mints code-like subject names (snake_case
identifiers, dotted paths), exempt its source type from the non-entity subject
gate via subject_gate.exempt_source_types so the names are not stripped; see
the PYTHON_SOURCE precedent. A name the gate does strip is recorded on the
particle under properties["extraction:gated_subjects"] with the token class
that matched, so the extractor's output stays inspectable after the gate.
Relation kinds¶
Typed edges between particles use the RelationType enum in
particles/core/schema.py. Never invent a kind string. Some members are
active (CO_EVIDENTIAL, PART_OF, SEQUENCE_IN, ENDORSES, DISPUTES, and
CONTRADICTS, which only the Engine's reconciliation writes, for a
contradiction it leaves standing between two projects); others are reserved
names with no consumer yet (BOOSTS, QUOTES, REPLIES_TO, MENTIONS). Emitting a reserved kind first needs its
consumer surface (query filter, CLI parsing, lint) built, and a kind not in the
enum needs the enum extended; both are design changes to propose before
building, not something an extractor does alone.
Extractors do not write relations: the Engine writes them after reconciliation,
once both endpoint ids exist. What an extractor should do, when its source
carries the identifiers a reserved kind will need, is capture them as
namespaced properties, so a later backfill can build the edges without
re-reading the blobs. The Mastodon extractor records
mastodon:reblogOfStatusId, mastodon:reblogOfAccountAcct and
mastodon:reblogOfStatusUri for the future BOOSTS kind.
Modifying an existing extractor¶
Bump EXTRACTOR_VERSION (patch or minor) whenever a change would produce
different particles from the same source bytes: new or changed prompts,
parsing, confidence, subjects, properties, verbalizations. The version is what
makes the change reach existing data: particles reindex --extractor-version
<old> finds and re-extracts every particle the prior version produced, and the
carry-forward cache is keyed on it, so without a bump unchanged chunks keep
their old particles. Do not rename SOURCE_TYPE or EXTRACTOR_ID as part of a
modification (see Identity constants).
Extractor configuration parameters¶
Tuneable parameters (item limits, score thresholds, timeouts) go in the
configuration model, never in module-level constants or os.environ.get():
- Add a sub-model in
particles/config.py: - Add it to
ParticlesConfigasmy_extractor: MyExtractorConfig = Field(default_factory=MyExtractorConfig). - Because extractors are Client-layer, add the section name to
CLIENT_SECTIONSinparticles/config.py, and add the section toconfig.yaml.samplewith a comment on each field and the# [client]tag on its header line. A test fails if the two disagree. (What the tag means to an operator: Configuration.) - Read it inside the function that uses it, at call time:
get_config().my_extractor.max_items. Reading it at import or in__init__freezes the value and ignores config reloads.
Secrets (API tokens) never go in config; they are read through
particles.secrets.
Things you must do¶
- Define the identity constants described above. The version is what the chunk-hash cache keys on for carry-forward; bumping it invalidates prior particles from this extractor.
- Implement
accepts(source_type) -> bool. It routes snapshots to you, and the benchmark / conformance runners use the same routing to pick applicable suites. - Return
ExtractionResult(candidates=..., quality_notes=...), not a list ofParticle. The pipeline wraps eachCandidateParticlewith extractor-agnostic provenance viacandidate_to_particle(). - Set
uncertainty_nature=EPISTEMICfor knowledge claims. ALEATORY is reserved for claims about a genuinely stochastic quantity. See conformance for how the diversity rule treats this.
Things you shouldn't do¶
- Don't call the database directly from the extractor. The pipeline
passes you the snapshot + content; it stores the resulting
particles. Side-effect-free
extract()is the contract. - Don't catch exceptions and return zero candidates silently; raise. The pipeline catches and records the failure properly.
- Don't read config at extractor
__init__time; read it insideextract(). The pipeline may construct your extractor before config is loaded.
Migration extractors: the attribution rules¶
A migration extractor reads another memory store's export and turns each
record into a particle. It is a structured (no-LLM) extractor: the records are
already claim-sized, and running them through the general extractor would
paraphrase atomic claims and add hallucination risk to content that had none.
mcp_memory.py is the reference. It has three extra obligations, all of which
exist so that a migrated belief is honest about being second-hand:
- Declare your confidence's origin. Set
CandidateParticle.calibration_source = CalibrationSource.IMPORTEDand take the value fromconfig.migration.import_confidence, one flat floor for the whole import.candidate_to_particlehonours a declared source verbatim and skips temperature scaling, which would be meaningless over a number no model produced.
Never map the source store's own score onto confidence_value. A
confidence is fixed at creation and multiplies through every ranking, so a
foreign scalar would silently re-rank the user's whole store. Preserve it as
a tag instead (<source>:score=…), where it stays legible and inert.
-
Cite the record, not the store you read it from. Set
provenance_locationto the record's position in the deposited blob (a line number, a JSON pointer); it lands on the source provenance reference, so the claim points at bytes this store holds and hashed. Never synthesise a provenance reference into the source store's own identifiers. The SDK never fetched them and cannot re-verify them, so that would be fabricated provenance. -
Attribute the act. Put a
ContributorRefwith roleimporteroncontributors, built from thedeposited_bykwarg the pipeline passes. Whatever the export records about its authors travels asauthor/agentcontributors andcontent_published_at, never as provenance.
Give the format its own source-type string: it keys your APPLICABILITY, your
DEFAULT_TRUST_WEIGHT, the operator's source-type trust statement, and
subjects.skip_live_authorities_source_types. Add it to that last list, because
a per-entity live ontology lookup over a bulk export is slow,
network-dependent, and can rewrite canonical names the migrating user never
chose. (What that list switches off is described in
Subject Authorities.)
Carry the source store's entity identity as an ExternalRef in a namespace of
its own, via CandidateParticle.external_refs. That is what lets a second
export of the same store re-attach to the same Subjects instead of forking the
graph.
Make the import rehearsable. Write the mapping as one synchronous function
from the parsed export to an ExtractionResult, call it from extract(), and
call it again from a preview_<format>_export(content) function that hands the
result to particles.extraction.migration_preview.build_preview. That is what
the verb's --dry-run prints: the counts, what was dropped, and a sample,
computed without a store. The rule that matters is that the preview runs
your mapping and never re-implements it, because a report from a second code
path can agree with itself while disagreeing with the import. Count what your
parser cannot place (unknown fields, skipped records) and surface it as quality
notes, so the dry run and the real run disclose the same losses.
An entity exists in the store only through a candidate that names it. There
is no subject-only candidate, so two things follow for a format whose entities
can be empty. Put an entity's metadata (subject_classes, external_refs) on
every candidate that names it, edges included, because for an entity with no
records of its own the edge is the only candidate that will. An entity no
candidate names does not migrate: do not invent a placeholder claim to carry
it, which would be a belief the source never held. Report it by name in
quality_notes instead, as mcp_memory.py does.
The fetch is not your job. A file-shaped export is deposited by the ordinary file path and needs no plugin at all; a hosted store behind an authenticated API needs an importer, which is a separate role, and your extractor runs unchanged either way.
Conformance + benchmark¶
Once your extractor works, write:
- A conformance fixture so
particles extractor conform <id>reports field-population rates against your source. Every REQUIRED field should be populated on 100 % of your output and every RECOMMENDED field above the0.8default threshold. If you only ever emit oneuncertainty_nature, the diversity rule raises an advisory, not a failure; for a structured extractor that is the honest result, so do not manufacture an ALEATORY emission to clear it. - A benchmark suite case so you can pin precision / recall / calibration_error against a gold-standard expected-particle list. (Not applicable to deterministic extractors; see above.)
Conformance is report-only: it never changes the store or blocks registration. An operator can, however, opt in to capping the effective trust of an extractor with a genuine REQUIRED failure; see Conformance trust cap.
Checklist¶
- [ ]
SOURCE_TYPEis unique,UPPER_SNAKE_CASE, and will not change - [ ]
EXTRACTOR_IDis a unique slug that will not change;EXTRACTOR_VERSIONis SemVer; both are class attributes - [ ]
EXTRACTOR_VERSIONis bumped if this changes existing extraction output - [ ]
APPLICABILITYandDEFAULT_TRUST_WEIGHTare set - [ ]
accepts()returnsTrueonly for the source types you handle - [ ]
extract()accepts**kwargs, raises on failure, and imports nothing fromstore,corpus,db, oringest - [ ] LLM calls stamp
provider_model(or route through a call site that does); deterministic extractors stamp nothing - [ ] Tuneable parameters live in a config sub-model, tagged
[client], read at call time - [ ] Tests in
tests/test_<name>.pycoveraccepts(), parsing, and any structured properties - [ ] A conformance fixture exists and
particles extractor conform <id>passes locally - [ ] LLM-driven only: a benchmark suite exists and
particles extractor benchmark <id>was run - [ ] Registered in
_make_extractors(), beforeGeneralExtractor
The canonical contract is the protocol itself, in
particles/extraction/registry.py.
Extraction is Client-layer, so it ships in linkedparticles-core and its
source lives in the particles-core-py repository.