fix: make retrieval strategy optional in BaseMemoryConfigs - #84
Open
breken-ai wants to merge 1 commit into
Open
Conversation
Problem ------- `LightMemory` cannot be constructed with the default configuration that its own signature declares. `LightMemory.__init__` is `def __init__(self, config: BaseMemoryConfigs = BaseMemoryConfigs()):` (src/lightmem/memory/lightmem.py:108) and its docstring lists retrieval as optional - "retrieve_strategy (optional)", "embedding_retriever (optional): Embedding-based retriever if retrieve_strategy is 'embedding' or 'hybrid'" (src/lightmem/memory/lightmem.py:129-131). In practice that default fails: construction unconditionally walks the embedding retrieval branch and dereferences an `embedding_retriever` that was never supplied, so there is no way to build an instance for indexing/extraction only. Root cause ---------- `retrieve_strategy` defaulted to `"embedding"` (src/lightmem/configs/base.py:78) while `embedding_retriever` defaulted to `None` (src/lightmem/configs/base.py:86). `LightMemory.__init__` (src/lightmem/memory/lightmem.py:178-183) follows the strategy, so the default configuration always asked for a retriever the default configuration never provided. Nothing validated that pairing, so the contradiction only surfaced at construction time. Approach -------- Default `retrieve_strategy` to `None` and add an after-model validator that reconciles strategy and retriever configuration: * When the strategy is omitted, infer it from the retriever configs that were actually supplied: `embedding` whenever an `embedding_retriever` is present, `context` when only a `context_retriever` is, and `None` when neither is configured. `LightMemory` then skips both retriever factories and constructs successfully. * The inference is gated on `"retrieve_strategy" not in self.model_fields_set`, so it fires only for an omitted field. An explicit `retrieve_strategy=None` keeps meaning "no retrieval" exactly as it did before, whatever retriever configs accompany it. That matters because the old `"embedding"` default made an explicit `None` the only way to obtain a retrieval-less instance, and such a config may still carry a `context_retriever` from when BM25 existed; inferring `context` for it would send it into ContextRetrieverFactory, which constructs for no configuration on this commit. * An omitted strategy never infers `hybrid`. A configuration that supplies both retrievers resolved to `embedding` before this change and built a working embedding-only instance; inferring `hybrid` would send it into the context branch (src/lightmem/memory/lightmem.py:181), which constructs for no configuration on this commit because `bm25.py` has been a single newline since 0915348 "delete BM25". Hybrid retrieval stays opt-in through an explicit `retrieve_strategy="hybrid"`, whose behaviour is unchanged. * When the strategy is set explicitly, require its matching retriever config and raise a clear `ValueError` otherwise, instead of failing later with an attribute error. This mirrors the check the web backend already performs in `web/backend/app/configspec.py:305`. Every in-repo config that sets `retrieve_strategy` also supplies a matching `embedding_retriever` on the following line - the README configuration example under "Initialize LightMem", the two example scripts, the four experiment scripts, `mcp/example.json`, the three tutorial notebooks, and the web console preset at `web/backend/app/configspec.py:116` - so their behaviour is unchanged. The field description and the three affected rows of the README configuration table are updated to describe the new default, the inference rule, that `hybrid` is never inferred and that an explicit `None` is honoured rather than inferred; the existing strategy-selection guidance in that table is kept. Behaviour changes worth flagging -------------------------------- Enumerating the full (strategy omitted / explicitly None) x (context_retriever present or absent) x (embedding_retriever present or absent) matrix and running every cell through config resolution and the real `LightMemory.__init__` on both trees, exactly two of the eight cells change, and neither constructed before: the omitted-strategy, no-retriever cell, which is the fix; and a configuration that supplies only a `context_retriever` and omits the strategy, which now resolves to `context` rather than `embedding`. The latter failed at construction before this change - `AttributeError: 'NoneType' object has no attribute 'model_name'` at src/lightmem/memory/lightmem.py:184 - and still fails, now with the pre-existing BM25 `ImportError` at src/lightmem/memory/lightmem.py:181 instead. No configuration that constructed before stops constructing, and all four explicit-None cells are identical on both trees. `configspec.validate()` both constructs `BaseMemoryConfigs` (line 220) and re-checks part of the same condition by hand (line 305), so the web console's error list changes. With `retrieve_strategy` set to `"embedding"` and no vector store, the one misconfiguration is now reported twice. With `"context"` or `"hybrid"` and no `context_retriever` - a case the hand-rolled check never covered - the model contributes an error the console did not report before: `context` goes from no error to one, `hybrid` gains a second, distinct one. Both are true positives; both configurations already failed inside a retriever factory when handed to `LightMemory`. The new entries have an empty `path`, so the frontend cannot anchor them to a field, and they carry no `message_zh`. Deduplicating that belongs in the web backend and is deliberately not done here; it is called out in the PR body. Verification ------------ `PYTHONPATH=src python -m pytest tests/test_memory_config.py -q` -> 15 passed, 1 warning. The warning is a pre-existing Pydantic V2 deprecation raised by `src/lightmem/configs/logging/base.py`. `PYTHONPATH=src python -m pytest tests -q` -> 17 passed, 1 warning. Restoring the pre-change `src/lightmem/configs/base.py` under the new test file turns those 15 passes into 6 failed, 9 passed; the nine that still pass are the no-regression guards, which are meant to hold on both trees. black, isort (black profile) and flake8 (88 columns) are clean on the new test; `src/lightmem/configs/base.py` compiles and its flake8 findings go from 18 to 16 with no new finding introduced; `git diff --check` is clean. Impact ------ The default configuration that `LightMemory.__init__` declares in its own signature now constructs, so an indexing/extraction-only instance is possible. Explicit misconfiguration fails fast with an actionable message rather than an attribute error deeper in construction. Callers that already pair a strategy with its retriever config - which is every configuration in this repository - see no change, and so do callers that passed `retrieve_strategy=None` explicitly to disable retrieval.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Related issue
Closes #78
Symptom
LightMemorycannot be constructed with the default configuration that its own signature declares.LightMemory.__init__isdef __init__(self, config: BaseMemoryConfigs = BaseMemoryConfigs()):(
src/lightmem/memory/lightmem.py:108), and its docstring lists retrieval as optional —"retrieve_strategy (optional)", "embedding_retriever (optional): Embedding-based retriever if
retrieve_strategy is 'embedding' or 'hybrid'" (
src/lightmem/memory/lightmem.py:129-131). Inpractice that default explodes:
So there is no way to build a LightMem instance for indexing/extraction only: any configuration that
does not spell out a vector store fails at construction, with an error that names neither the field
you forgot nor the setting that demanded it.
A note on how I reached that traceback, since it takes one step in a bare checkout: plain
LightMemory(BaseMemoryConfigs())fails earlier in my environment, withImportError: Could not import manager'lightmem.factory.memory_manager.openai.OpenaiManager': No module named 'openai'— the missing space aftermanageris upstream's — because I do not have theopenaiextra installed and the defaultmemory_managerbuilds an OpenAI client. The tracebackabove is the same call with a single
unittest.mock.patch.objectonMemoryManagerFactory.from_configreturning a stub manager — that one patch stands in for themissing extra. Nothing else in
__init__is stubbed;ShortMemBufferManagerand the retriever factory run for real, and the retriever factory is where it dies.
Root cause
Two adjacent field defaults contradict each other. Line numbers are on the base commit
8449d57:src/lightmem/configs/base.py:77-81—retrieve_strategydefaults to"embedding".src/lightmem/configs/base.py:86-89—embedding_retrieverdefaults toNone.src/lightmem/memory/lightmem.py:178-184—__init__reads the strategy and, when it is"embedding"or"hybrid", unconditionally callsEmbeddingRetrieverFactory.from_config(self.config.embedding_retriever).src/lightmem/factory/retriever/embeddingretriever/factory.py:25— that factory dereferencesconfig.model_nameon theNoneit was handed.Nothing validates the pairing, so the contradiction only surfaces at construction time as an
attribute error four frames deep. The sibling field already gets this right:
index_strategydefaults toNone(src/lightmem/configs/base.py:60-61), which is exactly whytext_embedderbeing unset is harmless whileembedding_retrieverbeing unset is fatal.What this change does
3 files changed, 224 insertions(+), 5 deletions(-):src/lightmem/configs/base.py—retrieve_strategynow defaults toNone, and a@model_validator(mode="after")reconciles strategy and retriever configuration:'embedding'whenever anembedding_retrieveris supplied,'context'when only a
context_retrieveris, and leftNonewhen neither is configured, in which caseLightMemoryskips both retriever factories and constructs;"retrieve_strategy" not in self.model_fields_set, so it fires onlyfor an omitted field. An explicit
retrieve_strategy=Noneis honoured as "no retrieval",exactly as on the base commit, whatever retriever configs accompany it — see behaviour change 1;
'hybrid', deliberately — see Why an omitted strategynever infers
'hybrid'below. Hybrid retrieval stays opt-in through an explicitretrieve_strategy="hybrid", which behaves exactly as it does today;ValueErrornamingthe missing field otherwise, instead of failing later inside a factory.
model_validatorwas already imported and unused in this module at the base commit, so the importbecomes live rather than new.
README.md:516-518— the configuration table row forretrieve_strategykeeps its existingstrategy-selection guidance and gains the
Nonedefault and the inference rule, including that'hybrid'is never inferred and that an explicitNoneis honoured rather than inferred; thecontext_retrieverandembedding_retrieverrows now also say that supplying them is what drivesthat inference, and which one wins when both are supplied.
tests/test_memory_config.py(new, 186 lines, 15 cases).After the change the same call succeeds:
Behaviour changes worth calling out
1. An explicit
retrieve_strategy=Nonestill means "no retrieval" — deliberately not a change.The inference is gated on
"retrieve_strategy" not in self.model_fields_set, so it fires only for anomitted field. A caller who spells the absence out gets exactly what the base commit gave them: no
strategy, no retriever, and a
LightMemorythat constructs.That distinction matters because on the base commit
retrieve_strategydefaulted to"embedding",so an explicit
Nonewas the only way to obtain a retrieval-less instance — which is precisely thepopulation this pull request sets out to serve. Such a configuration may still be carrying a
context_retrieverfrom when BM25 existed, and inferring'context'for it would send it intoContextRetrieverFactory, which constructs for no configuration on this commit (bm25.pyhas been asingle newline since
0915348 delete BM25).Measured on both trees over all four explicit-
Nonecells, with the manager and buffer stubbed asdescribed above and both retriever factories left real:
tests/test_memory_config.py::test_explicit_none_strategy_is_not_inferredcovers all four cells atconfig level and
::test_explicit_none_with_context_retriever_still_constructsbuilds a realLightMemoryfor thecontext_retrieverone and asserts neither retriever factory is called, so thegate cannot be lost unnoticed.
2. Why an omitted strategy never infers
'hybrid', and what stays unchanged because of it.A configuration that supplies both retrievers and omits
retrieve_strategyresolved to'embedding'on the base commit — that was the hardcoded default — and built a workingembedding-only
LightMemory. Inferring'hybrid'for it would look tidier and would break it:'hybrid'walks the context branch atsrc/lightmem/memory/lightmem.py:181, and no contextconfiguration constructs on this commit at all, because
bm25.pyhas been a single newline since0915348 delete BM25. So the rule is "anembedding_retrievermeans'embedding'", and thatconfiguration resolves and constructs exactly as before. Measured on both trees, with a config
carrying both retrievers, no strategy, and the manager and buffer stubbed as described above:
tests/test_memory_config.py::test_both_retrievers_without_strategy_still_build_embedding_onlyconstructs a real
LightMemoryfor that case and assertsContextRetrieverFactoryis never called,so the inference cannot drift back to
'hybrid'unnoticed. The cost of the rule is that a caller whowants hybrid retrieval must say so:
retrieve_strategy="hybrid"still builds both retrievers(
test_explicit_hybrid_still_builds_both_retrievers), and the README rows say'hybrid'is neverinferred.
3. A configuration that supplies only a
context_retrieverand omits the strategy now resolves to'context'instead of'embedding', which changes the error it dies with. Neither resolutionconstructs on this commit, so nothing that worked stops working, but the failure moves:
The second error is the pre-existing BM25 one: because
bm25.pyis empty,ContextRetrieverFactory.from_configraises it for any context configuration — inferred orexplicit — and it did so before this branch too. The inference sends such a config at the retriever it
actually asked for instead of at a vector store it never configured; it does not make it work, and it
did not work before. If you would rather this branch keep pointing those configs at the embedding
branch until BM25 is restored, dropping the
contextarm of the inference is a two-line change and Iam happy to make it.
4. A
LightMemorywith no retrieval configured now constructs, so the failure moves fromconstruction to first use.
LightMemory.retrievedoes not guard the optional components, so on thisbranch a retriever-less instance raises when you query it:
That is the pre-existing shape of the optional-component design —
index_strategyalready defaults toNone, sotext_embedderwas already absent by default — but before this change you could neverreach it, because construction died first. Adding a friendly "retrieval is not configured" error to
retrieve()felt like a separate change with its own API question, so I left it out; say the word andI will add it here or in a follow-up. The web backend already does exactly that for its own path
(
web/backend/app/instance.py:282-286raisesInstanceNotReadywith an actionable message).5. The web console's error list changes: one duplicated error, and two errors it did not report at
all before.
configspec.validate()both constructsBaseMemoryConfigs(**probe)(
web/backend/app/configspec.py:218-225) and re-checks the same kind of condition by hand(
web/backend/app/configspec.py:305). That hand-rolled check only covers a missingembedding_retriever, so the model's error duplicates it forembeddingand is new forcontextandhybrid. Reproduced on both trees for all three strategies the console's dropdown offers, withconfigspec.validate({'retrieve_strategy': <strategy>})['errors']:'embedding'— the same misconfiguration is now reported twice:'context'— base reports nothing; this branch reports one new error:'hybrid'— base reports only the vector store; this branch adds a second, distinct error about themissing context retriever:
The two new
contexterrors are true positives, not false alarms: on the base commit a config withretrieve_strategy="context"and nocontext_retrieveralready dies inside the retriever factory(
src/lightmem/memory/lightmem.py:181) the moment it reachesLightMemory, withImportError: Maybe class 'BM25' not found in module 'lightmem.factory.retriever.contextretriever.bm25'.That particular error is a separate, pre-existing matter —
bm25.pyhas been a single newline since0915348 delete BM25, so no context config constructs on this commit — and this branch neitherfixes nor worsens it.
Each new entry has an empty
path, so the frontend cannot anchor it to a field, and carries nomessage_zhwhere every other error dict inconfigspec.pydoes. Deduplicating means editingweb/backend/, which I judged out of scope for a change tosrc/lightmem/configs/base.py— theredundant check at
configspec.py:305is now fully covered by the model and could simply be deleted.I will do that here on request rather than leave it for a follow-up if you prefer.
This does not affect the configurations the console can actually produce: its
retrieve_strategyselect offers only
embedding/context/hybridwith no unset option(
web/backend/app/configspec.py:81-84), and all three shipped presets pair the strategy with a vectorstore. I ran
configspec.validate()overconfigspec.presets()on both trees — thefull,segmentand
lightpresets return the same error lists on base and on this branch — identical apart from thecheckout path each one embeds — all of them environment-related (missing local model directories,
unset API key) and none of them about retrieval.
So the validator adds no false positive to anything the console ships.
6. Explicitly misconfigured configs are now rejected at construction.
retrieve_strategy="hybrid"with no
context_retriever, for example, used to build a config object and fail later. It now raisesa
ValueErrorfrom the model. No previously working configuration is newly rejected: every inputthe validator rejects already crashed inside a retriever factory the moment it was handed to
LightMemory. Nor does any previously working configuration newly fail at construction. Ienumerated the full (strategy omitted / explicitly
None) x (context_retrieverpresent or absent) x(
embedding_retrieverpresent or absent) matrix — eight cells — and ran every one through configresolution and the real
LightMemory.__init__on both trees. Exactly two cells change, and neitherconstructed on the base commit:
AttributeErroratsrc/lightmem/memory/lightmem.py:184quoted at the top of this description and now constructs —that is the fix;
context_retriever, which is behaviour change 3 above: it died withthat same
AttributeErrorand now dies with the pre-existing BM25ImportErrorinstead.The other six are identical on both trees: the four explicit-
Nonecells above (behaviour change 1),and the two cells that supply an
embedding_retrieverand omit the strategy, which resolve to'embedding'on both trees and callEmbeddingRetrieverFactory.from_configexactly once on both(behaviour change 2 — that factory is a spy in this run, since I have no Qdrant instance).
Compatibility
Enumerated with
git grep -n retrieve_strategyon the base commit: 33 matching lines across 17files. Twelve of those lines assign a value to
retrieve_strategy; all twelve set"embedding",and all twelve supply an
embedding_retrieveron the immediately following line, so none of themchange behaviour:
retrieve_strategyembedding_retrieverREADME.mdexamples/run_lightmem_ollama.pyexamples/run_lightmem_transformers.pyexperiments/locomo/add_locomo.pyexperiments/longmemeval/offline_update.pyexperiments/longmemeval/run_lightmem_gpt.pyexperiments/longmemeval/run_lightmem_qwen.pymcp/example.jsontutorial-notebooks/LightMem_Example_code.ipynbtutorial-notebooks/LightMem_Example_longmemeval.ipynbtutorial-notebooks/LightMem_Example_travel.ipynbweb/backend/app/configspec.py(_base_preset)The remaining 21 lines are the field declaration and its neighbours' descriptions
(
src/lightmem/configs/base.py:77, 83, 87), consumers that read the resolved value(
src/lightmem/memory/lightmem.py:129-131, 178-184,mcp/server.py:288-291,web/backend/app/instance.py:187, 285,web/backend/app/configspec.py:81, 305), the READMEconfiguration table (
README.md:513, 516-518) and the frontend type(
web/frontend/src/lib/api.ts:148). None of them assigns the field.Testing
Environment: macOS (arm64), CPython 3.11.15 (the repo requires
>=3.10,<3.12,pyproject.toml:10),pytest 9.1.1, black 26.5.1, isort 9.0.1, flake8 7.3.0 — the four tools the
devextra declares(
pyproject.toml). Everything below was run in a cleangit worktreecheckout of this branch.The repository has no
conftest.pyand no pytest configuration, andlightmemis not installed inthe environment, so
PYTHONPATH=srcis required for collection to succeed at all. That is apre-existing condition of the checkout, not something this branch introduces.
New file
tests/test_memory_config.py, 15 cases:retrieve_strategy is None;LightMemory.__init__runs against a realBaseMemoryConfigs()and constructs, withneither retriever attribute set and neither retriever factory called;
contextfor a context-only config,embeddingfor anembedding-only one, and
embeddingagain when both retrievers are supplied;LightMemoryis built andContextRetrieverFactorymust not be called (the no-regression guard for behaviour change 2);retrieve_strategy="hybrid"with both retrievers still calls both factories;retrieve_strategy=Noneis never inferred, onefor each combination of retriever configs (the no-regression guard for behaviour change 1);
None-plus-context_retrievercase again at construction level: a realLightMemoryis built and neither retriever factory is called;
The base commit's
tests/contains one file and runs2 passed; the delta is exactly thefifteen new tests. The single warning is a pre-existing
PydanticDeprecatedSince20raised fromsrc/lightmem/configs/logging/base.py:7; it does not appear on base only because base's two testsnever import that module — importing
lightmem.configs.baseon the unmodified base commit emits theidentical warning.
The repository ships no
[tool.black],[tool.isort],setup.cfg,tox.inior.flake8, so thoseare black's own default line length and the black-compatible isort profile;
--max-line-length=88ispassed to flake8 so it agrees with black rather than with flake8's default of 79.
Flake8 on the modified module, base commit versus this branch:
Diffing the two sorted finding lists shows two deletions and nothing added:
The
F401goes away because the already-presentmodel_validatorimport is now used; theW391because the validator is appended past the file's old trailing blank line. The remaining 16 findings
are pre-existing and deliberately untouched.
Revert proof
In the same worktree,
src/lightmem/configs/base.pywas restored from the base commit with the newtest file left in place:
Restored afterwards:
git status --shortclean,PYTHONPATH=src python -m pytest tests -qback to17 passed, 1 warning.Six of fifteen fail without the change. The nine that pass on base are the no-regression guards, and
passing on base is the correct outcome for every one of them:
test_retriever_configs_infer_strategy[kwargs1-embedding]— an embedding-only config resolves to"embedding", which the old hardcoded default also satisfies, for a different reason;test_retriever_configs_infer_strategy[kwargs2-embedding]— the both-retrievers config resolves to"embedding"on both trees; that is the point of it;test_both_retrievers_without_strategy_still_build_embedding_only— same config at constructionlevel, embedding retriever built and context retriever not, on both trees;
test_explicit_hybrid_still_builds_both_retrievers— an explicit"hybrid"builds both retrieverson both trees, so the opt-in path is untouched by this change;
test_explicit_none_strategy_is_not_inferredcases andtest_explicit_none_with_context_retriever_still_constructs— an explicitretrieve_strategy=Noneresolves to
Noneand builds no retriever on both trees, which is exactly behaviour change 1.Those five explicit-
Noneguards are not vacuous. Swapping the validator's gate from"retrieve_strategy" not in self.model_fields_settoself.retrieve_strategy is None— the obviousalternative spelling, which cannot tell an omitted field from one explicitly set to
None— andleaving everything else identical fails four of them:
(
kwargs0— an explicitNonewith no retriever config at all — passes there too, because withnothing to infer from there is nothing to infer.)
What was NOT verified
openaiextrais not installed in my environment. The construction test stubs
MemoryManagerFactory.from_configandShortMemBufferManager, and replacesContextRetrieverFactory.from_configandEmbeddingRetrieverFactory.from_configwithMock()spies — necessarily, since the assertion is that they are never called. Only the factory classes
are real. What is genuinely exercised is the real
LightMemory.__init__against a realBaseMemoryConfigs.store or query a single memory on either tree.
web/backend/app/configspec.pyin-process andcalled
validate()andpresets(); the FastAPI app and the frontend were not launched, so I havenot seen how the console renders an error with an empty
path— only that such an error is nowproduced.
They were read and their line citations checked, nothing more.
check code that depends on
retrieve_strategydefaulting to"embedding"— behaviour changes 3and 6 above are the two places such a caller would notice. Behaviour changes 1 and 2 are the cases
I deliberately kept identical to the base commit, and each is covered by tests that pass on both
trees; code that relied on an explicit
retrieve_strategy=Noneto disable retrieval is unaffected.Checklist
This repository ships no pull request template and no
CONTRIBUTING.md(there is no.github/directory at all), so the items below come from the one contribution rule stated in the README:
"We welcome contributions from the community! If you'd like to contribute, please fork the repository
and submit a pull request. For major changes, please open an issue first to discuss what you would
like to change." (
README.md:614).a duplicate, BaseMemoryConfigs() cannot build a LightMemory: retrieve_strategy defaults to "embedding" but embedding_retriever defaults to None #82, without finding retrieve_strategy defaults to 'embedding' but embedding_retriever defaults to None, so a minimal config cannot construct LightMemory #78 first, and have closed it as a duplicate.
tests/test_memory_config.py,6 of 15 failing against the unpatched module; the other nine are no-regression guards that are
supposed to pass on base and do.
black/isort --profile black/flake8 --max-line-length=88clean on the new test file;src/lightmem/configs/base.pycompiles and loses two findings without gaining any.PYTHONPATH=src pytest testsgreen (17 passed), with no pre-existing test disturbed.README.md:516-518.reproduced output; the fix belongs in
web/backend/app/configspec.py:305and I kept this branchto
src/lightmem/configs/base.pyand its documentation. Say the word and I will include it.scope reason; disclosed as behaviour change 4 above.
modified file and are untouched.