graphmark 0.3.4__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 (94) hide show
  1. {graphmark-0.3.4 → graphmark-0.5.0}/CHANGELOG.md +123 -0
  2. {graphmark-0.3.4 → graphmark-0.5.0}/PKG-INFO +69 -1
  3. {graphmark-0.3.4 → graphmark-0.5.0}/README.md +68 -0
  4. {graphmark-0.3.4 → graphmark-0.5.0}/docs/ROADMAP.md +53 -30
  5. {graphmark-0.3.4 → graphmark-0.5.0}/pyproject.toml +1 -1
  6. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/__init__.py +19 -1
  7. graphmark-0.5.0/src/graphmark/graph.py +424 -0
  8. graphmark-0.5.0/tests/fixtures/suggest/README.md +62 -0
  9. graphmark-0.5.0/tests/test_diagnose.py +228 -0
  10. graphmark-0.5.0/tests/test_graph_catalogs.py +152 -0
  11. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_public_api.py +8 -0
  12. graphmark-0.5.0/tests/test_suggestions.py +279 -0
  13. graphmark-0.3.4/src/graphmark/graph.py +0 -221
  14. {graphmark-0.3.4 → graphmark-0.5.0}/.gitignore +0 -0
  15. {graphmark-0.3.4 → graphmark-0.5.0}/LICENSE +0 -0
  16. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/check.py +0 -0
  17. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/cli.py +0 -0
  18. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/config.py +0 -0
  19. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/dismiss.py +0 -0
  20. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/export.py +0 -0
  21. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/interfaces.py +0 -0
  22. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/metrics.py +0 -0
  23. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/model.py +0 -0
  24. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/parse.py +0 -0
  25. {graphmark-0.3.4 → graphmark-0.5.0}/src/graphmark/py.typed +0 -0
  26. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/README.md +0 -0
  27. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/config.toml +0 -0
  28. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/expected.json +0 -0
  29. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
  30. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
  31. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
  32. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
  33. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
  34. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
  35. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
  36. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
  37. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
  38. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
  39. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/dismiss/expected.json +0 -0
  40. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
  41. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
  42. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
  43. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
  44. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/config.toml +0 -0
  45. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/expected.json +0 -0
  46. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/expected_oog.json +0 -0
  47. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/similar.json +0 -0
  48. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/similar_oog.json +0 -0
  49. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
  50. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
  51. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
  52. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
  53. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
  54. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
  55. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/scoped/config.toml +0 -0
  56. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/scoped/expected.json +0 -0
  57. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
  58. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
  59. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
  60. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
  61. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/selflink/config.toml +0 -0
  62. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/selflink/expected.json +0 -0
  63. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/selflink/vault/a.md +0 -0
  64. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/selflink/vault/b.md +0 -0
  65. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/config.toml +0 -0
  66. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/expected.json +0 -0
  67. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
  68. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
  69. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
  70. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
  71. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
  72. {graphmark-0.3.4 → graphmark-0.5.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
  73. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_build_filters.py +0 -0
  74. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_check.py +0 -0
  75. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_check_policy.py +0 -0
  76. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_cli.py +0 -0
  77. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_config.py +0 -0
  78. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_degenerate.py +0 -0
  79. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_dismiss.py +0 -0
  80. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_export.py +0 -0
  81. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_gaps.py +0 -0
  82. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_gaps_banding.py +0 -0
  83. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_gaps_oog.py +0 -0
  84. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_gaps_params.py +0 -0
  85. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_graph.py +0 -0
  86. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_metrics.py +0 -0
  87. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_model.py +0 -0
  88. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_packaging.py +0 -0
  89. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_pagerank.py +0 -0
  90. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_parse.py +0 -0
  91. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_scoped.py +0 -0
  92. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_selflink.py +0 -0
  93. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_smoke.py +0 -0
  94. {graphmark-0.3.4 → graphmark-0.5.0}/tests/test_unresolved.py +0 -0
@@ -1,6 +1,129 @@
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
+
49
+ ## v0.4.0 (2026-07-25)
50
+
51
+ ### Features
52
+
53
+ - **graph**: Add diagnose() — why a link failed, not just that it did (#111)
54
+ ([#114](https://github.com/cdcoonce/graphmark/pull/114),
55
+ [`1d124a3`](https://github.com/cdcoonce/graphmark/commit/1d124a3423fc5a6f3c8a0a084e8d15927dd1d828))
56
+
57
+ Track E slice 2. graph.unresolved conflates two problems needing opposite repairs: a link matching
58
+ NOTHING wants its target created or deleted, a link matching TOO MUCH wants disambiguating against
59
+ what it collided with. A consumer holding only unresolved cannot tell them apart, so it rebuilds
60
+ the resolver — the drift this track exists to end.
61
+
62
+ diagnose(graph, display) returns a frozen LinkDiagnosis: display echoed verbatim, target when
63
+ resolved, one of the six DIAGNOSIS_REASONS, and the rel_paths in play (the colliding notes for
64
+ ambiguous, the unindexed markdown for out-of-scope-note). The reason set is exported as a tuple in
65
+ decision order so a consumer can switch on it exhaustively.
66
+
67
+ build() now classifies through the same function rather than agreeing with it separately — two
68
+ classifiers in one package would recreate the drift inside the package. Two property tests pin
69
+ them together: unresolved is exactly the ambiguous+missing displays, edges are exactly the
70
+ resolved non-self targets.
71
+
72
+ candidates_for() consolidates the two-branch matching that _targets_out_of_scope_note had duplicated
73
+ from the resolver. It reports matches only; deciding uniqueness stays the Resolver's job, so the
74
+ two cannot disagree about which notes were in play. The graph now also retains the resolver it was
75
+ built with, so a diagnosis can never contradict its own graph.
76
+
77
+ Behavior is unchanged: on the live vault, 130 unresolved / 3582 edges both before and after, and all
78
+ six frozen fixtures byte-identical. What is new is the answer — 8 of those 130 are ambiguous
79
+ collisions ([[2026-W27-tasks]] existing under both personal/archive and work/archive,
80
+ [[RESOURCES]] under four learning folders), which the old surface reported as plain breaks.
81
+
82
+ - **graph**: Retain catalog + out_of_scope on the built graph (#110)
83
+ ([#113](https://github.com/cdcoonce/graphmark/pull/113),
84
+ [`7f7a91c`](https://github.com/cdcoonce/graphmark/commit/7f7a91cae961a06155f565384a429c92356868fb))
85
+
86
+ * docs(roadmap): close Tracks C and D, open Track E (#110-#112)
87
+
88
+ afk-driver --expand reads this file verbatim, so a stale one makes the fleet propose already-built
89
+ work and pollutes the telemetry dataset. It still targeted "v0.2.0 — every error path tested"
90
+ while the package is 0.3.4 with Tracks C and D both fully shipped.
91
+
92
+ Track C closed (every item landed at 0.2.0). Track D marked shipped at 0.3.0, with its real
93
+ remaining risk named: the credibility of its flagship number. Four false-positive classes have now
94
+ been found by triaging a live vault against max_unresolved_links — #98 anchors (19%), #101
95
+ non-markdown (10%), #104 the .md extension, #107 out-of-scope notes (7%) — so "keep truthing the
96
+ metric before shipping a GitHub Action that fails someone's build" is the direction, not more
97
+ surface.
98
+
99
+ Track E opened: absorb the consumer's second link stack. graphmark says whether a link resolves; the
100
+ gardener needs to know why it failed and what to do about it, which is why it still carries its
101
+ own resolver and why #107 had to be fixed in two places. Sliced #110/#111/#112, with #112 flagged
102
+ not-autonomous-safe past freezing the baseline — its expected values are human judgment, not
103
+ oracle-derived.
104
+
105
+ * feat(graph): retain catalog + out_of_scope on the built graph (#110)
106
+
107
+ Track E slice 1. build() computed both mappings and threw both away, so a consumer that needs to say
108
+ anything about a link beyond "resolved / didn't" had to rebuild the entire parse/catalog/resolve
109
+ stack. the-vault's graph_gardener.py does exactly that, and the two stacks drift: #107 was the
110
+ fourth resolution fix that had to be applied in two places.
111
+
112
+ graph.catalog is normalized stem → in-scope rel_paths; a key with two or more paths IS an ambiguity
113
+ set, which is what lets a consumer say WHICH notes a bare link collided with instead of only that
114
+ it failed. graph.out_of_scope is the same for markdown outside the configured scope, retained from
115
+ #107. Both are constructor-optional, so three-positional construction keeps working.
116
+
117
+ Value lists are sorted by rel_path explicitly rather than inheriting the walk's order. Those
118
+ disagree: sorted(rglob) orders Path objects by parts tuple, yielding a/note.md before a-b/note.md,
119
+ while rel_path string order is the reverse ('-' < '/'). The first version of the ordering tests
120
+ used paths where the two agree, so deleting the sorts left the suite green — caught by mutation,
121
+ and the tests now use the discriminating case.
122
+
123
+ No behavior change: nothing about what resolves or what an edge is moves, and every frozen fixture
124
+ is byte-identical.
125
+
126
+
4
127
  ## v0.3.4 (2026-07-25)
5
128
 
6
129
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphmark
3
- Version: 0.3.4
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
@@ -183,8 +183,76 @@ graphmark.siloed_notes(graph) # reachable only through a single
183
183
  graphmark.neighborhood(graph, "a/b.md", depth=2)
184
184
  graphmark.to_dot(graph) # Graphviz DOT
185
185
  graph.unresolved # {rel_path: [broken link displays]}
186
+ graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
187
+ graph.out_of_scope # same, for markdown outside the configured scope
186
188
  ```
187
189
 
190
+ `catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
191
+ explain a link rather than just resolve it — which notes a bare `[[link]]` collided with, or whether
192
+ a link that failed to resolve points at a real file you deliberately excluded. Value lists are
193
+ sorted by rel_path.
194
+
195
+ ## `diagnose` — why a link failed, not just that it did
196
+
197
+ `graph.unresolved` conflates two different problems. A link that matched _nothing_ needs its target
198
+ created or deleted; a link that matched _too much_ needs disambiguating against the notes it
199
+ collided with. `diagnose` separates them, and names the three cases that are not broken at all.
200
+
201
+ ```python
202
+ d = graphmark.diagnose(graph, "2026-W27-tasks|W27 tasks")
203
+
204
+ d.reason # 'ambiguous'
205
+ d.candidates # ('personal/archive/tasks/2026-W27-tasks.md', 'work/archive/2026/tasks/2026-W27-tasks.md')
206
+ d.target # None — set only when reason == 'resolved'
207
+ d.display # echoed verbatim: what a human has to go and fix
208
+ ```
209
+
210
+ | `reason` | meaning | `candidates` |
211
+ | ------------------- | -------------------------------------------------------------- | ------------------- |
212
+ | `resolved` | names exactly one note; `target` is its rel_path | — |
213
+ | `ambiguous` | names several notes, so the resolver refused to pick | the colliding notes |
214
+ | `non-note-file` | targets a `.canvas` / `.base` / image / PDF — not indexed here | — |
215
+ | `out-of-scope-note` | targets real markdown outside the configured scope | the unindexed paths |
216
+ | `missing` | names nothing that exists — a genuine broken link | — |
217
+ | `intra-note` | `[[#Heading]]` / `[[#^block]]` — a same-note reference | — |
218
+
219
+ `DIAGNOSIS_REASONS` holds that set in decision order, so a consumer can switch on it exhaustively.
220
+ Alias, anchor and `.md` forms all diagnose identically to the bare form.
221
+
222
+ `VaultGraph.build` classifies its own links through this same function, so a diagnosis can never
223
+ contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
224
+ `missing`, and the edges are exactly the `resolved` non-self targets.
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
+
188
256
  ## Configuration
189
257
 
190
258
  ```toml
@@ -156,8 +156,76 @@ graphmark.siloed_notes(graph) # reachable only through a single
156
156
  graphmark.neighborhood(graph, "a/b.md", depth=2)
157
157
  graphmark.to_dot(graph) # Graphviz DOT
158
158
  graph.unresolved # {rel_path: [broken link displays]}
159
+ graph.catalog # {normalized stem: [rel_paths]} — 2+ means ambiguous
160
+ graph.out_of_scope # same, for markdown outside the configured scope
159
161
  ```
160
162
 
163
+ `catalog` and `out_of_scope` are the resolution state the build consulted. They are what you need to
164
+ explain a link rather than just resolve it — which notes a bare `[[link]]` collided with, or whether
165
+ a link that failed to resolve points at a real file you deliberately excluded. Value lists are
166
+ sorted by rel_path.
167
+
168
+ ## `diagnose` — why a link failed, not just that it did
169
+
170
+ `graph.unresolved` conflates two different problems. A link that matched _nothing_ needs its target
171
+ created or deleted; a link that matched _too much_ needs disambiguating against the notes it
172
+ collided with. `diagnose` separates them, and names the three cases that are not broken at all.
173
+
174
+ ```python
175
+ d = graphmark.diagnose(graph, "2026-W27-tasks|W27 tasks")
176
+
177
+ d.reason # 'ambiguous'
178
+ d.candidates # ('personal/archive/tasks/2026-W27-tasks.md', 'work/archive/2026/tasks/2026-W27-tasks.md')
179
+ d.target # None — set only when reason == 'resolved'
180
+ d.display # echoed verbatim: what a human has to go and fix
181
+ ```
182
+
183
+ | `reason` | meaning | `candidates` |
184
+ | ------------------- | -------------------------------------------------------------- | ------------------- |
185
+ | `resolved` | names exactly one note; `target` is its rel_path | — |
186
+ | `ambiguous` | names several notes, so the resolver refused to pick | the colliding notes |
187
+ | `non-note-file` | targets a `.canvas` / `.base` / image / PDF — not indexed here | — |
188
+ | `out-of-scope-note` | targets real markdown outside the configured scope | the unindexed paths |
189
+ | `missing` | names nothing that exists — a genuine broken link | — |
190
+ | `intra-note` | `[[#Heading]]` / `[[#^block]]` — a same-note reference | — |
191
+
192
+ `DIAGNOSIS_REASONS` holds that set in decision order, so a consumer can switch on it exhaustively.
193
+ Alias, anchor and `.md` forms all diagnose identically to the bare form.
194
+
195
+ `VaultGraph.build` classifies its own links through this same function, so a diagnosis can never
196
+ contradict the graph it describes: `unresolved` is exactly the displays diagnosing as `ambiguous` or
197
+ `missing`, and the edges are exactly the `resolved` non-self targets.
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
+
161
229
  ## Configuration
162
230
 
163
231
  ```toml
@@ -22,11 +22,13 @@ story** — the priority consumer is the owner's vault seam (`graph_cli.py`), no
22
22
 
23
23
  ## What's shipped (baseline — do not re-propose)
24
24
 
25
- - **The full engine, v0.1.1 on PyPI**: `parse.py` (wikilink extraction, code-span skipping),
26
- `graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build`), `metrics.py` (stats, orphans,
27
- hubs, clusters, bridges, siloed_notes, neighborhood, pure-python pagerank, gaps),
28
- `export.py` (JSON/DOT), `cli.py`, `config.py` (`VaultConfig` + `load_config`), and
29
- `dismiss.py` (content-hash weaklink dismissal store).
25
+ - **The full engine, v0.3.4 on PyPI**: `parse.py` (wikilink extraction, code-span skipping),
26
+ `graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build` + `unresolved`), `metrics.py`
27
+ (stats, orphans, hubs, clusters, bridges, siloed_notes, neighborhood, pure-python pagerank,
28
+ gaps), `check.py` (policy evaluation), `export.py` (JSON/DOT), `cli.py`, `config.py`
29
+ (`VaultConfig` + `CheckPolicy` + `load_config`), and `dismiss.py` (content-hash weaklink
30
+ dismissal store). Tracks A, C and D are all closed — read their sections before proposing
31
+ anything in those areas.
30
32
  - **Six frozen differential fixtures** — simple, alt, scoped, selflink, gaps (+ out-of-graph),
31
33
  dismiss — plus live-vault parity diffs against the reference engine.
32
34
  - **`gaps()` with injected similarity** — graphmark owns the deterministic ranking/filtering
@@ -41,38 +43,59 @@ story** — the priority consumer is the owner's vault seam (`graph_cli.py`), no
41
43
 
42
44
  ### Track A — Engine parity (CLOSED at v0.1.1 — do not re-propose engine work)
43
45
 
44
- ### Track C — Hardening & honest surface (gate-verifiable; autonomous-safe)
46
+ ### Track C — Hardening & honest surface (CLOSED at v0.2.0 — do not re-propose)
45
47
 
46
- _Where we are:_ v0.1.1 is correct on its parity paths but carries correctness debt and dead
47
- surface no consumer exercises.
48
- _Where we're going:_ **v0.2.0 every error path tested, every public knob real.**
48
+ Every item shipped: version single-sourcing, non-UTF8 notes, dismiss-store JSON guards,
49
+ `load_config` errors, DOT escaping, `neighborhood` on an unknown note, the dead-surface cut
50
+ (`model.Edge/Graph/Finding` gone `Document` is the only model type; the no-op `wikilink_pattern`
51
+ / `orphan_min_chars` knobs gone; the CLI `gaps` stub exits 2), the `GAPS_DEFAULT_*` banding
52
+ constants, and the `Similarity` Protocol. `py.typed`, the 3.11–3.13 CI matrix, `--version`, and
53
+ unified exit-code 2 for usage errors came with it.
49
54
 
50
- - Version single-sourcing (pyproject / `__init__.__version__` / smoke-test drift).
51
- - Robustness: non-UTF8 note handling in `parse_document`; JSON-corruption guards in
52
- `dismiss.load_dismissed` / `active_dismissed_sigs`; `load_config` clear error on missing keys;
53
- DOT identifier escaping in `to_dot`; defined behavior for `neighborhood` on an unknown note.
54
- - Cut or wire dead surface: `model.Edge/Graph/Finding` (never consumed — **removed in the 0.2 API
55
- cut; `Document` is the only model type**); the CLI `gaps` stub
56
- (null `similar_fn` — always `[]`); the silent no-op config knobs `wikilink_pattern` and
57
- `orphan_min_chars` (wire them or delete them); make the dismissal-store path injectable instead
58
- of hardcoding `.claude/data/connect-dismissed.json`.
59
- - Move the validated gaps banding policy (threshold / max-score / k / hub-degree defaults) from the
60
- consumer's argparse into the package so any consumer gets the proven band.
61
- - A `Similarity` Protocol in `interfaces.py` typing the injected `similar_fn`.
62
-
63
- _The principle:_ each slice is single-purpose with its asserting test **pre-frozen by the
64
- conductor before dispatch** — the oracle-first discipline that closed the zero-test hole.
65
-
66
- ### Track D — `graphmark check`: the deterministic CI gate (feature track)
55
+ ### Track D `graphmark check`: the deterministic CI gate (SHIPPED at v0.3.0)
67
56
 
68
57
  The one unserved niche the ecosystem offers: **headless vault-health gating**. Obsidian's official
69
58
  CLI requires the desktop app; the dormant Python incumbent has no CLI; link checkers do no graph
70
59
  metrics.
71
60
 
72
- - `graphmark check --config ...` — thresholds (max orphans, max unresolved links, max siloed
73
- notes, ...), non-zero exit on breach, byte-stable JSON report for diffing across runs.
74
- - Config-driven policy block (`[check]` in the TOML) so the gate is declarative.
75
- - Later: a thin GitHub Action wrapper. The owner's vault dogfoods the gate first.
61
+ Shipped: `graph.unresolved`, the `[check]` policy block (`max_orphans` /
62
+ `max_unresolved_links` / `max_siloed`, strict on unknown keys), `graphmark check` with exit 1
63
+ reserved for breach and a byte-stable JSON report, `graphmark.build()` + curated top-level
64
+ re-exports, and a README rewritten around the real surface.
65
+
66
+ _Where we are (0.3.4):_ the gate works and the vault dogfoods it. The remaining risk is the
67
+ **credibility of its flagship number**. Four false-positive classes have been found and fixed by
68
+ triaging a real vault against the metric — same-note anchors (#98, 19% of the count),
69
+ non-markdown targets (#101, 10%), the `.md` extension (#104), and links to notes that exist but
70
+ are out of graph scope (#107, 7%). A threshold nobody trusts is not a gate.
71
+
72
+ _Where we're going:_ keep truthing the metric against live vaults, then the thin GitHub Action
73
+ wrapper — but only once the count is trustworthy enough to fail someone's build.
74
+
75
+ ### Track E — One resolver: absorb the consumer's second link stack (feature track)
76
+
77
+ _The problem:_ the-vault's `graph_gardener.py` (Lane A) carries its **own** parse / catalog /
78
+ normalize / resolve stack, independent of this package's. The two drift, and drift here is
79
+ user-visible: the gardener spent weeks proposing repairs for working links that graphmark had
80
+ already learned were fine. #107 is the fourth fix that had to be applied in two places.
81
+
82
+ The gardener's stack survives because it answers questions graphmark cannot yet answer. graphmark
83
+ says _whether_ a link resolves; Lane A needs to know **why it failed and what to do about it** —
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 `[[jordan ellis]]` can be repaired to `[[Jordan Ellis]]`).
86
+
87
+ _Where we're going:_ a documented, typed **link-diagnosis** surface that makes the consumer a
88
+ formatter over graphmark's answers instead of a second resolver. The classification is already
89
+ implicit in `build` — resolved / ambiguous / out-of-scope note / non-note file / intra-note
90
+ reference / missing — it is simply thrown away. Candidate suggestion is the one genuinely new
91
+ behavior, and it is the piece a human must confirm: freeze a differential oracle from the current
92
+ gardener's hints on a real vault before changing anything.
93
+
94
+ Sliced as #110 (retain the catalogs), #111 (`LinkDiagnosis` + `diagnose()`), #112 (near-miss
95
+ suggestions — **not autonomous-safe past freezing the baseline**; the human annotation gate is the
96
+ point of it). The consumer-side deletion is hand-coded in the vault; the package side is afk-able.
97
+
98
+ _Constraint:_ this is additive. Nothing here may change what resolves or what an edge is.
76
99
 
77
100
  ### Track B — Judgment the oracle can't cover (human-validated)
78
101
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "graphmark"
3
- version = "0.3.4"
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"
@@ -26,7 +26,18 @@ from graphmark.dismiss import (
26
26
  weaklink_sig,
27
27
  )
28
28
  from graphmark.export import to_dot, to_json
29
- from graphmark.graph import NormalizeResolver, VaultGraph, build_catalog
29
+ from graphmark.graph import (
30
+ DIAGNOSIS_REASONS,
31
+ SUGGEST_MAX_MATCHES,
32
+ SUGGEST_MIN_COVERAGE,
33
+ LinkDiagnosis,
34
+ NormalizeResolver,
35
+ VaultGraph,
36
+ build_catalog,
37
+ candidates_for,
38
+ diagnose,
39
+ suggest_notes,
40
+ )
30
41
  from graphmark.interfaces import LinkExtractor, Resolver, Similarity
31
42
  from graphmark.metrics import (
32
43
  GAPS_DEFAULT_BAND,
@@ -87,6 +98,10 @@ __all__ = [
87
98
  "CheckPolicy",
88
99
  "Document",
89
100
  "LinkExtractor",
101
+ "DIAGNOSIS_REASONS",
102
+ "SUGGEST_MAX_MATCHES",
103
+ "SUGGEST_MIN_COVERAGE",
104
+ "LinkDiagnosis",
90
105
  "NormalizeResolver",
91
106
  "Resolver",
92
107
  "Similarity",
@@ -98,6 +113,9 @@ __all__ = [
98
113
  "bridges",
99
114
  "build",
100
115
  "build_catalog",
116
+ "candidates_for",
117
+ "diagnose",
118
+ "suggest_notes",
101
119
  "clusters",
102
120
  "gaps",
103
121
  "hubs",