Skip to content

Python API

Every public name in arche, grouped by what you do with it: the signature as it is in the source, one sentence, what comes back, and the guide that shows it in use. At the end of this page you know which name answers which question and where its full walkthrough is.

This page and arche.__all__ are the same list, and a test holds them together. That is the 1.x promise: these names keep working, with these meanings, through every 1.x release.

import arche

print(arche.__version__)
print(sorted(arche.describe()["verbs"]))
0.9.0
['compare', 'dedupe', 'find', 'reconcile']
You want to Names Guide
find the personal data in a text, make a copy you can hand on detect_pii, deidentify, Deidentified, Pipeline Find and mask
decide whether records are the same thing compare, reconcile, dedupe, find, describe, Receipt, AUTO_SPLINK_FLOOR Compare two records, Resolve a batch
PDFs and text files in, linked records out resolve_documents, DocumentReport Resolve documents
places in free text, a delivery request against your own sheet extract_places, resolve_place_request, SpatialMention, MasterSheet, Nominatim, Policy Resolve a delivery address
your own field names, once, for extraction and matching schema, Declaration, extract Extract to your schema
keep a decision, explain it, make it again attach, Ledger Keep, explain, replay
sign an answer, verify one arche.sign, arche.attest Attestation
build your own comparator arche.resolve.compare_geo and the helpers beside it Evidence, gates and distinctiveness

Every verb that makes a decision takes store=, a Ledger from attach. The return value is the same with or without it; the receipt is additionally recorded with the inputs it was made from. Every name below is importable as arche.<name> unless its module is given.

Find and mask

def detect_pii(text: str, jurisdiction: str | None = None, *,
               backend: str = "auto", statute: str | None = None) -> list[Detection]

The personal data in text as spans: category, offsets, confidence, detector and the statute section each falls under. Never the value. backend is auto (rules, plus the model when the detect extra is installed), basic (rules only) or gliner2-pii. jurisdiction=None infers the country from the text and raises JurisdictionRequiredError when the evidence is thin.

def deidentify(text: str, jurisdiction: str | None = None, *,
               backend: str = "auto", method: str = "statute", statute: str | None = None,
               salt: str = "", store: Any | None = None) -> Deidentified

A copy of text with its personal data removed, as a decision with an id. method is statute (the pack decides per category), mask, token or drop; salt keys the tokens so two deployments never mint the same one.

@dataclass(frozen=True)
class Deidentified:
    text: str
    detections: tuple[Detection, ...]
    outcomes: tuple[Any, ...]
    decision_id: str
    document_hash: str
    jurisdiction: str | None
    statute: str | None
    backend: str
    method: str
    pins: dict[str, Any]
    coverage: dict[str, Any]

    count: int                                  # property
    def by_category(self) -> dict[str, int]
    def spans(self) -> list[dict]               # value-free span report
    def record(self) -> dict[str, str]          # the tokenised fields a copy can be compared on

decision_id starts red:sha256: and is a content hash over the spans and the pins, so the same text under the same statute and method gives the same id.

class Pipeline:
    def __init__(self, jurisdiction: str | None = None, statute: str | None = None,
                 detectors: list[str] | None = None, address_parsing: bool = False,
                 audit: bool = True, tokenize_salt: str = "", overlays: list[str] | None = None,
                 transparency_notice: str | None = None, on_uncovered: str = "silent",
                 backend: str = "basic")
    def process(self, text: str) -> Result
    def process_file(self, source: str | Any) -> Result
    def describe(self) -> dict[str, Any]
    def effective_detectors(self, *, warn: bool = False) -> list[str]

The detection primitive underneath detect_pii and deidentify: detectors, a statute, and the redaction it requires. Result carries document_hash, detections, addresses, policy_outcomes, redacted_text, audit_log and metadata; Detection carries category, text, start, end, confidence, detector, identity_class, sensitivity_tier, regulatory_citation and metadata. Reach for Pipeline when you need the audit log or a custom detector list; otherwise the two verbs are the surface.

Compare and resolve

def compare(a, b, *, entity: str = "person", store=None, **kwargs) -> Receipt

Are these two the same thing? a and b are two dicts with your own field names, two Pipeline results, two canonical references, or two strings (with jurisdiction= and backend= so the fields can be read out first). entity picks the pack: person, organisation, place, product_electronics, product_grocery, product_home_goods, artist. person runs the Fellegi-Sunter engine; every other pack runs the pack engine, and the two do not share a score.

@dataclass
class Receipt:
    identity: str            # same_entity | review | different
    action: str              # merge | hold | no_op
    basis: str               # single_identifier, corroborated, pack:<name>, ...
    score: float
    factors: dict[str, float]
    field_weights: dict[str, dict[str, float]]
    explanation: str
    gate: dict[str, Any]
    vetoes: dict[str, Any]
    reference_id_a: str
    reference_id_b: str
    decision_id: str
    entity_id: str | None
    reference_a: Reference
    reference_b: Reference
    jurisdiction: str
    pins: dict[str, Any]

What compare returns. identity is the belief and action the recommendation, and they can disagree. decision_id is a content hash over the rounded evidence and the pins. The decision reads every field.

def reconcile(list_a, list_b, comparators: list[dict] | None = None, *,
              entity: str | None = None, tf=None, decl=None, schema=None,
              store=None, **kwargs) -> dict

Which of these are the same as those? Two lists of records with a stable id each; pass entity= for a shipped pack or comparators= for your own spec, not both. The batch result is a dict: matches (each edge a_id, b_id, decision of match or review, score, evidence, distinctive_max, decision_id), count, pins, blocking and backend. Pairs below threshold - review_margin are not returned; their absence is not a claim that they differ. Options in **kwargs include threshold, review_margin, id_field, distinctive_floor, block, backend, splink_settings, candidate_pairs, candidate_pins and truth_pairs.

AUTO_SPLINK_FLOOR = 1000

backend="auto" (the default) runs arche's own engine below this many records. At or above it, a shipped Splink recipe scores the batch when the entity has one, arche-core[resolve] is installed, and the records carry the recipe's columns; otherwise the engine again. result["backend"] says which was chosen and why. Three recipes ship in arche.resolve.recipes: person/febrl-v1 (reads given_name and surname as two columns, so a person caller with one name blob stays on the engine at every size), place/name-coords-v1, and product/identity-v1, which auto never picks because the engine's product pack measures better. backend="arche" is the engine regardless of size; backend="splink" is Splink regardless and needs splink_settings=. Backends has the measurement behind the floor.

def dedupe(records, comparators: list[dict] | None = None, *,
           entity: str | None = None, tf=None, decl=None, schema=None,
           store=None, **kwargs) -> dict

Collapse one list. Returns the same edge shape as reconcile plus clusters, the transitive closure over match edges only, each with members, size and held_together_by (direct when every pair was itself compared and matched, transitive when some pair never was), cluster_count and review. Self-pairs and mirrored edges are dropped. Ids must be unique.

def find(query: dict, within: list[dict], comparators: list[dict] | None = None, *,
         entity: str | None = None, tf=None, decl=None, schema=None,
         ambiguity_margin: float = 0.05, store=None, **kwargs) -> dict

Which of these is this one? One record against a list. Returns a verdict of found, ambiguous or not_found, the match when found, rivals and would_resolve when ambiguous, candidates (what was compared, best first), reason, query, blocking and pins. ambiguous is not a match: two or more candidates are within ambiguity_margin of each other.

def describe(entity: str | None = None) -> dict

What arche can be asked, and about what, as data: the verbs, the entities, outcomes, comparators, and for each pack in packs the fields it reads and what each comparator does. Pass entity for one pack.

from arche.resolve.reconcile import sign_edges

def sign_edges(result: dict[str, Any], *, private_key: Any, kid: str,
               decisions: tuple[str, ...] = ("match", "review")) -> list[dict[str, str]]

JWS-sign the edges of a batch result with a key from arche.sign.generate_keypair. Each signed payload carries the edge and the run pins, so a recipient can verify it and recompute the decision_id.

Documents

def resolve_documents(source: str | os.PathLike | Iterable[str | os.PathLike], *,
                      entity: str = "person", jurisdiction: str = "auto",
                      candidates: Iterable[Mapping[str, Any]] | None = None,
                      max_candidate_pairs: int = 1000, quiet: bool = True,
                      progress: ProgressHandler | bool | str | None = True,
                      extraction_backend: str = "auto",
                      store: Any | None = None) -> DocumentReport

A path, a directory, a glob or a list of paths in. Each document is parsed (plain text natively; PDF, DOCX and scanned pages through the doc and doc-ocr extras), its jurisdiction inferred or taken from jurisdiction=, its fields proposed, and every document resolved against the others or against candidates.

@dataclass
class DocumentReport:
    records: dict[str, dict[str, Any]]
    decisions: list[dict[str, Any]]
    detections: dict[str, dict[str, int]]
    record_provenance: dict[str, dict[str, str]]
    metadata: dict[str, Any]
    provenance: dict[str, dict[str, Any]]
    errors: dict[str, str]
    jurisdiction: str
    entity: str
    jurisdictions: dict[str, Any]
    jurisdiction_conflicts: dict[str, tuple[str, str]]
    timing: Timing
    review_fields: dict[str, dict[str, dict[str, object]]]

    def to_dicts(self, reveal: bool = False) -> list[dict[str, Any]]
    def to_rows(self, reveal: bool = False) -> tuple[list[str], list[list[str]]]
    def to_csv(self, path: str | os.PathLike | None = None, *, reveal: bool = False) -> str | Path
    def table(self, reveal: bool = False) -> str
    def review(self, *, reveal: bool = False) -> dict[str, Any]
    def unlinked(self) -> list[str]
    def to_json(self, reveal: bool = False, indent: int = 2) -> str
    def save_json(self, path: str | os.PathLike, reveal: bool = False) -> Path

Everything resolve_documents found, keyed by file name. Values are masked in every output unless reveal=True. read_metadata in the same module reads a document's own metadata without resolving it.

Places

def extract_places(text: str, *, rules: RolePack | None = None) -> list[PlaceMention]

Place mentions in free text with their spatial role (origin, destination, location, via, unknown) and the cue that decided it. PlaceMention carries role, text, span, cue, cue_span, cue_rule, cue_phrase, confidence, evidence, address, jurisdiction and jurisdiction_confidence; to_dict(reveal=False) gives the value-free form the MCP server returns.

def resolve_place_request(text: str, *, action: str, sources: Sequence[PlaceSource],
                          policy: Policy | None = None, now: datetime.date | None = None,
                          store: Any | None = None) -> PlaceRequest

Read the request, resolve each endpoint against the sources, apply the policy. PlaceRequest carries action, text, time_window, endpoints (a dict of Endpoint: role, status, mention, candidates, question, question_kind, decision_id, pins), policy and mentions, plus origin, destination and status as properties. An endpoint's status is verified, clarification_required (a question, with the likely answer first), refused (with what was tried) or missing.

@dataclass(frozen=True)
class SpatialMention:
    action_role: str
    target_text: str
    span: tuple[int, int]
    confidence: float
    relation_kind: str | None = None
    reference_text: str | None = None
    reference_qualifier: str | None = None
    access_hint: str | None = None
    street_number: str | None = None
    street: str | None = None
    street_suffix_known: bool = True
    cue: str | None = None
    evidence: tuple[str, ...] = ()

One endpoint as the sentence gave it: role, target, relation to a landmark, access hint. It is what a PlaceSource receives in candidates(mention, *, limit=5).

from arche.addr.request import MasterSheet, Nominatim, Policy

class MasterSheet:       # the caller's own places
    def __init__(self, records: Sequence[Mapping[str, Any]], name: str = "master_sheet",
                 id_field: str = "id", landmark_radius_m: float = 250.0)

class Nominatim:         # OpenStreetMap's geocoder, opt-in, rate-limited, off when offline
    def __init__(self, name: str = "nominatim",
                 user_agent: str = "arche-core (https://github.com/unpatterned-labs/arche)",
                 endpoint: str = "https://nominatim.openstreetmap.org/search",
                 country_codes: str | None = None)

@dataclass(frozen=True)
class Policy:
    verified_at: float = 0.85
    clarify_margin: float = 0.15
    minimum: float = 0.4
    require: tuple[str, ...] = ("origin", "destination")
    candidates_shown: int = 3
    confirm_typo_matches: bool = True

MasterSheet records need an id, a name and an address, and may carry lat, lon and kind (landmark). Policy says when an endpoint is verified, when it becomes a question, and when it is refused.

Schema

def schema(source: Declaration | str | Path | dict) -> Declaration

Load a declaration from a YAML path, a dict, or a Declaration as is. Pass the result as schema= to reconcile, dedupe, find and extract, so extraction and matching read the same field names.

@dataclass(frozen=True)
class Declaration:
    name: str
    version: str = "0"
    entity: str = ""
    id_field: str = "id"
    statute_id: str | None = None
    jurisdiction: str = "default"
    on_unknown: str = "warn"
    tf: str | None = None
    geo: dict | None = None
    fields: dict[str, FieldDecl]
    load_warnings: tuple[str, ...] = ()

    @classmethod
    def from_yaml(cls, path: str | Path) -> Declaration
    @classmethod
    def from_dict(cls, raw: dict) -> Declaration
    def comparators(self) -> list[dict]
    def json_schema(self) -> dict
    def tool_def(self, format: str = "json-schema") -> dict
    def validate_record(self, record: dict)
    def pin(self) -> str

A validated declaration. comparators() is the pack it implies; tool_def(format=) is the same declaration as a json-schema, anthropic or openai tool definition, which is what arche schema gen prints.

def extract(text: str, entity_types: list[str] | None = None, backend: str = "auto",
            llm_config: object | None = None, *, schema=None)

Entities in text as a list of Entity (text, entity_type, confidence, start, end, source, metadata), or with schema= one record in your own fields. backend="basic" needs no model.

The ledger

def attach(uri: str) -> Ledger

Open a ledger: duckdb:///:memory: for scratch, duckdb:///file.duckdb to keep. DuckDB loads on this call, not on import arche. Nothing leaves the machine.

class Ledger:
    def decision(self, decision_id: str) -> Decision
    def explain(self, decision_id: str) -> dict[str, Any]
    def replay(self, decision_id: str) -> Replay
    def entities(self, entity_type: str | None = None) -> list[EntityView]
    def path(self, record_a: str, record_b: str) -> list[Decision]
    def cases(self, entity_type: str | None = None) -> list[Case]
    def observe(self, record_id: str, evidence: Mapping[str, Any]) -> list[Decision]
    def resolve(self, record: Mapping[str, Any] | str, *, entity_type: str,
                jurisdiction: str = "default", backend: str = "basic") -> Resolution

    def record_compare(self, receipt: Any, a: Any, b: Any, *, call: Mapping[str, Any],
                       source: str = "compare", caller_ids=(None, None),
                       supersedes: str | None = None, record_ids=(None, None)) -> Decision
    def record_batch(self, result: Mapping[str, Any], list_a: list[Mapping[str, Any]],
                     list_b: list[Mapping[str, Any]], *, call: Mapping[str, Any],
                     verb: str = "reconcile", link: bool = True) -> list[Decision]
    def record_deidentify(self, deid: Any, text: str, *, call: Mapping[str, Any]) -> Decision
    def record_place_request(self, result: Any, *, sources: Sequence[Any], now: Any = None) -> list[Decision]

    def record(self, record_id: str) -> Record
    def entity(self, entity_id: str) -> EntityView
    def entity_of(self, record_id: str) -> str | None
    def history(self, record_id: str) -> tuple[Decision, ...]
    def events(self, limit: int | None = None) -> tuple[Event, ...]
    def read(self, source: str, *, limit: int | None = None) -> list[dict[str, Any]]
    def graph(self, entity_id: str | None = None)
    def close(self) -> None
Method Answers
decision the receipt as recorded; KeyError if the ledger never saw the id
explain supporting, refuting, missing, shared and gate for one decision
replay the decision made again with the engine installed now: reproduced, then, now, changed
entities every entity the linked decisions have built, largest first, with held_together_by, weak_links and bridges
path the chain of decisions that makes two records one entity; empty when they are not
cases pairs still at review, each with would_resolve
observe add evidence about a record and decide its open pairs again; new receipts name what they supersede
resolve a new record against the entities the ledger holds: found, review, ambiguous, conflict or not_found
record_* what store= calls for you: one receipt, a batch, a redaction, a place request
read a table or view from the same DuckDB file, as dicts, for your own analysis

record_a, record_b and record_id are content addresses of the form rec:sha256:...; ledger.record(id) gives the label the caller supplied.

Signing and attestation

from arche.sign import generate_keypair, load_private_key_pem, load_public_key, sign, verify

def generate_keypair() -> Keypair                       # .private_key, .public_key, .did_key
def load_private_key_pem(data: bytes | str, password: bytes | None = None) -> Keypair
def load_public_key(source: str | bytes) -> Ed25519PublicKey   # a did:key, PEM, or 32 raw bytes
def sign(payload: bytes | str | dict, private_key: Ed25519PrivateKey, *,
         kid: str | None = None, typ: str = "JWT", detached: bool = False,
         extra_header: dict[str, Any] | None = None) -> str
def verify(jws_compact: str, *, public_key: Ed25519PublicKey | None = None,
           resolver: Callable[[str], Ed25519PublicKey | None] | None = None,
           detached_payload: bytes | str | None = None,
           allow_did_key_from_kid: bool = False) -> VerificationResult

Ed25519 signatures, did:key identifiers and JWS envelopes. arche never stores a key. verify needs a key you already trust, or a resolver; without one it fails rather than falling back to the key the token names for itself. VerificationResult carries valid, trusted, header, payload, kid, key_source and error. The module also exports encode_did_key, decode_did_key, export_private_pem, export_public_pem, save_private_key, document_hash, and the SignWorkflow / VerifyExtractWorkflow pair that signs a Pipeline result and recovers it.

from arche.attest import attest, verify_attestation, signing_key, decision_ids_in

def attest(tool: str, inputs: Any, response: Any, *, keypair: Any, caller: str | None = None,
           decision_ids: list[str] | None = None, issued_at: datetime | None = None) -> dict[str, Any]
def verify_attestation(envelope: dict[str, Any], *, inputs: Any = None, response: Any = None,
                       public_key: Any = None, resolver: Any = None) -> AttestationCheck
def signing_key(path: str | Path | None = None) -> Any       # the installation's key, or None
def decision_ids_in(response: Any) -> list[str]

An attestation is a JWS over one answer: the tool called, a hash of the inputs, a hash of the response, who asked, and every decision id the response carried. AttestationCheck carries valid, trusted, signer, tool, decision_ids, inputs_match, response_match and problems. trusted is true only when the verifier supplied the key. arche serve and arche mcp attach one to every answer when ARCHE_SIGNING_KEY is set; arche attest verify checks it from the shell.

Comparator helpers

The pieces a comparator is made of, on arche.resolve. They are not in arche.__all__: you reach for them when writing your own comparator or reading why one scored what it did, and they are not part of the vocabulary. They are importable from arche too, for code written before 1.0.

from arche.resolve import (compare_geo, compare_place_qualifiers, load_type_vocab,
                           normalize_type_token, split_place_name, to_match_record)

def compare_geo(lat_a: float, lon_a: float, lat_b: float, lon_b: float, *,
                decay_km: float = 1.5) -> float
def split_place_name(name: str) -> tuple[str, str]                  # (core, qualifier)
def compare_place_qualifiers(name_a: str, name_b: str) -> float | None
def normalize_type_token(text: str, vocab: dict[str, str]) -> tuple[str | None, str]
def load_type_vocab(domain: str) -> dict[str, str]
def to_match_record(detections: Any) -> dict[str, Any]

compare_geo turns a distance into a similarity that decays with decay_km. split_place_name separates Kano Central from (Annex); compare_place_qualifiers scores the second half and answers None rather than 0.0 when one side has no qualifier, which is how an absent field stays absent. normalize_type_token reads PHC and Primary Health Centre as one type and hands back (type, residual name), with None for the type when it recognises none; load_type_vocab(domain) is the table you pass it. to_match_record turns Pipeline detections into a record the matcher can take.

Not part of the 1.x promise

These import and are unchanged. They are out of __all__ because a recommended verb should do its job on a plain install, and these ship fixtures only: without them the report comes back empty.

from arche import resolve_places, list_places          # the v0.1 directory lane

For places you hold yourself, resolve a delivery address is the lane that is promised.

What a promise about names does not cover

The list above promises names and their meanings. It does not promise numbers, and three kinds of number move on their own schedule.

Scores and confidences are calibration, not contract. Receipt.score, a candidate's confidence, distinctive_max: each is produced by weights that are measured and re-measured against the benchmarks. A release that improves a lane changes them, and that is the point of having benchmarks. What is promised is the shape ([0, 1], higher is more agreement) and the meaning of the verdict they feed.

Evidence strings are a growing vocabulary. 'street-number match', 'typo-tolerant street match', 'relation geometry agrees': a new comparator adds a new one. Read evidence as a list to show a person, not as an enum to branch on; the fields in factors and the verdict are what a program should decide by.

A decision_id is only stable against the engine that made it. Every id hashes the pinned versions, so a release that changes a comparator, a frequency table or a statute pack moves the ids that depended on it, by design. That is what replay reports: reproduced false with changed naming the pin. An id is an address for one decision under one engine, not a permanent name for a pair.

This is why arche.addr.request puts only two names in __all__, resolve_place_request and SpatialMention. Its Policy numbers, its evidence strings and its PLACE_ENGINE pin are all in the moving category above, and the module is four days younger than the freeze; Policy, MasterSheet, Endpoint, PlaceCandidate and PlaceSource are importable and documented in Resolve a delivery address, and they are not frozen for 1.x.

You want to Read
the same verbs from the shell Command line
the same verbs over a socket HTTP API
the same verbs from an agent MCP tools
what each field on a receipt means The decision
which extras unlock which names Extras