graphmark 0.8.0__tar.gz → 0.8.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. {graphmark-0.8.0 → graphmark-0.8.1}/CHANGELOG.md +28 -0
  2. {graphmark-0.8.0 → graphmark-0.8.1}/PKG-INFO +1 -1
  3. {graphmark-0.8.0 → graphmark-0.8.1}/pyproject.toml +1 -1
  4. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/graph.py +69 -24
  5. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_graph.py +6 -10
  6. graphmark-0.8.1/tests/test_path_index.py +153 -0
  7. {graphmark-0.8.0 → graphmark-0.8.1}/.gitignore +0 -0
  8. {graphmark-0.8.0 → graphmark-0.8.1}/LICENSE +0 -0
  9. {graphmark-0.8.0 → graphmark-0.8.1}/README.md +0 -0
  10. {graphmark-0.8.0 → graphmark-0.8.1}/docs/ROADMAP.md +0 -0
  11. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/__init__.py +0 -0
  12. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/check.py +0 -0
  13. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/cli.py +0 -0
  14. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/config.py +0 -0
  15. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/dismiss.py +0 -0
  16. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/export.py +0 -0
  17. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/interfaces.py +0 -0
  18. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/metrics.py +0 -0
  19. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/model.py +0 -0
  20. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/parse.py +0 -0
  21. {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/py.typed +0 -0
  22. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/README.md +0 -0
  23. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/config.toml +0 -0
  24. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/expected.json +0 -0
  25. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  26. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  27. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
  28. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  29. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  30. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  31. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  32. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  33. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  34. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  35. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/expected.json +0 -0
  36. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  37. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  38. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
  39. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  40. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/config.toml +0 -0
  41. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/expected.json +0 -0
  42. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/expected_oog.json +0 -0
  43. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/similar.json +0 -0
  44. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/similar_oog.json +0 -0
  45. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  46. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  47. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  48. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  49. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  50. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  51. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/config.toml +0 -0
  52. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/expected.json +0 -0
  53. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  54. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  55. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  56. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  57. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/config.toml +0 -0
  58. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/expected.json +0 -0
  59. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/vault/a.md +0 -0
  60. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/vault/b.md +0 -0
  61. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/config.toml +0 -0
  62. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/expected.json +0 -0
  63. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  64. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  65. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  66. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  67. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
  68. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  69. {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/suggest/README.md +0 -0
  70. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_aliases.py +0 -0
  71. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_bom.py +0 -0
  72. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_build_filters.py +0 -0
  73. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_check.py +0 -0
  74. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_check_policy.py +0 -0
  75. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_cli.py +0 -0
  76. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_config.py +0 -0
  77. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_degenerate.py +0 -0
  78. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_diagnose.py +0 -0
  79. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_dismiss.py +0 -0
  80. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_export.py +0 -0
  81. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps.py +0 -0
  82. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps_banding.py +0 -0
  83. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps_oog.py +0 -0
  84. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps_params.py +0 -0
  85. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_graph_catalogs.py +0 -0
  86. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_link_counts.py +0 -0
  87. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_links_report.py +0 -0
  88. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_markdown_links.py +0 -0
  89. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_metrics.py +0 -0
  90. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_model.py +0 -0
  91. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_non_note_file.py +0 -0
  92. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_packaging.py +0 -0
  93. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_pagerank.py +0 -0
  94. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_parse.py +0 -0
  95. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_path_suffix.py +0 -0
  96. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_properties.py +0 -0
  97. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_public_api.py +0 -0
  98. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_readme_examples.py +0 -0
  99. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_scoped.py +0 -0
  100. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_selflink.py +0 -0
  101. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_smoke.py +0 -0
  102. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_suggestions.py +0 -0
  103. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unicode.py +0 -0
  104. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unicode_punctuation.py +0 -0
  105. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unread_syntax.py +0 -0
  106. {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,34 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.8.1 (2026-07-26)
5
+
6
+ ### Performance Improvements
7
+
8
+ - **graph**: Index path resolution by filename instead of scanning (#157)
9
+ ([#160](https://github.com/cdcoonce/graphmark/pull/160),
10
+ [`84cdd29`](https://github.com/cdcoonce/graphmark/commit/84cdd297a665d93ffa3cbb988c18550ea57f5c94))
11
+
12
+ Every path-qualified display flattened the catalog and compared against every rel_path. That ceiling
13
+ was documented and accepted while the branch was rare — most wikilink displays are bare names
14
+ hitting a dict — but #152 made it the only branch, since every markdown link is path-qualified by
15
+ construction. blue-book (1120 notes, 10,149 markdown links) took 2.7 s against 0.25 s for the same
16
+ vault read as wikilinks.
17
+
18
+ A legal suffix match must consume whole path components, so a candidate's last component always
19
+ equals the display's last plus ".md". Bucketing by that filename is exactly equivalent to scanning
20
+ — same matches, same order, same verdicts.
21
+
22
+ The first attempt was no faster, and profiling rather than guessing found why: `_diagnose` consults
23
+ the in-scope catalog AND the out-of-scope one for the same display, so a single-slot cache
24
+ alternated and missed every time — 29,265 index rebuilds for 10,149 links. The cache is now
25
+ bounded-multi-slot, and it holds each catalog rather than just its id(), so a freed dict cannot
26
+ have its address reused under a stale index.
27
+
28
+ blue-book markdown: 2718 ms -> 267 ms (10.2x), now level with wikilink mode. Reference vault 195 ->
29
+ 181 ms. All link counts byte-identical in every mode.
30
+
31
+
4
32
  ## v0.8.0 (2026-07-26)
5
33
 
6
34
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.8.0
3
+ Version: 0.8.1
4
4
  Summary: Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults.
5
5
  Project-URL: Homepage, https://github.com/cdcoonce/graphmark
6
6
  Project-URL: Repository, https://github.com/cdcoonce/graphmark
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.8.0"
3
+ version = "0.8.1"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -144,12 +144,7 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
144
144
  return []
145
145
  if "/" in target:
146
146
  suffix = _fold_case(target) + ".md"
147
- return sorted(
148
- path
149
- for paths in catalog.values()
150
- for path in paths
151
- if _matches_path_suffix(path, suffix)
152
- )
147
+ return sorted(_suffix_matches(suffix, catalog, _CANDIDATE_INDEX))
153
148
  return list(catalog.get(_normalize(target), ()))
154
149
 
155
150
 
@@ -217,25 +212,74 @@ def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
217
212
  return catalog
218
213
 
219
214
 
215
+ class _FilenameIndex:
216
+ """Catalog paths bucketed by folded filename, so a path-qualified link scans a bucket.
217
+
218
+ A legal suffix match must consume whole path components (see ``_matches_path_suffix``), so a
219
+ candidate's **last** component always equals the display's last component plus ``.md``.
220
+ Bucketing by that filename is therefore *exactly* equivalent to scanning every path — same
221
+ matches, same order — while turning an O(N) sweep into a dict lookup over a bucket that is
222
+ almost always one entry deep.
223
+
224
+ This mattered little while path-qualified links were rare: most wikilink displays are bare names
225
+ hitting the catalog dict directly, which is why the ``O(L·N)`` ceiling was documented and
226
+ accepted. #152 changed which branch is hot — every markdown link is path-qualified by
227
+ construction — and a 1120-note vault with 10,149 of them took 2.9 s against 0.3 s for the same
228
+ vault read as wikilinks.
229
+
230
+ Keyed by catalog identity, which is safe because a catalog is invariant for a whole
231
+ ``VaultGraph.build()``. Each catalog is **held**, not just its ``id()``: without a reference the
232
+ dict could be freed and a new one allocated at the same address, and the stale index would then
233
+ answer for it.
234
+
235
+ Several slots, not one, and that is load-bearing rather than defensive. ``_diagnose`` consults
236
+ two different mappings for the same display — the in-scope ``catalog`` and the ``out_of_scope``
237
+ one — so a single slot alternates between them and misses *every* time. Measured: a single-slot
238
+ version rebuilt the index 29,265 times for 10,149 links and was no faster than the scan it
239
+ replaced. The cap keeps this from growing without bound if a caller cycles through catalogs.
240
+ """
241
+
242
+ #: Enough for the two mappings a build alternates between, with room to spare. Small on purpose:
243
+ #: this holds catalogs alive, so it must not become an unbounded retainer.
244
+ _MAX_SLOTS = 4
245
+
246
+ def __init__(self) -> None:
247
+ # Insertion-ordered, so the oldest entry is the one evicted.
248
+ self._slots: dict[int, tuple[dict[str, list[str]], dict[str, list[str]]]] = {}
249
+
250
+ def for_catalog(self, catalog: dict[str, list[str]]) -> dict[str, list[str]]:
251
+ cached = self._slots.get(id(catalog))
252
+ if cached is not None and cached[0] is catalog:
253
+ return cached[1]
254
+
255
+ buckets: dict[str, list[str]] = {}
256
+ for paths in catalog.values():
257
+ for path in paths:
258
+ buckets.setdefault(_fold_case(path.rsplit("/", 1)[-1]), []).append(path)
259
+
260
+ if len(self._slots) >= self._MAX_SLOTS:
261
+ del self._slots[next(iter(self._slots))]
262
+ self._slots[id(catalog)] = (catalog, buckets)
263
+ return buckets
264
+
265
+
266
+ def _suffix_matches(suffix: str, catalog: dict[str, list[str]], index: _FilenameIndex) -> list[str]:
267
+ """Every rel_path that ``suffix`` (a folded ``folder/note.md``) names, in flatten order."""
268
+ bucket = index.for_catalog(catalog).get(suffix.rsplit("/", 1)[-1], ())
269
+ return [path for path in bucket if _matches_path_suffix(path, suffix)]
270
+
271
+
272
+ #: Shared by ``candidates_for``, which takes a catalog rather than a resolver and so cannot reach
273
+ #: the resolver's own index. One index per process is enough — both callers see the same catalog
274
+ #: object for the length of a build, and a different catalog simply re-keys it.
275
+ _CANDIDATE_INDEX = _FilenameIndex()
276
+
277
+
220
278
  class NormalizeResolver:
221
279
  """Resolves wikilink displays via normalized basename, with path-suffix fallback."""
222
280
 
223
281
  def __init__(self) -> None:
224
- # Cache the flattened path list per catalog identity. catalog is invariant for a whole
225
- # VaultGraph.build(), so folder-style links reuse one flatten instead of rebuilding it
226
- # on every call. Keyed by id() and single-slot: a new catalog evicts the previous.
227
- self._flat_cache_id: int | None = None
228
- self._flat_cache: list[str] | None = None
229
-
230
- @staticmethod
231
- def _compute_flat(catalog: dict[str, list[str]]) -> list[str]:
232
- return [p for paths in catalog.values() for p in paths]
233
-
234
- def _flatten_paths(self, catalog: dict[str, list[str]]) -> list[str]:
235
- if self._flat_cache_id != id(catalog) or self._flat_cache is None:
236
- self._flat_cache = self._compute_flat(catalog)
237
- self._flat_cache_id = id(catalog)
238
- return self._flat_cache
282
+ self._index = _FilenameIndex()
239
283
 
240
284
  def resolve(self, display: str, catalog: dict[str, list[str]]) -> str | None:
241
285
  # Alias ("Note|alias"), anchor ("Note#Section") and an explicit ".md" extension all name
@@ -244,10 +288,11 @@ class NormalizeResolver:
244
288
  display = _strip_display(display)
245
289
 
246
290
  if "/" in display:
247
- # Path-suffix resolution: find unique rel_path ending with "display.md"
291
+ # Path-suffix resolution: find unique rel_path ending with "display.md". Note that an
292
+ # exact whole-path hit cannot short-circuit — "work/Tasks" names both "work/Tasks.md"
293
+ # and "a/work/Tasks.md", and the honest answer there is to decline.
248
294
  suffix = _fold_case(display) + ".md"
249
- all_paths = self._flatten_paths(catalog)
250
- matches = [p for p in all_paths if _matches_path_suffix(p, suffix)]
295
+ matches = _suffix_matches(suffix, catalog, self._index)
251
296
  return matches[0] if len(matches) == 1 else None
252
297
 
253
298
  # Bare-link resolution: normalize and look up in catalog
@@ -91,19 +91,15 @@ class TestNormalizeResolver:
91
91
  assert self.resolver.resolve("brain/alpha", catalog) == "brain/alpha.md"
92
92
  assert self.resolver.resolve("work/gamma", catalog) == "work/gamma.md"
93
93
 
94
- def test_flatten_runs_once_per_catalog(self, monkeypatch):
94
+ def test_the_path_index_is_built_once_per_catalog(self):
95
+ # Was written against the flatten this replaced (#157); the property is the same one and
96
+ # still worth holding — the O(n) precompute must not run per link.
95
97
  catalog = self._catalog("brain/alpha.md", "personal/beta.md")
96
- calls = {"n": 0}
97
- real_compute = NormalizeResolver._compute_flat
98
-
99
- def counting_compute(cat):
100
- calls["n"] += 1
101
- return real_compute(cat)
102
-
103
- monkeypatch.setattr(self.resolver, "_compute_flat", counting_compute)
98
+ self.resolver.resolve("brain/alpha", catalog)
99
+ buckets = self.resolver._index.for_catalog(catalog)
104
100
  for _ in range(5):
105
101
  self.resolver.resolve("brain/alpha", catalog)
106
- assert calls["n"] == 1 # the O(n) flatten runs once, reused for the rest
102
+ assert self.resolver._index.for_catalog(catalog) is buckets
107
103
 
108
104
  def test_new_catalog_triggers_reflatten(self):
109
105
  cat1 = self._catalog("brain/alpha.md")
@@ -0,0 +1,153 @@
1
+ """Path-suffix resolution is indexed, not scanned (#157).
2
+
3
+ Every path-qualified display used to flatten the catalog and compare against **every** rel_path.
4
+ With wikilinks that branch is rare — most displays are bare names hitting a dict — so the `O(L·N)`
5
+ ceiling was documented and accepted. #152 changed which branch is hot: every markdown link is
6
+ path-qualified by construction, so the rare branch became the only branch. Measured on a 1120-note
7
+ vault with 10,149 markdown links: 2.9 s against 0.3 s for the same vault read as wikilinks.
8
+
9
+ Any legal suffix match must end with the display's final component, so bucketing paths by folded
10
+ filename is **exactly** equivalent to scanning them all — same matches, same order, same verdicts.
11
+ This file exists to hold that equivalence, since a pure speedup that changes one answer is a bug.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from graphmark.config import VaultConfig
19
+ from graphmark.graph import NormalizeResolver, VaultGraph, candidates_for
20
+ from graphmark.parse import WikilinkExtractor
21
+
22
+
23
+ def _write(root: Path, rel: str, text: str = "") -> None:
24
+ path = root / rel
25
+ path.parent.mkdir(parents=True, exist_ok=True)
26
+ path.write_text(text, encoding="utf-8")
27
+
28
+
29
+ def _build(root: Path, **kw) -> VaultGraph:
30
+ return VaultGraph.build(VaultConfig(root=root, **kw), WikilinkExtractor(), NormalizeResolver())
31
+
32
+
33
+ def _scan_resolve(display: str, catalog: dict[str, list[str]]) -> list[str]:
34
+ """The pre-#157 behavior, written out: compare against every path, in flatten order.
35
+
36
+ Kept as an independent reference rather than by calling the implementation, so the equivalence
37
+ tests below compare two things instead of one thing with itself.
38
+ """
39
+ from graphmark.graph import _fold_case, _matches_path_suffix, _strip_display
40
+
41
+ target = _strip_display(display)
42
+ suffix = _fold_case(target) + ".md"
43
+ return [p for paths in catalog.values() for p in paths if _matches_path_suffix(p, suffix)]
44
+
45
+
46
+ class TestEquivalence:
47
+ """Same answers as the scan, on every shape that has ever mattered here."""
48
+
49
+ def test_matches_the_scan_across_the_shapes_136_turned_on(self, tmp_path):
50
+ for rel in (
51
+ "work/Tasks.md",
52
+ "homework/Tasks.md",
53
+ "a/b/work/Tasks.md",
54
+ "docs/Tasks.md",
55
+ "api-docs/Tasks.md",
56
+ "Tasks.md",
57
+ "work/Other.md",
58
+ ):
59
+ _write(tmp_path, rel)
60
+ catalog = _build(tmp_path).catalog
61
+ resolver = NormalizeResolver()
62
+ for display in (
63
+ "work/Tasks",
64
+ "homework/Tasks",
65
+ "b/work/Tasks",
66
+ "docs/Tasks",
67
+ "Tasks",
68
+ "work/Other",
69
+ "nope/Tasks",
70
+ "work/Nope",
71
+ ):
72
+ expected = sorted(_scan_resolve(display, catalog))
73
+ assert sorted(candidates_for(display, catalog)) == expected, display
74
+ if "/" in display:
75
+ unique = expected[0] if len(expected) == 1 else None
76
+ assert resolver.resolve(display, catalog) == unique, display
77
+
78
+ def test_case_and_unicode_form_still_fold(self, tmp_path):
79
+ _write(tmp_path, "Work/Tasks.md")
80
+ catalog = _build(tmp_path).catalog
81
+ assert NormalizeResolver().resolve("work/tasks", catalog) == "Work/Tasks.md"
82
+
83
+ def test_a_genuine_collision_is_still_declined(self, tmp_path):
84
+ _write(tmp_path, "a/work/Tasks.md")
85
+ _write(tmp_path, "b/work/Tasks.md")
86
+ catalog = _build(tmp_path).catalog
87
+ assert NormalizeResolver().resolve("work/Tasks", catalog) is None
88
+ assert candidates_for("work/Tasks", catalog) == ["a/work/Tasks.md", "b/work/Tasks.md"]
89
+
90
+ def test_a_whole_path_and_a_deeper_suffix_both_match(self, tmp_path):
91
+ # The case that forbids short-circuiting on an exact hit: "work/Tasks" names both, so the
92
+ # resolver must decline. An index that returned the exact match would change the verdict.
93
+ _write(tmp_path, "work/Tasks.md")
94
+ _write(tmp_path, "a/work/Tasks.md")
95
+ catalog = _build(tmp_path).catalog
96
+ assert NormalizeResolver().resolve("work/Tasks", catalog) is None
97
+ assert candidates_for("work/Tasks", catalog) == ["a/work/Tasks.md", "work/Tasks.md"]
98
+
99
+ def test_a_degenerate_trailing_slash_display(self, tmp_path):
100
+ _write(tmp_path, "a/b.md")
101
+ catalog = _build(tmp_path).catalog
102
+ assert candidates_for("a/", catalog) == _scan_resolve("a/", catalog)
103
+
104
+
105
+ class TestCacheSafety:
106
+ def test_two_catalogs_do_not_share_an_index(self, tmp_path):
107
+ _write(tmp_path, "one/note.md")
108
+ first = _build(tmp_path).catalog
109
+ _write(tmp_path, "two/other.md")
110
+ second = _build(tmp_path).catalog
111
+ resolver = NormalizeResolver()
112
+ assert resolver.resolve("one/note", first) == "one/note.md"
113
+ assert resolver.resolve("two/other", second) == "two/other.md"
114
+ # Back to the first catalog: a single-slot cache must re-key, not answer from the second.
115
+ assert resolver.resolve("two/other", first) is None
116
+ assert resolver.resolve("one/note", first) == "one/note.md"
117
+
118
+ def test_alternating_between_two_catalogs_does_not_rebuild(self):
119
+ """The regression guard for what actually made the first attempt useless.
120
+
121
+ `_diagnose` consults the in-scope catalog and the out-of-scope one for the same
122
+ display, so a single-slot cache alternates and misses every time. That version rebuilt
123
+ the index 29,265 times for 10,149 links and was no faster than the scan it replaced — a
124
+ "speedup" that was entirely cache thrash. The buckets' identity is the observable.
125
+ """
126
+ from graphmark.graph import _FilenameIndex
127
+
128
+ a = {"one": ["one/note.md"]}
129
+ b = {"two": ["two/other.md"]}
130
+ index = _FilenameIndex()
131
+ first_a, first_b = index.for_catalog(a), index.for_catalog(b)
132
+ for _ in range(5):
133
+ assert index.for_catalog(a) is first_a
134
+ assert index.for_catalog(b) is first_b
135
+
136
+ def test_the_slot_count_is_bounded(self):
137
+ # The cache holds catalogs alive, so it must not become an unbounded retainer.
138
+ from graphmark.graph import _FilenameIndex
139
+
140
+ index = _FilenameIndex()
141
+ for i in range(_FilenameIndex._MAX_SLOTS + 3):
142
+ index.for_catalog({f"k{i}": [f"f{i}/n.md"]})
143
+ assert len(index._slots) <= _FilenameIndex._MAX_SLOTS
144
+
145
+ def test_a_mutated_catalog_is_not_answered_from_a_stale_index(self, tmp_path):
146
+ _write(tmp_path, "one/note.md")
147
+ catalog = _build(tmp_path).catalog
148
+ resolver = NormalizeResolver()
149
+ assert resolver.resolve("one/note", catalog) == "one/note.md"
150
+ # Same object, new content. The cache keys on identity, so this is its honest limit:
151
+ # callers must not mutate a catalog mid-build. Asserted so the limit is visible.
152
+ catalog.setdefault("late", []).append("late/late.md")
153
+ assert resolver.resolve("late/late", catalog) is None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes