graphmark 0.4.0__tar.gz → 0.5.0__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 (93) hide show
  1. {graphmark-0.4.0 → graphmark-0.5.0}/CHANGELOG.md +45 -0
  2. {graphmark-0.4.0 → graphmark-0.5.0}/PKG-INFO +31 -1
  3. {graphmark-0.4.0 → graphmark-0.5.0}/README.md +30 -0
  4. {graphmark-0.4.0 → graphmark-0.5.0}/docs/ROADMAP.md +1 -1
  5. {graphmark-0.4.0 → graphmark-0.5.0}/pyproject.toml +1 -1
  6. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/__init__.py +6 -0
  7. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/graph.py +100 -5
  8. graphmark-0.5.0/tests/fixtures/suggest/README.md +62 -0
  9. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_diagnose.py +4 -4
  10. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_graph_catalogs.py +2 -2
  11. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_public_api.py +3 -0
  12. graphmark-0.5.0/tests/test_suggestions.py +279 -0
  13. {graphmark-0.4.0 → graphmark-0.5.0}/.gitignore +0 -0
  14. {graphmark-0.4.0 → graphmark-0.5.0}/LICENSE +0 -0
  15. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/check.py +0 -0
  16. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/cli.py +0 -0
  17. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/config.py +0 -0
  18. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/dismiss.py +0 -0
  19. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/export.py +0 -0
  20. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/interfaces.py +0 -0
  21. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/metrics.py +0 -0
  22. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/model.py +0 -0
  23. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/parse.py +0 -0
  24. {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/py.typed +0 -0
  25. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/README.md +0 -0
  26. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/config.toml +0 -0
  27. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/expected.json +0 -0
  28. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  29. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  30. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  31. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  32. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  33. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  34. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  35. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  36. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  37. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  38. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/expected.json +0 -0
  39. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  40. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  41. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  42. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  43. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/config.toml +0 -0
  44. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/expected.json +0 -0
  45. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  46. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/similar.json +0 -0
  47. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  48. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  49. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  50. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  51. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  52. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  53. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  54. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/config.toml +0 -0
  55. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/expected.json +0 -0
  56. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  57. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  58. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  59. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  60. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/config.toml +0 -0
  61. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/expected.json +0 -0
  62. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/vault/a.md +0 -0
  63. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/vault/b.md +0 -0
  64. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/config.toml +0 -0
  65. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/expected.json +0 -0
  66. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  67. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  68. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  69. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  70. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  71. {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  72. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_build_filters.py +0 -0
  73. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_check.py +0 -0
  74. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_check_policy.py +0 -0
  75. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_cli.py +0 -0
  76. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_config.py +0 -0
  77. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_degenerate.py +0 -0
  78. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_dismiss.py +0 -0
  79. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_export.py +0 -0
  80. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps.py +0 -0
  81. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps_banding.py +0 -0
  82. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps_oog.py +0 -0
  83. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps_params.py +0 -0
  84. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_graph.py +0 -0
  85. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_metrics.py +0 -0
  86. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_model.py +0 -0
  87. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_packaging.py +0 -0
  88. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_pagerank.py +0 -0
  89. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_parse.py +0 -0
  90. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_scoped.py +0 -0
  91. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_selflink.py +0 -0
  92. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_smoke.py +0 -0
  93. {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,51 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.5.0 (2026-07-25)
5
+
6
+ ### Features
7
+
8
+ - **graph**: Add calibrated near-miss suggestions (#112)
9
+ ([#116](https://github.com/cdcoonce/graphmark/pull/116),
10
+ [`db873b1`](https://github.com/cdcoonce/graphmark/commit/db873b1009bbef599e9eef035720306b62a548b8))
11
+
12
+ Track E slice 3, and the last thing keeping a consumer's parallel resolver alive. diagnose(graph,
13
+ display, suggest=k) fills candidates with near-miss notes for a `missing` verdict only — every
14
+ other reason already carries the rel_paths in play — so the default of 0 leaves the check gate's
15
+ hot path untouched.
16
+
17
+ The rule was calibrated, not invented. The prior art (bidirectional substring containment over
18
+ stems) was frozen over a real 521-note vault's broken links, giving 53 distinct displays, each
19
+ annotated by a human as useful / useless / missing / correct-none. Only then was an algorithm
20
+ chosen, and it is justified against that set: 27/27 useful kept — the non-negotiable — 7 of 8
21
+ useless dropped, 4 of 5 missing found, zero new false suggestions. Method and measured result in
22
+ tests/fixtures/suggest/README.md; the vault is private, so every shape is reproduced as a named
23
+ test with invented names rather than committing the rows.
24
+
25
+ Matching is directional, which is what separates a suggestion from a wrong answer. A display inside
26
+ a candidate is an abbreviation and always offered ([[Jordan]] -> Jordan Ellis). A candidate inside
27
+ a display is offered only above SUGGEST_MIN_COVERAGE: dropping a "-reference" suffix is the
28
+ answer, matching one word out of five is a real note that is NOT the target, and that shape is
29
+ what made the old hints untrustworthy. Partial overlap in neither direction is rejected — it
30
+ carried no useful hint anywhere in the baseline, and rejecting it is what holds the
31
+ false-suggestion rate at zero.
32
+
33
+ Two annotations were corrected mid-calibration and both changed the design: [[Work Tasks]] and
34
+ [[Personal Index]] looked useless only because the old rule printed bare stems, rendering four
35
+ distinct Index.md files as "Index, Index, Index, Index". Both are correct answers. Suggestions
36
+ therefore return rel_paths, and `index` is deliberately NOT a generic stem.
37
+
38
+ Also scrubs real colleague names out of the package. They had been used as doc/test examples and one
39
+ shipped in a docstring in 0.4.0; all replaced with invented placeholders. Published history still
40
+ carries them.
41
+
42
+ Both constants are pinned by the baseline: the cap is the lowest value that keeps every useful
43
+ suggestion, the floor the highest. Teeth-checked by mutation — disabling suppression fails 1,
44
+ dropping the coverage floor fails 3, dropping folder-keying fails 2, suggesting on every reason
45
+ fails 3, and dropping the digit filter initially survived until a ranking test was added to cover
46
+ it.
47
+
48
+
4
49
  ## v0.4.0 (2026-07-25)
5
50
 
6
51
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.4.0
3
+ Version: 0.5.0
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
@@ -223,6 +223,36 @@ Alias, anchor and `.md` forms all diagnose identically to the bare form.
223
223
  contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
224
224
  `missing`, and the edges are exactly the `resolved` non-self targets.
225
225
 
226
+ ### Near-miss suggestions
227
+
228
+ Pass `suggest=k` to turn a `missing` verdict into an actionable one. Suggestions are computed only
229
+ for `missing` — every other reason already carries the rel_paths in play — so the default of `0`
230
+ costs nothing on the `check` hot path.
231
+
232
+ ```python
233
+ graphmark.diagnose(graph, "Jordan", suggest=5).candidates
234
+ # ('people/Jordan Ellis.md',)
235
+ ```
236
+
237
+ Matching is **directional**, which is what separates a suggestion from a wrong answer:
238
+
239
+ - a display inside a candidate name is an abbreviation — `[[Jordan]]` → `Jordan Ellis`, or
240
+ `[[Mood Tracker]]` → `2026-04-11-mood-tracker` (pure-digit tokens are filing metadata, not
241
+ content);
242
+ - a candidate inside a display is offered only when it covers at least `SUGGEST_MIN_COVERAGE` of it
243
+ — `[[fable-prompt-technique-reference]]` → `fable-prompt-technique` is the answer, while
244
+ `[[Dagster PJM InSchedules]]` → `Dagster` is a real note that is _not_ the target;
245
+ - partial overlap in neither direction is rejected;
246
+ - a display matching more than `SUGGEST_MAX_MATCHES` notes names a topic, not a typo, and gets
247
+ nothing at all.
248
+
249
+ Notes whose stem is generic (`SKILL.md`, `README.md`) are matched on their **parent folder**, where
250
+ their name actually lives.
251
+
252
+ Both constants were calibrated against a human-annotated baseline of a real vault's broken links
253
+ rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
254
+ for the method and the measured result.
255
+
226
256
  ## Configuration
227
257
 
228
258
  ```toml
@@ -196,6 +196,36 @@ Alias, anchor and `.md` forms all diagnose identically to the bare form.
196
196
  contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
197
197
  `missing`, and the edges are exactly the `resolved` non-self targets.
198
198
 
199
+ ### Near-miss suggestions
200
+
201
+ Pass `suggest=k` to turn a `missing` verdict into an actionable one. Suggestions are computed only
202
+ for `missing` — every other reason already carries the rel_paths in play — so the default of `0`
203
+ costs nothing on the `check` hot path.
204
+
205
+ ```python
206
+ graphmark.diagnose(graph, "Jordan", suggest=5).candidates
207
+ # ('people/Jordan Ellis.md',)
208
+ ```
209
+
210
+ Matching is **directional**, which is what separates a suggestion from a wrong answer:
211
+
212
+ - a display inside a candidate name is an abbreviation — `[[Jordan]]` → `Jordan Ellis`, or
213
+ `[[Mood Tracker]]` → `2026-04-11-mood-tracker` (pure-digit tokens are filing metadata, not
214
+ content);
215
+ - a candidate inside a display is offered only when it covers at least `SUGGEST_MIN_COVERAGE` of it
216
+ — `[[fable-prompt-technique-reference]]` → `fable-prompt-technique` is the answer, while
217
+ `[[Dagster PJM InSchedules]]` → `Dagster` is a real note that is _not_ the target;
218
+ - partial overlap in neither direction is rejected;
219
+ - a display matching more than `SUGGEST_MAX_MATCHES` notes names a topic, not a typo, and gets
220
+ nothing at all.
221
+
222
+ Notes whose stem is generic (`SKILL.md`, `README.md`) are matched on their **parent folder**, where
223
+ their name actually lives.
224
+
225
+ Both constants were calibrated against a human-annotated baseline of a real vault's broken links
226
+ rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
227
+ for the method and the measured result.
228
+
199
229
  ## Configuration
200
230
 
201
231
  ```toml
@@ -82,7 +82,7 @@ already learned were fine. #107 is the fourth fix that had to be applied in two
82
82
  The gardener's stack survives because it answers questions graphmark cannot yet answer. graphmark
83
83
  says _whether_ a link resolves; Lane A needs to know **why it failed and what to do about it** —
84
84
  the ambiguity set, the near-miss candidates behind a "did you mean", and the canonical title of
85
- the note a display resolves to (so `[[ethan courtman]]` can be repaired to `[[Ethan Courtman]]`).
85
+ the note a display resolves to (so `[[jordan ellis]]` can be repaired to `[[Jordan Ellis]]`).
86
86
 
87
87
  _Where we're going:_ a documented, typed **link-diagnosis** surface that makes the consumer a
88
88
  formatter over graphmark's answers instead of a second resolver. The classification is already
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.4.0"
3
+ version = "0.5.0"
4
4
  description = "Deterministic knowledge-graph analysis for markdown / [[wikilink]] vaults."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -28,12 +28,15 @@ from graphmark.dismiss import (
28
28
  from graphmark.export import to_dot, to_json
29
29
  from graphmark.graph import (
30
30
  DIAGNOSIS_REASONS,
31
+ SUGGEST_MAX_MATCHES,
32
+ SUGGEST_MIN_COVERAGE,
31
33
  LinkDiagnosis,
32
34
  NormalizeResolver,
33
35
  VaultGraph,
34
36
  build_catalog,
35
37
  candidates_for,
36
38
  diagnose,
39
+ suggest_notes,
37
40
  )
38
41
  from graphmark.interfaces import LinkExtractor, Resolver, Similarity
39
42
  from graphmark.metrics import (
@@ -96,6 +99,8 @@ __all__ = [
96
99
  "Document",
97
100
  "LinkExtractor",
98
101
  "DIAGNOSIS_REASONS",
102
+ "SUGGEST_MAX_MATCHES",
103
+ "SUGGEST_MIN_COVERAGE",
99
104
  "LinkDiagnosis",
100
105
  "NormalizeResolver",
101
106
  "Resolver",
@@ -110,6 +115,7 @@ __all__ = [
110
115
  "build_catalog",
111
116
  "candidates_for",
112
117
  "diagnose",
118
+ "suggest_notes",
113
119
  "clusters",
114
120
  "gaps",
115
121
  "hubs",
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import re
6
6
  import string
7
- from dataclasses import dataclass, field
7
+ from dataclasses import dataclass, field, replace
8
8
  from pathlib import Path
9
9
 
10
10
  from graphmark.config import VaultConfig
@@ -144,6 +144,90 @@ class NormalizeResolver:
144
144
  return paths[0]
145
145
 
146
146
 
147
+ #: A display matching more than this many notes names a *topic*, not a mistyped note, and gets no
148
+ #: suggestions at all. Calibrated, not chosen: on the reference vault ``[[AMRT]]`` matched 47 notes
149
+ #: while ``[[Priya Raghavan]]`` needs a cap of at least 12 to survive (that token appears in 9 note
150
+ #: stems), so 12 is the lowest value that keeps every human-confirmed useful suggestion.
151
+ SUGGEST_MAX_MATCHES = 12
152
+
153
+ #: When a candidate's name sits *inside* a longer display, the fraction of the display it must
154
+ #: account for. ``[[fable-prompt-technique-reference]]`` → ``fable-prompt-technique`` covers 3 of 4
155
+ #: tokens and is the answer; ``[[Dagster PJM InSchedules]]`` → ``Dagster`` covers 1 of 3 and is a
156
+ #: real note that is not the target — the shape that made the old suggestions untrustworthy. 0.4 is
157
+ #: the highest floor that keeps every useful suggestion in the annotated baseline.
158
+ SUGGEST_MIN_COVERAGE = 0.4
159
+
160
+ #: Note stems that name nothing — the note's identity lives in its parent folder instead. Kept
161
+ #: deliberately short: ``index`` is NOT here, because a vault's ``personal/Index.md`` is a real,
162
+ #: linkable note and re-keying it onto its folder would lose the actual answer.
163
+ GENERIC_STEMS = frozenset({"skill", "readme"})
164
+
165
+
166
+ def _content_tokens(text: str) -> frozenset[str]:
167
+ """Normalized tokens with pure-digit runs dropped.
168
+
169
+ Date prefixes are filing metadata, not content: ``2026-04-11-mood-tracker`` and
170
+ ``Mood Tracker`` are the same note to a human, and the dated twin is the single most common
171
+ near-miss shape in a journal-style vault.
172
+ """
173
+ return frozenset(t for t in _normalize(text).split() if not t.isdigit())
174
+
175
+
176
+ def _suggestion_keys(catalog: dict[str, list[str]]) -> list[tuple[str, frozenset[str]]]:
177
+ """(rel_path, name tokens) for every note, keyed by folder where the stem is generic."""
178
+ keys: list[tuple[str, frozenset[str]]] = []
179
+ for paths in catalog.values():
180
+ for rel in paths:
181
+ path = Path(rel)
182
+ name = path.parent.name if path.stem.lower() in GENERIC_STEMS else path.stem
183
+ tokens = _content_tokens(name)
184
+ if tokens:
185
+ keys.append((rel, tokens))
186
+ return keys
187
+
188
+
189
+ def suggest_notes(display: str, catalog: dict[str, list[str]], k: int) -> tuple[str, ...]:
190
+ """Up to ``k`` notes whose names are near-misses for ``display``, best first.
191
+
192
+ Matching is **directional**, which is what separates a suggestion from noise:
193
+
194
+ * the display's tokens inside a candidate's — the display abbreviates a longer title
195
+ (``[[Jordan]]`` → ``Jordan Ellis``). Always offered; a short display naturally names a
196
+ longer note.
197
+ * a candidate's tokens inside the display's — offered only when the candidate accounts for at
198
+ least ``SUGGEST_MIN_COVERAGE`` of the display. Dropping a suffix is a real answer; matching
199
+ one word out of five is a wrong answer that invites a wrong repair.
200
+ * neither — rejected. Partial overlap produced no useful suggestion anywhere in the annotated
201
+ baseline, and rejecting it is what holds the false-suggestion rate at zero.
202
+
203
+ Ranked by how much of the longer name the match accounts for, ties broken by rel_path so the
204
+ output is byte-stable.
205
+ """
206
+ display_tokens = _content_tokens(display)
207
+ if not display_tokens or k <= 0:
208
+ return ()
209
+
210
+ scored: list[tuple[float, str]] = []
211
+ for rel, name_tokens in _suggestion_keys(catalog):
212
+ if display_tokens <= name_tokens:
213
+ score = len(display_tokens) / len(name_tokens)
214
+ elif name_tokens <= display_tokens:
215
+ score = len(name_tokens) / len(display_tokens)
216
+ if score < SUGGEST_MIN_COVERAGE:
217
+ continue
218
+ else:
219
+ continue
220
+ scored.append((score, rel))
221
+
222
+ # A display that matches half the vault is a topic. Offering its "best" dozen would be
223
+ # confident nonsense, so it gets nothing.
224
+ if len(scored) > SUGGEST_MAX_MATCHES:
225
+ return ()
226
+
227
+ scored.sort(key=lambda pair: (-pair[0], pair[1]))
228
+ return tuple(rel for _, rel in scored[:k])
229
+
230
+
147
231
  #: Every value ``LinkDiagnosis.reason`` can take, in the order they are decided. A consumer
148
232
  #: switches on these strings, so the set is part of the public contract.
149
233
  DIAGNOSIS_REASONS = (
@@ -167,10 +251,11 @@ class LinkDiagnosis:
167
251
 
168
252
  ``target`` is the resolved rel_path, set only when ``reason == "resolved"``; the caller recovers
169
253
  the note's canonical title from its stem, which is what makes a case repair like
170
- ``[[ethan courtman]]`` → ``[[Ethan Courtman]]`` possible.
254
+ ``[[jordan ellis]]`` → ``[[Jordan Ellis]]`` possible.
171
255
 
172
256
  ``candidates`` carries the rel_paths in play: the colliding notes for ``ambiguous``, the
173
- unindexed markdown for ``out-of-scope-note``. Empty for every other reason.
257
+ unindexed markdown for ``out-of-scope-note``, and — only when ``diagnose`` is asked for them
258
+ the near-miss suggestions for ``missing``. Empty for every other reason.
174
259
  """
175
260
 
176
261
  display: str
@@ -219,14 +304,24 @@ def _diagnose(
219
304
  return LinkDiagnosis(display=display, reason="missing")
220
305
 
221
306
 
222
- def diagnose(graph: VaultGraph, display: str) -> LinkDiagnosis:
307
+ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagnosis:
223
308
  """Explain what ``display`` names in ``graph`` — see :class:`LinkDiagnosis`.
224
309
 
225
310
  Uses the resolver the graph was built with, so a diagnosis can never contradict the graph it
226
311
  describes. A directly constructed ``VaultGraph`` with no resolver falls back to
227
312
  ``NormalizeResolver``.
313
+
314
+ ``suggest=k`` fills ``candidates`` with up to k near-miss notes — but only for a ``missing``
315
+ verdict, since every other reason either already carries the rel_paths in play or has nothing
316
+ to look for. The default of 0 does no extra work at all, keeping the vault-health gate's hot
317
+ path free of it.
228
318
  """
229
- return _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver)
319
+ if suggest < 0:
320
+ raise ValueError(f"suggest must be >= 0, got {suggest}")
321
+ diagnosis = _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver)
322
+ if suggest and diagnosis.reason == "missing":
323
+ return replace(diagnosis, candidates=suggest_notes(display, graph.catalog, suggest))
324
+ return diagnosis
230
325
 
231
326
 
232
327
  class VaultGraph:
@@ -0,0 +1,62 @@
1
+ # Suggestion calibration — provenance
2
+
3
+ `suggest_notes()` is the one part of graphmark whose expected output is **human judgment, not
4
+ oracle-derived**. There is no reference engine to diff against: the question "is this a useful
5
+ suggestion?" has no mechanical answer. So the rule was calibrated rather than invented, and this
6
+ file records how, because the numbers in `SUGGEST_MAX_MATCHES` / `SUGGEST_MIN_COVERAGE` are
7
+ meaningless without it.
8
+
9
+ ## Method
10
+
11
+ 1. **Freeze the baseline.** The prior art was a consumer's hint rule — bidirectional substring
12
+ containment over normalized note stems, capped at 5 for display. It was run over a real
13
+ 521-note vault's actual broken links, yielding **53 distinct displays** that reached the hint
14
+ search.
15
+ 2. **Annotate it.** A human marked every row:
16
+ - `useful` (25 → 27 after correction) — the hint is the answer; a new rule **must** keep it
17
+ - `useless` (8) — a wrong or unactionable suggestion; a new rule should drop it
18
+ - `missing` (5) — no hint today, but an obvious answer exists
19
+ - `correct-none` (13) — no hint, and nothing to find
20
+ 3. **Then** choose an algorithm, and justify it against the annotation.
21
+
22
+ The vault is private, so the annotated rows are not committed here — every distinct shape they
23
+ cover is reproduced as a named test case in `tests/test_suggestions.py`, with invented names.
24
+
25
+ ## What the annotation established
26
+
27
+ Two rows were reclassified during calibration, and both changed the design:
28
+
29
+ - `[[Work Tasks]] → Tasks` and `[[Personal Index]] → Index` were first marked useless, because the
30
+ hint rendered as a bare repeated stem (`Index, Index, Index, Index`). Both are in fact **correct**
31
+ — `work/Tasks.md` and `personal/Index.md`. The defect was the _display format_, not the matching:
32
+ showing stems threw away the path that disambiguates them. Suggestions therefore return
33
+ **rel_paths**. This is also why `index` is not in `GENERIC_STEMS`.
34
+
35
+ ## Result
36
+
37
+ | | old rule | calibrated rule |
38
+ | --------------------- | -------- | --------------- |
39
+ | useful kept | 27/27 | **27/27** |
40
+ | useless dropped | 0/8 | **7/8** |
41
+ | missing found | 0/5 | **4/5** |
42
+ | new false suggestions | — | **0** |
43
+
44
+ The two knowingly-accepted gaps:
45
+
46
+ - **`[[My Brain]]`** still suggests two real notes sharing that prefix. Defensible, so not worth
47
+ distorting the rule to remove.
48
+ - **`[[graphify-knowledge-graph-tool]] → graphify-memory-layer-eval`** is still missed. They share
49
+ one token of four in neither direction, so only edit distance would find it — and every
50
+ edit-distance variant tried scored worse overall, losing useful rows to keep this one. Rejecting
51
+ partial overlap is what holds the false-suggestion rate at zero.
52
+
53
+ ## Why the constants are what they are
54
+
55
+ - `SUGGEST_MAX_MATCHES = 12` — the **lowest** cap that keeps every useful suggestion. One display
56
+ (a two-token personal name whose first token appears in 9 note stems) needs 12; at 8 it is lost.
57
+ The pathological case it exists for matched 47 notes.
58
+ - `SUGGEST_MIN_COVERAGE = 0.4` — the **highest** floor that keeps every useful suggestion. At 0.5
59
+ two rows are lost; at 0.67 six are.
60
+
61
+ Re-running the calibration after any change to the rule is the check that matters. A green unit
62
+ suite proves the shapes still behave; only the annotated set proves the rule is still _useful_.
@@ -41,12 +41,12 @@ class TestResolved:
41
41
  assert d == LinkDiagnosis(display="b", target="b.md", reason="resolved", candidates=())
42
42
 
43
43
  def test_the_target_carries_the_canonical_title(self, tmp_path):
44
- # The repair the gardener needs: [[ethan courtman]] → [[Ethan Courtman]]. The caller
44
+ # The repair the gardener needs: [[jordan ellis]] → [[Jordan Ellis]]. The caller
45
45
  # recovers the title from the target's stem, so no separate title field is needed.
46
- _write(tmp_path, "people/Ethan Courtman.md")
47
- d = diagnose(_build(tmp_path), "ethan courtman")
46
+ _write(tmp_path, "people/Jordan Ellis.md")
47
+ d = diagnose(_build(tmp_path), "jordan ellis")
48
48
  assert d.reason == "resolved"
49
- assert Path(d.target).stem == "Ethan Courtman"
49
+ assert Path(d.target).stem == "Jordan Ellis"
50
50
 
51
51
  def test_path_qualified_link_resolves(self, tmp_path):
52
52
  _write(tmp_path, "docs/deep/note.md")
@@ -34,8 +34,8 @@ def _build(root: Path, **config_kwargs) -> VaultGraph:
34
34
 
35
35
  class TestCatalog:
36
36
  def test_maps_normalized_stem_to_rel_path(self, tmp_path):
37
- _write(tmp_path, "Ethan Courtman.md")
38
- assert _build(tmp_path).catalog == {"ethan courtman": ["Ethan Courtman.md"]}
37
+ _write(tmp_path, "Jordan Ellis.md")
38
+ assert _build(tmp_path).catalog == {"jordan ellis": ["Jordan Ellis.md"]}
39
39
 
40
40
  def test_punctuation_normalizes_to_the_same_key_as_spaces(self, tmp_path):
41
41
  # The vault's real repair case: [[oura pipeline]] should be able to find oura-pipeline.md.
@@ -118,6 +118,9 @@ class TestPublicSurface:
118
118
  "LinkDiagnosis",
119
119
  "DIAGNOSIS_REASONS",
120
120
  "candidates_for",
121
+ "suggest_notes",
122
+ "SUGGEST_MAX_MATCHES",
123
+ "SUGGEST_MIN_COVERAGE",
121
124
  # dismissal store
122
125
  "weaklink_sig",
123
126
  "record_dismissal",
@@ -0,0 +1,279 @@
1
+ """diagnose(..., suggest=k) — near-miss candidates for a link that names nothing.
2
+
3
+ The last thing keeping a consumer's parallel resolver alive: when a link resolves to nothing, a
4
+ useful tool offers near-miss note titles, and the repair proposal is only actionable because of
5
+ them.
6
+
7
+ The rule here was not invented — it was calibrated against a frozen baseline of a real 521-note
8
+ vault's broken links, annotated by a human as useful / useless / missing-the-obvious-answer (see
9
+ tests/fixtures/suggest/README.md). Three policy decisions came from that annotation:
10
+
11
+ 1. A display matching more than SUGGEST_MAX_MATCHES notes gets nothing: it names a topic, not a
12
+ typo. ([[AMRT]] matched 47 notes under the old rule.)
13
+ 2. A note whose stem is generic (SKILL.md, README.md) is indexed by its PARENT FOLDER, because
14
+ that is where its name lives.
15
+ 3. Matching is directional. A display inside a candidate is an abbreviation and always useful
16
+ ([[Jordan]] -> Jordan Ellis). A candidate inside a display is useful only when it covers
17
+ most of it ([[fable-prompt-technique-reference]] -> fable-prompt-technique) and is noise when
18
+ it is one word out of many ([[Dagster PJM InSchedules]] -> Dagster).
19
+
20
+ Partial overlap in neither direction is rejected: it produced no useful hint anywhere in the
21
+ baseline.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from pathlib import Path
27
+
28
+ import pytest
29
+
30
+ from graphmark.config import VaultConfig
31
+ from graphmark.graph import (
32
+ SUGGEST_MAX_MATCHES,
33
+ SUGGEST_MIN_COVERAGE,
34
+ NormalizeResolver,
35
+ VaultGraph,
36
+ diagnose,
37
+ )
38
+ from graphmark.parse import WikilinkExtractor
39
+
40
+
41
+ def _write(root: Path, rel: str, text: str = "") -> None:
42
+ path = root / rel
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ path.write_text(text, encoding="utf-8")
45
+
46
+
47
+ def _build(root: Path, **config_kwargs) -> VaultGraph:
48
+ return VaultGraph.build(
49
+ VaultConfig(root=root, **config_kwargs), WikilinkExtractor(), NormalizeResolver()
50
+ )
51
+
52
+
53
+ class TestDefaultIsUnchanged:
54
+ """suggest=0 must cost nothing — the check gate's hot path never wants suggestions."""
55
+
56
+ def test_no_suggestions_without_the_flag(self, tmp_path):
57
+ _write(tmp_path, "Jordan Ellis.md")
58
+ _write(tmp_path, "a.md", "See [[Jordan]].\n")
59
+ d = diagnose(_build(tmp_path), "Jordan")
60
+ assert (d.reason, d.candidates) == ("missing", ())
61
+
62
+ def test_explicit_zero_is_the_same(self, tmp_path):
63
+ _write(tmp_path, "Jordan Ellis.md")
64
+ assert diagnose(_build(tmp_path), "Jordan", suggest=0).candidates == ()
65
+
66
+
67
+ class TestAbbreviations:
68
+ """display ⊆ candidate — the display names part of a longer real title."""
69
+
70
+ def test_a_first_name_finds_the_full_name(self, tmp_path):
71
+ _write(tmp_path, "people/Jordan Ellis.md")
72
+ d = diagnose(_build(tmp_path), "Jordan", suggest=5)
73
+ assert d.reason == "missing"
74
+ assert d.candidates == ("people/Jordan Ellis.md",)
75
+
76
+ def test_a_title_finds_a_prefixed_slug(self, tmp_path):
77
+ _write(tmp_path, "ref/anthropic-containing-claude.md")
78
+ d = diagnose(_build(tmp_path), "Containing Claude", suggest=5)
79
+ assert d.candidates == ("ref/anthropic-containing-claude.md",)
80
+
81
+ def test_date_prefixes_are_not_content(self, tmp_path):
82
+ # A dated twin of the same note is the single most common shape in the baseline.
83
+ _write(tmp_path, "notes/2026-04-11-mood-tracker.md")
84
+ d = diagnose(_build(tmp_path), "Mood Tracker", suggest=5)
85
+ assert d.candidates == ("notes/2026-04-11-mood-tracker.md",)
86
+
87
+ def test_a_dated_twin_outranks_a_longer_untitled_match(self, tmp_path):
88
+ # Where dropping digits actually bites: both notes contain the display's tokens, so both
89
+ # match either way, but only ignoring the date makes the dated twin score a perfect 1.0
90
+ # and sort first. Counting "2026", "04" and "11" as content buries the right answer.
91
+ _write(tmp_path, "notes/2026-04-11-mood-tracker.md")
92
+ _write(tmp_path, "notes/mood tracker notes.md")
93
+ d = diagnose(_build(tmp_path), "Mood Tracker", suggest=5)
94
+ assert d.candidates[0] == "notes/2026-04-11-mood-tracker.md"
95
+
96
+ def test_punctuation_differences_do_not_matter(self, tmp_path):
97
+ _write(tmp_path, "trips/2026-mike-brenna-wedding-trip.md")
98
+ d = diagnose(_build(tmp_path), "Mike & Brenna Wedding Trip", suggest=5)
99
+ assert d.candidates == ("trips/2026-mike-brenna-wedding-trip.md",)
100
+
101
+ def test_several_expansions_all_surface(self, tmp_path):
102
+ # [[Robin]] is genuinely ambiguous between two people — both must show, or the human
103
+ # cannot pick.
104
+ _write(tmp_path, "people/Robin Vance.md")
105
+ _write(tmp_path, "people/Robin Weaver.md")
106
+ d = diagnose(_build(tmp_path), "Robin", suggest=5)
107
+ assert d.candidates == ("people/Robin Vance.md", "people/Robin Weaver.md")
108
+
109
+
110
+ class TestSuffixDrops:
111
+ """candidate ⊆ display — useful only when the candidate covers most of the display."""
112
+
113
+ def test_dropping_a_suffix_finds_the_base_note(self, tmp_path):
114
+ _write(tmp_path, "ref/fable-prompt-technique.md")
115
+ d = diagnose(_build(tmp_path), "fable-prompt-technique-reference", suggest=5)
116
+ assert d.candidates == ("ref/fable-prompt-technique.md",)
117
+
118
+ def test_a_two_word_display_finds_its_one_word_note(self, tmp_path):
119
+ # [[Work Tasks]] -> work/Tasks.md: coverage 1/2 clears the floor.
120
+ _write(tmp_path, "work/Tasks.md")
121
+ d = diagnose(_build(tmp_path), "Work Tasks", suggest=5)
122
+ assert d.candidates == ("work/Tasks.md",)
123
+
124
+ def test_one_word_out_of_many_is_rejected(self, tmp_path):
125
+ # [[Dagster PJM InSchedules]] -> Dagster was the archetypal noise hint: a real note, but
126
+ # not the target, and it invites a wrong repair.
127
+ _write(tmp_path, "ref/Dagster.md")
128
+ d = diagnose(_build(tmp_path), "Dagster PJM InSchedules", suggest=5)
129
+ assert d.candidates == ()
130
+
131
+ def test_the_coverage_floor_is_the_boundary(self, tmp_path):
132
+ # 2 of 5 tokens = 0.4, exactly the floor, which is inclusive.
133
+ _write(tmp_path, "ref/rec dashboard.md")
134
+ d = diagnose(_build(tmp_path), "reference rec dashboard env delivery", suggest=5)
135
+ assert d.candidates == ("ref/rec dashboard.md",)
136
+ # 1 of 5 = 0.2, below it.
137
+ _write(tmp_path, "ref/delivery.md")
138
+ assert (
139
+ "ref/delivery.md"
140
+ not in diagnose(
141
+ _build(tmp_path), "reference rec dashboard env delivery", suggest=5
142
+ ).candidates
143
+ )
144
+
145
+
146
+ class TestPartialOverlapRejected:
147
+ def test_sharing_one_token_in_neither_direction_suggests_nothing(self, tmp_path):
148
+ # {graphify, memory, layer, eval} vs {graphify, knowledge, graph, tool}: neither is a
149
+ # subset, so this is rejected. A known miss, accepted to keep the noise floor at zero.
150
+ _write(tmp_path, "ideas/graphify-memory-layer-eval.md")
151
+ d = diagnose(_build(tmp_path), "graphify-knowledge-graph-tool", suggest=5)
152
+ assert d.candidates == ()
153
+
154
+
155
+ class TestBroadMatchSuppression:
156
+ def test_a_display_matching_more_than_the_cap_suggests_nothing(self, tmp_path):
157
+ for i in range(SUGGEST_MAX_MATCHES + 1):
158
+ _write(tmp_path, f"notes/amrt topic {i}.md")
159
+ d = diagnose(_build(tmp_path), "AMRT", suggest=5)
160
+ assert d.candidates == ()
161
+
162
+ def test_exactly_at_the_cap_still_suggests(self, tmp_path):
163
+ for i in range(SUGGEST_MAX_MATCHES):
164
+ _write(tmp_path, f"notes/amrt topic {i}.md")
165
+ d = diagnose(_build(tmp_path), "AMRT", suggest=SUGGEST_MAX_MATCHES)
166
+ assert len(d.candidates) == SUGGEST_MAX_MATCHES
167
+
168
+
169
+ class TestFolderNamedNotes:
170
+ def test_a_generic_stem_is_indexed_by_its_folder(self, tmp_path):
171
+ _write(tmp_path, "skills/mr-review-packet/SKILL.md")
172
+ d = diagnose(_build(tmp_path), "mr-review-packet", suggest=5)
173
+ assert d.candidates == ("skills/mr-review-packet/SKILL.md",)
174
+
175
+ def test_readme_counts_as_generic_too(self, tmp_path):
176
+ _write(tmp_path, "tools/link-checker/README.md")
177
+ d = diagnose(_build(tmp_path), "link checker", suggest=5)
178
+ assert d.candidates == ("tools/link-checker/README.md",)
179
+
180
+ def test_a_normal_stem_is_still_indexed_by_its_stem(self, tmp_path):
181
+ _write(tmp_path, "skills/mr-review-packet/notes.md")
182
+ assert diagnose(_build(tmp_path), "mr-review-packet", suggest=5).candidates == ()
183
+
184
+ def test_index_is_not_generic(self, tmp_path):
185
+ # personal/Index.md is a real, linkable vault index — treating "Index" as generic would
186
+ # re-key four different indexes onto their folder names and lose the actual answer.
187
+ _write(tmp_path, "personal/Index.md")
188
+ d = diagnose(_build(tmp_path), "Personal Index", suggest=5)
189
+ assert d.candidates == ("personal/Index.md",)
190
+
191
+
192
+ class TestOrderingAndLimits:
193
+ def test_k_limits_the_result(self, tmp_path):
194
+ for name in ("Robin Vance", "Robin Weaver", "Robin Zhang"):
195
+ _write(tmp_path, f"people/{name}.md")
196
+ assert len(diagnose(_build(tmp_path), "Robin", suggest=2).candidates) == 2
197
+
198
+ def test_best_coverage_ranks_first(self, tmp_path):
199
+ _write(tmp_path, "a/afk-cockpit-ui.md")
200
+ _write(tmp_path, "a/afk-cockpit-metrics-audit-and-review.md")
201
+ d = diagnose(_build(tmp_path), "afk-cockpit", suggest=5)
202
+ assert d.candidates[0] == "a/afk-cockpit-ui.md"
203
+
204
+ def test_ties_break_on_rel_path(self, tmp_path):
205
+ _write(tmp_path, "zeta/Robin Vance.md")
206
+ _write(tmp_path, "alpha/Robin Ford.md")
207
+ d = diagnose(_build(tmp_path), "Robin", suggest=5)
208
+ assert d.candidates == ("alpha/Robin Ford.md", "zeta/Robin Vance.md")
209
+
210
+
211
+ class TestOnlyForMissing:
212
+ """Every other verdict already carries the right candidates, or needs none."""
213
+
214
+ def test_a_resolved_link_gets_no_suggestions(self, tmp_path):
215
+ _write(tmp_path, "b.md")
216
+ _write(tmp_path, "b extended.md")
217
+ d = diagnose(_build(tmp_path), "b", suggest=5)
218
+ assert (d.reason, d.candidates) == ("resolved", ())
219
+
220
+ def test_an_ambiguous_link_keeps_its_real_candidates(self, tmp_path):
221
+ _write(tmp_path, "one/note.md")
222
+ _write(tmp_path, "two/note.md")
223
+ _write(tmp_path, "three/note extended.md")
224
+ d = diagnose(_build(tmp_path), "note", suggest=5)
225
+ assert d.reason == "ambiguous"
226
+ assert d.candidates == ("one/note.md", "two/note.md")
227
+
228
+ def test_an_out_of_scope_link_keeps_its_real_candidates(self, tmp_path):
229
+ _write(tmp_path, "docs/a.md")
230
+ _write(tmp_path, "templates/guide.md")
231
+ _write(tmp_path, "docs/guide extended.md")
232
+ d = diagnose(_build(tmp_path, scoped_folders=["docs"]), "guide", suggest=5)
233
+ assert d.reason == "out-of-scope-note"
234
+ assert d.candidates == ("templates/guide.md",)
235
+
236
+ def test_an_intra_note_link_gets_no_suggestions(self, tmp_path):
237
+ _write(tmp_path, "a.md")
238
+ assert diagnose(_build(tmp_path), "#Heading", suggest=5).candidates == ()
239
+
240
+ def test_a_non_note_file_gets_no_suggestions(self, tmp_path):
241
+ _write(tmp_path, "a.md")
242
+ _write(tmp_path, "Board extended.md")
243
+ assert diagnose(_build(tmp_path), "Board.canvas", suggest=5).candidates == ()
244
+
245
+
246
+ class TestConstants:
247
+ def test_the_calibrated_band_is_published(self):
248
+ # Both values were fixed by the annotated baseline, not chosen by taste: the cap at 12
249
+ # because [[Priya Raghavan]] needs it (that token appears in 9 stems), the floor at 0.4
250
+ # because it is the highest value that keeps every useful hint.
251
+ assert SUGGEST_MAX_MATCHES == 12
252
+ assert SUGGEST_MIN_COVERAGE == 0.4
253
+
254
+ def test_exported_from_the_top_level(self):
255
+ import graphmark
256
+
257
+ assert graphmark.SUGGEST_MAX_MATCHES == SUGGEST_MAX_MATCHES
258
+ assert graphmark.SUGGEST_MIN_COVERAGE == SUGGEST_MIN_COVERAGE
259
+
260
+
261
+ class TestBuildIsUnaffected:
262
+ def test_suggestions_never_change_the_graph(self, tmp_path):
263
+ _write(tmp_path, "hub.md", "[[Jordan]] and [[b]]\n")
264
+ _write(tmp_path, "Jordan Ellis.md")
265
+ _write(tmp_path, "b.md")
266
+ graph = _build(tmp_path)
267
+ assert graph.unresolved == {"hub.md": ["Jordan"]}
268
+ assert graph.out_links["hub.md"] == {"b.md"}
269
+ # asking for suggestions is a read, not a mutation
270
+ diagnose(graph, "Jordan", suggest=5)
271
+ assert graph.unresolved == {"hub.md": ["Jordan"]}
272
+ assert graph.out_links["hub.md"] == {"b.md"}
273
+
274
+
275
+ @pytest.mark.parametrize("bad", [-1, -5])
276
+ def test_negative_suggest_is_a_usage_error(tmp_path, bad):
277
+ _write(tmp_path, "a.md")
278
+ with pytest.raises(ValueError, match="suggest"):
279
+ diagnose(_build(tmp_path), "Nowhere", suggest=bad)
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