You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
refactor: decompose oversized modules in src/codedupes #9
Five modules hold ~10.7k of the ~14k lines in src/codedupes:
module
lines
semantic.py
4,070
languages/tree_sitter_backend.py
1,912
cli.py
1,787
embedding_cache.py
1,749
analyzer.py
1,164
semantic.py alone mixes model loading/revision+trust policy, encode planning (routes/prompts), the context-overflow guard, embedding-cache binding (shard/namespace/variant resolution), pairwise duplicate mining, query search, device fallback handling, and runtime fingerprinting. Dogfooding evidence from running codedupes on its own src/: the top-ranked pair is resolve_encode_plan/_resolve_encode_plan — a public adapter and its private core sitting 2,200 lines apart in the same file.
Requirements
Behavior-preserving moves only; no logic changes ride along with the reorg.
Public API preserved via codedupes/__init__ re-exports; codedupes.semantic (and peers) remain importable facades. Scripts and tests import private seams (_embedding_runtime_fingerprint, _resolve_encode_plan, analyzer synthesis internals) — keep them working or update the importers in the same PR.
One subsystem per PR, not a big bang. Full model-free suite + ruff + the five corpus validators green between steps; docs links updated as files move.
Split along existing seams; no new abstraction layers invented for the reorg.
codedupes/semantic.py embeds code units, manages a process-wide model cache, fingerprints local model directories, resolves cache and vector-space identity, runs an OOM-recovery encode ladder, and implements duplicate scanning and query search. The recommendation is to convert it into a codedupes/semantic/ package of thirteen modules arranged as a strict import DAG, with every piece of process-wide mutable model state in exactly one module (model_registry.py). The split is a pure move with a single signature change, and each migration step ships independently behind a frozen-API test.
Two structural choices carry most of the design. First, all dtype, fast-math, and write-coherence policy lives below the model registry, because the cache-variant builder and the registry both need it. Placing that policy inside the registry creates an import cycle. Second, cache addressing stays in the same module as vector-space identity, because the two derive the same variant string and one suffix is already hand-duplicated between them.
get_code_unit_statement_count leaves the package entirely and moves next to CodeUnit in codedupes/models.py, since it has nothing to do with embeddings.
Import Order
An arrow A --> B means B imports A. Dependencies sit above their dependents, and no module imports anything below it. Sibling modules import each other with relative imports (from .identity import ...). No submodule imports from the package __init__, which becomes a re-export layer and would otherwise create cycles during package initialization.
Transitive and leaf edges are omitted for readability; vectors and encoding are leaves that anything may import. identity and model_registry are siblings: neither imports the other, and the orchestration modules (embed, search) are the first to import both. The constraint that keeps the graph acyclic is that nothing in revisions, device_policy, encoding, or identity may import model_registry.
Placement Decisions
Dtype, fast-math, and write-coherence policy sit in device_policy.py, below the registry._cache_variant_for (identity) needs _dtype_variant_for, which needs _resolve_model_dtype. The registry's load path also needs _resolve_model_dtype, and the coherence checks need _dtype_variant_for and _mps_fast_math_variant. If these functions live in the registry, then identity imports registry and registry imports identity. With them in device_policy.py, both depend downward on one policy module, and the registry holds only state and the load/release lifecycle.
_cache_write_allowed takes the execution device as a parameter. It is the only policy function that reads registry state, via _get_effective_model_device. Passing the device in removes the upward dependency. This is the only signature change in the split, and it has two call sites: the corpus write in embed.py and the query write in search.py.
Callers pass _get_effective_model_device(model, resolved_device). They must compute it at the call site, after encoding, so that a mid-encode CPU fallback is still reflected. That timing is the reason the function re-reads the device today.
_confirm_cache_revision_after_load lives in model_registry.py. It is revision logic, but its local-directory branch takes _model_lock and compares model is _model to return _model_local_fingerprint. Placing it in revisions.py or identity.py would force those modules to import the registry.
_classify_oom_device lives in errors.py. Both the model load path (registry) and the encode ladder (inference) classify OOMs. Placing it in inference.py would make the registry import inference, which imports the registry.
runtime.py sits below errors.py._wrap_semantic_backend_error embeds get_semantic_runtime_versions() in its message, so errors depend on runtime, not the reverse.
Cache addressing stays in identity.py.EmbeddingSpaceIdentity.runtime_variant is literally _cache_variant_for(...), and cache keys are derived from the same variant. The contextual-document suffix is currently written out twice: once in _compute_embeddings_unlocked through variant_suffix into _prepare_cache_context, and once inline in embedding_cache_keys_for_units. If the two copies drift, manifest keys silently stop matching the vectors the cache holds. Keep both call sites in one module, and collapse them into one helper during the move:
def_with_search_document(variant: str, search_document: SearchDocumentMode) ->str:
# Single definition of the contextual-document key suffix; used by# _prepare_cache_context and embedding_cache_keys_for_units.ifsearch_document=="contextual":
returnf"{variant}\x00search_document=contextual"returnvariant
The query-only query_provenance=2 suffix stays inside _prepare_cache_context, because it has a single writer.
The scan module is named duplicates.py, not pairs.py. The code already imports ordered_pair_key from the top-level codedupes/pairs.py. A second pairs module one level down makes greps and tracebacks ambiguous.
Invariants to Preserve
State each of these in the docstring of the module that owns it, so the constraint travels with the code.
One lock object, reentrant for the right reason._model_lock is defined only in model_registry.py, and embed.py and search.py import that object. It is an RLock because the top-level entry points (compute_embeddings_with_identity, find_similar_to_query) hold it for the whole operation, and inside that operation get_model() and _confirm_cache_revision_after_load acquire it again. The faithful-CPU restart calls _compute_embeddings_unlocked directly and does not re-acquire the lock, so it is not the reason. Documenting the wrong reason invites someone to "fix" the restart and then downgrade the lock.
Warning flags live beside their log_warning_once(globals(), ...) calls.log_warning_once reads and writes the flag by name in the mapping it is handed, which is the calling module's globals. _warned_mlx_mps_contention moves with _prepare_semantic_device into device_policy.py. _warned_cpu_fallback_reuse stays in model_registry.py, where _get_model_unlocked sets it and _clear_model_cache_unlocked resets it.
The fingerprint walk-scope global stays unlocked and private to local_model.py. It is safe without its own lock only because it is touched exclusively while the caller holds _model_lock. Only the context manager and the two accessor functions may reference it.
Model release order in _clear_model_cache_unlocked is load-bearing. The function drops every reference, deletes the local, and only then clears the device cache. It deliberately does not migrate the model to CPU first. Do not "tidy" this during the move.
The model-free warm path stays import-free. A fully cached corpus returns without importing torch, and _configure_semantic_runtime_env must run before torch is first imported. New modules may import torch, sentence_transformers, and transformers only inside functions, as _resolve_model_dtype, _move_model_to_cpu, and _get_model_unlocked do today. A top-level import added for type hints in any new module silently breaks both contracts.
Compatibility Surface
The package __init__.py is the public API and re-exports explicitly. Build the export list from actual usage, found by grepping the repository and tests for from codedupes.semantic import and semantic\.\w+. The list must include private names used outside the module. _prepare_search_document, for example, has no caller inside semantic.py, so something external imports it. Declare the list in __all__, and do not use star imports, because star imports skip underscore names.
Monkeypatches must target the module where the name is looked up at call time. After a function moves, a test that patches the facade binding no longer affects callers inside the defining module. Such tests keep passing while testing nothing. This was verified directly: with caller() and helper() in sem/impl.py and re-exported from sem/__init__.py, setting sem.helper leaves caller() returning the real value, while setting sem.impl.helper takes effect. Every patch of get_model, _resolve_hf_cached_revision, _get_loaded_model_commit_hash, _fingerprint_local_model_dir, the _model* globals, and similar names must be retargeted in the same commit that moves the name's callers.
Re-exported globals are snapshots.from .model_registry import _model binds None into the facade permanently. Any code outside the registry that reads semantic._model or semantic._model_execution_device must read model_registry._model directly.
No semantic.py shim alongside the package. When codedupes/semantic.py and codedupes/semantic/__init__.py both exist, Python imports the package (verified on 3.12). A leftover shim file is dead code that looks authoritative. Use git mv to turn the file into __init__.py so history follows.
Logger names change harmlessly. Loggers become codedupes.semantic.embed, codedupes.semantic.model_registry, and so on. They are children of codedupes.semantic, so configuration on the old name still applies through propagation.
Migration Sequence
Each step is one commit that passes the full test suite. Code not yet moved stays in __init__.py and imports moved names from their new module. Order is by risk, not strictly by the DAG: stateless leaves go first, and the singleton move goes alone.
Rename and add guards. Run git mv codedupes/semantic.py codedupes/semantic/__init__.py. Add two guard tests: one asserts the frozen export set is importable from codedupes.semantic, and one asserts that importing codedupes.semantic loads no heavy modules beyond the pre-split baseline.
Stateless leaves. Move runtime.py, errors.py, vectors.py, encoding.py, and the scan half of duplicates.py (find_semantic_duplicates and its block size). None of these touch shared state.
Fingerprinting and revisions. Move local_model.py and revisions.py together, because _resolve_revision_for_cache depends on the call-scoped fingerprint memo.
Device policy. Move device_policy.py, apply the _cache_write_allowed signature change, and move the MLX warning flag.
Identity. Move identity.py, including cache addressing, and introduce _with_search_document.
Registry. Move model_registry.py alone. Retarget every test patch and external read of registry names in this commit. This is the highest-risk step.
Orchestration. Move inference.py, embed.py, and search.py, then move run_semantic_analysis* into duplicates.py.
Facade. Reduce __init__.py to the explicit re-export list and __all__.
The guard tests from step 1 are small; this sketch is illustrative:
importsubprocessimportsysimportcodedupes.semanticassemanticFROZEN_EXPORTS= {"compute_embeddings", "find_semantic_duplicates"} # extend from the usage grepdeftest_export_surface_is_stable():
missing= {namefornameinFROZEN_EXPORTSifnothasattr(semantic, name)}
assertnotmissingdeftest_import_stays_model_free():
code="import sys, codedupes.semantic; print(sorted(m for m in sys.modules if m.split('.')[0] in {'torch', 'transformers', 'sentence_transformers'}))"out=subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, check=True).stdoutassertout.strip() =="[]"# or the recorded pre-split baseline if codedupes.devices imports torch eagerly
Rejected Alternatives
Grouping dtype and coherence helpers with the model singleton. The grouping feels natural because those helpers inspect the loaded model. It produces the registry <-> identity cycle described under Placement Decisions and bloats the one module whose move is genuinely risky.
Moving _prepare_semantic_device into a runtime module while its warning flag goes to the registry. The flag must sit in the same module as its log_warning_once(globals(), ...) call. Splitting them breaks the warn-once behavior, because the call reads and writes a flag that no longer exists in its module.
A separate cache_addressing.py. This separates the two call sites that share the contextual suffix, which is exactly the lockstep hazard this split should remove.
A semantic.py re-export shim next to the new package. Python resolves the package first, so the shim would never load.
Follow-up Work
The split leaves _compute_embeddings_unlocked at roughly 350 lines and _find_similar_to_query_unlocked at roughly 300. Two extractions address most of that size.
Cache-session extraction. Both functions repeat the "load model -> confirm revision -> re-key -> re-lookup" sequence and the mutable-branch provenance reconciliation (confirm_source_commit, snapshot source-commit comparison, bypass when the loaded commit is unknown). Moving these into a small cache-session object in a provenance.py would roughly halve both functions and give the "one matrix never mixes two checkpoints" invariant a single implementation.
Allocator guard helper. Four paths skip _prepare_semantic_device and must call restore_mps_memory_fraction_if_managed() by hand: the empty corpus and full warm hit in _compute_embeddings_unlocked, and the empty corpus and warm query hit in _find_similar_to_query_unlocked. A no_allocator_work() helper would encode that contract once.
Warning-flag storage (optional). Once each flag is colocated with its call, the flags could move to a per-module _WARNED: dict[str, bool] passed to log_warning_once instead of globals(). This works only if log_warning_once touches nothing but item get/set on its first argument, so check its implementation in embedding_cache.py first.
Coarser Variant
If thirteen modules is more granularity than the team wants, merge along the same DAG without changing any placement rule. This yields six modules:
Module
Contains
Imports from
foundation.py
runtime, errors, vectors, encoding
third-party only
policy.py
local_model, revisions, device_policy, identity
foundation
model_registry.py
unchanged
foundation, policy
embed.py
inference + embed
everything above
search.py
unchanged
everything above
duplicates.py
unchanged
foundation, embed
The registry stays alone in this variant as well. The point of the split is that the only module holding process-wide model state is small enough to review in one sitting.
Activity
pszemraj commentedon Sep 23, 2026
Splitting
codedupes/semantic.pyinto a Packagecodedupes/semantic.pyembeds code units, manages a process-wide model cache, fingerprints local model directories, resolves cache and vector-space identity, runs an OOM-recovery encode ladder, and implements duplicate scanning and query search. The recommendation is to convert it into acodedupes/semantic/package of thirteen modules arranged as a strict import DAG, with every piece of process-wide mutable model state in exactly one module (model_registry.py). The split is a pure move with a single signature change, and each migration step ships independently behind a frozen-API test.Two structural choices carry most of the design. First, all dtype, fast-math, and write-coherence policy lives below the model registry, because the cache-variant builder and the registry both need it. Placing that policy inside the registry creates an import cycle. Second, cache addressing stays in the same module as vector-space identity, because the two derive the same variant string and one suffix is already hand-duplicated between them.
Contents
Target Layout
get_code_unit_statement_countleaves the package entirely and moves next toCodeUnitincodedupes/models.py, since it has nothing to do with embeddings.Import Order
An arrow
A --> Bmeans B imports A. Dependencies sit above their dependents, and no module imports anything below it. Sibling modules import each other with relative imports (from .identity import ...). No submodule imports from the package__init__, which becomes a re-export layer and would otherwise create cycles during package initialization.Transitive and leaf edges are omitted for readability;
vectorsandencodingare leaves that anything may import.identityandmodel_registryare siblings: neither imports the other, and the orchestration modules (embed,search) are the first to import both. The constraint that keeps the graph acyclic is that nothing inrevisions,device_policy,encoding, oridentitymay importmodel_registry.Placement Decisions
Dtype, fast-math, and write-coherence policy sit in
device_policy.py, below the registry._cache_variant_for(identity) needs_dtype_variant_for, which needs_resolve_model_dtype. The registry's load path also needs_resolve_model_dtype, and the coherence checks need_dtype_variant_forand_mps_fast_math_variant. If these functions live in the registry, then identity imports registry and registry imports identity. With them indevice_policy.py, both depend downward on one policy module, and the registry holds only state and the load/release lifecycle._cache_write_allowedtakes the execution device as a parameter. It is the only policy function that reads registry state, via_get_effective_model_device. Passing the device in removes the upward dependency. This is the only signature change in the split, and it has two call sites: the corpus write inembed.pyand the query write insearch.py.def _cache_write_allowed( profile: SemanticModelProfile, device: str | None, mps_fallback: bool | None, resolved_device: str, model: object, + execution_device: str, ) -> bool: - execution_device = _get_effective_model_device(model, resolved_device) if not _fast_math_write_allowed(device, execution_device): return FalseCallers pass
_get_effective_model_device(model, resolved_device). They must compute it at the call site, after encoding, so that a mid-encode CPU fallback is still reflected. That timing is the reason the function re-reads the device today._confirm_cache_revision_after_loadlives inmodel_registry.py. It is revision logic, but its local-directory branch takes_model_lockand comparesmodel is _modelto return_model_local_fingerprint. Placing it inrevisions.pyoridentity.pywould force those modules to import the registry._classify_oom_devicelives inerrors.py. Both the model load path (registry) and the encode ladder (inference) classify OOMs. Placing it ininference.pywould make the registry import inference, which imports the registry.runtime.pysits belowerrors.py._wrap_semantic_backend_errorembedsget_semantic_runtime_versions()in its message, so errors depend on runtime, not the reverse.Cache addressing stays in
identity.py.EmbeddingSpaceIdentity.runtime_variantis literally_cache_variant_for(...), and cache keys are derived from the same variant. The contextual-document suffix is currently written out twice: once in_compute_embeddings_unlockedthroughvariant_suffixinto_prepare_cache_context, and once inline inembedding_cache_keys_for_units. If the two copies drift, manifest keys silently stop matching the vectors the cache holds. Keep both call sites in one module, and collapse them into one helper during the move:The query-only
query_provenance=2suffix stays inside_prepare_cache_context, because it has a single writer.The scan module is named
duplicates.py, notpairs.py. The code already importsordered_pair_keyfrom the top-levelcodedupes/pairs.py. A secondpairsmodule one level down makes greps and tracebacks ambiguous.Invariants to Preserve
State each of these in the docstring of the module that owns it, so the constraint travels with the code.
One lock object, reentrant for the right reason.
_model_lockis defined only inmodel_registry.py, andembed.pyandsearch.pyimport that object. It is anRLockbecause the top-level entry points (compute_embeddings_with_identity,find_similar_to_query) hold it for the whole operation, and inside that operationget_model()and_confirm_cache_revision_after_loadacquire it again. The faithful-CPU restart calls_compute_embeddings_unlockeddirectly and does not re-acquire the lock, so it is not the reason. Documenting the wrong reason invites someone to "fix" the restart and then downgrade the lock.Warning flags live beside their
log_warning_once(globals(), ...)calls.log_warning_oncereads and writes the flag by name in the mapping it is handed, which is the calling module's globals._warned_mlx_mps_contentionmoves with_prepare_semantic_deviceintodevice_policy.py._warned_cpu_fallback_reusestays inmodel_registry.py, where_get_model_unlockedsets it and_clear_model_cache_unlockedresets it.The fingerprint walk-scope global stays unlocked and private to
local_model.py. It is safe without its own lock only because it is touched exclusively while the caller holds_model_lock. Only the context manager and the two accessor functions may reference it.Model release order in
_clear_model_cache_unlockedis load-bearing. The function drops every reference, deletes the local, and only then clears the device cache. It deliberately does not migrate the model to CPU first. Do not "tidy" this during the move.The model-free warm path stays import-free. A fully cached corpus returns without importing torch, and
_configure_semantic_runtime_envmust run before torch is first imported. New modules may importtorch,sentence_transformers, andtransformersonly inside functions, as_resolve_model_dtype,_move_model_to_cpu, and_get_model_unlockeddo today. A top-level import added for type hints in any new module silently breaks both contracts.Compatibility Surface
The package
__init__.pyis the public API and re-exports explicitly. Build the export list from actual usage, found by grepping the repository and tests forfrom codedupes.semantic importandsemantic\.\w+. The list must include private names used outside the module._prepare_search_document, for example, has no caller insidesemantic.py, so something external imports it. Declare the list in__all__, and do not use star imports, because star imports skip underscore names.Monkeypatches must target the module where the name is looked up at call time. After a function moves, a test that patches the facade binding no longer affects callers inside the defining module. Such tests keep passing while testing nothing. This was verified directly: with
caller()andhelper()insem/impl.pyand re-exported fromsem/__init__.py, settingsem.helperleavescaller()returning the real value, while settingsem.impl.helpertakes effect. Every patch ofget_model,_resolve_hf_cached_revision,_get_loaded_model_commit_hash,_fingerprint_local_model_dir, the_model*globals, and similar names must be retargeted in the same commit that moves the name's callers.Re-exported globals are snapshots.
from .model_registry import _modelbindsNoneinto the facade permanently. Any code outside the registry that readssemantic._modelorsemantic._model_execution_devicemust readmodel_registry._modeldirectly.No
semantic.pyshim alongside the package. Whencodedupes/semantic.pyandcodedupes/semantic/__init__.pyboth exist, Python imports the package (verified on 3.12). A leftover shim file is dead code that looks authoritative. Usegit mvto turn the file into__init__.pyso history follows.Logger names change harmlessly. Loggers become
codedupes.semantic.embed,codedupes.semantic.model_registry, and so on. They are children ofcodedupes.semantic, so configuration on the old name still applies through propagation.Migration Sequence
Each step is one commit that passes the full test suite. Code not yet moved stays in
__init__.pyand imports moved names from their new module. Order is by risk, not strictly by the DAG: stateless leaves go first, and the singleton move goes alone.git mv codedupes/semantic.py codedupes/semantic/__init__.py. Add two guard tests: one asserts the frozen export set is importable fromcodedupes.semantic, and one asserts that importingcodedupes.semanticloads no heavy modules beyond the pre-split baseline.runtime.py,errors.py,vectors.py,encoding.py, and the scan half ofduplicates.py(find_semantic_duplicatesand its block size). None of these touch shared state.local_model.pyandrevisions.pytogether, because_resolve_revision_for_cachedepends on the call-scoped fingerprint memo.device_policy.py, apply the_cache_write_allowedsignature change, and move the MLX warning flag.identity.py, including cache addressing, and introduce_with_search_document.model_registry.pyalone. Retarget every test patch and external read of registry names in this commit. This is the highest-risk step.inference.py,embed.py, andsearch.py, then moverun_semantic_analysis*intoduplicates.py.__init__.pyto the explicit re-export list and__all__.The guard tests from step 1 are small; this sketch is illustrative:
Rejected Alternatives
Grouping dtype and coherence helpers with the model singleton. The grouping feels natural because those helpers inspect the loaded model. It produces the registry <-> identity cycle described under Placement Decisions and bloats the one module whose move is genuinely risky.
Moving
_prepare_semantic_deviceinto a runtime module while its warning flag goes to the registry. The flag must sit in the same module as itslog_warning_once(globals(), ...)call. Splitting them breaks the warn-once behavior, because the call reads and writes a flag that no longer exists in its module.A separate
cache_addressing.py. This separates the two call sites that share the contextual suffix, which is exactly the lockstep hazard this split should remove.A
semantic.pyre-export shim next to the new package. Python resolves the package first, so the shim would never load.Follow-up Work
The split leaves
_compute_embeddings_unlockedat roughly 350 lines and_find_similar_to_query_unlockedat roughly 300. Two extractions address most of that size.Cache-session extraction. Both functions repeat the "load model -> confirm revision -> re-key -> re-lookup" sequence and the mutable-branch provenance reconciliation (
confirm_source_commit, snapshot source-commit comparison, bypass when the loaded commit is unknown). Moving these into a small cache-session object in aprovenance.pywould roughly halve both functions and give the "one matrix never mixes two checkpoints" invariant a single implementation.Allocator guard helper. Four paths skip
_prepare_semantic_deviceand must callrestore_mps_memory_fraction_if_managed()by hand: the empty corpus and full warm hit in_compute_embeddings_unlocked, and the empty corpus and warm query hit in_find_similar_to_query_unlocked. Ano_allocator_work()helper would encode that contract once.Warning-flag storage (optional). Once each flag is colocated with its call, the flags could move to a per-module
_WARNED: dict[str, bool]passed tolog_warning_onceinstead ofglobals(). This works only iflog_warning_oncetouches nothing but item get/set on its first argument, so check its implementation inembedding_cache.pyfirst.Coarser Variant
If thirteen modules is more granularity than the team wants, merge along the same DAG without changing any placement rule. This yields six modules:
foundation.pypolicy.pyfoundationmodel_registry.pyfoundation,policyembed.pysearch.pyduplicates.pyfoundation,embedThe registry stays alone in this variant as well. The point of the split is that the only module holding process-wide model state is small enough to review in one sitting.