graphmark 0.4.0__tar.gz → 0.6.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 (94) hide show
  1. {graphmark-0.4.0 → graphmark-0.6.0}/CHANGELOG.md +129 -0
  2. {graphmark-0.4.0 → graphmark-0.6.0}/PKG-INFO +62 -1
  3. {graphmark-0.4.0 → graphmark-0.6.0}/README.md +61 -0
  4. {graphmark-0.4.0 → graphmark-0.6.0}/docs/ROADMAP.md +1 -1
  5. {graphmark-0.4.0 → graphmark-0.6.0}/pyproject.toml +1 -1
  6. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/__init__.py +8 -0
  7. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/config.py +4 -0
  8. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/graph.py +161 -7
  9. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/parse.py +32 -1
  10. graphmark-0.6.0/tests/fixtures/suggest/README.md +62 -0
  11. graphmark-0.6.0/tests/test_aliases.py +256 -0
  12. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_diagnose.py +4 -4
  13. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_graph_catalogs.py +2 -2
  14. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_parse.py +91 -0
  15. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_public_api.py +4 -0
  16. graphmark-0.6.0/tests/test_suggestions.py +279 -0
  17. {graphmark-0.4.0 → graphmark-0.6.0}/.gitignore +0 -0
  18. {graphmark-0.4.0 → graphmark-0.6.0}/LICENSE +0 -0
  19. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/check.py +0 -0
  20. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/cli.py +0 -0
  21. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/dismiss.py +0 -0
  22. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/export.py +0 -0
  23. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/interfaces.py +0 -0
  24. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/metrics.py +0 -0
  25. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/model.py +0 -0
  26. {graphmark-0.4.0 → graphmark-0.6.0}/src/graphmark/py.typed +0 -0
  27. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/README.md +0 -0
  28. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/config.toml +0 -0
  29. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/expected.json +0 -0
  30. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  31. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  32. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  33. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  34. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  35. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  36. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  37. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  38. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  39. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  40. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/expected.json +0 -0
  41. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  42. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  43. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  44. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  45. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/config.toml +0 -0
  46. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected.json +0 -0
  47. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  48. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar.json +0 -0
  49. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  50. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  51. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  52. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  53. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  54. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  55. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  56. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/config.toml +0 -0
  57. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/expected.json +0 -0
  58. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  59. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  60. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  61. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  62. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/config.toml +0 -0
  63. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/expected.json +0 -0
  64. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/a.md +0 -0
  65. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/selflink/vault/b.md +0 -0
  66. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/config.toml +0 -0
  67. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/expected.json +0 -0
  68. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  69. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  70. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  71. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  72. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  73. {graphmark-0.4.0 → graphmark-0.6.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  74. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_build_filters.py +0 -0
  75. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_check.py +0 -0
  76. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_check_policy.py +0 -0
  77. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_cli.py +0 -0
  78. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_config.py +0 -0
  79. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_degenerate.py +0 -0
  80. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_dismiss.py +0 -0
  81. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_export.py +0 -0
  82. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps.py +0 -0
  83. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps_banding.py +0 -0
  84. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps_oog.py +0 -0
  85. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_gaps_params.py +0 -0
  86. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_graph.py +0 -0
  87. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_metrics.py +0 -0
  88. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_model.py +0 -0
  89. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_packaging.py +0 -0
  90. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_pagerank.py +0 -0
  91. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_scoped.py +0 -0
  92. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_selflink.py +0 -0
  93. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_smoke.py +0 -0
  94. {graphmark-0.4.0 → graphmark-0.6.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,135 @@
1
1
  # CHANGELOG
2
2
 
3
3
 
4
+ ## v0.6.0 (2026-07-25)
5
+
6
+ ### Bug Fixes
7
+
8
+ - **parse**: Parse block-style frontmatter lists (#118)
9
+ ([#120](https://github.com/cdcoonce/graphmark/pull/120),
10
+ [`723c517`](https://github.com/cdcoonce/graphmark/commit/723c517d30732286fd0e6005996fb36d577042f5))
11
+
12
+ * chore(release): 0.3.4 [skip ci]
13
+
14
+ * chore(release): 0.4.0 [skip ci]
15
+
16
+ * chore(release): 0.5.0 [skip ci]
17
+
18
+ * fix(parse): parse block-style frontmatter lists (#118)
19
+
20
+ _parse_frontmatter handled scalars, quoted strings and inline lists, but a block list's item lines
21
+ contain no colon, so the loop skipped them and the bare `key:` line stored "". Every block-style
22
+ property in a real vault — aliases:, tags: — silently became an empty string instead of a list.
23
+
24
+ Block form is what Obsidian's own Properties UI writes, so this is the common case, not the edge
25
+ case, and Document.frontmatter is public: a consumer reading doc.frontmatter["tags"] got "" rather
26
+ than a list. Not an error, just quietly wrong data.
27
+
28
+ Item lines are matched on the leading dash BEFORE the key/value split, so an item containing a colon
29
+ ("- Note: A Subtitle") stays an item. A block closes at the next key rather than merely pausing —
30
+ without that, a stray indented line later in a malformed note would silently grow the earlier
31
+ list, which is misattribution rather than the promised fail-soft drop. That distinction survived
32
+ the first four mutations and now has its own test.
33
+
34
+ A `key:` with no items still reads as "" — an empty value, not an empty list — so nothing that
35
+ parsed before changes shape.
36
+
37
+ Parity: fixture notes use inline or scalar frontmatter only, so no expected.json can move. A test
38
+ asserts that property directly rather than leaving it as a claim in the commit message.
39
+
40
+ Still a targeted scan, not a YAML dependency: this runs over every note in a vault and a note
41
+ someone is mid-edit must not take the graph down.
42
+
43
+ ---------
44
+
45
+ Co-authored-by: semantic-release <semantic-release>
46
+
47
+ ### Features
48
+
49
+ - **graph**: Resolve frontmatter aliases in-package (#119)
50
+ ([#121](https://github.com/cdcoonce/graphmark/pull/121),
51
+ [`05bb6ce`](https://github.com/cdcoonce/graphmark/commit/05bb6ced17656a3fb3d1d1798068763b26b68588))
52
+
53
+ graphmark never read frontmatter during resolution, so every link written against an Obsidian
54
+ `aliases:` entry was reported broken. Measured on a real 521-note vault: stock graphmark reported
55
+ 23 broken links where the actual count was 0, and dropped 17 edges with them. A 100%
56
+ false-positive rate in max_unresolved_links — the flagship threshold of graphmark check, the one
57
+ product this repo offers outward. Larger than the four false-positive classes already fixed
58
+ (#98/#101/#104/#107) combined.
59
+
60
+ This reverses a documented decision. the-vault's AliasResolver says "naming policy belongs to the
61
+ seam, so the fallback lives here rather than in the graph algorithm". That holds for idiosyncratic
62
+ conventions; aliases: is core Obsidian, and this package claims to work on any Obsidian-family
63
+ vault. Human-triaged: move it in-package, on by default, since a default that silently produces
64
+ wrong answers is not fixed by a knob nobody knows to flip. resolve_aliases = false restores the
65
+ old behavior.
66
+
67
+ That AliasResolver is the oracle, and the live-vault differential is exact: stock graphmark now
68
+ reports the same broken-link set as the reference implementation, note for note — 0 and 0, from a
69
+ 51-entry alias map.
70
+
71
+ Its conservative rules are preserved. An alias colliding with any real note name is dropped at index
72
+ time, not merely outranked, so a note's own title can never be hijacked and an already-ambiguous
73
+ basename can't be rescued into resolving. An alias claimed by two notes resolves to nothing. A
74
+ slash-bearing alias is refused when the map is built, because normalization turns "/" into a space
75
+ and would otherwise make it reachable from a slash-free display — the lookup-side guard alone does
76
+ not cover that, which mutation testing caught.
77
+
78
+ One honest note: rule 1 (resolver before alias) is structurally unreachable given the other two
79
+ rules — no display can match both a catalog key and an alias key — so mutating the order leaves
80
+ the suite green. It is kept as insurance against a future relaxation and is documented as untested
81
+ rather than dressed up as covered.
82
+
83
+ Parity: this changes what resolves, so it is the most parity-sensitive change since extraction. It
84
+ is safe only because no frozen fixture declares aliases; a test asserts that property directly so
85
+ it cannot go stale.
86
+
87
+
88
+ ## v0.5.0 (2026-07-25)
89
+
90
+ ### Features
91
+
92
+ - **graph**: Add calibrated near-miss suggestions (#112)
93
+ ([#116](https://github.com/cdcoonce/graphmark/pull/116),
94
+ [`db873b1`](https://github.com/cdcoonce/graphmark/commit/db873b1009bbef599e9eef035720306b62a548b8))
95
+
96
+ Track E slice 3, and the last thing keeping a consumer's parallel resolver alive. diagnose(graph,
97
+ display, suggest=k) fills candidates with near-miss notes for a `missing` verdict only — every
98
+ other reason already carries the rel_paths in play — so the default of 0 leaves the check gate's
99
+ hot path untouched.
100
+
101
+ The rule was calibrated, not invented. The prior art (bidirectional substring containment over
102
+ stems) was frozen over a real 521-note vault's broken links, giving 53 distinct displays, each
103
+ annotated by a human as useful / useless / missing / correct-none. Only then was an algorithm
104
+ chosen, and it is justified against that set: 27/27 useful kept — the non-negotiable — 7 of 8
105
+ useless dropped, 4 of 5 missing found, zero new false suggestions. Method and measured result in
106
+ tests/fixtures/suggest/README.md; the vault is private, so every shape is reproduced as a named
107
+ test with invented names rather than committing the rows.
108
+
109
+ Matching is directional, which is what separates a suggestion from a wrong answer. A display inside
110
+ a candidate is an abbreviation and always offered ([[Jordan]] -> Jordan Ellis). A candidate inside
111
+ a display is offered only above SUGGEST_MIN_COVERAGE: dropping a "-reference" suffix is the
112
+ answer, matching one word out of five is a real note that is NOT the target, and that shape is
113
+ what made the old hints untrustworthy. Partial overlap in neither direction is rejected — it
114
+ carried no useful hint anywhere in the baseline, and rejecting it is what holds the
115
+ false-suggestion rate at zero.
116
+
117
+ Two annotations were corrected mid-calibration and both changed the design: [[Work Tasks]] and
118
+ [[Personal Index]] looked useless only because the old rule printed bare stems, rendering four
119
+ distinct Index.md files as "Index, Index, Index, Index". Both are correct answers. Suggestions
120
+ therefore return rel_paths, and `index` is deliberately NOT a generic stem.
121
+
122
+ Also scrubs real colleague names out of the package. They had been used as doc/test examples and one
123
+ shipped in a docstring in 0.4.0; all replaced with invented placeholders. Published history still
124
+ carries them.
125
+
126
+ Both constants are pinned by the baseline: the cap is the lowest value that keeps every useful
127
+ suggestion, the floor the highest. Teeth-checked by mutation — disabling suppression fails 1,
128
+ dropping the coverage floor fails 3, dropping folder-keying fails 2, suggesting on every reason
129
+ fails 3, and dropping the digit filter initially survived until a ranking test was added to cover
130
+ it.
131
+
132
+
4
133
  ## v0.4.0 (2026-07-25)
5
134
 
6
135
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.4.0
3
+ Version: 0.6.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
@@ -185,6 +185,7 @@ graphmark.to_dot(graph) # Graphviz DOT
185
185
  graph.unresolved # {rel_path: [broken link displays]}
186
186
  graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
187
187
  graph.out_of_scope # same, for markdown outside the configured scope
188
+ graph.aliases # {normalized alias: rel_path} — frontmatter aliases
188
189
  ```
189
190
 
190
191
  `catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
@@ -223,10 +224,70 @@ Alias, anchor and `.md` forms all diagnose identically to the bare form.
223
224
  contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
224
225
  `missing`, and the edges are exactly the `resolved` non-self targets.
225
226
 
227
+ ### Near-miss suggestions
228
+
229
+ Pass `suggest=k` to turn a `missing` verdict into an actionable one. Suggestions are computed only
230
+ for `missing` — every other reason already carries the rel_paths in play — so the default of `0`
231
+ costs nothing on the `check` hot path.
232
+
233
+ ```python
234
+ graphmark.diagnose(graph, "Jordan", suggest=5).candidates
235
+ # ('people/Jordan Ellis.md',)
236
+ ```
237
+
238
+ Matching is **directional**, which is what separates a suggestion from a wrong answer:
239
+
240
+ - a display inside a candidate name is an abbreviation — `[[Jordan]]` → `Jordan Ellis`, or
241
+ `[[Mood Tracker]]` → `2026-04-11-mood-tracker` (pure-digit tokens are filing metadata, not
242
+ content);
243
+ - a candidate inside a display is offered only when it covers at least `SUGGEST_MIN_COVERAGE` of it
244
+ — `[[fable-prompt-technique-reference]]` → `fable-prompt-technique` is the answer, while
245
+ `[[Dagster PJM InSchedules]]` → `Dagster` is a real note that is _not_ the target;
246
+ - partial overlap in neither direction is rejected;
247
+ - a display matching more than `SUGGEST_MAX_MATCHES` notes names a topic, not a typo, and gets
248
+ nothing at all.
249
+
250
+ Notes whose stem is generic (`SKILL.md`, `README.md`) are matched on their **parent folder**, where
251
+ their name actually lives.
252
+
253
+ Both constants were calibrated against a human-annotated baseline of a real vault's broken links
254
+ rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
255
+ for the method and the measured result.
256
+
257
+ ## Frontmatter aliases
258
+
259
+ Obsidian's `aliases:` property declares additional real names for a note, so links written against
260
+ one resolve like any other link. Block, inline and scalar forms all work:
261
+
262
+ ```yaml
263
+ ---
264
+ aliases:
265
+ - Mood Tracker
266
+ - mood-tracker
267
+ ---
268
+ ```
269
+
270
+ `[[Mood Tracker]]` now resolves to `2026-04-11-mood-tracker.md` and produces a real edge. On a
271
+ 521-note vault this was the difference between **23 reported broken links and 0** — every one of the
272
+ 23 a false positive, plus 17 edges that were silently missing.
273
+
274
+ Three rules keep it conservative:
275
+
276
+ - **A real note name always wins.** An alias can never hijack a link to a note that actually bears
277
+ that name.
278
+ - **An alias that collides with any real note name is dropped entirely** — not merely outranked, so
279
+ an already-ambiguous basename can't be rescued into resolving by someone's alias.
280
+ - **An alias claimed by two notes resolves to nothing**, the same refusal applied to colliding
281
+ basenames.
282
+
283
+ Set `resolve_aliases = false` for strict basename-only resolution. `graph.aliases` exposes the
284
+ resolved map.
285
+
226
286
  ## Configuration
227
287
 
228
288
  ```toml
229
289
  root = "." # required unless --root is passed
290
+ resolve_aliases = true # resolve frontmatter aliases: (default true)
230
291
  scoped_folders = ["brain", "work"] # limit the vault to these top-level folders
231
292
  excluded_dirs = [".git", "templates"] # skip notes under these directories
232
293
  rules_files = ["CLAUDE.md", "CLAUDE.local.md"] # agent-rules files, not vault content
@@ -158,6 +158,7 @@ graphmark.to_dot(graph) # Graphviz DOT
158
158
  graph.unresolved # {rel_path: [broken link displays]}
159
159
  graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
160
160
  graph.out_of_scope # same, for markdown outside the configured scope
161
+ graph.aliases # {normalized alias: rel_path} — frontmatter aliases
161
162
  ```
162
163
 
163
164
  `catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
@@ -196,10 +197,70 @@ Alias, anchor and `.md` forms all diagnose identically to the bare form.
196
197
  contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
197
198
  `missing`, and the edges are exactly the `resolved` non-self targets.
198
199
 
200
+ ### Near-miss suggestions
201
+
202
+ Pass `suggest=k` to turn a `missing` verdict into an actionable one. Suggestions are computed only
203
+ for `missing` — every other reason already carries the rel_paths in play — so the default of `0`
204
+ costs nothing on the `check` hot path.
205
+
206
+ ```python
207
+ graphmark.diagnose(graph, "Jordan", suggest=5).candidates
208
+ # ('people/Jordan Ellis.md',)
209
+ ```
210
+
211
+ Matching is **directional**, which is what separates a suggestion from a wrong answer:
212
+
213
+ - a display inside a candidate name is an abbreviation — `[[Jordan]]` → `Jordan Ellis`, or
214
+ `[[Mood Tracker]]` → `2026-04-11-mood-tracker` (pure-digit tokens are filing metadata, not
215
+ content);
216
+ - a candidate inside a display is offered only when it covers at least `SUGGEST_MIN_COVERAGE` of it
217
+ — `[[fable-prompt-technique-reference]]` → `fable-prompt-technique` is the answer, while
218
+ `[[Dagster PJM InSchedules]]` → `Dagster` is a real note that is _not_ the target;
219
+ - partial overlap in neither direction is rejected;
220
+ - a display matching more than `SUGGEST_MAX_MATCHES` notes names a topic, not a typo, and gets
221
+ nothing at all.
222
+
223
+ Notes whose stem is generic (`SKILL.md`, `README.md`) are matched on their **parent folder**, where
224
+ their name actually lives.
225
+
226
+ Both constants were calibrated against a human-annotated baseline of a real vault's broken links
227
+ rather than chosen by taste — see [`tests/fixtures/suggest/README.md`](tests/fixtures/suggest/README.md)
228
+ for the method and the measured result.
229
+
230
+ ## Frontmatter aliases
231
+
232
+ Obsidian's `aliases:` property declares additional real names for a note, so links written against
233
+ one resolve like any other link. Block, inline and scalar forms all work:
234
+
235
+ ```yaml
236
+ ---
237
+ aliases:
238
+ - Mood Tracker
239
+ - mood-tracker
240
+ ---
241
+ ```
242
+
243
+ `[[Mood Tracker]]` now resolves to `2026-04-11-mood-tracker.md` and produces a real edge. On a
244
+ 521-note vault this was the difference between **23 reported broken links and 0** — every one of the
245
+ 23 a false positive, plus 17 edges that were silently missing.
246
+
247
+ Three rules keep it conservative:
248
+
249
+ - **A real note name always wins.** An alias can never hijack a link to a note that actually bears
250
+ that name.
251
+ - **An alias that collides with any real note name is dropped entirely** — not merely outranked, so
252
+ an already-ambiguous basename can't be rescued into resolving by someone's alias.
253
+ - **An alias claimed by two notes resolves to nothing**, the same refusal applied to colliding
254
+ basenames.
255
+
256
+ Set `resolve_aliases = false` for strict basename-only resolution. `graph.aliases` exposes the
257
+ resolved map.
258
+
199
259
  ## Configuration
200
260
 
201
261
  ```toml
202
262
  root = "." # required unless --root is passed
263
+ resolve_aliases = true # resolve frontmatter aliases: (default true)
203
264
  scoped_folders = ["brain", "work"] # limit the vault to these top-level folders
204
265
  excluded_dirs = [".git", "templates"] # skip notes under these directories
205
266
  rules_files = ["CLAUDE.md", "CLAUDE.local.md"] # agent-rules files, not vault content
@@ -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.6.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,16 @@ 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,
36
+ build_aliases,
34
37
  build_catalog,
35
38
  candidates_for,
36
39
  diagnose,
40
+ suggest_notes,
37
41
  )
38
42
  from graphmark.interfaces import LinkExtractor, Resolver, Similarity
39
43
  from graphmark.metrics import (
@@ -96,6 +100,8 @@ __all__ = [
96
100
  "Document",
97
101
  "LinkExtractor",
98
102
  "DIAGNOSIS_REASONS",
103
+ "SUGGEST_MAX_MATCHES",
104
+ "SUGGEST_MIN_COVERAGE",
99
105
  "LinkDiagnosis",
100
106
  "NormalizeResolver",
101
107
  "Resolver",
@@ -107,9 +113,11 @@ __all__ = [
107
113
  "active_dismissed_sigs",
108
114
  "bridges",
109
115
  "build",
116
+ "build_aliases",
110
117
  "build_catalog",
111
118
  "candidates_for",
112
119
  "diagnose",
120
+ "suggest_notes",
113
121
  "clusters",
114
122
  "gaps",
115
123
  "hubs",
@@ -44,6 +44,9 @@ class VaultConfig:
44
44
  excluded_dirs: list[str] = field(default_factory=list)
45
45
  rules_files: list[str] = field(default_factory=lambda: ["CLAUDE.md", "CLAUDE.local.md"])
46
46
  transient_prefixes: tuple[str, ...] = ()
47
+ # Obsidian's `aliases:` property names a note, so links written against one resolve by
48
+ # default. Set False for strict basename-only resolution.
49
+ resolve_aliases: bool = True
47
50
  check: CheckPolicy = field(default_factory=CheckPolicy)
48
51
 
49
52
  def __post_init__(self) -> None:
@@ -118,5 +121,6 @@ def load_config(path: str | Path, *, root_override: str | Path | None = None) ->
118
121
  excluded_dirs=data.get("excluded_dirs", []),
119
122
  rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
120
123
  transient_prefixes=tuple(data.get("transient_prefixes", [])),
124
+ resolve_aliases=bool(data.get("resolve_aliases", True)),
121
125
  check=_parse_check(data, path),
122
126
  )
@@ -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
@@ -76,6 +76,43 @@ def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
76
76
  return list(catalog.get(_normalize(target), ()))
77
77
 
78
78
 
79
+ def build_aliases(docs: list[Document], catalog: dict[str, list[str]]) -> dict[str, str]:
80
+ """Map normalized alias → rel_path, for aliases that unambiguously name one note.
81
+
82
+ Obsidian's ``aliases:`` property declares additional real names for a note, so a link written
83
+ against one is not a broken link. Two rules keep this conservative, and both matter:
84
+
85
+ * **An alias that collides with any real note name is dropped entirely** — not merely
86
+ outranked. A note's own title must never be hijackable by someone else's alias, and an
87
+ already-ambiguous basename must not be rescued into resolving by a third note's alias.
88
+ * **An alias claimed by two or more notes resolves to nothing** — the same refusal graphmark
89
+ already applies to colliding basenames. Ambiguity stays ambiguous.
90
+
91
+ Alias keys go through ``_normalize``, the function that builds catalog keys, so an alias and a
92
+ note name can never drift apart on case or punctuation.
93
+ """
94
+ claims: dict[str, set[str]] = {}
95
+ for doc in docs:
96
+ raw = doc.frontmatter.get("aliases")
97
+ # A scalar is one alias; a list is many; anything else (a number, a mapping, a missing
98
+ # key) yields none rather than raising — a note someone is mid-edit must not break a build.
99
+ if isinstance(raw, str):
100
+ values = [raw]
101
+ elif isinstance(raw, list):
102
+ values = [v for v in raw if isinstance(v, str)]
103
+ else:
104
+ continue
105
+ for alias in values:
106
+ # A path is not a name: "[[folder/note]]" is resolved by path suffix, never by alias.
107
+ if "/" in alias:
108
+ continue
109
+ key = _normalize(alias)
110
+ if not key or key in catalog:
111
+ continue
112
+ claims.setdefault(key, set()).add(doc.rel_path)
113
+ return {key: paths.pop() for key, paths in claims.items() if len(paths) == 1}
114
+
115
+
79
116
  def _is_intra_note_reference(display: str) -> bool:
80
117
  """True for a link that targets no note, e.g. ``[[#Heading]]`` or ``[[#^block]]``.
81
118
 
@@ -144,6 +181,90 @@ class NormalizeResolver:
144
181
  return paths[0]
145
182
 
146
183
 
184
+ #: A display matching more than this many notes names a *topic*, not a mistyped note, and gets no
185
+ #: suggestions at all. Calibrated, not chosen: on the reference vault ``[[AMRT]]`` matched 47 notes
186
+ #: while ``[[Priya Raghavan]]`` needs a cap of at least 12 to survive (that token appears in 9 note
187
+ #: stems), so 12 is the lowest value that keeps every human-confirmed useful suggestion.
188
+ SUGGEST_MAX_MATCHES = 12
189
+
190
+ #: When a candidate's name sits *inside* a longer display, the fraction of the display it must
191
+ #: account for. ``[[fable-prompt-technique-reference]]`` → ``fable-prompt-technique`` covers 3 of 4
192
+ #: tokens and is the answer; ``[[Dagster PJM InSchedules]]`` → ``Dagster`` covers 1 of 3 and is a
193
+ #: real note that is not the target — the shape that made the old suggestions untrustworthy. 0.4 is
194
+ #: the highest floor that keeps every useful suggestion in the annotated baseline.
195
+ SUGGEST_MIN_COVERAGE = 0.4
196
+
197
+ #: Note stems that name nothing — the note's identity lives in its parent folder instead. Kept
198
+ #: deliberately short: ``index`` is NOT here, because a vault's ``personal/Index.md`` is a real,
199
+ #: linkable note and re-keying it onto its folder would lose the actual answer.
200
+ GENERIC_STEMS = frozenset({"skill", "readme"})
201
+
202
+
203
+ def _content_tokens(text: str) -> frozenset[str]:
204
+ """Normalized tokens with pure-digit runs dropped.
205
+
206
+ Date prefixes are filing metadata, not content: ``2026-04-11-mood-tracker`` and
207
+ ``Mood Tracker`` are the same note to a human, and the dated twin is the single most common
208
+ near-miss shape in a journal-style vault.
209
+ """
210
+ return frozenset(t for t in _normalize(text).split() if not t.isdigit())
211
+
212
+
213
+ def _suggestion_keys(catalog: dict[str, list[str]]) -> list[tuple[str, frozenset[str]]]:
214
+ """(rel_path, name tokens) for every note, keyed by folder where the stem is generic."""
215
+ keys: list[tuple[str, frozenset[str]]] = []
216
+ for paths in catalog.values():
217
+ for rel in paths:
218
+ path = Path(rel)
219
+ name = path.parent.name if path.stem.lower() in GENERIC_STEMS else path.stem
220
+ tokens = _content_tokens(name)
221
+ if tokens:
222
+ keys.append((rel, tokens))
223
+ return keys
224
+
225
+
226
+ def suggest_notes(display: str, catalog: dict[str, list[str]], k: int) -> tuple[str, ...]:
227
+ """Up to ``k`` notes whose names are near-misses for ``display``, best first.
228
+
229
+ Matching is **directional**, which is what separates a suggestion from noise:
230
+
231
+ * the display's tokens inside a candidate's — the display abbreviates a longer title
232
+ (``[[Jordan]]`` → ``Jordan Ellis``). Always offered; a short display naturally names a
233
+ longer note.
234
+ * a candidate's tokens inside the display's — offered only when the candidate accounts for at
235
+ least ``SUGGEST_MIN_COVERAGE`` of the display. Dropping a suffix is a real answer; matching
236
+ one word out of five is a wrong answer that invites a wrong repair.
237
+ * neither — rejected. Partial overlap produced no useful suggestion anywhere in the annotated
238
+ baseline, and rejecting it is what holds the false-suggestion rate at zero.
239
+
240
+ Ranked by how much of the longer name the match accounts for, ties broken by rel_path so the
241
+ output is byte-stable.
242
+ """
243
+ display_tokens = _content_tokens(display)
244
+ if not display_tokens or k <= 0:
245
+ return ()
246
+
247
+ scored: list[tuple[float, str]] = []
248
+ for rel, name_tokens in _suggestion_keys(catalog):
249
+ if display_tokens <= name_tokens:
250
+ score = len(display_tokens) / len(name_tokens)
251
+ elif name_tokens <= display_tokens:
252
+ score = len(name_tokens) / len(display_tokens)
253
+ if score < SUGGEST_MIN_COVERAGE:
254
+ continue
255
+ else:
256
+ continue
257
+ scored.append((score, rel))
258
+
259
+ # A display that matches half the vault is a topic. Offering its "best" dozen would be
260
+ # confident nonsense, so it gets nothing.
261
+ if len(scored) > SUGGEST_MAX_MATCHES:
262
+ return ()
263
+
264
+ scored.sort(key=lambda pair: (-pair[0], pair[1]))
265
+ return tuple(rel for _, rel in scored[:k])
266
+
267
+
147
268
  #: Every value ``LinkDiagnosis.reason`` can take, in the order they are decided. A consumer
148
269
  #: switches on these strings, so the set is part of the public contract.
149
270
  DIAGNOSIS_REASONS = (
@@ -167,10 +288,11 @@ class LinkDiagnosis:
167
288
 
168
289
  ``target`` is the resolved rel_path, set only when ``reason == "resolved"``; the caller recovers
169
290
  the note's canonical title from its stem, which is what makes a case repair like
170
- ``[[ethan courtman]]`` → ``[[Ethan Courtman]]`` possible.
291
+ ``[[jordan ellis]]`` → ``[[Jordan Ellis]]`` possible.
171
292
 
172
293
  ``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.
294
+ unindexed markdown for ``out-of-scope-note``, and — only when ``diagnose`` is asked for them
295
+ the near-miss suggestions for ``missing``. Empty for every other reason.
174
296
  """
175
297
 
176
298
  display: str
@@ -184,6 +306,7 @@ def _diagnose(
184
306
  catalog: dict[str, list[str]],
185
307
  out_of_scope: dict[str, list[str]],
186
308
  resolver: Resolver,
309
+ aliases: dict[str, str] | None = None,
187
310
  ) -> LinkDiagnosis:
188
311
  """Classify one display against already-built resolution state.
189
312
 
@@ -199,6 +322,22 @@ def _diagnose(
199
322
  if target is not None:
200
323
  return LinkDiagnosis(display=display, target=target, reason="resolved")
201
324
 
325
+ # The resolver runs first, so a real note named X always beats an alias X — otherwise renaming
326
+ # a note could silently hijack live links. An alias hit is a genuine resolution, not a
327
+ # consolation: it names the note as surely as the filename does.
328
+ #
329
+ # Given the rules build_aliases already enforces, this ordering is defense in depth rather
330
+ # than load-bearing: an alias key can never collide with a catalog key (dropped at index
331
+ # time), and a path-qualified display is excluded from alias lookup below, so no display can
332
+ # match both paths. Mutating the order leaves the suite green, which is expected — the
333
+ # ordering is insurance against a future relaxation of those rules, not the mechanism.
334
+ if aliases:
335
+ stripped = _strip_display(display)
336
+ if stripped and "/" not in stripped:
337
+ aliased = aliases.get(_normalize(stripped))
338
+ if aliased is not None:
339
+ return LinkDiagnosis(display=display, target=aliased, reason="resolved")
340
+
202
341
  # The resolver declined. Whether it declined because nothing matched or because too much did
203
342
  # is the distinction consumers need, and only the catalog can answer it.
204
343
  collisions = candidates_for(display, catalog)
@@ -219,14 +358,24 @@ def _diagnose(
219
358
  return LinkDiagnosis(display=display, reason="missing")
220
359
 
221
360
 
222
- def diagnose(graph: VaultGraph, display: str) -> LinkDiagnosis:
361
+ def diagnose(graph: VaultGraph, display: str, *, suggest: int = 0) -> LinkDiagnosis:
223
362
  """Explain what ``display`` names in ``graph`` — see :class:`LinkDiagnosis`.
224
363
 
225
364
  Uses the resolver the graph was built with, so a diagnosis can never contradict the graph it
226
365
  describes. A directly constructed ``VaultGraph`` with no resolver falls back to
227
366
  ``NormalizeResolver``.
367
+
368
+ ``suggest=k`` fills ``candidates`` with up to k near-miss notes — but only for a ``missing``
369
+ verdict, since every other reason either already carries the rel_paths in play or has nothing
370
+ to look for. The default of 0 does no extra work at all, keeping the vault-health gate's hot
371
+ path free of it.
228
372
  """
229
- return _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver)
373
+ if suggest < 0:
374
+ raise ValueError(f"suggest must be >= 0, got {suggest}")
375
+ diagnosis = _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver, graph.aliases)
376
+ if suggest and diagnosis.reason == "missing":
377
+ return replace(diagnosis, candidates=suggest_notes(display, graph.catalog, suggest))
378
+ return diagnosis
230
379
 
231
380
 
232
381
  class VaultGraph:
@@ -253,6 +402,7 @@ class VaultGraph:
253
402
  catalog: dict[str, list[str]] | None = None,
254
403
  out_of_scope: dict[str, list[str]] | None = None,
255
404
  resolver: Resolver | None = None,
405
+ aliases: dict[str, str] | None = None,
256
406
  ) -> None:
257
407
  self.nodes = nodes
258
408
  self.out_links = out_links
@@ -260,6 +410,7 @@ class VaultGraph:
260
410
  self.unresolved = unresolved if unresolved is not None else {}
261
411
  self.catalog = catalog if catalog is not None else {}
262
412
  self.out_of_scope = out_of_scope if out_of_scope is not None else {}
413
+ self.aliases = aliases if aliases is not None else {}
263
414
  # Retained so diagnose() answers with the same resolver that built the graph; a
264
415
  # pluggable Resolver that disagreed with the graph it describes would be worse than none.
265
416
  self.resolver: Resolver = resolver if resolver is not None else NormalizeResolver()
@@ -303,6 +454,7 @@ class VaultGraph:
303
454
  docs = [parse_document(p, root) for p in md_files]
304
455
  nodes = {doc.rel_path: doc for doc in docs}
305
456
  catalog = build_catalog(docs)
457
+ aliases = build_aliases(docs, catalog) if config.resolve_aliases else {}
306
458
 
307
459
  out_links: dict[str, set[str]] = {rel: set() for rel in nodes}
308
460
  back_links: dict[str, set[str]] = {rel: set() for rel in nodes}
@@ -316,7 +468,7 @@ class VaultGraph:
316
468
  broken = {"ambiguous", "missing"}
317
469
  for doc in docs:
318
470
  for display in extractor.extract(doc.text):
319
- d = _diagnose(display, catalog, out_of_scope, resolver)
471
+ d = _diagnose(display, catalog, out_of_scope, resolver, aliases)
320
472
  if d.reason in broken:
321
473
  unresolved.setdefault(doc.rel_path, []).append(display)
322
474
  elif d.target is not None and d.target != doc.rel_path:
@@ -326,4 +478,6 @@ class VaultGraph:
326
478
  for dst in targets:
327
479
  back_links[dst].add(src)
328
480
 
329
- return cls(nodes, out_links, back_links, unresolved, catalog, out_of_scope, resolver)
481
+ return cls(
482
+ nodes, out_links, back_links, unresolved, catalog, out_of_scope, resolver, aliases
483
+ )