Skip to content

fix: make retrieval strategy optional in BaseMemoryConfigs - #84

Open
breken-ai wants to merge 1 commit into
zjunlp:mainfrom
breken-ai:fix/optional-retrieval-strategy
Open

fix: make retrieval strategy optional in BaseMemoryConfigs#84
breken-ai wants to merge 1 commit into
zjunlp:mainfrom
breken-ai:fix/optional-retrieval-strategy

Conversation

@breken-ai

@breken-ai breken-ai commented Sep 10, 2026

Copy link
Copy Markdown

Related issue

Closes #78

Symptom

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 explodes:

  File ".../src/lightmem/memory/lightmem.py", line 184, in __init__
    self.embedding_retriever = EmbeddingRetrieverFactory.from_config(self.config.embedding_retriever)
  File ".../src/lightmem/factory/retriever/embeddingretriever/factory.py", line 25, in from_config
    model_name = config.model_name
AttributeError: 'NoneType' object has no attribute 'model_name'

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, with
ImportError: Could not import manager'lightmem.factory.memory_manager.openai.OpenaiManager': No module named 'openai' — the missing space after manager is upstream's — because I do not have the
openai extra installed and the default memory_manager builds an OpenAI client. The traceback
above is the same call with a single unittest.mock.patch.object on
MemoryManagerFactory.from_config returning a stub manager — that one patch stands in for the
missing extra. Nothing else in __init__ is stubbed; ShortMemBufferManager
and 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-81retrieve_strategy defaults to "embedding".
  • src/lightmem/configs/base.py:86-89embedding_retriever defaults to None.
  • src/lightmem/memory/lightmem.py:178-184__init__ reads the strategy and, when it is
    "embedding" or "hybrid", unconditionally calls
    EmbeddingRetrieverFactory.from_config(self.config.embedding_retriever).
  • src/lightmem/factory/retriever/embeddingretriever/factory.py:25 — that factory dereferences
    config.model_name on the None it 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_strategy defaults to None (src/lightmem/configs/base.py:60-61), which is exactly why
text_embedder being unset is harmless while embedding_retriever being unset is fatal.

What this change does

3 files changed, 224 insertions(+), 5 deletions(-):

  • src/lightmem/configs/base.pyretrieve_strategy now defaults to None, and a
    @model_validator(mode="after") reconciles strategy and retriever configuration:

    • strategy omitted'embedding' whenever an embedding_retriever is supplied, 'context'
      when only a context_retriever is, and left None when neither is configured, in which case
      LightMemory skips both retriever factories and constructs;
    • 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 is honoured as "no retrieval",
      exactly as on the base commit, whatever retriever configs accompany it — see behaviour change 1;
    • an omitted strategy never resolves to 'hybrid', deliberately — see Why an omitted strategy
      never infers 'hybrid'
      below. Hybrid retrieval stays opt-in through an explicit
      retrieve_strategy="hybrid", which behaves exactly as it does today;
    • strategy set explicitly → require its matching retriever config, and raise a ValueError naming
      the missing field otherwise, instead of failing later inside a factory.

    model_validator was already imported and unused in this module at the base commit, so the import
    becomes live rather than new.

  • README.md:516-518 — the configuration table row for retrieve_strategy keeps its existing
    strategy-selection guidance and gains the None default and the inference rule, including that
    'hybrid' is never inferred and that an explicit None is honoured rather than inferred; the
    context_retriever and embedding_retriever rows now also say that supplying them is what drives
    that 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:

ShortMemBufferManager initialized with max_tokens=512
constructed OK; retrieve_strategy = None | has embedding_retriever: False | has context_retriever: False

Behaviour changes worth calling out

1. An explicit retrieve_strategy=None still 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 an
omitted field. A caller who spells the absence out gets exactly what the base commit gave them: no
strategy, no retriever, and a LightMemory that constructs.

That distinction matters because on the base commit retrieve_strategy defaulted to "embedding",
so an explicit None was the only way to obtain a retrieval-less instance — which is precisely the
population this pull request sets out to serve. Such a configuration may still be carrying a
context_retriever from when BM25 existed, and inferring 'context' for it would send it into
ContextRetrieverFactory, which constructs for no configuration on this commit (bm25.py has been a
single newline since 0915348 delete BM25).

Measured on both trees over all four explicit-None cells, with the manager and buffer stubbed as
described above and both retriever factories left real:

base commit 8449d57:
neither              -> strategy=None   constructed OK | emb: False | ctx: False
context_retriever    -> strategy=None   constructed OK | emb: False | ctx: False
embedding_retriever  -> strategy=None   constructed OK | emb: False | ctx: False
both                 -> strategy=None   constructed OK | emb: False | ctx: False

this branch:
neither              -> strategy=None   constructed OK | emb: False | ctx: False
context_retriever    -> strategy=None   constructed OK | emb: False | ctx: False
embedding_retriever  -> strategy=None   constructed OK | emb: False | ctx: False
both                 -> strategy=None   constructed OK | emb: False | ctx: False

tests/test_memory_config.py::test_explicit_none_strategy_is_not_inferred covers all four cells at
config level and ::test_explicit_none_with_context_retriever_still_constructs builds a real
LightMemory for the context_retriever one and asserts neither retriever factory is called, so the
gate 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_strategy resolved to
'embedding' on the base commit — that was the hardcoded default — and built a working
embedding-only LightMemory. Inferring 'hybrid' for it would look tidier and would break it:
'hybrid' walks the context branch at src/lightmem/memory/lightmem.py:181, and no context
configuration constructs on this commit at all, because bm25.py has been a single newline since
0915348 delete BM25. So the rule is "an embedding_retriever means 'embedding'", and that
configuration 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:

base commit:
resolved retrieve_strategy = 'embedding'
constructed OK; retrieve_strategy = 'embedding' | has embedding_retriever: True | has context_retriever: False

this branch:
resolved retrieve_strategy = 'embedding'
constructed OK; retrieve_strategy = 'embedding' | has embedding_retriever: True | has context_retriever: False

tests/test_memory_config.py::test_both_retrievers_without_strategy_still_build_embedding_only
constructs a real LightMemory for that case and asserts ContextRetrieverFactory is never called,
so the inference cannot drift back to 'hybrid' unnoticed. The cost of the rule is that a caller who
wants 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 never
inferred.

3. A configuration that supplies only a context_retriever and omits the strategy now resolves to
'context' instead of 'embedding', which changes the error it dies with.
Neither resolution
constructs on this commit, so nothing that worked stops working, but the failure moves:

base commit:
resolved retrieve_strategy = 'embedding'
CONSTRUCTION FAILED at lightmem.py:184
AttributeError: 'NoneType' object has no attribute 'model_name'

this branch:
resolved retrieve_strategy = 'context'
CONSTRUCTION FAILED at lightmem.py:181
ImportError: Maybe class 'BM25' not found in module 'lightmem.factory.retriever.contextretriever.bm25': module 'lightmem.factory.retriever.contextretriever.bm25' has no attribute 'BM25'

The second error is the pre-existing BM25 one: because bm25.py is empty,
ContextRetrieverFactory.from_config raises it for any context configuration — inferred or
explicit — 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 context arm of the inference is a two-line change and I
am happy to make it.

4. A LightMemory with no retrieval configured now constructs, so the failure moves from
construction to first use.
LightMemory.retrieve does not guard the optional components, so on this
branch a retriever-less instance raises when you query it:

  File ".../src/lightmem/memory/lightmem.py", line 674, in retrieve
    query_vector = self.text_embedder.embed(query)
AttributeError: 'LightMemory' object has no attribute 'text_embedder'

That is the pre-existing shape of the optional-component design — index_strategy already defaults to
None, so text_embedder was already absent by default — but before this change you could never
reach 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 and
I 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-286 raises InstanceNotReady with 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 constructs BaseMemoryConfigs(**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 missing
embedding_retriever, so the model's error duplicates it for embedding and is new for context and
hybrid. Reproduced on both trees for all three strategies the console's dropdown offers, with
configspec.validate({'retrieve_strategy': <strategy>})['errors']:

'embedding' — the same misconfiguration is now reported twice:

base commit:
[
  {
    "path": "embedding_retriever",
    "message": "A vector store is required for embedding retrieval.",
    "message_zh": "使用 embedding 检索需要配置向量库。"
  }
]

this branch:
[
  {
    "path": "",
    "message": "Value error, embedding_retriever is required for embedding or hybrid retrieval"
  },
  {
    "path": "embedding_retriever",
    "message": "A vector store is required for embedding retrieval.",
    "message_zh": "使用 embedding 检索需要配置向量库。"
  }
]

'context' — base reports nothing; this branch reports one new error:

base commit:
[]

this branch:
[
  {
    "path": "",
    "message": "Value error, context_retriever is required for context or hybrid retrieval"
  }
]

'hybrid' — base reports only the vector store; this branch adds a second, distinct error about the
missing context retriever:

base commit:
[
  {
    "path": "embedding_retriever",
    "message": "A vector store is required for embedding retrieval.",
    "message_zh": "使用 embedding 检索需要配置向量库。"
  }
]

this branch:
[
  {
    "path": "",
    "message": "Value error, context_retriever is required for context or hybrid retrieval"
  },
  {
    "path": "embedding_retriever",
    "message": "A vector store is required for embedding retrieval.",
    "message_zh": "使用 embedding 检索需要配置向量库。"
  }
]

The two new context errors are true positives, not false alarms: on the base commit a config with
retrieve_strategy="context" and no context_retriever already dies inside the retriever factory
(src/lightmem/memory/lightmem.py:181) the moment it reaches LightMemory, with
ImportError: Maybe class 'BM25' not found in module 'lightmem.factory.retriever.contextretriever.bm25'.
That particular error is a separate, pre-existing matter — bm25.py has been a single newline since
0915348 delete BM25, so no context config constructs on this commit — and this branch neither
fixes nor worsens it.

Each new entry has an empty path, so the frontend cannot anchor it to a field, and carries no
message_zh where every other error dict in configspec.py does. Deduplicating means editing
web/backend/, which I judged out of scope for a change to src/lightmem/configs/base.py — the
redundant check at configspec.py:305 is 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_strategy
select offers only embedding/context/hybrid with no unset option
(web/backend/app/configspec.py:81-84), and all three shipped presets pair the strategy with a vector
store. I ran configspec.validate() over configspec.presets() on both trees — the full, segment
and light presets return the same error lists on base and on this branch — identical apart from the
checkout 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 raises
a ValueError from the model. No previously working configuration is newly rejected: every input
the 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. I
enumerated the full (strategy omitted / explicitly None) x (context_retriever present or absent) x
(embedding_retriever present or absent) matrix — eight cells — and ran every one through config
resolution and the real LightMemory.__init__ on both trees. Exactly two cells change, and neither
constructed on the base commit:

  • strategy omitted with neither retriever supplied, which died with the AttributeError at
    src/lightmem/memory/lightmem.py:184 quoted at the top of this description and now constructs —
    that is the fix;
  • strategy omitted with only a context_retriever, which is behaviour change 3 above: it died with
    that same AttributeError and now dies with the pre-existing BM25 ImportError instead.

The other six are identical on both trees: the four explicit-None cells above (behaviour change 1),
and the two cells that supply an embedding_retriever and omit the strategy, which resolve to
'embedding' on both trees and call EmbeddingRetrieverFactory.from_config exactly 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_strategy on the base commit: 33 matching lines across 17
files
. Twelve of those lines assign a value to retrieve_strategy; all twelve set "embedding",
and all twelve supply an embedding_retriever on the immediately following line, so none of them
change behaviour:

Config site retrieve_strategy embedding_retriever
README.md 272 273
examples/run_lightmem_ollama.py 117 118
examples/run_lightmem_transformers.py 172 173
experiments/locomo/add_locomo.py 202 203
experiments/longmemeval/offline_update.py 15 16
experiments/longmemeval/run_lightmem_gpt.py 122 123
experiments/longmemeval/run_lightmem_qwen.py 140 141
mcp/example.json 40 41
tutorial-notebooks/LightMem_Example_code.ipynb 399 400
tutorial-notebooks/LightMem_Example_longmemeval.ipynb 383 384
tutorial-notebooks/LightMem_Example_travel.ipynb 300 301
web/backend/app/configspec.py (_base_preset) 116 117

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 README
configuration 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 dev extra declares
(pyproject.toml). Everything below was run in a clean git worktree checkout of this branch.

The repository has no conftest.py and no pytest configuration, and lightmem is not installed in
the environment, so PYTHONPATH=src is required for collection to succeed at all. That is a
pre-existing condition of the checkout, not something this branch introduces.

New file tests/test_memory_config.py, 15 cases:

  • the minimal config resolves to retrieve_strategy is None;
  • the real LightMemory.__init__ runs against a real BaseMemoryConfigs() and constructs, with
    neither retriever attribute set and neither retriever factory called;
  • three parametrized inference cases — context for a context-only config, embedding for an
    embedding-only one, and embedding again when both retrievers are supplied;
  • the both-retrievers case again at construction level: a real LightMemory is built and
    ContextRetrieverFactory must not be called (the no-regression guard for behaviour change 2);
  • an explicit retrieve_strategy="hybrid" with both retrievers still calls both factories;
  • four parametrized cases asserting that an explicit retrieve_strategy=None is never inferred, one
    for each combination of retriever configs (the no-regression guard for behaviour change 1);
  • the explicit-None-plus-context_retriever case again at construction level: a real LightMemory
    is built and neither retriever factory is called;
  • three parametrized rejection cases for an explicit strategy without its retriever config.
$ PYTHONPATH=src python -m pytest tests/test_memory_config.py -q
15 passed, 1 warning

$ PYTHONPATH=src python -m pytest tests -q
17 passed, 1 warning

The base commit's tests/ contains one file and runs 2 passed; the delta is exactly the
fifteen new tests. The single warning is a pre-existing PydanticDeprecatedSince20 raised from
src/lightmem/configs/logging/base.py:7; it does not appear on base only because base's two tests
never import that module — importing lightmem.configs.base on the unmodified base commit emits the
identical warning.

$ black --check tests/test_memory_config.py
All done! ✨ 🍰 ✨
1 file would be left unchanged.

$ isort --profile black --check-only tests/test_memory_config.py
(no output, exit 0)

$ flake8 --max-line-length=88 tests/test_memory_config.py
(no output, exit 0)

$ python -m py_compile src/lightmem/configs/base.py
(no output, exit 0)

$ git diff --check 8449d57..HEAD
(no output)

The repository ships no [tool.black], [tool.isort], setup.cfg, tox.ini or .flake8, so those
are black's own default line length and the black-compatible isort profile; --max-line-length=88 is
passed 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:

$ flake8 --max-line-length=88 src/lightmem/configs/base.py | wc -l
      18     # base commit 8449d57
      16     # this branch

Diffing the two sorted finding lists shows two deletions and nothing added:

15d14
< F401 'pydantic.model_validator' imported but unused
18d16
< W391 blank line at end of file

The F401 goes away because the already-present model_validator import is now used; the W391
because 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.py was restored from the base commit with the new
test file left in place:

$ git checkout 8449d57 -- src/lightmem/configs/base.py
$ PYTHONPATH=src python -m pytest tests/test_memory_config.py -q
FAILED tests/test_memory_config.py::test_minimal_config_has_no_retriever_strategy
FAILED tests/test_memory_config.py::test_lightmemory_constructs_without_retrievers
FAILED tests/test_memory_config.py::test_retriever_configs_infer_strategy[kwargs0-context]
FAILED tests/test_memory_config.py::test_explicit_strategy_requires_its_retriever_configs[context]
FAILED tests/test_memory_config.py::test_explicit_strategy_requires_its_retriever_configs[embedding]
FAILED tests/test_memory_config.py::test_explicit_strategy_requires_its_retriever_configs[hybrid]
6 failed, 9 passed, 1 warning

Restored afterwards: git status --short clean, PYTHONPATH=src python -m pytest tests -q back to
17 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 construction
    level, embedding retriever built and context retriever not, on both trees;
  • test_explicit_hybrid_still_builds_both_retrievers — an explicit "hybrid" builds both retrievers
    on both trees, so the opt-in path is untouched by this change;
  • the four test_explicit_none_strategy_is_not_inferred cases and
    test_explicit_none_with_context_retriever_still_constructs — an explicit retrieve_strategy=None
    resolves to None and builds no retriever on both trees, which is exactly behaviour change 1.

Those five explicit-None guards are not vacuous. Swapping the validator's gate from
"retrieve_strategy" not in self.model_fields_set to self.retrieve_strategy is None — the obvious
alternative spelling, which cannot tell an omitted field from one explicitly set to None — and
leaving everything else identical fails four of them:

$ PYTHONPATH=src python -m pytest tests/test_memory_config.py -q
FAILED tests/test_memory_config.py::test_explicit_none_strategy_is_not_inferred[kwargs1]
FAILED tests/test_memory_config.py::test_explicit_none_strategy_is_not_inferred[kwargs2]
FAILED tests/test_memory_config.py::test_explicit_none_strategy_is_not_inferred[kwargs3]
FAILED tests/test_memory_config.py::test_explicit_none_with_context_retriever_still_constructs
4 failed, 11 passed, 1 warning

(kwargs0 — an explicit None with no retriever config at all — passes there too, because with
nothing to infer from there is nothing to infer.)

What was NOT verified

  • No live LLM provider, embedder, Qdrant instance, GPU, or running web server. The openai extra
    is not installed in my environment. The construction test stubs
    MemoryManagerFactory.from_config and ShortMemBufferManager, and replaces
    ContextRetrieverFactory.from_config and EmbeddingRetrieverFactory.from_config with Mock()
    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 real
    BaseMemoryConfigs.
  • No retrieval, indexing or update path was run. This change is about construction. I did not
    store or query a single memory on either tree.
  • The web console was never started. I imported web/backend/app/configspec.py in-process and
    called validate() and presets(); the FastAPI app and the frontend were not launched, so I have
    not seen how the console renders an error with an empty path — only that such an error is now
    produced.
  • The tutorial notebooks were not executed, and neither were the example or experiment scripts.
    They were read and their line citations checked, nothing more.
  • One platform, one interpreter. macOS arm64, CPython 3.11.15 only; the repo also supports 3.10.
  • Out-of-tree callers. The compatibility enumeration covers this repository. I have no way to
    check code that depends on retrieve_strategy defaulting to "embedding" — behaviour changes 3
    and 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=None to 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).

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

retrieve_strategy defaults to 'embedding' but embedding_retriever defaults to None, so a minimal config cannot construct LightMemory

1 participant