ef.corpus¶
The Corpus abstraction — ef’s source layer (L0) — and change detection.
A corpus is the entry point of the pipeline: the set of source documents to
be segmented, embedded and indexed. In ef a corpus is deliberately not a
new class — it is just a mapping:
Corpus = MutableMapping[source_id, Source]
Any dol-style mapping is a corpus: a plain dict (RAM), a filesystem
folder, an S3 bucket, an API-backed store. The backing store is swappable
because the rest of ef only ever sees a MutableMapping. This module
provides:
as_corpus()— the dependency-injection seam:None/ a mapping / a directory path / an iterable of strings → a corpus. Mirrorsas_embedder()andas_segmenter().content_hash()— the content hash of a source, the basis of change detection. Hashes normalized content (viaef.hashing); for a structured source it hashes the text only, so metadata-only edits never trigger a spurious re-embed.ChangeDetectingCorpus— a corpus wrapper that content-hashes values and reports what changed, both through-the-wrapper (via anon_changecallback) and out-of-band (viascan()).
ChangeDetectingCorpus is the “something changed” half of incremental
refresh; the downstream cascade is the ArtifactGraph (a later phase). The
on_change callback is the seam those two halves meet at.
Example — detect changes as they are written:
>>> events = []
>>> corpus = ChangeDetectingCorpus(on_change=events.append)
>>> corpus['doc1'] = 'hello world'
>>> corpus['doc1'] = 'hello world' # identical content — idempotent, no event
>>> corpus['doc1'] = 'hello there' # a real change
>>> del corpus['doc1']
>>> [(e.source_id, e.kind) for e in events]
[('doc1', 'added'), ('doc1', 'modified'), ('doc1', 'deleted')]
Module Attributes
A source document. |
|
a mapping from |
|
The kinds of change |
Functions
|
Content hash of a source document — |
|
Normalize |
Classes
|
A single detected change to a source in a corpus. |
|
The result of comparing a corpus's content against known content hashes. |
|
A corpus wrapper that content-hashes values and reports what changed. |
- class ef.corpus.ChangeDetectingCorpus(corpus=None, *, on_change=None, hashes=None, content_keys=None, fingerprint=None)[source]¶
Bases:
MutableMappingA corpus wrapper that content-hashes values and reports what changed.
Wraps any inner corpus (a
MutableMapping[source_id, Source]) and adds change detection without altering its mapping behavior — reads, writes and deletes pass straight through to the inner store. It is to a corpus whatCachedEmbedderis to an embedder: a transparent, composable layer.Changes are noticed two ways:
Through the wrapper. A
corpus[id] = sourceordel corpus[id]that actually changes content fires the injectedon_changecallback with aChangeEvent. Writing identical content is a no-op (no event) — the basis of idempotent ingestion.Out of band. When the backing store is edited directly (a file changed on disk, an S3 object replaced),
scan()re-reads every source, compares hashes, fires events for what differs and returns aCorpusDiff.diff()runs the same comparison with no sideeffects.
The
on_changecallback is the seam the downstreamArtifactGraphplugs into: a"modified"/"deleted"event is exactly the trigger for a cascade invalidation.On construction a baseline is established: the current content hashes of the inner corpus are recorded silently (no events). Pass an already-populated
hashesregistry to instead resume from a persisted baseline — then callscan()to reconcile any drift.- Parameters:
corpus (
Any) – the inner corpus to wrap. Coerced viaas_corpus(), so a directory path /dict/ iterable of sources /Noneall work.on_change (
Callable[[ChangeEvent],Any] |None) – called with eachChangeEvent. Defaults to a no-op. It should be cheap and not raise; an exception propagates to the caller after the corpus and registry are already consistent.hashes (
MutableMapping[str,str] |None) – the hash registry — aMutableMapping[source_id, str]of the last-known content hash per source. Injectable so it can be persisted (e.g. adolstore); defaults to an in-RAMdict. If passed already-populated it is trusted as the baseline.content_keys (
Iterable[str] |None) – forwarded tocontent_hash()— metadata keys that participate in the hash.fingerprint (
Callable[[str],Hashable] |None) – an optional cheapsource_id -> Hashablecallback (an mtime / etag prefilter). When given,scan()/diff()skip re-hashing a source whose fingerprint is unchanged. The content hash always remains the source of truth — the fingerprint is only a speed optimization, never trusted to declare a change.
>>> backing = {'a': 'one', 'b': 'two'} >>> corpus = ChangeDetectingCorpus(backing) # baseline adopts 'a' and 'b' >>> backing['a'] = 'ONE' # edited behind the wrapper's back >>> backing['c'] = 'three' >>> del backing['b'] >>> d = corpus.scan() >>> (d.added, d.modified, d.deleted) (('c',), ('a',), ('b',))- diff()[source]¶
Compare current corpus content against the registry — no side effects.
Detects out-of-band edits (changes made to the backing store directly) without firing events or updating the registry.
scan()is the effectful counterpart.- Return type:
- scan()[source]¶
Reconcile the registry with the corpus, fire events, return the diff.
Use this to pick up out-of-band edits — changes made to the backing store directly rather than through this wrapper. Every detected change fires a
ChangeEventand the registry (and fingerprints) are advanced to the new state, so a secondscan()with nothing further changed is a no-op.diff()is the side-effect-free version.- Return type:
- class ef.corpus.ChangeEvent(source_id, kind, old_hash=None, new_hash=None)[source]¶
Bases:
objectA single detected change to a source in a corpus.
Emitted by
ChangeDetectingCorpusto itson_changecallback.old_hashisNonefor an"added"event,new_hashisNonefor a"deleted"one.- source_id¶
the key of the source that changed.
- kind¶
"added","modified"or"deleted".
- old_hash¶
the source’s previous content hash, if any.
- new_hash¶
the source’s new content hash, if any.
- ef.corpus.ChangeKind¶
The kinds of change
ChangeDetectingCorpusreports.alias of
Literal[‘added’, ‘modified’, ‘deleted’]
- ef.corpus.Corpus¶
a mapping from
source_idtoSource.efnever requires a dedicated class — anydol-styleMutableMapping(RAM, filesystem, S3, API) is a corpus.as_corpus()builds one.- Type:
A corpus
- class ef.corpus.CorpusDiff(added=(), modified=(), deleted=(), unchanged=())[source]¶
Bases:
objectThe result of comparing a corpus’s content against known content hashes.
The four fields partition the corpus’s source ids; each is a sorted tuple, so a
CorpusDiffis deterministic and JSON-friendly. Truthiness reports whether anything changed.>>> d = CorpusDiff(added=('b',), modified=('a',)) >>> bool(d), d.changed (True, ('b', 'a')) >>> bool(CorpusDiff(unchanged=('a', 'b'))) False
- ef.corpus.Source = str | collections.abc.Mapping[str, typing.Any]¶
A source document. Either plain
text, or aMappingcarrying atextkey alongside metadata — the same shape aSegmenteraccepts as input.
- ef.corpus.as_corpus(x=None, /, **kwargs)[source]¶
Normalize
xinto a corpus (MutableMapping[source_id, Source]).The single dependency-injection seam through which every
efentry point accepts a user-supplied corpus — the mirror ofas_embedder()andas_segmenter(). Accepts, in order:None— a fresh empty in-RAMdict;a
Mapping— returned unchanged (adict, adolstore, another corpus: it already is a corpus);a directory path (
str/os.PathLikenaming an existing directory) — adolfilesystem store of the text files under it (dolis imported lazily; only this branch needs it);an iterable of sources (strings or mappings) — an in-RAM
dictkeyed by each source’scontent_hash()(content-addressed, so duplicate sources collapse to one entry).
Extra
**kwargsare forwarded to thedolfilesystem store (the directory branch only) and ignored otherwise.- Raises:
TypeError – if
xis a non-directory string, or none of the above.
>>> as_corpus() {} >>> as_corpus({'a': 'x'}) # a mapping passes straight through {'a': 'x'} >>> sorted(as_corpus(['alpha', 'beta']).values()) ['alpha', 'beta'] >>> len(as_corpus(['dup', 'dup'])) # content-addressed: duplicates collapse 1
- ef.corpus.content_hash(source, *, content_keys=None)[source]¶
Content hash of a source document —
sha256of its normalized content.This is the basis of change detection: re-reading an unchanged source yields an identical hash, so a refresh that finds the same hash does nothing. Hashing normalized content (NFC, no BOM,
\nline endings — seeef.hashing) means cosmetic encoding differences never look like a change.The treatment of a
Mappingsource is deliberate:a plain
strsource — the hash covers the text;a
Mappingwith atextkey — the hash coverssource['text']only. A change to title, tags, ACL or any other metadata does not change the hash, so it never triggers a spurious re-segment/re-embed (embedding is the pipeline’s most expensive step). Name the metadata keys that do feed downstream incontent_keysand changes to those keys will be detected;a
Mappingwithout atextkey — treated as a structured document and hashed via canonical (sorted-key) JSON of the whole mapping;bytes— hashed raw (no text normalization is possible pre-parse).
- Parameters:
source (
Any) – astr,bytes, or aMapping(ideally with atextkey).content_keys (
Iterable[str] |None) – metadata keys whose values join the text in the hash. Each key is looked up at the top level ofsourceor inside asource['metadata']sub-mapping. Use this only for metadata that genuinely affects the L1–L4 pipeline (e.g. a title prepended to the text before embedding); key order does not matter.
- Raises:
TypeError – if
sourceis not astr,bytesorMapping.- Return type:
>>> content_hash('hello') == content_hash('hello') True >>> content_hash('hello') == content_hash('hello world') False >>> content_hash('a\r\nb') == content_hash('a\nb') # line endings normalized True >>> a = {'text': 'body', 'metadata': {'tags': ['x']}} >>> b = {'text': 'body', 'metadata': {'tags': ['y']}} >>> content_hash(a) == content_hash(b) # metadata ignored by default True >>> content_hash(a, content_keys=['tags']) == content_hash(b, content_keys=['tags']) False >>> content_hash({'text': 'body'}) == content_hash('body') # wrapper is transparent True