graphmark 0.3.4__tar.gz → 0.4.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.3.4 → graphmark-0.4.0}/CHANGELOG.md +78 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/PKG-INFO +39 -1
- {graphmark-0.3.4 → graphmark-0.4.0}/README.md +38 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/docs/ROADMAP.md +53 -30
- {graphmark-0.3.4 → graphmark-0.4.0}/pyproject.toml +1 -1
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/__init__.py +13 -1
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/graph.py +136 -28
- graphmark-0.4.0/tests/test_diagnose.py +228 -0
- graphmark-0.4.0/tests/test_graph_catalogs.py +152 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_public_api.py +5 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/.gitignore +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/LICENSE +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/check.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/cli.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/config.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/export.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/model.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/parse.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_check.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_cli.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_config.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_export.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_gaps.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_graph.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_metrics.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_model.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_packaging.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_parse.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_scoped.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_selflink.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_smoke.py +0 -0
- {graphmark-0.3.4 → graphmark-0.4.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,84 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.4.0 (2026-07-25)
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
- **graph**: Add diagnose() — why a link failed, not just that it did (#111)
|
|
9
|
+
([#114](https://github.com/cdcoonce/graphmark/pull/114),
|
|
10
|
+
[`1d124a3`](https://github.com/cdcoonce/graphmark/commit/1d124a3423fc5a6f3c8a0a084e8d15927dd1d828))
|
|
11
|
+
|
|
12
|
+
Track E slice 2. graph.unresolved conflates two problems needing opposite repairs: a link matching
|
|
13
|
+
NOTHING wants its target created or deleted, a link matching TOO MUCH wants disambiguating against
|
|
14
|
+
what it collided with. A consumer holding only unresolved cannot tell them apart, so it rebuilds
|
|
15
|
+
the resolver — the drift this track exists to end.
|
|
16
|
+
|
|
17
|
+
diagnose(graph, display) returns a frozen LinkDiagnosis: display echoed verbatim, target when
|
|
18
|
+
resolved, one of the six DIAGNOSIS_REASONS, and the rel_paths in play (the colliding notes for
|
|
19
|
+
ambiguous, the unindexed markdown for out-of-scope-note). The reason set is exported as a tuple in
|
|
20
|
+
decision order so a consumer can switch on it exhaustively.
|
|
21
|
+
|
|
22
|
+
build() now classifies through the same function rather than agreeing with it separately — two
|
|
23
|
+
classifiers in one package would recreate the drift inside the package. Two property tests pin
|
|
24
|
+
them together: unresolved is exactly the ambiguous+missing displays, edges are exactly the
|
|
25
|
+
resolved non-self targets.
|
|
26
|
+
|
|
27
|
+
candidates_for() consolidates the two-branch matching that _targets_out_of_scope_note had duplicated
|
|
28
|
+
from the resolver. It reports matches only; deciding uniqueness stays the Resolver's job, so the
|
|
29
|
+
two cannot disagree about which notes were in play. The graph now also retains the resolver it was
|
|
30
|
+
built with, so a diagnosis can never contradict its own graph.
|
|
31
|
+
|
|
32
|
+
Behavior is unchanged: on the live vault, 130 unresolved / 3582 edges both before and after, and all
|
|
33
|
+
six frozen fixtures byte-identical. What is new is the answer — 8 of those 130 are ambiguous
|
|
34
|
+
collisions ([[2026-W27-tasks]] existing under both personal/archive and work/archive,
|
|
35
|
+
[[RESOURCES]] under four learning folders), which the old surface reported as plain breaks.
|
|
36
|
+
|
|
37
|
+
- **graph**: Retain catalog + out_of_scope on the built graph (#110)
|
|
38
|
+
([#113](https://github.com/cdcoonce/graphmark/pull/113),
|
|
39
|
+
[`7f7a91c`](https://github.com/cdcoonce/graphmark/commit/7f7a91cae961a06155f565384a429c92356868fb))
|
|
40
|
+
|
|
41
|
+
* docs(roadmap): close Tracks C and D, open Track E (#110-#112)
|
|
42
|
+
|
|
43
|
+
afk-driver --expand reads this file verbatim, so a stale one makes the fleet propose already-built
|
|
44
|
+
work and pollutes the telemetry dataset. It still targeted "v0.2.0 — every error path tested"
|
|
45
|
+
while the package is 0.3.4 with Tracks C and D both fully shipped.
|
|
46
|
+
|
|
47
|
+
Track C closed (every item landed at 0.2.0). Track D marked shipped at 0.3.0, with its real
|
|
48
|
+
remaining risk named: the credibility of its flagship number. Four false-positive classes have now
|
|
49
|
+
been found by triaging a live vault against max_unresolved_links — #98 anchors (19%), #101
|
|
50
|
+
non-markdown (10%), #104 the .md extension, #107 out-of-scope notes (7%) — so "keep truthing the
|
|
51
|
+
metric before shipping a GitHub Action that fails someone's build" is the direction, not more
|
|
52
|
+
surface.
|
|
53
|
+
|
|
54
|
+
Track E opened: absorb the consumer's second link stack. graphmark says whether a link resolves; the
|
|
55
|
+
gardener needs to know why it failed and what to do about it, which is why it still carries its
|
|
56
|
+
own resolver and why #107 had to be fixed in two places. Sliced #110/#111/#112, with #112 flagged
|
|
57
|
+
not-autonomous-safe past freezing the baseline — its expected values are human judgment, not
|
|
58
|
+
oracle-derived.
|
|
59
|
+
|
|
60
|
+
* feat(graph): retain catalog + out_of_scope on the built graph (#110)
|
|
61
|
+
|
|
62
|
+
Track E slice 1. build() computed both mappings and threw both away, so a consumer that needs to say
|
|
63
|
+
anything about a link beyond "resolved / didn't" had to rebuild the entire parse/catalog/resolve
|
|
64
|
+
stack. the-vault's graph_gardener.py does exactly that, and the two stacks drift: #107 was the
|
|
65
|
+
fourth resolution fix that had to be applied in two places.
|
|
66
|
+
|
|
67
|
+
graph.catalog is normalized stem → in-scope rel_paths; a key with two or more paths IS an ambiguity
|
|
68
|
+
set, which is what lets a consumer say WHICH notes a bare link collided with instead of only that
|
|
69
|
+
it failed. graph.out_of_scope is the same for markdown outside the configured scope, retained from
|
|
70
|
+
#107. Both are constructor-optional, so three-positional construction keeps working.
|
|
71
|
+
|
|
72
|
+
Value lists are sorted by rel_path explicitly rather than inheriting the walk's order. Those
|
|
73
|
+
disagree: sorted(rglob) orders Path objects by parts tuple, yielding a/note.md before a-b/note.md,
|
|
74
|
+
while rel_path string order is the reverse ('-' < '/'). The first version of the ordering tests
|
|
75
|
+
used paths where the two agree, so deleting the sorts left the suite green — caught by mutation,
|
|
76
|
+
and the tests now use the discriminating case.
|
|
77
|
+
|
|
78
|
+
No behavior change: nothing about what resolves or what an edge is moves, and every frozen fixture
|
|
79
|
+
is byte-identical.
|
|
80
|
+
|
|
81
|
+
|
|
4
82
|
## v0.3.4 (2026-07-25)
|
|
5
83
|
|
|
6
84
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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,46 @@ 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
|
+
|
|
188
226
|
## Configuration
|
|
189
227
|
|
|
190
228
|
```toml
|
|
@@ -156,8 +156,46 @@ 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
|
+
|
|
161
199
|
## Configuration
|
|
162
200
|
|
|
163
201
|
```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.
|
|
26
|
-
`graph.py` (catalog + `NormalizeResolver` + `VaultGraph.build`), `metrics.py`
|
|
27
|
-
hubs, clusters, bridges, siloed_notes, neighborhood, pure-python pagerank,
|
|
28
|
-
`export.py` (JSON/DOT), `cli.py`, `config.py`
|
|
29
|
-
`dismiss.py` (content-hash weaklink
|
|
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 (
|
|
46
|
+
### Track C — Hardening & honest surface (CLOSED at v0.2.0 — do not re-propose)
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
-
|
|
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 `[[ethan courtman]]` can be repaired to `[[Ethan Courtman]]`).
|
|
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
|
|
|
@@ -26,7 +26,15 @@ 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
|
|
29
|
+
from graphmark.graph import (
|
|
30
|
+
DIAGNOSIS_REASONS,
|
|
31
|
+
LinkDiagnosis,
|
|
32
|
+
NormalizeResolver,
|
|
33
|
+
VaultGraph,
|
|
34
|
+
build_catalog,
|
|
35
|
+
candidates_for,
|
|
36
|
+
diagnose,
|
|
37
|
+
)
|
|
30
38
|
from graphmark.interfaces import LinkExtractor, Resolver, Similarity
|
|
31
39
|
from graphmark.metrics import (
|
|
32
40
|
GAPS_DEFAULT_BAND,
|
|
@@ -87,6 +95,8 @@ __all__ = [
|
|
|
87
95
|
"CheckPolicy",
|
|
88
96
|
"Document",
|
|
89
97
|
"LinkExtractor",
|
|
98
|
+
"DIAGNOSIS_REASONS",
|
|
99
|
+
"LinkDiagnosis",
|
|
90
100
|
"NormalizeResolver",
|
|
91
101
|
"Resolver",
|
|
92
102
|
"Similarity",
|
|
@@ -98,6 +108,8 @@ __all__ = [
|
|
|
98
108
|
"bridges",
|
|
99
109
|
"build",
|
|
100
110
|
"build_catalog",
|
|
111
|
+
"candidates_for",
|
|
112
|
+
"diagnose",
|
|
101
113
|
"clusters",
|
|
102
114
|
"gaps",
|
|
103
115
|
"hubs",
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
"""Graph construction: catalog building, link resolution, and VaultGraph."""
|
|
1
|
+
"""Graph construction: catalog building, link resolution, diagnosis, and VaultGraph."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import re
|
|
6
6
|
import string
|
|
7
|
+
from dataclasses import dataclass, field
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
|
|
9
10
|
from graphmark.config import VaultConfig
|
|
@@ -53,26 +54,26 @@ def _targets_non_note_file(display: str) -> bool:
|
|
|
53
54
|
return bool(match) and match.group(0).lower() != ".md"
|
|
54
55
|
|
|
55
56
|
|
|
56
|
-
def
|
|
57
|
-
"""
|
|
57
|
+
def candidates_for(display: str, catalog: dict[str, list[str]]) -> list[str]:
|
|
58
|
+
"""Every rel_path in ``catalog`` that ``display`` names, sorted; empty if it names none.
|
|
58
59
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
60
|
+
Answers "what did this display match?" — the question behind both an ambiguity verdict (the
|
|
61
|
+
resolver saw these and refused to pick) and the out-of-scope check (a link into unindexed
|
|
62
|
+
markdown). Deciding *uniqueness* remains the ``Resolver``'s job; this only reports matches, so
|
|
63
|
+
the two never disagree about which notes were in play.
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
targets, so ambiguity among them says nothing about whether the in-graph link is broken.
|
|
65
|
+
Matching mirrors ``NormalizeResolver``: bare displays by normalized stem, ``[[folder/note]]``
|
|
66
|
+
by path suffix. Both branches read the same stripped display.
|
|
66
67
|
"""
|
|
67
68
|
target = _strip_display(display)
|
|
68
69
|
if not target:
|
|
69
|
-
return
|
|
70
|
+
return []
|
|
70
71
|
if "/" in target:
|
|
71
72
|
suffix = target.lower() + ".md"
|
|
72
|
-
return
|
|
73
|
-
path
|
|
73
|
+
return sorted(
|
|
74
|
+
path for paths in catalog.values() for path in paths if path.lower().endswith(suffix)
|
|
74
75
|
)
|
|
75
|
-
return _normalize(target)
|
|
76
|
+
return list(catalog.get(_normalize(target), ()))
|
|
76
77
|
|
|
77
78
|
|
|
78
79
|
def _is_intra_note_reference(display: str) -> bool:
|
|
@@ -86,11 +87,19 @@ def _is_intra_note_reference(display: str) -> bool:
|
|
|
86
87
|
|
|
87
88
|
|
|
88
89
|
def build_catalog(docs: list[Document]) -> dict[str, list[str]]:
|
|
89
|
-
"""Map normalized stem → list of rel_paths (len > 1 means ambiguous).
|
|
90
|
+
"""Map normalized stem → list of rel_paths (len > 1 means ambiguous).
|
|
91
|
+
|
|
92
|
+
Value lists are sorted by rel_path. ``build`` already walks in path order, but ``Path``
|
|
93
|
+
ordering and rel_path string ordering disagree where a separator meets punctuation
|
|
94
|
+
(``a-b/x.md`` vs ``a/b.md``), and this mapping is public state feeding byte-stable reports —
|
|
95
|
+
so the order is established here rather than inherited.
|
|
96
|
+
"""
|
|
90
97
|
catalog: dict[str, list[str]] = {}
|
|
91
98
|
for doc in docs:
|
|
92
99
|
key = _normalize(Path(doc.rel_path).stem)
|
|
93
100
|
catalog.setdefault(key, []).append(doc.rel_path)
|
|
101
|
+
for paths in catalog.values():
|
|
102
|
+
paths.sort()
|
|
94
103
|
return catalog
|
|
95
104
|
|
|
96
105
|
|
|
@@ -135,12 +144,104 @@ class NormalizeResolver:
|
|
|
135
144
|
return paths[0]
|
|
136
145
|
|
|
137
146
|
|
|
147
|
+
#: Every value ``LinkDiagnosis.reason`` can take, in the order they are decided. A consumer
|
|
148
|
+
#: switches on these strings, so the set is part of the public contract.
|
|
149
|
+
DIAGNOSIS_REASONS = (
|
|
150
|
+
"resolved",
|
|
151
|
+
"ambiguous",
|
|
152
|
+
"non-note-file",
|
|
153
|
+
"out-of-scope-note",
|
|
154
|
+
"missing",
|
|
155
|
+
"intra-note",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class LinkDiagnosis:
|
|
161
|
+
"""Why a single wikilink display ended up where it did.
|
|
162
|
+
|
|
163
|
+
``build`` sorts every link into one of the ``DIAGNOSIS_REASONS`` and then keeps only whether it
|
|
164
|
+
resolved, which conflates an *ambiguous* link with a *missing* one. Those need opposite repairs
|
|
165
|
+
— disambiguate against the colliding notes, versus create or delete the target — so a consumer
|
|
166
|
+
holding only ``unresolved`` cannot act without rebuilding the resolver itself.
|
|
167
|
+
|
|
168
|
+
``target`` is the resolved rel_path, set only when ``reason == "resolved"``; the caller recovers
|
|
169
|
+
the note's canonical title from its stem, which is what makes a case repair like
|
|
170
|
+
``[[ethan courtman]]`` → ``[[Ethan Courtman]]`` possible.
|
|
171
|
+
|
|
172
|
+
``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.
|
|
174
|
+
"""
|
|
175
|
+
|
|
176
|
+
display: str
|
|
177
|
+
target: str | None = None
|
|
178
|
+
reason: str = "missing"
|
|
179
|
+
candidates: tuple[str, ...] = field(default=())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _diagnose(
|
|
183
|
+
display: str,
|
|
184
|
+
catalog: dict[str, list[str]],
|
|
185
|
+
out_of_scope: dict[str, list[str]],
|
|
186
|
+
resolver: Resolver,
|
|
187
|
+
) -> LinkDiagnosis:
|
|
188
|
+
"""Classify one display against already-built resolution state.
|
|
189
|
+
|
|
190
|
+
The single classifier: ``build`` calls this while assembling a graph (before a ``VaultGraph``
|
|
191
|
+
exists to pass), and the public ``diagnose`` wraps it for callers holding a built graph. Two
|
|
192
|
+
independent classifiers would drift from each other inside the package, which is the exact
|
|
193
|
+
failure this surface exists to remove.
|
|
194
|
+
"""
|
|
195
|
+
if _is_intra_note_reference(display):
|
|
196
|
+
return LinkDiagnosis(display=display, reason="intra-note")
|
|
197
|
+
|
|
198
|
+
target = resolver.resolve(display, catalog)
|
|
199
|
+
if target is not None:
|
|
200
|
+
return LinkDiagnosis(display=display, target=target, reason="resolved")
|
|
201
|
+
|
|
202
|
+
# The resolver declined. Whether it declined because nothing matched or because too much did
|
|
203
|
+
# is the distinction consumers need, and only the catalog can answer it.
|
|
204
|
+
collisions = candidates_for(display, catalog)
|
|
205
|
+
if collisions:
|
|
206
|
+
return LinkDiagnosis(display=display, reason="ambiguous", candidates=tuple(collisions))
|
|
207
|
+
|
|
208
|
+
# Ordered as build has always ordered it: a "[[Board.canvas]]" is a non-note file even if some
|
|
209
|
+
# out-of-scope note happens to share the stem.
|
|
210
|
+
if _targets_non_note_file(display):
|
|
211
|
+
return LinkDiagnosis(display=display, reason="non-note-file")
|
|
212
|
+
|
|
213
|
+
unindexed = candidates_for(display, out_of_scope)
|
|
214
|
+
if unindexed:
|
|
215
|
+
return LinkDiagnosis(
|
|
216
|
+
display=display, reason="out-of-scope-note", candidates=tuple(unindexed)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return LinkDiagnosis(display=display, reason="missing")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def diagnose(graph: VaultGraph, display: str) -> LinkDiagnosis:
|
|
223
|
+
"""Explain what ``display`` names in ``graph`` — see :class:`LinkDiagnosis`.
|
|
224
|
+
|
|
225
|
+
Uses the resolver the graph was built with, so a diagnosis can never contradict the graph it
|
|
226
|
+
describes. A directly constructed ``VaultGraph`` with no resolver falls back to
|
|
227
|
+
``NormalizeResolver``.
|
|
228
|
+
"""
|
|
229
|
+
return _diagnose(display, graph.catalog, graph.out_of_scope, graph.resolver)
|
|
230
|
+
|
|
231
|
+
|
|
138
232
|
class VaultGraph:
|
|
139
233
|
"""Built graph: all nodes plus resolved out/back adjacency.
|
|
140
234
|
|
|
141
235
|
``unresolved`` maps a rel_path to the raw link displays in it that resolved to nothing, in
|
|
142
236
|
extraction order; notes with no such links are absent. It is the inspectable form of what
|
|
143
237
|
build() would otherwise drop silently, and the source of the broken-link health count.
|
|
238
|
+
|
|
239
|
+
``catalog`` and ``out_of_scope`` are the resolution state build() consulted, retained instead of
|
|
240
|
+
discarded: normalized stem → rel_paths, in path order, for in-scope notes and for markdown that
|
|
241
|
+
exists outside the configured scope respectively. A ``catalog`` key with two or more paths *is*
|
|
242
|
+
an ambiguity set. Consumers need both to say anything about a link beyond whether it resolved —
|
|
243
|
+
without them, the only way to explain a broken link is to rebuild the whole parse/catalog/
|
|
244
|
+
resolve stack, and a second stack drifts from this one.
|
|
144
245
|
"""
|
|
145
246
|
|
|
146
247
|
def __init__(
|
|
@@ -149,11 +250,19 @@ class VaultGraph:
|
|
|
149
250
|
out_links: dict[str, set[str]],
|
|
150
251
|
back_links: dict[str, set[str]],
|
|
151
252
|
unresolved: dict[str, list[str]] | None = None,
|
|
253
|
+
catalog: dict[str, list[str]] | None = None,
|
|
254
|
+
out_of_scope: dict[str, list[str]] | None = None,
|
|
255
|
+
resolver: Resolver | None = None,
|
|
152
256
|
) -> None:
|
|
153
257
|
self.nodes = nodes
|
|
154
258
|
self.out_links = out_links
|
|
155
259
|
self.back_links = back_links
|
|
156
260
|
self.unresolved = unresolved if unresolved is not None else {}
|
|
261
|
+
self.catalog = catalog if catalog is not None else {}
|
|
262
|
+
self.out_of_scope = out_of_scope if out_of_scope is not None else {}
|
|
263
|
+
# Retained so diagnose() answers with the same resolver that built the graph; a
|
|
264
|
+
# pluggable Resolver that disagreed with the graph it describes would be worse than none.
|
|
265
|
+
self.resolver: Resolver = resolver if resolver is not None else NormalizeResolver()
|
|
157
266
|
|
|
158
267
|
@classmethod
|
|
159
268
|
def build(
|
|
@@ -188,6 +297,9 @@ class VaultGraph:
|
|
|
188
297
|
continue
|
|
189
298
|
md_files.append(path)
|
|
190
299
|
|
|
300
|
+
for paths in out_of_scope.values():
|
|
301
|
+
paths.sort() # same rel_path ordering guarantee as build_catalog
|
|
302
|
+
|
|
191
303
|
docs = [parse_document(p, root) for p in md_files]
|
|
192
304
|
nodes = {doc.rel_path: doc for doc in docs}
|
|
193
305
|
catalog = build_catalog(docs)
|
|
@@ -197,25 +309,21 @@ class VaultGraph:
|
|
|
197
309
|
|
|
198
310
|
unresolved: dict[str, list[str]] = {}
|
|
199
311
|
|
|
312
|
+
# Classification lives in _diagnose so build and the public diagnose() can never disagree
|
|
313
|
+
# about why a link failed. Ambiguous and missing are both broken from a vault-health view;
|
|
314
|
+
# the raw display is recorded once per occurrence, since that is what a human goes and
|
|
315
|
+
# fixes.
|
|
316
|
+
broken = {"ambiguous", "missing"}
|
|
200
317
|
for doc in docs:
|
|
201
318
|
for display in extractor.extract(doc.text):
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
target = resolver.resolve(display, catalog)
|
|
205
|
-
if target is None:
|
|
206
|
-
if _targets_non_note_file(display) or _targets_out_of_scope_note(
|
|
207
|
-
display, out_of_scope
|
|
208
|
-
):
|
|
209
|
-
continue # out of scope, not a broken note link
|
|
210
|
-
# Unresolvable OR ambiguous — the Resolver protocol conflates the two, and
|
|
211
|
-
# both are equally broken from a vault-health view. Record the raw display
|
|
212
|
-
# (what a human has to go fix) once per occurrence.
|
|
319
|
+
d = _diagnose(display, catalog, out_of_scope, resolver)
|
|
320
|
+
if d.reason in broken:
|
|
213
321
|
unresolved.setdefault(doc.rel_path, []).append(display)
|
|
214
|
-
elif target != doc.rel_path:
|
|
215
|
-
out_links[doc.rel_path].add(target)
|
|
322
|
+
elif d.target is not None and d.target != doc.rel_path:
|
|
323
|
+
out_links[doc.rel_path].add(d.target)
|
|
216
324
|
|
|
217
325
|
for src, targets in out_links.items():
|
|
218
326
|
for dst in targets:
|
|
219
327
|
back_links[dst].add(src)
|
|
220
328
|
|
|
221
|
-
return cls(nodes, out_links, back_links, unresolved)
|
|
329
|
+
return cls(nodes, out_links, back_links, unresolved, catalog, out_of_scope, resolver)
|