graphmark 0.7.1__tar.gz → 0.8.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.7.1 → graphmark-0.8.0}/CHANGELOG.md +210 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/PKG-INFO +27 -1
- {graphmark-0.7.1 → graphmark-0.8.0}/README.md +26 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/docs/ROADMAP.md +42 -2
- {graphmark-0.7.1 → graphmark-0.8.0}/pyproject.toml +1 -1
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/cli.py +81 -12
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/config.py +17 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/graph.py +149 -12
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/parse.py +49 -0
- graphmark-0.8.0/tests/test_markdown_links.py +155 -0
- graphmark-0.8.0/tests/test_non_note_file.py +90 -0
- graphmark-0.8.0/tests/test_properties.py +692 -0
- graphmark-0.8.0/tests/test_readme_examples.py +158 -0
- graphmark-0.8.0/tests/test_unicode.py +102 -0
- graphmark-0.8.0/tests/test_unicode_punctuation.py +113 -0
- graphmark-0.8.0/tests/test_unread_syntax.py +111 -0
- graphmark-0.7.1/tests/test_properties.py +0 -322
- {graphmark-0.7.1 → graphmark-0.8.0}/.gitignore +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/LICENSE +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/__init__.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/check.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/dismiss.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/export.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/interfaces.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/metrics.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/model.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/src/graphmark/py.typed +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/README.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/daily/2026-07-01.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/alpha.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/docs/center.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/echo.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/foxtrot.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/orphan.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/misc/stub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/beta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/delta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/alt/vault/refs/gamma.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/.claude/data/connect-dismissed.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/alpha.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/beta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/dismiss/vault/gamma.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/expected_oog.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/similar.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/similar_oog.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/a.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/docs/hub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/c.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/misc/d.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/b.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/gaps/vault/refs/e.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/docs/one.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/junk/ignored.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/misc/excluded.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/scoped/vault/refs/two.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/vault/a.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/selflink/vault/b.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/config.toml +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/expected.json +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/alpha.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/brain/hub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/beta.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/personal/gamma.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/island.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/simple/vault/reference/stub.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/fixtures/suggest/README.md +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_aliases.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_bom.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_build_filters.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_check.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_check_policy.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_cli.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_config.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_degenerate.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_diagnose.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_dismiss.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_export.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps_banding.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps_oog.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_gaps_params.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_graph.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_graph_catalogs.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_link_counts.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_links_report.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_metrics.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_model.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_packaging.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_pagerank.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_parse.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_path_suffix.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_public_api.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_scoped.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_selflink.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_smoke.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_suggestions.py +0 -0
- {graphmark-0.7.1 → graphmark-0.8.0}/tests/test_unresolved.py +0 -0
|
@@ -1,6 +1,216 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.8.0 (2026-07-26)
|
|
5
|
+
|
|
6
|
+
### Documentation
|
|
7
|
+
|
|
8
|
+
- Document link_syntax and close the markdown non-goal
|
|
9
|
+
([#158](https://github.com/cdcoonce/graphmark/pull/158),
|
|
10
|
+
[`5523fe3`](https://github.com/cdcoonce/graphmark/commit/5523fe31b548eb5e94001515ab8e8adb7c084849))
|
|
11
|
+
|
|
12
|
+
* docs: document link_syntax and close the markdown non-goal
|
|
13
|
+
|
|
14
|
+
README gains a Link syntax section — the config key, the warning, and the one thing a user must
|
|
15
|
+
understand: a wikilink NAMES a note (ambiguous when two share it) while a markdown link is a PATH
|
|
16
|
+
RELATIVE TO THE LINKING NOTE (never ambiguous for the same reason). Different resolution regimes,
|
|
17
|
+
not two spellings.
|
|
18
|
+
|
|
19
|
+
Roadmap's non-goal narrowed to Logseq, recording that #152 was the "on demand" trigger the clause
|
|
20
|
+
reserved — and recording what it did not do: no basename fallback, because silently trying a
|
|
21
|
+
second rule when the first fails is the shape of #136. The dialect question is #156 with its
|
|
22
|
+
measurement.
|
|
23
|
+
|
|
24
|
+
* fix(test): a console block's output is not a command
|
|
25
|
+
|
|
26
|
+
The README-example extractor treated every line starting with "graphmark " as an invocation,
|
|
27
|
+
including output. Adding the link-syntax section wrapped the #151 warning onto a second line
|
|
28
|
+
beginning "graphmark only reads ..." — collected as a command and run.
|
|
29
|
+
|
|
30
|
+
In a `console` block, `$ ` marks input and everything else is output, so the prefix is now required
|
|
31
|
+
there; `bash` blocks keep bare lines. Caught by the suite it belongs to, on its author's own
|
|
32
|
+
input.
|
|
33
|
+
|
|
34
|
+
- Second corpus run — the null result and the extractor blind spot
|
|
35
|
+
([#153](https://github.com/cdcoonce/graphmark/pull/153),
|
|
36
|
+
[`475b111`](https://github.com/cdcoonce/graphmark/commit/475b1112767bb9ca92d82cb0d391bf20543759c4))
|
|
37
|
+
|
|
38
|
+
Repeated the corpus study after #123/#136/#137/#138/#139 shipped, to ask whether those fixes move
|
|
39
|
+
any real number and whether there is demand for the link-syntax work the roadmap defers. Six
|
|
40
|
+
vaults; two overlap the first run.
|
|
41
|
+
|
|
42
|
+
The five fixes moved NOTHING — arkalim and kepano reproduce the first run's percentages to the
|
|
43
|
+
decimal. Recorded rather than buried: correctness-by-inspection has outrun what this corpus can
|
|
44
|
+
corroborate.
|
|
45
|
+
|
|
46
|
+
The finding that was not theoretical: lyz-code/blue-book, 1120 notes, carries 11,198 markdown-style
|
|
47
|
+
[text](note.md) links (99% targeting a real note) and extracts to ZERO edges. Every note an
|
|
48
|
+
orphan, and `check` looks nearly healthy because those links were never extracted and so are not
|
|
49
|
+
unresolved.
|
|
50
|
+
|
|
51
|
+
That is a third named limit of the Track F thesis: the conservation law sums over what the EXTRACTOR
|
|
52
|
+
produced, so a syntax it does not know is invisible to it by construction. The accounting is
|
|
53
|
+
auditable only within the universe the extractor defines. Filed as #151 (warn) and #152 (the
|
|
54
|
+
markdown-syntax decision).
|
|
55
|
+
|
|
56
|
+
### Features
|
|
57
|
+
|
|
58
|
+
- Read markdown-style [text](note.md) links, config-gated (#152)
|
|
59
|
+
([#155](https://github.com/cdcoonce/graphmark/pull/155),
|
|
60
|
+
[`6716a38`](https://github.com/cdcoonce/graphmark/commit/6716a3845b15665190f03f1bce3605a2927322e2))
|
|
61
|
+
|
|
62
|
+
The roadmap reserved alternate link-syntax adapters for "on demand"; the second corpus run supplied
|
|
63
|
+
the demand — lyz-code/blue-book, 1120 notes, 10,149 markdown-style links, zero extracted edges.
|
|
64
|
+
|
|
65
|
+
Four decisions, all reversible in config:
|
|
66
|
+
|
|
67
|
+
1. `link_syntax = "wikilink" | "markdown" | "both"`, defaulting to "wikilink", so every existing
|
|
68
|
+
vault, every frozen fixture and the reference vault stay byte-identical. An unknown value fails
|
|
69
|
+
loudly — a typo would otherwise produce an empty graph, the exact failure #151 exists to surface.
|
|
70
|
+
2. Resolution is RELATIVE TO THE LINKING NOTE, which is genuinely new: a markdown target is a
|
|
71
|
+
path, not a name. Rather than change the `Resolver` protocol — which never receives the source
|
|
72
|
+
note, and which the roadmap forbids redesigning — the target is normalized into a vault-relative
|
|
73
|
+
path before diagnosis, where the existing path branch resolves it exactly. 3. No new frozen
|
|
74
|
+
fixture: the oracle is wikilink-only and regenerating it is a human's call (Track B). Hand-written
|
|
75
|
+
assertions instead. 4. A target escaping the vault root is `missing` — not resolved, not an error.
|
|
76
|
+
|
|
77
|
+
Relative is default markdown semantics (CommonMark, mkdocs, GitHub) and NOT the wikilink rule.
|
|
78
|
+
blue-book resolves 5.8% that way against 92.7% by basename-anywhere, because it runs the
|
|
79
|
+
mkdocs-autolinks plugin. That dialect is a separate decision, not a fallback slipped in here —
|
|
80
|
+
silently trying a second rule when the first fails is how a link resolves to the wrong note.
|
|
81
|
+
|
|
82
|
+
The #151 warning is suppressed once markdown links are actually read.
|
|
83
|
+
|
|
84
|
+
- **graph**: Warn when the vault's links are in a syntax graphmark cannot read (#151)
|
|
85
|
+
([#154](https://github.com/cdcoonce/graphmark/pull/154),
|
|
86
|
+
[`d94ae63`](https://github.com/cdcoonce/graphmark/commit/d94ae63e7a789fabdb420423eaeaee8e771eb2a7))
|
|
87
|
+
|
|
88
|
+
graphmark reported a confidently EMPTY graph for a densely linked vault, with nothing to say it had
|
|
89
|
+
seen none of the links. Measured on lyz-code/blue-book: 1120 notes, 11,198 markdown-style
|
|
90
|
+
[text](note.md) links (99% targeting a note that exists), 0 extracted edges. Every note an orphan,
|
|
91
|
+
no clusters, no hubs, uniform PageRank — and `check` looks nearly healthy, because links that were
|
|
92
|
+
never extracted are not `unresolved`.
|
|
93
|
+
|
|
94
|
+
The conservation law from #124 cannot catch this: it sums over what the EXTRACTOR produced, so a
|
|
95
|
+
syntax the extractor does not know sits outside the universe being counted. The buckets balance
|
|
96
|
+
perfectly at 39 while 11,198 links go unseen.
|
|
97
|
+
|
|
98
|
+
The test is relational, not calibrated — it fires only when the unread syntax strictly outnumbers
|
|
99
|
+
the read one. No threshold to tune, no false-positive mode, and the reference vault's
|
|
100
|
+
17-against-6226 can never trip it. Same discipline as #133's assertion.
|
|
101
|
+
|
|
102
|
+
Warning only: stderr, one line, and it touches no count, no metric and no verdict.
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
## v0.7.2 (2026-07-25)
|
|
106
|
+
|
|
107
|
+
### Bug Fixes
|
|
108
|
+
|
|
109
|
+
- **cli**: Accept --config/--root after the subcommand (#130)
|
|
110
|
+
([#148](https://github.com/cdcoonce/graphmark/pull/148),
|
|
111
|
+
[`1a89e2e`](https://github.com/cdcoonce/graphmark/commit/1a89e2ef542581f076200a29ddd04e0773cbc2dd))
|
|
112
|
+
|
|
113
|
+
Every CLI example in the README failed. `--config` and `--root` were defined on the parent parser
|
|
114
|
+
before `add_subparsers`, so argparse accepted them only BEFORE the subcommand, while all ten
|
|
115
|
+
examples in the CLI block — plus the `check` ones — are written the other way. The README is the
|
|
116
|
+
PyPI landing page, so the first thing a new user copies errored.
|
|
117
|
+
|
|
118
|
+
Both options are now attached to every subparser too, under a separate dest, and reconciled after
|
|
119
|
+
parsing (argparse would otherwise let the subparser's default overwrite a value the top-level
|
|
120
|
+
parser had already read). Both collect with action="append", so a repeat within one position is
|
|
121
|
+
caught as well as one across the two: conflicting values are a usage error, not a silent
|
|
122
|
+
last-wins.
|
|
123
|
+
|
|
124
|
+
The actual defect was that the docs and the parser had no shared test — the suite used the working
|
|
125
|
+
form exclusively. `tests/test_readme_examples.py` now extracts every `graphmark ...` invocation
|
|
126
|
+
from README.md and runs it, so an example that stops working fails the gate instead of shipping.
|
|
127
|
+
|
|
128
|
+
- **graph**: Fold non-ASCII punctuation in _normalize (#139)
|
|
129
|
+
([#145](https://github.com/cdcoonce/graphmark/pull/145),
|
|
130
|
+
[`cef4484`](https://github.com/cdcoonce/graphmark/commit/cef448405c1d59577c85ff4f8723299f3ee74dbc))
|
|
131
|
+
|
|
132
|
+
`string.punctuation` is ASCII-only, so every non-ASCII mark survived normalization and then had to
|
|
133
|
+
be typed byte-for-byte: an em-dash title was unreachable from a typed hyphen, a curly apostrophe
|
|
134
|
+
from a straight one, `…` from `...`. The catalog key literally retained the mark while an
|
|
135
|
+
ASCII-typed display normalized it away, so the two could never meet — while Obsidian's own
|
|
136
|
+
switcher resolves it.
|
|
137
|
+
|
|
138
|
+
`_fold_punctuation` folds by Unicode category (P* and S*) instead, a superset of the old ASCII
|
|
139
|
+
table, so pure-ASCII results are unchanged. The ASCII translate table stays the fast path; the
|
|
140
|
+
cached per-character category lookup runs only on strings that still hold non-ASCII after it.
|
|
141
|
+
|
|
142
|
+
Builds on #123: NFC composition runs first, so a decomposed accent is a single letter rather than a
|
|
143
|
+
letter plus a combining mark this fold must not touch.
|
|
144
|
+
|
|
145
|
+
Path-suffix matching deliberately still folds only case and form — a "/" is structural there and
|
|
146
|
+
punctuation folding needs the extension handled first. Follow-up filed.
|
|
147
|
+
|
|
148
|
+
Reference vault: 531 notes, link counts identical, build 207 ms.
|
|
149
|
+
|
|
150
|
+
- **graph**: Normalize Unicode form before comparing names (#123)
|
|
151
|
+
([#144](https://github.com/cdcoonce/graphmark/pull/144),
|
|
152
|
+
[`9251554`](https://github.com/cdcoonce/graphmark/commit/9251554691723e7b6ca1d11bf0b7ddb1dffed002))
|
|
153
|
+
|
|
154
|
+
macOS's HFS+/APFS store filenames decomposed (NFD: `é` is `e` + U+0301) while text typed in an
|
|
155
|
+
editor is composed (NFC: a single U+00E9). `_normalize` lowercased, folded punctuation and
|
|
156
|
+
collapsed whitespace but never normalized Unicode form, so the two strings never compared equal
|
|
157
|
+
and an accented note title was a phantom broken link. Obsidian resolves it fine.
|
|
158
|
+
|
|
159
|
+
`_fold_case` does NFC-then-lowercase and is shared by `_normalize` and by path-suffix matching — the
|
|
160
|
+
one comparison that does not go through `_normalize` — so no comparison in the module can be left
|
|
161
|
+
in the wrong form. Note names, aliases and path-qualified links all fold identically as a result.
|
|
162
|
+
|
|
163
|
+
Eighth false-positive class in `unresolved`. The reference vault is unaffected (its only non-ASCII
|
|
164
|
+
filenames are em-dashes, which have no decomposition — which is exactly why this stayed invisible
|
|
165
|
+
there); link counts byte-identical.
|
|
166
|
+
|
|
167
|
+
- **graph**: Require a letter in a file extension (#138)
|
|
168
|
+
([#147](https://github.com/cdcoonce/graphmark/pull/147),
|
|
169
|
+
[`4206cc5`](https://github.com/cdcoonce/graphmark/commit/4206cc5f941e5f0967202ac0b99390a55ab0393e))
|
|
170
|
+
|
|
171
|
+
`_targets_non_note_file` treated any trailing `.` + 1-10 alphanumerics as an extension, so
|
|
172
|
+
`[[Meeting 3.5]]` with no such note was suppressed as a file instead of reported as broken. `.5`
|
|
173
|
+
satisfied the pattern as readily as `.md`.
|
|
174
|
+
|
|
175
|
+
The first class found that DEFLATES `check`'s flagship number rather than inflating it, which is
|
|
176
|
+
strictly worse for a gate: an undercount is invisible by construction and reads as health. It hits
|
|
177
|
+
any vault that numbers its notes — phases, specs, chapters, versioned documents.
|
|
178
|
+
|
|
179
|
+
The separating rule is that a real extension contains a letter; ".5" and ".61850" differ only in
|
|
180
|
+
length, so no length bound could tell them apart. The existing 10-character bound and the
|
|
181
|
+
space-after-the-dot rule are both preserved.
|
|
182
|
+
|
|
183
|
+
Reference vault: 17 non-note-file suppressions before and after, all genuine .base/.png
|
|
184
|
+
(hand-audited).
|
|
185
|
+
|
|
186
|
+
### Testing
|
|
187
|
+
|
|
188
|
+
- **properties**: Add the known-name invariant and an oracle-backed generator (#133)
|
|
189
|
+
([#149](https://github.com/cdcoonce/graphmark/pull/149),
|
|
190
|
+
[`4cd304f`](https://github.com/cdcoonce/graphmark/commit/4cd304f80da52f2941205986d3b4bb3de783fdab))
|
|
191
|
+
|
|
192
|
+
Two things, one purpose: make Track F's detector actually detect.
|
|
193
|
+
|
|
194
|
+
#133's invariant — no link reported `missing` may name a key the resolver already knows (catalog or
|
|
195
|
+
aliases). Under correct behavior that is impossible, so it is an internal-consistency assertion,
|
|
196
|
+
not a heuristic: no thresholds, no calibration, no false-positive mode. Asserted over generated
|
|
197
|
+
vaults, with resolve_aliases=False (where the alias half is vacuous rather than wrong), and with
|
|
198
|
+
teeth — breaking the lookup while the map is still built must fail it.
|
|
199
|
+
|
|
200
|
+
The generator, measured. With the alphabet widened to today's four defect classes, reverting
|
|
201
|
+
#123/#136/#137/#138/#139 one at a time still left the suite GREEN: 0 of 4. Every existing property
|
|
202
|
+
is a conservation or self-consistency check, and each of those bugs is perfectly self-consistent —
|
|
203
|
+
#136's fabricated edge really is in the graph. Self-consistency cannot see a wrong answer.
|
|
204
|
+
|
|
205
|
+
So the generator now carries an oracle: it builds a note, then writes the link FROM it through a
|
|
206
|
+
transformation Obsidian treats as identity-preserving, so the expected target is known by
|
|
207
|
+
construction. Plus a negative oracle over names known to be absent (the false-negative half, which
|
|
208
|
+
no conservation law can see), narrow per-vault name pools so collisions co-occur, and an assertion
|
|
209
|
+
that an `ambiguous` candidate set is minimal rather than merely non-empty.
|
|
210
|
+
|
|
211
|
+
Catch rate on the same experiment: 6 of 6, adding #119.
|
|
212
|
+
|
|
213
|
+
|
|
4
214
|
## v0.7.1 (2026-07-25)
|
|
5
215
|
|
|
6
216
|
### Bug Fixes
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: graphmark
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.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
|
|
@@ -111,6 +111,32 @@ notice the tool is wrong about your vault.
|
|
|
111
111
|
The same block appears in `check`'s report under `links`, as context for the verdict. It never
|
|
112
112
|
changes the verdict.
|
|
113
113
|
|
|
114
|
+
## Link syntax
|
|
115
|
+
|
|
116
|
+
graphmark reads `[[wikilinks]]` by default. A vault written in markdown link syntax —
|
|
117
|
+
`[text](note.md)`, what most non-Obsidian markdown gardens use — would otherwise produce an empty
|
|
118
|
+
graph, so set `link_syntax`:
|
|
119
|
+
|
|
120
|
+
```toml
|
|
121
|
+
root = "."
|
|
122
|
+
link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The two resolve differently, and the difference is not cosmetic:
|
|
126
|
+
|
|
127
|
+
- a **wikilink** names a note — `[[Note]]` finds `Note.md` anywhere in the vault, and two notes with
|
|
128
|
+
that name make it ambiguous;
|
|
129
|
+
- a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
|
|
130
|
+
file and nothing else, so the same filename in two folders is not ambiguous at all.
|
|
131
|
+
|
|
132
|
+
If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
|
|
133
|
+
confidently empty graph:
|
|
134
|
+
|
|
135
|
+
```console
|
|
136
|
+
graphmark: warning: 39 wikilinks extracted but 10149 markdown-style [](.md) links found;
|
|
137
|
+
graphmark only reads [[wikilinks]] — this graph is probably not your vault
|
|
138
|
+
```
|
|
139
|
+
|
|
114
140
|
## `graphmark check` — vault health as a CI gate
|
|
115
141
|
|
|
116
142
|
Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
|
|
@@ -83,6 +83,32 @@ notice the tool is wrong about your vault.
|
|
|
83
83
|
The same block appears in `check`'s report under `links`, as context for the verdict. It never
|
|
84
84
|
changes the verdict.
|
|
85
85
|
|
|
86
|
+
## Link syntax
|
|
87
|
+
|
|
88
|
+
graphmark reads `[[wikilinks]]` by default. A vault written in markdown link syntax —
|
|
89
|
+
`[text](note.md)`, what most non-Obsidian markdown gardens use — would otherwise produce an empty
|
|
90
|
+
graph, so set `link_syntax`:
|
|
91
|
+
|
|
92
|
+
```toml
|
|
93
|
+
root = "."
|
|
94
|
+
link_syntax = "both" # "wikilink" (default) | "markdown" | "both"
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The two resolve differently, and the difference is not cosmetic:
|
|
98
|
+
|
|
99
|
+
- a **wikilink** names a note — `[[Note]]` finds `Note.md` anywhere in the vault, and two notes with
|
|
100
|
+
that name make it ambiguous;
|
|
101
|
+
- a **markdown** link is a path **relative to the linking note** — `[x](../other/b.md)` means that
|
|
102
|
+
file and nothing else, so the same filename in two folders is not ambiguous at all.
|
|
103
|
+
|
|
104
|
+
If your links are in a syntax graphmark is not reading, it says so on stderr rather than reporting a
|
|
105
|
+
confidently empty graph:
|
|
106
|
+
|
|
107
|
+
```console
|
|
108
|
+
graphmark: warning: 39 wikilinks extracted but 10149 markdown-style [](.md) links found;
|
|
109
|
+
graphmark only reads [[wikilinks]] — this graph is probably not your vault
|
|
110
|
+
```
|
|
111
|
+
|
|
86
112
|
## `graphmark check` — vault health as a CI gate
|
|
87
113
|
|
|
88
114
|
Declare thresholds in the config's `[check]` table, then run `graphmark check` as a build step. It
|
|
@@ -163,6 +163,41 @@ Two things this says about the track:
|
|
|
163
163
|
names that are suffixes of other folder names, BOMs, numeric title suffixes, non-ASCII
|
|
164
164
|
punctuation. Widening the generator's _alphabet_ — not its vault count — is the follow-on.
|
|
165
165
|
|
|
166
|
+
_Follow-on, same day (#133's slice):_ widening the alphabet was **not enough, and the measurement
|
|
167
|
+
says why.** With the alphabet widened, reverting #123/#136/#137/#138/#139 one at a time left the
|
|
168
|
+
whole property suite green — a catch rate of **0 of 4** on the classes it was widened for. Every
|
|
169
|
+
invariant in that file is a _conservation or self-consistency_ property, and each of those bugs is
|
|
170
|
+
perfectly self-consistent: #136's fabricated edge really is in the graph, so nothing vanishes; a
|
|
171
|
+
BOM'd note simply has no frontmatter; a link the normalizer cannot match is genuinely missing by the
|
|
172
|
+
package's own rules. **Self-consistency cannot see a wrong answer.**
|
|
173
|
+
|
|
174
|
+
What was missing is an **oracle** — a link whose intended target is known independently of what the
|
|
175
|
+
resolver says. The generator now builds a note first and writes the link _from_ it, through a
|
|
176
|
+
transformation Obsidian treats as identity-preserving (case, `.md`, anchor, alias display, padding,
|
|
177
|
+
NFD/NFC, punctuation swaps, path qualification, ASCII twins of typographic marks), plus a negative
|
|
178
|
+
oracle over names known to be absent. Two further changes were needed to make it bite: narrow
|
|
179
|
+
per-vault name pools, so the collisions that matter actually co-occur, and an assertion that an
|
|
180
|
+
`ambiguous` candidate set is _minimal_, not merely non-empty.
|
|
181
|
+
|
|
182
|
+
Catch rate on the same experiment: **6 of 6**, adding #119 — the class that started this track.
|
|
183
|
+
|
|
184
|
+
The generalizable lesson, and the one to carry into future test design here: **a property suite over
|
|
185
|
+
generated input still needs a correct answer to compare against.** Invariants prove the package is
|
|
186
|
+
consistent with itself, which is exactly the thing a systematically wrong resolver also is.
|
|
187
|
+
|
|
188
|
+
_Second corpus run, same day (see `docs/corpus-study.md`):_ a **third** named limit, and a null
|
|
189
|
+
result. The null result first — the five fixes above moved **no** third-party number; the two vaults
|
|
190
|
+
overlapping the first run reproduce it to the decimal. They are correct and pinned, but against real
|
|
191
|
+
data they are theoretical, joining #123 there.
|
|
192
|
+
|
|
193
|
+
The limit: `lyz-code/blue-book` (1120 notes) carries **11,198 markdown-style `[text](note.md)` links,
|
|
194
|
+
99% of them targeting a real note**, and graphmark extracts **zero**. Every note an orphan, and
|
|
195
|
+
`check` looks nearly healthy because those links were never extracted and so are not *unresolved*.
|
|
196
|
+
The conservation law sums over what the **extractor** produced — so a link syntax the extractor does
|
|
197
|
+
not know is invisible to it by construction. **The accounting is auditable only within the universe
|
|
198
|
+
the extractor defines.** Filed as #151 (warn) and #152 (the markdown-syntax decision — the "on
|
|
199
|
+
demand" trigger the non-goals list reserved).
|
|
200
|
+
|
|
166
201
|
Audited alongside: the reference vault's two suppressed buckets, by hand — 17 `non-note-file` (all
|
|
167
202
|
genuine `.base`/`.png`) and 40 `intra-note` (all genuine heading refs), 0 false suppressions. A
|
|
168
203
|
suppressed bucket is only trustworthy once someone has read it.
|
|
@@ -202,8 +237,13 @@ Evaluated against the ecosystem and dropped for zero consumer pull; do not re-li
|
|
|
202
237
|
- Betweenness centrality, Louvain/Leiden community detection.
|
|
203
238
|
- CSV/GML/Cypher export; incremental/cached builds; any LLM "propose" pass.
|
|
204
239
|
- MCP server or retrieval/search surface.
|
|
205
|
-
- Alternate link-syntax adapters (Logseq
|
|
206
|
-
|
|
240
|
+
- Alternate link-syntax adapters (Logseq) — the pluggable interfaces exist so these _can_ be added
|
|
241
|
+
on demand; do not build them speculatively. **Markdown `[text](note.md)` links SHIPPED 2026-07-25**
|
|
242
|
+
(#152) — the second corpus run supplied the demand this clause reserved: a 1120-note vault with
|
|
243
|
+
10,149 of them and zero extracted edges. Config-gated (`link_syntax`), default unchanged,
|
|
244
|
+
`Resolver` untouched. Note what it did *not* do: blue-book resolves 5.8% by strict relative path
|
|
245
|
+
and 92.7% by basename-anywhere (it runs `mkdocs-autolinks`), and no fallback was slipped in —
|
|
246
|
+
silently trying a second rule when the first fails is the shape of #136. The dialect is #156.
|
|
207
247
|
- Re-platforming (no swapping networkx; no async/parallel rewrites).
|
|
208
248
|
- Performance work without a benchmark showing pain (the live consumer is a ~340-note vault). The
|
|
209
249
|
known ceilings — O(L·N) path-suffix resolution, O(A·(V+E)) `siloed_notes`, per-metric graph
|
|
@@ -42,7 +42,39 @@ def _load(args: argparse.Namespace) -> tuple[VaultGraph, VaultConfig]:
|
|
|
42
42
|
return graph, config
|
|
43
43
|
|
|
44
44
|
|
|
45
|
+
def _reconcile_globals(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
|
|
46
|
+
"""Merge each global's leading and trailing occurrence into one value.
|
|
47
|
+
|
|
48
|
+
``--config``/``--root`` are attached to the top-level parser *and*, under a separate dest, to
|
|
49
|
+
every subparser, so both ``graphmark --root X stats`` and ``graphmark stats --root X`` work.
|
|
50
|
+
Trailing-option order is what every comparable CLI accepts and what a user types by reflex — and
|
|
51
|
+
it is what the README documents, so before this the first command a new user copied errored.
|
|
52
|
+
|
|
53
|
+
Repeats with the **same** value are harmless. Two different values are a usage error rather than
|
|
54
|
+
a silent last-wins: guessing which one the user meant is exactly the kind of quiet wrong answer
|
|
55
|
+
this tool exists not to give. Both options collect with ``action="append"``, so this covers a
|
|
56
|
+
repeat *within* one position (``stats --root A --root B``) as well as one across the two —
|
|
57
|
+
argparse's own last-wins would otherwise swallow the first silently.
|
|
58
|
+
"""
|
|
59
|
+
for name in ("config", "root"):
|
|
60
|
+
after = f"{name}_after"
|
|
61
|
+
# `after` is absent when no subcommand ran at all — argparse never reached a subparser.
|
|
62
|
+
given = (getattr(args, name) or []) + (getattr(args, after, None) or [])
|
|
63
|
+
distinct = sorted(set(given))
|
|
64
|
+
if len(distinct) > 1:
|
|
65
|
+
parser.error(f"--{name} given more than once with conflicting values: {distinct}")
|
|
66
|
+
setattr(args, name, distinct[0] if distinct else None)
|
|
67
|
+
if hasattr(args, after):
|
|
68
|
+
delattr(args, after)
|
|
69
|
+
|
|
70
|
+
|
|
45
71
|
def main() -> None:
|
|
72
|
+
# Attached to every subparser under a distinct dest, then reconciled: argparse would otherwise
|
|
73
|
+
# let the subparser's default (None) overwrite a value the top-level parser had already read.
|
|
74
|
+
trailing_globals = argparse.ArgumentParser(add_help=False)
|
|
75
|
+
trailing_globals.add_argument("--config", metavar="PATH", dest="config_after", action="append")
|
|
76
|
+
trailing_globals.add_argument("--root", metavar="PATH", dest="root_after", action="append")
|
|
77
|
+
|
|
46
78
|
parser = argparse.ArgumentParser(
|
|
47
79
|
prog="graphmark",
|
|
48
80
|
description=(
|
|
@@ -51,49 +83,86 @@ def main() -> None:
|
|
|
51
83
|
),
|
|
52
84
|
)
|
|
53
85
|
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
54
|
-
parser.add_argument("--config", metavar="PATH", help="TOML config file")
|
|
55
|
-
parser.add_argument(
|
|
86
|
+
parser.add_argument("--config", metavar="PATH", action="append", help="TOML config file")
|
|
87
|
+
parser.add_argument(
|
|
88
|
+
"--root", metavar="PATH", action="append", help="Vault root (overrides --config root)"
|
|
89
|
+
)
|
|
56
90
|
|
|
57
91
|
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
58
92
|
|
|
59
|
-
sub.add_parser(
|
|
60
|
-
|
|
93
|
+
sub.add_parser(
|
|
94
|
+
"stats",
|
|
95
|
+
parents=[trailing_globals],
|
|
96
|
+
help="Aggregate vault stats: notes, edges, orphans, clusters, density",
|
|
97
|
+
)
|
|
98
|
+
sub.add_parser(
|
|
99
|
+
"orphans", parents=[trailing_globals], help="Notes with no links in or out (degree 0)"
|
|
100
|
+
)
|
|
61
101
|
|
|
62
|
-
hubs_p = sub.add_parser(
|
|
102
|
+
hubs_p = sub.add_parser(
|
|
103
|
+
"hubs", parents=[trailing_globals], help="Most-connected notes, by undirected degree"
|
|
104
|
+
)
|
|
63
105
|
hubs_p.add_argument("--n", type=int, default=10, help="How many hubs to return (default: 10)")
|
|
64
106
|
|
|
65
|
-
sub.add_parser(
|
|
66
|
-
|
|
67
|
-
|
|
107
|
+
sub.add_parser(
|
|
108
|
+
"clusters",
|
|
109
|
+
parents=[trailing_globals],
|
|
110
|
+
help="Connected components of the link graph, largest first",
|
|
111
|
+
)
|
|
112
|
+
sub.add_parser(
|
|
113
|
+
"bridges",
|
|
114
|
+
parents=[trailing_globals],
|
|
115
|
+
help="Articulation points: notes whose removal splits the graph",
|
|
116
|
+
)
|
|
117
|
+
sub.add_parser(
|
|
118
|
+
"siloed",
|
|
119
|
+
parents=[trailing_globals],
|
|
120
|
+
help="Notes reachable from the mainland only through one bridge",
|
|
121
|
+
)
|
|
68
122
|
|
|
69
|
-
nb_p = sub.add_parser(
|
|
123
|
+
nb_p = sub.add_parser(
|
|
124
|
+
"neighborhood", parents=[trailing_globals], help="Links in and out of one note"
|
|
125
|
+
)
|
|
70
126
|
nb_p.add_argument("--note", required=True, help="Vault-relative path, e.g. brain/hub.md")
|
|
71
127
|
nb_p.add_argument(
|
|
72
128
|
"--depth", type=int, default=1, help="1 for direct links, 2 to add two-hop (default: 1)"
|
|
73
129
|
)
|
|
74
130
|
|
|
75
|
-
pr_p = sub.add_parser(
|
|
131
|
+
pr_p = sub.add_parser(
|
|
132
|
+
"pagerank",
|
|
133
|
+
parents=[trailing_globals],
|
|
134
|
+
help="PageRank importance ranking over the link graph",
|
|
135
|
+
)
|
|
76
136
|
pr_p.add_argument("--n", type=int, default=10, help="How many notes to return (default: 10)")
|
|
77
137
|
pr_p.add_argument(
|
|
78
138
|
"--alpha", type=float, default=0.85, help="Damping factor in (0, 1) (default: 0.85)"
|
|
79
139
|
)
|
|
80
140
|
|
|
81
|
-
exp_p = sub.add_parser(
|
|
141
|
+
exp_p = sub.add_parser(
|
|
142
|
+
"export", parents=[trailing_globals], help="Export the graph in another format"
|
|
143
|
+
)
|
|
82
144
|
exp_p.add_argument("format", choices=["dot"], help="Output format")
|
|
83
145
|
|
|
84
|
-
sub.add_parser(
|
|
146
|
+
sub.add_parser(
|
|
147
|
+
"gaps",
|
|
148
|
+
parents=[trailing_globals],
|
|
149
|
+
help="Link-gap suggestions (library-only; see the README)",
|
|
150
|
+
)
|
|
85
151
|
|
|
86
152
|
sub.add_parser(
|
|
87
153
|
"links",
|
|
154
|
+
parents=[trailing_globals],
|
|
88
155
|
help="How every wikilink was classified: counts per reason, plus alias resolutions",
|
|
89
156
|
)
|
|
90
157
|
|
|
91
158
|
sub.add_parser(
|
|
92
159
|
"check",
|
|
160
|
+
parents=[trailing_globals],
|
|
93
161
|
help="Gate vault health against the config's [check] thresholds (exit 1 on breach)",
|
|
94
162
|
)
|
|
95
163
|
|
|
96
164
|
args = parser.parse_args()
|
|
165
|
+
_reconcile_globals(parser, args)
|
|
97
166
|
|
|
98
167
|
# Usage errors all exit 2, matching argparse's own convention (and leaving exit 1 free for
|
|
99
168
|
# future domain-level outcomes such as a `check` threshold breach). Help for a missing
|
|
@@ -11,6 +11,10 @@ import tomllib
|
|
|
11
11
|
from dataclasses import dataclass, field, fields
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
#: Every value ``VaultConfig.link_syntax`` accepts. A consumer may switch on these, so the set is
|
|
15
|
+
#: part of the config contract.
|
|
16
|
+
LINK_SYNTAXES = frozenset({"wikilink", "markdown", "both"})
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
@dataclass(frozen=True)
|
|
16
20
|
class CheckPolicy:
|
|
@@ -47,6 +51,12 @@ class VaultConfig:
|
|
|
47
51
|
# Obsidian's `aliases:` property names a note, so links written against one resolve by
|
|
48
52
|
# default. Set False for strict basename-only resolution.
|
|
49
53
|
resolve_aliases: bool = True
|
|
54
|
+
#: Which link syntax to read. ``"wikilink"`` (the default) is Obsidian's ``[[Note]]`` and is
|
|
55
|
+
#: what every frozen fixture encodes, so the default keeps existing behavior byte-identical.
|
|
56
|
+
#: ``"markdown"`` reads ``[text](note.md)``, the syntax non-Obsidian markdown vaults use — added
|
|
57
|
+
#: on demand after a corpus vault turned out to have 11,198 of them and zero extracted edges.
|
|
58
|
+
#: ``"both"`` reads each, which is what Obsidian itself accepts.
|
|
59
|
+
link_syntax: str = "wikilink"
|
|
50
60
|
check: CheckPolicy = field(default_factory=CheckPolicy)
|
|
51
61
|
|
|
52
62
|
def __post_init__(self) -> None:
|
|
@@ -54,6 +64,12 @@ class VaultConfig:
|
|
|
54
64
|
# obscure AttributeError on the first Path operation.
|
|
55
65
|
if not isinstance(self.root, Path):
|
|
56
66
|
self.root = Path(self.root)
|
|
67
|
+
# Fail loudly rather than silently reading nothing: a typo here would produce an empty
|
|
68
|
+
# graph, which is exactly the failure #151 exists to make visible.
|
|
69
|
+
if self.link_syntax not in LINK_SYNTAXES:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
f"link_syntax must be one of {sorted(LINK_SYNTAXES)}, got {self.link_syntax!r}"
|
|
72
|
+
)
|
|
57
73
|
|
|
58
74
|
|
|
59
75
|
def _parse_check(data: dict, path: Path) -> CheckPolicy:
|
|
@@ -122,5 +138,6 @@ def load_config(path: str | Path, *, root_override: str | Path | None = None) ->
|
|
|
122
138
|
rules_files=data.get("rules_files", ["CLAUDE.md", "CLAUDE.local.md"]),
|
|
123
139
|
transient_prefixes=tuple(data.get("transient_prefixes", [])),
|
|
124
140
|
resolve_aliases=bool(data.get("resolve_aliases", True)),
|
|
141
|
+
link_syntax=data.get("link_syntax", "wikilink"),
|
|
125
142
|
check=_parse_check(data, path),
|
|
126
143
|
)
|