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.
- {graphmark-0.8.0 → graphmark-0.8.1}/CHANGELOG.md +28 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/PKG-INFO +1 -1
- {graphmark-0.8.0 → graphmark-0.8.1}/pyproject.toml +1 -1
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/graph.py +69 -24
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_graph.py +6 -10
- graphmark-0.8.1/tests/test_path_index.py +153 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/.gitignore +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/LICENSE +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/README.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/docs/ROADMAP.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/__init__.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/check.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/cli.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/config.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/export.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/metrics.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/model.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/parse.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/src/graphmark/py.typed +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/README.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_aliases.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_bom.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_build_filters.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_check.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_check_policy.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_cli.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_config.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_degenerate.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_diagnose.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_dismiss.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_export.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_gaps_params.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_link_counts.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_links_report.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_markdown_links.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_metrics.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_model.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_non_note_file.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_packaging.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_pagerank.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_parse.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_path_suffix.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_properties.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_public_api.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_readme_examples.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_scoped.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_selflink.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_smoke.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_suggestions.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unicode.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unicode_punctuation.py +0 -0
- {graphmark-0.8.0 → graphmark-0.8.1}/tests/test_unread_syntax.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
97
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphmark-0.8.0 → graphmark-0.8.1}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json
RENAMED
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|