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.
- {graphmark-0.4.0 → graphmark-0.5.0}/CHANGELOG.md +45 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/PKG-INFO +31 -1
- {graphmark-0.4.0 → graphmark-0.5.0}/README.md +30 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/docs/ROADMAP.md +1 -1
- {graphmark-0.4.0 → graphmark-0.5.0}/pyproject.toml +1 -1
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/__init__.py +6 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/graph.py +100 -5
- graphmark-0.5.0/tests/fixtures/suggest/README.md +62 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_diagnose.py +4 -4
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_graph_catalogs.py +2 -2
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_public_api.py +3 -0
- graphmark-0.5.0/tests/test_suggestions.py +279 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/.gitignore +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/LICENSE +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/check.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/config.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/export.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/model.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/parse.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_check.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_cli.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_config.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_export.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_graph.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_metrics.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_model.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_packaging.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_parse.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_scoped.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_selflink.py +0 -0
- {graphmark-0.4.0 → graphmark-0.5.0}/tests/test_smoke.py +0 -0
- {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.
|
|
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 `[[
|
|
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
|
|
@@ -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
|
-
``[[
|
|
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
|
|
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
|
-
|
|
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: [[
|
|
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/
|
|
47
|
-
d = diagnose(_build(tmp_path), "
|
|
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 == "
|
|
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, "
|
|
38
|
-
assert _build(tmp_path).catalog == {"
|
|
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.
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{graphmark-0.4.0 → graphmark-0.5.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|